diff --git a/api/api/v1/apicategory_types.go b/api/api/v1/apicategory_types.go index d2c7a2718..842bf42ac 100644 --- a/api/api/v1/apicategory_types.go +++ b/api/api/v1/apicategory_types.go @@ -13,6 +13,41 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// LintingMode controls how linting failures affect API creation. +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" + // LintingModeNone indicates that no linting is configured for this category. + LintingModeNone LintingMode = "None" +) + +// 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 URL-encoded query parameter to the linter API. + // +required + 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 + // +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 linting is configured. + // Each entry must start with a leading slash. + // +optional + // +listType=set + // +kubebuilder:validation:items:Pattern=`^/` + 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. @@ -39,16 +74,11 @@ type ApiCategorySpec struct { // +kubebuilder:default:=true MustHaveGroupPrefix bool `json:"mustHaveGroupPrefix,omitempty"` + // Linting configures OAS specification linting for APIs in this category. + // +optional 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 { // Categories defines the list of team categories that are allowed to use this API category. // If empty, all team categories are allowed. diff --git a/api/api/v1/zz_generated.deepcopy.go b/api/api/v1/zz_generated.deepcopy.go index e56441fda..db6314891 100644 --- a/api/api/v1/zz_generated.deepcopy.go +++ b/api/api/v1/zz_generated.deepcopy.go @@ -136,7 +136,7 @@ func (in *ApiCategorySpec) DeepCopyInto(out *ApiCategorySpec) { if in.Linting != nil { in, out := &in.Linting, &out.Linting *out = new(LintingConfig) - **out = **in + (*in).DeepCopyInto(*out) } } @@ -668,6 +668,11 @@ func (in *Limits) DeepCopy() *Limits { // 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. 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 888e4fcfd..4b31dc2b2 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,14 +80,36 @@ spec: minLength: 1 type: string linting: + description: Linting configures OAS specification linting for APIs + in this category. properties: - enabled: - description: Enabled indicates whether linting is enabled for - this API category. - type: boolean + mode: + 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: Ruleset specifies the ruleset to use for linting. + description: |- + Ruleset is the name of the linter ruleset to apply. + If set, it is passed as a URL-encoded query parameter to the linter API. 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 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/api/internal/controller/apicategory_controller_test.go b/api/internal/controller/apicategory_controller_test.go index 28a7a18e2..15ae11f88 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", - }, }, } } diff --git a/rover-server/cmd/main.go b/rover-server/cmd/main.go index 078761fb5..566babf7c 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), + ApiSpecifications: controller.NewApiSpecificationController(stores, cfg.OasLinting), Rovers: controller.NewRoverController(stores), Roadmaps: controller.NewRoadmapController(stores), EventSpecifications: controller.NewEventSpecificationController(stores), diff --git a/rover-server/config/rbac/role.yaml b/rover-server/config/rbac/role.yaml index bef6ec9a6..2b1d47f69 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 85ed35411..ce9029d5d 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" @@ -16,6 +17,15 @@ type ServerConfig struct { Security SecurityConfig `json:"security"` Log LogConfig `json:"log"` FileManager FileManagerConfig `json:"fileManager"` + OasLinting OasLintingConfig `json:"oasLinting"` +} + +type OasLintingConfig struct { + ErrorMessage string `json:"errorMessage"` + Timeout time.Duration `json:"timeout"` + URL string `json:"url"` + DashboardURL string `json:"dashboardURL"` + SkipTLS bool `json:"skipTLS"` } type SecurityConfig struct { @@ -72,6 +82,13 @@ func setDefaults() { // FileManager 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.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 viper.SetDefault("database.reduceMemory", false) // see common-server docs diff --git a/rover-server/internal/controller/apilinter.go b/rover-server/internal/controller/apilinter.go new file mode 100644 index 000000000..13f0ffc59 --- /dev/null +++ b/rover-server/internal/controller/apilinter.go @@ -0,0 +1,154 @@ +// 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" + commonclient "github.com/telekom/controlplane/common-server/pkg/client" + "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 + // LintBlocked means linting ran, the spec failed, and the category mode is Block. + LintBlocked +) + +// ApiLinter abstracts the full OAS linting lifecycle: config lookup, +// 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 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 { + errorMessage 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, + httpClient: commonclient.NewHttpClientOrDie( + commonclient.WithClientName("oaslint"), + commonclient.WithClientTimeout(lintCfg.Timeout), + commonclient.WithSkipTlsVerify(lintCfg.SkipTLS), + ), + } +} + +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) + + 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", "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.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) 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 + } + apiSpec.Spec.Lint = nil + return true +} + +func (l *apiLinterImpl) runLint(ctx context.Context, apiSpec *roverv1.ApiSpecification, ruleset string, specBytes []byte) error { + log := logr.FromContextOrDiscard(ctx).WithName("linting") + + var opts []oaslint.ExternalLinterOption + if ruleset != "" { + opts = append(opts, oaslint.WithRuleset(ruleset)) + } + opts = append(opts, oaslint.WithHTTPClient(l.httpClient)) + linter := oaslint.NewExternalLinter(l.url, opts...) + + result, err := linter.Lint(ctx, specBytes) + if err != nil { + apiSpec.Spec.Lint = &roverv1.LintResult{ + Passed: false, + Message: fmt.Sprintf("linter API error: %s", 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 nil +} + +func (l *apiLinterImpl) buildLintResult(result *oaslint.LintResult) *roverv1.LintResult { + lintResult := &roverv1.LintResult{ + Passed: result.Passed, + Message: result.Reason, + } + 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) + } + 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 9c563308f..2cb1fbead 100644 --- a/rover-server/internal/controller/apispecification.go +++ b/rover-server/internal/controller/apispecification.go @@ -9,11 +9,14 @@ import ( "context" "crypto/sha256" "encoding/base64" + "fmt" "io" + "strings" + "github.com/go-logr/logr" "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/log" "github.com/pkg/errors" + 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" @@ -23,6 +26,7 @@ import ( "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" @@ -36,12 +40,16 @@ var _ server.ApiSpecificationController = &ApiSpecificationController{} type ApiSpecificationController struct { stores *s.Stores Store store.ObjectStore[*roverv1.ApiSpecification] + + // Linter handles OAS linting operations. If nil, linting is disabled. + Linter ApiLinter } -func NewApiSpecificationController(stores *s.Stores) *ApiSpecificationController { +func NewApiSpecificationController(stores *s.Stores, lintCfg config.OasLintingConfig) *ApiSpecificationController { return &ApiSpecificationController{ stores: stores, Store: stores.APISpecificationStore, + Linter: NewApiLinter(lintCfg), } } @@ -50,7 +58,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") } @@ -189,6 +197,20 @@ 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.validateApiCategoryFromList(categoryList, apiSpec.Spec.Category); catErr != nil { + 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 @@ -200,11 +222,29 @@ func (a *ApiSpecificationController) Update(ctx context.Context, resourceId stri } EnsureLabelsOrDie(ctx, apiSpec) + // 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 { + 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) if err != nil { 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) } @@ -227,6 +267,57 @@ func (a *ApiSpecificationController) GetStatus(ctx context.Context, resourceId s return status.MapAPISpecificationResponse(ctx, apiSpec, a.stores) } +// 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 + } + 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 + } + result := &apiv1.ApiCategoryList{Items: make([]apiv1.ApiCategory, 0, len(categoryList.Items))} + for _, item := range categoryList.Items { + result.Items = append(result.Items, *item) + } + 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 { + if categoryList == nil { + return nil + } + + found, ok := categoryList.FindByLabelValue(category) + if !ok { + allowedLabels := strings.Join(categoryList.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 +} + 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_test.go b/rover-server/internal/controller/apispecification_lint_test.go new file mode 100644 index 000000000..1c2e0df3c --- /dev/null +++ b/rover-server/internal/controller/apispecification_lint_test.go @@ -0,0 +1,334 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +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" + 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 linter *apiLinterImpl + + BeforeEach(func() { + linter = &apiLinterImpl{} + }) + + It("should skip linting for category-whitelisted basepath", func() { + lintCfg := &apiv1.LintingConfig{ + WhitelistedBasepaths: []string{"/eni/internal/v1"}, + } + apiSpec := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{BasePath: "/eni/internal/v1"}, + } + + 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 require linting when basepath is not whitelisted", func() { + lintCfg := &apiv1.LintingConfig{} + apiSpec := &roverv1.ApiSpecification{ + Spec: roverv1.ApiSpecificationSpec{BasePath: "/eni/test/v1"}, + } + + result := linter.prepareLinting(lintCfg, apiSpec) + Expect(result).To(BeTrue()) + Expect(apiSpec.Spec.Lint).To(BeNil()) + }) + }) + + 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", + }, + }, + } + } + + 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: "/test/api/v1", + Category: "test-cat", + Hash: "new-hash", + }, + } + }) + + AfterEach(func() { + if linterServer != nil { + linterServer.Close() + } + }) + + 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)) + }) + + 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")) + }) +}) diff --git a/rover-server/internal/controller/suite_controller_test.go b/rover-server/internal/controller/suite_controller_test.go index e4d9501c7..febf9b426 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), + ApiSpecifications: NewApiSpecificationController(stores, config.OasLintingConfig{}), Rovers: NewRoverController(stores), Roadmaps: NewRoadmapController(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 c912a969c..d942f3aa4 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 new file mode 100644 index 000000000..39244b8e0 --- /dev/null +++ b/rover-server/internal/oaslint/external.go @@ -0,0 +1,131 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package oaslint + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + commonclient "github.com/telekom/controlplane/common-server/pkg/client" +) + +const ( + scanEndpoint = "api/linter/scans" + yamlContentType = "application/yaml; charset=UTF-8" +) + +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 HTTPDoer +} + +// ExternalLinterOption configures the ExternalLinter. +type ExternalLinterOption func(*ExternalLinter) + +// WithHTTPClient overrides the default HTTP client. +func WithHTTPClient(c HTTPDoer) ExternalLinterOption { + return func(l *ExternalLinter) { + l.client = c + } +} + +// WithRuleset sets the ruleset query parameter for linter scan requests. +func WithRuleset(ruleset string) ExternalLinterOption { + return func(l *ExternalLinter) { + l.ruleset = ruleset + } +} + +// NewExternalLinter creates a new ExternalLinter targeting the given base URL. +func NewExternalLinter(baseURL string, opts ...ExternalLinterOption) *ExternalLinter { + l := &ExternalLinter{ + baseURL: baseURL, + client: &http.Client{}, + } + 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) { + 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)) + 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() + + 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.NewDecoder(resp.Body).Decode(&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, + 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 new file mode 100644 index 000000000..dda6a0e1a --- /dev/null +++ b/rover-server/internal/oaslint/external_test.go @@ -0,0 +1,172 @@ +// 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.Errors).To(Equal(0)) + Expect(result.Warnings).To(Equal(2)) + 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 API error")) + 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 000000000..fbbb3c831 --- /dev/null +++ b/rover-server/internal/oaslint/linter.go @@ -0,0 +1,23 @@ +// 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 + LinterId string + Errors int + Warnings int +} diff --git a/rover-server/internal/oaslint/noop.go b/rover-server/internal/oaslint/noop.go new file mode 100644 index 000000000..9fbb0b201 --- /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 000000000..1b4f4b300 --- /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 fb8cd65ab..6503a722c 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] RoadmapStore store.ObjectStore[*roverv1.Roadmap] @@ -79,6 +80,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 000000000..cc7f740e3 --- /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() +} diff --git a/rover/api/v1/apispecification_types.go b/rover/api/v1/apispecification_types.go index ff5763e80..f5ff36c9b 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 { diff --git a/rover/api/v1/zz_generated.deepcopy.go b/rover/api/v1/zz_generated.deepcopy.go index 7e05cdf85..e9b5f9b22 100644 --- a/rover/api/v1/zz_generated.deepcopy.go +++ b/rover/api/v1/zz_generated.deepcopy.go @@ -230,6 +230,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. @@ -812,6 +817,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 978bfdbf9..5a88cfe50 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 diff --git a/rover/internal/controller/apispecification_controller.go b/rover/internal/controller/apispecification_controller.go index b7003e8c8..1604a2fa9 100644 --- a/rover/internal/controller/apispecification_controller.go +++ b/rover/internal/controller/apispecification_controller.go @@ -7,6 +7,7 @@ package controller import ( "context" + 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" @@ -17,7 +18,6 @@ import ( apispec_handler "github.com/telekom/controlplane/rover/internal/handler/apispecification" - apiapi "github.com/telekom/controlplane/api/api/v1" rover "github.com/telekom/controlplane/rover/api/v1" ) @@ -36,6 +36,8 @@ 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) { return r.Controller.Reconcile(ctx, req, &rover.ApiSpecification{}) @@ -44,7 +46,10 @@ 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{} + + r.Controller = cc.NewController(h, r.Client, r.Recorder) return ctrl.NewControllerManagedBy(mgr). For(&rover.ApiSpecification{}). diff --git a/rover/internal/controller/index.go b/rover/internal/controller/index.go index 02dc356cf..853e5f400 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" @@ -14,8 +15,10 @@ 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" ) func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { @@ -42,6 +45,18 @@ func RegisterIndicesOrDie(ctx context.Context, mgr ctrl.Manager) { os.Exit(1) } + err = mgr.GetFieldIndexer().IndexField(ctx, &apiapi.ApiCategory{}, roverindex.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", roverindex.FieldApiCategoryLabelValue) + os.Exit(1) + } + if cconfig.FeaturePubSub.IsEnabled() { err = index.SetOwnerIndex(ctx, mgr.GetFieldIndexer(), &eventv1.EventExposure{}) if err != nil { diff --git a/rover/internal/handler/apispecification/handler.go b/rover/internal/handler/apispecification/handler.go index c76ba553b..31e2f6064 100644 --- a/rover/internal/handler/apispecification/handler.go +++ b/rover/internal/handler/apispecification/handler.go @@ -6,6 +6,8 @@ package apispecification import ( "context" + "fmt" + "strings" "github.com/pkg/errors" apiapi "github.com/telekom/controlplane/api/api/v1" @@ -15,16 +17,70 @@ 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" ) var _ handler.Handler[*roverv1.ApiSpecification] = (*ApiSpecificationHandler)(nil) +// ApiSpecificationHandler reconciles ApiSpecification resources. +// 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{} func (h *ApiSpecificationHandler) CreateOrUpdate(ctx context.Context, apiSpec *roverv1.ApiSpecification) error { + mode := h.lookupLintingMode(ctx, apiSpec.Spec.Category) + + // 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) + } + apiSpec.SetCondition(condition.NewBlockedCondition(msg)) + apiSpec.SetCondition(condition.NewNotReadyCondition("LintingFailed", + "API specification did not pass linting")) + return nil + } + return h.createOrUpdateApi(ctx, apiSpec) +} + +func (h *ApiSpecificationHandler) Delete(_ context.Context, _ *roverv1.ApiSpecification) error { + return nil +} + +// lookupLintingMode finds the ApiCategory and returns the effective linting mode. +func (h *ApiSpecificationHandler) lookupLintingMode(ctx context.Context, category string) apiapi.LintingMode { + cat, err := h.getApiCategory(ctx, category) + if err != nil || cat == nil || cat.Spec.Linting == nil { + return apiapi.LintingModeNone + } + mode := cat.Spec.Linting.Mode + if mode == "" { + mode = apiapi.LintingModeBlock + } + 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) name := roverv1.MakeName(apiSpec) @@ -66,7 +122,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 +129,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 000000000..a0ad9d9d4 --- /dev/null +++ b/rover/internal/handler/apispecification/handler_test.go @@ -0,0 +1,297 @@ +// Copyright 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package apispecification_test + +import ( + "context" + "fmt" + + . "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", + }, + }, + Spec: roverv1.ApiSpecificationSpec{ + Specification: "file-id-123", + Category: category, + BasePath: "/eni/test/v1", + Hash: hash, + Version: "1.0.0", + }, + } +} + +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: apiapi.ApiCategorySpec{ + LabelValue: name, + Linting: linting, + }, + } +} + +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 "" +} + +// setupMockClient creates a mock JanitorClient injected into context. +// The mock expects CreateOrUpdate and returns success. +// 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) + _ = 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). + Return(listErr).Maybe() + + return cclient.WithClient(ctx, fakeClient) +} + +var _ = Describe("ApiSpecification Handler Linting Gate", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + Context("when linting is pending (Spec.Lint nil, block mode)", func() { + It("should proceed with Api creation to avoid blocking indefinitely", func() { + cat := newApiCategory("other", &apiapi.LintingConfig{ + Mode: apiapi.LintingModeBlock, + }) + mockCtx := setupMockClient(ctx, cat) + h := &handler.ApiSpecificationHandler{} + apiSpec := newApiSpec("hash1", "other") + + 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 is pending (Spec.Lint nil, warn mode)", func() { + It("should proceed with Api creation", func() { + 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) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("API updated")) + }) + }) + + Context("when linting failed in block mode", func() { + It("should set blocked condition with explicit block mode", func() { + 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(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() { + 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", + DashboardURL: "https://linter.example.com/scans/scan-123", + } + + 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")) + Expect(conditionMessage(apiSpec, condition.ConditionTypeProcessing)).To(ContainSubstring("scan-123")) + }) + + It("should default to block mode when linting mode is empty string", func() { + 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(mockCtx, apiSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(hasCondition(apiSpec, condition.ConditionTypeProcessing)).To(BeTrue()) + }) + }) + + Context("when linting failed in warn mode", func() { + It("should proceed with Api creation", func() { + 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"} + + 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 with Api creation", func() { + mockCtx := setupMockClient(ctx) + h := &handler.ApiSpecificationHandler{} + apiSpec := newApiSpec("hash1", "other") + apiSpec.Spec.Lint = &roverv1.LintResult{Passed: true, Message: "no errors"} + + 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 no category is found", func() { + mockCtx := setupMockClient(ctx) + h := &handler.ApiSpecificationHandler{} + apiSpec := newApiSpec("hash1", "other") + + 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() { + cat := newApiCategory("other", nil) + mockCtx := setupMockClient(ctx, cat) + h := &handler.ApiSpecificationHandler{} + apiSpec := newApiSpec("hash1", "other") + + 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 := setupMockClientWithListError(ctx, fmt.Errorf("api category lookup failed")) + h := &handler.ApiSpecificationHandler{} + apiSpec := newApiSpec("hash1", "other") + + 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("Delete", func() { + It("should return nil", func() { + h := &handler.ApiSpecificationHandler{} + err := h.Delete(ctx, newApiSpec("hash1", "other")) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/rover/internal/handler/apispecification/suite_test.go b/rover/internal/handler/apispecification/suite_test.go new file mode 100644 index 000000000..ebcbbf98e --- /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/index/index.go b/rover/internal/index/index.go new file mode 100644 index 000000000..3e2b20bdd --- /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"