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()))