From baef16c7af7dc27dffbc37bc37339fa5a99d11b0 Mon Sep 17 00:00:00 2001 From: Paramoshka Date: Tue, 5 May 2026 12:10:01 +0300 Subject: [PATCH 1/7] [chore] Implemented gateway requirements Signed-off-by: Paramoshka --- internal/module/openapi.go | 26 ++++- pkg/config.go | 2 + pkg/linters/templates/rules/httproute.go | 143 +++++++++++++++++++++++ pkg/linters/templates/templates.go | 5 +- 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 pkg/linters/templates/rules/httproute.go diff --git a/internal/module/openapi.go b/internal/module/openapi.go index f227e81a..5734e567 100644 --- a/internal/module/openapi.go +++ b/internal/module/openapi.go @@ -61,7 +61,10 @@ func applyDigests(moduleName string, digests, helmValues map[string]any) { func helmFormatModuleImages(m *Module, rawValues map[string]any) (chartutil.Values, error) { caps := chartutil.DefaultCapabilities vers := []string(caps.APIVersions) - vers = append(vers, "autoscaling.k8s.io/v1/VerticalPodAutoscaler") + vers = appendIfMissing(vers, "autoscaling.k8s.io/v1/VerticalPodAutoscaler") + vers = appendIfMissing(vers, "gateway.networking.k8s.io/v1/Gateway") + vers = appendIfMissing(vers, "gateway.networking.k8s.io/v1/HTTPRoute") + vers = appendIfMissing(vers, "gateway.networking.k8s.io/v1/ListenerSet") caps.APIVersions = vers digests := map[string]any{ @@ -83,6 +86,17 @@ func helmFormatModuleImages(m *Module, rawValues map[string]any) (chartutil.Valu } applyDigests(m.GetName(), digests, rawValues) + _ = mergo.Merge(&rawValues, map[string]any{ + "global": map[string]any{ + "discovery": map[string]any{ + "gatewayAPIDefaultGateway": map[string]any{ + "name": "default", + "namespace": "d8-alb", + }, + }, + }, + }, mergo.WithOverride) + top := map[string]any{ "Chart": m.GetMetadata(), "Capabilities": caps, @@ -100,6 +114,16 @@ func helmFormatModuleImages(m *Module, rawValues map[string]any) (chartutil.Valu return top, nil } +func appendIfMissing(values []string, value string) []string { + for _, item := range values { + if item == value { + return values + } + } + + return append(values, value) +} + func ComposeValuesFromSchemas(m *Module, globalSchema *spec.Schema) (chartutil.Values, error) { if globalSchema == nil { globalSchema = &spec.Schema{} diff --git a/pkg/config.go b/pkg/config.go index 476f1a87..c4eead38 100644 --- a/pkg/config.go +++ b/pkg/config.go @@ -138,6 +138,7 @@ type TemplatesLinterRules struct { ServicePortRule RuleConfig ClusterDomainRule RuleConfig RegistryRule RuleConfig + HTTPRouteRule RuleConfig } type PrometheusRuleSettings struct { @@ -153,6 +154,7 @@ type TemplatesExcludeRules struct { ServicePort ServicePortExcludeList KubeRBACProxy StringRuleExcludeList Ingress KindRuleExcludeList + HTTPRoute KindRuleExcludeList } type ServicePortExcludeList []ServicePortExclude diff --git a/pkg/linters/templates/rules/httproute.go b/pkg/linters/templates/rules/httproute.go new file mode 100644 index 00000000..63c29918 --- /dev/null +++ b/pkg/linters/templates/rules/httproute.go @@ -0,0 +1,143 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/deckhouse/dmt/internal/storage" + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" +) + +const ( + HTTPRouteRuleName = "httproute-rules" + IngressKind = "Ingress" + HTTPRouteKind = "HTTPRoute" + ListenerSetKind = "ListenerSet" + AppLabelKey = "app" +) + +type HTTPRouteRule struct { + pkg.RuleMeta + pkg.KindRule +} + +func NewHTTPRouteRule(excludeRules []pkg.KindRuleExclude) *HTTPRouteRule { + return &HTTPRouteRule{ + RuleMeta: pkg.RuleMeta{ + Name: HTTPRouteRuleName, + }, + KindRule: pkg.KindRule{ + ExcludeRules: excludeRules, + }, + } +} + +func (r *HTTPRouteRule) ModuleMustHaveGatewayResources(md pkg.Module, errorList *errors.LintRuleErrorsList) { + errorList = errorList.WithRule(r.GetName()) + + httpRoutes := collectStoreObjectsByKind(md, HTTPRouteKind) + listenerSets := collectStoreObjectsByKind(md, ListenerSetKind) + + for _, object := range md.GetStorage() { + if object.Unstructured.GetKind() != IngressKind { + continue + } + + name := object.Unstructured.GetName() + if !r.Enabled(IngressKind, name) { + continue + } + errorListObj := errorList.WithObjectID(object.Identity()).WithFilePath(object.GetPath()) + + route, ok := findHTTPRouteByLabels(object, httpRoutes) + if !ok { + errorListObj.Errorf("Ingress %q requires a matching HTTPRoute with the same app label, but none was found", name) + continue + } + + if err := validateHTTPRouteParentRefs(route, listenerSets); err != nil { + errorList.WithObjectID(route.Identity()). + WithFilePath(route.GetPath()). + Errorf("HTTPRoute %q is invalid for Ingress migration: %v", route.Unstructured.GetName(), err) + } + } +} + +func collectStoreObjectsByKind(md pkg.Module, kind string) []storage.StoreObject { + var objects []storage.StoreObject + + for _, object := range md.GetStorage() { + if object.Unstructured.GetKind() == kind { + objects = append(objects, object) + } + } + + return objects +} + +func findHTTPRouteByLabels(ingress storage.StoreObject, routes []storage.StoreObject) (storage.StoreObject, bool) { + ingressAppLabel := ingress.Unstructured.GetLabels()[AppLabelKey] + if ingressAppLabel == "" { + return storage.StoreObject{}, false + } + + for _, route := range routes { + if route.Unstructured.GetLabels()[AppLabelKey] == ingressAppLabel { + return route, true + } + } + + return storage.StoreObject{}, false +} + +func validateHTTPRouteParentRefs( + route storage.StoreObject, + listenerSets []storage.StoreObject, +) error { + parentRefs, found, err := unstructured.NestedSlice(route.Unstructured.Object, "spec", "parentRefs") + if err != nil { + return fmt.Errorf("cannot read spec.parentRefs: %w", err) + } + + if !found || len(parentRefs) == 0 { + return fmt.Errorf("spec.parentRefs must reference an existing ListenerSet") + } + + for _, parent := range parentRefs { + parentMap, ok := parent.(map[string]any) + if !ok { + continue + } + + name, ok := parentMap["name"].(string) + if !ok || name == "" { + continue + } + + for _, listenerSet := range listenerSets { + if listenerSet.Unstructured.GetName() == name { + return nil + } + } + } + + return fmt.Errorf("spec.parentRefs does not reference any ListenerSet found in module templates") +} diff --git a/pkg/linters/templates/templates.go b/pkg/linters/templates/templates.go index 2e86bf6a..705eeca4 100644 --- a/pkg/linters/templates/templates.go +++ b/pkg/linters/templates/templates.go @@ -59,7 +59,8 @@ func (l *Templates) Run(m *module.Module) { rules.NewPDBRule(l.cfg.ExcludeRules.PDBAbsent.Get()).ControllerMustHavePDB(m, errorList.WithMaxLevel(l.cfg.Rules.PDBRule.GetLevel())) // Ingress ingressRule := rules.NewIngressRule(l.cfg.ExcludeRules.Ingress.Get()) - + // HttpRoute + httpRouteRule := rules.NewHTTPRouteRule(l.cfg.ExcludeRules.HTTPRoute.Get()) // monitoring prometheusRule := rules.NewPrometheusRule(l.cfg) grafanaRule := rules.NewGrafanaRule(l.cfg) @@ -82,6 +83,8 @@ func (l *Templates) Run(m *module.Module) { ingressRule.CheckSnippetsRule(object, errorList.WithMaxLevel(l.cfg.Rules.IngressRule.GetLevel())) } + httpRouteRule.ModuleMustHaveGatewayResources(m, errorList.WithMaxLevel(l.cfg.Rules.HTTPRouteRule.GetLevel())) + // Cluster domain rule clusterDomainRule := rules.NewClusterDomainRule() clusterDomainRule.ValidateClusterDomainInTemplates(m, errorList.WithMaxLevel(l.cfg.Rules.ClusterDomainRule.GetLevel())) From 90b25a4d36486aa394082bdcd12d7b3512dd3af2 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Thu, 25 Jun 2026 15:39:40 +0300 Subject: [PATCH 2/7] lint Signed-off-by: Pavel Okhlopkov --- pkg/linters/templates/rules/httproute.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/linters/templates/rules/httproute.go b/pkg/linters/templates/rules/httproute.go index 63c29918..3d80ea48 100644 --- a/pkg/linters/templates/rules/httproute.go +++ b/pkg/linters/templates/rules/httproute.go @@ -65,6 +65,7 @@ func (r *HTTPRouteRule) ModuleMustHaveGatewayResources(md pkg.Module, errorList if !r.Enabled(IngressKind, name) { continue } + errorListObj := errorList.WithObjectID(object.Identity()).WithFilePath(object.GetPath()) route, ok := findHTTPRouteByLabels(object, httpRoutes) From 72efc1a0a7fd8ca181630ebbc4cfbf93bc5fdb8b Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Thu, 25 Jun 2026 16:01:53 +0300 Subject: [PATCH 3/7] add e2e Signed-off-by: Pavel Okhlopkov --- .../httproute-ingress-missing/expected.yaml | 9 +++++++++ .../module/module.yaml | 2 ++ .../module/openapi/config-values.yaml | 2 ++ .../module/openapi/values.yaml | 4 ++++ .../module/templates/ingress.yaml | 19 +++++++++++++++++++ .../httproute-no-listenerset/expected.yaml | 10 ++++++++++ .../module/module.yaml | 2 ++ .../module/openapi/config-values.yaml | 2 ++ .../module/openapi/values.yaml | 4 ++++ .../module/templates/httproute.yaml | 18 ++++++++++++++++++ .../module/templates/ingress.yaml | 19 +++++++++++++++++++ .../templates/httproute-valid/expected.yaml | 8 ++++++++ .../httproute-valid/module/module.yaml | 2 ++ .../module/openapi/config-values.yaml | 2 ++ .../module/openapi/values.yaml | 4 ++++ .../module/templates/httproute.yaml | 18 ++++++++++++++++++ .../module/templates/ingress.yaml | 19 +++++++++++++++++++ .../module/templates/listenerset.yaml | 13 +++++++++++++ 18 files changed, 157 insertions(+) create mode 100644 test/e2e/testdata/templates/httproute-ingress-missing/expected.yaml create mode 100644 test/e2e/testdata/templates/httproute-ingress-missing/module/module.yaml create mode 100644 test/e2e/testdata/templates/httproute-ingress-missing/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/templates/httproute-ingress-missing/module/openapi/values.yaml create mode 100644 test/e2e/testdata/templates/httproute-ingress-missing/module/templates/ingress.yaml create mode 100644 test/e2e/testdata/templates/httproute-no-listenerset/expected.yaml create mode 100644 test/e2e/testdata/templates/httproute-no-listenerset/module/module.yaml create mode 100644 test/e2e/testdata/templates/httproute-no-listenerset/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/templates/httproute-no-listenerset/module/openapi/values.yaml create mode 100644 test/e2e/testdata/templates/httproute-no-listenerset/module/templates/httproute.yaml create mode 100644 test/e2e/testdata/templates/httproute-no-listenerset/module/templates/ingress.yaml create mode 100644 test/e2e/testdata/templates/httproute-valid/expected.yaml create mode 100644 test/e2e/testdata/templates/httproute-valid/module/module.yaml create mode 100644 test/e2e/testdata/templates/httproute-valid/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/templates/httproute-valid/module/openapi/values.yaml create mode 100644 test/e2e/testdata/templates/httproute-valid/module/templates/httproute.yaml create mode 100644 test/e2e/testdata/templates/httproute-valid/module/templates/ingress.yaml create mode 100644 test/e2e/testdata/templates/httproute-valid/module/templates/listenerset.yaml diff --git a/test/e2e/testdata/templates/httproute-ingress-missing/expected.yaml b/test/e2e/testdata/templates/httproute-ingress-missing/expected.yaml new file mode 100644 index 00000000..f92e4234 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-ingress-missing/expected.yaml @@ -0,0 +1,9 @@ +description: > + A module that ships an Ingress with an app label but no matching HTTPRoute + must be flagged by the httproute-rules rule. +module: module +expect: + - linter: templates + rule: httproute-rules + level: error + textContains: "requires a matching HTTPRoute with the same app label, but none was found" diff --git a/test/e2e/testdata/templates/httproute-ingress-missing/module/module.yaml b/test/e2e/testdata/templates/httproute-ingress-missing/module/module.yaml new file mode 100644 index 00000000..fc14f6e0 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-ingress-missing/module/module.yaml @@ -0,0 +1,2 @@ +name: e2e-httproute-ingress-missing +namespace: e2e-httproute-ingress-missing diff --git a/test/e2e/testdata/templates/httproute-ingress-missing/module/openapi/config-values.yaml b/test/e2e/testdata/templates/httproute-ingress-missing/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/templates/httproute-ingress-missing/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/templates/httproute-ingress-missing/module/openapi/values.yaml b/test/e2e/testdata/templates/httproute-ingress-missing/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-ingress-missing/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/templates/httproute-ingress-missing/module/templates/ingress.yaml b/test/e2e/testdata/templates/httproute-ingress-missing/module/templates/ingress.yaml new file mode 100644 index 00000000..fd882781 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-ingress-missing/module/templates/ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: e2e-app + namespace: e2e-httproute-ingress-missing + labels: + app: e2e-app +spec: + rules: + - host: e2e.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: e2e-svc + port: + number: 80 diff --git a/test/e2e/testdata/templates/httproute-no-listenerset/expected.yaml b/test/e2e/testdata/templates/httproute-no-listenerset/expected.yaml new file mode 100644 index 00000000..715c73c3 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-no-listenerset/expected.yaml @@ -0,0 +1,10 @@ +description: > + A module that ships an Ingress and a matching HTTPRoute (same app label), + but whose HTTPRoute parentRefs do not reference any ListenerSet present in + the module templates, must be flagged by the httproute-rules rule. +module: module +expect: + - linter: templates + rule: httproute-rules + level: error + textContains: "is invalid for Ingress migration" diff --git a/test/e2e/testdata/templates/httproute-no-listenerset/module/module.yaml b/test/e2e/testdata/templates/httproute-no-listenerset/module/module.yaml new file mode 100644 index 00000000..b622dc99 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-no-listenerset/module/module.yaml @@ -0,0 +1,2 @@ +name: e2e-httproute-no-listenerset +namespace: e2e-httproute-no-listenerset diff --git a/test/e2e/testdata/templates/httproute-no-listenerset/module/openapi/config-values.yaml b/test/e2e/testdata/templates/httproute-no-listenerset/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/templates/httproute-no-listenerset/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/templates/httproute-no-listenerset/module/openapi/values.yaml b/test/e2e/testdata/templates/httproute-no-listenerset/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-no-listenerset/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/templates/httproute-no-listenerset/module/templates/httproute.yaml b/test/e2e/testdata/templates/httproute-no-listenerset/module/templates/httproute.yaml new file mode 100644 index 00000000..92d3c222 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-no-listenerset/module/templates/httproute.yaml @@ -0,0 +1,18 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: e2e-app + namespace: e2e-httproute-no-listenerset + labels: + app: e2e-app +spec: + parentRefs: + - name: nonexistent-listener-set + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: e2e-svc + port: 80 diff --git a/test/e2e/testdata/templates/httproute-no-listenerset/module/templates/ingress.yaml b/test/e2e/testdata/templates/httproute-no-listenerset/module/templates/ingress.yaml new file mode 100644 index 00000000..c9ba97fc --- /dev/null +++ b/test/e2e/testdata/templates/httproute-no-listenerset/module/templates/ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: e2e-app + namespace: e2e-httproute-no-listenerset + labels: + app: e2e-app +spec: + rules: + - host: e2e.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: e2e-svc + port: + number: 80 diff --git a/test/e2e/testdata/templates/httproute-valid/expected.yaml b/test/e2e/testdata/templates/httproute-valid/expected.yaml new file mode 100644 index 00000000..d0a734f3 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-valid/expected.yaml @@ -0,0 +1,8 @@ +description: > + A module with an Ingress, a matching HTTPRoute (same app label), and a + ListenerSet referenced by the HTTPRoute parentRefs must produce no + httproute-rules findings. +module: module +expectAbsent: + - linter: templates + rule: httproute-rules diff --git a/test/e2e/testdata/templates/httproute-valid/module/module.yaml b/test/e2e/testdata/templates/httproute-valid/module/module.yaml new file mode 100644 index 00000000..ac628d1e --- /dev/null +++ b/test/e2e/testdata/templates/httproute-valid/module/module.yaml @@ -0,0 +1,2 @@ +name: e2e-httproute-valid +namespace: e2e-httproute-valid diff --git a/test/e2e/testdata/templates/httproute-valid/module/openapi/config-values.yaml b/test/e2e/testdata/templates/httproute-valid/module/openapi/config-values.yaml new file mode 100644 index 00000000..03b0d8bf --- /dev/null +++ b/test/e2e/testdata/templates/httproute-valid/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/templates/httproute-valid/module/openapi/values.yaml b/test/e2e/testdata/templates/httproute-valid/module/openapi/values.yaml new file mode 100644 index 00000000..47180da5 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-valid/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/templates/httproute-valid/module/templates/httproute.yaml b/test/e2e/testdata/templates/httproute-valid/module/templates/httproute.yaml new file mode 100644 index 00000000..f6de80c6 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-valid/module/templates/httproute.yaml @@ -0,0 +1,18 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: e2e-app + namespace: e2e-httproute-valid + labels: + app: e2e-app +spec: + parentRefs: + - name: e2e-listener-set + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: e2e-svc + port: 80 diff --git a/test/e2e/testdata/templates/httproute-valid/module/templates/ingress.yaml b/test/e2e/testdata/templates/httproute-valid/module/templates/ingress.yaml new file mode 100644 index 00000000..2a9b6f6b --- /dev/null +++ b/test/e2e/testdata/templates/httproute-valid/module/templates/ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: e2e-app + namespace: e2e-httproute-valid + labels: + app: e2e-app +spec: + rules: + - host: e2e.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: e2e-svc + port: + number: 80 diff --git a/test/e2e/testdata/templates/httproute-valid/module/templates/listenerset.yaml b/test/e2e/testdata/templates/httproute-valid/module/templates/listenerset.yaml new file mode 100644 index 00000000..b8ef5e86 --- /dev/null +++ b/test/e2e/testdata/templates/httproute-valid/module/templates/listenerset.yaml @@ -0,0 +1,13 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: ListenerSet +metadata: + name: e2e-listener-set + namespace: e2e-httproute-valid +spec: + parentRef: + name: default + namespace: d8-alb + listeners: + - name: http + protocol: HTTP + port: 80 From ce84bd2dc5a0acd6240c72df94c93197bcc3fad9 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Thu, 25 Jun 2026 16:07:32 +0300 Subject: [PATCH 4/7] bump docs Signed-off-by: Pavel Okhlopkov --- pkg/linters/templates/README.md | 331 +++++++++++++++++++++++++++++++- 1 file changed, 330 insertions(+), 1 deletion(-) diff --git a/pkg/linters/templates/README.md b/pkg/linters/templates/README.md index 2d804420..62b0aabe 100644 --- a/pkg/linters/templates/README.md +++ b/pkg/linters/templates/README.md @@ -15,6 +15,7 @@ Proper template validation prevents runtime issues, ensures applications are pro | [kube-rbac-proxy](#kube-rbac-proxy) | Validates kube-rbac-proxy CA certificates in namespaces | ✅ | enabled | | [service-port](#service-port) | Validates services use named target ports | ✅ | enabled | | [ingress-rules](#ingress-rules) | Validates Ingress configuration snippets | ✅ | enabled | +| [httproute-rules](#httproute-rules) | Validates that every Ingress has a companion HTTPRoute backed by a ListenerSet | ✅ | enabled | | [prometheus-rules](#prometheus-rules) | Validates Prometheus rules with promtool and proper templates | ✅ | enabled | | [grafana-dashboards](#grafana-dashboards) | Validates Grafana dashboard templates | ✅ | enabled | | [cluster-domain](#cluster-domain) | Validates cluster domain configuration is dynamic | ❌ | enabled | @@ -1009,6 +1010,213 @@ linters-settings: --- +### httproute-rules + +**Purpose:** Ensures that every module using Ingress-Nginx also ships a Gateway API equivalent — an `HTTPRoute` with a matching `app` label and a `ListenerSet` referenced by that route's `parentRefs`. This is the linter enforcement of DKP's transition to Gateway API. + +**Background:** + +DKP is moving from Ingress-Nginx toward the Kubernetes Gateway API. The goal is to claim full Gateway API readiness: every module that exposes HTTP traffic must work through both stacks simultaneously during the transition period. When Ingress-Nginx is eventually retired, modules that followed this rule will require no additional changes. + +The rule enforces the migration contract: + +> *If a module ships an Ingress, it must also ship a functionally equivalent HTTPRoute and a ListenerSet. An Ingress without a Gateway API counterpart is incomplete.* + +**What it checks:** + +For each `Ingress` object in the module templates: + +1. The Ingress has an `app` label. +2. An `HTTPRoute` with the same `app` label value exists in the module templates. +3. The HTTPRoute's `spec.parentRefs` references at least one `ListenerSet` that is also present in the module templates. + +**Why it matters:** + +- Modules that expose services only through Ingress-Nginx will stop working the moment that controller is removed from a cluster. +- Shipping both resources in parallel keeps the module functional on Ingress-Nginx clusters today and on Gateway-API-only clusters tomorrow — with zero additional effort at migration time. +- The ListenerSet requirement guarantees the HTTPRoute is actually connected to a gateway and not left dangling. + +**Examples:** + +❌ **Incorrect** — Ingress without a matching HTTPRoute: + +```yaml +# templates/ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: dashboard + namespace: d8-my-module + labels: + app: dashboard # ← app label is present, but no HTTPRoute exists +spec: + rules: + - host: dashboard.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: dashboard + port: + number: 80 +``` + +**Error:** +``` +Error: Ingress "dashboard" requires a matching HTTPRoute with the same app label, but none was found +``` + +❌ **Incorrect** — HTTPRoute exists but its `parentRefs` do not reference any `ListenerSet` in the module: + +```yaml +# templates/ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: dashboard + namespace: d8-my-module + labels: + app: dashboard +spec: + rules: + - host: dashboard.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: dashboard + port: + number: 80 +--- +# templates/httproute.yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: dashboard + namespace: d8-my-module + labels: + app: dashboard +spec: + parentRefs: + - name: some-external-gateway # ❌ Not a ListenerSet defined in this module + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: dashboard + port: 80 +``` + +**Error:** +``` +Error: HTTPRoute "dashboard" is invalid for Ingress migration: + spec.parentRefs does not reference any ListenerSet found in module templates +``` + +❌ **Incorrect** — HTTPRoute has empty `parentRefs`: + +```yaml +# templates/httproute.yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: dashboard + labels: + app: dashboard +spec: + parentRefs: [] # ❌ Empty + rules: + - ... +``` + +**Error:** +``` +Error: HTTPRoute "dashboard" is invalid for Ingress migration: + spec.parentRefs must reference an existing ListenerSet +``` + +✅ **Correct** — Ingress, HTTPRoute and ListenerSet all present and properly connected: + +```yaml +# templates/ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: dashboard + namespace: d8-my-module + labels: + app: dashboard +spec: + rules: + - host: dashboard.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: dashboard + port: + number: 80 +--- +# templates/httproute.yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: dashboard + namespace: d8-my-module + labels: + app: dashboard # ✅ Matches Ingress app label +spec: + parentRefs: + - name: d8-alb-listener # ✅ References a ListenerSet defined below + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: dashboard + port: 80 +--- +# templates/listenerset.yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: ListenerSet +metadata: + name: d8-alb-listener # ✅ Name matches HTTPRoute parentRef + namespace: d8-my-module +spec: + parentRef: + name: default + namespace: d8-alb + listeners: + - name: http + protocol: HTTP + port: 80 +``` + +**Configuration:** + +```yaml +# .dmt.yaml +linters-settings: + templates: + exclude-rules: + httproute: + - kind: Ingress + name: legacy-webhook # Ingress that cannot yet be migrated + - kind: Ingress + name: internal-only +``` + +--- + ### prometheus-rules **Purpose:** Validates Prometheus alerting and recording rules using promtool and ensures proper Helm template structure. This catches syntax errors, invalid queries, and ensures monitoring rules are properly packaged. @@ -1680,6 +1888,8 @@ linters-settings: impact: error ingress: impact: warning + httproute: + impact: error prometheus-rules: impact: info grafana-dashboards: @@ -1725,7 +1935,14 @@ linters-settings: name: dashboard - kind: Ingress name: legacy-api - + + # HTTPRoute exclusions — Ingresses that are not yet required to have a Gateway API companion + httproute: + - kind: Ingress + name: legacy-webhook + - kind: Ingress + name: internal-only + # Service port exclusions (by service name and port name) service-port: - name: d8-control-plane-apiserver @@ -2073,6 +2290,118 @@ Error: Ingress annotation "nginx.ingress.kubernetes.io/configuration-snippet" do name: dashboard ``` +### Issue: Ingress requires a matching HTTPRoute + +**Symptom:** +``` +Error: Ingress "my-app" requires a matching HTTPRoute with the same app label, but none was found +``` + +**Cause:** A module ships an Ingress but no `HTTPRoute` with the same `app` label exists in the templates. + +**Solutions:** + +1. **Add an HTTPRoute and a ListenerSet** (preferred — full Gateway API compliance): + + ```yaml + # templates/httproute.yaml + apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: my-app + namespace: d8-my-module + labels: + app: my-app # Must match the Ingress app label + spec: + parentRefs: + - name: d8-alb-listener + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: my-app + port: 80 + --- + # templates/listenerset.yaml + apiVersion: gateway.networking.k8s.io/v1 + kind: ListenerSet + metadata: + name: d8-alb-listener + namespace: d8-my-module + spec: + parentRef: + name: default + namespace: d8-alb + listeners: + - name: http + protocol: HTTP + port: 80 + ``` + +2. **Exclude the Ingress temporarily** (only when migration is genuinely blocked): + + ```yaml + # .dmt.yaml + linters-settings: + templates: + exclude-rules: + httproute: + - kind: Ingress + name: my-app + ``` + +### Issue: HTTPRoute parentRefs do not reference any ListenerSet + +**Symptom:** +``` +Error: HTTPRoute "my-app" is invalid for Ingress migration: + spec.parentRefs does not reference any ListenerSet found in module templates +``` + +**Cause:** The HTTPRoute exists and matches the Ingress `app` label, but its `spec.parentRefs` either is empty or names a gateway that is not defined as a `ListenerSet` in the module. + +**Solutions:** + +1. **Add a ListenerSet to the module and reference it**: + + ```yaml + # templates/listenerset.yaml + apiVersion: gateway.networking.k8s.io/v1 + kind: ListenerSet + metadata: + name: d8-alb-listener # ← this name must appear in parentRefs + namespace: d8-my-module + spec: + parentRef: + name: default + namespace: d8-alb + listeners: + - name: http + protocol: HTTP + port: 80 + ``` + + ```yaml + # templates/httproute.yaml (excerpt) + spec: + parentRefs: + - name: d8-alb-listener # ← matches ListenerSet above + ``` + +2. **Exclude the Ingress** if a ListenerSet cannot be provided yet: + + ```yaml + # .dmt.yaml + linters-settings: + templates: + exclude-rules: + httproute: + - kind: Ingress + name: my-app + ``` + ### Issue: Invalid Prometheus rules **Symptom:** From c49bb91dd0327aefc3d5974844098dbb336d1d54 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Thu, 25 Jun 2026 16:09:58 +0300 Subject: [PATCH 5/7] fix error messages Signed-off-by: Pavel Okhlopkov --- pkg/linters/templates/rules/httproute.go | 20 +++++++++++++++---- .../httproute-ingress-missing/expected.yaml | 2 +- .../httproute-no-listenerset/expected.yaml | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/pkg/linters/templates/rules/httproute.go b/pkg/linters/templates/rules/httproute.go index 3d80ea48..e2b6888a 100644 --- a/pkg/linters/templates/rules/httproute.go +++ b/pkg/linters/templates/rules/httproute.go @@ -32,6 +32,10 @@ const ( HTTPRouteKind = "HTTPRoute" ListenerSetKind = "ListenerSet" AppLabelKey = "app" + + // networkTeamHint is appended to every finding so module authors know who + // can help with the Ingress -> Gateway API migration and review the change. + networkTeamHint = "If you need help with the Gateway API migration or a review, reach out to the network team in #dev-network." ) type HTTPRouteRule struct { @@ -70,14 +74,22 @@ func (r *HTTPRouteRule) ModuleMustHaveGatewayResources(md pkg.Module, errorList route, ok := findHTTPRouteByLabels(object, httpRoutes) if !ok { - errorListObj.Errorf("Ingress %q requires a matching HTTPRoute with the same app label, but none was found", name) + errorListObj.Errorf( + "Ingress %q ships in this module, but the corresponding Gateway API resources are missing: "+ + "no HTTPRoute with a matching %q=%q label was found. "+ + "Every Ingress must be accompanied by a Gateway API HTTPRoute (referencing a ListenerSet) so the module is ready for the Ingress -> Gateway API migration. %s", + name, AppLabelKey, object.Unstructured.GetLabels()[AppLabelKey], networkTeamHint, + ) continue } if err := validateHTTPRouteParentRefs(route, listenerSets); err != nil { errorList.WithObjectID(route.Identity()). WithFilePath(route.GetPath()). - Errorf("HTTPRoute %q is invalid for Ingress migration: %v", route.Unstructured.GetName(), err) + Errorf( + "HTTPRoute %q does not yet provide a valid Gateway API replacement for Ingress %q: %v. %s", + route.Unstructured.GetName(), name, err, networkTeamHint, + ) } } } @@ -119,7 +131,7 @@ func validateHTTPRouteParentRefs( } if !found || len(parentRefs) == 0 { - return fmt.Errorf("spec.parentRefs must reference an existing ListenerSet") + return fmt.Errorf("spec.parentRefs is empty, so it does not reference a ListenerSet defined in the module templates") } for _, parent := range parentRefs { @@ -140,5 +152,5 @@ func validateHTTPRouteParentRefs( } } - return fmt.Errorf("spec.parentRefs does not reference any ListenerSet found in module templates") + return fmt.Errorf("none of the spec.parentRefs reference a ListenerSet defined in the module templates") } diff --git a/test/e2e/testdata/templates/httproute-ingress-missing/expected.yaml b/test/e2e/testdata/templates/httproute-ingress-missing/expected.yaml index f92e4234..011717d5 100644 --- a/test/e2e/testdata/templates/httproute-ingress-missing/expected.yaml +++ b/test/e2e/testdata/templates/httproute-ingress-missing/expected.yaml @@ -6,4 +6,4 @@ expect: - linter: templates rule: httproute-rules level: error - textContains: "requires a matching HTTPRoute with the same app label, but none was found" + textContains: "the corresponding Gateway API resources are missing" diff --git a/test/e2e/testdata/templates/httproute-no-listenerset/expected.yaml b/test/e2e/testdata/templates/httproute-no-listenerset/expected.yaml index 715c73c3..1192d7fd 100644 --- a/test/e2e/testdata/templates/httproute-no-listenerset/expected.yaml +++ b/test/e2e/testdata/templates/httproute-no-listenerset/expected.yaml @@ -7,4 +7,4 @@ expect: - linter: templates rule: httproute-rules level: error - textContains: "is invalid for Ingress migration" + textContains: "does not yet provide a valid Gateway API replacement for Ingress" From 5b48541dd6da6c80e47971945ca9259fd3384b4c Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Thu, 25 Jun 2026 16:12:03 +0300 Subject: [PATCH 6/7] lint Signed-off-by: Pavel Okhlopkov --- pkg/linters/templates/rules/httproute.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/linters/templates/rules/httproute.go b/pkg/linters/templates/rules/httproute.go index e2b6888a..a50d8585 100644 --- a/pkg/linters/templates/rules/httproute.go +++ b/pkg/linters/templates/rules/httproute.go @@ -80,6 +80,7 @@ func (r *HTTPRouteRule) ModuleMustHaveGatewayResources(md pkg.Module, errorList "Every Ingress must be accompanied by a Gateway API HTTPRoute (referencing a ListenerSet) so the module is ready for the Ingress -> Gateway API migration. %s", name, AppLabelKey, object.Unstructured.GetLabels()[AppLabelKey], networkTeamHint, ) + continue } From 3593272fe939e6300f1a6bd6c83ffc9eb0dcdc5d Mon Sep 17 00:00:00 2001 From: Mikhail Scherba Date: Mon, 29 Jun 2026 15:04:09 +0300 Subject: [PATCH 7/7] use single rule for httproute and ingress Signed-off-by: Mikhail Scherba --- pkg/linters/hooks/hooks.go | 2 +- pkg/linters/hooks/rules/rules.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/linters/hooks/hooks.go b/pkg/linters/hooks/hooks.go index 8a2bccff..1817a201 100644 --- a/pkg/linters/hooks/hooks.go +++ b/pkg/linters/hooks/hooks.go @@ -50,7 +50,7 @@ func (h *Hooks) Run(m *module.Module) { r := rules.NewHookRule(h.cfg) for _, object := range m.GetStorage() { - r.CheckIngressCopyCustomCertificateRule(m, object, errorList) + r.CheckCopyCustomCertificateRule(m, object, errorList) } } diff --git a/pkg/linters/hooks/rules/rules.go b/pkg/linters/hooks/rules/rules.go index 4f75e2cc..f0f5b390 100644 --- a/pkg/linters/hooks/rules/rules.go +++ b/pkg/linters/hooks/rules/rules.go @@ -48,7 +48,7 @@ func NewHookRule(cfg *pkg.HooksLinterConfig) *HookRule { } } -func (l *HookRule) CheckIngressCopyCustomCertificateRule(m *module.Module, object storage.StoreObject, errorList *errors.LintRuleErrorsList) { +func (l *HookRule) CheckCopyCustomCertificateRule(m *module.Module, object storage.StoreObject, errorList *errors.LintRuleErrorsList) { errorList = errorList.WithRule(l.GetName()).WithFilePath(object.GetPath()) const ( @@ -59,7 +59,7 @@ func (l *HookRule) CheckIngressCopyCustomCertificateRule(m *module.Module, objec errorList = errorList.WithMaxLevel(ptr.To(pkg.Ignored)) } - if object.Unstructured.GetKind() != "Ingress" { + if object.Unstructured.GetKind() != "Ingress" && object.Unstructured.GetKind() != "HTTPRoute" { return } @@ -82,7 +82,7 @@ func (l *HookRule) CheckIngressCopyCustomCertificateRule(m *module.Module, objec } if _, ok := imports[copyCustomCertificateImport]; !ok { - errorList.Error("Ingress resource exists but module does not have copy_custom_certificate hook") + errorList.Errorf("%s resource exists but module does not have copy_custom_certificate hook", object.Unstructured.GetKind()) } }