Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
a143620
feat(admin): add linting configuration
stefan-ctrl Apr 28, 2026
2cdaa1e
refactor(api): remove LintingConfig from ApiCategorySpec and related …
stefan-ctrl Apr 28, 2026
53d8c7a
feat(rover): add external linter integration and enhance ApiSpecifica…
stefan-ctrl Apr 28, 2026
0422636
feat(rover-server): integrate external linter and enhance API specifi…
stefan-ctrl Apr 28, 2026
24648b8
refactor: remove from admin config
stefan-ctrl Apr 29, 2026
9a1bb96
feat(api): add whitelist and linting config to apüi category
stefan-ctrl Apr 29, 2026
843e7da
feat(api): add LintingModeNone and ruleset description to ApiCategory…
stefan-ctrl Apr 29, 2026
0fe4516
feat(api): enhance ApiSpecification with linting results and update h…
stefan-ctrl Apr 29, 2026
799f51e
feat(api): refactor OAS linting integration and enhance linting confi…
stefan-ctrl Apr 29, 2026
0d63d9b
feat: add `none`
stefan-ctrl May 6, 2026
8e4df39
feat: add sync to mimic current behaviour
stefan-ctrl May 6, 2026
ed60c71
feat(api): update LintingMode documentation and add validation for Wh…
stefan-ctrl May 6, 2026
e1b5bde
feat(api): simplify linting mode definition and enhance WhitelistedBa…
stefan-ctrl May 6, 2026
3104ad0
feat(api): enhance linting error handling and improve response mapping
stefan-ctrl May 6, 2026
f8e9e06
test: remove unused test
stefan-ctrl May 6, 2026
db2f31e
refactor(rover): remove double checking of category
stefan-ctrl May 6, 2026
4db1eb6
feat(api): refactor linting configuration handling and improve valida…
stefan-ctrl May 6, 2026
427a979
feat(tests): enhance ApiSpecification handler tests with mock client …
stefan-ctrl May 6, 2026
ae6b0b1
feat(api): enhance ApiSpecificationController with async linting supp…
stefan-ctrl May 6, 2026
6082d9a
Merge branch 'main' into feat/oaslint
stefan-ctrl May 6, 2026
82df404
Merge branch 'main' into feat/oaslint
stefan-ctrl May 13, 2026
20fad69
refactor: enum pascal case
stefan-ctrl May 13, 2026
4edc142
refactor: add index
stefan-ctrl May 13, 2026
9f6f6c2
refactor: use ConfigStructs to avoid parameter overload
stefan-ctrl May 13, 2026
941910d
refactor: use commonclient error handler + reduce memory usage
stefan-ctrl May 13, 2026
e51d49d
refactor: remove async mode for now
stefan-ctrl May 13, 2026
d573338
chore: UPDATE_SNAPS
stefan-ctrl May 13, 2026
72197cb
refactor: update linting configuration and remove unused URL fields
stefan-ctrl May 13, 2026
05c4c4e
refactor: improve to access the store intead
stefan-ctrl May 13, 2026
cee8de3
refactor: streamline linting process and remove unused linting helpers
stefan-ctrl May 13, 2026
32deef7
chore: revert to main
stefan-ctrl May 13, 2026
55f5f80
chore: update snapshots
stefan-ctrl May 13, 2026
f967e1d
refactor: move logic to handler
stefan-ctrl May 13, 2026
a94205f
chore: fix snapshots
stefan-ctrl May 13, 2026
a8119a6
feat: linting option for TLS (but skip is default)
stefan-ctrl May 13, 2026
557cec7
Merge branch 'main' into feat/oaslint
stefan-ctrl May 13, 2026
3ca4e9d
fix: clarify description of Ruleset parameter in linting configuration
stefan-ctrl May 13, 2026
06e6d5c
feat: add RBAC permissions for ApiCategories and update linting descr…
stefan-ctrl May 13, 2026
71a10f5
fix: update timestamps in snapshots and escape ruleset in linting URL
stefan-ctrl May 13, 2026
abdcefb
Merge branch 'feat/oaslint' of https://github.com/telekom/controlplan…
stefan-ctrl May 13, 2026
7a51dfb
refactor: add utc back in
stefan-ctrl May 13, 2026
32b811e
Merge branch 'main' into feat/oaslint
stefan-ctrl May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions api/api/v1/apicategory_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Comment thread
stefan-ctrl marked this conversation as resolved.

// 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.
Expand All @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion api/api/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 27 additions & 5 deletions api/config/crd/bases/api.cp.ei.telekom.de_apicategories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
stefan-ctrl marked this conversation as resolved.
type: object
mustHaveGroupPrefix:
default: true
Expand Down
4 changes: 0 additions & 4 deletions api/internal/controller/apicategory_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
package controller

import (
. "github.com/onsi/ginkgo/v2"

Check failure on line 8 in api/internal/controller/apicategory_controller_test.go

View workflow job for this annotation

GitHub Actions / Api / Static Checks for api

File is not properly formatted (gci)
. "github.com/onsi/gomega"
"sigs.k8s.io/controller-runtime/pkg/client"

Expand Down Expand Up @@ -34,10 +34,6 @@
Names: []string{"test-team"},
},
MustHaveGroupPrefix: true,
Linting: &apiv1.LintingConfig{
Enabled: false,
Ruleset: "owasp",
},
},
}
}
Expand Down
2 changes: 1 addition & 1 deletion rover-server/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
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),
Expand All @@ -64,7 +64,7 @@
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
err := app.Listen(cfg.Address)
if err != nil && err != http.ErrServerClosed {

Check failure on line 67 in rover-server/cmd/main.go

View workflow job for this annotation

GitHub Actions / Rover-Server / Static Checks for rover-server

comparing with != will fail on wrapped errors. Use errors.Is to check for a specific error (errorlint)
log.Log.Error(err, "Failed to start server")
}
}()
Expand Down
1 change: 1 addition & 0 deletions rover-server/config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ rules:
- apiGroups:
- api.cp.ei.telekom.de
resources:
- apicategories
- apiexposures
- apis
- apisubscriptions
Expand Down
17 changes: 17 additions & 0 deletions rover-server/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package config

import (
"strings"
"time"

"github.com/pkg/errors"
"github.com/spf13/viper"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Comment thread
stefan-ctrl marked this conversation as resolved.
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
Expand Down
154 changes: 154 additions & 0 deletions rover-server/internal/controller/apilinter.go
Original file line number Diff line number Diff line change
@@ -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"

Check failure on line 12 in rover-server/internal/controller/apilinter.go

View workflow job for this annotation

GitHub Actions / Rover-Server / Static Checks for rover-server

File is not properly formatted (gci)
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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Normally you would pass []byte via io.Reader interfaces. It a bit nicer

}

// apiLinterImpl is the production implementation of ApiLinter.
type apiLinterImpl struct {
errorMessage string
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On first look this confused me a bit, but this basically is the errorMessage returned if the lint fails? Maybe name it errorMessageTemplate or add a comment

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
}
Loading
Loading