From 2a84bc0c52cc26fb7c3c6144fd65541f98915b85 Mon Sep 17 00:00:00 2001 From: Mikhail Scherba Date: Fri, 17 Jul 2026 10:02:32 +0300 Subject: [PATCH 1/2] add gateway api certificate issuer linter Signed-off-by: Mikhail Scherba --- internal/module/module.go | 2 + pkg/config.go | 46 ++-- pkg/config/global/global.go | 27 +-- pkg/config/linters_settings.go | 46 ++-- pkg/linters/templates/README.md | 86 +++++++ .../rules/certificate_gateway_issuer.go | 138 ++++++++++++ .../rules/certificate_gateway_issuer_test.go | 209 ++++++++++++++++++ pkg/linters/templates/templates.go | 4 + test/e2e/README.md | 3 + .../expected.yaml | 7 + .../module/.dmtlint.yaml | 6 + .../module/module.yaml | 2 + .../module/openapi/config-values.yaml | 2 + .../module/openapi/values.yaml | 4 + .../module/templates/certificate.yaml | 13 ++ .../expected.yaml | 9 + .../module/module.yaml | 2 + .../module/openapi/config-values.yaml | 2 + .../module/openapi/values.yaml | 4 + .../module/templates/certificate.yaml | 13 ++ .../expected.yaml | 7 + .../module/module.yaml | 2 + .../module/openapi/config-values.yaml | 2 + .../module/openapi/values.yaml | 4 + .../module/templates/_helpers.tpl | 3 + .../module/templates/certificate.yaml | 12 + 26 files changed, 598 insertions(+), 57 deletions(-) create mode 100644 pkg/linters/templates/rules/certificate_gateway_issuer.go create mode 100644 pkg/linters/templates/rules/certificate_gateway_issuer_test.go create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-disabled/expected.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/.dmtlint.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/module.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/openapi/values.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/templates/certificate.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/expected.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/module.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/openapi/values.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/templates/certificate.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-valid/expected.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/module.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/openapi/config-values.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/openapi/values.yaml create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/templates/_helpers.tpl create mode 100644 test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/templates/certificate.yaml diff --git a/internal/module/module.go b/internal/module/module.go index 4ec79441f..ea9549018 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -348,6 +348,7 @@ func mapTemplatesRules(linterSettings *pkg.LintersSettings, configSettings *conf rules.EnabledModulesRule.SetLevel(globalRules.EnabledModulesRule.Impact, fallbackImpact) rules.MountPointsRule.SetLevel(globalRules.MountPointsRule.Impact, fallbackImpact) rules.WebhookConfigurationRule.SetLevel(globalRules.WebhookConfigurationRule.Impact, fallbackImpact) + rules.CertificateGatewayIssuerRule.SetLevel(globalRules.CertificateGatewayIssuerRule.Impact, fallbackImpact) } // mapOpenAPIRules configures OpenAPI linter rules @@ -463,6 +464,7 @@ func mapTemplatesExclusionsAndSettings(linterSettings *pkg.LintersSettings, conf excludes.KubeRBACProxy = pkg.StringRuleExcludeList(configExcludes.KubeRBACProxy) excludes.Ingress = configExcludes.Ingress.Get() excludes.HTTPRoute = configExcludes.HTTPRoute.Get() + excludes.CertificateGatewayIssuer = configExcludes.CertificateGatewayIssuer.Get() excludes.EnabledModules.Files = pkg.StringRuleExcludeList(configExcludes.EnabledModules.Files) excludes.EnabledModules.Directories = pkg.DirectoryRuleExcludeList(configExcludes.EnabledModules.Directories) excludes.WebhookConfiguration = configExcludes.WebhookConfiguration.Get() diff --git a/pkg/config.go b/pkg/config.go index df87f1f6a..6d2016172 100644 --- a/pkg/config.go +++ b/pkg/config.go @@ -131,19 +131,20 @@ type TemplatesLinterConfig struct { GrafanaDashboardsSettings GrafanaDashboardsSettings } type TemplatesLinterRules struct { - VPARule RuleConfig - PDBRule RuleConfig - IngressRule RuleConfig - PrometheusRule RuleConfig - GrafanaRule RuleConfig - KubeRBACProxyRule RuleConfig - ServicePortRule RuleConfig - ClusterDomainRule RuleConfig - RegistryRule RuleConfig - HTTPRouteRule RuleConfig - EnabledModulesRule RuleConfig - WebhookConfigurationRule RuleConfig - MountPointsRule RuleConfig + VPARule RuleConfig + PDBRule RuleConfig + IngressRule RuleConfig + PrometheusRule RuleConfig + GrafanaRule RuleConfig + KubeRBACProxyRule RuleConfig + ServicePortRule RuleConfig + ClusterDomainRule RuleConfig + RegistryRule RuleConfig + HTTPRouteRule RuleConfig + EnabledModulesRule RuleConfig + WebhookConfigurationRule RuleConfig + MountPointsRule RuleConfig + CertificateGatewayIssuerRule RuleConfig } type PrometheusRuleSettings struct { @@ -154,15 +155,16 @@ type GrafanaDashboardsSettings struct { Disable bool } type TemplatesExcludeRules struct { - VPAAbsent KindRuleExcludeList - PDBAbsent KindRuleExcludeList - ServicePort ServicePortExcludeList - KubeRBACProxy StringRuleExcludeList - Ingress KindRuleExcludeList - HTTPRoute KindRuleExcludeList - EnabledModules EnabledModulesExcludeRule - WebhookConfiguration KindRuleExcludeList - MountPoints StringRuleExcludeList + VPAAbsent KindRuleExcludeList + PDBAbsent KindRuleExcludeList + ServicePort ServicePortExcludeList + KubeRBACProxy StringRuleExcludeList + Ingress KindRuleExcludeList + HTTPRoute KindRuleExcludeList + CertificateGatewayIssuer KindRuleExcludeList + EnabledModules EnabledModulesExcludeRule + WebhookConfiguration KindRuleExcludeList + MountPoints StringRuleExcludeList } type EnabledModulesExcludeRule struct { diff --git a/pkg/config/global/global.go b/pkg/config/global/global.go index 266a865d1..96a44a5bb 100644 --- a/pkg/config/global/global.go +++ b/pkg/config/global/global.go @@ -130,19 +130,20 @@ type TemplatesLinterConfig struct { } type TemplatesLinterRules struct { - VPARule RuleConfig `mapstructure:"vpa"` - PDBRule RuleConfig `mapstructure:"pdb"` - IngressRule RuleConfig `mapstructure:"ingress"` - HTTPRouteRule RuleConfig `mapstructure:"httproute"` - PrometheusRule RuleConfig `mapstructure:"prometheus-rules"` - GrafanaRule RuleConfig `mapstructure:"grafana-dashboards"` - KubeRBACProxyRule RuleConfig `mapstructure:"kube-rbac-proxy"` - ServicePortRule RuleConfig `mapstructure:"service-port"` - ClusterDomainRule RuleConfig `mapstructure:"cluster-domain"` - RegistryRule RuleConfig `mapstructure:"registry"` - EnabledModulesRule RuleConfig `mapstructure:"enabled-modules"` - WebhookConfigurationRule RuleConfig `mapstructure:"webhook-configuration-annotations"` - MountPointsRule RuleConfig `mapstructure:"mount-points"` + VPARule RuleConfig `mapstructure:"vpa"` + PDBRule RuleConfig `mapstructure:"pdb"` + IngressRule RuleConfig `mapstructure:"ingress"` + HTTPRouteRule RuleConfig `mapstructure:"httproute"` + PrometheusRule RuleConfig `mapstructure:"prometheus-rules"` + GrafanaRule RuleConfig `mapstructure:"grafana-dashboards"` + KubeRBACProxyRule RuleConfig `mapstructure:"kube-rbac-proxy"` + ServicePortRule RuleConfig `mapstructure:"service-port"` + ClusterDomainRule RuleConfig `mapstructure:"cluster-domain"` + RegistryRule RuleConfig `mapstructure:"registry"` + EnabledModulesRule RuleConfig `mapstructure:"enabled-modules"` + WebhookConfigurationRule RuleConfig `mapstructure:"webhook-configuration-annotations"` + MountPointsRule RuleConfig `mapstructure:"mount-points"` + CertificateGatewayIssuerRule RuleConfig `mapstructure:"certificate-gateway-issuer"` } func (c LinterConfig) IsWarn() bool { diff --git a/pkg/config/linters_settings.go b/pkg/config/linters_settings.go index c4c2b17c7..f8e0f1548 100644 --- a/pkg/config/linters_settings.go +++ b/pkg/config/linters_settings.go @@ -220,31 +220,33 @@ type TemplatesSettings struct { } type TemplatesLinterRules struct { - VPARule RuleConfig `mapstructure:"vpa"` - PDBRule RuleConfig `mapstructure:"pdb"` - IngressRule RuleConfig `mapstructure:"ingress"` - HTTPRouteRule RuleConfig `mapstructure:"httproute"` - PrometheusRule RuleConfig `mapstructure:"prometheus-rules"` - GrafanaRule RuleConfig `mapstructure:"grafana-dashboards"` - KubeRBACProxyRule RuleConfig `mapstructure:"kube-rbac-proxy"` - ServicePortRule RuleConfig `mapstructure:"service-port"` - ClusterDomainRule RuleConfig `mapstructure:"cluster-domain"` - RegistryRule RuleConfig `mapstructure:"registry"` - EnabledModulesRule RuleConfig `mapstructure:"enabled-modules"` - WebhookConfigurationRule RuleConfig `mapstructure:"webhook-configuration-annotations"` - MountPointsRule RuleConfig `mapstructure:"mount-points"` + VPARule RuleConfig `mapstructure:"vpa"` + PDBRule RuleConfig `mapstructure:"pdb"` + IngressRule RuleConfig `mapstructure:"ingress"` + HTTPRouteRule RuleConfig `mapstructure:"httproute"` + PrometheusRule RuleConfig `mapstructure:"prometheus-rules"` + GrafanaRule RuleConfig `mapstructure:"grafana-dashboards"` + KubeRBACProxyRule RuleConfig `mapstructure:"kube-rbac-proxy"` + ServicePortRule RuleConfig `mapstructure:"service-port"` + ClusterDomainRule RuleConfig `mapstructure:"cluster-domain"` + RegistryRule RuleConfig `mapstructure:"registry"` + EnabledModulesRule RuleConfig `mapstructure:"enabled-modules"` + WebhookConfigurationRule RuleConfig `mapstructure:"webhook-configuration-annotations"` + MountPointsRule RuleConfig `mapstructure:"mount-points"` + CertificateGatewayIssuerRule RuleConfig `mapstructure:"certificate-gateway-issuer"` } type TemplatesExcludeRules struct { - VPAAbsent KindRuleExcludeList `mapstructure:"vpa"` - PDBAbsent KindRuleExcludeList `mapstructure:"pdb"` - ServicePort ServicePortExcludeList `mapstructure:"service-port"` - KubeRBACProxy StringRuleExcludeList `mapstructure:"kube-rbac-proxy"` - Ingress KindRuleExcludeList `mapstructure:"ingress"` - HTTPRoute KindRuleExcludeList `mapstructure:"httproute"` - EnabledModules EnabledModulesExcludeRule `mapstructure:"enabled-modules"` - WebhookConfiguration KindRuleExcludeList `mapstructure:"webhook-configuration-annotations"` - MountPoints StringRuleExcludeList `mapstructure:"mount-points"` + VPAAbsent KindRuleExcludeList `mapstructure:"vpa"` + PDBAbsent KindRuleExcludeList `mapstructure:"pdb"` + ServicePort ServicePortExcludeList `mapstructure:"service-port"` + KubeRBACProxy StringRuleExcludeList `mapstructure:"kube-rbac-proxy"` + Ingress KindRuleExcludeList `mapstructure:"ingress"` + HTTPRoute KindRuleExcludeList `mapstructure:"httproute"` + CertificateGatewayIssuer KindRuleExcludeList `mapstructure:"certificate-gateway-issuer"` + EnabledModules EnabledModulesExcludeRule `mapstructure:"enabled-modules"` + WebhookConfiguration KindRuleExcludeList `mapstructure:"webhook-configuration-annotations"` + MountPoints StringRuleExcludeList `mapstructure:"mount-points"` } type EnabledModulesExcludeRule struct { diff --git a/pkg/linters/templates/README.md b/pkg/linters/templates/README.md index 8c95d4b3a..31ff9afd2 100644 --- a/pkg/linters/templates/README.md +++ b/pkg/linters/templates/README.md @@ -24,6 +24,7 @@ Proper template validation prevents runtime issues, ensures applications are pro | [enabled-modules](#enabled-modules) | Detects usage of `.Values.global.enabledModules` in templates | ✅ | enabled | | [webhook-configuration-annotations](#webhook-configuration-annotations) | Checks webhook configurations have werf.io/weight or deploy-dependency annotations | ✅ | enabled | | [mount-points](#mount-points) | Validates that mount-points.yaml directories are used as volumeMounts in pod controllers | ✅ | enabled | +| [certificate-gateway-issuer](#certificate-gateway-issuer) | Validates Gateway API Certificates use the helm_lib ClusterIssuer helper | ✅ | enabled | "Configurable" means that this rule can be configured using the `.dmtlint.yaml` file, including customizing the rule's parameters and/or disabling the rule. @@ -2033,8 +2034,77 @@ linters-settings: **When to exclude:** Pods managed outside Helm (operators, mutating webhooks, static pods, bashible) have `volumeMounts` that are not present in Helm templates. Directories from `mount-points.yaml` for these containers will produce false positives — exclude them with the corresponding paths. ``` +--- + +### certificate-gateway-issuer + +**Purpose:** Ensures Certificate objects used with Gateway API reference the ClusterIssuer through the shared helm_lib helper instead of hardcoding `printf "letsencrypt-gateway-%s"`. + +**Description:** + +Scans Helm template files for `kind: Certificate` resources whose `spec.issuerRef.name` is set with `{{ printf "letsencrypt-gateway-%s" ... }}` and requires the recommended include helper instead. + +**What it checks:** + +1. Template files under `templates/` (`.yaml`, `.yml`, `.tpl`, `.tpl.yaml`, `.tpl.yml`) +2. Presence of `kind: Certificate` +3. `issuerRef.name` using `printf "letsencrypt-gateway-%s"` (or single-quoted equivalent) + +**Why it matters:** + +Hardcoded Gateway issuer names: +- Bypass the shared helm_lib naming convention for Gateway API ClusterIssuers +- Drift when issuer naming changes across Deckhouse releases +- Make Certificate templates harder to review and maintain + +**Examples:** + +❌ **Incorrect** - Hardcoded printf issuer name: + +```yaml +# templates/certificate.yaml +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: gateway-cert +spec: + issuerRef: + name: {{ printf "letsencrypt-gateway-%s" $moduleGateway.name }} + kind: ClusterIssuer +``` + +**Error:** +``` +Certificates related to Gateway API must refer to issuer using {{ include "helm_lib_module_https_cert_manager_cluster_issuer_name_for_gateway_api" . }} +``` + +✅ **Correct** - Use the helm_lib helper: + +```yaml +# templates/certificate.yaml +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: gateway-cert +spec: + issuerRef: + name: {{ include "helm_lib_module_https_cert_manager_cluster_issuer_name_for_gateway_api" . }} + kind: ClusterIssuer +``` + **Configuration:** +```yaml +# .dmtlint.yaml +linters-settings: + templates: + exclude-rules: + certificate-gateway-issuer: + - kind: Certificate + name: gateway-cert +``` + +--- ## Configuration @@ -2057,6 +2127,11 @@ linters-settings: prometheus-rules: disable: true + + exclude-rules: + certificate-gateway-issuer: + - kind: Certificate + name: gateway-cert ``` ### Per-Rule Impact Levels @@ -2092,6 +2167,8 @@ linters-settings: impact: warning webhook-configuration-annotations: impact: error + certificate-gateway-issuer: + impact: warning ``` ### Rule-Level Exclusions @@ -2131,6 +2208,11 @@ linters-settings: - kind: Ingress name: internal-only + # Certificate Gateway issuer exclusions (by kind and name) + certificate-gateway-issuer: + - kind: Certificate + name: legacy-gateway-cert + # Service port exclusions (by service name and port name) service-port: - name: d8-control-plane-apiserver @@ -2194,6 +2276,10 @@ linters-settings: ingress: - kind: Ingress name: internal-dashboard + + certificate-gateway-issuer: + - kind: Certificate + name: internal-dashboard-cert service-port: - name: apiserver diff --git a/pkg/linters/templates/rules/certificate_gateway_issuer.go b/pkg/linters/templates/rules/certificate_gateway_issuer.go new file mode 100644 index 000000000..5da5d30d1 --- /dev/null +++ b/pkg/linters/templates/rules/certificate_gateway_issuer.go @@ -0,0 +1,138 @@ +/* +Copyright 2025 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 ( + "os" + "regexp" + "strings" + + "github.com/deckhouse/dmt/internal/storage" + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" +) + +const ( + CertificateGatewayIssuerRuleName = "certificate-gateway-issuer" + recommendedGatewayIssuerInclude = `{{ include "helm_lib_module_https_cert_manager_cluster_issuer_name_for_gateway_api" . }}` + certificateGatewayIssuerMessage = "Certificates related to Gateway API must refer to issuer using " + + recommendedGatewayIssuerInclude +) + +var ( + kindCertificateRegexp = regexp.MustCompile(`(?m)^\s*kind:\s*"?Certificate"?\s*$`) + // Matches issuerRef.name set via printf "letsencrypt-gateway-%s" ... (single or double quotes). + forbiddenGatewayIssuerPrintfRegexp = regexp.MustCompile( + `issuerRef:\s*\n(?:[ \t]+[^\n]+\n)*?[ \t]*name:\s*\{\{[^}]*printf\s+["']letsencrypt-gateway-%s["']`, + ) +) + +type CertificateGatewayIssuerRule struct { + pkg.RuleMeta + pkg.KindRule +} + +func NewCertificateGatewayIssuerRule(excludeRules []pkg.KindRuleExclude) *CertificateGatewayIssuerRule { + return &CertificateGatewayIssuerRule{ + RuleMeta: pkg.RuleMeta{ + Name: CertificateGatewayIssuerRuleName, + }, + KindRule: pkg.KindRule{ + ExcludeRules: excludeRules, + }, + } +} + +func (r *CertificateGatewayIssuerRule) ValidateCertificateGatewayIssuer(m pkg.Module, errorList *errors.LintRuleErrorsList) { + errorList = errorList.WithRule(r.GetName()) + + for _, object := range m.GetStorage() { + if object.Unstructured.GetKind() != "Certificate" { + continue + } + + name := object.Unstructured.GetName() + if !r.Enabled("Certificate", name) { + continue + } + + objectErrorList := errorList.WithObjectID(object.Identity()).WithFilePath(object.GetPath()) + content, err := os.ReadFile(object.AbsPath) + if err != nil { + objectErrorList.Errorf("Failed to read file %s: %v", object.GetPath(), err) + continue + } + + if !containsForbiddenGatewayIssuerForObject(string(content), object) { + continue + } + + objectErrorList.Error(certificateGatewayIssuerMessage) + } +} + +func containsForbiddenGatewayIssuerForObject(content string, object storage.StoreObject) bool { + documents := splitYAMLDocuments(content) + foundNamedDocument := false + + for _, document := range documents { + if !kindCertificateRegexp.MatchString(document) { + continue + } + + if !documentContainsObjectName(document, object.Unstructured.GetName()) { + continue + } + + foundNamedDocument = true + if containsForbiddenGatewayIssuer(document) { + return true + } + } + + if foundNamedDocument { + return false + } + + return containsForbiddenGatewayIssuer(content) +} + +func containsForbiddenGatewayIssuer(content string) bool { + if !kindCertificateRegexp.MatchString(content) { + return false + } + + if forbiddenGatewayIssuerPrintfRegexp.MatchString(content) { + return true + } + + // Fallback for single-line / differently indented issuerRef.name forms. + return strings.Contains(content, `issuerRef`) && + (strings.Contains(content, `printf "letsencrypt-gateway-%s"`) || + strings.Contains(content, `printf 'letsencrypt-gateway-%s'`)) +} + +func splitYAMLDocuments(content string) []string { + return regexp.MustCompile(`(?m)^---\s*$`).Split(content, -1) +} + +func documentContainsObjectName(document, objectName string) bool { + quotedName := regexp.QuoteMeta(objectName) + nameRegexp := regexp.MustCompile(`(?m)^\s*name:\s*"?` + quotedName + `"?\s*$`) + + return nameRegexp.MatchString(document) +} diff --git a/pkg/linters/templates/rules/certificate_gateway_issuer_test.go b/pkg/linters/templates/rules/certificate_gateway_issuer_test.go new file mode 100644 index 000000000..2d460cfb9 --- /dev/null +++ b/pkg/linters/templates/rules/certificate_gateway_issuer_test.go @@ -0,0 +1,209 @@ +/* +Copyright 2025 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 ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gojuno/minimock/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/deckhouse/dmt/internal/mocks" + "github.com/deckhouse/dmt/internal/storage" + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" +) + +func TestCertificateGatewayIssuerRule_ValidateCertificateGatewayIssuer(t *testing.T) { + tests := []struct { + name string + templateFiles map[string]string + storageObjects map[storage.ResourceIndex]storage.StoreObject + excludeRules []pkg.KindRuleExclude + expectedErrors []string + }{ + { + name: "should detect forbidden printf issuer in Certificate", + templateFiles: map[string]string{ + "templates/certificate.yaml": ` +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: gateway-cert +spec: + issuerRef: + name: {{ printf "letsencrypt-gateway-%s" $moduleGateway.name }} + kind: ClusterIssuer +`, + }, + storageObjects: map[storage.ResourceIndex]storage.StoreObject{ + { + Kind: "Certificate", + Name: "gateway-cert", + }: { + AbsPath: "templates/certificate.yaml", + Unstructured: unstructured.Unstructured{Object: map[string]any{ + "kind": "Certificate", + "metadata": map[string]any{ + "name": "gateway-cert", + }, + }}, + }, + }, + expectedErrors: []string{certificateGatewayIssuerMessage}, + }, + { + name: "should accept recommended include helper", + templateFiles: map[string]string{ + "templates/certificate.yaml": ` +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: gateway-cert +spec: + issuerRef: + name: {{ include "helm_lib_module_https_cert_manager_cluster_issuer_name_for_gateway_api" . }} + kind: ClusterIssuer +`, + }, + storageObjects: map[storage.ResourceIndex]storage.StoreObject{ + { + Kind: "Certificate", + Name: "gateway-cert", + }: { + AbsPath: "templates/certificate.yaml", + Unstructured: unstructured.Unstructured{Object: map[string]any{ + "kind": "Certificate", + "metadata": map[string]any{ + "name": "gateway-cert", + }, + }}, + }, + }, + expectedErrors: []string{}, + }, + { + name: "should ignore printf outside Certificate objects", + templateFiles: map[string]string{ + "templates/configmap.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + issuer: {{ printf "letsencrypt-gateway-%s" $moduleGateway.name }} +`, + }, + storageObjects: map[storage.ResourceIndex]storage.StoreObject{}, + expectedErrors: []string{}, + }, + { + name: "should skip excluded certificate", + templateFiles: map[string]string{ + "templates/certificate.yaml": ` +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: gateway-cert +spec: + issuerRef: + name: {{ printf "letsencrypt-gateway-%s" $moduleGateway.name }} + kind: ClusterIssuer +`, + }, + storageObjects: map[storage.ResourceIndex]storage.StoreObject{ + { + Kind: "Certificate", + Name: "gateway-cert", + }: { + AbsPath: "templates/certificate.yaml", + Unstructured: unstructured.Unstructured{Object: map[string]any{ + "kind": "Certificate", + "metadata": map[string]any{ + "name": "gateway-cert", + }, + }}, + }, + }, + excludeRules: []pkg.KindRuleExclude{ + { + Kind: "Certificate", + Name: "gateway-cert", + }, + }, + expectedErrors: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "certificate-gateway-issuer-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + modulePath := filepath.Join(tempDir, "module") + require.NoError(t, os.MkdirAll(modulePath, 0o755)) + + for filePath, content := range tt.templateFiles { + fullPath := filepath.Join(modulePath, filePath) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755)) + require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o600)) + } + + mc := minimock.NewController(t) + mockModule := mocks.NewModuleMock(mc) + objects := make(map[storage.ResourceIndex]storage.StoreObject, len(tt.storageObjects)) + for index, object := range tt.storageObjects { + object.AbsPath = filepath.Join(modulePath, object.AbsPath) + objects[index] = object + } + mockModule.GetStorageMock.Return(objects) + + errorList := errors.NewLintRuleErrorsList() + rule := NewCertificateGatewayIssuerRule(tt.excludeRules) + + rule.ValidateCertificateGatewayIssuer(mockModule, errorList) + + errs := errorList.GetErrors() + require.Len(t, errs, len(tt.expectedErrors)) + + for i, expectedError := range tt.expectedErrors { + assert.Contains(t, errs[i].Text, expectedError) + } + }) + } +} + +func TestContainsForbiddenGatewayIssuer(t *testing.T) { + assert.True(t, containsForbiddenGatewayIssuer(strings.TrimSpace(` +kind: Certificate +spec: + issuerRef: + name: {{ printf "letsencrypt-gateway-%s" $moduleGateway.name }} +`))) + assert.False(t, containsForbiddenGatewayIssuer(strings.TrimSpace(` +kind: Certificate +spec: + issuerRef: + name: {{ include "helm_lib_module_https_cert_manager_cluster_issuer_name_for_gateway_api" . }} +`))) +} diff --git a/pkg/linters/templates/templates.go b/pkg/linters/templates/templates.go index 87b4c1a50..3ce078319 100644 --- a/pkg/linters/templates/templates.go +++ b/pkg/linters/templates/templates.go @@ -89,6 +89,10 @@ func (l *Templates) Run(m *module.Module) { clusterDomainRule := rules.NewClusterDomainRule() clusterDomainRule.ValidateClusterDomainInTemplates(m, errorList.WithMaxLevel(l.cfg.Rules.ClusterDomainRule.GetLevel())) + // Certificate Gateway API issuer rule + rules.NewCertificateGatewayIssuerRule(l.cfg.ExcludeRules.CertificateGatewayIssuer.Get()). + ValidateCertificateGatewayIssuer(m, errorList.WithMaxLevel(l.cfg.Rules.CertificateGatewayIssuerRule.GetLevel())) + // werf file // The following line is commented out because the Werf rule validation is not currently required. // If needed in the future, uncomment and ensure the rule is properly configured. diff --git a/test/e2e/README.md b/test/e2e/README.md index 3a52f4c22..d3e01b7b5 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -138,6 +138,9 @@ go test ./test/e2e/ -run 'TestE2E//' -v | `templates/prometheus-promtool` | `prometheus-rules` (invalid PromQL via promtool) | | `templates/kube-rbac-proxy` | `kube-rbac-proxy` (d8-* namespace without kube-rbac-proxy CA) | | `templates/cluster-domain` | `cluster-domain` (hardcoded `cluster.local`) | +| `templates/certificate-gateway-issuer-forbidden` | `certificate-gateway-issuer` (Certificate uses `printf "letsencrypt-gateway-%s"`) | +| `templates/certificate-gateway-issuer-valid` | `certificate-gateway-issuer` (Certificate uses the recommended include helper) | +| `templates/certificate-gateway-issuer-disabled` | `certificate-gateway-issuer` excluded via `templates.exclude-rules` | | `templates/registry` | `registry` (global dockercfg without module override) | | `templates/enabled-modules` | `enabled-modules` (deprecated `.Values.global.enabledModules | has`) | diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/expected.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/expected.yaml new file mode 100644 index 000000000..0a5da7471 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/expected.yaml @@ -0,0 +1,7 @@ +description: > + A Certificate that sets issuerRef.name via printf "letsencrypt-gateway-%s" + must not fail the lint when certificate-gateway-issuer is excluded. +module: module +expectPass: + - linter: templates + rule: certificate-gateway-issuer diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/.dmtlint.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/.dmtlint.yaml new file mode 100644 index 000000000..75ccc7f3a --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/.dmtlint.yaml @@ -0,0 +1,6 @@ +linters-settings: + templates: + exclude-rules: + certificate-gateway-issuer: + - kind: Certificate + name: gateway-cert diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/module.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/module.yaml new file mode 100644 index 000000000..dfec2b9d1 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/module.yaml @@ -0,0 +1,2 @@ +name: e2e-certificate-gateway-issuer-disabled +namespace: e2e-certificate-gateway-issuer-disabled diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/openapi/config-values.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/openapi/config-values.yaml new file mode 100644 index 000000000..03b0d8bfe --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/openapi/values.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/openapi/values.yaml new file mode 100644 index 000000000..47180da56 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/templates/certificate.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/templates/certificate.yaml new file mode 100644 index 000000000..a5b21f9c6 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-disabled/module/templates/certificate.yaml @@ -0,0 +1,13 @@ +{{- $moduleGateway := dict "name" "test-gateway" }} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: gateway-cert + namespace: e2e-certificate-gateway-issuer-disabled +spec: + secretName: gateway-cert-tls + issuerRef: + name: {{ printf "letsencrypt-gateway-%s" $moduleGateway.name }} + kind: ClusterIssuer + dnsNames: + - example.com diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/expected.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/expected.yaml new file mode 100644 index 000000000..d178dc4d0 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/expected.yaml @@ -0,0 +1,9 @@ +description: > + A Certificate that sets issuerRef.name via printf "letsencrypt-gateway-%s" + must be flagged by the certificate-gateway-issuer rule. +module: module +expect: + - linter: templates + rule: certificate-gateway-issuer + level: error + textContains: "Certificates related to Gateway API must refer to issuer using" diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/module.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/module.yaml new file mode 100644 index 000000000..3cd4823f8 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/module.yaml @@ -0,0 +1,2 @@ +name: e2e-certificate-gateway-issuer-forbidden +namespace: e2e-certificate-gateway-issuer-forbidden diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/openapi/config-values.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/openapi/config-values.yaml new file mode 100644 index 000000000..03b0d8bfe --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/openapi/values.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/openapi/values.yaml new file mode 100644 index 000000000..47180da56 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/openapi/values.yaml @@ -0,0 +1,4 @@ +x-extend: + schema: config-values.yaml +type: object +properties: {} diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/templates/certificate.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/templates/certificate.yaml new file mode 100644 index 000000000..fe29bc12c --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-forbidden/module/templates/certificate.yaml @@ -0,0 +1,13 @@ +{{- $moduleGateway := dict "name" "test-gateway" }} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: gateway-cert + namespace: e2e-certificate-gateway-issuer-forbidden +spec: + secretName: gateway-cert-tls + issuerRef: + name: {{ printf "letsencrypt-gateway-%s" $moduleGateway.name }} + kind: ClusterIssuer + dnsNames: + - example.com diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-valid/expected.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/expected.yaml new file mode 100644 index 000000000..2dde46737 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/expected.yaml @@ -0,0 +1,7 @@ +description: > + A Certificate that uses the recommended Gateway API ClusterIssuer include + helper must not be flagged by certificate-gateway-issuer. +module: module +expectPass: + - linter: templates + rule: certificate-gateway-issuer diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/module.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/module.yaml new file mode 100644 index 000000000..503173586 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/module.yaml @@ -0,0 +1,2 @@ +name: e2e-certificate-gateway-issuer-valid +namespace: e2e-certificate-gateway-issuer-valid diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/openapi/config-values.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/openapi/config-values.yaml new file mode 100644 index 000000000..03b0d8bfe --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/openapi/config-values.yaml @@ -0,0 +1,2 @@ +type: object +properties: {} diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/openapi/values.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/openapi/values.yaml new file mode 100644 index 000000000..47180da56 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-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/certificate-gateway-issuer-valid/module/templates/_helpers.tpl b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/templates/_helpers.tpl new file mode 100644 index 000000000..73b1bf53f --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/templates/_helpers.tpl @@ -0,0 +1,3 @@ +{{- define "helm_lib_module_https_cert_manager_cluster_issuer_name_for_gateway_api" -}} +letsencrypt-gateway-test-gateway +{{- end }} diff --git a/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/templates/certificate.yaml b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/templates/certificate.yaml new file mode 100644 index 000000000..be296c005 --- /dev/null +++ b/test/e2e/testdata/templates/certificate-gateway-issuer-valid/module/templates/certificate.yaml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: gateway-cert + namespace: e2e-certificate-gateway-issuer-valid +spec: + secretName: gateway-cert-tls + issuerRef: + name: {{ include "helm_lib_module_https_cert_manager_cluster_issuer_name_for_gateway_api" . }} + kind: ClusterIssuer + dnsNames: + - example.com From cdb4a7b6b4448bc0ee53c1ca52b6c1b8833b99af Mon Sep 17 00:00:00 2001 From: Mikhail Scherba Date: Wed, 22 Jul 2026 12:42:04 +0300 Subject: [PATCH 2/2] fix lint Signed-off-by: Mikhail Scherba --- pkg/linters/templates/rules/certificate_gateway_issuer.go | 2 ++ pkg/linters/templates/rules/certificate_gateway_issuer_test.go | 3 +++ 2 files changed, 5 insertions(+) diff --git a/pkg/linters/templates/rules/certificate_gateway_issuer.go b/pkg/linters/templates/rules/certificate_gateway_issuer.go index 5da5d30d1..dad7f3837 100644 --- a/pkg/linters/templates/rules/certificate_gateway_issuer.go +++ b/pkg/linters/templates/rules/certificate_gateway_issuer.go @@ -71,6 +71,7 @@ func (r *CertificateGatewayIssuerRule) ValidateCertificateGatewayIssuer(m pkg.Mo } objectErrorList := errorList.WithObjectID(object.Identity()).WithFilePath(object.GetPath()) + content, err := os.ReadFile(object.AbsPath) if err != nil { objectErrorList.Errorf("Failed to read file %s: %v", object.GetPath(), err) @@ -99,6 +100,7 @@ func containsForbiddenGatewayIssuerForObject(content string, object storage.Stor } foundNamedDocument = true + if containsForbiddenGatewayIssuer(document) { return true } diff --git a/pkg/linters/templates/rules/certificate_gateway_issuer_test.go b/pkg/linters/templates/rules/certificate_gateway_issuer_test.go index 2d460cfb9..7f0d2e9e4 100644 --- a/pkg/linters/templates/rules/certificate_gateway_issuer_test.go +++ b/pkg/linters/templates/rules/certificate_gateway_issuer_test.go @@ -158,6 +158,7 @@ spec: t.Run(tt.name, func(t *testing.T) { tempDir, err := os.MkdirTemp("", "certificate-gateway-issuer-test") require.NoError(t, err) + defer os.RemoveAll(tempDir) modulePath := filepath.Join(tempDir, "module") @@ -171,11 +172,13 @@ spec: mc := minimock.NewController(t) mockModule := mocks.NewModuleMock(mc) + objects := make(map[storage.ResourceIndex]storage.StoreObject, len(tt.storageObjects)) for index, object := range tt.storageObjects { object.AbsPath = filepath.Join(modulePath, object.AbsPath) objects[index] = object } + mockModule.GetStorageMock.Return(objects) errorList := errors.NewLintRuleErrorsList()