From 776a3e6915d8355dca2d3047d26193cdfe04a7b9 Mon Sep 17 00:00:00 2001 From: Sergiy Kulanov Date: Tue, 7 Jul 2026 16:34:59 +0300 Subject: [PATCH] EPMDEDP-17177: feat: add HTTPRoute exposure to application Helm chart scaffold Scaffolded applications can now be exposed via a Gateway API HTTPRoute as an alternative to nginx Ingress, driven by the platform-level gateway selection so no per-application change is required. - Thread the ingress controller type and parent Gateway (name/namespace) into the scaffold template config, resolved from the platform gateway selection at codebase creation - Render the httproute section when Envoy is selected and the ingress section otherwise, preserving the default hostname (-.), custom host and TLS options - Ship an HTTPRoute template attached to the shared Gateway, guarded nil-safely so the dormant exposure template never errors when its values section is absent - Drop the obsolete pre-1.19 Kubernetes version branching from the Ingress template - Keep Ingress the default for nginx clusters so existing applications and updates are unaffected Signed-off-by: Sergiy Kulanov --- .../helm-chart/kubernetes/README.tmpl | 11 +++ .../kubernetes/templates/httproute.yaml | 45 ++++++++++ .../kubernetes/templates/ingress.yaml | 27 +----- .../helm-chart/kubernetes/values.tmpl | 33 ++++++++ .../codebase/service/template/template.go | 13 +-- pkg/model/gerrit_config_template.go | 5 ++ pkg/platform/helper.go | 9 ++ pkg/platform/helper_test.go | 14 ++++ pkg/util/template_test.go | 82 +++++++++++++++++++ 9 files changed, 210 insertions(+), 29 deletions(-) create mode 100644 build/templates/applications/helm-chart/kubernetes/templates/httproute.yaml diff --git a/build/templates/applications/helm-chart/kubernetes/README.tmpl b/build/templates/applications/helm-chart/kubernetes/README.tmpl index db3797d1..9e55ed36 100644 --- a/build/templates/applications/helm-chart/kubernetes/README.tmpl +++ b/build/templates/applications/helm-chart/kubernetes/README.tmpl @@ -31,6 +31,16 @@ A Helm chart for Kubernetes | image.repository | string | `"{{.Name}}"` | | | image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | | imagePullSecrets[0].name | string | `"regcred"` | | +{{- if eq .IngressController "envoy" }} +| httproute.annotations | object | `{}` | | +| httproute.dnsWildcard | string | `"{{.DnsWildcard}}"` | | +| httproute.enabled | bool | `false` | | +| httproute.gateway.name | string | `"{{.GatewayName}}"` | | +| httproute.gateway.namespace | string | `"{{.GatewayNamespace}}"` | | +| httproute.hosts[0].host | string | `"edpDefault"` | | +| httproute.hosts[0].paths[0].path | string | `"/"` | | +| httproute.hosts[0].paths[0].pathType | string | `"PathPrefix"` | | +{{- else }} | ingress.annotations | object | `{}` | | | ingress.className | string | `""` | | | ingress.dnsWildcard | string | `"{{.DnsWildcard}}"` | | @@ -39,6 +49,7 @@ A Helm chart for Kubernetes | ingress.hosts[0].paths[0].path | string | `"/"` | | | ingress.hosts[0].paths[0].pathType | string | `"ImplementationSpecific"` | | | ingress.tls | list | `[]` | | +{{- end }} | livenessProbe.tcpSocket.port | string | `"http"` | | | nameOverride | string | `""` | | | nodeSelector | object | `{}` | https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#nodeselector | diff --git a/build/templates/applications/helm-chart/kubernetes/templates/httproute.yaml b/build/templates/applications/helm-chart/kubernetes/templates/httproute.yaml new file mode 100644 index 00000000..91aece65 --- /dev/null +++ b/build/templates/applications/helm-chart/kubernetes/templates/httproute.yaml @@ -0,0 +1,45 @@ +{{- if and (hasKey .Values "httproute") .Values.httproute.enabled -}} +{{- $fullName := include "REPLACE_IT.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- $top := . -}} +{{- range $i, $host := .Values.httproute.hosts }} +{{- if $i }} +--- +{{- end }} +# Gateway API rules cannot carry a hostname, so each host gets its own HTTPRoute +# to keep path matches scoped to that host (mirrors the per-host rules of an Ingress). +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $fullName }}{{ if $i }}-{{ $i }}{{ end }} + labels: + {{- include "REPLACE_IT.labels" $top | nindent 4 }} + {{- with $top.Values.httproute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + - name: {{ $top.Values.httproute.gateway.name }} + namespace: {{ $top.Values.httproute.gateway.namespace }} + {{- with $top.Values.httproute.gateway.sectionName }} + sectionName: {{ . }} + {{- end }} + hostnames: + {{- if eq $host.host "edpDefault" }} + - {{ $fullName }}-{{ $top.Release.Namespace }}.{{ $top.Values.httproute.dnsWildcard }} + {{- else }} + - {{ $host.host | quote }} + {{- end }} + rules: + - matches: + {{- range $host.paths }} + - path: + type: {{ .pathType | default "PathPrefix" }} + value: {{ .path }} + {{- end }} + backendRefs: + - name: {{ $fullName }} + port: {{ $svcPort }} +{{- end }} +{{- end }} diff --git a/build/templates/applications/helm-chart/kubernetes/templates/ingress.yaml b/build/templates/applications/helm-chart/kubernetes/templates/ingress.yaml index ae92a2d4..bdce58fa 100644 --- a/build/templates/applications/helm-chart/kubernetes/templates/ingress.yaml +++ b/build/templates/applications/helm-chart/kubernetes/templates/ingress.yaml @@ -1,18 +1,7 @@ -{{- if .Values.ingress.enabled -}} +{{- if and (hasKey .Values "ingress") .Values.ingress.enabled -}} {{- $fullName := include "REPLACE_IT.fullname" . -}} {{- $svcPort := .Values.service.port -}} -{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} - {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} - {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} - {{- end }} -{{- end }} -{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} apiVersion: networking.k8s.io/v1 -{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} -apiVersion: networking.k8s.io/v1beta1 -{{- else -}} -apiVersion: extensions/v1beta1 -{{- end }} kind: Ingress metadata: name: {{ $fullName }} @@ -23,8 +12,8 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} - ingressClassName: {{ .Values.ingress.className }} + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} {{- end }} {{- if .Values.ingress.tls }} tls: @@ -38,9 +27,6 @@ spec: {{- end }} rules: {{- range .Values.ingress.hosts }} - # if .host = edpDefault then we need to use - # {{ $fullName }}-{{ $.Release.Namespace }}.{{ $.Values.ingress.dnsWildcard }} - # otherwise we need to use {{ .host }} {{- if eq .host "edpDefault" }} - host: {{ $fullName }}-{{ $.Release.Namespace }}.{{ $.Values.ingress.dnsWildcard }} {{- else }} @@ -50,19 +36,12 @@ spec: paths: {{- range .paths }} - path: {{ .path }} - {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} pathType: {{ .pathType }} - {{- end }} backend: - {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} service: name: {{ $fullName }} port: number: {{ $svcPort }} - {{- else }} - serviceName: {{ $fullName }} - servicePort: {{ $svcPort }} - {{- end }} {{- end }} {{- end }} {{- end }} diff --git a/build/templates/applications/helm-chart/kubernetes/values.tmpl b/build/templates/applications/helm-chart/kubernetes/values.tmpl index bcb5ca36..11fded9c 100644 --- a/build/templates/applications/helm-chart/kubernetes/values.tmpl +++ b/build/templates/applications/helm-chart/kubernetes/values.tmpl @@ -46,6 +46,38 @@ service: type: ClusterIP port: 8080 +{{- if eq .IngressController "envoy" }} +# Expose the application via a Gateway API HTTPRoute on the shared Envoy Gateway. +httproute: + enabled: false + dnsWildcard: {{.DnsWildcard}} + # -- Parent Gateway the HTTPRoute attaches to. Defaults to the platform gateway. + gateway: + name: {{.GatewayName}} + namespace: {{.GatewayNamespace}} + # -- Optional Gateway listener to bind to (e.g. an HTTPS listener that terminates TLS). + # sectionName: "" + # Each entry below is rendered as its own HTTPRoute, so paths stay scoped to their host. + # 'edpDefault' expands to -.; set a literal host to override. + hosts: + - host: edpDefault + paths: + - path: / + pathType: PathPrefix + # Example — expose two custom hosts, each with its own paths (renders two HTTPRoutes): + # hosts: + # - host: api.example.com + # paths: + # - path: /v1 + # pathType: PathPrefix + # - path: /v2 + # pathType: PathPrefix + # - host: web.example.com + # paths: + # - path: / + # pathType: PathPrefix + annotations: {} +{{- else }} ingress: enabled: false dnsWildcard: {{.DnsWildcard}} @@ -63,6 +95,7 @@ ingress: # - secretName: chart-example-tls # hosts: # - chart-example.local +{{- end }} resources: {} # We usually recommend not to specify default resources and to leave this as a conscious diff --git a/controllers/codebase/service/template/template.go b/controllers/codebase/service/template/template.go index 38c1ce57..a784bc45 100644 --- a/controllers/codebase/service/template/template.go +++ b/controllers/codebase/service/template/template.go @@ -65,11 +65,14 @@ func buildTemplateConfig( } cf := model.ConfigGoTemplating{ - Name: cb.Name, - PlatformType: platform.GetPlatformType(), - Lang: cb.Spec.Lang, - DnsWildcard: conf.DnsWildcard, - Framework: cb.Spec.Framework, + Name: cb.Name, + PlatformType: platform.GetPlatformType(), + Lang: cb.Spec.Lang, + DnsWildcard: conf.DnsWildcard, + Framework: cb.Spec.Framework, + IngressController: platform.GetIngressControllerType(), + GatewayName: platform.GatewayName(), + GatewayNamespace: platform.GatewayNamespace(), } cf.GitURL, err = getProjectUrl(c, &cb.Spec, cb.Namespace) diff --git a/pkg/model/gerrit_config_template.go b/pkg/model/gerrit_config_template.go index 3672f492..caeb68e9 100644 --- a/pkg/model/gerrit_config_template.go +++ b/pkg/model/gerrit_config_template.go @@ -7,4 +7,9 @@ type ConfigGoTemplating struct { DnsWildcard string Framework string GitURL string + // IngressController selects the scaffolded exposure: "nginx" -> Ingress, "envoy" -> HTTPRoute. + IngressController string + // GatewayName/GatewayNamespace target the parent Gateway; used only when IngressController is "envoy". + GatewayName string + GatewayNamespace string } diff --git a/pkg/platform/helper.go b/pkg/platform/helper.go index 86a7dfbc..bf8aa070 100644 --- a/pkg/platform/helper.go +++ b/pkg/platform/helper.go @@ -42,6 +42,15 @@ func IsEnvoy() bool { return os.Getenv(IngressControllerEnv) == IngressControllerEnvoy } +// GetIngressControllerType returns the ingress controller type, defaulting to nginx for backward compatibility. +func GetIngressControllerType() string { + if IsEnvoy() { + return IngressControllerEnvoy + } + + return IngressControllerNginx +} + // GatewayName returns the name of the parent Gateway API Gateway resource. func GatewayName() string { return os.Getenv(GatewayNameEnv) diff --git a/pkg/platform/helper_test.go b/pkg/platform/helper_test.go index 65275393..472c0e78 100644 --- a/pkg/platform/helper_test.go +++ b/pkg/platform/helper_test.go @@ -68,6 +68,20 @@ func TestIsEnvoy_DefaultFalse(t *testing.T) { } } +func TestGetIngressControllerType_Envoy(t *testing.T) { + t.Setenv(IngressControllerEnv, IngressControllerEnvoy) + + if GetIngressControllerType() != IngressControllerEnvoy { + t.Error("envoy should be reported when INGRESS_CONTROLLER_TYPE is envoy") + } +} + +func TestGetIngressControllerType_DefaultNginx(t *testing.T) { + if GetIngressControllerType() != IngressControllerNginx { + t.Error("nginx should be the default ingress controller when INGRESS_CONTROLLER_TYPE is unset") + } +} + func TestGatewayName(t *testing.T) { t.Setenv(GatewayNameEnv, "main-gateway") diff --git a/pkg/util/template_test.go b/pkg/util/template_test.go index 4a251507..b10a98d8 100644 --- a/pkg/util/template_test.go +++ b/pkg/util/template_test.go @@ -51,6 +51,88 @@ func TestCopyTemplate_HelmTemplates_ShouldPass(t *testing.T) { assert.Contains(t, string(b), "home: https://example.com") } +// TestCopyTemplate_HelmTemplates_ExposureByController checks that the gateway selection +// configures the matching exposure in values.yaml, while both exposure templates are always +// shipped (each gated by its own values block, so the unselected one stays inert). +func TestCopyTemplate_HelmTemplates_ExposureByController(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cf *model.ConfigGoTemplating + wantValues []string + absentValues []string + }{ + { + name: "nginx renders ingress block", + cf: &model.ConfigGoTemplating{ + Name: "c-name", + PlatformType: "kubernetes", + Lang: "go", + DnsWildcard: "mydomain.example.com", + GitURL: "https://example.com", + IngressController: "nginx", + }, + wantValues: []string{"ingress:", "dnsWildcard: mydomain.example.com"}, + absentValues: []string{"httproute:"}, + }, + { + name: "envoy renders httproute block wired to the gateway", + cf: &model.ConfigGoTemplating{ + Name: "c-name", + PlatformType: "kubernetes", + Lang: "go", + DnsWildcard: "mydomain.example.com", + GitURL: "https://example.com", + IngressController: "envoy", + GatewayName: "main-gateway", + GatewayNamespace: "envoy-gateway-system", + }, + wantValues: []string{ + "httproute:", + "name: main-gateway", + "namespace: envoy-gateway-system", + "dnsWildcard: mydomain.example.com", + }, + absentValues: []string{"\ningress:"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + testDir := t.TempDir() + + err := CopyTemplate( + ctrl.LoggerInto(context.Background(), logr.Discard()), + HelmChartDeploymentScriptType, + testDir, + "../../build", + tt.cf, + ) + require.NoError(t, err) + + values, err := os.ReadFile(fmt.Sprintf("%v/deploy-templates/values.yaml", testDir)) + require.NoError(t, err) + + for _, want := range tt.wantValues { + assert.Contains(t, string(values), want) + } + + for _, absent := range tt.absentValues { + assert.NotContains(t, string(values), absent) + } + + // Both exposure templates always ship; each is gated by its own values block. + for _, f := range []string{"ingress.yaml", "httproute.yaml"} { + _, err = os.Stat(fmt.Sprintf("%v/deploy-templates/templates/%s", testDir, f)) + require.NoError(t, err, "%s should always be shipped", f) + } + }) + } +} + func TestCopyTemplate_HelmTemplates_DirectoryExists(t *testing.T) { t.Parallel()