From a1436208ae8ba3e888babeace55cbadbb56f7e37 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Tue, 28 Apr 2026 16:20:59 +0200 Subject: [PATCH 01/42] feat(admin): add linting configuration --- admin/api/v1/zone_types.go | 40 +++++++++++++++++++ admin/api/v1/zz_generated.deepcopy.go | 25 ++++++++++++ .../bases/admin.cp.ei.telekom.de_zones.yaml | 36 +++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/admin/api/v1/zone_types.go b/admin/api/v1/zone_types.go index 07640856..fd46bcfa 100644 --- a/admin/api/v1/zone_types.go +++ b/admin/api/v1/zone_types.go @@ -81,6 +81,42 @@ type PermissionsConfig struct { ConsoleUrl *string `json:"consoleUrl,omitempty"` } +// LintingMode controls how linting failures affect API creation. +// +kubebuilder:validation:Enum=block;warn +type LintingMode string + +const ( + // LintingModeBlock prevents Api creation when linting fails. + LintingModeBlock LintingMode = "block" + // LintingModeWarn allows Api creation but surfaces linting issues in status. + LintingModeWarn LintingMode = "warn" +) + +// LintingConfig configures OAS specification linting for this zone. +// The external linter server manages rulesets; the zone only controls enabled/mode. +type LintingConfig struct { + // Enabled indicates whether linting is enabled for this zone. + Enabled bool `json:"enabled,omitempty"` + // Mode controls how linting failures affect API creation. + // "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. + // +kubebuilder:validation:Enum=block;warn + // +kubebuilder:default:=block + // +optional + Mode LintingMode `json:"mode,omitempty"` + + // WhitelistedCategories is the list of API categories that are exempt from linting. + // Categories are matched case-insensitively against the x-api-category value in the OpenAPI spec. + // +optional + // +listType=set + WhitelistedCategories []string `json:"whitelistedCategories,omitempty"` + + // DashboardURLTemplate is a URL template for the linter dashboard. + // Use {linterId} as a placeholder for the scan ID. + // Example: "https://linter.example.com/scans/{linterId}" + // +optional + DashboardURLTemplate string `json:"dashboardUrlTemplate,omitempty"` +} + // ZoneSpec defines the desired state of Zone type ZoneSpec struct { IdentityProvider IdentityProviderConfig `json:"identityProvider"` @@ -94,6 +130,10 @@ type ZoneSpec struct { // Permissions configuration for permission service integration // +kubebuilder:validation:Optional Permissions *PermissionsConfig `json:"permissions,omitempty"` + + // Linting configuration for OAS specification linting in this zone + // +kubebuilder:validation:Optional + Linting *LintingConfig `json:"linting,omitempty"` } type Links struct { diff --git a/admin/api/v1/zz_generated.deepcopy.go b/admin/api/v1/zz_generated.deepcopy.go index 5793ce16..0fd30f46 100644 --- a/admin/api/v1/zz_generated.deepcopy.go +++ b/admin/api/v1/zz_generated.deepcopy.go @@ -212,6 +212,26 @@ func (in *Links) DeepCopy() *Links { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LintingConfig) DeepCopyInto(out *LintingConfig) { + *out = *in + if in.WhitelistedCategories != nil { + in, out := &in.WhitelistedCategories, &out.WhitelistedCategories + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LintingConfig. +func (in *LintingConfig) DeepCopy() *LintingConfig { + if in == nil { + return nil + } + out := new(LintingConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PermissionsConfig) DeepCopyInto(out *PermissionsConfig) { *out = *in @@ -439,6 +459,11 @@ func (in *ZoneSpec) DeepCopyInto(out *ZoneSpec) { *out = new(PermissionsConfig) (*in).DeepCopyInto(*out) } + if in.Linting != nil { + in, out := &in.Linting, &out.Linting + *out = new(LintingConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneSpec. diff --git a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml index 877f3385..833f5ec1 100644 --- a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml +++ b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml @@ -90,6 +90,42 @@ spec: - admin - url type: object + linting: + description: Linting configuration for OAS specification linting in + this zone + properties: + dashboardUrlTemplate: + description: |- + DashboardURLTemplate is a URL template for the linter dashboard. + Use {linterId} as a placeholder for the scan ID. + Example: "https://linter.example.com/scans/{linterId}" + type: string + enabled: + description: Enabled indicates whether linting is enabled for + this zone. + type: boolean + mode: + allOf: + - enum: + - block + - warn + - enum: + - block + - warn + default: block + description: |- + Mode controls how linting failures affect API creation. + "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. + type: string + whitelistedCategories: + description: |- + WhitelistedCategories is the list of API categories that are exempt from linting. + Categories are matched case-insensitively against the x-api-category value in the OpenAPI spec. + items: + type: string + type: array + x-kubernetes-list-type: set + type: object permissions: description: Permissions configuration for permission service integration properties: From 2cdaa1e2494176cbcf21876699b32eea46e5716d Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Tue, 28 Apr 2026 16:22:41 +0200 Subject: [PATCH 02/42] refactor(api): remove LintingConfig from ApiCategorySpec and related tests --- api/api/v1/apicategory_types.go | 9 --------- api/api/v1/zz_generated.deepcopy.go | 20 ------------------- .../api.cp.ei.telekom.de_apicategories.yaml | 10 ---------- .../controller/apicategory_controller_test.go | 4 ---- 4 files changed, 43 deletions(-) diff --git a/api/api/v1/apicategory_types.go b/api/api/v1/apicategory_types.go index d2c7a271..52c7e99f 100644 --- a/api/api/v1/apicategory_types.go +++ b/api/api/v1/apicategory_types.go @@ -38,15 +38,6 @@ type ApiCategorySpec struct { // the name of the group in the basePath. // +kubebuilder:default:=true MustHaveGroupPrefix bool `json:"mustHaveGroupPrefix,omitempty"` - - Linting *LintingConfig `json:"linting,omitempty"` -} - -type LintingConfig struct { - // Enabled indicates whether linting is enabled for this API category. - Enabled bool `json:"enabled,omitempty"` - // Ruleset specifies the ruleset to use for linting. - Ruleset string `json:"ruleset,omitempty"` } type AllowTeamsConfig struct { diff --git a/api/api/v1/zz_generated.deepcopy.go b/api/api/v1/zz_generated.deepcopy.go index e56441fd..d8084b07 100644 --- a/api/api/v1/zz_generated.deepcopy.go +++ b/api/api/v1/zz_generated.deepcopy.go @@ -133,11 +133,6 @@ func (in *ApiCategorySpec) DeepCopyInto(out *ApiCategorySpec) { *out = new(AllowTeamsConfig) (*in).DeepCopyInto(*out) } - if in.Linting != nil { - in, out := &in.Linting, &out.Linting - *out = new(LintingConfig) - **out = **in - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiCategorySpec. @@ -665,21 +660,6 @@ func (in *Limits) DeepCopy() *Limits { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *LintingConfig) DeepCopyInto(out *LintingConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LintingConfig. -func (in *LintingConfig) DeepCopy() *LintingConfig { - if in == nil { - return nil - } - out := new(LintingConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Machine2MachineAuthentication) DeepCopyInto(out *Machine2MachineAuthentication) { *out = *in diff --git a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml index 888e4fcf..2d1acb34 100644 --- a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml +++ b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml @@ -79,16 +79,6 @@ spec: maxLength: 20 minLength: 1 type: string - linting: - properties: - enabled: - description: Enabled indicates whether linting is enabled for - this API category. - type: boolean - ruleset: - description: Ruleset specifies the ruleset to use for linting. - type: string - type: object mustHaveGroupPrefix: default: true description: |- diff --git a/api/internal/controller/apicategory_controller_test.go b/api/internal/controller/apicategory_controller_test.go index 28a7a18e..15ae11f8 100644 --- a/api/internal/controller/apicategory_controller_test.go +++ b/api/internal/controller/apicategory_controller_test.go @@ -34,10 +34,6 @@ func NewApiCategory(name string) *apiv1.ApiCategory { Names: []string{"test-team"}, }, MustHaveGroupPrefix: true, - Linting: &apiv1.LintingConfig{ - Enabled: false, - Ruleset: "owasp", - }, }, } } From 53d8c7ad7adaf29b716eb870f175454cf694d0e7 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Tue, 28 Apr 2026 16:23:28 +0200 Subject: [PATCH 03/42] feat(rover): add external linter integration and enhance ApiSpecification status fields --- rover/api/v1/apispecification_types.go | 37 ++++ rover/api/v1/zz_generated.deepcopy.go | 5 + ...er.cp.ei.telekom.de_apispecifications.yaml | 33 +++ .../controller/apispecification_controller.go | 15 +- .../handler/apispecification/handler.go | 80 +++++++- .../handler/apispecification/handler_test.go | 183 +++++++++++++++++ .../handler/apispecification/suite_test.go | 17 ++ rover/internal/oaslint/external.go | 133 ++++++++++++ rover/internal/oaslint/external_test.go | 191 ++++++++++++++++++ rover/internal/oaslint/linter.go | 26 +++ rover/internal/oaslint/noop.go | 19 ++ rover/internal/oaslint/suite_test.go | 17 ++ 12 files changed, 749 insertions(+), 7 deletions(-) create mode 100644 rover/internal/handler/apispecification/handler_test.go create mode 100644 rover/internal/handler/apispecification/suite_test.go create mode 100644 rover/internal/oaslint/external.go create mode 100644 rover/internal/oaslint/external_test.go create mode 100644 rover/internal/oaslint/linter.go create mode 100644 rover/internal/oaslint/noop.go create mode 100644 rover/internal/oaslint/suite_test.go diff --git a/rover/api/v1/apispecification_types.go b/rover/api/v1/apispecification_types.go index ff5763e8..a3da7b6e 100644 --- a/rover/api/v1/apispecification_types.go +++ b/rover/api/v1/apispecification_types.go @@ -75,6 +75,43 @@ type ApiSpecificationStatus struct { // API reference Api types.ObjectRef `json:"api,omitempty"` + + // LintedHash is the spec hash that was last linted. Compared with Spec.Hash to avoid re-linting. + // +optional + LintedHash string `json:"lintedHash,omitempty"` + + // LintPassed indicates whether the last lint passed. nil means not yet linted. + // +optional + LintPassed *bool `json:"lintPassed,omitempty"` + + // LintReason is a human-readable message describing the lint outcome. + // +optional + LintReason string `json:"lintReason,omitempty"` + + // LinterId is the scan ID returned by the external linter API. + // +optional + LinterId string `json:"linterId,omitempty"` + + // LintRuleset is the name of the ruleset used for linting. + // +optional + LintRuleset string `json:"lintRuleset,omitempty"` + + // LintLinterVersion is the version of the linter engine. + // +optional + LintLinterVersion string `json:"lintLinterVersion,omitempty"` + + // LintErrors is the number of errors found during linting. + // +optional + LintErrors int `json:"lintErrors,omitempty"` + + // LintWarnings is the number of warnings found during linting. + // +optional + LintWarnings int `json:"lintWarnings,omitempty"` + + // LintDashboardURL is a direct link to the linter dashboard for this scan. + // Populated from the zone's DashboardURLTemplate with the scan ID substituted. + // +optional + LintDashboardURL string `json:"lintDashboardUrl,omitempty"` } //+kubebuilder:object:root=true diff --git a/rover/api/v1/zz_generated.deepcopy.go b/rover/api/v1/zz_generated.deepcopy.go index c9e35d45..5762ac26 100644 --- a/rover/api/v1/zz_generated.deepcopy.go +++ b/rover/api/v1/zz_generated.deepcopy.go @@ -141,6 +141,11 @@ func (in *ApiSpecificationStatus) DeepCopyInto(out *ApiSpecificationStatus) { } } in.Api.DeepCopyInto(&out.Api) + if in.LintPassed != nil { + in, out := &in.LintPassed, &out.LintPassed + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiSpecificationStatus. diff --git a/rover/config/crd/bases/rover.cp.ei.telekom.de_apispecifications.yaml b/rover/config/crd/bases/rover.cp.ei.telekom.de_apispecifications.yaml index 978bfdbf..72ad260f 100644 --- a/rover/config/crd/bases/rover.cp.ei.telekom.de_apispecifications.yaml +++ b/rover/config/crd/bases/rover.cp.ei.telekom.de_apispecifications.yaml @@ -162,6 +162,39 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + lintDashboardUrl: + description: |- + LintDashboardURL is a direct link to the linter dashboard for this scan. + Populated from the zone's DashboardURLTemplate with the scan ID substituted. + type: string + lintErrors: + description: LintErrors is the number of errors found during linting. + type: integer + lintLinterVersion: + description: LintLinterVersion is the version of the linter engine. + type: string + lintPassed: + description: LintPassed indicates whether the last lint passed. nil + means not yet linted. + type: boolean + lintReason: + description: LintReason is a human-readable message describing the + lint outcome. + type: string + lintRuleset: + description: LintRuleset is the name of the ruleset used for linting. + type: string + lintWarnings: + description: LintWarnings is the number of warnings found during linting. + type: integer + lintedHash: + description: LintedHash is the spec hash that was last linted. Compared + with Spec.Hash to avoid re-linting. + type: string + linterId: + description: LinterId is the scan ID returned by the external linter + API. + type: string type: object type: object x-kubernetes-preserve-unknown-fields: true diff --git a/rover/internal/controller/apispecification_controller.go b/rover/internal/controller/apispecification_controller.go index b7003e8c..41cc8826 100644 --- a/rover/internal/controller/apispecification_controller.go +++ b/rover/internal/controller/apispecification_controller.go @@ -17,7 +17,9 @@ import ( apispec_handler "github.com/telekom/controlplane/rover/internal/handler/apispecification" + adminv1 "github.com/telekom/controlplane/admin/api/v1" apiapi "github.com/telekom/controlplane/api/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" rover "github.com/telekom/controlplane/rover/api/v1" ) @@ -36,6 +38,7 @@ type ApiSpecificationReconciler struct { // +kubebuilder:rbac:groups=rover.cp.ei.telekom.de,resources=apispecifications/status,verbs=get;update;patch // +kubebuilder:rbac:groups=rover.cp.ei.telekom.de,resources=apispecifications/finalizers,verbs=update // +kubebuilder:rbac:groups=api.cp.ei.telekom.de,resources=apis,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=zones,verbs=get;list;watch func (r *ApiSpecificationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { return r.Controller.Reconcile(ctx, req, &rover.ApiSpecification{}) @@ -44,7 +47,17 @@ func (r *ApiSpecificationReconciler) Reconcile(ctx context.Context, req ctrl.Req // SetupWithManager sets up the controller with the Manager. func (r *ApiSpecificationReconciler) SetupWithManager(mgr ctrl.Manager) error { r.Recorder = mgr.GetEventRecorderFor("apispecification-controller") - r.Controller = cc.NewController(&apispec_handler.ApiSpecificationHandler{}, r.Client, r.Recorder) + + h := &apispec_handler.ApiSpecificationHandler{ + ListZones: func(ctx context.Context, environment string) (*adminv1.ZoneList, error) { + c := cclient.ClientFromContextOrDie(ctx) + list := &adminv1.ZoneList{} + err := c.List(ctx, list, client.InNamespace(environment)) + return list, err + }, + } + + r.Controller = cc.NewController(h, r.Client, r.Recorder) return ctrl.NewControllerManagedBy(mgr). For(&rover.ApiSpecification{}). diff --git a/rover/internal/handler/apispecification/handler.go b/rover/internal/handler/apispecification/handler.go index c76ba553..5433d7c0 100644 --- a/rover/internal/handler/apispecification/handler.go +++ b/rover/internal/handler/apispecification/handler.go @@ -6,11 +6,15 @@ package apispecification import ( "context" + "fmt" + "github.com/go-logr/logr" "github.com/pkg/errors" + adminv1 "github.com/telekom/controlplane/admin/api/v1" apiapi "github.com/telekom/controlplane/api/api/v1" "github.com/telekom/controlplane/common/pkg/client" "github.com/telekom/controlplane/common/pkg/condition" + "github.com/telekom/controlplane/common/pkg/config" "github.com/telekom/controlplane/common/pkg/handler" "github.com/telekom/controlplane/common/pkg/types" "github.com/telekom/controlplane/common/pkg/util/labelutil" @@ -21,10 +25,79 @@ import ( var _ handler.Handler[*roverv1.ApiSpecification] = (*ApiSpecificationHandler)(nil) -type ApiSpecificationHandler struct{} +// ApiSpecificationHandler reconciles ApiSpecification resources. +// Linting is performed by rover-server at upload time and stored in the CRD status fields. +// This handler reads the lint result and gates Api resource creation accordingly. +type ApiSpecificationHandler struct { + ListZones func(ctx context.Context, environment string) (*adminv1.ZoneList, error) +} func (h *ApiSpecificationHandler) CreateOrUpdate(ctx context.Context, apiSpec *roverv1.ApiSpecification) error { + log := logr.FromContextOrDiscard(ctx) + + // Linting is pending (async) — set processing condition and wait for the result. + if apiSpec.Status.LintPassed == nil && apiSpec.Status.LintReason == "Linting in progress" { + apiSpec.SetCondition(condition.NewProcessingCondition("LintingPending", + "OAS linting is in progress, waiting for result")) + apiSpec.SetCondition(condition.NewNotReadyCondition("LintingPending", + "API specification is being linted")) + log.V(0).Info("Linting in progress, waiting for result") + return nil + } + + // Check if linting failed and the zone config blocks on failure + if apiSpec.Status.LintPassed != nil && !*apiSpec.Status.LintPassed { + environment := apiSpec.Labels[config.EnvironmentLabelKey] + mode := h.lookupLintingMode(ctx, environment) + if mode == adminv1.LintingModeBlock { + msg := fmt.Sprintf("OAS linting failed: %s", apiSpec.Status.LintReason) + if apiSpec.Status.LintDashboardURL != "" { + msg = fmt.Sprintf("%s. View details: %s", msg, apiSpec.Status.LintDashboardURL) + } + apiSpec.SetCondition(condition.NewBlockedCondition(msg)) + apiSpec.SetCondition(condition.NewNotReadyCondition("LintingFailed", + "API specification did not pass linting")) + log.V(0).Info("Linting failed in block mode, skipping Api creation", + "reason", apiSpec.Status.LintReason, "errors", apiSpec.Status.LintErrors) + return nil + } + // warn mode: log and continue + log.V(0).Info("Linting failed in warn mode, proceeding with Api creation", + "reason", apiSpec.Status.LintReason, "errors", apiSpec.Status.LintErrors) + } + + return h.createOrUpdateApi(ctx, apiSpec) +} + +func (h *ApiSpecificationHandler) Delete(_ context.Context, _ *roverv1.ApiSpecification) error { + return nil +} +// lookupLintingMode finds the Zone in the environment and returns the effective linting mode. +// Defaults to LintingModeBlock if any zone has linting enabled but no explicit mode. +func (h *ApiSpecificationHandler) lookupLintingMode(ctx context.Context, environment string) adminv1.LintingMode { + if h.ListZones == nil { + return adminv1.LintingModeBlock + } + zones, err := h.ListZones(ctx, environment) + if err != nil || zones == nil { + return adminv1.LintingModeBlock + } + for i := range zones.Items { + zone := &zones.Items[i] + if zone.Spec.Linting != nil && zone.Spec.Linting.Enabled { + mode := zone.Spec.Linting.Mode + if mode == "" { + mode = adminv1.LintingModeBlock + } + return mode + } + } + return adminv1.LintingModeBlock +} + +// createOrUpdateApi contains the Api resource creation logic. +func (h *ApiSpecificationHandler) createOrUpdateApi(ctx context.Context, apiSpec *roverv1.ApiSpecification) error { c := client.ClientFromContextOrDie(ctx) name := roverv1.MakeName(apiSpec) @@ -66,7 +139,6 @@ func (h *ApiSpecificationHandler) CreateOrUpdate(ctx context.Context, apiSpec *r if c.AnyChanged() { apiSpec.SetCondition(condition.NewProcessingCondition("Provisioning", "API updated")) apiSpec.SetCondition(condition.NewNotReadyCondition("Provisioning", "API is not ready")) - } else { apiSpec.SetCondition(condition.NewDoneProcessingCondition("API created")) apiSpec.SetCondition(condition.NewReadyCondition("Provisioned", "API is ready")) @@ -74,7 +146,3 @@ func (h *ApiSpecificationHandler) CreateOrUpdate(ctx context.Context, apiSpec *r return nil } - -func (h *ApiSpecificationHandler) Delete(ctx context.Context, obj *roverv1.ApiSpecification) error { - return nil -} diff --git a/rover/internal/handler/apispecification/handler_test.go b/rover/internal/handler/apispecification/handler_test.go new file mode 100644 index 00000000..aec279ce --- /dev/null +++ b/rover/internal/handler/apispecification/handler_test.go @@ -0,0 +1,183 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package apispecification_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + roverv1 "github.com/telekom/controlplane/rover/api/v1" + handler "github.com/telekom/controlplane/rover/internal/handler/apispecification" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newApiSpec(hash, category string) *roverv1.ApiSpecification { + return &roverv1.ApiSpecification{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "controlplane.2/environment": "test-env", + }, + }, + Spec: roverv1.ApiSpecificationSpec{ + Specification: "file-id-123", + Category: category, + BasePath: "/eni/test/v1", + Hash: hash, + Version: "1.0.0", + }, + } +} + +func newZone(name string, linting *adminv1.LintingConfig) *adminv1.Zone { + return &adminv1.Zone{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "test-env", + }, + Spec: adminv1.ZoneSpec{ + Linting: linting, + }, + } +} + +func listZonesWith(zones ...*adminv1.Zone) func(context.Context, string) (*adminv1.ZoneList, error) { + return func(_ context.Context, _ string) (*adminv1.ZoneList, error) { + items := make([]adminv1.Zone, len(zones)) + for i, z := range zones { + items[i] = *z + } + return &adminv1.ZoneList{Items: items}, nil + } +} + +var _ = Describe("ApiSpecification Handler Linting Gate", func() { + + Context("when LintPassed is nil (no linting performed)", func() { + It("should not block", func() { + h := &handler.ApiSpecificationHandler{} + apiSpec := newApiSpec("hash1", "other") + Expect(apiSpec.Status.LintPassed).To(BeNil()) + _ = h + }) + }) + + Context("when linting is in progress (async pending)", func() { + It("should have nil LintPassed with pending reason", func() { + apiSpec := newApiSpec("hash1", "other") + apiSpec.Status.LintPassed = nil + apiSpec.Status.LintReason = "Linting in progress" + Expect(apiSpec.Status.LintPassed).To(BeNil()) + Expect(apiSpec.Status.LintReason).To(Equal("Linting in progress")) + }) + }) + + Context("when linting passed", func() { + It("should not set blocked condition", func() { + apiSpec := newApiSpec("hash1", "other") + passed := true + apiSpec.Status.LintPassed = &passed + apiSpec.Status.LintReason = "no errors" + Expect(*apiSpec.Status.LintPassed).To(BeTrue()) + }) + }) + + Context("when linting failed in block mode", func() { + It("should have failing lint status", func() { + apiSpec := newApiSpec("hash1", "strict-cat") + passed := false + apiSpec.Status.LintPassed = &passed + apiSpec.Status.LintReason = "found 3 errors" + apiSpec.Status.LintErrors = 3 + apiSpec.Status.LintWarnings = 1 + Expect(*apiSpec.Status.LintPassed).To(BeFalse()) + Expect(apiSpec.Status.LintErrors).To(Equal(3)) + }) + }) + + Context("when linting failed with dashboard URL", func() { + It("should include the dashboard URL in the status", func() { + apiSpec := newApiSpec("hash1", "strict-cat") + passed := false + apiSpec.Status.LintPassed = &passed + apiSpec.Status.LintReason = "found 3 errors" + apiSpec.Status.LintErrors = 3 + apiSpec.Status.LintDashboardURL = "https://linter.example.com/scans/scan-123" + Expect(apiSpec.Status.LintDashboardURL).To(Equal("https://linter.example.com/scans/scan-123")) + }) + }) + + Context("Zone with whitelisted categories", func() { + It("should support whitelisted categories in zone config", func() { + zone := newZone("dp1", &adminv1.LintingConfig{ + Enabled: true, + Mode: adminv1.LintingModeBlock, + WhitelistedCategories: []string{"internal", "legacy"}, + }) + Expect(zone.Spec.Linting.WhitelistedCategories).To(ContainElement("internal")) + Expect(zone.Spec.Linting.WhitelistedCategories).To(ContainElement("legacy")) + }) + + It("should support dashboard URL template in zone config", func() { + zone := newZone("dp1", &adminv1.LintingConfig{ + Enabled: true, + DashboardURLTemplate: "https://linter.example.com/scans/{linterId}", + }) + Expect(zone.Spec.Linting.DashboardURLTemplate).To(Equal("https://linter.example.com/scans/{linterId}")) + }) + }) + + Context("LintingMode behavior on Zone", func() { + It("should default to block mode when mode is empty", func() { + zone := newZone("dp1", &adminv1.LintingConfig{ + Enabled: true, + }) + Expect(zone.Spec.Linting.Mode).To(Equal(adminv1.LintingMode(""))) + }) + + It("should support warn mode", func() { + zone := newZone("dp1", &adminv1.LintingConfig{ + Enabled: true, + Mode: adminv1.LintingModeWarn, + }) + Expect(zone.Spec.Linting.Mode).To(Equal(adminv1.LintingModeWarn)) + }) + + It("should support block mode explicitly", func() { + zone := newZone("dp1", &adminv1.LintingConfig{ + Enabled: true, + Mode: adminv1.LintingModeBlock, + }) + Expect(zone.Spec.Linting.Mode).To(Equal(adminv1.LintingModeBlock)) + }) + }) + + Context("lookupLintingMode via Zone", func() { + It("should return block mode when ListZones is nil", func() { + h := &handler.ApiSpecificationHandler{} + _ = h + }) + + It("should return zone linting mode", func() { + zone := newZone("dp1", &adminv1.LintingConfig{ + Enabled: true, + Mode: adminv1.LintingModeWarn, + }) + h := &handler.ApiSpecificationHandler{ + ListZones: listZonesWith(zone), + } + _ = h + }) + + It("should skip zones without linting config", func() { + zone := newZone("dp1", nil) + h := &handler.ApiSpecificationHandler{ + ListZones: listZonesWith(zone), + } + _ = h + }) + }) +}) diff --git a/rover/internal/handler/apispecification/suite_test.go b/rover/internal/handler/apispecification/suite_test.go new file mode 100644 index 00000000..ebcbbf98 --- /dev/null +++ b/rover/internal/handler/apispecification/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package apispecification_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestApiSpecificationHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ApiSpecification Handler Suite") +} diff --git a/rover/internal/oaslint/external.go b/rover/internal/oaslint/external.go new file mode 100644 index 00000000..201aef90 --- /dev/null +++ b/rover/internal/oaslint/external.go @@ -0,0 +1,133 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const ( + defaultConnectTimeout = 5 * time.Second + defaultReadTimeout = 50 * time.Second + scanEndpoint = "api/linter/scans" + yamlContentType = "application/yaml; charset=UTF-8" +) + +var _ Linter = (*ExternalLinter)(nil) + +// ExternalLinter calls an external linter REST API (Atlas Linter Service compatible). +// POST {baseURL}/api/linter/scans with the OAS spec as YAML body. +type ExternalLinter struct { + baseURL string + client *http.Client +} + +// ExternalLinterOption configures the ExternalLinter. +type ExternalLinterOption func(*ExternalLinter) + +// WithHTTPClient overrides the default HTTP client. +func WithHTTPClient(c *http.Client) ExternalLinterOption { + return func(l *ExternalLinter) { + l.client = c + } +} + +// NewExternalLinter creates a new ExternalLinter targeting the given base URL. +func NewExternalLinter(baseURL string, opts ...ExternalLinterOption) *ExternalLinter { + l := &ExternalLinter{ + baseURL: baseURL, + client: &http.Client{ + Timeout: defaultConnectTimeout + defaultReadTimeout, + }, + } + for _, o := range opts { + o(l) + } + return l +} + +// linterScanResponse mirrors the external linter API response (Atlas Linter Service). +type linterScanResponse struct { + ID string `json:"id"` + CreatedAt string `json:"createdAt"` + Ruleset linterRuleset `json:"ruleset"` + Info violationsInfo `json:"info"` + LinterVersion string `json:"linterVersion"` +} + +type linterRuleset struct { + Name string `json:"name"` + Hash string `json:"hash"` + URL string `json:"url,omitempty"` +} + +type violationsInfo struct { + Infos int `json:"infos"` + Warnings int `json:"warnings"` + Errors int `json:"errors"` + Hints int `json:"hints"` +} + +func (l *ExternalLinter) Lint(ctx context.Context, spec []byte, _ string) (*LintResult, error) { + url := fmt.Sprintf("%s/%s", l.baseURL, scanEndpoint) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(spec)) + if err != nil { + return nil, fmt.Errorf("creating linter request: %w", err) + } + req.Header.Set("Content-Type", yamlContentType) + + resp, err := l.client.Do(req) + if err != nil { + return nil, fmt.Errorf("calling linter API: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading linter response: %w", err) + } + + if resp.StatusCode == http.StatusRequestTimeout { + return nil, fmt.Errorf("linting timed out (HTTP 408)") + } + + if resp.StatusCode >= 500 { + return nil, fmt.Errorf("linter service unavailable (HTTP %d)", resp.StatusCode) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("linter API returned unexpected status %d", resp.StatusCode) + } + + var scan linterScanResponse + if err := json.Unmarshal(body, &scan); err != nil { + return nil, fmt.Errorf("decoding linter response: %w", err) + } + + passed := scan.Info.Errors == 0 + reason := "linter scan result does not contain errors" + if !passed { + reason = fmt.Sprintf("linter scan found %d error(s) per %q rules", scan.Info.Errors, scan.Ruleset.Name) + } + + return &LintResult{ + Passed: passed, + Reason: reason, + Ruleset: scan.Ruleset.Name, + LinterVersion: scan.LinterVersion, + LinterId: scan.ID, + Errors: scan.Info.Errors, + Warnings: scan.Info.Warnings, + Hints: scan.Info.Hints, + Infos: scan.Info.Infos, + }, nil +} diff --git a/rover/internal/oaslint/external_test.go b/rover/internal/oaslint/external_test.go new file mode 100644 index 00000000..88887de3 --- /dev/null +++ b/rover/internal/oaslint/external_test.go @@ -0,0 +1,191 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ExternalLinter", func() { + var ( + ctx context.Context + server *httptest.Server + linter *ExternalLinter + spec []byte + ) + + BeforeEach(func() { + ctx = context.Background() + spec = []byte(`openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +servers: + - url: http://example.com/api/v1 +`) + }) + + AfterEach(func() { + if server != nil { + server.Close() + } + }) + + Context("when the linter API returns a clean scan", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.URL.Path).To(Equal("/api/linter/scans")) + Expect(r.Header.Get("Content-Type")).To(Equal(yamlContentType)) + + resp := linterScanResponse{ + ID: "scan-123", + CreatedAt: "2025-01-01T00:00:00Z", + Ruleset: linterRuleset{ + Name: "default-ruleset", + Hash: "abc123", + }, + Info: violationsInfo{ + Infos: 1, + Warnings: 2, + Errors: 0, + Hints: 3, + }, + LinterVersion: "1.5.0", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + linter = NewExternalLinter(server.URL) + }) + + It("should return a passing result", func() { + result, err := linter.Lint(ctx, spec, "default-ruleset") + Expect(err).NotTo(HaveOccurred()) + Expect(result.Passed).To(BeTrue()) + Expect(result.LinterId).To(Equal("scan-123")) + Expect(result.Ruleset).To(Equal("default-ruleset")) + Expect(result.LinterVersion).To(Equal("1.5.0")) + Expect(result.Errors).To(Equal(0)) + Expect(result.Warnings).To(Equal(2)) + Expect(result.Hints).To(Equal(3)) + Expect(result.Infos).To(Equal(1)) + Expect(result.Reason).To(ContainSubstring("does not contain errors")) + }) + }) + + Context("when the linter API returns errors", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := linterScanResponse{ + ID: "scan-456", + Ruleset: linterRuleset{ + Name: "strict-ruleset", + }, + Info: violationsInfo{ + Errors: 5, + Warnings: 3, + }, + LinterVersion: "1.5.0", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + linter = NewExternalLinter(server.URL) + }) + + It("should return a failing result", func() { + result, err := linter.Lint(ctx, spec, "strict-ruleset") + Expect(err).NotTo(HaveOccurred()) + Expect(result.Passed).To(BeFalse()) + Expect(result.Errors).To(Equal(5)) + Expect(result.Warnings).To(Equal(3)) + Expect(result.LinterId).To(Equal("scan-456")) + Expect(result.Reason).To(ContainSubstring("5 error(s)")) + Expect(result.Reason).To(ContainSubstring("strict-ruleset")) + }) + }) + + Context("when the linter API returns 5xx", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + linter = NewExternalLinter(server.URL) + }) + + It("should return an error", func() { + result, err := linter.Lint(ctx, spec, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("linter service unavailable")) + Expect(result).To(BeNil()) + }) + }) + + Context("when the linter API returns 408 timeout", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusRequestTimeout) + })) + linter = NewExternalLinter(server.URL) + }) + + It("should return a timeout error", func() { + result, err := linter.Lint(ctx, spec, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timed out")) + Expect(result).To(BeNil()) + }) + }) + + Context("when the linter API is unreachable", func() { + BeforeEach(func() { + linter = NewExternalLinter("http://localhost:1", WithHTTPClient(&http.Client{ + Timeout: 1 * time.Second, + })) + }) + + It("should return a connection error", func() { + result, err := linter.Lint(ctx, spec, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("calling linter API")) + Expect(result).To(BeNil()) + }) + }) + + Context("when the linter API returns invalid JSON", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("not json")) //nolint:errcheck + })) + linter = NewExternalLinter(server.URL) + }) + + It("should return a decode error", func() { + result, err := linter.Lint(ctx, spec, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("decoding linter response")) + Expect(result).To(BeNil()) + }) + }) +}) + +var _ = Describe("NoopLinter", func() { + It("should always return a passing result", func() { + linter := &NoopLinter{} + result, err := linter.Lint(context.Background(), []byte("anything"), "any-ruleset") + Expect(err).NotTo(HaveOccurred()) + Expect(result.Passed).To(BeTrue()) + Expect(result.Reason).To(ContainSubstring("disabled")) + }) +}) diff --git a/rover/internal/oaslint/linter.go b/rover/internal/oaslint/linter.go new file mode 100644 index 00000000..71f87870 --- /dev/null +++ b/rover/internal/oaslint/linter.go @@ -0,0 +1,26 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import "context" + +// Linter defines the interface for OAS specification linting. +// Implementations can call an external linter API or lint in-process (e.g. vacuum). +type Linter interface { + Lint(ctx context.Context, spec []byte, ruleset string) (*LintResult, error) +} + +// LintResult contains the outcome of a linting operation. +type LintResult struct { + Passed bool + Reason string + Ruleset string + LinterVersion string + LinterId string + Errors int + Warnings int + Hints int + Infos int +} diff --git a/rover/internal/oaslint/noop.go b/rover/internal/oaslint/noop.go new file mode 100644 index 00000000..102e01ce --- /dev/null +++ b/rover/internal/oaslint/noop.go @@ -0,0 +1,19 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import "context" + +var _ Linter = (*NoopLinter)(nil) + +// NoopLinter always returns a passing result. Used when linting is disabled. +type NoopLinter struct{} + +func (n *NoopLinter) Lint(_ context.Context, _ []byte, _ string) (*LintResult, error) { + return &LintResult{ + Passed: true, + Reason: "linting is disabled", + }, nil +} diff --git a/rover/internal/oaslint/suite_test.go b/rover/internal/oaslint/suite_test.go new file mode 100644 index 00000000..1b4f4b30 --- /dev/null +++ b/rover/internal/oaslint/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestOasLint(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "OAS Lint Suite") +} From 042263634f18ee77135d6eb45d2fc77fec71cdeb Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Tue, 28 Apr 2026 16:29:43 +0200 Subject: [PATCH 04/42] feat(rover-server): integrate external linter and enhance API specification validation --- rover-server/cmd/main.go | 26 +- rover-server/internal/config/config.go | 14 + .../__snapshots__/suite_controller_test.snap | 4 +- .../internal/controller/apispecification.go | 241 +++++++++++++++++- .../controller/suite_controller_test.go | 2 +- .../status/__snapshots__/status_test.snap | 4 +- rover-server/internal/oaslint/external.go | 133 ++++++++++ .../internal/oaslint/external_test.go | 191 ++++++++++++++ rover-server/internal/oaslint/linter.go | 26 ++ rover-server/internal/oaslint/noop.go | 19 ++ rover-server/internal/oaslint/suite_test.go | 17 ++ rover-server/pkg/store/stores.go | 2 + rover-server/test/mocks/mocks_ApiCategory.go | 34 +++ 13 files changed, 701 insertions(+), 12 deletions(-) create mode 100644 rover-server/internal/oaslint/external.go create mode 100644 rover-server/internal/oaslint/external_test.go create mode 100644 rover-server/internal/oaslint/linter.go create mode 100644 rover-server/internal/oaslint/noop.go create mode 100644 rover-server/internal/oaslint/suite_test.go create mode 100644 rover-server/test/mocks/mocks_ApiCategory.go diff --git a/rover-server/cmd/main.go b/rover-server/cmd/main.go index 208176e3..91a7431b 100644 --- a/rover-server/cmd/main.go +++ b/rover-server/cmd/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -21,6 +22,7 @@ import ( "github.com/telekom/controlplane/rover-server/internal/config" "github.com/telekom/controlplane/rover-server/internal/controller" + "github.com/telekom/controlplane/rover-server/internal/oaslint" "github.com/telekom/controlplane/rover-server/internal/server" "github.com/telekom/controlplane/rover-server/pkg/log" "github.com/telekom/controlplane/rover-server/pkg/store" @@ -48,10 +50,19 @@ func main() { filesapi.WithSkipTLSVerify(cfg.FileManager.SkipTLS), ) + var linter oaslint.Linter + if cfg.OasLinting.URL != "" { + log.Log.Info("OAS linting enabled", "url", cfg.OasLinting.URL) + linter = oaslint.NewExternalLinter(cfg.OasLinting.URL) + } + + whitelistedBasepaths := parseDelimitedSet(cfg.OasLinting.WhitelistedBasepaths, ";") + whitelistedCategories := parseDelimitedSet(cfg.OasLinting.WhitelistedCategories, ";") + s := server.Server{ Config: cfg, Log: log.Log, - ApiSpecifications: controller.NewApiSpecificationController(stores), + ApiSpecifications: controller.NewApiSpecificationController(stores, linter, whitelistedBasepaths, whitelistedCategories, cfg.OasLinting.ErrorMessage), Rovers: controller.NewRoverController(stores), EventSpecifications: controller.NewEventSpecificationController(stores), } @@ -79,3 +90,16 @@ func main() { log.Log.Info("Server gracefully stopped") } + +// parseDelimitedSet parses a delimited string into a set. +// Empty entries are ignored. Categories are lowercased for case-insensitive matching. +func parseDelimitedSet(s, delimiter string) map[string]struct{} { + result := make(map[string]struct{}) + for _, v := range strings.Split(s, delimiter) { + v = strings.TrimSpace(v) + if v != "" { + result[strings.ToLower(v)] = struct{}{} + } + } + return result +} diff --git a/rover-server/internal/config/config.go b/rover-server/internal/config/config.go index 85ed3541..d5222fea 100644 --- a/rover-server/internal/config/config.go +++ b/rover-server/internal/config/config.go @@ -16,6 +16,14 @@ type ServerConfig struct { Security SecurityConfig `json:"security"` Log LogConfig `json:"log"` FileManager FileManagerConfig `json:"fileManager"` + OasLinting OasLintingConfig `json:"oasLinting"` +} + +type OasLintingConfig struct { + URL string `json:"url"` + ErrorMessage string `json:"errorMessage"` + WhitelistedBasepaths string `json:"whitelistedBasepaths"` + WhitelistedCategories string `json:"whitelistedCategories"` } type SecurityConfig struct { @@ -72,6 +80,12 @@ func setDefaults() { // FileManager viper.SetDefault("fileManager.skipTLS", true) + // OAS Linting + viper.SetDefault("oasLinting.url", "") + viper.SetDefault("oasLinting.errorMessage", "Linter scan result contains errors. Please visit the linter UI for details on the RULESET_NAME_PLACEHOLDER ruleset.") + viper.SetDefault("oasLinting.whitelistedBasepaths", "") + viper.SetDefault("oasLinting.whitelistedCategories", "") + // Database viper.SetDefault("database.filepath", "") // empty string means in-memory only viper.SetDefault("database.reduceMemory", false) // see common-server docs diff --git a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap index 10882ade..492140a4 100755 --- a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap +++ b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap @@ -617,7 +617,7 @@ [Rover Controller Get rover status should return the status of a rover successfully - 1] { - "createdAt": "2025-09-18T08:39:44Z", + "createdAt": "2025-09-18T10:39:44+02:00", "errors": [ { "cause": "NoApproval", @@ -632,7 +632,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T07:16:40Z", + "processedAt": "2025-10-08T09:16:40+02:00", "processingState": "done", "state": "blocked" } diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 9c563308..100109c8 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -9,16 +9,21 @@ import ( "context" "crypto/sha256" "encoding/base64" + "fmt" "io" + "strings" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/log" "github.com/pkg/errors" + adminv1 "github.com/telekom/controlplane/admin/api/v1" + apiv1 "github.com/telekom/controlplane/api/api/v1" "github.com/telekom/controlplane/common-server/pkg/problems" "github.com/telekom/controlplane/common-server/pkg/server/middleware/security" "github.com/telekom/controlplane/common-server/pkg/store" filesapi "github.com/telekom/controlplane/file-manager/api" "github.com/telekom/controlplane/rover-server/internal/file" + "github.com/telekom/controlplane/rover-server/internal/oaslint" roverv1 "github.com/telekom/controlplane/rover/api/v1" "gopkg.in/yaml.v3" @@ -34,15 +39,46 @@ import ( var _ server.ApiSpecificationController = &ApiSpecificationController{} type ApiSpecificationController struct { - stores *s.Stores - Store store.ObjectStore[*roverv1.ApiSpecification] + stores *s.Stores + Store store.ObjectStore[*roverv1.ApiSpecification] + ZoneStore store.ObjectStore[*adminv1.Zone] + Linter oaslint.Linter + + // ListApiCategories is a function to list all ApiCategories for validation at upload time. + // If nil, category validation is skipped. + ListApiCategories func(ctx context.Context) (*apiv1.ApiCategoryList, error) + + // Whitelists and error message for linting, from config. + WhitelistedBasepaths map[string]struct{} + WhitelistedCategories map[string]struct{} + ErrorMessage string } -func NewApiSpecificationController(stores *s.Stores) *ApiSpecificationController { - return &ApiSpecificationController{ - stores: stores, - Store: stores.APISpecificationStore, +func NewApiSpecificationController(stores *s.Stores, linter oaslint.Linter, whitelistedBasepaths, whitelistedCategories map[string]struct{}, errorMessage string) *ApiSpecificationController { + ctrl := &ApiSpecificationController{ + stores: stores, + Store: stores.APISpecificationStore, + ZoneStore: stores.ZoneStore, + Linter: linter, + WhitelistedBasepaths: whitelistedBasepaths, + WhitelistedCategories: whitelistedCategories, + ErrorMessage: errorMessage, } + if stores.APICategoryStore != nil { + ctrl.ListApiCategories = func(ctx context.Context) (*apiv1.ApiCategoryList, error) { + listOpts := store.NewListOpts() + categoryList, err := stores.APICategoryStore.List(ctx, listOpts) + if err != nil { + return nil, err + } + result := &apiv1.ApiCategoryList{Items: make([]apiv1.ApiCategory, 0, len(categoryList.Items))} + for _, item := range categoryList.Items { + result.Items = append(result.Items, *item) + } + return result, nil + } + } + return ctrl } // Create implements server.ApiSpecificationController. @@ -189,6 +225,11 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri return res, err } + // Validate the API category against the known ApiCategories. + if catErr := a.validateApiCategory(ctx, apiSpec.Spec.Category); catErr != nil { + return res, catErr + } + fileAPIResp, err := a.uploadFile(ctx, specMarshaled, id) if err != nil { return res, err @@ -200,11 +241,25 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri } EnsureLabelsOrDie(ctx, apiSpec) + // Check if linting can be skipped (whitelist or hash dedup) synchronously. + // If the spec needs actual external linting, we dispatch it asynchronously. + var lintCfg *adminv1.LintingConfig + var needsAsyncLint bool + if a.Linter != nil { + lintCfg = a.lookupLintingConfig(ctx, id.Environment) + needsAsyncLint = a.prepareLinting(ctx, lintCfg, apiSpec, specMarshaled) + } + err = a.Store.CreateOrReplace(ctx, apiSpec) if err != nil { return res, err } + // Dispatch async linting if needed. The background goroutine will update the CRD status. + if needsAsyncLint { + a.dispatchAsyncLint(ctx, apiSpec.Namespace, apiSpec.Name, lintCfg, specMarshaled) + } + return a.Get(ctx, resourceId) } @@ -227,6 +282,180 @@ func (a *ApiSpecificationController) GetStatus(ctx context.Context, resourceId s return status.MapAPISpecificationResponse(ctx, apiSpec, a.stores) } +// prepareLinting checks whitelists and hash dedup synchronously. +// It returns true if an async external linter call is needed. +// If linting is not needed (disabled, whitelisted, or hash unchanged), it updates apiSpec in place and returns false. +func (a *ApiSpecificationController) prepareLinting(_ context.Context, lintCfg *adminv1.LintingConfig, apiSpec *roverv1.ApiSpecification, specBytes []byte) bool { + if a.Linter == nil || lintCfg == nil || !lintCfg.Enabled { + return false + } + + // Check basepath whitelist (controller-config level). + if _, ok := a.WhitelistedBasepaths[apiSpec.Spec.BasePath]; ok { + log.Infof("Basepath %q is whitelisted, skipping linting", apiSpec.Spec.BasePath) + passed := true + apiSpec.Status.LintPassed = &passed + apiSpec.Status.LintReason = fmt.Sprintf("The basepath %q is whitelisted", apiSpec.Spec.BasePath) + return false + } + + // Check category whitelist (controller-config level). + if _, ok := a.WhitelistedCategories[strings.ToLower(apiSpec.Spec.Category)]; ok { + log.Infof("Category %q is whitelisted (controller config), skipping linting", apiSpec.Spec.Category) + passed := true + apiSpec.Status.LintPassed = &passed + apiSpec.Status.LintReason = fmt.Sprintf("The category %q is whitelisted", apiSpec.Spec.Category) + return false + } + + // Check category whitelist (zone-level). + if isCategoryWhitelistedByZone(lintCfg, apiSpec.Spec.Category) { + log.Infof("Category %q is whitelisted (zone config), skipping linting", apiSpec.Spec.Category) + passed := true + apiSpec.Status.LintPassed = &passed + apiSpec.Status.LintReason = fmt.Sprintf("The category %q is whitelisted by zone", apiSpec.Spec.Category) + return false + } + + // Hash dedup: skip re-linting if the spec content has not changed. + specHash := computeHash(specBytes) + if apiSpec.Status.LintedHash == specHash && apiSpec.Status.LintPassed != nil { + log.Infof("Spec hash unchanged (%s), reusing previous lint result (passed=%v)", specHash, *apiSpec.Status.LintPassed) + return false + } + + // Mark as linting pending — the actual call happens asynchronously. + apiSpec.Status.LintPassed = nil + apiSpec.Status.LintReason = "Linting in progress" + apiSpec.Status.LintedHash = "" + return true +} + +// dispatchAsyncLint runs the external linter call in a background goroutine. +// It updates the ApiSpecification CRD status with the lint result when done. +func (a *ApiSpecificationController) dispatchAsyncLint(ctx context.Context, ns, name string, lintCfg *adminv1.LintingConfig, specBytes []byte) { + // Create a detached context so the background work is not cancelled when the HTTP request ends. + bgCtx := context.WithoutCancel(ctx) + go func() { + result, err := a.Linter.Lint(bgCtx, specBytes) + if err != nil { + log.Errorf("Async OAS linting failed for %s/%s: %v", ns, name, err) + a.updateLintStatus(bgCtx, ns, name, lintCfg, &oaslint.LintResult{ + Passed: false, + Reason: fmt.Sprintf("linter API error: %s", err), + }, specBytes) + return + } + + a.updateLintStatus(bgCtx, ns, name, lintCfg, result, specBytes) + }() +} + +// updateLintStatus fetches the current ApiSpecification, updates its lint status fields, and writes it back. +func (a *ApiSpecificationController) updateLintStatus(ctx context.Context, ns, name string, lintCfg *adminv1.LintingConfig, result *oaslint.LintResult, specBytes []byte) { + apiSpec, err := a.Store.Get(ctx, ns, name) + if err != nil { + log.Errorf("Failed to fetch ApiSpecification %s/%s for lint status update: %v", ns, name, err) + return + } + + specHash := computeHash(specBytes) + passed := result.Passed + apiSpec.Status.LintedHash = specHash + apiSpec.Status.LintPassed = &passed + apiSpec.Status.LintReason = result.Reason + apiSpec.Status.LinterId = result.LinterId + apiSpec.Status.LintRuleset = result.Ruleset + apiSpec.Status.LintLinterVersion = result.LinterVersion + apiSpec.Status.LintErrors = result.Errors + apiSpec.Status.LintWarnings = result.Warnings + + // Populate the linter dashboard URL from zone config if available. + if lintCfg != nil && lintCfg.DashboardURLTemplate != "" && result.LinterId != "" { + apiSpec.Status.LintDashboardURL = strings.ReplaceAll(lintCfg.DashboardURLTemplate, "{linterId}", result.LinterId) + } + + if !passed { + message := strings.ReplaceAll(a.ErrorMessage, "RULESET_NAME_PLACEHOLDER", result.Ruleset) + apiSpec.Status.LintReason = message + log.Infof("Async OAS linting failed for %s/%s: %s (errors=%d, warnings=%d)", + ns, name, result.Reason, result.Errors, result.Warnings) + } + + if err := a.Store.CreateOrReplace(ctx, apiSpec); err != nil { + log.Errorf("Failed to update lint status for %s/%s: %v", ns, name, err) + } +} + +// isCategoryWhitelistedByZone checks if the given category is whitelisted in the zone-level linting config. +func isCategoryWhitelistedByZone(lintCfg *adminv1.LintingConfig, category string) bool { + for _, wl := range lintCfg.WhitelistedCategories { + if strings.EqualFold(wl, category) { + return true + } + } + return false +} + +// computeHash returns the base64-encoded SHA-256 hash of the given data. +func computeHash(data []byte) string { + hasher := sha256.New() + hasher.Write(data) + return base64.StdEncoding.EncodeToString(hasher.Sum(nil)) +} + +// validateApiCategory validates that the given category is a known and active ApiCategory. +// If ListApiCategories is nil, validation is skipped. +func (a *ApiSpecificationController) validateApiCategory(ctx context.Context, category string) error { + if a.ListApiCategories == nil { + return nil + } + + apiCategoryList, err := a.ListApiCategories(ctx) + if err != nil { + log.Warnf("Failed to list ApiCategories for validation: %v", err) + return nil + } + + found, ok := apiCategoryList.FindByLabelValue(category) + if !ok { + allowedLabels := strings.Join(apiCategoryList.AllowedLabelValues(), ", ") + return problems.BadRequest( + fmt.Sprintf("ApiCategory %q not found. Allowed values are: [%s]", category, allowedLabels)) + } + + if !found.Spec.Active { + return problems.BadRequest( + fmt.Sprintf("ApiCategory %q is not active", category)) + } + + return nil +} + +// lookupLintingConfig finds the linting configuration from the zones in the given environment. +// It returns the first zone's linting config that is enabled. +func (a *ApiSpecificationController) lookupLintingConfig(ctx context.Context, environment string) *adminv1.LintingConfig { + if a.ZoneStore == nil { + return nil + } + + listOpts := store.NewListOpts() + listOpts.Prefix = environment + zoneList, err := a.ZoneStore.List(ctx, listOpts) + if err != nil || zoneList == nil { + return nil + } + + for i := range zoneList.Items { + zone := zoneList.Items[i] + if zone.Spec.Linting != nil && zone.Spec.Linting.Enabled { + return zone.Spec.Linting + } + } + + return nil +} + func (a *ApiSpecificationController) uploadFile(ctx context.Context, specMarshaled []byte, id mapper.ResourceIdInfo) (*filesapi.FileUploadResponse, error) { if len(specMarshaled) == 0 || specMarshaled == nil { return nil, errors.New("input api specification has length 0 or nil") diff --git a/rover-server/internal/controller/suite_controller_test.go b/rover-server/internal/controller/suite_controller_test.go index 965bcf52..3f242d92 100644 --- a/rover-server/internal/controller/suite_controller_test.go +++ b/rover-server/internal/controller/suite_controller_test.go @@ -111,7 +111,7 @@ var _ = BeforeSuite(func() { s := server.Server{ Config: &config.ServerConfig{}, Log: log.Log, - ApiSpecifications: NewApiSpecificationController(stores), + ApiSpecifications: NewApiSpecificationController(stores, nil, nil, nil, ""), Rovers: NewRoverController(stores), EventSpecifications: NewEventSpecificationController(stores), } diff --git a/rover-server/internal/mapper/status/__snapshots__/status_test.snap b/rover-server/internal/mapper/status/__snapshots__/status_test.snap index 5e298203..3f445fed 100755 --- a/rover-server/internal/mapper/status/__snapshots__/status_test.snap +++ b/rover-server/internal/mapper/status/__snapshots__/status_test.snap @@ -19,7 +19,7 @@ [Rover Status Mapper MapRoverResponse must map rover response correctly - 1] { - "createdAt": "2025-09-18T08:39:44Z", + "createdAt": "2025-09-18T10:39:44+02:00", "errors": [ { "cause": "NoApproval", @@ -34,7 +34,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T07:16:40Z", + "processedAt": "2025-10-08T09:16:40+02:00", "processingState": "done", "state": "blocked" } diff --git a/rover-server/internal/oaslint/external.go b/rover-server/internal/oaslint/external.go new file mode 100644 index 00000000..cac5d14f --- /dev/null +++ b/rover-server/internal/oaslint/external.go @@ -0,0 +1,133 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const ( + defaultConnectTimeout = 5 * time.Second + defaultReadTimeout = 50 * time.Second + scanEndpoint = "api/linter/scans" + yamlContentType = "application/yaml; charset=UTF-8" +) + +var _ Linter = (*ExternalLinter)(nil) + +// ExternalLinter calls an external linter REST API (Atlas Linter Service compatible). +// POST {baseURL}/api/linter/scans with the OAS spec as YAML body. +type ExternalLinter struct { + baseURL string + client *http.Client +} + +// ExternalLinterOption configures the ExternalLinter. +type ExternalLinterOption func(*ExternalLinter) + +// WithHTTPClient overrides the default HTTP client. +func WithHTTPClient(c *http.Client) ExternalLinterOption { + return func(l *ExternalLinter) { + l.client = c + } +} + +// NewExternalLinter creates a new ExternalLinter targeting the given base URL. +func NewExternalLinter(baseURL string, opts ...ExternalLinterOption) *ExternalLinter { + l := &ExternalLinter{ + baseURL: baseURL, + client: &http.Client{ + Timeout: defaultConnectTimeout + defaultReadTimeout, + }, + } + for _, o := range opts { + o(l) + } + return l +} + +// linterScanResponse mirrors the external linter API response (Atlas Linter Service). +type linterScanResponse struct { + ID string `json:"id"` + CreatedAt string `json:"createdAt"` + Ruleset linterRuleset `json:"ruleset"` + Info violationsInfo `json:"info"` + LinterVersion string `json:"linterVersion"` +} + +type linterRuleset struct { + Name string `json:"name"` + Hash string `json:"hash"` + URL string `json:"url,omitempty"` +} + +type violationsInfo struct { + Infos int `json:"infos"` + Warnings int `json:"warnings"` + Errors int `json:"errors"` + Hints int `json:"hints"` +} + +func (l *ExternalLinter) Lint(ctx context.Context, spec []byte) (*LintResult, error) { + url := fmt.Sprintf("%s/%s", l.baseURL, scanEndpoint) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(spec)) + if err != nil { + return nil, fmt.Errorf("creating linter request: %w", err) + } + req.Header.Set("Content-Type", yamlContentType) + + resp, err := l.client.Do(req) + if err != nil { + return nil, fmt.Errorf("calling linter API: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading linter response: %w", err) + } + + if resp.StatusCode == http.StatusRequestTimeout { + return nil, fmt.Errorf("linting timed out (HTTP 408)") + } + + if resp.StatusCode >= 500 { + return nil, fmt.Errorf("linter service unavailable (HTTP %d)", resp.StatusCode) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("linter API returned unexpected status %d", resp.StatusCode) + } + + var scan linterScanResponse + if err := json.Unmarshal(body, &scan); err != nil { + return nil, fmt.Errorf("decoding linter response: %w", err) + } + + passed := scan.Info.Errors == 0 + reason := "linter scan result does not contain errors" + if !passed { + reason = fmt.Sprintf("linter scan found %d error(s) per %q rules", scan.Info.Errors, scan.Ruleset.Name) + } + + return &LintResult{ + Passed: passed, + Reason: reason, + Ruleset: scan.Ruleset.Name, + LinterVersion: scan.LinterVersion, + LinterId: scan.ID, + Errors: scan.Info.Errors, + Warnings: scan.Info.Warnings, + Hints: scan.Info.Hints, + Infos: scan.Info.Infos, + }, nil +} diff --git a/rover-server/internal/oaslint/external_test.go b/rover-server/internal/oaslint/external_test.go new file mode 100644 index 00000000..6c348bd1 --- /dev/null +++ b/rover-server/internal/oaslint/external_test.go @@ -0,0 +1,191 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ExternalLinter", func() { + var ( + ctx context.Context + server *httptest.Server + linter *ExternalLinter + spec []byte + ) + + BeforeEach(func() { + ctx = context.Background() + spec = []byte(`openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +servers: + - url: http://example.com/api/v1 +`) + }) + + AfterEach(func() { + if server != nil { + server.Close() + } + }) + + Context("when the linter API returns a clean scan", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.URL.Path).To(Equal("/api/linter/scans")) + Expect(r.Header.Get("Content-Type")).To(Equal(yamlContentType)) + + resp := linterScanResponse{ + ID: "scan-123", + CreatedAt: "2025-01-01T00:00:00Z", + Ruleset: linterRuleset{ + Name: "default-ruleset", + Hash: "abc123", + }, + Info: violationsInfo{ + Infos: 1, + Warnings: 2, + Errors: 0, + Hints: 3, + }, + LinterVersion: "1.5.0", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + linter = NewExternalLinter(server.URL) + }) + + It("should return a passing result", func() { + result, err := linter.Lint(ctx, spec) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Passed).To(BeTrue()) + Expect(result.LinterId).To(Equal("scan-123")) + Expect(result.Ruleset).To(Equal("default-ruleset")) + Expect(result.LinterVersion).To(Equal("1.5.0")) + Expect(result.Errors).To(Equal(0)) + Expect(result.Warnings).To(Equal(2)) + Expect(result.Hints).To(Equal(3)) + Expect(result.Infos).To(Equal(1)) + Expect(result.Reason).To(ContainSubstring("does not contain errors")) + }) + }) + + Context("when the linter API returns errors", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := linterScanResponse{ + ID: "scan-456", + Ruleset: linterRuleset{ + Name: "strict-ruleset", + }, + Info: violationsInfo{ + Errors: 5, + Warnings: 3, + }, + LinterVersion: "1.5.0", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + linter = NewExternalLinter(server.URL) + }) + + It("should return a failing result", func() { + result, err := linter.Lint(ctx, spec) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Passed).To(BeFalse()) + Expect(result.Errors).To(Equal(5)) + Expect(result.Warnings).To(Equal(3)) + Expect(result.LinterId).To(Equal("scan-456")) + Expect(result.Reason).To(ContainSubstring("5 error(s)")) + Expect(result.Reason).To(ContainSubstring("strict-ruleset")) + }) + }) + + Context("when the linter API returns 5xx", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + linter = NewExternalLinter(server.URL) + }) + + It("should return an error", func() { + result, err := linter.Lint(ctx, spec) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("linter service unavailable")) + Expect(result).To(BeNil()) + }) + }) + + Context("when the linter API returns 408 timeout", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusRequestTimeout) + })) + linter = NewExternalLinter(server.URL) + }) + + It("should return a timeout error", func() { + result, err := linter.Lint(ctx, spec) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timed out")) + Expect(result).To(BeNil()) + }) + }) + + Context("when the linter API is unreachable", func() { + BeforeEach(func() { + linter = NewExternalLinter("http://localhost:1", WithHTTPClient(&http.Client{ + Timeout: 1 * time.Second, + })) + }) + + It("should return a connection error", func() { + result, err := linter.Lint(ctx, spec) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("calling linter API")) + Expect(result).To(BeNil()) + }) + }) + + Context("when the linter API returns invalid JSON", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("not json")) //nolint:errcheck + })) + linter = NewExternalLinter(server.URL) + }) + + It("should return a decode error", func() { + result, err := linter.Lint(ctx, spec) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("decoding linter response")) + Expect(result).To(BeNil()) + }) + }) +}) + +var _ = Describe("NoopLinter", func() { + It("should always return a passing result", func() { + linter := &NoopLinter{} + result, err := linter.Lint(context.Background(), []byte("anything")) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Passed).To(BeTrue()) + Expect(result.Reason).To(ContainSubstring("disabled")) + }) +}) diff --git a/rover-server/internal/oaslint/linter.go b/rover-server/internal/oaslint/linter.go new file mode 100644 index 00000000..be11255d --- /dev/null +++ b/rover-server/internal/oaslint/linter.go @@ -0,0 +1,26 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import "context" + +// Linter defines the interface for OAS specification linting. +// The external linter server manages rulesets; clients just send the spec. +type Linter interface { + Lint(ctx context.Context, spec []byte) (*LintResult, error) +} + +// LintResult contains the outcome of a linting operation. +type LintResult struct { + Passed bool + Reason string + Ruleset string + LinterVersion string + LinterId string + Errors int + Warnings int + Hints int + Infos int +} diff --git a/rover-server/internal/oaslint/noop.go b/rover-server/internal/oaslint/noop.go new file mode 100644 index 00000000..9fbb0b20 --- /dev/null +++ b/rover-server/internal/oaslint/noop.go @@ -0,0 +1,19 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import "context" + +var _ Linter = (*NoopLinter)(nil) + +// NoopLinter always returns a passing result. Used when linting is disabled. +type NoopLinter struct{} + +func (n *NoopLinter) Lint(_ context.Context, _ []byte) (*LintResult, error) { + return &LintResult{ + Passed: true, + Reason: "linting is disabled", + }, nil +} diff --git a/rover-server/internal/oaslint/suite_test.go b/rover-server/internal/oaslint/suite_test.go new file mode 100644 index 00000000..1b4f4b30 --- /dev/null +++ b/rover-server/internal/oaslint/suite_test.go @@ -0,0 +1,17 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestOasLint(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "OAS Lint Suite") +} diff --git a/rover-server/pkg/store/stores.go b/rover-server/pkg/store/stores.go index 8b7ce572..811f12a9 100644 --- a/rover-server/pkg/store/stores.go +++ b/rover-server/pkg/store/stores.go @@ -37,6 +37,7 @@ type Stores struct { APIStore store.ObjectStore[*apiv1.Api] APISubscriptionStore store.ObjectStore[*apiv1.ApiSubscription] APIExposureStore store.ObjectStore[*apiv1.ApiExposure] + APICategoryStore store.ObjectStore[*apiv1.ApiCategory] EventSpecificationStore store.ObjectStore[*roverv1.EventSpecification] EventTypeStore store.ObjectStore[*eventv1.EventType] @@ -73,6 +74,7 @@ func NewStores(ctx context.Context, cfg *rest.Config) *Stores { s.ApplicationStore = NewOrDie[*applicationv1.Application](ctx, dynamicClient, applicationv1.GroupVersion.WithResource("applications"), applicationv1.GroupVersion.WithKind("Application")) s.APISubscriptionStore = NewOrDie[*apiv1.ApiSubscription](ctx, dynamicClient, apiv1.GroupVersion.WithResource("apisubscriptions"), apiv1.GroupVersion.WithKind("ApiSubscription")) s.APIExposureStore = NewOrDie[*apiv1.ApiExposure](ctx, dynamicClient, apiv1.GroupVersion.WithResource("apiexposures"), apiv1.GroupVersion.WithKind("ApiExposure")) + s.APICategoryStore = NewOrDie[*apiv1.ApiCategory](ctx, dynamicClient, apiv1.GroupVersion.WithResource("apicategories"), apiv1.GroupVersion.WithKind("ApiCategory")) if cconfig.FeaturePubSub.IsEnabled() { s.EventSpecificationStore = NewOrDie[*roverv1.EventSpecification](ctx, dynamicClient, roverv1.GroupVersion.WithResource("eventspecifications"), roverv1.GroupVersion.WithKind("EventSpecification")) diff --git a/rover-server/test/mocks/mocks_ApiCategory.go b/rover-server/test/mocks/mocks_ApiCategory.go new file mode 100644 index 00000000..cc7f740e --- /dev/null +++ b/rover-server/test/mocks/mocks_ApiCategory.go @@ -0,0 +1,34 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package mocks + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/mock" + apiv1 "github.com/telekom/controlplane/api/api/v1" + "github.com/telekom/controlplane/common-server/pkg/store" +) + +func NewAPICategoryStoreMock(testing ginkgo.FullGinkgoTInterface) store.ObjectStore[*apiv1.ApiCategory] { + mockStore := NewMockObjectStore[*apiv1.ApiCategory](testing) + ConfigureAPICategoryStoreMock(testing, mockStore) + return mockStore +} + +func ConfigureAPICategoryStoreMock(_ ginkgo.FullGinkgoTInterface, mockedStore *MockObjectStore[*apiv1.ApiCategory]) { + categories := []*apiv1.ApiCategory{ + {Spec: apiv1.ApiCategorySpec{LabelValue: "other", Active: true}}, + {Spec: apiv1.ApiCategorySpec{LabelValue: "test", Active: true}}, + {Spec: apiv1.ApiCategorySpec{LabelValue: "g-api", Active: true}}, + {Spec: apiv1.ApiCategorySpec{LabelValue: "m-api", Active: true}}, + {Spec: apiv1.ApiCategorySpec{LabelValue: "infrastructure", Active: true}}, + } + + mockedStore.EXPECT().List( + mock.Anything, + mock.Anything, + ).Return( + &store.ListResponse[*apiv1.ApiCategory]{Items: categories}, nil).Maybe() +} From 24648b88a5535e83cd771abb208540d4daca065b Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 29 Apr 2026 15:48:31 +0200 Subject: [PATCH 05/42] refactor: remove from admin config --- admin/api/v1/zone_types.go | 40 ------------------- admin/api/v1/zz_generated.deepcopy.go | 25 ------------ .../bases/admin.cp.ei.telekom.de_zones.yaml | 36 ----------------- 3 files changed, 101 deletions(-) diff --git a/admin/api/v1/zone_types.go b/admin/api/v1/zone_types.go index fd46bcfa..07640856 100644 --- a/admin/api/v1/zone_types.go +++ b/admin/api/v1/zone_types.go @@ -81,42 +81,6 @@ type PermissionsConfig struct { ConsoleUrl *string `json:"consoleUrl,omitempty"` } -// LintingMode controls how linting failures affect API creation. -// +kubebuilder:validation:Enum=block;warn -type LintingMode string - -const ( - // LintingModeBlock prevents Api creation when linting fails. - LintingModeBlock LintingMode = "block" - // LintingModeWarn allows Api creation but surfaces linting issues in status. - LintingModeWarn LintingMode = "warn" -) - -// LintingConfig configures OAS specification linting for this zone. -// The external linter server manages rulesets; the zone only controls enabled/mode. -type LintingConfig struct { - // Enabled indicates whether linting is enabled for this zone. - Enabled bool `json:"enabled,omitempty"` - // Mode controls how linting failures affect API creation. - // "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. - // +kubebuilder:validation:Enum=block;warn - // +kubebuilder:default:=block - // +optional - Mode LintingMode `json:"mode,omitempty"` - - // WhitelistedCategories is the list of API categories that are exempt from linting. - // Categories are matched case-insensitively against the x-api-category value in the OpenAPI spec. - // +optional - // +listType=set - WhitelistedCategories []string `json:"whitelistedCategories,omitempty"` - - // DashboardURLTemplate is a URL template for the linter dashboard. - // Use {linterId} as a placeholder for the scan ID. - // Example: "https://linter.example.com/scans/{linterId}" - // +optional - DashboardURLTemplate string `json:"dashboardUrlTemplate,omitempty"` -} - // ZoneSpec defines the desired state of Zone type ZoneSpec struct { IdentityProvider IdentityProviderConfig `json:"identityProvider"` @@ -130,10 +94,6 @@ type ZoneSpec struct { // Permissions configuration for permission service integration // +kubebuilder:validation:Optional Permissions *PermissionsConfig `json:"permissions,omitempty"` - - // Linting configuration for OAS specification linting in this zone - // +kubebuilder:validation:Optional - Linting *LintingConfig `json:"linting,omitempty"` } type Links struct { diff --git a/admin/api/v1/zz_generated.deepcopy.go b/admin/api/v1/zz_generated.deepcopy.go index 0fd30f46..5793ce16 100644 --- a/admin/api/v1/zz_generated.deepcopy.go +++ b/admin/api/v1/zz_generated.deepcopy.go @@ -212,26 +212,6 @@ func (in *Links) DeepCopy() *Links { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *LintingConfig) DeepCopyInto(out *LintingConfig) { - *out = *in - if in.WhitelistedCategories != nil { - in, out := &in.WhitelistedCategories, &out.WhitelistedCategories - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LintingConfig. -func (in *LintingConfig) DeepCopy() *LintingConfig { - if in == nil { - return nil - } - out := new(LintingConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PermissionsConfig) DeepCopyInto(out *PermissionsConfig) { *out = *in @@ -459,11 +439,6 @@ func (in *ZoneSpec) DeepCopyInto(out *ZoneSpec) { *out = new(PermissionsConfig) (*in).DeepCopyInto(*out) } - if in.Linting != nil { - in, out := &in.Linting, &out.Linting - *out = new(LintingConfig) - (*in).DeepCopyInto(*out) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneSpec. diff --git a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml index 833f5ec1..877f3385 100644 --- a/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml +++ b/admin/config/crd/bases/admin.cp.ei.telekom.de_zones.yaml @@ -90,42 +90,6 @@ spec: - admin - url type: object - linting: - description: Linting configuration for OAS specification linting in - this zone - properties: - dashboardUrlTemplate: - description: |- - DashboardURLTemplate is a URL template for the linter dashboard. - Use {linterId} as a placeholder for the scan ID. - Example: "https://linter.example.com/scans/{linterId}" - type: string - enabled: - description: Enabled indicates whether linting is enabled for - this zone. - type: boolean - mode: - allOf: - - enum: - - block - - warn - - enum: - - block - - warn - default: block - description: |- - Mode controls how linting failures affect API creation. - "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. - type: string - whitelistedCategories: - description: |- - WhitelistedCategories is the list of API categories that are exempt from linting. - Categories are matched case-insensitively against the x-api-category value in the OpenAPI spec. - items: - type: string - type: array - x-kubernetes-list-type: set - type: object permissions: description: Permissions configuration for permission service integration properties: From 9a1bb9684fca3a27bc78e2c5a9078c32b7e9ccc7 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 29 Apr 2026 15:48:59 +0200 Subject: [PATCH 06/42] =?UTF-8?q?feat(api):=20add=20whitelist=20and=20lint?= =?UTF-8?q?ing=20config=20to=20ap=C3=BCi=20category?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- api/api/v1/apicategory_types.go | 43 +++++++++++++++++++ api/api/v1/zz_generated.deepcopy.go | 25 +++++++++++ .../api.cp.ei.telekom.de_apicategories.yaml | 33 ++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/api/api/v1/apicategory_types.go b/api/api/v1/apicategory_types.go index 52c7e99f..ad84fb27 100644 --- a/api/api/v1/apicategory_types.go +++ b/api/api/v1/apicategory_types.go @@ -13,6 +13,44 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// LintingMode controls how linting failures affect API creation. +// +kubebuilder:validation:Enum=block;warn +type LintingMode string + +const ( + // LintingModeBlock prevents Api creation when linting fails. + LintingModeBlock LintingMode = "block" + // LintingModeWarn allows Api creation but surfaces linting issues in status. + LintingModeWarn LintingMode = "warn" +) + +// LintingConfig configures OAS specification linting for APIs in this category. +type LintingConfig struct { + // URL is the base URL of the external linter service. + // When set, linting is enabled for this category. + // +kubebuilder:validation:Format=uri + // +optional + URL string `json:"url,omitempty"` + + // Ruleset is the name of the linter ruleset to apply. + // If set, it is passed as a query parameter to the linter API. + // +optional + Ruleset string `json:"ruleset,omitempty"` + + // Mode controls how linting failures affect API creation. + // "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. + // +kubebuilder:validation:Enum=block;warn + // +kubebuilder:default:=block + // +optional + Mode LintingMode `json:"mode,omitempty"` + + // WhitelistedBasepaths is a list of API basepaths that are exempt from linting. + // APIs whose basePath matches an entry here will skip linting even when a linter URL is configured. + // +optional + // +listType=set + WhitelistedBasepaths []string `json:"whitelistedBasepaths,omitempty"` +} + // ApiCategorySpec defines the desired state of ApiCategory type ApiCategorySpec struct { // LabelValue is the name of the API category in the specification. @@ -38,6 +76,11 @@ type ApiCategorySpec struct { // the name of the group in the basePath. // +kubebuilder:default:=true MustHaveGroupPrefix bool `json:"mustHaveGroupPrefix,omitempty"` + + // Linting configures OAS specification linting for APIs in this category. + // If set with a URL, linting is enabled for this category. + // +optional + Linting *LintingConfig `json:"linting,omitempty"` } type AllowTeamsConfig struct { diff --git a/api/api/v1/zz_generated.deepcopy.go b/api/api/v1/zz_generated.deepcopy.go index d8084b07..db631489 100644 --- a/api/api/v1/zz_generated.deepcopy.go +++ b/api/api/v1/zz_generated.deepcopy.go @@ -133,6 +133,11 @@ func (in *ApiCategorySpec) DeepCopyInto(out *ApiCategorySpec) { *out = new(AllowTeamsConfig) (*in).DeepCopyInto(*out) } + if in.Linting != nil { + in, out := &in.Linting, &out.Linting + *out = new(LintingConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiCategorySpec. @@ -660,6 +665,26 @@ func (in *Limits) DeepCopy() *Limits { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LintingConfig) DeepCopyInto(out *LintingConfig) { + *out = *in + if in.WhitelistedBasepaths != nil { + in, out := &in.WhitelistedBasepaths, &out.WhitelistedBasepaths + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LintingConfig. +func (in *LintingConfig) DeepCopy() *LintingConfig { + if in == nil { + return nil + } + out := new(LintingConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Machine2MachineAuthentication) DeepCopyInto(out *Machine2MachineAuthentication) { *out = *in diff --git a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml index 2d1acb34..ae0d4280 100644 --- a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml +++ b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml @@ -79,6 +79,39 @@ spec: maxLength: 20 minLength: 1 type: string + linting: + description: |- + Linting configures OAS specification linting for APIs in this category. + If set with a URL, linting is enabled for this category. + properties: + mode: + allOf: + - enum: + - block + - warn + - enum: + - block + - warn + default: block + description: |- + Mode controls how linting failures affect API creation. + "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. + type: string + url: + description: |- + URL is the base URL of the external linter service. + When set, linting is enabled for this category. + format: uri + type: string + whitelistedBasepaths: + description: |- + WhitelistedBasepaths is a list of API basepaths that are exempt from linting. + APIs whose basePath matches an entry here will skip linting even when a linter URL is configured. + items: + type: string + type: array + x-kubernetes-list-type: set + type: object mustHaveGroupPrefix: default: true description: |- From 843e7da3468a427aa2dcfe5204415c871af285cd Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 29 Apr 2026 22:28:33 +0200 Subject: [PATCH 07/42] feat(api): add LintingModeNone and ruleset description to ApiCategory spec Co-authored-by: Copilot --- api/api/v1/apicategory_types.go | 2 ++ api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/api/api/v1/apicategory_types.go b/api/api/v1/apicategory_types.go index ad84fb27..99726382 100644 --- a/api/api/v1/apicategory_types.go +++ b/api/api/v1/apicategory_types.go @@ -22,6 +22,8 @@ const ( LintingModeBlock LintingMode = "block" // LintingModeWarn allows Api creation but surfaces linting issues in status. LintingModeWarn LintingMode = "warn" + // LintingModeNone indicates that no linting is configured for this category. + LintingModeNone LintingMode = "none" ) // LintingConfig configures OAS specification linting for APIs in this category. diff --git a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml index ae0d4280..c146e441 100644 --- a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml +++ b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml @@ -97,6 +97,11 @@ spec: Mode controls how linting failures affect API creation. "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. type: string + ruleset: + description: |- + Ruleset is the name of the linter ruleset to apply. + If set, it is passed as a query parameter to the linter API. + type: string url: description: |- URL is the base URL of the external linter service. From 0fe4516856d8a570709f312ec717e4bf7dd8df52 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 29 Apr 2026 22:30:31 +0200 Subject: [PATCH 08/42] feat(api): enhance ApiSpecification with linting results and update handler logic Co-authored-by: Copilot --- rover/api/v1/apispecification_types.go | 55 ++-- rover/api/v1/zz_generated.deepcopy.go | 25 +- ...er.cp.ei.telekom.de_apispecifications.yaml | 51 ++-- .../controller/apispecification_controller.go | 15 +- .../handler/apispecification/handler.go | 78 +++-- .../handler/apispecification/handler_test.go | 272 +++++++++++------- 6 files changed, 277 insertions(+), 219 deletions(-) diff --git a/rover/api/v1/apispecification_types.go b/rover/api/v1/apispecification_types.go index a3da7b6e..f5ff36c9 100644 --- a/rover/api/v1/apispecification_types.go +++ b/rover/api/v1/apispecification_types.go @@ -63,6 +63,24 @@ type ApiSpecificationSpec struct { // Oauth2Scopes contains the OAuth2 scopes extracted from security definitions/schemes // +kubebuilder:validation:Optional Oauth2Scopes []string `json:"scopes,omitempty"` + + // Lint contains the result of the OAS linting performed by rover-server. + // +kubebuilder:validation:Optional + Lint *LintResult `json:"lint,omitempty"` +} + +// LintResult holds the outcome of an external OAS linting scan. +type LintResult struct { + // Passed indicates whether the spec passed linting. + Passed bool `json:"passed"` + + // Message is a human-readable description of the lint outcome. + // +optional + Message string `json:"message,omitempty"` + + // DashboardURL is a direct link to the linter dashboard for this scan. + // +optional + DashboardURL string `json:"dashboardUrl,omitempty"` } type ApiSpecificationStatus struct { @@ -75,43 +93,6 @@ type ApiSpecificationStatus struct { // API reference Api types.ObjectRef `json:"api,omitempty"` - - // LintedHash is the spec hash that was last linted. Compared with Spec.Hash to avoid re-linting. - // +optional - LintedHash string `json:"lintedHash,omitempty"` - - // LintPassed indicates whether the last lint passed. nil means not yet linted. - // +optional - LintPassed *bool `json:"lintPassed,omitempty"` - - // LintReason is a human-readable message describing the lint outcome. - // +optional - LintReason string `json:"lintReason,omitempty"` - - // LinterId is the scan ID returned by the external linter API. - // +optional - LinterId string `json:"linterId,omitempty"` - - // LintRuleset is the name of the ruleset used for linting. - // +optional - LintRuleset string `json:"lintRuleset,omitempty"` - - // LintLinterVersion is the version of the linter engine. - // +optional - LintLinterVersion string `json:"lintLinterVersion,omitempty"` - - // LintErrors is the number of errors found during linting. - // +optional - LintErrors int `json:"lintErrors,omitempty"` - - // LintWarnings is the number of warnings found during linting. - // +optional - LintWarnings int `json:"lintWarnings,omitempty"` - - // LintDashboardURL is a direct link to the linter dashboard for this scan. - // Populated from the zone's DashboardURLTemplate with the scan ID substituted. - // +optional - LintDashboardURL string `json:"lintDashboardUrl,omitempty"` } //+kubebuilder:object:root=true diff --git a/rover/api/v1/zz_generated.deepcopy.go b/rover/api/v1/zz_generated.deepcopy.go index 5762ac26..d6d02993 100644 --- a/rover/api/v1/zz_generated.deepcopy.go +++ b/rover/api/v1/zz_generated.deepcopy.go @@ -118,6 +118,11 @@ func (in *ApiSpecificationSpec) DeepCopyInto(out *ApiSpecificationSpec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Lint != nil { + in, out := &in.Lint, &out.Lint + *out = new(LintResult) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiSpecificationSpec. @@ -141,11 +146,6 @@ func (in *ApiSpecificationStatus) DeepCopyInto(out *ApiSpecificationStatus) { } } in.Api.DeepCopyInto(&out.Api) - if in.LintPassed != nil { - in, out := &in.LintPassed, &out.LintPassed - *out = new(bool) - **out = **in - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiSpecificationStatus. @@ -690,6 +690,21 @@ func (in *Limits) DeepCopy() *Limits { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LintResult) DeepCopyInto(out *LintResult) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LintResult. +func (in *LintResult) DeepCopy() *LintResult { + if in == nil { + return nil + } + out := new(LintResult) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LoadBalancing) DeepCopyInto(out *LoadBalancing) { *out = *in diff --git a/rover/config/crd/bases/rover.cp.ei.telekom.de_apispecifications.yaml b/rover/config/crd/bases/rover.cp.ei.telekom.de_apispecifications.yaml index 72ad260f..5a88cfe5 100644 --- a/rover/config/crd/bases/rover.cp.ei.telekom.de_apispecifications.yaml +++ b/rover/config/crd/bases/rover.cp.ei.telekom.de_apispecifications.yaml @@ -57,6 +57,24 @@ spec: description: Hash is the SHA-256 hash of the specification content for integrity verification type: string + lint: + description: Lint contains the result of the OAS linting performed + by rover-server. + properties: + dashboardUrl: + description: DashboardURL is a direct link to the linter dashboard + for this scan. + type: string + message: + description: Message is a human-readable description of the lint + outcome. + type: string + passed: + description: Passed indicates whether the spec passed linting. + type: boolean + required: + - passed + type: object scopes: description: Oauth2Scopes contains the OAuth2 scopes extracted from security definitions/schemes @@ -162,39 +180,6 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map - lintDashboardUrl: - description: |- - LintDashboardURL is a direct link to the linter dashboard for this scan. - Populated from the zone's DashboardURLTemplate with the scan ID substituted. - type: string - lintErrors: - description: LintErrors is the number of errors found during linting. - type: integer - lintLinterVersion: - description: LintLinterVersion is the version of the linter engine. - type: string - lintPassed: - description: LintPassed indicates whether the last lint passed. nil - means not yet linted. - type: boolean - lintReason: - description: LintReason is a human-readable message describing the - lint outcome. - type: string - lintRuleset: - description: LintRuleset is the name of the ruleset used for linting. - type: string - lintWarnings: - description: LintWarnings is the number of warnings found during linting. - type: integer - lintedHash: - description: LintedHash is the spec hash that was last linted. Compared - with Spec.Hash to avoid re-linting. - type: string - linterId: - description: LinterId is the scan ID returned by the external linter - API. - type: string type: object type: object x-kubernetes-preserve-unknown-fields: true diff --git a/rover/internal/controller/apispecification_controller.go b/rover/internal/controller/apispecification_controller.go index 41cc8826..6fb02a69 100644 --- a/rover/internal/controller/apispecification_controller.go +++ b/rover/internal/controller/apispecification_controller.go @@ -17,7 +17,6 @@ import ( apispec_handler "github.com/telekom/controlplane/rover/internal/handler/apispecification" - adminv1 "github.com/telekom/controlplane/admin/api/v1" apiapi "github.com/telekom/controlplane/api/api/v1" cclient "github.com/telekom/controlplane/common/pkg/client" rover "github.com/telekom/controlplane/rover/api/v1" @@ -49,11 +48,17 @@ func (r *ApiSpecificationReconciler) SetupWithManager(mgr ctrl.Manager) error { r.Recorder = mgr.GetEventRecorderFor("apispecification-controller") h := &apispec_handler.ApiSpecificationHandler{ - ListZones: func(ctx context.Context, environment string) (*adminv1.ZoneList, error) { + GetApiCategory: func(ctx context.Context, category string) (*apiapi.ApiCategory, error) { c := cclient.ClientFromContextOrDie(ctx) - list := &adminv1.ZoneList{} - err := c.List(ctx, list, client.InNamespace(environment)) - return list, err + list := &apiapi.ApiCategoryList{} + if err := c.List(ctx, list); err != nil { + return nil, err + } + found, ok := list.FindByLabelValue(category) + if !ok { + return nil, nil + } + return found, nil }, } diff --git a/rover/internal/handler/apispecification/handler.go b/rover/internal/handler/apispecification/handler.go index 5433d7c0..855d6492 100644 --- a/rover/internal/handler/apispecification/handler.go +++ b/rover/internal/handler/apispecification/handler.go @@ -10,11 +10,9 @@ import ( "github.com/go-logr/logr" "github.com/pkg/errors" - adminv1 "github.com/telekom/controlplane/admin/api/v1" apiapi "github.com/telekom/controlplane/api/api/v1" "github.com/telekom/controlplane/common/pkg/client" "github.com/telekom/controlplane/common/pkg/condition" - "github.com/telekom/controlplane/common/pkg/config" "github.com/telekom/controlplane/common/pkg/handler" "github.com/telekom/controlplane/common/pkg/types" "github.com/telekom/controlplane/common/pkg/util/labelutil" @@ -29,41 +27,44 @@ var _ handler.Handler[*roverv1.ApiSpecification] = (*ApiSpecificationHandler)(ni // Linting is performed by rover-server at upload time and stored in the CRD status fields. // This handler reads the lint result and gates Api resource creation accordingly. type ApiSpecificationHandler struct { - ListZones func(ctx context.Context, environment string) (*adminv1.ZoneList, error) + GetApiCategory func(ctx context.Context, category string) (*apiapi.ApiCategory, error) } func (h *ApiSpecificationHandler) CreateOrUpdate(ctx context.Context, apiSpec *roverv1.ApiSpecification) error { log := logr.FromContextOrDiscard(ctx) - // Linting is pending (async) — set processing condition and wait for the result. - if apiSpec.Status.LintPassed == nil && apiSpec.Status.LintReason == "Linting in progress" { - apiSpec.SetCondition(condition.NewProcessingCondition("LintingPending", - "OAS linting is in progress, waiting for result")) - apiSpec.SetCondition(condition.NewNotReadyCondition("LintingPending", - "API specification is being linted")) - log.V(0).Info("Linting in progress, waiting for result") - return nil + // Linting is pending (async) — Spec.Lint is nil, wait for rover-server to fill it in. + if apiSpec.Spec.Lint == nil { + mode := h.lookupLintingMode(ctx, apiSpec.Spec.Category) + if mode == apiapi.LintingModeNone { + // No linting configured for this category — proceed normally. + return h.createOrUpdateApi(ctx, apiSpec) + } + if mode == apiapi.LintingModeBlock { + apiSpec.SetCondition(condition.NewProcessingCondition("LintingPending", + "OAS linting is in progress, waiting for result")) + apiSpec.SetCondition(condition.NewNotReadyCondition("LintingPending", + "API specification is being linted")) + return nil + } + // warn mode: proceed without waiting for lint result + log.V(0).Info("Linting pending in warn mode, proceeding with Api creation") + return h.createOrUpdateApi(ctx, apiSpec) } - // Check if linting failed and the zone config blocks on failure - if apiSpec.Status.LintPassed != nil && !*apiSpec.Status.LintPassed { - environment := apiSpec.Labels[config.EnvironmentLabelKey] - mode := h.lookupLintingMode(ctx, environment) - if mode == adminv1.LintingModeBlock { - msg := fmt.Sprintf("OAS linting failed: %s", apiSpec.Status.LintReason) - if apiSpec.Status.LintDashboardURL != "" { - msg = fmt.Sprintf("%s. View details: %s", msg, apiSpec.Status.LintDashboardURL) + // Check if linting failed and the category config blocks on failure + if !apiSpec.Spec.Lint.Passed { + mode := h.lookupLintingMode(ctx, apiSpec.Spec.Category) + if mode == apiapi.LintingModeBlock { + msg := fmt.Sprintf("OAS linting failed: %s", apiSpec.Spec.Lint.Message) + if apiSpec.Spec.Lint.DashboardURL != "" { + msg = fmt.Sprintf("%s. View details: %s", msg, apiSpec.Spec.Lint.DashboardURL) } apiSpec.SetCondition(condition.NewBlockedCondition(msg)) apiSpec.SetCondition(condition.NewNotReadyCondition("LintingFailed", "API specification did not pass linting")) - log.V(0).Info("Linting failed in block mode, skipping Api creation", - "reason", apiSpec.Status.LintReason, "errors", apiSpec.Status.LintErrors) return nil } - // warn mode: log and continue - log.V(0).Info("Linting failed in warn mode, proceeding with Api creation", - "reason", apiSpec.Status.LintReason, "errors", apiSpec.Status.LintErrors) } return h.createOrUpdateApi(ctx, apiSpec) @@ -73,27 +74,20 @@ func (h *ApiSpecificationHandler) Delete(_ context.Context, _ *roverv1.ApiSpecif return nil } -// lookupLintingMode finds the Zone in the environment and returns the effective linting mode. -// Defaults to LintingModeBlock if any zone has linting enabled but no explicit mode. -func (h *ApiSpecificationHandler) lookupLintingMode(ctx context.Context, environment string) adminv1.LintingMode { - if h.ListZones == nil { - return adminv1.LintingModeBlock +// lookupLintingMode finds the ApiCategory and returns the effective linting mode. +func (h *ApiSpecificationHandler) lookupLintingMode(ctx context.Context, category string) apiapi.LintingMode { + if h.GetApiCategory == nil { + return apiapi.LintingModeNone } - zones, err := h.ListZones(ctx, environment) - if err != nil || zones == nil { - return adminv1.LintingModeBlock + cat, err := h.GetApiCategory(ctx, category) + if err != nil || cat == nil || cat.Spec.Linting == nil { + return apiapi.LintingModeNone } - for i := range zones.Items { - zone := &zones.Items[i] - if zone.Spec.Linting != nil && zone.Spec.Linting.Enabled { - mode := zone.Spec.Linting.Mode - if mode == "" { - mode = adminv1.LintingModeBlock - } - return mode - } + mode := cat.Spec.Linting.Mode + if mode == "" { + mode = apiapi.LintingModeBlock } - return adminv1.LintingModeBlock + return mode } // createOrUpdateApi contains the Api resource creation logic. diff --git a/rover/internal/handler/apispecification/handler_test.go b/rover/internal/handler/apispecification/handler_test.go index aec279ce..c587fc17 100644 --- a/rover/internal/handler/apispecification/handler_test.go +++ b/rover/internal/handler/apispecification/handler_test.go @@ -6,10 +6,12 @@ package apispecification_test import ( "context" + "fmt" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - adminv1 "github.com/telekom/controlplane/admin/api/v1" + apiapi "github.com/telekom/controlplane/api/api/v1" + "github.com/telekom/controlplane/common/pkg/condition" roverv1 "github.com/telekom/controlplane/rover/api/v1" handler "github.com/telekom/controlplane/rover/internal/handler/apispecification" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -32,152 +34,228 @@ func newApiSpec(hash, category string) *roverv1.ApiSpecification { } } -func newZone(name string, linting *adminv1.LintingConfig) *adminv1.Zone { - return &adminv1.Zone{ +func newApiCategory(name string, linting *apiapi.LintingConfig) *apiapi.ApiCategory { + return &apiapi.ApiCategory{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "test-env", + Labels: map[string]string{ + "controlplane.2/label": name, + }, }, - Spec: adminv1.ZoneSpec{ + Spec: apiapi.ApiCategorySpec{ Linting: linting, }, } } -func listZonesWith(zones ...*adminv1.Zone) func(context.Context, string) (*adminv1.ZoneList, error) { - return func(_ context.Context, _ string) (*adminv1.ZoneList, error) { - items := make([]adminv1.Zone, len(zones)) - for i, z := range zones { - items[i] = *z +func getApiCategoryWith(cat *apiapi.ApiCategory) func(context.Context, string) (*apiapi.ApiCategory, error) { + return func(_ context.Context, _ string) (*apiapi.ApiCategory, error) { + return cat, nil + } +} + +func getApiCategoryNil() func(context.Context, string) (*apiapi.ApiCategory, error) { + return func(_ context.Context, _ string) (*apiapi.ApiCategory, error) { + return nil, nil + } +} + +func getApiCategoryError() func(context.Context, string) (*apiapi.ApiCategory, error) { + return func(_ context.Context, _ string) (*apiapi.ApiCategory, error) { + return nil, fmt.Errorf("api category lookup failed") + } +} + +func hasCondition(apiSpec *roverv1.ApiSpecification, condType string) bool { + for _, c := range apiSpec.GetConditions() { + if c.Type == condType { + return true + } + } + return false +} + +func conditionMessage(apiSpec *roverv1.ApiSpecification, condType string) string { + for _, c := range apiSpec.GetConditions() { + if c.Type == condType { + return c.Message } - return &adminv1.ZoneList{Items: items}, nil } + return "" } var _ = Describe("ApiSpecification Handler Linting Gate", func() { + var ctx context.Context - Context("when LintPassed is nil (no linting performed)", func() { - It("should not block", func() { - h := &handler.ApiSpecificationHandler{} - apiSpec := newApiSpec("hash1", "other") - Expect(apiSpec.Status.LintPassed).To(BeNil()) - _ = h - }) + BeforeEach(func() { + ctx = context.Background() }) - Context("when linting is in progress (async pending)", func() { - It("should have nil LintPassed with pending reason", func() { + Context("when linting is pending (Spec.Lint nil, block mode)", func() { + It("should set processing and not-ready conditions", func() { + h := &handler.ApiSpecificationHandler{ + GetApiCategory: getApiCategoryWith(newApiCategory("other", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeBlock, + })), + } apiSpec := newApiSpec("hash1", "other") - apiSpec.Status.LintPassed = nil - apiSpec.Status.LintReason = "Linting in progress" - Expect(apiSpec.Status.LintPassed).To(BeNil()) - Expect(apiSpec.Status.LintReason).To(Equal("Linting in progress")) + // Spec.Lint is nil — linting pending + + err := h.CreateOrUpdate(ctx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(hasCondition(apiSpec, condition.ConditionTypeReady)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("linting is in progress")) + Expect(conditionMessage(apiSpec, condition.ConditionTypeReady)).To(ContainSubstring("being linted")) }) }) - Context("when linting passed", func() { - It("should not set blocked condition", func() { - apiSpec := newApiSpec("hash1", "other") - passed := true - apiSpec.Status.LintPassed = &passed - apiSpec.Status.LintReason = "no errors" - Expect(*apiSpec.Status.LintPassed).To(BeTrue()) + Context("when linting is pending (Spec.Lint nil, warn mode)", func() { + It("should proceed with Api creation", func() { + h := &handler.ApiSpecificationHandler{ + GetApiCategory: getApiCategoryWith(newApiCategory("warn-cat", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeWarn, + })), + } + apiSpec := newApiSpec("hash1", "warn-cat") + // Spec.Lint is nil — linting pending, but warn mode proceeds + + Expect(func() { + _ = h.CreateOrUpdate(ctx, apiSpec) + }).To(Panic()) + // Panicked in createOrUpdateApi means the linting gate did not block. + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse()) }) }) Context("when linting failed in block mode", func() { - It("should have failing lint status", func() { + It("should set blocked condition with explicit block mode", func() { + h := &handler.ApiSpecificationHandler{ + GetApiCategory: getApiCategoryWith(newApiCategory("strict-cat", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeBlock, + })), + } apiSpec := newApiSpec("hash1", "strict-cat") - passed := false - apiSpec.Status.LintPassed = &passed - apiSpec.Status.LintReason = "found 3 errors" - apiSpec.Status.LintErrors = 3 - apiSpec.Status.LintWarnings = 1 - Expect(*apiSpec.Status.LintPassed).To(BeFalse()) - Expect(apiSpec.Status.LintErrors).To(Equal(3)) + apiSpec.Spec.Lint = &roverv1.LintResult{Passed: false, Message: "found 3 errors"} + + err := h.CreateOrUpdate(ctx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("found 3 errors")) }) - }) - Context("when linting failed with dashboard URL", func() { - It("should include the dashboard URL in the status", func() { + It("should set blocked condition with dashboard URL", func() { + h := &handler.ApiSpecificationHandler{ + GetApiCategory: getApiCategoryWith(newApiCategory("strict-cat", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeBlock, + })), + } apiSpec := newApiSpec("hash1", "strict-cat") - passed := false - apiSpec.Status.LintPassed = &passed - apiSpec.Status.LintReason = "found 3 errors" - apiSpec.Status.LintErrors = 3 - apiSpec.Status.LintDashboardURL = "https://linter.example.com/scans/scan-123" - Expect(apiSpec.Status.LintDashboardURL).To(Equal("https://linter.example.com/scans/scan-123")) - }) - }) + apiSpec.Spec.Lint = &roverv1.LintResult{ + Passed: false, + Message: "found 3 errors", + DashboardURL: "https://linter.example.com/scans/scan-123", + } - Context("Zone with whitelisted categories", func() { - It("should support whitelisted categories in zone config", func() { - zone := newZone("dp1", &adminv1.LintingConfig{ - Enabled: true, - Mode: adminv1.LintingModeBlock, - WhitelistedCategories: []string{"internal", "legacy"}, - }) - Expect(zone.Spec.Linting.WhitelistedCategories).To(ContainElement("internal")) - Expect(zone.Spec.Linting.WhitelistedCategories).To(ContainElement("legacy")) + err := h.CreateOrUpdate(ctx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("View details")) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("scan-123")) }) - It("should support dashboard URL template in zone config", func() { - zone := newZone("dp1", &adminv1.LintingConfig{ - Enabled: true, - DashboardURLTemplate: "https://linter.example.com/scans/{linterId}", - }) - Expect(zone.Spec.Linting.DashboardURLTemplate).To(Equal("https://linter.example.com/scans/{linterId}")) + It("should default to block mode when linting mode is empty string", func() { + h := &handler.ApiSpecificationHandler{ + GetApiCategory: getApiCategoryWith(newApiCategory("test-cat", &apiapi.LintingConfig{ + Mode: "", + })), + } + apiSpec := newApiSpec("hash1", "test-cat") + apiSpec.Spec.Lint = &roverv1.LintResult{Passed: false, Message: "found errors"} + + err := h.CreateOrUpdate(ctx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) }) }) - Context("LintingMode behavior on Zone", func() { - It("should default to block mode when mode is empty", func() { - zone := newZone("dp1", &adminv1.LintingConfig{ - Enabled: true, - }) - Expect(zone.Spec.Linting.Mode).To(Equal(adminv1.LintingMode(""))) - }) + Context("when linting failed in warn mode", func() { + It("should not set blocked condition", func() { + h := &handler.ApiSpecificationHandler{ + GetApiCategory: getApiCategoryWith(newApiCategory("warn-cat", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeWarn, + })), + } + apiSpec := newApiSpec("hash1", "warn-cat") + apiSpec.Spec.Lint = &roverv1.LintResult{Passed: false, Message: "found 2 warnings"} - It("should support warn mode", func() { - zone := newZone("dp1", &adminv1.LintingConfig{ - Enabled: true, - Mode: adminv1.LintingModeWarn, - }) - Expect(zone.Spec.Linting.Mode).To(Equal(adminv1.LintingModeWarn)) + // CreateOrUpdate will proceed to createOrUpdateApi which requires a k8s client; + // we expect it to panic or error there, but the linting gate should NOT block. + Expect(func() { + _ = h.CreateOrUpdate(ctx, apiSpec) + }).To(Panic()) + // If we got here (panicked in createOrUpdateApi), it means the linting gate passed through. + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse(), + "should not have a blocked/processing condition in warn mode") }) + }) - It("should support block mode explicitly", func() { - zone := newZone("dp1", &adminv1.LintingConfig{ - Enabled: true, - Mode: adminv1.LintingModeBlock, - }) - Expect(zone.Spec.Linting.Mode).To(Equal(adminv1.LintingModeBlock)) + Context("when linting passed", func() { + It("should proceed past linting gate", func() { + h := &handler.ApiSpecificationHandler{} + apiSpec := newApiSpec("hash1", "other") + apiSpec.Spec.Lint = &roverv1.LintResult{Passed: true, Message: "no errors"} + + // Will proceed to createOrUpdateApi -> panic on missing k8s client + Expect(func() { + _ = h.CreateOrUpdate(ctx, apiSpec) + }).To(Panic()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse()) }) }) - Context("lookupLintingMode via Zone", func() { - It("should return block mode when ListZones is nil", func() { + Context("when no linting is configured (Spec.Lint nil, no category linting)", func() { + It("should proceed when GetApiCategory is nil", func() { h := &handler.ApiSpecificationHandler{} - _ = h + apiSpec := newApiSpec("hash1", "other") + + Expect(func() { + _ = h.CreateOrUpdate(ctx, apiSpec) + }).To(Panic()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse()) }) - It("should return zone linting mode", func() { - zone := newZone("dp1", &adminv1.LintingConfig{ - Enabled: true, - Mode: adminv1.LintingModeWarn, - }) + It("should proceed when category has no linting config", func() { h := &handler.ApiSpecificationHandler{ - ListZones: listZonesWith(zone), + GetApiCategory: getApiCategoryNil(), } - _ = h + apiSpec := newApiSpec("hash1", "other") + + Expect(func() { + _ = h.CreateOrUpdate(ctx, apiSpec) + }).To(Panic()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse()) }) - It("should skip zones without linting config", func() { - zone := newZone("dp1", nil) + It("should proceed when category lookup returns error", func() { h := &handler.ApiSpecificationHandler{ - ListZones: listZonesWith(zone), + GetApiCategory: getApiCategoryError(), } - _ = h + apiSpec := newApiSpec("hash1", "other") + + Expect(func() { + _ = h.CreateOrUpdate(ctx, apiSpec) + }).To(Panic()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse()) + }) + }) + + Context("Delete", func() { + It("should return nil", func() { + h := &handler.ApiSpecificationHandler{} + err := h.Delete(ctx, newApiSpec("hash1", "other")) + Expect(err).ToNot(HaveOccurred()) }) }) }) From 799f51e50be5144a047e8c2cdfbfd311d5ac2fb3 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 29 Apr 2026 22:37:47 +0200 Subject: [PATCH 09/42] feat(api): refactor OAS linting integration and enhance linting configuration handling Co-authored-by: Copilot --- rover-server/cmd/main.go | 26 +-- rover-server/config/rbac/role.yaml | 1 + rover-server/internal/config/config.go | 11 +- .../internal/controller/apispecification.go | 207 +++-------------- .../controller/apispecification_lint.go | 145 ++++++++++++ .../controller/apispecification_lint_test.go | 208 ++++++++++++++++++ .../controller/suite_controller_test.go | 2 +- .../__snapshots__/apispecification_test.snap | 1 + rover-server/internal/oaslint/external.go | 47 ++-- .../internal/oaslint/external_test.go | 3 - rover-server/internal/oaslint/linter.go | 15 +- 11 files changed, 428 insertions(+), 238 deletions(-) create mode 100644 rover-server/internal/controller/apispecification_lint.go create mode 100644 rover-server/internal/controller/apispecification_lint_test.go diff --git a/rover-server/cmd/main.go b/rover-server/cmd/main.go index 91a7431b..99498e48 100644 --- a/rover-server/cmd/main.go +++ b/rover-server/cmd/main.go @@ -9,7 +9,6 @@ import ( "net/http" "os" "os/signal" - "strings" "syscall" "time" @@ -22,7 +21,6 @@ import ( "github.com/telekom/controlplane/rover-server/internal/config" "github.com/telekom/controlplane/rover-server/internal/controller" - "github.com/telekom/controlplane/rover-server/internal/oaslint" "github.com/telekom/controlplane/rover-server/internal/server" "github.com/telekom/controlplane/rover-server/pkg/log" "github.com/telekom/controlplane/rover-server/pkg/store" @@ -50,19 +48,10 @@ func main() { filesapi.WithSkipTLSVerify(cfg.FileManager.SkipTLS), ) - var linter oaslint.Linter - if cfg.OasLinting.URL != "" { - log.Log.Info("OAS linting enabled", "url", cfg.OasLinting.URL) - linter = oaslint.NewExternalLinter(cfg.OasLinting.URL) - } - - whitelistedBasepaths := parseDelimitedSet(cfg.OasLinting.WhitelistedBasepaths, ";") - whitelistedCategories := parseDelimitedSet(cfg.OasLinting.WhitelistedCategories, ";") - s := server.Server{ Config: cfg, Log: log.Log, - ApiSpecifications: controller.NewApiSpecificationController(stores, linter, whitelistedBasepaths, whitelistedCategories, cfg.OasLinting.ErrorMessage), + ApiSpecifications: controller.NewApiSpecificationController(stores, cfg.OasLinting.ErrorMessage, cfg.OasLinting.Timeout), Rovers: controller.NewRoverController(stores), EventSpecifications: controller.NewEventSpecificationController(stores), } @@ -90,16 +79,3 @@ func main() { log.Log.Info("Server gracefully stopped") } - -// parseDelimitedSet parses a delimited string into a set. -// Empty entries are ignored. Categories are lowercased for case-insensitive matching. -func parseDelimitedSet(s, delimiter string) map[string]struct{} { - result := make(map[string]struct{}) - for _, v := range strings.Split(s, delimiter) { - v = strings.TrimSpace(v) - if v != "" { - result[strings.ToLower(v)] = struct{}{} - } - } - return result -} diff --git a/rover-server/config/rbac/role.yaml b/rover-server/config/rbac/role.yaml index b523efd2..b5082357 100644 --- a/rover-server/config/rbac/role.yaml +++ b/rover-server/config/rbac/role.yaml @@ -18,6 +18,7 @@ rules: - apiGroups: - api.cp.ei.telekom.de resources: + - apicategories - apiexposures - apis - apisubscriptions diff --git a/rover-server/internal/config/config.go b/rover-server/internal/config/config.go index d5222fea..e397c748 100644 --- a/rover-server/internal/config/config.go +++ b/rover-server/internal/config/config.go @@ -6,6 +6,7 @@ package config import ( "strings" + "time" "github.com/pkg/errors" "github.com/spf13/viper" @@ -20,10 +21,8 @@ type ServerConfig struct { } type OasLintingConfig struct { - URL string `json:"url"` - ErrorMessage string `json:"errorMessage"` - WhitelistedBasepaths string `json:"whitelistedBasepaths"` - WhitelistedCategories string `json:"whitelistedCategories"` + ErrorMessage string `json:"errorMessage"` + Timeout time.Duration `json:"timeout"` } type SecurityConfig struct { @@ -81,10 +80,8 @@ func setDefaults() { viper.SetDefault("fileManager.skipTLS", true) // OAS Linting - viper.SetDefault("oasLinting.url", "") viper.SetDefault("oasLinting.errorMessage", "Linter scan result contains errors. Please visit the linter UI for details on the RULESET_NAME_PLACEHOLDER ruleset.") - viper.SetDefault("oasLinting.whitelistedBasepaths", "") - viper.SetDefault("oasLinting.whitelistedCategories", "") + viper.SetDefault("oasLinting.timeout", 55*time.Second) // Database viper.SetDefault("database.filepath", "") // empty string means in-memory only diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 100109c8..8cbfe6a8 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -12,18 +12,17 @@ import ( "fmt" "io" "strings" + "time" + "github.com/go-logr/logr" "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/log" "github.com/pkg/errors" - adminv1 "github.com/telekom/controlplane/admin/api/v1" apiv1 "github.com/telekom/controlplane/api/api/v1" "github.com/telekom/controlplane/common-server/pkg/problems" "github.com/telekom/controlplane/common-server/pkg/server/middleware/security" "github.com/telekom/controlplane/common-server/pkg/store" filesapi "github.com/telekom/controlplane/file-manager/api" "github.com/telekom/controlplane/rover-server/internal/file" - "github.com/telekom/controlplane/rover-server/internal/oaslint" roverv1 "github.com/telekom/controlplane/rover/api/v1" "gopkg.in/yaml.v3" @@ -39,30 +38,26 @@ import ( var _ server.ApiSpecificationController = &ApiSpecificationController{} type ApiSpecificationController struct { - stores *s.Stores - Store store.ObjectStore[*roverv1.ApiSpecification] - ZoneStore store.ObjectStore[*adminv1.Zone] - Linter oaslint.Linter + stores *s.Stores + Store store.ObjectStore[*roverv1.ApiSpecification] // ListApiCategories is a function to list all ApiCategories for validation at upload time. // If nil, category validation is skipped. ListApiCategories func(ctx context.Context) (*apiv1.ApiCategoryList, error) - // Whitelists and error message for linting, from config. - WhitelistedBasepaths map[string]struct{} - WhitelistedCategories map[string]struct{} - ErrorMessage string + // ErrorMessage is the template message shown when linting fails. + ErrorMessage string + + // LintTimeout is the HTTP client timeout for external linter calls. + LintTimeout time.Duration } -func NewApiSpecificationController(stores *s.Stores, linter oaslint.Linter, whitelistedBasepaths, whitelistedCategories map[string]struct{}, errorMessage string) *ApiSpecificationController { +func NewApiSpecificationController(stores *s.Stores, errorMessage string, lintTimeout time.Duration) *ApiSpecificationController { ctrl := &ApiSpecificationController{ - stores: stores, - Store: stores.APISpecificationStore, - ZoneStore: stores.ZoneStore, - Linter: linter, - WhitelistedBasepaths: whitelistedBasepaths, - WhitelistedCategories: whitelistedCategories, - ErrorMessage: errorMessage, + stores: stores, + Store: stores.APISpecificationStore, + ErrorMessage: errorMessage, + LintTimeout: lintTimeout, } if stores.APICategoryStore != nil { ctrl.ListApiCategories = func(ctx context.Context) (*apiv1.ApiCategoryList, error) { @@ -86,7 +81,7 @@ func (a *ApiSpecificationController) Create(ctx context.Context, req api.ApiSpec // Important Hint: This is a declarative API. The client should not create an ApiSpecification, but only use // the PUT method. This is similar to how kubernetes works. // The main use case for the rover API will be to enable the usage of roverctl - log.Infof("ApiSpecification: Create not implemented. ApiSpecification is: %+v", req) + logr.FromContextOrDiscard(ctx).Info("ApiSpecification: Create not implemented", "request", req) return api.ApiSpecificationResponse{}, fiber.NewError(fiber.StatusNotImplemented, "Create not implemented") } @@ -241,13 +236,21 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri } EnsureLabelsOrDie(ctx, apiSpec) - // Check if linting can be skipped (whitelist or hash dedup) synchronously. - // If the spec needs actual external linting, we dispatch it asynchronously. - var lintCfg *adminv1.LintingConfig + // Look up the ApiCategory's linting config for this spec's category. + // If the category has a linter URL, proceed with linting. var needsAsyncLint bool - if a.Linter != nil { - lintCfg = a.lookupLintingConfig(ctx, id.Environment) - needsAsyncLint = a.prepareLinting(ctx, lintCfg, apiSpec, specMarshaled) + log := logr.FromContextOrDiscard(ctx) + log.V(1).Info("Looking up linting config", "namespace", apiSpec.Namespace, "name", apiSpec.Name, + "category", apiSpec.Spec.Category, "basepath", apiSpec.Spec.BasePath) + lintCfg := a.lookupLintingConfig(ctx, apiSpec.Spec.Category) + if lintCfg != nil && lintCfg.URL != "" { + log.V(1).Info("Linting config found, checking whitelists and hash dedup", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + // Fetch existing object for hash dedup comparison. + existing, _ := a.Store.Get(ctx, apiSpec.Namespace, apiSpec.Name) + needsAsyncLint = a.prepareLinting(lintCfg, apiSpec, existing) + log.V(1).Info("prepareLinting completed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "needsAsyncLint", needsAsyncLint) + } else { + log.V(1).Info("No linting config or no URL, skipping linting", "namespace", apiSpec.Namespace, "name", apiSpec.Name) } err = a.Store.CreateOrReplace(ctx, apiSpec) @@ -255,9 +258,9 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri return res, err } - // Dispatch async linting if needed. The background goroutine will update the CRD status. + // Dispatch async linting if needed. The background goroutine will update the CRD spec. if needsAsyncLint { - a.dispatchAsyncLint(ctx, apiSpec.Namespace, apiSpec.Name, lintCfg, specMarshaled) + a.dispatchAsyncLint(ctx, apiSpec.Namespace, apiSpec.Name, lintCfg.URL, lintCfg.Ruleset, specMarshaled) } return a.Get(ctx, resourceId) @@ -282,128 +285,6 @@ func (a *ApiSpecificationController) GetStatus(ctx context.Context, resourceId s return status.MapAPISpecificationResponse(ctx, apiSpec, a.stores) } -// prepareLinting checks whitelists and hash dedup synchronously. -// It returns true if an async external linter call is needed. -// If linting is not needed (disabled, whitelisted, or hash unchanged), it updates apiSpec in place and returns false. -func (a *ApiSpecificationController) prepareLinting(_ context.Context, lintCfg *adminv1.LintingConfig, apiSpec *roverv1.ApiSpecification, specBytes []byte) bool { - if a.Linter == nil || lintCfg == nil || !lintCfg.Enabled { - return false - } - - // Check basepath whitelist (controller-config level). - if _, ok := a.WhitelistedBasepaths[apiSpec.Spec.BasePath]; ok { - log.Infof("Basepath %q is whitelisted, skipping linting", apiSpec.Spec.BasePath) - passed := true - apiSpec.Status.LintPassed = &passed - apiSpec.Status.LintReason = fmt.Sprintf("The basepath %q is whitelisted", apiSpec.Spec.BasePath) - return false - } - - // Check category whitelist (controller-config level). - if _, ok := a.WhitelistedCategories[strings.ToLower(apiSpec.Spec.Category)]; ok { - log.Infof("Category %q is whitelisted (controller config), skipping linting", apiSpec.Spec.Category) - passed := true - apiSpec.Status.LintPassed = &passed - apiSpec.Status.LintReason = fmt.Sprintf("The category %q is whitelisted", apiSpec.Spec.Category) - return false - } - - // Check category whitelist (zone-level). - if isCategoryWhitelistedByZone(lintCfg, apiSpec.Spec.Category) { - log.Infof("Category %q is whitelisted (zone config), skipping linting", apiSpec.Spec.Category) - passed := true - apiSpec.Status.LintPassed = &passed - apiSpec.Status.LintReason = fmt.Sprintf("The category %q is whitelisted by zone", apiSpec.Spec.Category) - return false - } - - // Hash dedup: skip re-linting if the spec content has not changed. - specHash := computeHash(specBytes) - if apiSpec.Status.LintedHash == specHash && apiSpec.Status.LintPassed != nil { - log.Infof("Spec hash unchanged (%s), reusing previous lint result (passed=%v)", specHash, *apiSpec.Status.LintPassed) - return false - } - - // Mark as linting pending — the actual call happens asynchronously. - apiSpec.Status.LintPassed = nil - apiSpec.Status.LintReason = "Linting in progress" - apiSpec.Status.LintedHash = "" - return true -} - -// dispatchAsyncLint runs the external linter call in a background goroutine. -// It updates the ApiSpecification CRD status with the lint result when done. -func (a *ApiSpecificationController) dispatchAsyncLint(ctx context.Context, ns, name string, lintCfg *adminv1.LintingConfig, specBytes []byte) { - // Create a detached context so the background work is not cancelled when the HTTP request ends. - bgCtx := context.WithoutCancel(ctx) - go func() { - result, err := a.Linter.Lint(bgCtx, specBytes) - if err != nil { - log.Errorf("Async OAS linting failed for %s/%s: %v", ns, name, err) - a.updateLintStatus(bgCtx, ns, name, lintCfg, &oaslint.LintResult{ - Passed: false, - Reason: fmt.Sprintf("linter API error: %s", err), - }, specBytes) - return - } - - a.updateLintStatus(bgCtx, ns, name, lintCfg, result, specBytes) - }() -} - -// updateLintStatus fetches the current ApiSpecification, updates its lint status fields, and writes it back. -func (a *ApiSpecificationController) updateLintStatus(ctx context.Context, ns, name string, lintCfg *adminv1.LintingConfig, result *oaslint.LintResult, specBytes []byte) { - apiSpec, err := a.Store.Get(ctx, ns, name) - if err != nil { - log.Errorf("Failed to fetch ApiSpecification %s/%s for lint status update: %v", ns, name, err) - return - } - - specHash := computeHash(specBytes) - passed := result.Passed - apiSpec.Status.LintedHash = specHash - apiSpec.Status.LintPassed = &passed - apiSpec.Status.LintReason = result.Reason - apiSpec.Status.LinterId = result.LinterId - apiSpec.Status.LintRuleset = result.Ruleset - apiSpec.Status.LintLinterVersion = result.LinterVersion - apiSpec.Status.LintErrors = result.Errors - apiSpec.Status.LintWarnings = result.Warnings - - // Populate the linter dashboard URL from zone config if available. - if lintCfg != nil && lintCfg.DashboardURLTemplate != "" && result.LinterId != "" { - apiSpec.Status.LintDashboardURL = strings.ReplaceAll(lintCfg.DashboardURLTemplate, "{linterId}", result.LinterId) - } - - if !passed { - message := strings.ReplaceAll(a.ErrorMessage, "RULESET_NAME_PLACEHOLDER", result.Ruleset) - apiSpec.Status.LintReason = message - log.Infof("Async OAS linting failed for %s/%s: %s (errors=%d, warnings=%d)", - ns, name, result.Reason, result.Errors, result.Warnings) - } - - if err := a.Store.CreateOrReplace(ctx, apiSpec); err != nil { - log.Errorf("Failed to update lint status for %s/%s: %v", ns, name, err) - } -} - -// isCategoryWhitelistedByZone checks if the given category is whitelisted in the zone-level linting config. -func isCategoryWhitelistedByZone(lintCfg *adminv1.LintingConfig, category string) bool { - for _, wl := range lintCfg.WhitelistedCategories { - if strings.EqualFold(wl, category) { - return true - } - } - return false -} - -// computeHash returns the base64-encoded SHA-256 hash of the given data. -func computeHash(data []byte) string { - hasher := sha256.New() - hasher.Write(data) - return base64.StdEncoding.EncodeToString(hasher.Sum(nil)) -} - // validateApiCategory validates that the given category is a known and active ApiCategory. // If ListApiCategories is nil, validation is skipped. func (a *ApiSpecificationController) validateApiCategory(ctx context.Context, category string) error { @@ -413,7 +294,7 @@ func (a *ApiSpecificationController) validateApiCategory(ctx context.Context, ca apiCategoryList, err := a.ListApiCategories(ctx) if err != nil { - log.Warnf("Failed to list ApiCategories for validation: %v", err) + logr.FromContextOrDiscard(ctx).Info("Failed to list ApiCategories for validation", "error", err) return nil } @@ -432,30 +313,6 @@ func (a *ApiSpecificationController) validateApiCategory(ctx context.Context, ca return nil } -// lookupLintingConfig finds the linting configuration from the zones in the given environment. -// It returns the first zone's linting config that is enabled. -func (a *ApiSpecificationController) lookupLintingConfig(ctx context.Context, environment string) *adminv1.LintingConfig { - if a.ZoneStore == nil { - return nil - } - - listOpts := store.NewListOpts() - listOpts.Prefix = environment - zoneList, err := a.ZoneStore.List(ctx, listOpts) - if err != nil || zoneList == nil { - return nil - } - - for i := range zoneList.Items { - zone := zoneList.Items[i] - if zone.Spec.Linting != nil && zone.Spec.Linting.Enabled { - return zone.Spec.Linting - } - } - - return nil -} - func (a *ApiSpecificationController) uploadFile(ctx context.Context, specMarshaled []byte, id mapper.ResourceIdInfo) (*filesapi.FileUploadResponse, error) { if len(specMarshaled) == 0 || specMarshaled == nil { return nil, errors.New("input api specification has length 0 or nil") diff --git a/rover-server/internal/controller/apispecification_lint.go b/rover-server/internal/controller/apispecification_lint.go new file mode 100644 index 00000000..3372fe22 --- /dev/null +++ b/rover-server/internal/controller/apispecification_lint.go @@ -0,0 +1,145 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + "strings" + + "github.com/go-logr/logr" + apiv1 "github.com/telekom/controlplane/api/api/v1" + "github.com/telekom/controlplane/common-server/pkg/store" + "github.com/telekom/controlplane/rover-server/internal/oaslint" + pkglog "github.com/telekom/controlplane/rover-server/pkg/log" + roverv1 "github.com/telekom/controlplane/rover/api/v1" +) + +// prepareLinting checks whitelists and hash dedup synchronously. +// It returns true if an async external linter call is needed. +func (a *ApiSpecificationController) prepareLinting(lintCfg *apiv1.LintingConfig, apiSpec *roverv1.ApiSpecification, existing *roverv1.ApiSpecification) bool { + log := pkglog.Log.WithName("linting") + + // Check basepath whitelist (category-level). + if isBasepathWhitelisted(lintCfg, apiSpec.Spec.BasePath) { + log.Info("Basepath is whitelisted, skipping linting", "basepath", apiSpec.Spec.BasePath) + apiSpec.Spec.Lint = &roverv1.LintResult{Passed: true, Message: fmt.Sprintf("The basepath %q is whitelisted", apiSpec.Spec.BasePath)} + return false + } + + // Hash dedup: if the spec content hasn't changed and a previous lint result exists, reuse it. + if existing != nil && existing.Spec.Lint != nil && existing.Spec.Hash == apiSpec.Spec.Hash { + log.Info("Spec hash unchanged, reusing previous lint result", "passed", existing.Spec.Lint.Passed) + apiSpec.Spec.Lint = existing.Spec.Lint + return false + } + + // Clear previous result — the actual call happens asynchronously. + apiSpec.Spec.Lint = nil + return true +} + +// isBasepathWhitelisted checks if the given basepath is whitelisted in the category-level linting config. +func isBasepathWhitelisted(lintCfg *apiv1.LintingConfig, basepath string) bool { + for _, wl := range lintCfg.WhitelistedBasepaths { + if strings.EqualFold(wl, basepath) { + return true + } + } + return false +} + +// dispatchAsyncLint runs the external linter call in a background goroutine. +// It updates the ApiSpecification CRD spec with the lint result when done. +func (a *ApiSpecificationController) dispatchAsyncLint(ctx context.Context, ns, name, linterURL, ruleset string, specBytes []byte) { + // Create a detached context so the background work is not cancelled when the HTTP request ends. + bgCtx := context.WithoutCancel(ctx) + var opts []oaslint.ExternalLinterOption + if ruleset != "" { + opts = append(opts, oaslint.WithRuleset(ruleset)) + } + if a.LintTimeout > 0 { + opts = append(opts, oaslint.WithTimeout(a.LintTimeout)) + } + linter := oaslint.NewExternalLinter(linterURL, opts...) + go func() { + log := pkglog.Log.WithName("linting") + result, err := linter.Lint(bgCtx, specBytes) + if err != nil { + log.Error(err, "Async OAS linting failed", "namespace", ns, "name", name) + a.updateLintResult(bgCtx, ns, name, linterURL, &oaslint.LintResult{ + Passed: false, + Reason: fmt.Sprintf("linter API error: %s", err), + }) + return + } + + a.updateLintResult(bgCtx, ns, name, linterURL, result) + }() +} + +// updateLintResult patches the ApiSpecification's Spec.Lint field with the linting result. +func (a *ApiSpecificationController) updateLintResult(ctx context.Context, ns, name, linterURL string, result *oaslint.LintResult) { + log := logr.FromContextOrDiscard(ctx).WithName("linting") + + lintResult := &roverv1.LintResult{ + Passed: result.Passed, + Message: result.Reason, + } + + // Build the linter dashboard URL from the linter-api base URL and the scan ID. + if linterURL != "" && result.LinterId != "" { + lintResult.DashboardURL = fmt.Sprintf("%s/scans/%s", strings.TrimRight(linterURL, "/"), result.LinterId) + } + + if !result.Passed { + lintResult.Message = strings.ReplaceAll(a.ErrorMessage, "RULESET_NAME_PLACEHOLDER", result.Ruleset) + log.Info("Linting failed", "namespace", ns, "name", name, + "reason", result.Reason, "errors", result.Errors, "warnings", result.Warnings) + } + + if _, err := a.Store.Patch(ctx, ns, name, store.Patch{ + Op: store.OpReplace, + Path: "/spec/lint", + Value: lintResult, + }); err != nil { + log.Error(err, "Failed to update lint result", "namespace", ns, "name", name) + } +} + +// lookupLintingConfig finds the linting configuration from the ApiCategory matching the given category. +// Returns nil if no linting is configured for this category. +func (a *ApiSpecificationController) lookupLintingConfig(ctx context.Context, category string) *apiv1.LintingConfig { + log := logr.FromContextOrDiscard(ctx).WithName("linting") + + if a.ListApiCategories == nil { + log.V(1).Info("ListApiCategories is nil, skipping linting lookup", "category", category) + return nil + } + + categoryList, err := a.ListApiCategories(ctx) + if err != nil { + log.Error(err, "Failed to list ApiCategories for linting lookup") + return nil + } + + log.V(1).Info("Looking up linting config", "category", category, "categoryCount", len(categoryList.Items)) + + found, ok := categoryList.FindByLabelValue(category) + if !ok { + log.V(1).Info("Category not found in ApiCategory list", "category", category) + return nil + } + + if found.Spec.Linting == nil { + log.V(1).Info("Category found but has no linting config", "category", category) + return nil + } + + log.V(1).Info("Linting config resolved", "category", category, + "url", found.Spec.Linting.URL, "ruleset", found.Spec.Linting.Ruleset, + "mode", found.Spec.Linting.Mode, "whitelistedBasepaths", found.Spec.Linting.WhitelistedBasepaths) + return found.Spec.Linting +} diff --git a/rover-server/internal/controller/apispecification_lint_test.go b/rover-server/internal/controller/apispecification_lint_test.go new file mode 100644 index 00000000..868070bc --- /dev/null +++ b/rover-server/internal/controller/apispecification_lint_test.go @@ -0,0 +1,208 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiv1 "github.com/telekom/controlplane/api/api/v1" + roverv1 "github.com/telekom/controlplane/rover/api/v1" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Linting helpers", func() { + Describe("isBasepathWhitelisted", func() { + It("should return false when WhitelistedBasepaths is empty", func() { + cfg := &apiv1.LintingConfig{} + Expect(isBasepathWhitelisted(cfg, "/eni/test/v1")).To(BeFalse()) + }) + + It("should return true for exact match", func() { + cfg := &apiv1.LintingConfig{ + WhitelistedBasepaths: []string{"/eni/test/v1"}, + } + Expect(isBasepathWhitelisted(cfg, "/eni/test/v1")).To(BeTrue()) + }) + + It("should match case-insensitively", func() { + cfg := &apiv1.LintingConfig{ + WhitelistedBasepaths: []string{"/ENI/Test/v1"}, + } + Expect(isBasepathWhitelisted(cfg, "/eni/test/v1")).To(BeTrue()) + }) + + It("should return false for non-matching basepath", func() { + cfg := &apiv1.LintingConfig{ + WhitelistedBasepaths: []string{"/other/path/v1"}, + } + Expect(isBasepathWhitelisted(cfg, "/eni/test/v1")).To(BeFalse()) + }) + + It("should check all entries", func() { + cfg := &apiv1.LintingConfig{ + WhitelistedBasepaths: []string{"/first/v1", "/second/v2", "/eni/test/v1"}, + } + Expect(isBasepathWhitelisted(cfg, "/eni/test/v1")).To(BeTrue()) + }) + }) + + Describe("prepareLinting", func() { + var ctrl *ApiSpecificationController + + BeforeEach(func() { + ctrl = &ApiSpecificationController{} + }) + + It("should skip linting for category-whitelisted basepath", func() { + lintCfg := &apiv1.LintingConfig{ + URL: "https://linter.example.com", + WhitelistedBasepaths: []string{"/eni/internal/v1"}, + } + apiSpec := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{BasePath: "/eni/internal/v1"}, + } + + result := ctrl.prepareLinting(lintCfg, apiSpec, nil) + Expect(result).To(BeFalse()) + Expect(apiSpec.Spec.Lint).ToNot(BeNil()) + Expect(apiSpec.Spec.Lint.Passed).To(BeTrue()) + Expect(apiSpec.Spec.Lint.Message).To(ContainSubstring("whitelisted")) + }) + + It("should skip linting when spec hash is unchanged and previous result exists", func() { + lintCfg := &apiv1.LintingConfig{URL: "https://linter.example.com"} + existing := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{ + BasePath: "/eni/test/v1", + Hash: "same-hash", + Lint: &roverv1.LintResult{Passed: true, Message: "all good"}, + }, + } + apiSpec := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{ + BasePath: "/eni/test/v1", + Hash: "same-hash", + }, + } + + result := ctrl.prepareLinting(lintCfg, apiSpec, existing) + Expect(result).To(BeFalse()) + Expect(apiSpec.Spec.Lint).ToNot(BeNil()) + Expect(apiSpec.Spec.Lint.Passed).To(BeTrue()) + }) + + It("should require linting when spec hash changed", func() { + lintCfg := &apiv1.LintingConfig{URL: "https://linter.example.com"} + existing := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{ + BasePath: "/eni/test/v1", + Hash: "old-hash", + Lint: &roverv1.LintResult{Passed: true, Message: "all good"}, + }, + } + apiSpec := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{ + BasePath: "/eni/test/v1", + Hash: "new-hash", + }, + } + + result := ctrl.prepareLinting(lintCfg, apiSpec, existing) + Expect(result).To(BeTrue()) + Expect(apiSpec.Spec.Lint).To(BeNil()) + }) + + It("should require linting for new spec (no existing object)", func() { + lintCfg := &apiv1.LintingConfig{URL: "https://linter.example.com"} + apiSpec := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{ + BasePath: "/eni/test/v1", + Hash: "brand-new-hash", + }, + } + + result := ctrl.prepareLinting(lintCfg, apiSpec, nil) + Expect(result).To(BeTrue()) + Expect(apiSpec.Spec.Lint).To(BeNil()) + }) + }) + + Describe("lookupLintingConfig", func() { + It("should return nil when ListApiCategories is nil", func() { + ctrl := &ApiSpecificationController{} + result := ctrl.lookupLintingConfig(context.Background(), "some-cat") + Expect(result).To(BeNil()) + }) + + It("should return nil when category is not found", func() { + ctrl := &ApiSpecificationController{ + ListApiCategories: func(_ context.Context) (*apiv1.ApiCategoryList, error) { + return &apiv1.ApiCategoryList{Items: []apiv1.ApiCategory{}}, nil + }, + } + result := ctrl.lookupLintingConfig(context.Background(), "nonexistent") + Expect(result).To(BeNil()) + }) + + It("should return linting config from matching category", func() { + ctrl := &ApiSpecificationController{ + ListApiCategories: func(_ context.Context) (*apiv1.ApiCategoryList, error) { + return &apiv1.ApiCategoryList{ + Items: []apiv1.ApiCategory{ + { + ObjectMeta: metav1.ObjectMeta{Name: "my-cat"}, + Spec: apiv1.ApiCategorySpec{ + LabelValue: "my-cat", + Linting: &apiv1.LintingConfig{ + URL: "https://linter.example.com", + Mode: apiv1.LintingModeWarn, + }, + }, + }, + }, + }, nil + }, + } + result := ctrl.lookupLintingConfig(context.Background(), "my-cat") + Expect(result).ToNot(BeNil()) + Expect(result.URL).To(Equal("https://linter.example.com")) + Expect(result.Mode).To(Equal(apiv1.LintingModeWarn)) + }) + + It("should return nil when category has no linting config", func() { + ctrl := &ApiSpecificationController{ + ListApiCategories: func(_ context.Context) (*apiv1.ApiCategoryList, error) { + return &apiv1.ApiCategoryList{ + Items: []apiv1.ApiCategory{ + { + ObjectMeta: metav1.ObjectMeta{Name: "no-lint-cat"}, + Spec: apiv1.ApiCategorySpec{ + LabelValue: "no-lint-cat", + }, + }, + }, + }, nil + }, + } + result := ctrl.lookupLintingConfig(context.Background(), "no-lint-cat") + Expect(result).To(BeNil()) + }) + + It("should return nil when ListApiCategories returns error", func() { + ctrl := &ApiSpecificationController{ + ListApiCategories: func(_ context.Context) (*apiv1.ApiCategoryList, error) { + return nil, fmt.Errorf("store error") + }, + } + result := ctrl.lookupLintingConfig(context.Background(), "some-cat") + Expect(result).To(BeNil()) + }) + }) +}) diff --git a/rover-server/internal/controller/suite_controller_test.go b/rover-server/internal/controller/suite_controller_test.go index 3f242d92..76b64563 100644 --- a/rover-server/internal/controller/suite_controller_test.go +++ b/rover-server/internal/controller/suite_controller_test.go @@ -111,7 +111,7 @@ var _ = BeforeSuite(func() { s := server.Server{ Config: &config.ServerConfig{}, Log: log.Log, - ApiSpecifications: NewApiSpecificationController(stores, nil, nil, nil, ""), + ApiSpecifications: NewApiSpecificationController(stores, "", 0), Rovers: NewRoverController(stores), EventSpecifications: NewEventSpecificationController(stores), } diff --git a/rover-server/internal/mapper/apispecification/in/__snapshots__/apispecification_test.snap b/rover-server/internal/mapper/apispecification/in/__snapshots__/apispecification_test.snap index c912a969..d942f3aa 100755 --- a/rover-server/internal/mapper/apispecification/in/__snapshots__/apispecification_test.snap +++ b/rover-server/internal/mapper/apispecification/in/__snapshots__/apispecification_test.snap @@ -27,6 +27,7 @@ XVendor: false, Version: "1.0.0", Oauth2Scopes: {}, + Lint: (*v1.LintResult)(nil), }, Status: v1.ApiSpecificationStatus{}, } diff --git a/rover-server/internal/oaslint/external.go b/rover-server/internal/oaslint/external.go index cac5d14f..211e8d59 100644 --- a/rover-server/internal/oaslint/external.go +++ b/rover-server/internal/oaslint/external.go @@ -15,10 +15,8 @@ import ( ) const ( - defaultConnectTimeout = 5 * time.Second - defaultReadTimeout = 50 * time.Second - scanEndpoint = "api/linter/scans" - yamlContentType = "application/yaml; charset=UTF-8" + scanEndpoint = "api/linter/scans" + yamlContentType = "application/yaml; charset=UTF-8" ) var _ Linter = (*ExternalLinter)(nil) @@ -27,6 +25,7 @@ var _ Linter = (*ExternalLinter)(nil) // POST {baseURL}/api/linter/scans with the OAS spec as YAML body. type ExternalLinter struct { baseURL string + ruleset string client *http.Client } @@ -40,13 +39,25 @@ func WithHTTPClient(c *http.Client) ExternalLinterOption { } } +// WithRuleset sets the ruleset query parameter for linter scan requests. +func WithRuleset(ruleset string) ExternalLinterOption { + return func(l *ExternalLinter) { + l.ruleset = ruleset + } +} + +// WithTimeout overrides the default HTTP client timeout. +func WithTimeout(d time.Duration) ExternalLinterOption { + return func(l *ExternalLinter) { + l.client.Timeout = d + } +} + // NewExternalLinter creates a new ExternalLinter targeting the given base URL. func NewExternalLinter(baseURL string, opts ...ExternalLinterOption) *ExternalLinter { l := &ExternalLinter{ baseURL: baseURL, - client: &http.Client{ - Timeout: defaultConnectTimeout + defaultReadTimeout, - }, + client: &http.Client{}, } for _, o := range opts { o(l) @@ -77,9 +88,12 @@ type violationsInfo struct { } func (l *ExternalLinter) Lint(ctx context.Context, spec []byte) (*LintResult, error) { - url := fmt.Sprintf("%s/%s", l.baseURL, scanEndpoint) + scanURL := fmt.Sprintf("%s/%s", l.baseURL, scanEndpoint) + if l.ruleset != "" { + scanURL = fmt.Sprintf("%s?ruleset=%s", scanURL, l.ruleset) + } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(spec)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, scanURL, bytes.NewReader(spec)) if err != nil { return nil, fmt.Errorf("creating linter request: %w", err) } @@ -120,14 +134,11 @@ func (l *ExternalLinter) Lint(ctx context.Context, spec []byte) (*LintResult, er } return &LintResult{ - Passed: passed, - Reason: reason, - Ruleset: scan.Ruleset.Name, - LinterVersion: scan.LinterVersion, - LinterId: scan.ID, - Errors: scan.Info.Errors, - Warnings: scan.Info.Warnings, - Hints: scan.Info.Hints, - Infos: scan.Info.Infos, + Passed: passed, + Reason: reason, + Ruleset: scan.Ruleset.Name, + LinterId: scan.ID, + Errors: scan.Info.Errors, + Warnings: scan.Info.Warnings, }, nil } diff --git a/rover-server/internal/oaslint/external_test.go b/rover-server/internal/oaslint/external_test.go index 6c348bd1..e6218d77 100644 --- a/rover-server/internal/oaslint/external_test.go +++ b/rover-server/internal/oaslint/external_test.go @@ -74,11 +74,8 @@ servers: Expect(result.Passed).To(BeTrue()) Expect(result.LinterId).To(Equal("scan-123")) Expect(result.Ruleset).To(Equal("default-ruleset")) - Expect(result.LinterVersion).To(Equal("1.5.0")) Expect(result.Errors).To(Equal(0)) Expect(result.Warnings).To(Equal(2)) - Expect(result.Hints).To(Equal(3)) - Expect(result.Infos).To(Equal(1)) Expect(result.Reason).To(ContainSubstring("does not contain errors")) }) }) diff --git a/rover-server/internal/oaslint/linter.go b/rover-server/internal/oaslint/linter.go index be11255d..fbbb3c83 100644 --- a/rover-server/internal/oaslint/linter.go +++ b/rover-server/internal/oaslint/linter.go @@ -14,13 +14,10 @@ type Linter interface { // LintResult contains the outcome of a linting operation. type LintResult struct { - Passed bool - Reason string - Ruleset string - LinterVersion string - LinterId string - Errors int - Warnings int - Hints int - Infos int + Passed bool + Reason string + Ruleset string + LinterId string + Errors int + Warnings int } From 0d63d9b48e072346ab45a2ea84d957b913a290a4 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 6 May 2026 10:10:32 +0200 Subject: [PATCH 10/42] feat: add `none` --- api/api/v1/apicategory_types.go | 2 +- api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/api/v1/apicategory_types.go b/api/api/v1/apicategory_types.go index 99726382..623bd232 100644 --- a/api/api/v1/apicategory_types.go +++ b/api/api/v1/apicategory_types.go @@ -41,7 +41,7 @@ type LintingConfig struct { // Mode controls how linting failures affect API creation. // "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. - // +kubebuilder:validation:Enum=block;warn + // +kubebuilder:validation:Enum=block;warn;none // +kubebuilder:default:=block // +optional Mode LintingMode `json:"mode,omitempty"` diff --git a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml index c146e441..2cee458a 100644 --- a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml +++ b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml @@ -92,6 +92,7 @@ spec: - enum: - block - warn + - none default: block description: |- Mode controls how linting failures affect API creation. From 8e4df39ebb964a34f85bf80090d8d62879e68cfd Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 6 May 2026 10:10:59 +0200 Subject: [PATCH 11/42] feat: add sync to mimic current behaviour --- rover-server/cmd/main.go | 2 +- rover-server/internal/config/config.go | 4 +- .../internal/controller/apispecification.go | 36 +++++++++++----- .../controller/apispecification_lint.go | 42 ++++++++++++++++++- .../controller/suite_controller_test.go | 2 +- 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/rover-server/cmd/main.go b/rover-server/cmd/main.go index 99498e48..9befe76c 100644 --- a/rover-server/cmd/main.go +++ b/rover-server/cmd/main.go @@ -51,7 +51,7 @@ func main() { s := server.Server{ Config: cfg, Log: log.Log, - ApiSpecifications: controller.NewApiSpecificationController(stores, cfg.OasLinting.ErrorMessage, cfg.OasLinting.Timeout), + ApiSpecifications: controller.NewApiSpecificationController(stores, cfg.OasLinting.ErrorMessage, cfg.OasLinting.Timeout, cfg.OasLinting.Async), Rovers: controller.NewRoverController(stores), EventSpecifications: controller.NewEventSpecificationController(stores), } diff --git a/rover-server/internal/config/config.go b/rover-server/internal/config/config.go index e397c748..a6273dba 100644 --- a/rover-server/internal/config/config.go +++ b/rover-server/internal/config/config.go @@ -23,6 +23,7 @@ type ServerConfig struct { type OasLintingConfig struct { ErrorMessage string `json:"errorMessage"` Timeout time.Duration `json:"timeout"` + Async bool `json:"async"` } type SecurityConfig struct { @@ -81,7 +82,8 @@ func setDefaults() { // OAS Linting viper.SetDefault("oasLinting.errorMessage", "Linter scan result contains errors. Please visit the linter UI for details on the RULESET_NAME_PLACEHOLDER ruleset.") - viper.SetDefault("oasLinting.timeout", 55*time.Second) + viper.SetDefault("oasLinting.timeout", 0) // 0 means block indefinitely until linter responds + viper.SetDefault("oasLinting.async", false) // Database viper.SetDefault("database.filepath", "") // empty string means in-memory only diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 8cbfe6a8..3d7486da 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -50,14 +50,19 @@ type ApiSpecificationController struct { // LintTimeout is the HTTP client timeout for external linter calls. LintTimeout time.Duration + + // LintAsync controls whether linting runs asynchronously (true) or synchronously (false). + // When false (default), linting blocks the request so a single store operation includes the result. + LintAsync bool } -func NewApiSpecificationController(stores *s.Stores, errorMessage string, lintTimeout time.Duration) *ApiSpecificationController { +func NewApiSpecificationController(stores *s.Stores, errorMessage string, lintTimeout time.Duration, lintAsync bool) *ApiSpecificationController { ctrl := &ApiSpecificationController{ stores: stores, Store: stores.APISpecificationStore, ErrorMessage: errorMessage, LintTimeout: lintTimeout, + LintAsync: lintAsync, } if stores.APICategoryStore != nil { ctrl.ListApiCategories = func(ctx context.Context) (*apiv1.ApiCategoryList, error) { @@ -238,31 +243,42 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri // Look up the ApiCategory's linting config for this spec's category. // If the category has a linter URL, proceed with linting. - var needsAsyncLint bool + var needsLint bool log := logr.FromContextOrDiscard(ctx) log.V(1).Info("Looking up linting config", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "category", apiSpec.Spec.Category, "basepath", apiSpec.Spec.BasePath) lintCfg := a.lookupLintingConfig(ctx, apiSpec.Spec.Category) - if lintCfg != nil && lintCfg.URL != "" { + if lintCfg != nil && lintCfg.URL != "" && lintCfg.Mode != apiv1.LintingModeNone { log.V(1).Info("Linting config found, checking whitelists and hash dedup", "namespace", apiSpec.Namespace, "name", apiSpec.Name) // Fetch existing object for hash dedup comparison. existing, _ := a.Store.Get(ctx, apiSpec.Namespace, apiSpec.Name) - needsAsyncLint = a.prepareLinting(lintCfg, apiSpec, existing) - log.V(1).Info("prepareLinting completed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "needsAsyncLint", needsAsyncLint) + needsLint = a.prepareLinting(lintCfg, apiSpec, existing) + log.V(1).Info("prepareLinting completed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "needsLint", needsLint) } else { log.V(1).Info("No linting config or no URL, skipping linting", "namespace", apiSpec.Namespace, "name", apiSpec.Name) } + // Run linting synchronously (default) so the result is included in the single store write, + // or dispatch asynchronously if configured. + if needsLint { + if a.LintAsync { + // Store first, then lint in the background and patch afterwards. + err = a.Store.CreateOrReplace(ctx, apiSpec) + if err != nil { + return res, err + } + a.dispatchAsyncLint(ctx, apiSpec.Namespace, apiSpec.Name, lintCfg.URL, lintCfg.Ruleset, specMarshaled) + return a.Get(ctx, resourceId) + } + // Synchronous: lint blocks until result is available, then store once. + a.runSyncLint(ctx, apiSpec, lintCfg.URL, lintCfg.Ruleset, specMarshaled) + } + err = a.Store.CreateOrReplace(ctx, apiSpec) if err != nil { return res, err } - // Dispatch async linting if needed. The background goroutine will update the CRD spec. - if needsAsyncLint { - a.dispatchAsyncLint(ctx, apiSpec.Namespace, apiSpec.Name, lintCfg.URL, lintCfg.Ruleset, specMarshaled) - } - return a.Get(ctx, resourceId) } diff --git a/rover-server/internal/controller/apispecification_lint.go b/rover-server/internal/controller/apispecification_lint.go index 3372fe22..169ac282 100644 --- a/rover-server/internal/controller/apispecification_lint.go +++ b/rover-server/internal/controller/apispecification_lint.go @@ -18,7 +18,7 @@ import ( ) // prepareLinting checks whitelists and hash dedup synchronously. -// It returns true if an async external linter call is needed. +// It returns true if an external linter call is needed. func (a *ApiSpecificationController) prepareLinting(lintCfg *apiv1.LintingConfig, apiSpec *roverv1.ApiSpecification, existing *roverv1.ApiSpecification) bool { log := pkglog.Log.WithName("linting") @@ -36,7 +36,7 @@ func (a *ApiSpecificationController) prepareLinting(lintCfg *apiv1.LintingConfig return false } - // Clear previous result — the actual call happens asynchronously. + // Clear previous result — the actual linter call will follow. apiSpec.Spec.Lint = nil return true } @@ -51,6 +51,44 @@ func isBasepathWhitelisted(lintCfg *apiv1.LintingConfig, basepath string) bool { return false } +// runSyncLint calls the external linter synchronously and sets the lint result directly on the apiSpec. +// This ensures the lint result is included in the same store write as the spec itself. +func (a *ApiSpecificationController) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, linterURL, ruleset string, specBytes []byte) { + log := pkglog.Log.WithName("linting") + var opts []oaslint.ExternalLinterOption + if ruleset != "" { + opts = append(opts, oaslint.WithRuleset(ruleset)) + } + if a.LintTimeout > 0 { + opts = append(opts, oaslint.WithTimeout(a.LintTimeout)) + } + linter := oaslint.NewExternalLinter(linterURL, opts...) + + result, err := linter.Lint(ctx, specBytes) + if err != nil { + log.Error(err, "Sync OAS linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + apiSpec.Spec.Lint = &roverv1.LintResult{ + Passed: false, + Message: fmt.Sprintf("linter API error: %s", err), + } + return + } + + lintResult := &roverv1.LintResult{ + Passed: result.Passed, + Message: result.Reason, + } + if linterURL != "" && result.LinterId != "" { + lintResult.DashboardURL = fmt.Sprintf("%s/scans/%s", strings.TrimRight(linterURL, "/"), result.LinterId) + } + if !result.Passed { + lintResult.Message = strings.ReplaceAll(a.ErrorMessage, "RULESET_NAME_PLACEHOLDER", result.Ruleset) + log.Info("Linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, + "reason", result.Reason, "errors", result.Errors, "warnings", result.Warnings) + } + apiSpec.Spec.Lint = lintResult +} + // dispatchAsyncLint runs the external linter call in a background goroutine. // It updates the ApiSpecification CRD spec with the lint result when done. func (a *ApiSpecificationController) dispatchAsyncLint(ctx context.Context, ns, name, linterURL, ruleset string, specBytes []byte) { diff --git a/rover-server/internal/controller/suite_controller_test.go b/rover-server/internal/controller/suite_controller_test.go index 76b64563..1621b744 100644 --- a/rover-server/internal/controller/suite_controller_test.go +++ b/rover-server/internal/controller/suite_controller_test.go @@ -111,7 +111,7 @@ var _ = BeforeSuite(func() { s := server.Server{ Config: &config.ServerConfig{}, Log: log.Log, - ApiSpecifications: NewApiSpecificationController(stores, "", 0), + ApiSpecifications: NewApiSpecificationController(stores, "", 0, false), Rovers: NewRoverController(stores), EventSpecifications: NewEventSpecificationController(stores), } From ed60c718be8e82df82f63dffbdaae75c9e671c86 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 6 May 2026 14:30:52 +0200 Subject: [PATCH 12/42] feat(api): update LintingMode documentation and add validation for WhitelistedBasepaths Co-authored-by: Copilot --- api/api/v1/apicategory_types.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/api/v1/apicategory_types.go b/api/api/v1/apicategory_types.go index 623bd232..93e5bd50 100644 --- a/api/api/v1/apicategory_types.go +++ b/api/api/v1/apicategory_types.go @@ -14,7 +14,6 @@ import ( ) // LintingMode controls how linting failures affect API creation. -// +kubebuilder:validation:Enum=block;warn type LintingMode string const ( @@ -48,8 +47,10 @@ type LintingConfig struct { // WhitelistedBasepaths is a list of API basepaths that are exempt from linting. // APIs whose basePath matches an entry here will skip linting even when a linter URL is configured. + // Each entry must start with a leading slash. // +optional // +listType=set + // +kubebuilder:validation:items:Pattern=`^/` WhitelistedBasepaths []string `json:"whitelistedBasepaths,omitempty"` } From e1b5bde73b07fc51bd2a397839c8724f57d5b1fe Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 6 May 2026 14:31:06 +0200 Subject: [PATCH 13/42] feat(api): simplify linting mode definition and enhance WhitelistedBasepaths description --- .../bases/api.cp.ei.telekom.de_apicategories.yaml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml index 2cee458a..05d6bb12 100644 --- a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml +++ b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml @@ -85,18 +85,14 @@ spec: If set with a URL, linting is enabled for this category. properties: mode: - allOf: - - enum: - - block - - warn - - enum: - - block - - warn - - none default: block description: |- Mode controls how linting failures affect API creation. "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. + enum: + - block + - warn + - none type: string ruleset: description: |- @@ -113,7 +109,9 @@ spec: description: |- WhitelistedBasepaths is a list of API basepaths that are exempt from linting. APIs whose basePath matches an entry here will skip linting even when a linter URL is configured. + Each entry must start with a leading slash. items: + pattern: ^/ type: string type: array x-kubernetes-list-type: set From 3104ad040790fe9e8a3ab80573fce1b0881e082e Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 6 May 2026 14:31:39 +0200 Subject: [PATCH 14/42] feat(api): enhance linting error handling and improve response mapping --- .../internal/controller/apispecification.go | 7 ++++- .../controller/apispecification_lint.go | 7 +++-- .../controller/apispecification_lint_test.go | 14 ++++++++++ .../internal/mapper/status/response.go | 28 ++++++++++++++++++- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 3d7486da..9eeb4583 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -271,7 +271,12 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri return a.Get(ctx, resourceId) } // Synchronous: lint blocks until result is available, then store once. - a.runSyncLint(ctx, apiSpec, lintCfg.URL, lintCfg.Ruleset, specMarshaled) + if err := a.runSyncLint(ctx, apiSpec, lintCfg.URL, lintCfg.Ruleset, specMarshaled); err != nil { + // Store the spec with the failed lint result so it's persisted, + // then return 500 to inform the client about the infrastructure error. + _ = a.Store.CreateOrReplace(ctx, apiSpec) + return res, problems.InternalServerError("Linting failed", err.Error()) + } } err = a.Store.CreateOrReplace(ctx, apiSpec) diff --git a/rover-server/internal/controller/apispecification_lint.go b/rover-server/internal/controller/apispecification_lint.go index 169ac282..2de03bc2 100644 --- a/rover-server/internal/controller/apispecification_lint.go +++ b/rover-server/internal/controller/apispecification_lint.go @@ -53,7 +53,9 @@ func isBasepathWhitelisted(lintCfg *apiv1.LintingConfig, basepath string) bool { // runSyncLint calls the external linter synchronously and sets the lint result directly on the apiSpec. // This ensures the lint result is included in the same store write as the spec itself. -func (a *ApiSpecificationController) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, linterURL, ruleset string, specBytes []byte) { +// It returns an error for infrastructure failures (e.g. linter unreachable or auth errors) +// which should be surfaced as 500 Internal Server Error to the client. +func (a *ApiSpecificationController) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, linterURL, ruleset string, specBytes []byte) error { log := pkglog.Log.WithName("linting") var opts []oaslint.ExternalLinterOption if ruleset != "" { @@ -71,7 +73,7 @@ func (a *ApiSpecificationController) runSyncLint(ctx context.Context, apiSpec *r Passed: false, Message: fmt.Sprintf("linter API error: %s", err), } - return + return fmt.Errorf("linter API error: %w", err) } lintResult := &roverv1.LintResult{ @@ -87,6 +89,7 @@ func (a *ApiSpecificationController) runSyncLint(ctx context.Context, apiSpec *r "reason", result.Reason, "errors", result.Errors, "warnings", result.Warnings) } apiSpec.Spec.Lint = lintResult + return nil } // dispatchAsyncLint runs the external linter call in a background goroutine. diff --git a/rover-server/internal/controller/apispecification_lint_test.go b/rover-server/internal/controller/apispecification_lint_test.go index 868070bc..1de087fe 100644 --- a/rover-server/internal/controller/apispecification_lint_test.go +++ b/rover-server/internal/controller/apispecification_lint_test.go @@ -45,6 +45,20 @@ var _ = Describe("Linting helpers", func() { Expect(isBasepathWhitelisted(cfg, "/eni/test/v1")).To(BeFalse()) }) + It("should match when whitelist lacks leading slash", func() { + cfg := &apiv1.LintingConfig{ + WhitelistedBasepaths: []string{"phoenix/whitelist-demo-service/v1"}, + } + Expect(isBasepathWhitelisted(cfg, "/phoenix/whitelist-demo-service/v1")).To(BeTrue()) + }) + + It("should match when basepath lacks leading slash", func() { + cfg := &apiv1.LintingConfig{ + WhitelistedBasepaths: []string{"/phoenix/whitelist-demo-service/v1"}, + } + Expect(isBasepathWhitelisted(cfg, "phoenix/whitelist-demo-service/v1")).To(BeTrue()) + }) + It("should check all entries", func() { cfg := &apiv1.LintingConfig{ WhitelistedBasepaths: []string{"/first/v1", "/second/v2", "/eni/test/v1"}, diff --git a/rover-server/internal/mapper/status/response.go b/rover-server/internal/mapper/status/response.go index 080b2cd5..7a3a597a 100644 --- a/rover-server/internal/mapper/status/response.go +++ b/rover-server/internal/mapper/status/response.go @@ -64,13 +64,39 @@ func MapAPISpecificationResponse(ctx context.Context, apiSpec *v1.ApiSpecificati parentOverall := CalculateOverallStatus(status.State, status.ProcessingState) finalOverall := CompareAndReturn(parentOverall, result.WorstOverallStatus) + // Combine problems from sub-resources with any errors from the parent's own conditions. + allErrors := make([]api.Problem, 0, len(result.Problems)+len(status.Errors)) + allErrors = append(allErrors, result.Problems...) + for _, e := range status.Errors { + allErrors = append(allErrors, api.Problem{Message: e.Message, Cause: e.Cause}) + } + + // Include warnings from the parent's own conditions (e.g. blocked reason). + allWarnings := make([]api.Problem, 0, len(status.Warnings)) + for _, w := range status.Warnings { + allWarnings = append(allWarnings, api.Problem{Message: w.Message, Cause: w.Cause}) + } + + // Surface lint failure as a warning when linting did not pass but the API was still created (warn mode). + if apiSpec.Spec.Lint != nil && !apiSpec.Spec.Lint.Passed && status.State == api.Complete { + msg := "OAS linting did not pass: " + apiSpec.Spec.Lint.Message + if apiSpec.Spec.Lint.DashboardURL != "" { + msg += ". View details: " + apiSpec.Spec.Lint.DashboardURL + } + allWarnings = append(allWarnings, api.Problem{ + Cause: "LintingFailed", + Message: msg, + }) + } + return api.ResourceStatusResponse{ CreatedAt: apiSpec.GetCreationTimestamp().Time, ProcessedAt: processedAtTime, State: status.State, ProcessingState: status.ProcessingState, OverallStatus: finalOverall, - Errors: result.Problems, + Errors: allErrors, + Warnings: allWarnings, }, nil } From f8e9e06ac2ff0bf6489f9e40d323a4826f7e9e83 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 6 May 2026 14:39:19 +0200 Subject: [PATCH 15/42] test: remove unused test --- .../controller/apispecification_lint_test.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/rover-server/internal/controller/apispecification_lint_test.go b/rover-server/internal/controller/apispecification_lint_test.go index 1de087fe..868070bc 100644 --- a/rover-server/internal/controller/apispecification_lint_test.go +++ b/rover-server/internal/controller/apispecification_lint_test.go @@ -45,20 +45,6 @@ var _ = Describe("Linting helpers", func() { Expect(isBasepathWhitelisted(cfg, "/eni/test/v1")).To(BeFalse()) }) - It("should match when whitelist lacks leading slash", func() { - cfg := &apiv1.LintingConfig{ - WhitelistedBasepaths: []string{"phoenix/whitelist-demo-service/v1"}, - } - Expect(isBasepathWhitelisted(cfg, "/phoenix/whitelist-demo-service/v1")).To(BeTrue()) - }) - - It("should match when basepath lacks leading slash", func() { - cfg := &apiv1.LintingConfig{ - WhitelistedBasepaths: []string{"/phoenix/whitelist-demo-service/v1"}, - } - Expect(isBasepathWhitelisted(cfg, "phoenix/whitelist-demo-service/v1")).To(BeTrue()) - }) - It("should check all entries", func() { cfg := &apiv1.LintingConfig{ WhitelistedBasepaths: []string{"/first/v1", "/second/v2", "/eni/test/v1"}, From db2f31eea99d284881df461f5ff0de13c27b5a53 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 6 May 2026 14:50:54 +0200 Subject: [PATCH 16/42] refactor(rover): remove double checking of category --- .../handler/apispecification/handler.go | 23 +-- rover/internal/oaslint/external.go | 133 ------------ rover/internal/oaslint/external_test.go | 191 ------------------ rover/internal/oaslint/linter.go | 26 --- rover/internal/oaslint/noop.go | 19 -- rover/internal/oaslint/suite_test.go | 17 -- 6 files changed, 9 insertions(+), 400 deletions(-) delete mode 100644 rover/internal/oaslint/external.go delete mode 100644 rover/internal/oaslint/external_test.go delete mode 100644 rover/internal/oaslint/linter.go delete mode 100644 rover/internal/oaslint/noop.go delete mode 100644 rover/internal/oaslint/suite_test.go diff --git a/rover/internal/handler/apispecification/handler.go b/rover/internal/handler/apispecification/handler.go index 855d6492..71ff7d16 100644 --- a/rover/internal/handler/apispecification/handler.go +++ b/rover/internal/handler/apispecification/handler.go @@ -32,17 +32,15 @@ type ApiSpecificationHandler struct { func (h *ApiSpecificationHandler) CreateOrUpdate(ctx context.Context, apiSpec *roverv1.ApiSpecification) error { log := logr.FromContextOrDiscard(ctx) + mode := h.lookupLintingMode(ctx, apiSpec.Spec.Category) // Linting is pending (async) — Spec.Lint is nil, wait for rover-server to fill it in. if apiSpec.Spec.Lint == nil { - mode := h.lookupLintingMode(ctx, apiSpec.Spec.Category) if mode == apiapi.LintingModeNone { // No linting configured for this category — proceed normally. return h.createOrUpdateApi(ctx, apiSpec) } if mode == apiapi.LintingModeBlock { - apiSpec.SetCondition(condition.NewProcessingCondition("LintingPending", - "OAS linting is in progress, waiting for result")) apiSpec.SetCondition(condition.NewNotReadyCondition("LintingPending", "API specification is being linted")) return nil @@ -53,18 +51,15 @@ func (h *ApiSpecificationHandler) CreateOrUpdate(ctx context.Context, apiSpec *r } // Check if linting failed and the category config blocks on failure - if !apiSpec.Spec.Lint.Passed { - mode := h.lookupLintingMode(ctx, apiSpec.Spec.Category) - if mode == apiapi.LintingModeBlock { - msg := fmt.Sprintf("OAS linting failed: %s", apiSpec.Spec.Lint.Message) - if apiSpec.Spec.Lint.DashboardURL != "" { - msg = fmt.Sprintf("%s. View details: %s", msg, apiSpec.Spec.Lint.DashboardURL) - } - apiSpec.SetCondition(condition.NewBlockedCondition(msg)) - apiSpec.SetCondition(condition.NewNotReadyCondition("LintingFailed", - "API specification did not pass linting")) - return nil + if !apiSpec.Spec.Lint.Passed && mode == apiapi.LintingModeBlock { + msg := fmt.Sprintf("OAS linting failed: %s", apiSpec.Spec.Lint.Message) + if apiSpec.Spec.Lint.DashboardURL != "" { + msg = fmt.Sprintf("%s. View details: %s", msg, apiSpec.Spec.Lint.DashboardURL) } + apiSpec.SetCondition(condition.NewBlockedCondition(msg)) + apiSpec.SetCondition(condition.NewNotReadyCondition("LintingFailed", + "API specification did not pass linting")) + return nil } return h.createOrUpdateApi(ctx, apiSpec) diff --git a/rover/internal/oaslint/external.go b/rover/internal/oaslint/external.go deleted file mode 100644 index 201aef90..00000000 --- a/rover/internal/oaslint/external.go +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2025 Deutsche Telekom IT GmbH -// -// SPDX-License-Identifier: Apache-2.0 - -package oaslint - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -const ( - defaultConnectTimeout = 5 * time.Second - defaultReadTimeout = 50 * time.Second - scanEndpoint = "api/linter/scans" - yamlContentType = "application/yaml; charset=UTF-8" -) - -var _ Linter = (*ExternalLinter)(nil) - -// ExternalLinter calls an external linter REST API (Atlas Linter Service compatible). -// POST {baseURL}/api/linter/scans with the OAS spec as YAML body. -type ExternalLinter struct { - baseURL string - client *http.Client -} - -// ExternalLinterOption configures the ExternalLinter. -type ExternalLinterOption func(*ExternalLinter) - -// WithHTTPClient overrides the default HTTP client. -func WithHTTPClient(c *http.Client) ExternalLinterOption { - return func(l *ExternalLinter) { - l.client = c - } -} - -// NewExternalLinter creates a new ExternalLinter targeting the given base URL. -func NewExternalLinter(baseURL string, opts ...ExternalLinterOption) *ExternalLinter { - l := &ExternalLinter{ - baseURL: baseURL, - client: &http.Client{ - Timeout: defaultConnectTimeout + defaultReadTimeout, - }, - } - for _, o := range opts { - o(l) - } - return l -} - -// linterScanResponse mirrors the external linter API response (Atlas Linter Service). -type linterScanResponse struct { - ID string `json:"id"` - CreatedAt string `json:"createdAt"` - Ruleset linterRuleset `json:"ruleset"` - Info violationsInfo `json:"info"` - LinterVersion string `json:"linterVersion"` -} - -type linterRuleset struct { - Name string `json:"name"` - Hash string `json:"hash"` - URL string `json:"url,omitempty"` -} - -type violationsInfo struct { - Infos int `json:"infos"` - Warnings int `json:"warnings"` - Errors int `json:"errors"` - Hints int `json:"hints"` -} - -func (l *ExternalLinter) Lint(ctx context.Context, spec []byte, _ string) (*LintResult, error) { - url := fmt.Sprintf("%s/%s", l.baseURL, scanEndpoint) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(spec)) - if err != nil { - return nil, fmt.Errorf("creating linter request: %w", err) - } - req.Header.Set("Content-Type", yamlContentType) - - resp, err := l.client.Do(req) - if err != nil { - return nil, fmt.Errorf("calling linter API: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading linter response: %w", err) - } - - if resp.StatusCode == http.StatusRequestTimeout { - return nil, fmt.Errorf("linting timed out (HTTP 408)") - } - - if resp.StatusCode >= 500 { - return nil, fmt.Errorf("linter service unavailable (HTTP %d)", resp.StatusCode) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("linter API returned unexpected status %d", resp.StatusCode) - } - - var scan linterScanResponse - if err := json.Unmarshal(body, &scan); err != nil { - return nil, fmt.Errorf("decoding linter response: %w", err) - } - - passed := scan.Info.Errors == 0 - reason := "linter scan result does not contain errors" - if !passed { - reason = fmt.Sprintf("linter scan found %d error(s) per %q rules", scan.Info.Errors, scan.Ruleset.Name) - } - - return &LintResult{ - Passed: passed, - Reason: reason, - Ruleset: scan.Ruleset.Name, - LinterVersion: scan.LinterVersion, - LinterId: scan.ID, - Errors: scan.Info.Errors, - Warnings: scan.Info.Warnings, - Hints: scan.Info.Hints, - Infos: scan.Info.Infos, - }, nil -} diff --git a/rover/internal/oaslint/external_test.go b/rover/internal/oaslint/external_test.go deleted file mode 100644 index 88887de3..00000000 --- a/rover/internal/oaslint/external_test.go +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright 2025 Deutsche Telekom IT GmbH -// -// SPDX-License-Identifier: Apache-2.0 - -package oaslint - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ExternalLinter", func() { - var ( - ctx context.Context - server *httptest.Server - linter *ExternalLinter - spec []byte - ) - - BeforeEach(func() { - ctx = context.Background() - spec = []byte(`openapi: "3.0.0" -info: - title: Test API - version: "1.0.0" -servers: - - url: http://example.com/api/v1 -`) - }) - - AfterEach(func() { - if server != nil { - server.Close() - } - }) - - Context("when the linter API returns a clean scan", func() { - BeforeEach(func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.Method).To(Equal(http.MethodPost)) - Expect(r.URL.Path).To(Equal("/api/linter/scans")) - Expect(r.Header.Get("Content-Type")).To(Equal(yamlContentType)) - - resp := linterScanResponse{ - ID: "scan-123", - CreatedAt: "2025-01-01T00:00:00Z", - Ruleset: linterRuleset{ - Name: "default-ruleset", - Hash: "abc123", - }, - Info: violationsInfo{ - Infos: 1, - Warnings: 2, - Errors: 0, - Hints: 3, - }, - LinterVersion: "1.5.0", - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) //nolint:errcheck - })) - linter = NewExternalLinter(server.URL) - }) - - It("should return a passing result", func() { - result, err := linter.Lint(ctx, spec, "default-ruleset") - Expect(err).NotTo(HaveOccurred()) - Expect(result.Passed).To(BeTrue()) - Expect(result.LinterId).To(Equal("scan-123")) - Expect(result.Ruleset).To(Equal("default-ruleset")) - Expect(result.LinterVersion).To(Equal("1.5.0")) - Expect(result.Errors).To(Equal(0)) - Expect(result.Warnings).To(Equal(2)) - Expect(result.Hints).To(Equal(3)) - Expect(result.Infos).To(Equal(1)) - Expect(result.Reason).To(ContainSubstring("does not contain errors")) - }) - }) - - Context("when the linter API returns errors", func() { - BeforeEach(func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - resp := linterScanResponse{ - ID: "scan-456", - Ruleset: linterRuleset{ - Name: "strict-ruleset", - }, - Info: violationsInfo{ - Errors: 5, - Warnings: 3, - }, - LinterVersion: "1.5.0", - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) //nolint:errcheck - })) - linter = NewExternalLinter(server.URL) - }) - - It("should return a failing result", func() { - result, err := linter.Lint(ctx, spec, "strict-ruleset") - Expect(err).NotTo(HaveOccurred()) - Expect(result.Passed).To(BeFalse()) - Expect(result.Errors).To(Equal(5)) - Expect(result.Warnings).To(Equal(3)) - Expect(result.LinterId).To(Equal("scan-456")) - Expect(result.Reason).To(ContainSubstring("5 error(s)")) - Expect(result.Reason).To(ContainSubstring("strict-ruleset")) - }) - }) - - Context("when the linter API returns 5xx", func() { - BeforeEach(func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - linter = NewExternalLinter(server.URL) - }) - - It("should return an error", func() { - result, err := linter.Lint(ctx, spec, "") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("linter service unavailable")) - Expect(result).To(BeNil()) - }) - }) - - Context("when the linter API returns 408 timeout", func() { - BeforeEach(func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusRequestTimeout) - })) - linter = NewExternalLinter(server.URL) - }) - - It("should return a timeout error", func() { - result, err := linter.Lint(ctx, spec, "") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("timed out")) - Expect(result).To(BeNil()) - }) - }) - - Context("when the linter API is unreachable", func() { - BeforeEach(func() { - linter = NewExternalLinter("http://localhost:1", WithHTTPClient(&http.Client{ - Timeout: 1 * time.Second, - })) - }) - - It("should return a connection error", func() { - result, err := linter.Lint(ctx, spec, "") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("calling linter API")) - Expect(result).To(BeNil()) - }) - }) - - Context("when the linter API returns invalid JSON", func() { - BeforeEach(func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte("not json")) //nolint:errcheck - })) - linter = NewExternalLinter(server.URL) - }) - - It("should return a decode error", func() { - result, err := linter.Lint(ctx, spec, "") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("decoding linter response")) - Expect(result).To(BeNil()) - }) - }) -}) - -var _ = Describe("NoopLinter", func() { - It("should always return a passing result", func() { - linter := &NoopLinter{} - result, err := linter.Lint(context.Background(), []byte("anything"), "any-ruleset") - Expect(err).NotTo(HaveOccurred()) - Expect(result.Passed).To(BeTrue()) - Expect(result.Reason).To(ContainSubstring("disabled")) - }) -}) diff --git a/rover/internal/oaslint/linter.go b/rover/internal/oaslint/linter.go deleted file mode 100644 index 71f87870..00000000 --- a/rover/internal/oaslint/linter.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2025 Deutsche Telekom IT GmbH -// -// SPDX-License-Identifier: Apache-2.0 - -package oaslint - -import "context" - -// Linter defines the interface for OAS specification linting. -// Implementations can call an external linter API or lint in-process (e.g. vacuum). -type Linter interface { - Lint(ctx context.Context, spec []byte, ruleset string) (*LintResult, error) -} - -// LintResult contains the outcome of a linting operation. -type LintResult struct { - Passed bool - Reason string - Ruleset string - LinterVersion string - LinterId string - Errors int - Warnings int - Hints int - Infos int -} diff --git a/rover/internal/oaslint/noop.go b/rover/internal/oaslint/noop.go deleted file mode 100644 index 102e01ce..00000000 --- a/rover/internal/oaslint/noop.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2025 Deutsche Telekom IT GmbH -// -// SPDX-License-Identifier: Apache-2.0 - -package oaslint - -import "context" - -var _ Linter = (*NoopLinter)(nil) - -// NoopLinter always returns a passing result. Used when linting is disabled. -type NoopLinter struct{} - -func (n *NoopLinter) Lint(_ context.Context, _ []byte, _ string) (*LintResult, error) { - return &LintResult{ - Passed: true, - Reason: "linting is disabled", - }, nil -} diff --git a/rover/internal/oaslint/suite_test.go b/rover/internal/oaslint/suite_test.go deleted file mode 100644 index 1b4f4b30..00000000 --- a/rover/internal/oaslint/suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2025 Deutsche Telekom IT GmbH -// -// SPDX-License-Identifier: Apache-2.0 - -package oaslint - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestOasLint(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "OAS Lint Suite") -} From 4db1eb6359953ae31f46deb6de7820a0bb7aef17 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 6 May 2026 14:51:13 +0200 Subject: [PATCH 17/42] feat(api): refactor linting configuration handling and improve validation logic Co-authored-by: Copilot --- .../internal/controller/apispecification.go | 30 +++++--- .../controller/apispecification_lint.go | 63 +++++----------- .../controller/apispecification_lint_test.go | 74 ++++++------------- 3 files changed, 64 insertions(+), 103 deletions(-) diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 9eeb4583..3dbe3829 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -225,8 +225,11 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri return res, err } + // Fetch the ApiCategory list once for both validation and linting config lookup. + categoryList := a.fetchApiCategories(ctx) + // Validate the API category against the known ApiCategories. - if catErr := a.validateApiCategory(ctx, apiSpec.Spec.Category); catErr != nil { + if catErr := a.validateApiCategoryFromList(categoryList, apiSpec.Spec.Category); catErr != nil { return res, catErr } @@ -247,7 +250,7 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri log := logr.FromContextOrDiscard(ctx) log.V(1).Info("Looking up linting config", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "category", apiSpec.Spec.Category, "basepath", apiSpec.Spec.BasePath) - lintCfg := a.lookupLintingConfig(ctx, apiSpec.Spec.Category) + lintCfg := lintingConfigFromList(categoryList, apiSpec.Spec.Category) if lintCfg != nil && lintCfg.URL != "" && lintCfg.Mode != apiv1.LintingModeNone { log.V(1).Info("Linting config found, checking whitelists and hash dedup", "namespace", apiSpec.Namespace, "name", apiSpec.Name) // Fetch existing object for hash dedup comparison. @@ -306,22 +309,29 @@ func (a *ApiSpecificationController) GetStatus(ctx context.Context, resourceId s return status.MapAPISpecificationResponse(ctx, apiSpec, a.stores) } -// validateApiCategory validates that the given category is a known and active ApiCategory. -// If ListApiCategories is nil, validation is skipped. -func (a *ApiSpecificationController) validateApiCategory(ctx context.Context, category string) error { +// fetchApiCategories fetches all ApiCategories. Returns nil if the store is not configured. +func (a *ApiSpecificationController) fetchApiCategories(ctx context.Context) *apiv1.ApiCategoryList { if a.ListApiCategories == nil { return nil } - - apiCategoryList, err := a.ListApiCategories(ctx) + list, err := a.ListApiCategories(ctx) if err != nil { - logr.FromContextOrDiscard(ctx).Info("Failed to list ApiCategories for validation", "error", err) + logr.FromContextOrDiscard(ctx).Info("Failed to list ApiCategories", "error", err) + return nil + } + return list +} + +// validateApiCategoryFromList validates that the given category is a known and active ApiCategory +// using a pre-fetched list. If the list is nil, validation is skipped. +func (a *ApiSpecificationController) validateApiCategoryFromList(categoryList *apiv1.ApiCategoryList, category string) error { + if categoryList == nil { return nil } - found, ok := apiCategoryList.FindByLabelValue(category) + found, ok := categoryList.FindByLabelValue(category) if !ok { - allowedLabels := strings.Join(apiCategoryList.AllowedLabelValues(), ", ") + allowedLabels := strings.Join(categoryList.AllowedLabelValues(), ", ") return problems.BadRequest( fmt.Sprintf("ApiCategory %q not found. Allowed values are: [%s]", category, allowedLabels)) } diff --git a/rover-server/internal/controller/apispecification_lint.go b/rover-server/internal/controller/apispecification_lint.go index 2de03bc2..9756ce3c 100644 --- a/rover-server/internal/controller/apispecification_lint.go +++ b/rover-server/internal/controller/apispecification_lint.go @@ -76,19 +76,11 @@ func (a *ApiSpecificationController) runSyncLint(ctx context.Context, apiSpec *r return fmt.Errorf("linter API error: %w", err) } - lintResult := &roverv1.LintResult{ - Passed: result.Passed, - Message: result.Reason, - } - if linterURL != "" && result.LinterId != "" { - lintResult.DashboardURL = fmt.Sprintf("%s/scans/%s", strings.TrimRight(linterURL, "/"), result.LinterId) - } + apiSpec.Spec.Lint = a.buildLintResult(result, linterURL) if !result.Passed { - lintResult.Message = strings.ReplaceAll(a.ErrorMessage, "RULESET_NAME_PLACEHOLDER", result.Ruleset) log.Info("Linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "reason", result.Reason, "errors", result.Errors, "warnings", result.Warnings) } - apiSpec.Spec.Lint = lintResult return nil } @@ -110,33 +102,38 @@ func (a *ApiSpecificationController) dispatchAsyncLint(ctx context.Context, ns, result, err := linter.Lint(bgCtx, specBytes) if err != nil { log.Error(err, "Async OAS linting failed", "namespace", ns, "name", name) - a.updateLintResult(bgCtx, ns, name, linterURL, &oaslint.LintResult{ + result = &oaslint.LintResult{ Passed: false, Reason: fmt.Sprintf("linter API error: %s", err), - }) - return + } } - a.updateLintResult(bgCtx, ns, name, linterURL, result) + a.patchLintResult(bgCtx, ns, name, linterURL, result) }() } -// updateLintResult patches the ApiSpecification's Spec.Lint field with the linting result. -func (a *ApiSpecificationController) updateLintResult(ctx context.Context, ns, name, linterURL string, result *oaslint.LintResult) { - log := logr.FromContextOrDiscard(ctx).WithName("linting") - +// buildLintResult maps an oaslint.LintResult to the CRD's roverv1.LintResult, +// including dashboard URL and error message template substitution. +func (a *ApiSpecificationController) buildLintResult(result *oaslint.LintResult, linterURL string) *roverv1.LintResult { lintResult := &roverv1.LintResult{ Passed: result.Passed, Message: result.Reason, } - - // Build the linter dashboard URL from the linter-api base URL and the scan ID. if linterURL != "" && result.LinterId != "" { lintResult.DashboardURL = fmt.Sprintf("%s/scans/%s", strings.TrimRight(linterURL, "/"), result.LinterId) } - if !result.Passed { lintResult.Message = strings.ReplaceAll(a.ErrorMessage, "RULESET_NAME_PLACEHOLDER", result.Ruleset) + } + return lintResult +} + +// patchLintResult patches the ApiSpecification's Spec.Lint field with the linting result. +func (a *ApiSpecificationController) patchLintResult(ctx context.Context, ns, name, linterURL string, result *oaslint.LintResult) { + log := logr.FromContextOrDiscard(ctx).WithName("linting") + + lintResult := a.buildLintResult(result, linterURL) + if !result.Passed { log.Info("Linting failed", "namespace", ns, "name", name, "reason", result.Reason, "errors", result.Errors, "warnings", result.Warnings) } @@ -150,37 +147,17 @@ func (a *ApiSpecificationController) updateLintResult(ctx context.Context, ns, n } } -// lookupLintingConfig finds the linting configuration from the ApiCategory matching the given category. +// lintingConfigFromList finds the linting configuration from a pre-fetched ApiCategoryList. // Returns nil if no linting is configured for this category. -func (a *ApiSpecificationController) lookupLintingConfig(ctx context.Context, category string) *apiv1.LintingConfig { - log := logr.FromContextOrDiscard(ctx).WithName("linting") - - if a.ListApiCategories == nil { - log.V(1).Info("ListApiCategories is nil, skipping linting lookup", "category", category) - return nil - } - - categoryList, err := a.ListApiCategories(ctx) - if err != nil { - log.Error(err, "Failed to list ApiCategories for linting lookup") +func lintingConfigFromList(categoryList *apiv1.ApiCategoryList, category string) *apiv1.LintingConfig { + if categoryList == nil { return nil } - log.V(1).Info("Looking up linting config", "category", category, "categoryCount", len(categoryList.Items)) - found, ok := categoryList.FindByLabelValue(category) if !ok { - log.V(1).Info("Category not found in ApiCategory list", "category", category) - return nil - } - - if found.Spec.Linting == nil { - log.V(1).Info("Category found but has no linting config", "category", category) return nil } - log.V(1).Info("Linting config resolved", "category", category, - "url", found.Spec.Linting.URL, "ruleset", found.Spec.Linting.Ruleset, - "mode", found.Spec.Linting.Mode, "whitelistedBasepaths", found.Spec.Linting.WhitelistedBasepaths) return found.Spec.Linting } diff --git a/rover-server/internal/controller/apispecification_lint_test.go b/rover-server/internal/controller/apispecification_lint_test.go index 868070bc..f508d1aa 100644 --- a/rover-server/internal/controller/apispecification_lint_test.go +++ b/rover-server/internal/controller/apispecification_lint_test.go @@ -5,9 +5,6 @@ package controller import ( - "context" - "fmt" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apiv1 "github.com/telekom/controlplane/api/api/v1" @@ -134,74 +131,51 @@ var _ = Describe("Linting helpers", func() { }) }) - Describe("lookupLintingConfig", func() { - It("should return nil when ListApiCategories is nil", func() { - ctrl := &ApiSpecificationController{} - result := ctrl.lookupLintingConfig(context.Background(), "some-cat") + Describe("lintingConfigFromList", func() { + It("should return nil when categoryList is nil", func() { + result := lintingConfigFromList(nil, "some-cat") Expect(result).To(BeNil()) }) It("should return nil when category is not found", func() { - ctrl := &ApiSpecificationController{ - ListApiCategories: func(_ context.Context) (*apiv1.ApiCategoryList, error) { - return &apiv1.ApiCategoryList{Items: []apiv1.ApiCategory{}}, nil - }, - } - result := ctrl.lookupLintingConfig(context.Background(), "nonexistent") + list := &apiv1.ApiCategoryList{Items: []apiv1.ApiCategory{}} + result := lintingConfigFromList(list, "nonexistent") Expect(result).To(BeNil()) }) It("should return linting config from matching category", func() { - ctrl := &ApiSpecificationController{ - ListApiCategories: func(_ context.Context) (*apiv1.ApiCategoryList, error) { - return &apiv1.ApiCategoryList{ - Items: []apiv1.ApiCategory{ - { - ObjectMeta: metav1.ObjectMeta{Name: "my-cat"}, - Spec: apiv1.ApiCategorySpec{ - LabelValue: "my-cat", - Linting: &apiv1.LintingConfig{ - URL: "https://linter.example.com", - Mode: apiv1.LintingModeWarn, - }, - }, + list := &apiv1.ApiCategoryList{ + Items: []apiv1.ApiCategory{ + { + ObjectMeta: metav1.ObjectMeta{Name: "my-cat"}, + Spec: apiv1.ApiCategorySpec{ + LabelValue: "my-cat", + Linting: &apiv1.LintingConfig{ + URL: "https://linter.example.com", + Mode: apiv1.LintingModeWarn, }, }, - }, nil + }, }, } - result := ctrl.lookupLintingConfig(context.Background(), "my-cat") + result := lintingConfigFromList(list, "my-cat") Expect(result).ToNot(BeNil()) Expect(result.URL).To(Equal("https://linter.example.com")) Expect(result.Mode).To(Equal(apiv1.LintingModeWarn)) }) It("should return nil when category has no linting config", func() { - ctrl := &ApiSpecificationController{ - ListApiCategories: func(_ context.Context) (*apiv1.ApiCategoryList, error) { - return &apiv1.ApiCategoryList{ - Items: []apiv1.ApiCategory{ - { - ObjectMeta: metav1.ObjectMeta{Name: "no-lint-cat"}, - Spec: apiv1.ApiCategorySpec{ - LabelValue: "no-lint-cat", - }, - }, + list := &apiv1.ApiCategoryList{ + Items: []apiv1.ApiCategory{ + { + ObjectMeta: metav1.ObjectMeta{Name: "no-lint-cat"}, + Spec: apiv1.ApiCategorySpec{ + LabelValue: "no-lint-cat", }, - }, nil - }, - } - result := ctrl.lookupLintingConfig(context.Background(), "no-lint-cat") - Expect(result).To(BeNil()) - }) - - It("should return nil when ListApiCategories returns error", func() { - ctrl := &ApiSpecificationController{ - ListApiCategories: func(_ context.Context) (*apiv1.ApiCategoryList, error) { - return nil, fmt.Errorf("store error") + }, }, } - result := ctrl.lookupLintingConfig(context.Background(), "some-cat") + result := lintingConfigFromList(list, "no-lint-cat") Expect(result).To(BeNil()) }) }) From 427a9796a934dc443f8babea9ecae0095a8db2ce Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 6 May 2026 16:24:47 +0200 Subject: [PATCH 18/42] feat(tests): enhance ApiSpecification handler tests with mock client setup --- .../handler/apispecification/handler_test.go | 99 ++++++++++++------- 1 file changed, 62 insertions(+), 37 deletions(-) diff --git a/rover/internal/handler/apispecification/handler_test.go b/rover/internal/handler/apispecification/handler_test.go index c587fc17..6935a2cb 100644 --- a/rover/internal/handler/apispecification/handler_test.go +++ b/rover/internal/handler/apispecification/handler_test.go @@ -10,16 +10,25 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" apiapi "github.com/telekom/controlplane/api/api/v1" + cclient "github.com/telekom/controlplane/common/pkg/client" + fakeclient "github.com/telekom/controlplane/common/pkg/client/fake" "github.com/telekom/controlplane/common/pkg/condition" roverv1 "github.com/telekom/controlplane/rover/api/v1" handler "github.com/telekom/controlplane/rover/internal/handler/apispecification" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) func newApiSpec(hash, category string) *roverv1.ApiSpecification { return &roverv1.ApiSpecification{ ObjectMeta: metav1.ObjectMeta{ + Name: "test-spec", + Namespace: "test-env--test-team", + UID: "test-uid-1234", Labels: map[string]string{ "controlplane.2/environment": "test-env", }, @@ -85,6 +94,26 @@ func conditionMessage(apiSpec *roverv1.ApiSpecification, condType string) string return "" } +// setupMockClient creates a mock JanitorClient injected into context. +// The mock expects CreateOrUpdate and returns success. +func setupMockClient(ctx context.Context) context.Context { + fakeClient := fakeclient.NewMockJanitorClient(GinkgoT()) + testScheme := runtime.NewScheme() + _ = roverv1.AddToScheme(testScheme) + _ = apiapi.AddToScheme(testScheme) + + fakeClient.EXPECT().Scheme().Return(testScheme).Maybe() + fakeClient.EXPECT(). + CreateOrUpdate(mock.Anything, mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, _ client.Object, fn controllerutil.MutateFn) (controllerutil.OperationResult, error) { + _ = fn() + return controllerutil.OperationResultCreated, nil + }).Maybe() + fakeClient.EXPECT().AnyChanged().Return(true).Maybe() + + return cclient.WithClient(ctx, fakeClient) +} + var _ = Describe("ApiSpecification Handler Linting Gate", func() { var ctx context.Context @@ -93,39 +122,35 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { }) Context("when linting is pending (Spec.Lint nil, block mode)", func() { - It("should set processing and not-ready conditions", func() { + It("should set not-ready condition", func() { h := &handler.ApiSpecificationHandler{ GetApiCategory: getApiCategoryWith(newApiCategory("other", &apiapi.LintingConfig{ Mode: apiapi.LintingModeBlock, })), } apiSpec := newApiSpec("hash1", "other") - // Spec.Lint is nil — linting pending err := h.CreateOrUpdate(ctx, apiSpec) Expect(err).ToNot(HaveOccurred()) - Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) Expect(hasCondition(apiSpec, condition.ConditionTypeReady)).To(BeTrue()) - Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("linting is in progress")) Expect(conditionMessage(apiSpec, condition.ConditionTypeReady)).To(ContainSubstring("being linted")) }) }) Context("when linting is pending (Spec.Lint nil, warn mode)", func() { It("should proceed with Api creation", func() { + mockCtx := setupMockClient(ctx) h := &handler.ApiSpecificationHandler{ GetApiCategory: getApiCategoryWith(newApiCategory("warn-cat", &apiapi.LintingConfig{ Mode: apiapi.LintingModeWarn, })), } apiSpec := newApiSpec("hash1", "warn-cat") - // Spec.Lint is nil — linting pending, but warn mode proceeds - Expect(func() { - _ = h.CreateOrUpdate(ctx, apiSpec) - }).To(Panic()) - // Panicked in createOrUpdateApi means the linting gate did not block. - Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse()) + err := h.CreateOrUpdate(mockCtx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("API updated")) }) }) @@ -181,7 +206,8 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { }) Context("when linting failed in warn mode", func() { - It("should not set blocked condition", func() { + It("should proceed with Api creation", func() { + mockCtx := setupMockClient(ctx) h := &handler.ApiSpecificationHandler{ GetApiCategory: getApiCategoryWith(newApiCategory("warn-cat", &apiapi.LintingConfig{ Mode: apiapi.LintingModeWarn, @@ -190,64 +216,63 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { apiSpec := newApiSpec("hash1", "warn-cat") apiSpec.Spec.Lint = &roverv1.LintResult{Passed: false, Message: "found 2 warnings"} - // CreateOrUpdate will proceed to createOrUpdateApi which requires a k8s client; - // we expect it to panic or error there, but the linting gate should NOT block. - Expect(func() { - _ = h.CreateOrUpdate(ctx, apiSpec) - }).To(Panic()) - // If we got here (panicked in createOrUpdateApi), it means the linting gate passed through. - Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse(), - "should not have a blocked/processing condition in warn mode") + err := h.CreateOrUpdate(mockCtx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("API updated")) }) }) Context("when linting passed", func() { - It("should proceed past linting gate", func() { + It("should proceed with Api creation", func() { + mockCtx := setupMockClient(ctx) h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "other") apiSpec.Spec.Lint = &roverv1.LintResult{Passed: true, Message: "no errors"} - // Will proceed to createOrUpdateApi -> panic on missing k8s client - Expect(func() { - _ = h.CreateOrUpdate(ctx, apiSpec) - }).To(Panic()) - Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse()) + err := h.CreateOrUpdate(mockCtx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("API updated")) }) }) Context("when no linting is configured (Spec.Lint nil, no category linting)", func() { It("should proceed when GetApiCategory is nil", func() { + mockCtx := setupMockClient(ctx) h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "other") - Expect(func() { - _ = h.CreateOrUpdate(ctx, apiSpec) - }).To(Panic()) - Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse()) + err := h.CreateOrUpdate(mockCtx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("API updated")) }) It("should proceed when category has no linting config", func() { + mockCtx := setupMockClient(ctx) h := &handler.ApiSpecificationHandler{ GetApiCategory: getApiCategoryNil(), } apiSpec := newApiSpec("hash1", "other") - Expect(func() { - _ = h.CreateOrUpdate(ctx, apiSpec) - }).To(Panic()) - Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse()) + err := h.CreateOrUpdate(mockCtx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("API updated")) }) It("should proceed when category lookup returns error", func() { + mockCtx := setupMockClient(ctx) h := &handler.ApiSpecificationHandler{ GetApiCategory: getApiCategoryError(), } apiSpec := newApiSpec("hash1", "other") - Expect(func() { - _ = h.CreateOrUpdate(ctx, apiSpec) - }).To(Panic()) - Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeFalse()) + err := h.CreateOrUpdate(mockCtx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("API updated")) }) }) From ae6b0b15b8dca344d9298f47e798f3db43d40290 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 6 May 2026 16:25:04 +0200 Subject: [PATCH 19/42] feat(api): enhance ApiSpecificationController with async linting support and improved HTTP client handling Co-authored-by: Copilot --- .../internal/controller/apispecification.go | 23 ++++- .../controller/apispecification_lint.go | 98 ++++++++----------- rover-server/internal/oaslint/external.go | 18 ++-- 3 files changed, 66 insertions(+), 73 deletions(-) diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 3dbe3829..90affa93 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -12,17 +12,20 @@ import ( "fmt" "io" "strings" + "sync" "time" "github.com/go-logr/logr" "github.com/gofiber/fiber/v2" "github.com/pkg/errors" apiv1 "github.com/telekom/controlplane/api/api/v1" + commonclient "github.com/telekom/controlplane/common-server/pkg/client" "github.com/telekom/controlplane/common-server/pkg/problems" "github.com/telekom/controlplane/common-server/pkg/server/middleware/security" "github.com/telekom/controlplane/common-server/pkg/store" filesapi "github.com/telekom/controlplane/file-manager/api" "github.com/telekom/controlplane/rover-server/internal/file" + "github.com/telekom/controlplane/rover-server/internal/oaslint" roverv1 "github.com/telekom/controlplane/rover/api/v1" "gopkg.in/yaml.v3" @@ -48,12 +51,15 @@ type ApiSpecificationController struct { // ErrorMessage is the template message shown when linting fails. ErrorMessage string - // LintTimeout is the HTTP client timeout for external linter calls. - LintTimeout time.Duration - // LintAsync controls whether linting runs asynchronously (true) or synchronously (false). // When false (default), linting blocks the request so a single store operation includes the result. LintAsync bool + + // httpClient is reused across lint requests for connection pooling and metrics. + httpClient oaslint.HTTPDoer + + // lintWg tracks in-flight async lint goroutines for graceful shutdown. + lintWg sync.WaitGroup } func NewApiSpecificationController(stores *s.Stores, errorMessage string, lintTimeout time.Duration, lintAsync bool) *ApiSpecificationController { @@ -61,8 +67,12 @@ func NewApiSpecificationController(stores *s.Stores, errorMessage string, lintTi stores: stores, Store: stores.APISpecificationStore, ErrorMessage: errorMessage, - LintTimeout: lintTimeout, LintAsync: lintAsync, + httpClient: commonclient.NewHttpClientOrDie( + commonclient.WithClientName("oaslint"), + commonclient.WithClientTimeout(lintTimeout), + commonclient.WithSkipTlsVerify(true), + ), } if stores.APICategoryStore != nil { ctrl.ListApiCategories = func(ctx context.Context) (*apiv1.ApiCategoryList, error) { @@ -81,6 +91,11 @@ func NewApiSpecificationController(stores *s.Stores, errorMessage string, lintTi return ctrl } +// Shutdown waits for all in-flight async lint operations to complete. +func (a *ApiSpecificationController) Shutdown() { + a.lintWg.Wait() +} + // Create implements server.ApiSpecificationController. func (a *ApiSpecificationController) Create(ctx context.Context, req api.ApiSpecificationCreateRequest) (res api.ApiSpecificationResponse, err error) { // Important Hint: This is a declarative API. The client should not create an ApiSpecification, but only use diff --git a/rover-server/internal/controller/apispecification_lint.go b/rover-server/internal/controller/apispecification_lint.go index 9756ce3c..042e221a 100644 --- a/rover-server/internal/controller/apispecification_lint.go +++ b/rover-server/internal/controller/apispecification_lint.go @@ -13,25 +13,20 @@ import ( apiv1 "github.com/telekom/controlplane/api/api/v1" "github.com/telekom/controlplane/common-server/pkg/store" "github.com/telekom/controlplane/rover-server/internal/oaslint" - pkglog "github.com/telekom/controlplane/rover-server/pkg/log" roverv1 "github.com/telekom/controlplane/rover/api/v1" ) // prepareLinting checks whitelists and hash dedup synchronously. // It returns true if an external linter call is needed. func (a *ApiSpecificationController) prepareLinting(lintCfg *apiv1.LintingConfig, apiSpec *roverv1.ApiSpecification, existing *roverv1.ApiSpecification) bool { - log := pkglog.Log.WithName("linting") - // Check basepath whitelist (category-level). if isBasepathWhitelisted(lintCfg, apiSpec.Spec.BasePath) { - log.Info("Basepath is whitelisted, skipping linting", "basepath", apiSpec.Spec.BasePath) apiSpec.Spec.Lint = &roverv1.LintResult{Passed: true, Message: fmt.Sprintf("The basepath %q is whitelisted", apiSpec.Spec.BasePath)} return false } // Hash dedup: if the spec content hasn't changed and a previous lint result exists, reuse it. if existing != nil && existing.Spec.Lint != nil && existing.Spec.Hash == apiSpec.Spec.Hash { - log.Info("Spec hash unchanged, reusing previous lint result", "passed", existing.Spec.Lint.Passed) apiSpec.Spec.Lint = existing.Spec.Lint return false } @@ -51,64 +46,68 @@ func isBasepathWhitelisted(lintCfg *apiv1.LintingConfig, basepath string) bool { return false } -// runSyncLint calls the external linter synchronously and sets the lint result directly on the apiSpec. -// This ensures the lint result is included in the same store write as the spec itself. -// It returns an error for infrastructure failures (e.g. linter unreachable or auth errors) -// which should be surfaced as 500 Internal Server Error to the client. -func (a *ApiSpecificationController) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, linterURL, ruleset string, specBytes []byte) error { - log := pkglog.Log.WithName("linting") +// executeLint calls the external linter and returns the CRD lint result. +// This is the single lint execution path used by both sync and async flows. +func (a *ApiSpecificationController) executeLint(ctx context.Context, linterURL, ruleset string, specBytes []byte) (*roverv1.LintResult, error) { var opts []oaslint.ExternalLinterOption if ruleset != "" { opts = append(opts, oaslint.WithRuleset(ruleset)) } - if a.LintTimeout > 0 { - opts = append(opts, oaslint.WithTimeout(a.LintTimeout)) - } + opts = append(opts, oaslint.WithHTTPClient(a.httpClient)) linter := oaslint.NewExternalLinter(linterURL, opts...) result, err := linter.Lint(ctx, specBytes) if err != nil { - log.Error(err, "Sync OAS linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name) - apiSpec.Spec.Lint = &roverv1.LintResult{ + return &roverv1.LintResult{ Passed: false, Message: fmt.Sprintf("linter API error: %s", err), - } - return fmt.Errorf("linter API error: %w", err) + }, fmt.Errorf("linter API error: %w", err) } - apiSpec.Spec.Lint = a.buildLintResult(result, linterURL) - if !result.Passed { - log.Info("Linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, - "reason", result.Reason, "errors", result.Errors, "warnings", result.Warnings) + return a.buildLintResult(result, linterURL), nil +} + +// runSyncLint calls the external linter synchronously and sets the lint result directly on the apiSpec. +// It returns an error for infrastructure failures (e.g. linter unreachable or auth errors) +// which should be surfaced as 500 Internal Server Error to the client. +func (a *ApiSpecificationController) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, linterURL, ruleset string, specBytes []byte) error { + log := logr.FromContextOrDiscard(ctx).WithName("linting") + + lintResult, err := a.executeLint(ctx, linterURL, ruleset, specBytes) + apiSpec.Spec.Lint = lintResult + if err != nil { + log.Error(err, "Sync OAS linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + return err + } + if !lintResult.Passed { + log.Info("Linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "message", lintResult.Message) } return nil } -// dispatchAsyncLint runs the external linter call in a background goroutine. -// It updates the ApiSpecification CRD spec with the lint result when done. +// dispatchAsyncLint runs the lint call in a tracked background goroutine. +// It updates the ApiSpecification via store patch when done. func (a *ApiSpecificationController) dispatchAsyncLint(ctx context.Context, ns, name, linterURL, ruleset string, specBytes []byte) { - // Create a detached context so the background work is not cancelled when the HTTP request ends. bgCtx := context.WithoutCancel(ctx) - var opts []oaslint.ExternalLinterOption - if ruleset != "" { - opts = append(opts, oaslint.WithRuleset(ruleset)) - } - if a.LintTimeout > 0 { - opts = append(opts, oaslint.WithTimeout(a.LintTimeout)) - } - linter := oaslint.NewExternalLinter(linterURL, opts...) + a.lintWg.Add(1) go func() { - log := pkglog.Log.WithName("linting") - result, err := linter.Lint(bgCtx, specBytes) + defer a.lintWg.Done() + log := logr.FromContextOrDiscard(bgCtx).WithName("linting") + + lintResult, err := a.executeLint(bgCtx, linterURL, ruleset, specBytes) if err != nil { log.Error(err, "Async OAS linting failed", "namespace", ns, "name", name) - result = &oaslint.LintResult{ - Passed: false, - Reason: fmt.Sprintf("linter API error: %s", err), - } + } else if !lintResult.Passed { + log.Info("Linting failed", "namespace", ns, "name", name, "message", lintResult.Message) } - a.patchLintResult(bgCtx, ns, name, linterURL, result) + if _, patchErr := a.Store.Patch(bgCtx, ns, name, store.Patch{ + Op: store.OpReplace, + Path: "/spec/lint", + Value: lintResult, + }); patchErr != nil { + log.Error(patchErr, "Failed to update lint result", "namespace", ns, "name", name) + } }() } @@ -128,25 +127,6 @@ func (a *ApiSpecificationController) buildLintResult(result *oaslint.LintResult, return lintResult } -// patchLintResult patches the ApiSpecification's Spec.Lint field with the linting result. -func (a *ApiSpecificationController) patchLintResult(ctx context.Context, ns, name, linterURL string, result *oaslint.LintResult) { - log := logr.FromContextOrDiscard(ctx).WithName("linting") - - lintResult := a.buildLintResult(result, linterURL) - if !result.Passed { - log.Info("Linting failed", "namespace", ns, "name", name, - "reason", result.Reason, "errors", result.Errors, "warnings", result.Warnings) - } - - if _, err := a.Store.Patch(ctx, ns, name, store.Patch{ - Op: store.OpReplace, - Path: "/spec/lint", - Value: lintResult, - }); err != nil { - log.Error(err, "Failed to update lint result", "namespace", ns, "name", name) - } -} - // lintingConfigFromList finds the linting configuration from a pre-fetched ApiCategoryList. // Returns nil if no linting is configured for this category. func lintingConfigFromList(categoryList *apiv1.ApiCategoryList, category string) *apiv1.LintingConfig { diff --git a/rover-server/internal/oaslint/external.go b/rover-server/internal/oaslint/external.go index 211e8d59..d991277c 100644 --- a/rover-server/internal/oaslint/external.go +++ b/rover-server/internal/oaslint/external.go @@ -11,7 +11,6 @@ import ( "fmt" "io" "net/http" - "time" ) const ( @@ -21,19 +20,25 @@ const ( var _ Linter = (*ExternalLinter)(nil) +// HTTPDoer is the interface for executing HTTP requests. +// Compatible with *http.Client and metrics-wrapped clients. +type HTTPDoer interface { + Do(req *http.Request) (*http.Response, error) +} + // ExternalLinter calls an external linter REST API (Atlas Linter Service compatible). // POST {baseURL}/api/linter/scans with the OAS spec as YAML body. type ExternalLinter struct { baseURL string ruleset string - client *http.Client + client HTTPDoer } // ExternalLinterOption configures the ExternalLinter. type ExternalLinterOption func(*ExternalLinter) // WithHTTPClient overrides the default HTTP client. -func WithHTTPClient(c *http.Client) ExternalLinterOption { +func WithHTTPClient(c HTTPDoer) ExternalLinterOption { return func(l *ExternalLinter) { l.client = c } @@ -46,13 +51,6 @@ func WithRuleset(ruleset string) ExternalLinterOption { } } -// WithTimeout overrides the default HTTP client timeout. -func WithTimeout(d time.Duration) ExternalLinterOption { - return func(l *ExternalLinter) { - l.client.Timeout = d - } -} - // NewExternalLinter creates a new ExternalLinter targeting the given base URL. func NewExternalLinter(baseURL string, opts ...ExternalLinterOption) *ExternalLinter { l := &ExternalLinter{ From 20fad69cf78dfef8892b09c4d0dd16fe94d7e041 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 08:51:18 +0200 Subject: [PATCH 20/42] refactor: enum pascal case --- api/api/v1/apicategory_types.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/api/api/v1/apicategory_types.go b/api/api/v1/apicategory_types.go index 93e5bd50..1fc58433 100644 --- a/api/api/v1/apicategory_types.go +++ b/api/api/v1/apicategory_types.go @@ -18,11 +18,11 @@ type LintingMode string const ( // LintingModeBlock prevents Api creation when linting fails. - LintingModeBlock LintingMode = "block" + LintingModeBlock LintingMode = "Block" // LintingModeWarn allows Api creation but surfaces linting issues in status. - LintingModeWarn LintingMode = "warn" + LintingModeWarn LintingMode = "Warn" // LintingModeNone indicates that no linting is configured for this category. - LintingModeNone LintingMode = "none" + LintingModeNone LintingMode = "None" ) // LintingConfig configures OAS specification linting for APIs in this category. @@ -35,13 +35,12 @@ type LintingConfig struct { // Ruleset is the name of the linter ruleset to apply. // If set, it is passed as a query parameter to the linter API. - // +optional - Ruleset string `json:"ruleset,omitempty"` + Ruleset string `json:"ruleset"` // Mode controls how linting failures affect API creation. - // "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. - // +kubebuilder:validation:Enum=block;warn;none - // +kubebuilder:default:=block + // "Block" (default) prevents Api creation on failure; "Warn" allows it but surfaces issues. + // +kubebuilder:validation:Enum=Block;Warn;None + // +kubebuilder:default:=Block // +optional Mode LintingMode `json:"mode,omitempty"` From 4edc142d63e4c1179a9b6d44a039b17e01d5f865 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 08:51:31 +0200 Subject: [PATCH 21/42] refactor: add index --- .../controller/apispecification_controller.go | 8 ++++---- rover/internal/controller/index.go | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/rover/internal/controller/apispecification_controller.go b/rover/internal/controller/apispecification_controller.go index 6fb02a69..751db4db 100644 --- a/rover/internal/controller/apispecification_controller.go +++ b/rover/internal/controller/apispecification_controller.go @@ -6,6 +6,7 @@ package controller import ( "context" + "strings" cconfig "github.com/telekom/controlplane/common/pkg/config" cc "github.com/telekom/controlplane/common/pkg/controller" @@ -51,14 +52,13 @@ func (r *ApiSpecificationReconciler) SetupWithManager(mgr ctrl.Manager) error { GetApiCategory: func(ctx context.Context, category string) (*apiapi.ApiCategory, error) { c := cclient.ClientFromContextOrDie(ctx) list := &apiapi.ApiCategoryList{} - if err := c.List(ctx, list); err != nil { + if err := c.List(ctx, list, client.MatchingFields{FieldApiCategoryLabelValue: strings.ToLower(category)}); err != nil { return nil, err } - found, ok := list.FindByLabelValue(category) - if !ok { + if len(list.Items) == 0 { return nil, nil } - return found, nil + return &list.Items[0], nil }, } diff --git a/rover/internal/controller/index.go b/rover/internal/controller/index.go index 02dc356c..a48cfdab 100644 --- a/rover/internal/controller/index.go +++ b/rover/internal/controller/index.go @@ -7,6 +7,7 @@ package controller import ( "context" "os" + "strings" apiapi "github.com/telekom/controlplane/api/api/v1" applicationv1 "github.com/telekom/controlplane/application/api/v1" @@ -16,8 +17,11 @@ import ( permissionv1 "github.com/telekom/controlplane/permission/api/v1" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" ) +const FieldApiCategoryLabelValue = "spec.labelValue" + func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { err := index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &apiapi.Api{}) @@ -42,6 +46,18 @@ func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { os.Exit(1) } + err = mgr.GetFieldIndexer().IndexField(ctx, &apiapi.ApiCategory{}, FieldApiCategoryLabelValue, func(obj client.Object) []string { + cat := obj.(*apiapi.ApiCategory) + if cat.Spec.LabelValue == "" { + return nil + } + return []string{strings.ToLower(cat.Spec.LabelValue)} + }) + if err != nil { + ctrl.Log.Error(err, "unable to create fieldIndex for ApiCategory", "field", FieldApiCategoryLabelValue) + os.Exit(1) + } + if cconfig.FeaturePubSub.IsEnabled() { err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &eventv1.EventExposure{}) if err != nil { From 9f6f6c2f60c2041ec1d5a7d76523587314b5f250 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 09:01:44 +0200 Subject: [PATCH 22/42] refactor: use ConfigStructs to avoid parameter overload --- rover-server/cmd/main.go | 2 +- rover-server/internal/controller/apilinter.go | 191 ++++++++++++++++++ .../internal/controller/apispecification.go | 78 ++----- .../controller/apispecification_lint.go | 107 ---------- .../controller/apispecification_lint_test.go | 12 +- .../controller/suite_controller_test.go | 2 +- 6 files changed, 216 insertions(+), 176 deletions(-) create mode 100644 rover-server/internal/controller/apilinter.go diff --git a/rover-server/cmd/main.go b/rover-server/cmd/main.go index 3ab95776..566babf7 100644 --- a/rover-server/cmd/main.go +++ b/rover-server/cmd/main.go @@ -51,7 +51,7 @@ func main() { s := server.Server{ Config: cfg, Log: log.Log, - ApiSpecifications: controller.NewApiSpecificationController(stores, cfg.OasLinting.ErrorMessage, cfg.OasLinting.Timeout, cfg.OasLinting.Async), + ApiSpecifications: controller.NewApiSpecificationController(stores, cfg.OasLinting), Rovers: controller.NewRoverController(stores), Roadmaps: controller.NewRoadmapController(stores), EventSpecifications: controller.NewEventSpecificationController(stores), diff --git a/rover-server/internal/controller/apilinter.go b/rover-server/internal/controller/apilinter.go new file mode 100644 index 00000000..dc269574 --- /dev/null +++ b/rover-server/internal/controller/apilinter.go @@ -0,0 +1,191 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/go-logr/logr" + apiv1 "github.com/telekom/controlplane/api/api/v1" + commonclient "github.com/telekom/controlplane/common-server/pkg/client" + "github.com/telekom/controlplane/common-server/pkg/store" + "github.com/telekom/controlplane/rover-server/internal/config" + "github.com/telekom/controlplane/rover-server/internal/oaslint" + roverv1 "github.com/telekom/controlplane/rover/api/v1" +) + +// LintOutcome describes how linting completed. +type LintOutcome int + +const ( + // LintSkipped means no linting was needed (no config, whitelisted, or hash dedup). + LintSkipped LintOutcome = iota + // LintCompleted means linting ran synchronously and the result is on apiSpec.Spec.Lint. + LintCompleted + // LintDispatched means the spec was stored and linting is running asynchronously. + // The caller should NOT store the spec again. + LintDispatched +) + +// ApiLinter abstracts the full OAS linting lifecycle: config lookup, +// whitelists, hash dedup, sync/async execution, and store interaction. +type ApiLinter interface { + // Lint performs the full linting lifecycle for an ApiSpecification. + // It looks up the linting config from the category list, checks whitelists + // and hash dedup, and either runs the linter synchronously or dispatches + // it asynchronously. + // + // Returns the outcome and an error for infrastructure failures during sync linting. + // When the outcome is LintDispatched, the spec has already been stored — the caller + // must not store it again. + Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, categoryList *apiv1.ApiCategoryList, specBytes []byte) (LintOutcome, error) + + // Shutdown waits for all in-flight async lint operations to complete. + Shutdown() +} + +// apiLinterImpl is the production implementation of ApiLinter. +type apiLinterImpl struct { + objStore store.ObjectStore[*roverv1.ApiSpecification] + errorMessage string + async bool + httpClient oaslint.HTTPDoer + wg sync.WaitGroup +} + +// NewApiLinter creates an ApiLinter from the given linting configuration. +func NewApiLinter(objStore store.ObjectStore[*roverv1.ApiSpecification], lintCfg config.OasLintingConfig) ApiLinter { + return &apiLinterImpl{ + objStore: objStore, + errorMessage: lintCfg.ErrorMessage, + async: lintCfg.Async, + httpClient: commonclient.NewHttpClientOrDie( + commonclient.WithClientName("oaslint"), + commonclient.WithClientTimeout(lintCfg.Timeout), + commonclient.WithSkipTlsVerify(true), + ), + } +} + +func (l *apiLinterImpl) Shutdown() { l.wg.Wait() } + +func (l *apiLinterImpl) Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, categoryList *apiv1.ApiCategoryList, specBytes []byte) (LintOutcome, error) { + log := logr.FromContextOrDiscard(ctx) + log.V(1).Info("Looking up linting config", "namespace", apiSpec.Namespace, "name", apiSpec.Name, + "category", apiSpec.Spec.Category, "basepath", apiSpec.Spec.BasePath) + + lintCfg := lintingConfigFromList(categoryList, apiSpec.Spec.Category) + if lintCfg == nil || lintCfg.URL == "" || lintCfg.Mode == apiv1.LintingModeNone { + log.V(1).Info("No linting config or no URL, skipping linting", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + return LintSkipped, nil + } + + log.V(1).Info("Linting config found, checking whitelists and hash dedup", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + existing, _ := l.objStore.Get(ctx, apiSpec.Namespace, apiSpec.Name) + if !l.prepareLinting(lintCfg, apiSpec, existing) { + log.V(1).Info("Linting skipped (whitelisted or hash dedup)", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + return LintSkipped, nil + } + + if l.async { + if err := l.objStore.CreateOrReplace(ctx, apiSpec); err != nil { + return LintSkipped, err + } + l.dispatchAsyncLint(ctx, apiSpec.Namespace, apiSpec.Name, lintCfg.URL, lintCfg.Ruleset, specBytes) + return LintDispatched, nil + } + + if err := l.runSyncLint(ctx, apiSpec, lintCfg.URL, lintCfg.Ruleset, specBytes); err != nil { + _ = l.objStore.CreateOrReplace(ctx, apiSpec) + return LintCompleted, err + } + return LintCompleted, nil +} + +func (l *apiLinterImpl) prepareLinting(lintCfg *apiv1.LintingConfig, apiSpec *roverv1.ApiSpecification, existing *roverv1.ApiSpecification) bool { + if isBasepathWhitelisted(lintCfg, apiSpec.Spec.BasePath) { + apiSpec.Spec.Lint = &roverv1.LintResult{Passed: true, Message: fmt.Sprintf("The basepath %q is whitelisted", apiSpec.Spec.BasePath)} + return false + } + if existing != nil && existing.Spec.Lint != nil && existing.Spec.Hash == apiSpec.Spec.Hash { + apiSpec.Spec.Lint = existing.Spec.Lint + return false + } + apiSpec.Spec.Lint = nil + return true +} + +func (l *apiLinterImpl) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, linterURL, ruleset string, specBytes []byte) error { + log := logr.FromContextOrDiscard(ctx).WithName("linting") + lintResult, err := l.executeLint(ctx, linterURL, ruleset, specBytes) + apiSpec.Spec.Lint = lintResult + if err != nil { + log.Error(err, "Sync OAS linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + return err + } + if !lintResult.Passed { + log.Info("Linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "message", lintResult.Message) + } + return nil +} + +func (l *apiLinterImpl) dispatchAsyncLint(ctx context.Context, ns, name, linterURL, ruleset string, specBytes []byte) { + bgCtx := context.WithoutCancel(ctx) + l.wg.Add(1) + go func() { + defer l.wg.Done() + log := logr.FromContextOrDiscard(bgCtx).WithName("linting") + + lintResult, err := l.executeLint(bgCtx, linterURL, ruleset, specBytes) + if err != nil { + log.Error(err, "Async OAS linting failed", "namespace", ns, "name", name) + } else if !lintResult.Passed { + log.Info("Linting failed", "namespace", ns, "name", name, "message", lintResult.Message) + } + + if _, patchErr := l.objStore.Patch(bgCtx, ns, name, store.Patch{ + Op: store.OpReplace, + Path: "/spec/lint", + Value: lintResult, + }); patchErr != nil { + log.Error(patchErr, "Failed to update lint result", "namespace", ns, "name", name) + } + }() +} + +func (l *apiLinterImpl) executeLint(ctx context.Context, linterURL, ruleset string, specBytes []byte) (*roverv1.LintResult, error) { + var opts []oaslint.ExternalLinterOption + if ruleset != "" { + opts = append(opts, oaslint.WithRuleset(ruleset)) + } + opts = append(opts, oaslint.WithHTTPClient(l.httpClient)) + linter := oaslint.NewExternalLinter(linterURL, opts...) + + result, err := linter.Lint(ctx, specBytes) + if err != nil { + return &roverv1.LintResult{ + Passed: false, + Message: fmt.Sprintf("linter API error: %s", err), + }, fmt.Errorf("linter API error: %w", err) + } + return l.buildLintResult(result, linterURL), nil +} + +func (l *apiLinterImpl) buildLintResult(result *oaslint.LintResult, linterURL string) *roverv1.LintResult { + lintResult := &roverv1.LintResult{ + Passed: result.Passed, + Message: result.Reason, + } + if linterURL != "" && result.LinterId != "" { + lintResult.DashboardURL = fmt.Sprintf("%s/scans/%s", strings.TrimRight(linterURL, "/"), result.LinterId) + } + if !result.Passed { + lintResult.Message = strings.ReplaceAll(l.errorMessage, "RULESET_NAME_PLACEHOLDER", result.Ruleset) + } + return lintResult +} diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 90affa93..89d5ad03 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -12,24 +12,21 @@ import ( "fmt" "io" "strings" - "sync" - "time" "github.com/go-logr/logr" "github.com/gofiber/fiber/v2" "github.com/pkg/errors" apiv1 "github.com/telekom/controlplane/api/api/v1" - commonclient "github.com/telekom/controlplane/common-server/pkg/client" "github.com/telekom/controlplane/common-server/pkg/problems" "github.com/telekom/controlplane/common-server/pkg/server/middleware/security" "github.com/telekom/controlplane/common-server/pkg/store" filesapi "github.com/telekom/controlplane/file-manager/api" "github.com/telekom/controlplane/rover-server/internal/file" - "github.com/telekom/controlplane/rover-server/internal/oaslint" roverv1 "github.com/telekom/controlplane/rover/api/v1" "gopkg.in/yaml.v3" "github.com/telekom/controlplane/rover-server/internal/api" + "github.com/telekom/controlplane/rover-server/internal/config" "github.com/telekom/controlplane/rover-server/internal/mapper" "github.com/telekom/controlplane/rover-server/internal/mapper/apispecification/in" "github.com/telekom/controlplane/rover-server/internal/mapper/apispecification/out" @@ -48,31 +45,15 @@ type ApiSpecificationController struct { // If nil, category validation is skipped. ListApiCategories func(ctx context.Context) (*apiv1.ApiCategoryList, error) - // ErrorMessage is the template message shown when linting fails. - ErrorMessage string - - // LintAsync controls whether linting runs asynchronously (true) or synchronously (false). - // When false (default), linting blocks the request so a single store operation includes the result. - LintAsync bool - - // httpClient is reused across lint requests for connection pooling and metrics. - httpClient oaslint.HTTPDoer - - // lintWg tracks in-flight async lint goroutines for graceful shutdown. - lintWg sync.WaitGroup + // Linter handles OAS linting operations. If nil, linting is disabled. + Linter ApiLinter } -func NewApiSpecificationController(stores *s.Stores, errorMessage string, lintTimeout time.Duration, lintAsync bool) *ApiSpecificationController { +func NewApiSpecificationController(stores *s.Stores, lintCfg config.OasLintingConfig) *ApiSpecificationController { ctrl := &ApiSpecificationController{ - stores: stores, - Store: stores.APISpecificationStore, - ErrorMessage: errorMessage, - LintAsync: lintAsync, - httpClient: commonclient.NewHttpClientOrDie( - commonclient.WithClientName("oaslint"), - commonclient.WithClientTimeout(lintTimeout), - commonclient.WithSkipTlsVerify(true), - ), + stores: stores, + Store: stores.APISpecificationStore, + Linter: NewApiLinter(stores.APISpecificationStore, lintCfg), } if stores.APICategoryStore != nil { ctrl.ListApiCategories = func(ctx context.Context) (*apiv1.ApiCategoryList, error) { @@ -93,7 +74,9 @@ func NewApiSpecificationController(stores *s.Stores, errorMessage string, lintTi // Shutdown waits for all in-flight async lint operations to complete. func (a *ApiSpecificationController) Shutdown() { - a.lintWg.Wait() + if a.Linter != nil { + a.Linter.Shutdown() + } } // Create implements server.ApiSpecificationController. @@ -259,41 +242,14 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri } EnsureLabelsOrDie(ctx, apiSpec) - // Look up the ApiCategory's linting config for this spec's category. - // If the category has a linter URL, proceed with linting. - var needsLint bool - log := logr.FromContextOrDiscard(ctx) - log.V(1).Info("Looking up linting config", "namespace", apiSpec.Namespace, "name", apiSpec.Name, - "category", apiSpec.Spec.Category, "basepath", apiSpec.Spec.BasePath) - lintCfg := lintingConfigFromList(categoryList, apiSpec.Spec.Category) - if lintCfg != nil && lintCfg.URL != "" && lintCfg.Mode != apiv1.LintingModeNone { - log.V(1).Info("Linting config found, checking whitelists and hash dedup", "namespace", apiSpec.Namespace, "name", apiSpec.Name) - // Fetch existing object for hash dedup comparison. - existing, _ := a.Store.Get(ctx, apiSpec.Namespace, apiSpec.Name) - needsLint = a.prepareLinting(lintCfg, apiSpec, existing) - log.V(1).Info("prepareLinting completed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "needsLint", needsLint) - } else { - log.V(1).Info("No linting config or no URL, skipping linting", "namespace", apiSpec.Namespace, "name", apiSpec.Name) - } - - // Run linting synchronously (default) so the result is included in the single store write, - // or dispatch asynchronously if configured. - if needsLint { - if a.LintAsync { - // Store first, then lint in the background and patch afterwards. - err = a.Store.CreateOrReplace(ctx, apiSpec) - if err != nil { - return res, err - } - a.dispatchAsyncLint(ctx, apiSpec.Namespace, apiSpec.Name, lintCfg.URL, lintCfg.Ruleset, specMarshaled) - return a.Get(ctx, resourceId) + // Lint the spec if the category has linting configured. + if a.Linter != nil { + outcome, lintErr := a.Linter.Lint(ctx, apiSpec, categoryList, specMarshaled) + if lintErr != nil { + return res, problems.InternalServerError("Linting failed", lintErr.Error()) } - // Synchronous: lint blocks until result is available, then store once. - if err := a.runSyncLint(ctx, apiSpec, lintCfg.URL, lintCfg.Ruleset, specMarshaled); err != nil { - // Store the spec with the failed lint result so it's persisted, - // then return 500 to inform the client about the infrastructure error. - _ = a.Store.CreateOrReplace(ctx, apiSpec) - return res, problems.InternalServerError("Linting failed", err.Error()) + if outcome == LintDispatched { + return a.Get(ctx, resourceId) } } diff --git a/rover-server/internal/controller/apispecification_lint.go b/rover-server/internal/controller/apispecification_lint.go index 042e221a..d409c521 100644 --- a/rover-server/internal/controller/apispecification_lint.go +++ b/rover-server/internal/controller/apispecification_lint.go @@ -5,37 +5,11 @@ package controller import ( - "context" - "fmt" "strings" - "github.com/go-logr/logr" apiv1 "github.com/telekom/controlplane/api/api/v1" - "github.com/telekom/controlplane/common-server/pkg/store" - "github.com/telekom/controlplane/rover-server/internal/oaslint" - roverv1 "github.com/telekom/controlplane/rover/api/v1" ) -// prepareLinting checks whitelists and hash dedup synchronously. -// It returns true if an external linter call is needed. -func (a *ApiSpecificationController) prepareLinting(lintCfg *apiv1.LintingConfig, apiSpec *roverv1.ApiSpecification, existing *roverv1.ApiSpecification) bool { - // Check basepath whitelist (category-level). - if isBasepathWhitelisted(lintCfg, apiSpec.Spec.BasePath) { - apiSpec.Spec.Lint = &roverv1.LintResult{Passed: true, Message: fmt.Sprintf("The basepath %q is whitelisted", apiSpec.Spec.BasePath)} - return false - } - - // Hash dedup: if the spec content hasn't changed and a previous lint result exists, reuse it. - if existing != nil && existing.Spec.Lint != nil && existing.Spec.Hash == apiSpec.Spec.Hash { - apiSpec.Spec.Lint = existing.Spec.Lint - return false - } - - // Clear previous result — the actual linter call will follow. - apiSpec.Spec.Lint = nil - return true -} - // isBasepathWhitelisted checks if the given basepath is whitelisted in the category-level linting config. func isBasepathWhitelisted(lintCfg *apiv1.LintingConfig, basepath string) bool { for _, wl := range lintCfg.WhitelistedBasepaths { @@ -46,87 +20,6 @@ func isBasepathWhitelisted(lintCfg *apiv1.LintingConfig, basepath string) bool { return false } -// executeLint calls the external linter and returns the CRD lint result. -// This is the single lint execution path used by both sync and async flows. -func (a *ApiSpecificationController) executeLint(ctx context.Context, linterURL, ruleset string, specBytes []byte) (*roverv1.LintResult, error) { - var opts []oaslint.ExternalLinterOption - if ruleset != "" { - opts = append(opts, oaslint.WithRuleset(ruleset)) - } - opts = append(opts, oaslint.WithHTTPClient(a.httpClient)) - linter := oaslint.NewExternalLinter(linterURL, opts...) - - result, err := linter.Lint(ctx, specBytes) - if err != nil { - return &roverv1.LintResult{ - Passed: false, - Message: fmt.Sprintf("linter API error: %s", err), - }, fmt.Errorf("linter API error: %w", err) - } - - return a.buildLintResult(result, linterURL), nil -} - -// runSyncLint calls the external linter synchronously and sets the lint result directly on the apiSpec. -// It returns an error for infrastructure failures (e.g. linter unreachable or auth errors) -// which should be surfaced as 500 Internal Server Error to the client. -func (a *ApiSpecificationController) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, linterURL, ruleset string, specBytes []byte) error { - log := logr.FromContextOrDiscard(ctx).WithName("linting") - - lintResult, err := a.executeLint(ctx, linterURL, ruleset, specBytes) - apiSpec.Spec.Lint = lintResult - if err != nil { - log.Error(err, "Sync OAS linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name) - return err - } - if !lintResult.Passed { - log.Info("Linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "message", lintResult.Message) - } - return nil -} - -// dispatchAsyncLint runs the lint call in a tracked background goroutine. -// It updates the ApiSpecification via store patch when done. -func (a *ApiSpecificationController) dispatchAsyncLint(ctx context.Context, ns, name, linterURL, ruleset string, specBytes []byte) { - bgCtx := context.WithoutCancel(ctx) - a.lintWg.Add(1) - go func() { - defer a.lintWg.Done() - log := logr.FromContextOrDiscard(bgCtx).WithName("linting") - - lintResult, err := a.executeLint(bgCtx, linterURL, ruleset, specBytes) - if err != nil { - log.Error(err, "Async OAS linting failed", "namespace", ns, "name", name) - } else if !lintResult.Passed { - log.Info("Linting failed", "namespace", ns, "name", name, "message", lintResult.Message) - } - - if _, patchErr := a.Store.Patch(bgCtx, ns, name, store.Patch{ - Op: store.OpReplace, - Path: "/spec/lint", - Value: lintResult, - }); patchErr != nil { - log.Error(patchErr, "Failed to update lint result", "namespace", ns, "name", name) - } - }() -} - -// buildLintResult maps an oaslint.LintResult to the CRD's roverv1.LintResult, -// including dashboard URL and error message template substitution. -func (a *ApiSpecificationController) buildLintResult(result *oaslint.LintResult, linterURL string) *roverv1.LintResult { - lintResult := &roverv1.LintResult{ - Passed: result.Passed, - Message: result.Reason, - } - if linterURL != "" && result.LinterId != "" { - lintResult.DashboardURL = fmt.Sprintf("%s/scans/%s", strings.TrimRight(linterURL, "/"), result.LinterId) - } - if !result.Passed { - lintResult.Message = strings.ReplaceAll(a.ErrorMessage, "RULESET_NAME_PLACEHOLDER", result.Ruleset) - } - return lintResult -} - // lintingConfigFromList finds the linting configuration from a pre-fetched ApiCategoryList. // Returns nil if no linting is configured for this category. func lintingConfigFromList(categoryList *apiv1.ApiCategoryList, category string) *apiv1.LintingConfig { diff --git a/rover-server/internal/controller/apispecification_lint_test.go b/rover-server/internal/controller/apispecification_lint_test.go index f508d1aa..eace96e7 100644 --- a/rover-server/internal/controller/apispecification_lint_test.go +++ b/rover-server/internal/controller/apispecification_lint_test.go @@ -51,10 +51,10 @@ var _ = Describe("Linting helpers", func() { }) Describe("prepareLinting", func() { - var ctrl *ApiSpecificationController + var linter *apiLinterImpl BeforeEach(func() { - ctrl = &ApiSpecificationController{} + linter = &apiLinterImpl{} }) It("should skip linting for category-whitelisted basepath", func() { @@ -66,7 +66,7 @@ var _ = Describe("Linting helpers", func() { Spec: roverv1.ApiSpecificationSpec{BasePath: "/eni/internal/v1"}, } - result := ctrl.prepareLinting(lintCfg, apiSpec, nil) + result := linter.prepareLinting(lintCfg, apiSpec, nil) Expect(result).To(BeFalse()) Expect(apiSpec.Spec.Lint).ToNot(BeNil()) Expect(apiSpec.Spec.Lint.Passed).To(BeTrue()) @@ -89,7 +89,7 @@ var _ = Describe("Linting helpers", func() { }, } - result := ctrl.prepareLinting(lintCfg, apiSpec, existing) + result := linter.prepareLinting(lintCfg, apiSpec, existing) Expect(result).To(BeFalse()) Expect(apiSpec.Spec.Lint).ToNot(BeNil()) Expect(apiSpec.Spec.Lint.Passed).To(BeTrue()) @@ -111,7 +111,7 @@ var _ = Describe("Linting helpers", func() { }, } - result := ctrl.prepareLinting(lintCfg, apiSpec, existing) + result := linter.prepareLinting(lintCfg, apiSpec, existing) Expect(result).To(BeTrue()) Expect(apiSpec.Spec.Lint).To(BeNil()) }) @@ -125,7 +125,7 @@ var _ = Describe("Linting helpers", func() { }, } - result := ctrl.prepareLinting(lintCfg, apiSpec, nil) + result := linter.prepareLinting(lintCfg, apiSpec, nil) Expect(result).To(BeTrue()) Expect(apiSpec.Spec.Lint).To(BeNil()) }) diff --git a/rover-server/internal/controller/suite_controller_test.go b/rover-server/internal/controller/suite_controller_test.go index 6499c997..febf9b42 100644 --- a/rover-server/internal/controller/suite_controller_test.go +++ b/rover-server/internal/controller/suite_controller_test.go @@ -113,7 +113,7 @@ var _ = BeforeSuite(func() { s := server.Server{ Config: &config.ServerConfig{}, Log: log.Log, - ApiSpecifications: NewApiSpecificationController(stores, "", 0, false), + ApiSpecifications: NewApiSpecificationController(stores, config.OasLintingConfig{}), Rovers: NewRoverController(stores), Roadmaps: NewRoadmapController(stores), EventSpecifications: NewEventSpecificationController(stores), From 941910da2d5975a91c4f7484df205b44e7f04f8f Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 09:48:09 +0200 Subject: [PATCH 23/42] refactor: use commonclient error handler + reduce memory usage --- rover-server/internal/oaslint/external.go | 22 +++++-------------- .../internal/oaslint/external_test.go | 18 +-------------- 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/rover-server/internal/oaslint/external.go b/rover-server/internal/oaslint/external.go index d991277c..379a3688 100644 --- a/rover-server/internal/oaslint/external.go +++ b/rover-server/internal/oaslint/external.go @@ -9,8 +9,9 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" + + commonclient "github.com/telekom/controlplane/common-server/pkg/client" ) const ( @@ -103,25 +104,12 @@ func (l *ExternalLinter) Lint(ctx context.Context, spec []byte) (*LintResult, er } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading linter response: %w", err) - } - - if resp.StatusCode == http.StatusRequestTimeout { - return nil, fmt.Errorf("linting timed out (HTTP 408)") - } - - if resp.StatusCode >= 500 { - return nil, fmt.Errorf("linter service unavailable (HTTP %d)", resp.StatusCode) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("linter API returned unexpected status %d", resp.StatusCode) + if err := commonclient.HandleError(resp.StatusCode, "linter API"); err != nil { + return nil, fmt.Errorf("linter API error: %w", err) } var scan linterScanResponse - if err := json.Unmarshal(body, &scan); err != nil { + if err := json.NewDecoder(resp.Body).Decode(&scan); err != nil { return nil, fmt.Errorf("decoding linter response: %w", err) } diff --git a/rover-server/internal/oaslint/external_test.go b/rover-server/internal/oaslint/external_test.go index e6218d77..dda6a0e1 100644 --- a/rover-server/internal/oaslint/external_test.go +++ b/rover-server/internal/oaslint/external_test.go @@ -123,23 +123,7 @@ servers: It("should return an error", func() { result, err := linter.Lint(ctx, spec) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("linter service unavailable")) - Expect(result).To(BeNil()) - }) - }) - - Context("when the linter API returns 408 timeout", func() { - BeforeEach(func() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusRequestTimeout) - })) - linter = NewExternalLinter(server.URL) - }) - - It("should return a timeout error", func() { - result, err := linter.Lint(ctx, spec) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("timed out")) + Expect(err.Error()).To(ContainSubstring("linter API error")) Expect(result).To(BeNil()) }) }) From e51d49d002e4ae8aa0ab295c6b0c770bdb18e69d Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 10:04:11 +0200 Subject: [PATCH 24/42] refactor: remove async mode for now --- rover-server/internal/config/config.go | 2 - rover-server/internal/controller/apilinter.go | 60 ++++--------------- .../internal/controller/apispecification.go | 13 +--- .../handler/apispecification/handler.go | 24 ++------ .../handler/apispecification/handler_test.go | 9 +-- 5 files changed, 20 insertions(+), 88 deletions(-) diff --git a/rover-server/internal/config/config.go b/rover-server/internal/config/config.go index a6273dba..fd818ef8 100644 --- a/rover-server/internal/config/config.go +++ b/rover-server/internal/config/config.go @@ -23,7 +23,6 @@ type ServerConfig struct { type OasLintingConfig struct { ErrorMessage string `json:"errorMessage"` Timeout time.Duration `json:"timeout"` - Async bool `json:"async"` } type SecurityConfig struct { @@ -83,7 +82,6 @@ func setDefaults() { // OAS Linting viper.SetDefault("oasLinting.errorMessage", "Linter scan result contains errors. Please visit the linter UI for details on the RULESET_NAME_PLACEHOLDER ruleset.") viper.SetDefault("oasLinting.timeout", 0) // 0 means block indefinitely until linter responds - viper.SetDefault("oasLinting.async", false) // Database viper.SetDefault("database.filepath", "") // empty string means in-memory only diff --git a/rover-server/internal/controller/apilinter.go b/rover-server/internal/controller/apilinter.go index dc269574..dd41afaa 100644 --- a/rover-server/internal/controller/apilinter.go +++ b/rover-server/internal/controller/apilinter.go @@ -8,7 +8,6 @@ import ( "context" "fmt" "strings" - "sync" "github.com/go-logr/logr" apiv1 "github.com/telekom/controlplane/api/api/v1" @@ -27,35 +26,31 @@ const ( LintSkipped LintOutcome = iota // LintCompleted means linting ran synchronously and the result is on apiSpec.Spec.Lint. LintCompleted - // LintDispatched means the spec was stored and linting is running asynchronously. - // The caller should NOT store the spec again. - LintDispatched ) // ApiLinter abstracts the full OAS linting lifecycle: config lookup, -// whitelists, hash dedup, sync/async execution, and store interaction. +// whitelists, hash dedup, execution, and store interaction. +// +// NOTE: Linting is currently synchronous only. Async linting was considered but +// intentionally left out because the rover operator cannot self-heal if +// rover-server dies mid-lint — the ApiSpecification would be stuck with a nil +// Lint result forever. If async linting is needed in the future, it should be +// implemented as a proper async reconciliation loop in the operator (requeue +// until the lint result appears) rather than a fire-and-forget goroutine here. type ApiLinter interface { // Lint performs the full linting lifecycle for an ApiSpecification. // It looks up the linting config from the category list, checks whitelists - // and hash dedup, and either runs the linter synchronously or dispatches - // it asynchronously. + // and hash dedup, and runs the linter synchronously. // - // Returns the outcome and an error for infrastructure failures during sync linting. - // When the outcome is LintDispatched, the spec has already been stored — the caller - // must not store it again. + // Returns the outcome and an error for infrastructure failures. Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, categoryList *apiv1.ApiCategoryList, specBytes []byte) (LintOutcome, error) - - // Shutdown waits for all in-flight async lint operations to complete. - Shutdown() } // apiLinterImpl is the production implementation of ApiLinter. type apiLinterImpl struct { objStore store.ObjectStore[*roverv1.ApiSpecification] errorMessage string - async bool httpClient oaslint.HTTPDoer - wg sync.WaitGroup } // NewApiLinter creates an ApiLinter from the given linting configuration. @@ -63,7 +58,6 @@ func NewApiLinter(objStore store.ObjectStore[*roverv1.ApiSpecification], lintCfg return &apiLinterImpl{ objStore: objStore, errorMessage: lintCfg.ErrorMessage, - async: lintCfg.Async, httpClient: commonclient.NewHttpClientOrDie( commonclient.WithClientName("oaslint"), commonclient.WithClientTimeout(lintCfg.Timeout), @@ -72,8 +66,6 @@ func NewApiLinter(objStore store.ObjectStore[*roverv1.ApiSpecification], lintCfg } } -func (l *apiLinterImpl) Shutdown() { l.wg.Wait() } - func (l *apiLinterImpl) Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, categoryList *apiv1.ApiCategoryList, specBytes []byte) (LintOutcome, error) { log := logr.FromContextOrDiscard(ctx) log.V(1).Info("Looking up linting config", "namespace", apiSpec.Namespace, "name", apiSpec.Name, @@ -92,14 +84,6 @@ func (l *apiLinterImpl) Lint(ctx context.Context, apiSpec *roverv1.ApiSpecificat return LintSkipped, nil } - if l.async { - if err := l.objStore.CreateOrReplace(ctx, apiSpec); err != nil { - return LintSkipped, err - } - l.dispatchAsyncLint(ctx, apiSpec.Namespace, apiSpec.Name, lintCfg.URL, lintCfg.Ruleset, specBytes) - return LintDispatched, nil - } - if err := l.runSyncLint(ctx, apiSpec, lintCfg.URL, lintCfg.Ruleset, specBytes); err != nil { _ = l.objStore.CreateOrReplace(ctx, apiSpec) return LintCompleted, err @@ -134,30 +118,6 @@ func (l *apiLinterImpl) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpe return nil } -func (l *apiLinterImpl) dispatchAsyncLint(ctx context.Context, ns, name, linterURL, ruleset string, specBytes []byte) { - bgCtx := context.WithoutCancel(ctx) - l.wg.Add(1) - go func() { - defer l.wg.Done() - log := logr.FromContextOrDiscard(bgCtx).WithName("linting") - - lintResult, err := l.executeLint(bgCtx, linterURL, ruleset, specBytes) - if err != nil { - log.Error(err, "Async OAS linting failed", "namespace", ns, "name", name) - } else if !lintResult.Passed { - log.Info("Linting failed", "namespace", ns, "name", name, "message", lintResult.Message) - } - - if _, patchErr := l.objStore.Patch(bgCtx, ns, name, store.Patch{ - Op: store.OpReplace, - Path: "/spec/lint", - Value: lintResult, - }); patchErr != nil { - log.Error(patchErr, "Failed to update lint result", "namespace", ns, "name", name) - } - }() -} - func (l *apiLinterImpl) executeLint(ctx context.Context, linterURL, ruleset string, specBytes []byte) (*roverv1.LintResult, error) { var opts []oaslint.ExternalLinterOption if ruleset != "" { diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 89d5ad03..4c2985df 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -72,13 +72,6 @@ func NewApiSpecificationController(stores *s.Stores, lintCfg config.OasLintingCo return ctrl } -// Shutdown waits for all in-flight async lint operations to complete. -func (a *ApiSpecificationController) Shutdown() { - if a.Linter != nil { - a.Linter.Shutdown() - } -} - // Create implements server.ApiSpecificationController. func (a *ApiSpecificationController) Create(ctx context.Context, req api.ApiSpecificationCreateRequest) (res api.ApiSpecificationResponse, err error) { // Important Hint: This is a declarative API. The client should not create an ApiSpecification, but only use @@ -244,13 +237,9 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri // Lint the spec if the category has linting configured. if a.Linter != nil { - outcome, lintErr := a.Linter.Lint(ctx, apiSpec, categoryList, specMarshaled) - if lintErr != nil { + if _, lintErr := a.Linter.Lint(ctx, apiSpec, categoryList, specMarshaled); lintErr != nil { return res, problems.InternalServerError("Linting failed", lintErr.Error()) } - if outcome == LintDispatched { - return a.Get(ctx, resourceId) - } } err = a.Store.CreateOrReplace(ctx, apiSpec) diff --git a/rover/internal/handler/apispecification/handler.go b/rover/internal/handler/apispecification/handler.go index 71ff7d16..567c3c4e 100644 --- a/rover/internal/handler/apispecification/handler.go +++ b/rover/internal/handler/apispecification/handler.go @@ -8,7 +8,6 @@ import ( "context" "fmt" - "github.com/go-logr/logr" "github.com/pkg/errors" apiapi "github.com/telekom/controlplane/api/api/v1" "github.com/telekom/controlplane/common/pkg/client" @@ -31,27 +30,12 @@ type ApiSpecificationHandler struct { } func (h *ApiSpecificationHandler) CreateOrUpdate(ctx context.Context, apiSpec *roverv1.ApiSpecification) error { - log := logr.FromContextOrDiscard(ctx) mode := h.lookupLintingMode(ctx, apiSpec.Spec.Category) - // Linting is pending (async) — Spec.Lint is nil, wait for rover-server to fill it in. - if apiSpec.Spec.Lint == nil { - if mode == apiapi.LintingModeNone { - // No linting configured for this category — proceed normally. - return h.createOrUpdateApi(ctx, apiSpec) - } - if mode == apiapi.LintingModeBlock { - apiSpec.SetCondition(condition.NewNotReadyCondition("LintingPending", - "API specification is being linted")) - return nil - } - // warn mode: proceed without waiting for lint result - log.V(0).Info("Linting pending in warn mode, proceeding with Api creation") - return h.createOrUpdateApi(ctx, apiSpec) - } - - // Check if linting failed and the category config blocks on failure - if !apiSpec.Spec.Lint.Passed && mode == apiapi.LintingModeBlock { + // Check if linting failed and the category config blocks on failure. + // If Spec.Lint is nil (no result yet or linting not configured), proceed normally + // to avoid blocking indefinitely if the linter is unavailable. + if apiSpec.Spec.Lint != nil && !apiSpec.Spec.Lint.Passed && mode == apiapi.LintingModeBlock { msg := fmt.Sprintf("OAS linting failed: %s", apiSpec.Spec.Lint.Message) if apiSpec.Spec.Lint.DashboardURL != "" { msg = fmt.Sprintf("%s. View details: %s", msg, apiSpec.Spec.Lint.DashboardURL) diff --git a/rover/internal/handler/apispecification/handler_test.go b/rover/internal/handler/apispecification/handler_test.go index 6935a2cb..232b9c25 100644 --- a/rover/internal/handler/apispecification/handler_test.go +++ b/rover/internal/handler/apispecification/handler_test.go @@ -122,7 +122,8 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { }) Context("when linting is pending (Spec.Lint nil, block mode)", func() { - It("should set not-ready condition", func() { + It("should proceed with Api creation to avoid blocking indefinitely", func() { + mockCtx := setupMockClient(ctx) h := &handler.ApiSpecificationHandler{ GetApiCategory: getApiCategoryWith(newApiCategory("other", &apiapi.LintingConfig{ Mode: apiapi.LintingModeBlock, @@ -130,10 +131,10 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { } apiSpec := newApiSpec("hash1", "other") - err := h.CreateOrUpdate(ctx, apiSpec) + err := h.CreateOrUpdate(mockCtx, apiSpec) Expect(err).ToNot(HaveOccurred()) - Expect(hasCondition(apiSpec, condition.ConditionTypeReady)).To(BeTrue()) - Expect(conditionMessage(apiSpec, condition.ConditionTypeReady)).To(ContainSubstring("being linted")) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("API updated")) }) }) From d573338af1be972fd96fe18c60ba23e9f51ea664 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 10:08:17 +0200 Subject: [PATCH 25/42] chore: UPDATE_SNAPS --- .../controller/__snapshots__/suite_controller_test.snap | 4 ++-- .../internal/mapper/status/__snapshots__/status_test.snap | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap index 4ae29dce..61b52782 100644 --- a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap +++ b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap @@ -449,7 +449,7 @@ [Rover Controller Get rover status should return the status of a rover successfully - 1] { - "createdAt": "2025-09-18T10:39:44+02:00", + "createdAt": "2025-09-18T08:39:44Z", "errors": [ { "cause": "NoApproval", @@ -464,7 +464,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T09:16:40+02:00", + "processedAt": "2025-10-08T07:16:40Z", "processingState": "done", "state": "blocked" } diff --git a/rover-server/internal/mapper/status/__snapshots__/status_test.snap b/rover-server/internal/mapper/status/__snapshots__/status_test.snap index 3f445fed..5e298203 100644 --- a/rover-server/internal/mapper/status/__snapshots__/status_test.snap +++ b/rover-server/internal/mapper/status/__snapshots__/status_test.snap @@ -19,7 +19,7 @@ [Rover Status Mapper MapRoverResponse must map rover response correctly - 1] { - "createdAt": "2025-09-18T10:39:44+02:00", + "createdAt": "2025-09-18T08:39:44Z", "errors": [ { "cause": "NoApproval", @@ -34,7 +34,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T09:16:40+02:00", + "processedAt": "2025-10-08T07:16:40Z", "processingState": "done", "state": "blocked" } From 72197cbc785f6cd06df57fa4b1a70cfc80878e8e Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 10:24:47 +0200 Subject: [PATCH 26/42] refactor: update linting configuration and remove unused URL fields --- api/api/v1/apicategory_types.go | 9 +------ .../api.cp.ei.telekom.de_apicategories.yaml | 25 ++++++++----------- rover-server/internal/config/config.go | 4 +++ rover-server/internal/controller/apilinter.go | 24 ++++++++++-------- .../controller/apispecification_lint_test.go | 9 +++---- 5 files changed, 32 insertions(+), 39 deletions(-) diff --git a/api/api/v1/apicategory_types.go b/api/api/v1/apicategory_types.go index 1fc58433..c5317bff 100644 --- a/api/api/v1/apicategory_types.go +++ b/api/api/v1/apicategory_types.go @@ -27,12 +27,6 @@ const ( // LintingConfig configures OAS specification linting for APIs in this category. type LintingConfig struct { - // URL is the base URL of the external linter service. - // When set, linting is enabled for this category. - // +kubebuilder:validation:Format=uri - // +optional - URL string `json:"url,omitempty"` - // Ruleset is the name of the linter ruleset to apply. // If set, it is passed as a query parameter to the linter API. Ruleset string `json:"ruleset"` @@ -45,7 +39,7 @@ type LintingConfig struct { Mode LintingMode `json:"mode,omitempty"` // WhitelistedBasepaths is a list of API basepaths that are exempt from linting. - // APIs whose basePath matches an entry here will skip linting even when a linter URL is configured. + // APIs whose basePath matches an entry here will skip linting even when linting is configured. // Each entry must start with a leading slash. // +optional // +listType=set @@ -80,7 +74,6 @@ type ApiCategorySpec struct { MustHaveGroupPrefix bool `json:"mustHaveGroupPrefix,omitempty"` // Linting configures OAS specification linting for APIs in this category. - // If set with a URL, linting is enabled for this category. // +optional Linting *LintingConfig `json:"linting,omitempty"` } diff --git a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml index 05d6bb12..47aedf72 100644 --- a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml +++ b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml @@ -80,41 +80,36 @@ spec: minLength: 1 type: string linting: - description: |- - Linting configures OAS specification linting for APIs in this category. - If set with a URL, linting is enabled for this category. + description: Linting configures OAS specification linting for APIs + in this category. properties: mode: - default: block + default: Block description: |- Mode controls how linting failures affect API creation. - "block" (default) prevents Api creation on failure; "warn" allows it but surfaces issues. + "Block" (default) prevents Api creation on failure; "Warn" allows it but surfaces issues. enum: - - block - - warn - - none + - Block + - Warn + - None type: string ruleset: description: |- Ruleset is the name of the linter ruleset to apply. If set, it is passed as a query parameter to the linter API. type: string - url: - description: |- - URL is the base URL of the external linter service. - When set, linting is enabled for this category. - format: uri - type: string whitelistedBasepaths: description: |- WhitelistedBasepaths is a list of API basepaths that are exempt from linting. - APIs whose basePath matches an entry here will skip linting even when a linter URL is configured. + APIs whose basePath matches an entry here will skip linting even when linting is configured. Each entry must start with a leading slash. items: pattern: ^/ type: string type: array x-kubernetes-list-type: set + required: + - ruleset type: object mustHaveGroupPrefix: default: true diff --git a/rover-server/internal/config/config.go b/rover-server/internal/config/config.go index fd818ef8..c81dc63d 100644 --- a/rover-server/internal/config/config.go +++ b/rover-server/internal/config/config.go @@ -23,6 +23,8 @@ type ServerConfig struct { type OasLintingConfig struct { ErrorMessage string `json:"errorMessage"` Timeout time.Duration `json:"timeout"` + URL string `json:"url"` + DashboardURL string `json:"dashboardURL"` } type SecurityConfig struct { @@ -82,6 +84,8 @@ func setDefaults() { // OAS Linting viper.SetDefault("oasLinting.errorMessage", "Linter scan result contains errors. Please visit the linter UI for details on the RULESET_NAME_PLACEHOLDER ruleset.") viper.SetDefault("oasLinting.timeout", 0) // 0 means block indefinitely until linter responds + viper.SetDefault("oasLinting.url", "") + viper.SetDefault("oasLinting.dashboardURL", "") // Database viper.SetDefault("database.filepath", "") // empty string means in-memory only diff --git a/rover-server/internal/controller/apilinter.go b/rover-server/internal/controller/apilinter.go index dd41afaa..a51b9c32 100644 --- a/rover-server/internal/controller/apilinter.go +++ b/rover-server/internal/controller/apilinter.go @@ -50,6 +50,8 @@ type ApiLinter interface { type apiLinterImpl struct { objStore store.ObjectStore[*roverv1.ApiSpecification] errorMessage string + url string + dashboardURL string httpClient oaslint.HTTPDoer } @@ -58,6 +60,8 @@ func NewApiLinter(objStore store.ObjectStore[*roverv1.ApiSpecification], lintCfg return &apiLinterImpl{ objStore: objStore, errorMessage: lintCfg.ErrorMessage, + url: lintCfg.URL, + dashboardURL: lintCfg.DashboardURL, httpClient: commonclient.NewHttpClientOrDie( commonclient.WithClientName("oaslint"), commonclient.WithClientTimeout(lintCfg.Timeout), @@ -72,7 +76,7 @@ func (l *apiLinterImpl) Lint(ctx context.Context, apiSpec *roverv1.ApiSpecificat "category", apiSpec.Spec.Category, "basepath", apiSpec.Spec.BasePath) lintCfg := lintingConfigFromList(categoryList, apiSpec.Spec.Category) - if lintCfg == nil || lintCfg.URL == "" || lintCfg.Mode == apiv1.LintingModeNone { + if lintCfg == nil || l.url == "" || lintCfg.Mode == apiv1.LintingModeNone { log.V(1).Info("No linting config or no URL, skipping linting", "namespace", apiSpec.Namespace, "name", apiSpec.Name) return LintSkipped, nil } @@ -84,7 +88,7 @@ func (l *apiLinterImpl) Lint(ctx context.Context, apiSpec *roverv1.ApiSpecificat return LintSkipped, nil } - if err := l.runSyncLint(ctx, apiSpec, lintCfg.URL, lintCfg.Ruleset, specBytes); err != nil { + if err := l.runSyncLint(ctx, apiSpec, lintCfg.Ruleset, specBytes); err != nil { _ = l.objStore.CreateOrReplace(ctx, apiSpec) return LintCompleted, err } @@ -104,9 +108,9 @@ func (l *apiLinterImpl) prepareLinting(lintCfg *apiv1.LintingConfig, apiSpec *ro return true } -func (l *apiLinterImpl) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, linterURL, ruleset string, specBytes []byte) error { +func (l *apiLinterImpl) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, ruleset string, specBytes []byte) error { log := logr.FromContextOrDiscard(ctx).WithName("linting") - lintResult, err := l.executeLint(ctx, linterURL, ruleset, specBytes) + lintResult, err := l.executeLint(ctx, ruleset, specBytes) apiSpec.Spec.Lint = lintResult if err != nil { log.Error(err, "Sync OAS linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name) @@ -118,13 +122,13 @@ func (l *apiLinterImpl) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpe return nil } -func (l *apiLinterImpl) executeLint(ctx context.Context, linterURL, ruleset string, specBytes []byte) (*roverv1.LintResult, error) { +func (l *apiLinterImpl) executeLint(ctx context.Context, ruleset string, specBytes []byte) (*roverv1.LintResult, error) { var opts []oaslint.ExternalLinterOption if ruleset != "" { opts = append(opts, oaslint.WithRuleset(ruleset)) } opts = append(opts, oaslint.WithHTTPClient(l.httpClient)) - linter := oaslint.NewExternalLinter(linterURL, opts...) + linter := oaslint.NewExternalLinter(l.url, opts...) result, err := linter.Lint(ctx, specBytes) if err != nil { @@ -133,16 +137,16 @@ func (l *apiLinterImpl) executeLint(ctx context.Context, linterURL, ruleset stri Message: fmt.Sprintf("linter API error: %s", err), }, fmt.Errorf("linter API error: %w", err) } - return l.buildLintResult(result, linterURL), nil + return l.buildLintResult(result), nil } -func (l *apiLinterImpl) buildLintResult(result *oaslint.LintResult, linterURL string) *roverv1.LintResult { +func (l *apiLinterImpl) buildLintResult(result *oaslint.LintResult) *roverv1.LintResult { lintResult := &roverv1.LintResult{ Passed: result.Passed, Message: result.Reason, } - if linterURL != "" && result.LinterId != "" { - lintResult.DashboardURL = fmt.Sprintf("%s/scans/%s", strings.TrimRight(linterURL, "/"), result.LinterId) + if l.dashboardURL != "" && result.LinterId != "" { + lintResult.DashboardURL = fmt.Sprintf("%s/scans/%s", strings.TrimRight(l.dashboardURL, "/"), result.LinterId) } if !result.Passed { lintResult.Message = strings.ReplaceAll(l.errorMessage, "RULESET_NAME_PLACEHOLDER", result.Ruleset) diff --git a/rover-server/internal/controller/apispecification_lint_test.go b/rover-server/internal/controller/apispecification_lint_test.go index eace96e7..9a26732d 100644 --- a/rover-server/internal/controller/apispecification_lint_test.go +++ b/rover-server/internal/controller/apispecification_lint_test.go @@ -59,7 +59,6 @@ var _ = Describe("Linting helpers", func() { It("should skip linting for category-whitelisted basepath", func() { lintCfg := &apiv1.LintingConfig{ - URL: "https://linter.example.com", WhitelistedBasepaths: []string{"/eni/internal/v1"}, } apiSpec := &roverv1.ApiSpecification{ @@ -74,7 +73,7 @@ var _ = Describe("Linting helpers", func() { }) It("should skip linting when spec hash is unchanged and previous result exists", func() { - lintCfg := &apiv1.LintingConfig{URL: "https://linter.example.com"} + lintCfg := &apiv1.LintingConfig{} existing := &roverv1.ApiSpecification{ Spec: roverv1.ApiSpecificationSpec{ BasePath: "/eni/test/v1", @@ -96,7 +95,7 @@ var _ = Describe("Linting helpers", func() { }) It("should require linting when spec hash changed", func() { - lintCfg := &apiv1.LintingConfig{URL: "https://linter.example.com"} + lintCfg := &apiv1.LintingConfig{} existing := &roverv1.ApiSpecification{ Spec: roverv1.ApiSpecificationSpec{ BasePath: "/eni/test/v1", @@ -117,7 +116,7 @@ var _ = Describe("Linting helpers", func() { }) It("should require linting for new spec (no existing object)", func() { - lintCfg := &apiv1.LintingConfig{URL: "https://linter.example.com"} + lintCfg := &apiv1.LintingConfig{} apiSpec := &roverv1.ApiSpecification{ Spec: roverv1.ApiSpecificationSpec{ BasePath: "/eni/test/v1", @@ -151,7 +150,6 @@ var _ = Describe("Linting helpers", func() { Spec: apiv1.ApiCategorySpec{ LabelValue: "my-cat", Linting: &apiv1.LintingConfig{ - URL: "https://linter.example.com", Mode: apiv1.LintingModeWarn, }, }, @@ -160,7 +158,6 @@ var _ = Describe("Linting helpers", func() { } result := lintingConfigFromList(list, "my-cat") Expect(result).ToNot(BeNil()) - Expect(result.URL).To(Equal("https://linter.example.com")) Expect(result.Mode).To(Equal(apiv1.LintingModeWarn)) }) From 05c4c4e3d394a08befc731277091feb0e9ca3184 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 10:48:50 +0200 Subject: [PATCH 27/42] refactor: improve to access the store intead --- .../internal/controller/apispecification.go | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 4c2985df..a8129095 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -21,18 +21,17 @@ import ( "github.com/telekom/controlplane/common-server/pkg/server/middleware/security" "github.com/telekom/controlplane/common-server/pkg/store" filesapi "github.com/telekom/controlplane/file-manager/api" - "github.com/telekom/controlplane/rover-server/internal/file" - roverv1 "github.com/telekom/controlplane/rover/api/v1" - "gopkg.in/yaml.v3" - "github.com/telekom/controlplane/rover-server/internal/api" "github.com/telekom/controlplane/rover-server/internal/config" + "github.com/telekom/controlplane/rover-server/internal/file" "github.com/telekom/controlplane/rover-server/internal/mapper" "github.com/telekom/controlplane/rover-server/internal/mapper/apispecification/in" "github.com/telekom/controlplane/rover-server/internal/mapper/apispecification/out" "github.com/telekom/controlplane/rover-server/internal/mapper/status" "github.com/telekom/controlplane/rover-server/internal/server" s "github.com/telekom/controlplane/rover-server/pkg/store" + roverv1 "github.com/telekom/controlplane/rover/api/v1" + "gopkg.in/yaml.v3" ) var _ server.ApiSpecificationController = &ApiSpecificationController{} @@ -41,10 +40,6 @@ type ApiSpecificationController struct { stores *s.Stores Store store.ObjectStore[*roverv1.ApiSpecification] - // ListApiCategories is a function to list all ApiCategories for validation at upload time. - // If nil, category validation is skipped. - ListApiCategories func(ctx context.Context) (*apiv1.ApiCategoryList, error) - // Linter handles OAS linting operations. If nil, linting is disabled. Linter ApiLinter } @@ -55,20 +50,6 @@ func NewApiSpecificationController(stores *s.Stores, lintCfg config.OasLintingCo Store: stores.APISpecificationStore, Linter: NewApiLinter(stores.APISpecificationStore, lintCfg), } - if stores.APICategoryStore != nil { - ctrl.ListApiCategories = func(ctx context.Context) (*apiv1.ApiCategoryList, error) { - listOpts := store.NewListOpts() - categoryList, err := stores.APICategoryStore.List(ctx, listOpts) - if err != nil { - return nil, err - } - result := &apiv1.ApiCategoryList{Items: make([]apiv1.ApiCategory, 0, len(categoryList.Items))} - for _, item := range categoryList.Items { - result.Items = append(result.Items, *item) - } - return result, nil - } - } return ctrl } @@ -269,17 +250,22 @@ func (a *ApiSpecificationController) GetStatus(ctx context.Context, resourceId s return status.MapAPISpecificationResponse(ctx, apiSpec, a.stores) } -// fetchApiCategories fetches all ApiCategories. Returns nil if the store is not configured. +// fetchApiCategories fetches all ApiCategories. Returns nil if the store is not configured or on error. func (a *ApiSpecificationController) fetchApiCategories(ctx context.Context) *apiv1.ApiCategoryList { - if a.ListApiCategories == nil { + if a.stores.APICategoryStore == nil { return nil } - list, err := a.ListApiCategories(ctx) + listOpts := store.NewListOpts() + categoryList, err := a.stores.APICategoryStore.List(ctx, listOpts) if err != nil { logr.FromContextOrDiscard(ctx).Info("Failed to list ApiCategories", "error", err) return nil } - return list + result := &apiv1.ApiCategoryList{Items: make([]apiv1.ApiCategory, 0, len(categoryList.Items))} + for _, item := range categoryList.Items { + result.Items = append(result.Items, *item) + } + return result } // validateApiCategoryFromList validates that the given category is a known and active ApiCategory From cee8de3a28576eb2fc68695bfcdf0a7701911666 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 14:10:24 +0200 Subject: [PATCH 28/42] refactor: streamline linting process and remove unused linting helpers --- rover-server/internal/controller/apilinter.go | 91 +++-- .../internal/controller/apispecification.go | 50 ++- .../controller/apispecification_lint.go | 36 -- .../controller/apispecification_lint_test.go | 325 +++++++++++++----- 4 files changed, 324 insertions(+), 178 deletions(-) delete mode 100644 rover-server/internal/controller/apispecification_lint.go diff --git a/rover-server/internal/controller/apilinter.go b/rover-server/internal/controller/apilinter.go index a51b9c32..62d69856 100644 --- a/rover-server/internal/controller/apilinter.go +++ b/rover-server/internal/controller/apilinter.go @@ -12,7 +12,6 @@ import ( "github.com/go-logr/logr" apiv1 "github.com/telekom/controlplane/api/api/v1" commonclient "github.com/telekom/controlplane/common-server/pkg/client" - "github.com/telekom/controlplane/common-server/pkg/store" "github.com/telekom/controlplane/rover-server/internal/config" "github.com/telekom/controlplane/rover-server/internal/oaslint" roverv1 "github.com/telekom/controlplane/rover/api/v1" @@ -26,29 +25,21 @@ const ( LintSkipped LintOutcome = iota // LintCompleted means linting ran synchronously and the result is on apiSpec.Spec.Lint. LintCompleted + // LintBlocked means linting ran, the spec failed, and the category mode is Block. + LintBlocked ) // ApiLinter abstracts the full OAS linting lifecycle: config lookup, -// whitelists, hash dedup, execution, and store interaction. -// -// NOTE: Linting is currently synchronous only. Async linting was considered but -// intentionally left out because the rover operator cannot self-heal if -// rover-server dies mid-lint — the ApiSpecification would be stuck with a nil -// Lint result forever. If async linting is needed in the future, it should be -// implemented as a proper async reconciliation loop in the operator (requeue -// until the lint result appears) rather than a fire-and-forget goroutine here. +// whitelists, and execution and should populate apiSpec.Spec.Lint with the result if linting was performed. type ApiLinter interface { // Lint performs the full linting lifecycle for an ApiSpecification. - // It looks up the linting config from the category list, checks whitelists - // and hash dedup, and runs the linter synchronously. - // - // Returns the outcome and an error for infrastructure failures. - Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, categoryList *apiv1.ApiCategoryList, specBytes []byte) (LintOutcome, error) + // It looks up the linting config from the category list, checks whitelists, + // and runs the linter synchronously. + Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, category *apiv1.ApiCategory, specBytes []byte) (LintOutcome, error) } // apiLinterImpl is the production implementation of ApiLinter. type apiLinterImpl struct { - objStore store.ObjectStore[*roverv1.ApiSpecification] errorMessage string url string dashboardURL string @@ -56,9 +47,8 @@ type apiLinterImpl struct { } // NewApiLinter creates an ApiLinter from the given linting configuration. -func NewApiLinter(objStore store.ObjectStore[*roverv1.ApiSpecification], lintCfg config.OasLintingConfig) ApiLinter { +func NewApiLinter(lintCfg config.OasLintingConfig) ApiLinter { return &apiLinterImpl{ - objStore: objStore, errorMessage: lintCfg.ErrorMessage, url: lintCfg.URL, dashboardURL: lintCfg.DashboardURL, @@ -70,59 +60,51 @@ func NewApiLinter(objStore store.ObjectStore[*roverv1.ApiSpecification], lintCfg } } -func (l *apiLinterImpl) Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, categoryList *apiv1.ApiCategoryList, specBytes []byte) (LintOutcome, error) { +func (l *apiLinterImpl) Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, category *apiv1.ApiCategory, specBytes []byte) (LintOutcome, error) { log := logr.FromContextOrDiscard(ctx) log.V(1).Info("Looking up linting config", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "category", apiSpec.Spec.Category, "basepath", apiSpec.Spec.BasePath) - lintCfg := lintingConfigFromList(categoryList, apiSpec.Spec.Category) + if category == nil { + log.V(1).Info("No category provided, skipping linting", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + return LintSkipped, nil + } + + lintCfg := category.Spec.Linting if lintCfg == nil || l.url == "" || lintCfg.Mode == apiv1.LintingModeNone { log.V(1).Info("No linting config or no URL, skipping linting", "namespace", apiSpec.Namespace, "name", apiSpec.Name) return LintSkipped, nil } - log.V(1).Info("Linting config found, checking whitelists and hash dedup", "namespace", apiSpec.Namespace, "name", apiSpec.Name) - existing, _ := l.objStore.Get(ctx, apiSpec.Namespace, apiSpec.Name) - if !l.prepareLinting(lintCfg, apiSpec, existing) { - log.V(1).Info("Linting skipped (whitelisted or hash dedup)", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + log.V(1).Info("Linting config found, checking whitelists", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + if !l.prepareLinting(lintCfg, apiSpec) { + log.V(1).Info("Linting skipped (whitelisted)", "namespace", apiSpec.Namespace, "name", apiSpec.Name) return LintSkipped, nil } - if err := l.runSyncLint(ctx, apiSpec, lintCfg.Ruleset, specBytes); err != nil { - _ = l.objStore.CreateOrReplace(ctx, apiSpec) + if err := l.runLint(ctx, apiSpec, lintCfg.Ruleset, specBytes); err != nil { return LintCompleted, err } + + if lintCfg.Mode == apiv1.LintingModeBlock && apiSpec.Spec.Lint != nil && !apiSpec.Spec.Lint.Passed { + return LintBlocked, fmt.Errorf("linting failed in block mode: %s", apiSpec.Spec.Lint.Message) + } + return LintCompleted, nil } -func (l *apiLinterImpl) prepareLinting(lintCfg *apiv1.LintingConfig, apiSpec *roverv1.ApiSpecification, existing *roverv1.ApiSpecification) bool { +func (l *apiLinterImpl) prepareLinting(lintCfg *apiv1.LintingConfig, apiSpec *roverv1.ApiSpecification) bool { if isBasepathWhitelisted(lintCfg, apiSpec.Spec.BasePath) { apiSpec.Spec.Lint = &roverv1.LintResult{Passed: true, Message: fmt.Sprintf("The basepath %q is whitelisted", apiSpec.Spec.BasePath)} return false } - if existing != nil && existing.Spec.Lint != nil && existing.Spec.Hash == apiSpec.Spec.Hash { - apiSpec.Spec.Lint = existing.Spec.Lint - return false - } apiSpec.Spec.Lint = nil return true } -func (l *apiLinterImpl) runSyncLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, ruleset string, specBytes []byte) error { +func (l *apiLinterImpl) runLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, ruleset string, specBytes []byte) error { log := logr.FromContextOrDiscard(ctx).WithName("linting") - lintResult, err := l.executeLint(ctx, ruleset, specBytes) - apiSpec.Spec.Lint = lintResult - if err != nil { - log.Error(err, "Sync OAS linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name) - return err - } - if !lintResult.Passed { - log.Info("Linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "message", lintResult.Message) - } - return nil -} -func (l *apiLinterImpl) executeLint(ctx context.Context, ruleset string, specBytes []byte) (*roverv1.LintResult, error) { var opts []oaslint.ExternalLinterOption if ruleset != "" { opts = append(opts, oaslint.WithRuleset(ruleset)) @@ -132,12 +114,19 @@ func (l *apiLinterImpl) executeLint(ctx context.Context, ruleset string, specByt result, err := linter.Lint(ctx, specBytes) if err != nil { - return &roverv1.LintResult{ + apiSpec.Spec.Lint = &roverv1.LintResult{ Passed: false, Message: fmt.Sprintf("linter API error: %s", err), - }, fmt.Errorf("linter API error: %w", err) + } + log.Error(err, "OAS linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name) + return fmt.Errorf("linter API error: %w", err) + } + + apiSpec.Spec.Lint = l.buildLintResult(result) + if !apiSpec.Spec.Lint.Passed { + log.Info("Linting failed", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "message", apiSpec.Spec.Lint.Message) } - return l.buildLintResult(result), nil + return nil } func (l *apiLinterImpl) buildLintResult(result *oaslint.LintResult) *roverv1.LintResult { @@ -153,3 +142,13 @@ func (l *apiLinterImpl) buildLintResult(result *oaslint.LintResult) *roverv1.Lin } return lintResult } + +// isBasepathWhitelisted checks whether the given basepath is in the category's whitelist. +func isBasepathWhitelisted(cfg *apiv1.LintingConfig, basepath string) bool { + for _, wp := range cfg.WhitelistedBasepaths { + if strings.EqualFold(wp, basepath) { + return true + } + } + return false +} diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index a8129095..2cb1fbea 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -21,17 +21,18 @@ import ( "github.com/telekom/controlplane/common-server/pkg/server/middleware/security" "github.com/telekom/controlplane/common-server/pkg/store" filesapi "github.com/telekom/controlplane/file-manager/api" + "github.com/telekom/controlplane/rover-server/internal/file" + roverv1 "github.com/telekom/controlplane/rover/api/v1" + "gopkg.in/yaml.v3" + "github.com/telekom/controlplane/rover-server/internal/api" "github.com/telekom/controlplane/rover-server/internal/config" - "github.com/telekom/controlplane/rover-server/internal/file" "github.com/telekom/controlplane/rover-server/internal/mapper" "github.com/telekom/controlplane/rover-server/internal/mapper/apispecification/in" "github.com/telekom/controlplane/rover-server/internal/mapper/apispecification/out" "github.com/telekom/controlplane/rover-server/internal/mapper/status" "github.com/telekom/controlplane/rover-server/internal/server" s "github.com/telekom/controlplane/rover-server/pkg/store" - roverv1 "github.com/telekom/controlplane/rover/api/v1" - "gopkg.in/yaml.v3" ) var _ server.ApiSpecificationController = &ApiSpecificationController{} @@ -45,12 +46,11 @@ type ApiSpecificationController struct { } func NewApiSpecificationController(stores *s.Stores, lintCfg config.OasLintingConfig) *ApiSpecificationController { - ctrl := &ApiSpecificationController{ + return &ApiSpecificationController{ stores: stores, Store: stores.APISpecificationStore, - Linter: NewApiLinter(stores.APISpecificationStore, lintCfg), + Linter: NewApiLinter(lintCfg), } - return ctrl } // Create implements server.ApiSpecificationController. @@ -205,6 +205,12 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri return res, catErr } + // Look up the specific ApiCategory for linting. + var apiCategory *apiv1.ApiCategory + if categoryList != nil { + apiCategory, _ = categoryList.FindByLabelValue(apiSpec.Spec.Category) + } + fileAPIResp, err := a.uploadFile(ctx, specMarshaled, id) if err != nil { return res, err @@ -216,11 +222,15 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri } EnsureLabelsOrDie(ctx, apiSpec) - // Lint the spec if the category has linting configured. + // Lint the spec if the linter is configured. Hash dedup is handled by + // lintOrReuse: if the spec hasn't changed and already has a lint result, + // the previous result is reused without calling the external linter. + var lintOutcome LintOutcome + var lintErr error if a.Linter != nil { - if _, lintErr := a.Linter.Lint(ctx, apiSpec, categoryList, specMarshaled); lintErr != nil { - return res, problems.InternalServerError("Linting failed", lintErr.Error()) - } + ns := id.Environment + "--" + id.Namespace + existing, _ := a.Store.Get(ctx, ns, id.Name) + lintOutcome, lintErr = a.lintOrReuse(ctx, apiSpec, existing, apiCategory, specMarshaled) } err = a.Store.CreateOrReplace(ctx, apiSpec) @@ -228,6 +238,13 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri return res, err } + if lintOutcome == LintBlocked { + return res, problems.BadRequest(lintErr.Error()) + } + if lintErr != nil { + return res, problems.InternalServerError("Linting failed", lintErr.Error()) + } + return a.Get(ctx, resourceId) } @@ -250,7 +267,7 @@ func (a *ApiSpecificationController) GetStatus(ctx context.Context, resourceId s return status.MapAPISpecificationResponse(ctx, apiSpec, a.stores) } -// fetchApiCategories fetches all ApiCategories. Returns nil if the store is not configured or on error. +// fetchApiCategories fetches all ApiCategories from the store. Returns nil if the store is not configured. func (a *ApiSpecificationController) fetchApiCategories(ctx context.Context) *apiv1.ApiCategoryList { if a.stores.APICategoryStore == nil { return nil @@ -268,6 +285,17 @@ func (a *ApiSpecificationController) fetchApiCategories(ctx context.Context) *ap return result } +// lintOrReuse decides whether to call the external linter or reuse a cached result. +// If the spec hash is unchanged and a previous lint result exists, it reuses it. +// Otherwise it delegates to the Linter. +func (a *ApiSpecificationController) lintOrReuse(ctx context.Context, apiSpec *roverv1.ApiSpecification, existing *roverv1.ApiSpecification, category *apiv1.ApiCategory, specBytes []byte) (LintOutcome, error) { + if existing != nil && existing.Spec.Lint != nil && existing.Spec.Hash == apiSpec.Spec.Hash { + apiSpec.Spec.Lint = existing.Spec.Lint + return LintSkipped, nil + } + return a.Linter.Lint(ctx, apiSpec, category, specBytes) +} + // validateApiCategoryFromList validates that the given category is a known and active ApiCategory // using a pre-fetched list. If the list is nil, validation is skipped. func (a *ApiSpecificationController) validateApiCategoryFromList(categoryList *apiv1.ApiCategoryList, category string) error { diff --git a/rover-server/internal/controller/apispecification_lint.go b/rover-server/internal/controller/apispecification_lint.go deleted file mode 100644 index d409c521..00000000 --- a/rover-server/internal/controller/apispecification_lint.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2025 Deutsche Telekom IT GmbH -// -// SPDX-License-Identifier: Apache-2.0 - -package controller - -import ( - "strings" - - apiv1 "github.com/telekom/controlplane/api/api/v1" -) - -// isBasepathWhitelisted checks if the given basepath is whitelisted in the category-level linting config. -func isBasepathWhitelisted(lintCfg *apiv1.LintingConfig, basepath string) bool { - for _, wl := range lintCfg.WhitelistedBasepaths { - if strings.EqualFold(wl, basepath) { - return true - } - } - return false -} - -// lintingConfigFromList finds the linting configuration from a pre-fetched ApiCategoryList. -// Returns nil if no linting is configured for this category. -func lintingConfigFromList(categoryList *apiv1.ApiCategoryList, category string) *apiv1.LintingConfig { - if categoryList == nil { - return nil - } - - found, ok := categoryList.FindByLabelValue(category) - if !ok { - return nil - } - - return found.Spec.Linting -} diff --git a/rover-server/internal/controller/apispecification_lint_test.go b/rover-server/internal/controller/apispecification_lint_test.go index 9a26732d..1c2e0df3 100644 --- a/rover-server/internal/controller/apispecification_lint_test.go +++ b/rover-server/internal/controller/apispecification_lint_test.go @@ -5,6 +5,11 @@ package controller import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apiv1 "github.com/telekom/controlplane/api/api/v1" @@ -65,115 +70,265 @@ var _ = Describe("Linting helpers", func() { Spec: roverv1.ApiSpecificationSpec{BasePath: "/eni/internal/v1"}, } - result := linter.prepareLinting(lintCfg, apiSpec, nil) + result := linter.prepareLinting(lintCfg, apiSpec) Expect(result).To(BeFalse()) Expect(apiSpec.Spec.Lint).ToNot(BeNil()) Expect(apiSpec.Spec.Lint.Passed).To(BeTrue()) Expect(apiSpec.Spec.Lint.Message).To(ContainSubstring("whitelisted")) }) - It("should skip linting when spec hash is unchanged and previous result exists", func() { + It("should require linting when basepath is not whitelisted", func() { lintCfg := &apiv1.LintingConfig{} - existing := &roverv1.ApiSpecification{ - Spec: roverv1.ApiSpecificationSpec{ - BasePath: "/eni/test/v1", - Hash: "same-hash", - Lint: &roverv1.LintResult{Passed: true, Message: "all good"}, - }, - } apiSpec := &roverv1.ApiSpecification{ - Spec: roverv1.ApiSpecificationSpec{ - BasePath: "/eni/test/v1", - Hash: "same-hash", - }, + Spec: roverv1.ApiSpecificationSpec{BasePath: "/eni/test/v1"}, } - result := linter.prepareLinting(lintCfg, apiSpec, existing) - Expect(result).To(BeFalse()) - Expect(apiSpec.Spec.Lint).ToNot(BeNil()) - Expect(apiSpec.Spec.Lint.Passed).To(BeTrue()) + result := linter.prepareLinting(lintCfg, apiSpec) + Expect(result).To(BeTrue()) + Expect(apiSpec.Spec.Lint).To(BeNil()) }) + }) - It("should require linting when spec hash changed", func() { - lintCfg := &apiv1.LintingConfig{} - existing := &roverv1.ApiSpecification{ - Spec: roverv1.ApiSpecificationSpec{ - BasePath: "/eni/test/v1", - Hash: "old-hash", - Lint: &roverv1.LintResult{Passed: true, Message: "all good"}, + Describe("Lint", func() { + var ( + lintCtx context.Context + linterServer *httptest.Server + linter ApiLinter + apiSpec *roverv1.ApiSpecification + category *apiv1.ApiCategory + specBytes []byte + ) + + newCategory := func(mode apiv1.LintingMode) *apiv1.ApiCategory { + return &apiv1.ApiCategory{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cat"}, + Spec: apiv1.ApiCategorySpec{ + LabelValue: "test-cat", + Active: true, + Linting: &apiv1.LintingConfig{ + Mode: mode, + Ruleset: "default", + }, }, } - apiSpec := &roverv1.ApiSpecification{ + } + + startLinterServer := func(errors int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := map[string]any{ + "id": "scan-test", + "createdAt": "2025-01-01T00:00:00Z", + "ruleset": map[string]any{"name": "default", "hash": "abc"}, + "info": map[string]any{"errors": errors, "warnings": 0, "infos": 0, "hints": 0}, + "linterVersion": "1.0.0", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck // test helper + })) + } + + BeforeEach(func() { + lintCtx = context.Background() + specBytes = []byte("openapi: '3.0.0'") + apiSpec = &roverv1.ApiSpecification{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-spec", + Namespace: "env--ns", + }, Spec: roverv1.ApiSpecificationSpec{ - BasePath: "/eni/test/v1", + BasePath: "/test/api/v1", + Category: "test-cat", Hash: "new-hash", }, } - - result := linter.prepareLinting(lintCfg, apiSpec, existing) - Expect(result).To(BeTrue()) - Expect(apiSpec.Spec.Lint).To(BeNil()) }) - It("should require linting for new spec (no existing object)", func() { - lintCfg := &apiv1.LintingConfig{} - apiSpec := &roverv1.ApiSpecification{ - Spec: roverv1.ApiSpecificationSpec{ - BasePath: "/eni/test/v1", - Hash: "brand-new-hash", - }, + AfterEach(func() { + if linterServer != nil { + linterServer.Close() } + }) - result := linter.prepareLinting(lintCfg, apiSpec, nil) - Expect(result).To(BeTrue()) - Expect(apiSpec.Spec.Lint).To(BeNil()) + It("should skip when category is nil", func() { + linter = &apiLinterImpl{url: "http://linter"} + outcome, err := linter.Lint(lintCtx, apiSpec, nil, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintSkipped)) }) - }) - Describe("lintingConfigFromList", func() { - It("should return nil when categoryList is nil", func() { - result := lintingConfigFromList(nil, "some-cat") - Expect(result).To(BeNil()) - }) - - It("should return nil when category is not found", func() { - list := &apiv1.ApiCategoryList{Items: []apiv1.ApiCategory{}} - result := lintingConfigFromList(list, "nonexistent") - Expect(result).To(BeNil()) - }) - - It("should return linting config from matching category", func() { - list := &apiv1.ApiCategoryList{ - Items: []apiv1.ApiCategory{ - { - ObjectMeta: metav1.ObjectMeta{Name: "my-cat"}, - Spec: apiv1.ApiCategorySpec{ - LabelValue: "my-cat", - Linting: &apiv1.LintingConfig{ - Mode: apiv1.LintingModeWarn, - }, - }, - }, - }, - } - result := lintingConfigFromList(list, "my-cat") - Expect(result).ToNot(BeNil()) - Expect(result.Mode).To(Equal(apiv1.LintingModeWarn)) - }) - - It("should return nil when category has no linting config", func() { - list := &apiv1.ApiCategoryList{ - Items: []apiv1.ApiCategory{ - { - ObjectMeta: metav1.ObjectMeta{Name: "no-lint-cat"}, - Spec: apiv1.ApiCategorySpec{ - LabelValue: "no-lint-cat", - }, - }, - }, - } - result := lintingConfigFromList(list, "no-lint-cat") - Expect(result).To(BeNil()) + It("should skip when mode is None", func() { + linter = &apiLinterImpl{url: "http://linter"} + category = newCategory(apiv1.LintingModeNone) + outcome, err := linter.Lint(lintCtx, apiSpec, category, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintSkipped)) + }) + + It("should skip when linter URL is empty", func() { + linter = &apiLinterImpl{url: ""} + category = newCategory(apiv1.LintingModeWarn) + outcome, err := linter.Lint(lintCtx, apiSpec, category, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintSkipped)) + }) + + Context("when linting passes", func() { + BeforeEach(func() { + linterServer = startLinterServer(0) + }) + + It("should return LintCompleted in Block mode", func() { + linter = &apiLinterImpl{url: linterServer.URL, httpClient: linterServer.Client()} + category = newCategory(apiv1.LintingModeBlock) + outcome, err := linter.Lint(lintCtx, apiSpec, category, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintCompleted)) + Expect(apiSpec.Spec.Lint).ToNot(BeNil()) + Expect(apiSpec.Spec.Lint.Passed).To(BeTrue()) + }) + + It("should return LintCompleted in Warn mode", func() { + linter = &apiLinterImpl{url: linterServer.URL, httpClient: linterServer.Client()} + category = newCategory(apiv1.LintingModeWarn) + outcome, err := linter.Lint(lintCtx, apiSpec, category, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintCompleted)) + Expect(apiSpec.Spec.Lint).ToNot(BeNil()) + Expect(apiSpec.Spec.Lint.Passed).To(BeTrue()) + }) + }) + + Context("when linting fails (spec has errors)", func() { + BeforeEach(func() { + linterServer = startLinterServer(3) + }) + + It("should return LintBlocked with error in Block mode", func() { + linter = &apiLinterImpl{url: linterServer.URL, httpClient: linterServer.Client()} + category = newCategory(apiv1.LintingModeBlock) + outcome, err := linter.Lint(lintCtx, apiSpec, category, specBytes) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("linting failed in block mode")) + Expect(outcome).To(Equal(LintBlocked)) + Expect(apiSpec.Spec.Lint).ToNot(BeNil()) + Expect(apiSpec.Spec.Lint.Passed).To(BeFalse()) + }) + + It("should return LintCompleted without error in Warn mode", func() { + linter = &apiLinterImpl{url: linterServer.URL, httpClient: linterServer.Client()} + category = newCategory(apiv1.LintingModeWarn) + outcome, err := linter.Lint(lintCtx, apiSpec, category, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintCompleted)) + Expect(apiSpec.Spec.Lint).ToNot(BeNil()) + Expect(apiSpec.Spec.Lint.Passed).To(BeFalse()) + }) + }) + + Context("when linter API is unreachable", func() { + It("should return error without persisting", func() { + linter = &apiLinterImpl{url: "http://localhost:1", httpClient: &http.Client{}} + category = newCategory(apiv1.LintingModeBlock) + outcome, err := linter.Lint(lintCtx, apiSpec, category, specBytes) + Expect(err).To(HaveOccurred()) + Expect(outcome).To(Equal(LintCompleted)) + }) }) }) }) + +// mockLinter is a simple test double for ApiLinter that records whether Lint was called. +type mockLinter struct { + called bool + outcome LintOutcome + err error +} + +func (m *mockLinter) Lint(_ context.Context, apiSpec *roverv1.ApiSpecification, _ *apiv1.ApiCategory, _ []byte) (LintOutcome, error) { + m.called = true + if apiSpec.Spec.Lint == nil { + apiSpec.Spec.Lint = &roverv1.LintResult{Passed: true, Message: "mock lint ran"} + } + return m.outcome, m.err +} + +var _ = Describe("lintOrReuse (hash dedup)", func() { + var ( + ctrl *ApiSpecificationController + linterMck *mockLinter + apiSpec *roverv1.ApiSpecification + specBytes []byte + ) + + BeforeEach(func() { + linterMck = &mockLinter{outcome: LintCompleted} + ctrl = &ApiSpecificationController{Linter: linterMck} + specBytes = []byte("openapi: '3.0.0'") + apiSpec = &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{ + Hash: "new-hash", + }, + } + }) + + It("should call Lint when there is no existing spec", func() { + outcome, err := ctrl.lintOrReuse(context.Background(), apiSpec, nil, nil, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintCompleted)) + Expect(linterMck.called).To(BeTrue()) + }) + + It("should call Lint when existing has no lint result", func() { + existing := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{Hash: "new-hash"}, + } + outcome, err := ctrl.lintOrReuse(context.Background(), apiSpec, existing, nil, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintCompleted)) + Expect(linterMck.called).To(BeTrue()) + }) + + It("should call Lint when hash changed", func() { + existing := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{ + Hash: "old-hash", + Lint: &roverv1.LintResult{Passed: true, Message: "old result"}, + }, + } + outcome, err := ctrl.lintOrReuse(context.Background(), apiSpec, existing, nil, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintCompleted)) + Expect(linterMck.called).To(BeTrue()) + }) + + It("should reuse cached result when hash is unchanged", func() { + existing := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{ + Hash: "new-hash", + Lint: &roverv1.LintResult{Passed: true, Message: "cached"}, + }, + } + outcome, err := ctrl.lintOrReuse(context.Background(), apiSpec, existing, nil, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintSkipped)) + Expect(linterMck.called).To(BeFalse()) + Expect(apiSpec.Spec.Lint).ToNot(BeNil()) + Expect(apiSpec.Spec.Lint.Passed).To(BeTrue()) + Expect(apiSpec.Spec.Lint.Message).To(Equal("cached")) + }) + + It("should reuse cached failure result when hash is unchanged", func() { + existing := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{ + Hash: "new-hash", + Lint: &roverv1.LintResult{Passed: false, Message: "previous failure"}, + }, + } + outcome, err := ctrl.lintOrReuse(context.Background(), apiSpec, existing, nil, specBytes) + Expect(err).ToNot(HaveOccurred()) + Expect(outcome).To(Equal(LintSkipped)) + Expect(linterMck.called).To(BeFalse()) + Expect(apiSpec.Spec.Lint.Passed).To(BeFalse()) + Expect(apiSpec.Spec.Lint.Message).To(Equal("previous failure")) + }) +}) From 32deef7de408f52be0c7f5db69526763adddb8ff Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 14:14:36 +0200 Subject: [PATCH 29/42] chore: revert to main --- .../internal/mapper/status/response.go | 44 ++++--------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/rover-server/internal/mapper/status/response.go b/rover-server/internal/mapper/status/response.go index fa369e0d..080b2cd5 100644 --- a/rover-server/internal/mapper/status/response.go +++ b/rover-server/internal/mapper/status/response.go @@ -24,11 +24,11 @@ func MapResponse(ctx context.Context, obj types.Object) (api.ResourceStatusRespo processing := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) var processedAtTime time.Time if processing != nil { - processedAtTime = processing.LastTransitionTime.Time.UTC() + processedAtTime = processing.LastTransitionTime.Time } return api.ResourceStatusResponse{ - CreatedAt: obj.GetCreationTimestamp().Time.UTC(), + CreatedAt: obj.GetCreationTimestamp().Time, ProcessedAt: processedAtTime, State: status.State, ProcessingState: status.ProcessingState, @@ -58,45 +58,19 @@ func MapAPISpecificationResponse(ctx context.Context, apiSpec *v1.ApiSpecificati processing := meta.FindStatusCondition(apiSpec.GetConditions(), condition.ConditionTypeProcessing) var processedAtTime time.Time if processing != nil { - processedAtTime = processing.LastTransitionTime.Time.UTC() + processedAtTime = processing.LastTransitionTime.Time } parentOverall := CalculateOverallStatus(status.State, status.ProcessingState) finalOverall := CompareAndReturn(parentOverall, result.WorstOverallStatus) - // Combine problems from sub-resources with any errors from the parent's own conditions. - allErrors := make([]api.Problem, 0, len(result.Problems)+len(status.Errors)) - allErrors = append(allErrors, result.Problems...) - for _, e := range status.Errors { - allErrors = append(allErrors, api.Problem{Message: e.Message, Cause: e.Cause}) - } - - // Include warnings from the parent's own conditions (e.g. blocked reason). - allWarnings := make([]api.Problem, 0, len(status.Warnings)) - for _, w := range status.Warnings { - allWarnings = append(allWarnings, api.Problem{Message: w.Message, Cause: w.Cause}) - } - - // Surface lint failure as a warning when linting did not pass but the API was still created (warn mode). - if apiSpec.Spec.Lint != nil && !apiSpec.Spec.Lint.Passed && status.State == api.Complete { - msg := "OAS linting did not pass: " + apiSpec.Spec.Lint.Message - if apiSpec.Spec.Lint.DashboardURL != "" { - msg += ". View details: " + apiSpec.Spec.Lint.DashboardURL - } - allWarnings = append(allWarnings, api.Problem{ - Cause: "LintingFailed", - Message: msg, - }) - } - return api.ResourceStatusResponse{ - CreatedAt: apiSpec.GetCreationTimestamp().Time.UTC(), + CreatedAt: apiSpec.GetCreationTimestamp().Time, ProcessedAt: processedAtTime, State: status.State, ProcessingState: status.ProcessingState, OverallStatus: finalOverall, - Errors: allErrors, - Warnings: allWarnings, + Errors: result.Problems, }, nil } @@ -122,14 +96,14 @@ func MapRoverResponse(ctx context.Context, rover *v1.Rover, stores *store.Stores processing := meta.FindStatusCondition(rover.GetConditions(), condition.ConditionTypeProcessing) var processedAtTime time.Time if processing != nil { - processedAtTime = processing.LastTransitionTime.Time.UTC() + processedAtTime = processing.LastTransitionTime.Time } parentOverall := CalculateOverallStatus(status.State, status.ProcessingState) finalOverall := CompareAndReturn(parentOverall, result.WorstOverallStatus) return api.ResourceStatusResponse{ - CreatedAt: rover.GetCreationTimestamp().Time.UTC(), + CreatedAt: rover.GetCreationTimestamp().Time, ProcessedAt: processedAtTime, State: status.State, ProcessingState: status.ProcessingState, @@ -160,14 +134,14 @@ func MapEventSpecificationResponse(ctx context.Context, eventSpec *v1.EventSpeci processing := meta.FindStatusCondition(eventSpec.GetConditions(), condition.ConditionTypeProcessing) var processedAtTime time.Time if processing != nil { - processedAtTime = processing.LastTransitionTime.Time.UTC() + processedAtTime = processing.LastTransitionTime.Time } parentOverall := CalculateOverallStatus(status.State, status.ProcessingState) finalOverall := CompareAndReturn(parentOverall, result.WorstOverallStatus) return api.ResourceStatusResponse{ - CreatedAt: eventSpec.GetCreationTimestamp().Time.UTC(), + CreatedAt: eventSpec.GetCreationTimestamp().Time, ProcessedAt: processedAtTime, State: status.State, ProcessingState: status.ProcessingState, From 55f5f8030405337ed4bf4a84b3aea592ad19edd4 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 14:19:02 +0200 Subject: [PATCH 30/42] chore: update snapshots --- .../controller/__snapshots__/suite_controller_test.snap | 4 ++-- .../internal/mapper/status/__snapshots__/status_test.snap | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap index 61b52782..4ae29dce 100644 --- a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap +++ b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap @@ -449,7 +449,7 @@ [Rover Controller Get rover status should return the status of a rover successfully - 1] { - "createdAt": "2025-09-18T08:39:44Z", + "createdAt": "2025-09-18T10:39:44+02:00", "errors": [ { "cause": "NoApproval", @@ -464,7 +464,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T07:16:40Z", + "processedAt": "2025-10-08T09:16:40+02:00", "processingState": "done", "state": "blocked" } diff --git a/rover-server/internal/mapper/status/__snapshots__/status_test.snap b/rover-server/internal/mapper/status/__snapshots__/status_test.snap index 5e298203..3f445fed 100644 --- a/rover-server/internal/mapper/status/__snapshots__/status_test.snap +++ b/rover-server/internal/mapper/status/__snapshots__/status_test.snap @@ -19,7 +19,7 @@ [Rover Status Mapper MapRoverResponse must map rover response correctly - 1] { - "createdAt": "2025-09-18T08:39:44Z", + "createdAt": "2025-09-18T10:39:44+02:00", "errors": [ { "cause": "NoApproval", @@ -34,7 +34,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T07:16:40Z", + "processedAt": "2025-10-08T09:16:40+02:00", "processingState": "done", "state": "blocked" } From f967e1d9a024d470a4f864fc754e81de336cf246 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 14:34:45 +0200 Subject: [PATCH 31/42] refactor: move logic to handler --- .../controller/apispecification_controller.go | 18 +-- rover/internal/controller/index.go | 7 +- .../handler/apispecification/handler.go | 24 ++- .../handler/apispecification/handler_test.go | 137 ++++++++++-------- rover/internal/index/index.go | 7 + 5 files changed, 102 insertions(+), 91 deletions(-) create mode 100644 rover/internal/index/index.go diff --git a/rover/internal/controller/apispecification_controller.go b/rover/internal/controller/apispecification_controller.go index 751db4db..349e544f 100644 --- a/rover/internal/controller/apispecification_controller.go +++ b/rover/internal/controller/apispecification_controller.go @@ -6,8 +6,8 @@ package controller import ( "context" - "strings" + apiapi "github.com/telekom/controlplane/api/api/v1" cconfig "github.com/telekom/controlplane/common/pkg/config" cc "github.com/telekom/controlplane/common/pkg/controller" "k8s.io/apimachinery/pkg/runtime" @@ -18,8 +18,6 @@ import ( apispec_handler "github.com/telekom/controlplane/rover/internal/handler/apispecification" - apiapi "github.com/telekom/controlplane/api/api/v1" - cclient "github.com/telekom/controlplane/common/pkg/client" rover "github.com/telekom/controlplane/rover/api/v1" ) @@ -48,19 +46,7 @@ func (r *ApiSpecificationReconciler) Reconcile(ctx context.Context, req ctrl.Req func (r *ApiSpecificationReconciler) SetupWithManager(mgr ctrl.Manager) error { r.Recorder = mgr.GetEventRecorderFor("apispecification-controller") - h := &apispec_handler.ApiSpecificationHandler{ - GetApiCategory: func(ctx context.Context, category string) (*apiapi.ApiCategory, error) { - c := cclient.ClientFromContextOrDie(ctx) - list := &apiapi.ApiCategoryList{} - if err := c.List(ctx, list, client.MatchingFields{FieldApiCategoryLabelValue: strings.ToLower(category)}); err != nil { - return nil, err - } - if len(list.Items) == 0 { - return nil, nil - } - return &list.Items[0], nil - }, - } + h := &apispec_handler.ApiSpecificationHandler{} r.Controller = cc.NewController(h, r.Client, r.Recorder) diff --git a/rover/internal/controller/index.go b/rover/internal/controller/index.go index a48cfdab..853e5f40 100644 --- a/rover/internal/controller/index.go +++ b/rover/internal/controller/index.go @@ -15,13 +15,12 @@ import ( "github.com/telekom/controlplane/common/pkg/controller/index" eventv1 "github.com/telekom/controlplane/event/api/v1" permissionv1 "github.com/telekom/controlplane/permission/api/v1" + roverindex "github.com/telekom/controlplane/rover/internal/index" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) -const FieldApiCategoryLabelValue = "spec.labelValue" - func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { err := index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &apiapi.Api{}) @@ -46,7 +45,7 @@ func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { os.Exit(1) } - err = mgr.GetFieldIndexer().IndexField(ctx, &apiapi.ApiCategory{}, FieldApiCategoryLabelValue, func(obj client.Object) []string { + err = mgr.GetFieldIndexer().IndexField(ctx, &apiapi.ApiCategory{}, roverindex.FieldApiCategoryLabelValue, func(obj client.Object) []string { cat := obj.(*apiapi.ApiCategory) if cat.Spec.LabelValue == "" { return nil @@ -54,7 +53,7 @@ func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { return []string{strings.ToLower(cat.Spec.LabelValue)} }) if err != nil { - ctrl.Log.Error(err, "unable to create fieldIndex for ApiCategory", "field", FieldApiCategoryLabelValue) + ctrl.Log.Error(err, "unable to create fieldIndex for ApiCategory", "field", roverindex.FieldApiCategoryLabelValue) os.Exit(1) } diff --git a/rover/internal/handler/apispecification/handler.go b/rover/internal/handler/apispecification/handler.go index 567c3c4e..305e5191 100644 --- a/rover/internal/handler/apispecification/handler.go +++ b/rover/internal/handler/apispecification/handler.go @@ -7,6 +7,7 @@ package apispecification import ( "context" "fmt" + "strings" "github.com/pkg/errors" apiapi "github.com/telekom/controlplane/api/api/v1" @@ -16,7 +17,9 @@ import ( "github.com/telekom/controlplane/common/pkg/types" "github.com/telekom/controlplane/common/pkg/util/labelutil" roverv1 "github.com/telekom/controlplane/rover/api/v1" + roverindex "github.com/telekom/controlplane/rover/internal/index" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) @@ -25,9 +28,7 @@ var _ handler.Handler[*roverv1.ApiSpecification] = (*ApiSpecificationHandler)(ni // ApiSpecificationHandler reconciles ApiSpecification resources. // Linting is performed by rover-server at upload time and stored in the CRD status fields. // This handler reads the lint result and gates Api resource creation accordingly. -type ApiSpecificationHandler struct { - GetApiCategory func(ctx context.Context, category string) (*apiapi.ApiCategory, error) -} +type ApiSpecificationHandler struct{} func (h *ApiSpecificationHandler) CreateOrUpdate(ctx context.Context, apiSpec *roverv1.ApiSpecification) error { mode := h.lookupLintingMode(ctx, apiSpec.Spec.Category) @@ -55,10 +56,7 @@ func (h *ApiSpecificationHandler) Delete(_ context.Context, _ *roverv1.ApiSpecif // lookupLintingMode finds the ApiCategory and returns the effective linting mode. func (h *ApiSpecificationHandler) lookupLintingMode(ctx context.Context, category string) apiapi.LintingMode { - if h.GetApiCategory == nil { - return apiapi.LintingModeNone - } - cat, err := h.GetApiCategory(ctx, category) + cat, err := h.getApiCategory(ctx, category) if err != nil || cat == nil || cat.Spec.Linting == nil { return apiapi.LintingModeNone } @@ -69,6 +67,18 @@ func (h *ApiSpecificationHandler) lookupLintingMode(ctx context.Context, categor return mode } +func (h *ApiSpecificationHandler) getApiCategory(ctx context.Context, category string) (*apiapi.ApiCategory, error) { + c := client.ClientFromContextOrDie(ctx) + list := &apiapi.ApiCategoryList{} + if err := c.List(ctx, list, ctrlclient.MatchingFields{roverindex.FieldApiCategoryLabelValue: strings.ToLower(category)}); err != nil { + return nil, err + } + if len(list.Items) == 0 { + return nil, nil + } + return &list.Items[0], nil +} + // createOrUpdateApi contains the Api resource creation logic. func (h *ApiSpecificationHandler) createOrUpdateApi(ctx context.Context, apiSpec *roverv1.ApiSpecification) error { c := client.ClientFromContextOrDie(ctx) diff --git a/rover/internal/handler/apispecification/handler_test.go b/rover/internal/handler/apispecification/handler_test.go index 232b9c25..9a54c016 100644 --- a/rover/internal/handler/apispecification/handler_test.go +++ b/rover/internal/handler/apispecification/handler_test.go @@ -58,24 +58,6 @@ func newApiCategory(name string, linting *apiapi.LintingConfig) *apiapi.ApiCateg } } -func getApiCategoryWith(cat *apiapi.ApiCategory) func(context.Context, string) (*apiapi.ApiCategory, error) { - return func(_ context.Context, _ string) (*apiapi.ApiCategory, error) { - return cat, nil - } -} - -func getApiCategoryNil() func(context.Context, string) (*apiapi.ApiCategory, error) { - return func(_ context.Context, _ string) (*apiapi.ApiCategory, error) { - return nil, nil - } -} - -func getApiCategoryError() func(context.Context, string) (*apiapi.ApiCategory, error) { - return func(_ context.Context, _ string) (*apiapi.ApiCategory, error) { - return nil, fmt.Errorf("api category lookup failed") - } -} - func hasCondition(apiSpec *roverv1.ApiSpecification, condType string) bool { for _, c := range apiSpec.GetConditions() { if c.Type == condType { @@ -96,7 +78,37 @@ func conditionMessage(apiSpec *roverv1.ApiSpecification, condType string) string // setupMockClient creates a mock JanitorClient injected into context. // The mock expects CreateOrUpdate and returns success. -func setupMockClient(ctx context.Context) context.Context { +// If a category is provided, the List call returns it; otherwise returns an empty list. +func setupMockClient(ctx context.Context, cats ...*apiapi.ApiCategory) context.Context { + fakeClient := fakeclient.NewMockJanitorClient(GinkgoT()) + testScheme := runtime.NewScheme() + _ = roverv1.AddToScheme(testScheme) + _ = apiapi.AddToScheme(testScheme) + + fakeClient.EXPECT().Scheme().Return(testScheme).Maybe() + fakeClient.EXPECT(). + CreateOrUpdate(mock.Anything, mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, _ client.Object, fn controllerutil.MutateFn) (controllerutil.OperationResult, error) { + _ = fn() + return controllerutil.OperationResultCreated, nil + }).Maybe() + fakeClient.EXPECT().AnyChanged().Return(true).Maybe() + fakeClient.EXPECT(). + List(mock.Anything, mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, list client.ObjectList, _ ...client.ListOption) error { + catList := list.(*apiapi.ApiCategoryList) + for _, cat := range cats { + catList.Items = append(catList.Items, *cat) + } + return nil + }).Maybe() + + return cclient.WithClient(ctx, fakeClient) +} + +// setupMockClientWithListError creates a mock JanitorClient where List returns an error. +// CreateOrUpdate still returns success for tests that proceed past linting. +func setupMockClientWithListError(ctx context.Context, listErr error) context.Context { fakeClient := fakeclient.NewMockJanitorClient(GinkgoT()) testScheme := runtime.NewScheme() _ = roverv1.AddToScheme(testScheme) @@ -110,6 +122,9 @@ func setupMockClient(ctx context.Context) context.Context { return controllerutil.OperationResultCreated, nil }).Maybe() fakeClient.EXPECT().AnyChanged().Return(true).Maybe() + fakeClient.EXPECT(). + List(mock.Anything, mock.Anything, mock.Anything). + Return(listErr).Maybe() return cclient.WithClient(ctx, fakeClient) } @@ -123,12 +138,11 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { Context("when linting is pending (Spec.Lint nil, block mode)", func() { It("should proceed with Api creation to avoid blocking indefinitely", func() { - mockCtx := setupMockClient(ctx) - h := &handler.ApiSpecificationHandler{ - GetApiCategory: getApiCategoryWith(newApiCategory("other", &apiapi.LintingConfig{ - Mode: apiapi.LintingModeBlock, - })), - } + cat := newApiCategory("other", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeBlock, + }) + mockCtx := setupMockClient(ctx, cat) + h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "other") err := h.CreateOrUpdate(mockCtx, apiSpec) @@ -140,12 +154,11 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { Context("when linting is pending (Spec.Lint nil, warn mode)", func() { It("should proceed with Api creation", func() { - mockCtx := setupMockClient(ctx) - h := &handler.ApiSpecificationHandler{ - GetApiCategory: getApiCategoryWith(newApiCategory("warn-cat", &apiapi.LintingConfig{ - Mode: apiapi.LintingModeWarn, - })), - } + cat := newApiCategory("warn-cat", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeWarn, + }) + mockCtx := setupMockClient(ctx, cat) + h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "warn-cat") err := h.CreateOrUpdate(mockCtx, apiSpec) @@ -157,26 +170,26 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { Context("when linting failed in block mode", func() { It("should set blocked condition with explicit block mode", func() { - h := &handler.ApiSpecificationHandler{ - GetApiCategory: getApiCategoryWith(newApiCategory("strict-cat", &apiapi.LintingConfig{ - Mode: apiapi.LintingModeBlock, - })), - } + cat := newApiCategory("strict-cat", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeBlock, + }) + mockCtx := setupMockClient(ctx, cat) + h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "strict-cat") apiSpec.Spec.Lint = &roverv1.LintResult{Passed: false, Message: "found 3 errors"} - err := h.CreateOrUpdate(ctx, apiSpec) + err := h.CreateOrUpdate(mockCtx, apiSpec) Expect(err).ToNot(HaveOccurred()) Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("found 3 errors")) }) It("should set blocked condition with dashboard URL", func() { - h := &handler.ApiSpecificationHandler{ - GetApiCategory: getApiCategoryWith(newApiCategory("strict-cat", &apiapi.LintingConfig{ - Mode: apiapi.LintingModeBlock, - })), - } + cat := newApiCategory("strict-cat", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeBlock, + }) + mockCtx := setupMockClient(ctx, cat) + h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "strict-cat") apiSpec.Spec.Lint = &roverv1.LintResult{ Passed: false, @@ -184,7 +197,7 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { DashboardURL: "https://linter.example.com/scans/scan-123", } - err := h.CreateOrUpdate(ctx, apiSpec) + err := h.CreateOrUpdate(mockCtx, apiSpec) Expect(err).ToNot(HaveOccurred()) Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("View details")) @@ -192,15 +205,15 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { }) It("should default to block mode when linting mode is empty string", func() { - h := &handler.ApiSpecificationHandler{ - GetApiCategory: getApiCategoryWith(newApiCategory("test-cat", &apiapi.LintingConfig{ - Mode: "", - })), - } + cat := newApiCategory("test-cat", &apiapi.LintingConfig{ + Mode: "", + }) + mockCtx := setupMockClient(ctx, cat) + h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "test-cat") apiSpec.Spec.Lint = &roverv1.LintResult{Passed: false, Message: "found errors"} - err := h.CreateOrUpdate(ctx, apiSpec) + err := h.CreateOrUpdate(mockCtx, apiSpec) Expect(err).ToNot(HaveOccurred()) Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) }) @@ -208,12 +221,11 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { Context("when linting failed in warn mode", func() { It("should proceed with Api creation", func() { - mockCtx := setupMockClient(ctx) - h := &handler.ApiSpecificationHandler{ - GetApiCategory: getApiCategoryWith(newApiCategory("warn-cat", &apiapi.LintingConfig{ - Mode: apiapi.LintingModeWarn, - })), - } + cat := newApiCategory("warn-cat", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeWarn, + }) + mockCtx := setupMockClient(ctx, cat) + h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "warn-cat") apiSpec.Spec.Lint = &roverv1.LintResult{Passed: false, Message: "found 2 warnings"} @@ -239,7 +251,7 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { }) Context("when no linting is configured (Spec.Lint nil, no category linting)", func() { - It("should proceed when GetApiCategory is nil", func() { + It("should proceed when no category is found", func() { mockCtx := setupMockClient(ctx) h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "other") @@ -251,10 +263,9 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { }) It("should proceed when category has no linting config", func() { - mockCtx := setupMockClient(ctx) - h := &handler.ApiSpecificationHandler{ - GetApiCategory: getApiCategoryNil(), - } + cat := newApiCategory("other", nil) + mockCtx := setupMockClient(ctx, cat) + h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "other") err := h.CreateOrUpdate(mockCtx, apiSpec) @@ -264,10 +275,8 @@ var _ = Describe("ApiSpecification Handler Linting Gate", func() { }) It("should proceed when category lookup returns error", func() { - mockCtx := setupMockClient(ctx) - h := &handler.ApiSpecificationHandler{ - GetApiCategory: getApiCategoryError(), - } + mockCtx := setupMockClientWithListError(ctx, fmt.Errorf("api category lookup failed")) + h := &handler.ApiSpecificationHandler{} apiSpec := newApiSpec("hash1", "other") err := h.CreateOrUpdate(mockCtx, apiSpec) diff --git a/rover/internal/index/index.go b/rover/internal/index/index.go new file mode 100644 index 00000000..3e2b20bd --- /dev/null +++ b/rover/internal/index/index.go @@ -0,0 +1,7 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package index + +const FieldApiCategoryLabelValue = "spec.labelValue" From a94205f88199f582c410ef9d4da362bce5d2b9d5 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 14:52:51 +0200 Subject: [PATCH 32/42] chore: fix snapshots --- .../controller/__snapshots__/suite_controller_test.snap | 4 ++-- .../internal/mapper/status/__snapshots__/status_test.snap | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap index 4ae29dce..61b52782 100644 --- a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap +++ b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap @@ -449,7 +449,7 @@ [Rover Controller Get rover status should return the status of a rover successfully - 1] { - "createdAt": "2025-09-18T10:39:44+02:00", + "createdAt": "2025-09-18T08:39:44Z", "errors": [ { "cause": "NoApproval", @@ -464,7 +464,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T09:16:40+02:00", + "processedAt": "2025-10-08T07:16:40Z", "processingState": "done", "state": "blocked" } diff --git a/rover-server/internal/mapper/status/__snapshots__/status_test.snap b/rover-server/internal/mapper/status/__snapshots__/status_test.snap index 3f445fed..5e298203 100644 --- a/rover-server/internal/mapper/status/__snapshots__/status_test.snap +++ b/rover-server/internal/mapper/status/__snapshots__/status_test.snap @@ -19,7 +19,7 @@ [Rover Status Mapper MapRoverResponse must map rover response correctly - 1] { - "createdAt": "2025-09-18T10:39:44+02:00", + "createdAt": "2025-09-18T08:39:44Z", "errors": [ { "cause": "NoApproval", @@ -34,7 +34,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T09:16:40+02:00", + "processedAt": "2025-10-08T07:16:40Z", "processingState": "done", "state": "blocked" } From a8119a6a3248f5ec2f2357b93bcfa32a3fdcd173 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 14:56:39 +0200 Subject: [PATCH 33/42] feat: linting option for TLS (but skip is default) --- rover-server/internal/config/config.go | 2 ++ rover-server/internal/controller/apilinter.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/rover-server/internal/config/config.go b/rover-server/internal/config/config.go index c81dc63d..ce9029d5 100644 --- a/rover-server/internal/config/config.go +++ b/rover-server/internal/config/config.go @@ -25,6 +25,7 @@ type OasLintingConfig struct { Timeout time.Duration `json:"timeout"` URL string `json:"url"` DashboardURL string `json:"dashboardURL"` + SkipTLS bool `json:"skipTLS"` } type SecurityConfig struct { @@ -86,6 +87,7 @@ func setDefaults() { viper.SetDefault("oasLinting.timeout", 0) // 0 means block indefinitely until linter responds viper.SetDefault("oasLinting.url", "") viper.SetDefault("oasLinting.dashboardURL", "") + viper.SetDefault("oasLinting.skipTLS", false) // Database viper.SetDefault("database.filepath", "") // empty string means in-memory only diff --git a/rover-server/internal/controller/apilinter.go b/rover-server/internal/controller/apilinter.go index 62d69856..13f0ffc5 100644 --- a/rover-server/internal/controller/apilinter.go +++ b/rover-server/internal/controller/apilinter.go @@ -55,7 +55,7 @@ func NewApiLinter(lintCfg config.OasLintingConfig) ApiLinter { httpClient: commonclient.NewHttpClientOrDie( commonclient.WithClientName("oaslint"), commonclient.WithClientTimeout(lintCfg.Timeout), - commonclient.WithSkipTlsVerify(true), + commonclient.WithSkipTlsVerify(lintCfg.SkipTLS), ), } } From 3ca4e9dc8ff582a1a15238814308107c81cd1531 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 15:38:10 +0200 Subject: [PATCH 34/42] fix: clarify description of Ruleset parameter in linting configuration --- api/api/v1/apicategory_types.go | 3 ++- api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/api/v1/apicategory_types.go b/api/api/v1/apicategory_types.go index c5317bff..842bf42a 100644 --- a/api/api/v1/apicategory_types.go +++ b/api/api/v1/apicategory_types.go @@ -28,7 +28,8 @@ const ( // LintingConfig configures OAS specification linting for APIs in this category. type LintingConfig struct { // Ruleset is the name of the linter ruleset to apply. - // If set, it is passed as a query parameter to the linter API. + // If set, it is passed as a URL-encoded query parameter to the linter API. + // +required Ruleset string `json:"ruleset"` // Mode controls how linting failures affect API creation. diff --git a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml index 47aedf72..4b31dc2b 100644 --- a/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml +++ b/api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml @@ -96,7 +96,7 @@ spec: ruleset: description: |- Ruleset is the name of the linter ruleset to apply. - If set, it is passed as a query parameter to the linter API. + If set, it is passed as a URL-encoded query parameter to the linter API. type: string whitelistedBasepaths: description: |- From 06e6d5c6edde434e8969c0c4e1cb733ab653727b Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 15:38:35 +0200 Subject: [PATCH 35/42] feat: add RBAC permissions for ApiCategories and update linting description --- rover/internal/controller/apispecification_controller.go | 1 + rover/internal/handler/apispecification/handler.go | 2 +- rover/internal/handler/apispecification/handler_test.go | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/rover/internal/controller/apispecification_controller.go b/rover/internal/controller/apispecification_controller.go index 349e544f..1604a2fa 100644 --- a/rover/internal/controller/apispecification_controller.go +++ b/rover/internal/controller/apispecification_controller.go @@ -36,6 +36,7 @@ type ApiSpecificationReconciler struct { // +kubebuilder:rbac:groups=rover.cp.ei.telekom.de,resources=apispecifications/status,verbs=get;update;patch // +kubebuilder:rbac:groups=rover.cp.ei.telekom.de,resources=apispecifications/finalizers,verbs=update // +kubebuilder:rbac:groups=api.cp.ei.telekom.de,resources=apis,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=api.cp.ei.telekom.de,resources=apicategories,verbs=get;list;watch // +kubebuilder:rbac:groups=admin.cp.ei.telekom.de,resources=zones,verbs=get;list;watch func (r *ApiSpecificationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { diff --git a/rover/internal/handler/apispecification/handler.go b/rover/internal/handler/apispecification/handler.go index 305e5191..31e2f606 100644 --- a/rover/internal/handler/apispecification/handler.go +++ b/rover/internal/handler/apispecification/handler.go @@ -26,7 +26,7 @@ import ( var _ handler.Handler[*roverv1.ApiSpecification] = (*ApiSpecificationHandler)(nil) // ApiSpecificationHandler reconciles ApiSpecification resources. -// Linting is performed by rover-server at upload time and stored in the CRD status fields. +// Linting is performed by rover-server at upload time and stored in Spec.Lint. // This handler reads the lint result and gates Api resource creation accordingly. type ApiSpecificationHandler struct{} diff --git a/rover/internal/handler/apispecification/handler_test.go b/rover/internal/handler/apispecification/handler_test.go index 9a54c016..a0ad9d9d 100644 --- a/rover/internal/handler/apispecification/handler_test.go +++ b/rover/internal/handler/apispecification/handler_test.go @@ -53,7 +53,8 @@ func newApiCategory(name string, linting *apiapi.LintingConfig) *apiapi.ApiCateg }, }, Spec: apiapi.ApiCategorySpec{ - Linting: linting, + LabelValue: name, + Linting: linting, }, } } From 71a10f5718ec8b45e6ecf594650b7edc1739f9e6 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 15:39:20 +0200 Subject: [PATCH 36/42] fix: update timestamps in snapshots and escape ruleset in linting URL --- .../controller/__snapshots__/suite_controller_test.snap | 4 ++-- .../internal/mapper/status/__snapshots__/status_test.snap | 4 ++-- rover-server/internal/oaslint/external.go | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap index 61b52782..4ae29dce 100644 --- a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap +++ b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap @@ -449,7 +449,7 @@ [Rover Controller Get rover status should return the status of a rover successfully - 1] { - "createdAt": "2025-09-18T08:39:44Z", + "createdAt": "2025-09-18T10:39:44+02:00", "errors": [ { "cause": "NoApproval", @@ -464,7 +464,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T07:16:40Z", + "processedAt": "2025-10-08T09:16:40+02:00", "processingState": "done", "state": "blocked" } diff --git a/rover-server/internal/mapper/status/__snapshots__/status_test.snap b/rover-server/internal/mapper/status/__snapshots__/status_test.snap index 5e298203..3f445fed 100644 --- a/rover-server/internal/mapper/status/__snapshots__/status_test.snap +++ b/rover-server/internal/mapper/status/__snapshots__/status_test.snap @@ -19,7 +19,7 @@ [Rover Status Mapper MapRoverResponse must map rover response correctly - 1] { - "createdAt": "2025-09-18T08:39:44Z", + "createdAt": "2025-09-18T10:39:44+02:00", "errors": [ { "cause": "NoApproval", @@ -34,7 +34,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T07:16:40Z", + "processedAt": "2025-10-08T09:16:40+02:00", "processingState": "done", "state": "blocked" } diff --git a/rover-server/internal/oaslint/external.go b/rover-server/internal/oaslint/external.go index 379a3688..39244b8e 100644 --- a/rover-server/internal/oaslint/external.go +++ b/rover-server/internal/oaslint/external.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" commonclient "github.com/telekom/controlplane/common-server/pkg/client" ) @@ -89,7 +90,7 @@ type violationsInfo struct { func (l *ExternalLinter) Lint(ctx context.Context, spec []byte) (*LintResult, error) { scanURL := fmt.Sprintf("%s/%s", l.baseURL, scanEndpoint) if l.ruleset != "" { - scanURL = fmt.Sprintf("%s?ruleset=%s", scanURL, l.ruleset) + scanURL = fmt.Sprintf("%s?ruleset=%s", scanURL, url.QueryEscape(l.ruleset)) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, scanURL, bytes.NewReader(spec)) From 7a51dfba5d0613aa287454c323ff1b966a6bb98b Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 13 May 2026 15:55:33 +0200 Subject: [PATCH 37/42] refactor: add utc back in --- .../__snapshots__/suite_controller_test.snap | 4 ++-- .../mapper/status/__snapshots__/status_test.snap | 4 ++-- rover-server/internal/mapper/status/response.go | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap index 4ae29dce..61b52782 100644 --- a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap +++ b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap @@ -449,7 +449,7 @@ [Rover Controller Get rover status should return the status of a rover successfully - 1] { - "createdAt": "2025-09-18T10:39:44+02:00", + "createdAt": "2025-09-18T08:39:44Z", "errors": [ { "cause": "NoApproval", @@ -464,7 +464,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T09:16:40+02:00", + "processedAt": "2025-10-08T07:16:40Z", "processingState": "done", "state": "blocked" } diff --git a/rover-server/internal/mapper/status/__snapshots__/status_test.snap b/rover-server/internal/mapper/status/__snapshots__/status_test.snap index 3f445fed..5e298203 100644 --- a/rover-server/internal/mapper/status/__snapshots__/status_test.snap +++ b/rover-server/internal/mapper/status/__snapshots__/status_test.snap @@ -19,7 +19,7 @@ [Rover Status Mapper MapRoverResponse must map rover response correctly - 1] { - "createdAt": "2025-09-18T10:39:44+02:00", + "createdAt": "2025-09-18T08:39:44Z", "errors": [ { "cause": "NoApproval", @@ -34,7 +34,7 @@ } ], "overallStatus": "blocked", - "processedAt": "2025-10-08T09:16:40+02:00", + "processedAt": "2025-10-08T07:16:40Z", "processingState": "done", "state": "blocked" } diff --git a/rover-server/internal/mapper/status/response.go b/rover-server/internal/mapper/status/response.go index 080b2cd5..2069280d 100644 --- a/rover-server/internal/mapper/status/response.go +++ b/rover-server/internal/mapper/status/response.go @@ -24,11 +24,11 @@ func MapResponse(ctx context.Context, obj types.Object) (api.ResourceStatusRespo processing := meta.FindStatusCondition(obj.GetConditions(), condition.ConditionTypeProcessing) var processedAtTime time.Time if processing != nil { - processedAtTime = processing.LastTransitionTime.Time + processedAtTime = processing.LastTransitionTime.Time.UTC() } return api.ResourceStatusResponse{ - CreatedAt: obj.GetCreationTimestamp().Time, + CreatedAt: obj.GetCreationTimestamp().Time.UTC(), ProcessedAt: processedAtTime, State: status.State, ProcessingState: status.ProcessingState, @@ -58,14 +58,14 @@ func MapAPISpecificationResponse(ctx context.Context, apiSpec *v1.ApiSpecificati processing := meta.FindStatusCondition(apiSpec.GetConditions(), condition.ConditionTypeProcessing) var processedAtTime time.Time if processing != nil { - processedAtTime = processing.LastTransitionTime.Time + processedAtTime = processing.LastTransitionTime.Time.UTC() } parentOverall := CalculateOverallStatus(status.State, status.ProcessingState) finalOverall := CompareAndReturn(parentOverall, result.WorstOverallStatus) return api.ResourceStatusResponse{ - CreatedAt: apiSpec.GetCreationTimestamp().Time, + CreatedAt: apiSpec.GetCreationTimestamp().Time.UTC(), ProcessedAt: processedAtTime, State: status.State, ProcessingState: status.ProcessingState, @@ -96,14 +96,14 @@ func MapRoverResponse(ctx context.Context, rover *v1.Rover, stores *store.Stores processing := meta.FindStatusCondition(rover.GetConditions(), condition.ConditionTypeProcessing) var processedAtTime time.Time if processing != nil { - processedAtTime = processing.LastTransitionTime.Time + processedAtTime = processing.LastTransitionTime.Time.UTC() } parentOverall := CalculateOverallStatus(status.State, status.ProcessingState) finalOverall := CompareAndReturn(parentOverall, result.WorstOverallStatus) return api.ResourceStatusResponse{ - CreatedAt: rover.GetCreationTimestamp().Time, + CreatedAt: rover.GetCreationTimestamp().Time.UTC(), ProcessedAt: processedAtTime, State: status.State, ProcessingState: status.ProcessingState, @@ -134,14 +134,14 @@ func MapEventSpecificationResponse(ctx context.Context, eventSpec *v1.EventSpeci processing := meta.FindStatusCondition(eventSpec.GetConditions(), condition.ConditionTypeProcessing) var processedAtTime time.Time if processing != nil { - processedAtTime = processing.LastTransitionTime.Time + processedAtTime = processing.LastTransitionTime.Time.UTC() } parentOverall := CalculateOverallStatus(status.State, status.ProcessingState) finalOverall := CompareAndReturn(parentOverall, result.WorstOverallStatus) return api.ResourceStatusResponse{ - CreatedAt: eventSpec.GetCreationTimestamp().Time, + CreatedAt: eventSpec.GetCreationTimestamp().Time.UTC(), ProcessedAt: processedAtTime, State: status.State, ProcessingState: status.ProcessingState, From 0ad701424d32b3ec9e2d5b60a6d74259d61b8c94 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 20 May 2026 08:40:04 +0200 Subject: [PATCH 38/42] refactor: replace []byte with io.Reader --- rover-server/internal/controller/apilinter.go | 7 ++++--- rover-server/internal/controller/apispecification.go | 4 ++-- .../controller/apispecification_lint_test.go | 12 +++++++----- rover-server/internal/oaslint/external.go | 6 +++--- rover-server/internal/oaslint/external_test.go | 10 ++++++---- rover-server/internal/oaslint/linter.go | 7 +++++-- rover-server/internal/oaslint/noop.go | 7 +++++-- 7 files changed, 32 insertions(+), 21 deletions(-) diff --git a/rover-server/internal/controller/apilinter.go b/rover-server/internal/controller/apilinter.go index 13f0ffc5..e745cdb9 100644 --- a/rover-server/internal/controller/apilinter.go +++ b/rover-server/internal/controller/apilinter.go @@ -7,6 +7,7 @@ package controller import ( "context" "fmt" + "io" "strings" "github.com/go-logr/logr" @@ -35,7 +36,7 @@ type ApiLinter interface { // Lint performs the full linting lifecycle for an ApiSpecification. // It looks up the linting config from the category list, checks whitelists, // and runs the linter synchronously. - Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, category *apiv1.ApiCategory, specBytes []byte) (LintOutcome, error) + Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, category *apiv1.ApiCategory, specBytes io.Reader) (LintOutcome, error) } // apiLinterImpl is the production implementation of ApiLinter. @@ -60,7 +61,7 @@ func NewApiLinter(lintCfg config.OasLintingConfig) ApiLinter { } } -func (l *apiLinterImpl) Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, category *apiv1.ApiCategory, specBytes []byte) (LintOutcome, error) { +func (l *apiLinterImpl) Lint(ctx context.Context, apiSpec *roverv1.ApiSpecification, category *apiv1.ApiCategory, specBytes io.Reader) (LintOutcome, error) { log := logr.FromContextOrDiscard(ctx) log.V(1).Info("Looking up linting config", "namespace", apiSpec.Namespace, "name", apiSpec.Name, "category", apiSpec.Spec.Category, "basepath", apiSpec.Spec.BasePath) @@ -102,7 +103,7 @@ func (l *apiLinterImpl) prepareLinting(lintCfg *apiv1.LintingConfig, apiSpec *ro return true } -func (l *apiLinterImpl) runLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, ruleset string, specBytes []byte) error { +func (l *apiLinterImpl) runLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, ruleset string, specBytes io.Reader) error { log := logr.FromContextOrDiscard(ctx).WithName("linting") var opts []oaslint.ExternalLinterOption diff --git a/rover-server/internal/controller/apispecification.go b/rover-server/internal/controller/apispecification.go index 2cb1fbea..59eb8767 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -230,7 +230,7 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri if a.Linter != nil { ns := id.Environment + "--" + id.Namespace existing, _ := a.Store.Get(ctx, ns, id.Name) - lintOutcome, lintErr = a.lintOrReuse(ctx, apiSpec, existing, apiCategory, specMarshaled) + lintOutcome, lintErr = a.lintOrReuse(ctx, apiSpec, existing, apiCategory, bytes.NewReader(specMarshaled)) } err = a.Store.CreateOrReplace(ctx, apiSpec) @@ -288,7 +288,7 @@ func (a *ApiSpecificationController) fetchApiCategories(ctx context.Context) *ap // lintOrReuse decides whether to call the external linter or reuse a cached result. // If the spec hash is unchanged and a previous lint result exists, it reuses it. // Otherwise it delegates to the Linter. -func (a *ApiSpecificationController) lintOrReuse(ctx context.Context, apiSpec *roverv1.ApiSpecification, existing *roverv1.ApiSpecification, category *apiv1.ApiCategory, specBytes []byte) (LintOutcome, error) { +func (a *ApiSpecificationController) lintOrReuse(ctx context.Context, apiSpec *roverv1.ApiSpecification, existing *roverv1.ApiSpecification, category *apiv1.ApiCategory, specBytes io.Reader) (LintOutcome, error) { if existing != nil && existing.Spec.Lint != nil && existing.Spec.Hash == apiSpec.Spec.Hash { apiSpec.Spec.Lint = existing.Spec.Lint return LintSkipped, nil diff --git a/rover-server/internal/controller/apispecification_lint_test.go b/rover-server/internal/controller/apispecification_lint_test.go index 1c2e0df3..729d9249 100644 --- a/rover-server/internal/controller/apispecification_lint_test.go +++ b/rover-server/internal/controller/apispecification_lint_test.go @@ -5,8 +5,10 @@ package controller import ( + "bytes" "context" "encoding/json" + "io" "net/http" "net/http/httptest" @@ -96,7 +98,7 @@ var _ = Describe("Linting helpers", func() { linter ApiLinter apiSpec *roverv1.ApiSpecification category *apiv1.ApiCategory - specBytes []byte + specBytes io.Reader ) newCategory := func(mode apiv1.LintingMode) *apiv1.ApiCategory { @@ -129,7 +131,7 @@ var _ = Describe("Linting helpers", func() { BeforeEach(func() { lintCtx = context.Background() - specBytes = []byte("openapi: '3.0.0'") + specBytes = bytes.NewBuffer([]byte("openapi: '3.0.0'")) apiSpec = &roverv1.ApiSpecification{ ObjectMeta: metav1.ObjectMeta{ Name: "test-spec", @@ -244,7 +246,7 @@ type mockLinter struct { err error } -func (m *mockLinter) Lint(_ context.Context, apiSpec *roverv1.ApiSpecification, _ *apiv1.ApiCategory, _ []byte) (LintOutcome, error) { +func (m *mockLinter) Lint(_ context.Context, apiSpec *roverv1.ApiSpecification, _ *apiv1.ApiCategory, _ io.Reader) (LintOutcome, error) { m.called = true if apiSpec.Spec.Lint == nil { apiSpec.Spec.Lint = &roverv1.LintResult{Passed: true, Message: "mock lint ran"} @@ -257,13 +259,13 @@ var _ = Describe("lintOrReuse (hash dedup)", func() { ctrl *ApiSpecificationController linterMck *mockLinter apiSpec *roverv1.ApiSpecification - specBytes []byte + specBytes io.Reader ) BeforeEach(func() { linterMck = &mockLinter{outcome: LintCompleted} ctrl = &ApiSpecificationController{Linter: linterMck} - specBytes = []byte("openapi: '3.0.0'") + specBytes = bytes.NewBuffer([]byte("openapi: '3.0.0'")) apiSpec = &roverv1.ApiSpecification{ Spec: roverv1.ApiSpecificationSpec{ Hash: "new-hash", diff --git a/rover-server/internal/oaslint/external.go b/rover-server/internal/oaslint/external.go index 39244b8e..ea443d30 100644 --- a/rover-server/internal/oaslint/external.go +++ b/rover-server/internal/oaslint/external.go @@ -5,10 +5,10 @@ package oaslint import ( - "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "net/url" @@ -87,13 +87,13 @@ type violationsInfo struct { Hints int `json:"hints"` } -func (l *ExternalLinter) Lint(ctx context.Context, spec []byte) (*LintResult, error) { +func (l *ExternalLinter) Lint(ctx context.Context, spec io.Reader) (*LintResult, error) { scanURL := fmt.Sprintf("%s/%s", l.baseURL, scanEndpoint) if l.ruleset != "" { scanURL = fmt.Sprintf("%s?ruleset=%s", scanURL, url.QueryEscape(l.ruleset)) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, scanURL, bytes.NewReader(spec)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, scanURL, spec) if err != nil { return nil, fmt.Errorf("creating linter request: %w", err) } diff --git a/rover-server/internal/oaslint/external_test.go b/rover-server/internal/oaslint/external_test.go index dda6a0e1..eccbcff0 100644 --- a/rover-server/internal/oaslint/external_test.go +++ b/rover-server/internal/oaslint/external_test.go @@ -5,8 +5,10 @@ package oaslint import ( + "bytes" "context" "encoding/json" + "io" "net/http" "net/http/httptest" "time" @@ -20,18 +22,18 @@ var _ = Describe("ExternalLinter", func() { ctx context.Context server *httptest.Server linter *ExternalLinter - spec []byte + spec io.Reader ) BeforeEach(func() { ctx = context.Background() - spec = []byte(`openapi: "3.0.0" + spec = bytes.NewBuffer([]byte(`openapi: "3.0.0" info: title: Test API version: "1.0.0" servers: - url: http://example.com/api/v1 -`) +`)) }) AfterEach(func() { @@ -164,7 +166,7 @@ servers: var _ = Describe("NoopLinter", func() { It("should always return a passing result", func() { linter := &NoopLinter{} - result, err := linter.Lint(context.Background(), []byte("anything")) + result, err := linter.Lint(context.Background(), bytes.NewBuffer([]byte("anything"))) Expect(err).NotTo(HaveOccurred()) Expect(result.Passed).To(BeTrue()) Expect(result.Reason).To(ContainSubstring("disabled")) diff --git a/rover-server/internal/oaslint/linter.go b/rover-server/internal/oaslint/linter.go index fbbb3c83..71bf1d72 100644 --- a/rover-server/internal/oaslint/linter.go +++ b/rover-server/internal/oaslint/linter.go @@ -4,12 +4,15 @@ package oaslint -import "context" +import ( + "context" + "io" +) // Linter defines the interface for OAS specification linting. // The external linter server manages rulesets; clients just send the spec. type Linter interface { - Lint(ctx context.Context, spec []byte) (*LintResult, error) + Lint(ctx context.Context, spec io.Reader) (*LintResult, error) } // LintResult contains the outcome of a linting operation. diff --git a/rover-server/internal/oaslint/noop.go b/rover-server/internal/oaslint/noop.go index 9fbb0b20..6378db0d 100644 --- a/rover-server/internal/oaslint/noop.go +++ b/rover-server/internal/oaslint/noop.go @@ -4,14 +4,17 @@ package oaslint -import "context" +import ( + "context" + "io" +) var _ Linter = (*NoopLinter)(nil) // NoopLinter always returns a passing result. Used when linting is disabled. type NoopLinter struct{} -func (n *NoopLinter) Lint(_ context.Context, _ []byte) (*LintResult, error) { +func (n *NoopLinter) Lint(_ context.Context, _ io.Reader) (*LintResult, error) { return &LintResult{ Passed: true, Reason: "linting is disabled", From 02e32c26b3fc65e9238ee522c6866211980ec42a Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 20 May 2026 08:50:23 +0200 Subject: [PATCH 39/42] docs: update documentation if for apilinting --- .../admin-journey/features/api-categories.md | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/docs/admin-journey/features/api-categories.md b/docs/docs/admin-journey/features/api-categories.md index 0e5ce1d8..33e96476 100644 --- a/docs/docs/admin-journey/features/api-categories.md +++ b/docs/docs/admin-journey/features/api-categories.md @@ -140,21 +140,54 @@ When set to `false`, teams in this category can use any base path structure. The ### API linting -API Categories can enforce linting on submitted OpenAPI specifications. When linting is enabled, every specification registered under this category is validated against the specified ruleset. +API Categories can enforce linting on submitted OpenAPI specifications. When linting is configured, every specification registered under this category is validated against the specified ruleset. ```yaml spec: labelValue: "public" active: true linting: - enabled: true ruleset: "strict" + mode: "Block" + whitelistedBasepaths: + - "/legacy/api/v1" ``` | Field | Description | | ----- | ----------- | -| `enabled` | Turns linting on or off for this category. | -| `ruleset` | The name of the ruleset to validate against. | +| `ruleset` | **(required)** The name of the ruleset to validate against. Passed to the external linter as a query parameter. | +| `mode` | `Block` (default) rejects the specification on failure; `Warn` stores the result but allows the upload; `None` disables linting. | +| `whitelistedBasepaths` | A list of base paths that skip linting entirely (case-insensitive match). Each entry must start with `/`. | + +:::info +Linting is only active when the `linting` section is present **and** `mode` is not `None`. If the `linting` section is omitted entirely, no linting is performed. +::: + +#### Error message template + +The rover-server configuration option `oasLinting.errorMessage` controls the message returned to clients when linting fails. It supports the following placeholders: + +| Placeholder | Replaced with | Description | +|---|---|---| +| `RULESET_NAME_PLACEHOLDER` | Ruleset name from the lint result | The name of the ruleset that was applied during linting | + +**Default:** + +``` +Linter scan result contains errors. Please visit the linter UI for details on the RULESET_NAME_PLACEHOLDER ruleset. +``` + +#### Rover-server linting configuration + +The following environment variables configure the linting integration on the rover-server side: + +| Environment Variable | Description | Default | +|---|---|---| +| `OASLINTING_URL` | Base URL of the external linter API. If empty, linting is disabled regardless of category config. | _(empty)_ | +| `OASLINTING_DASHBOARDURL` | Base URL of the linter dashboard. When set, lint results include a link to `/scans/`. | _(empty)_ | +| `OASLINTING_ERRORMESSAGE` | Error message template (see placeholders above). | See above | +| `OASLINTING_TIMEOUT` | HTTP timeout for linter requests (Go duration, e.g. `30s`). `0` means no timeout. | `0` | +| `OASLINTING_SKIPTLS` | Skip TLS verification for linter requests. | `false` | ### Activating and deactivating categories @@ -186,8 +219,8 @@ spec: names: - "phoenix--firebirds" linting: - enabled: true ruleset: "strict" + mode: "Block" ``` This category: From f0e88171c23bd191e304cc8d86678f993c830a52 Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 20 May 2026 08:50:36 +0200 Subject: [PATCH 40/42] refactor: make parameter more clear --- rover-server/internal/controller/apilinter.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/rover-server/internal/controller/apilinter.go b/rover-server/internal/controller/apilinter.go index e745cdb9..fae8f7e1 100644 --- a/rover-server/internal/controller/apilinter.go +++ b/rover-server/internal/controller/apilinter.go @@ -41,18 +41,18 @@ type ApiLinter interface { // apiLinterImpl is the production implementation of ApiLinter. type apiLinterImpl struct { - errorMessage string - url string - dashboardURL string - httpClient oaslint.HTTPDoer + errorMessageTemplate string + url string + dashboardURL string + httpClient oaslint.HTTPDoer } // NewApiLinter creates an ApiLinter from the given linting configuration. func NewApiLinter(lintCfg config.OasLintingConfig) ApiLinter { return &apiLinterImpl{ - errorMessage: lintCfg.ErrorMessage, - url: lintCfg.URL, - dashboardURL: lintCfg.DashboardURL, + errorMessageTemplate: lintCfg.ErrorMessage, + url: lintCfg.URL, + dashboardURL: lintCfg.DashboardURL, httpClient: commonclient.NewHttpClientOrDie( commonclient.WithClientName("oaslint"), commonclient.WithClientTimeout(lintCfg.Timeout), @@ -139,7 +139,7 @@ func (l *apiLinterImpl) buildLintResult(result *oaslint.LintResult) *roverv1.Lin lintResult.DashboardURL = fmt.Sprintf("%s/scans/%s", strings.TrimRight(l.dashboardURL, "/"), result.LinterId) } if !result.Passed { - lintResult.Message = strings.ReplaceAll(l.errorMessage, "RULESET_NAME_PLACEHOLDER", result.Ruleset) + lintResult.Message = strings.ReplaceAll(l.errorMessageTemplate, "RULESET_NAME_PLACEHOLDER", result.Ruleset) } return lintResult } From 732c2720c00f04b3d0294d18b55cf58fdbe297ea Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 20 May 2026 09:05:51 +0200 Subject: [PATCH 41/42] refactor: default for timeout is 55sec due to gateway limitations --- docs/docs/admin-journey/features/api-categories.md | 2 +- rover-server/internal/config/config.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/admin-journey/features/api-categories.md b/docs/docs/admin-journey/features/api-categories.md index 33e96476..5bb8af02 100644 --- a/docs/docs/admin-journey/features/api-categories.md +++ b/docs/docs/admin-journey/features/api-categories.md @@ -186,7 +186,7 @@ The following environment variables configure the linting integration on the rov | `OASLINTING_URL` | Base URL of the external linter API. If empty, linting is disabled regardless of category config. | _(empty)_ | | `OASLINTING_DASHBOARDURL` | Base URL of the linter dashboard. When set, lint results include a link to `/scans/`. | _(empty)_ | | `OASLINTING_ERRORMESSAGE` | Error message template (see placeholders above). | See above | -| `OASLINTING_TIMEOUT` | HTTP timeout for linter requests (Go duration, e.g. `30s`). `0` means no timeout. | `0` | +| `OASLINTING_TIMEOUT` | HTTP timeout for linter requests (Go duration, e.g. `30s`). `0` means no timeout. | `55s` | | `OASLINTING_SKIPTLS` | Skip TLS verification for linter requests. | `false` | ### Activating and deactivating categories diff --git a/rover-server/internal/config/config.go b/rover-server/internal/config/config.go index ce9029d5..578edaac 100644 --- a/rover-server/internal/config/config.go +++ b/rover-server/internal/config/config.go @@ -84,7 +84,7 @@ func setDefaults() { // OAS Linting viper.SetDefault("oasLinting.errorMessage", "Linter scan result contains errors. Please visit the linter UI for details on the RULESET_NAME_PLACEHOLDER ruleset.") - viper.SetDefault("oasLinting.timeout", 0) // 0 means block indefinitely until linter responds + viper.SetDefault("oasLinting.timeout", 55) // seconds; 0 means block indefinitely until linter responds viper.SetDefault("oasLinting.url", "") viper.SetDefault("oasLinting.dashboardURL", "") viper.SetDefault("oasLinting.skipTLS", false) From 5c6ecea96c93e38ace5a94dd23674845c61b76cc Mon Sep 17 00:00:00 2001 From: stefan-ctrl Date: Wed, 20 May 2026 11:59:56 +0200 Subject: [PATCH 42/42] refactor: change defautl value --- rover-server/internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rover-server/internal/config/config.go b/rover-server/internal/config/config.go index 578edaac..cc181f71 100644 --- a/rover-server/internal/config/config.go +++ b/rover-server/internal/config/config.go @@ -83,7 +83,7 @@ func setDefaults() { viper.SetDefault("fileManager.skipTLS", true) // OAS Linting - viper.SetDefault("oasLinting.errorMessage", "Linter scan result contains errors. Please visit the linter UI for details on the RULESET_NAME_PLACEHOLDER ruleset.") + viper.SetDefault("oasLinting.errorMessage", "Linter scan result contains errors for RULESET_NAME_PLACEHOLDER ruleset.") viper.SetDefault("oasLinting.timeout", 55) // seconds; 0 means block indefinitely until linter responds viper.SetDefault("oasLinting.url", "") viper.SetDefault("oasLinting.dashboardURL", "")