From 7ea68f42e2b74ddba0a5472a8063dc4514db9744 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 20 Oct 2026 17:28:19 +0200 Subject: [PATCH 1/3] fix(helm): configure TLS for shared control plane and CAS listeners Signed-off-by: Miguel Martinez Trivino --- deployment/chainloop/Chart.yaml | 2 +- deployment/chainloop/README.md | 36 +++- deployment/chainloop/chart_test.go | 39 +++- deployment/chainloop/shared_tls_test.go | 171 ++++++++++++++++++ .../chainloop/templates/cas/configmap.yaml | 5 + .../chainloop/templates/cas/deployment.yaml | 11 +- .../chainloop/templates/cas/ingress.yaml | 10 +- .../chainloop/templates/cas/service-http.yaml | 1 + .../templates/controlplane/configmap.yaml | 5 + .../templates/controlplane/deployment.yaml | 11 +- .../templates/controlplane/ingress.yaml | 8 +- .../templates/controlplane/service-http.yaml | 1 + deployment/chainloop/values.yaml | 16 +- 13 files changed, 287 insertions(+), 29 deletions(-) create mode 100644 deployment/chainloop/shared_tls_test.go diff --git a/deployment/chainloop/Chart.yaml b/deployment/chainloop/Chart.yaml index fd0081247..d468a5c11 100644 --- a/deployment/chainloop/Chart.yaml +++ b/deployment/chainloop/Chart.yaml @@ -7,7 +7,7 @@ description: Chainloop is an open source software supply chain control plane, a type: application # Bump the patch (not minor, not major) version on each change in the Chart Source code -version: 1.470.0 +version: 1.470.1 # Do not update appVersion, this is handled automatically by the release process appVersion: v1.127.0 diff --git a/deployment/chainloop/README.md b/deployment/chainloop/README.md index f1e2e03a2..2abb30014 100644 --- a/deployment/chainloop/README.md +++ b/deployment/chainloop/README.md @@ -247,7 +247,7 @@ The existing gRPC ingress stays available. Existing clients can keep their curre To test the shared endpoint, point a gRPC client to the control plane HTTP hostname on port 443. The CAS shared endpoint mirrors the control plane (spec D-020): one standard HTTP listener dispatches each request, and native gRPC reaches the native transport through an in-process reverse proxy. `cas.ingress` is one catch-all route on the same hostname — one path `/` with an HTTP/2 (gRPC) upstream — so ByteStream uploads and downloads, browser downloads, and status probes share one Service port and one certificate, including mixed requests on one upstream connection. -Set gRPC transport annotations under `cas.ingress.annotations`, such as `backend-protocol: GRPCS` for TLS to the pod. The upload body-size annotations keep their existing defaults. +The chart selects gRPC transport annotations from the pod TLS configuration. You can override them under `cas.ingress.annotations`. The upload body-size annotations keep their existing defaults. Enable the shared endpoint with a `cas.ingress` block — for example: @@ -275,12 +275,30 @@ The chart configures HTTP/2 upstream traffic for these controllers: Native gRPC clients normally use TLS on the ingress. Plaintext client HTTP/2 needs controller configuration too. - Traefik: `controlplane.service.annotations` includes `traefik.ingress.kubernetes.io/service.serversscheme: h2c`. -These defaults apply to plaintext traffic between the ingress and the pod. Public ingress TLS still works as before. -If you enable TLS on the pod listener, configure the controller for encrypted HTTP/2 upstream traffic instead. -For ingress-nginx, use `GRPCS`. For F5 NGINX, also configure `nginx.org/ssl-services`. -For Traefik, use the `https` service scheme and configure certificate trust as required. +To enable pod TLS, provide each component with a Secret containing `tls.crt` and `tls.key`: + +```yaml +controlplane: + tls: + existingSecret: cp-tls +cas: + tls: + existingSecret: cas-tls +``` + +For each component, the chart reuses that Secret and its existing mount for both `server.http.tls_config` and `server.grpc.tls_config`. +The legacy `tlsConfig.secret.name` setting also enables both listeners; `tls.existingSecret` takes precedence when both are set. +Startup, readiness, and liveness probes switch to HTTPS. These settings also apply when no Ingress is enabled. +The chart selects `GRPCS` for ingress-nginx, adds `nginx.org/ssl-services` for F5 NGINX, and selects the `https` service scheme for Traefik. +Explicit annotation overrides take precedence. Configure the controller's certificate trust as required. Other controllers need their own upstream protocol settings. +The shared Service ports derive `appProtocol` from the same TLS setting: `kubernetes.io/h2` with TLS and `kubernetes.io/h2c` without TLS. +When routing through a Gateway, Gateway-to-pod TLS also requires infrastructure-managed `BackendTLSPolicy` resources with certificate hostname validation and CA trust. The Service protocol alone does not configure that trust or upstream TLS. + +Public TLS termination remains controlled by `ingress.tls` and `ingressAPI.tls`, independently of pod TLS. +When upgrading a deployment that already sets a pod TLS Secret for gRPC, the shared HTTP listener also switches to TLS. Direct clients of that listener must use TLS; HTTP requests must use HTTPS. + The NGINX gRPC module path supports the shared endpoint without path or content-type rules. The regression suite checks that path with NGINX Open Source. It does not certify every controller or NGINX Plus release. See the [F5 annotation reference](https://docs.nginx.com/nginx-ingress-controller/configuration/ingress-resources/advanced-configuration-with-annotations/) for controller requirements. @@ -616,7 +634,7 @@ Once done, you can access with [two predefined users](https://github.com/chainlo | `controlplane.containerPorts.grpc` | controlplane gRPC container port | `9000` | | `controlplane.containerPorts.metrics` | controlplane prometheus metrics container port | `5000` | | `controlplane.enableProfiler` | Enable pprof profiling on port 6060 | `false` | -| `controlplane.tls.existingSecret` | Existing secret name containing TLS certificate to be used by the controlplane grpc server. NOTE: When it's set it will disable secret creation. The secret must contains 2 keys: tls.crt and tls.key respectively containing the certificate and private key. | `""` | +| `controlplane.tls.existingSecret` | Secret with tls.crt and tls.key for both listeners. Direct clients must use TLS. Public Ingress TLS termination remains separate. The legacy tlsConfig.secret.name value is also supported. | `""` | | `controlplane.pluginsDir` | Directory where to look for plugins | `/plugins` | | `controlplane.pluginsNetworkPolicy` | Outbound network policy for the plugins that accept an arbitrary target URL, currently the generic webhook and Dependency-Track plugins. Plugins whose destination is a known public service, such as the Slack and Discord webhooks, always refuse non-public destinations. | | | `controlplane.pluginsNetworkPolicy.blockPrivateTargets` | Refuse requests to destinations that are not publicly routable, such as loopback, private ranges and cloud metadata endpoints | `false` | @@ -679,7 +697,7 @@ Once done, you can access with [two predefined users](https://github.com/chainlo | `controlplane.service.loadBalancerIP` | controlplane service Load Balancer IP | `""` | | `controlplane.service.loadBalancerSourceRanges` | controlplane service Load Balancer sources | `[]` | | `controlplane.service.externalTrafficPolicy` | controlplane service external traffic policy | `Cluster` | -| `controlplane.service.annotations` | Annotations for the shared HTTP and gRPC service. Traefik uses h2c upstream. | See values.yaml | +| `controlplane.service.annotations` | Annotations for the shared HTTP and gRPC service. Traefik uses h2c or HTTPS to match shared listener TLS. | See values.yaml | | `controlplane.service.extraPorts` | Extra ports to expose in controlplane service (normally used with the `sidecars` value) | `[]` | | `controlplane.service.sessionAffinity` | Control where client requests go, to the same pod or round-robin | `None` | | `controlplane.service.sessionAffinityConfig` | Additional settings for the sessionAffinity | `{}` | @@ -846,7 +864,7 @@ Once done, you can access with [two predefined users](https://github.com/chainlo | `cas.containerPorts.http` | controlplane HTTP container port | `8000` | | `cas.containerPorts.grpc` | controlplane gRPC container port | `9000` | | `cas.containerPorts.metrics` | controlplane prometheus metrics container port | `5000` | -| `cas.tls.existingSecret` | Existing secret name containing TLS certificate to be used by the cas grpc server. NOTE: When it's set it will disable secret creation. The secret must contains 2 keys: tls.crt and tls.key respectively containing the certificate and private key. | `""` | +| `cas.tls.existingSecret` | Secret with tls.crt and tls.key for both listeners. Direct clients must use TLS. Public Ingress TLS termination remains separate. The legacy tlsConfig.secret.name value is also supported. | `""` | ### CAS Networking @@ -861,7 +879,7 @@ Once done, you can access with [two predefined users](https://github.com/chainlo | `cas.service.loadBalancerIP` | cas service Load Balancer IP | `""` | | `cas.service.loadBalancerSourceRanges` | cas service Load Balancer sources | `[]` | | `cas.service.externalTrafficPolicy` | cas service external traffic policy | `Cluster` | -| `cas.service.annotations` | Annotations for the shared HTTP and gRPC service. Traefik uses h2c for HTTP/2 upstream traffic. | See values.yaml | +| `cas.service.annotations` | Annotations for the shared HTTP and gRPC service. Traefik uses h2c or HTTPS to match shared listener TLS. | See values.yaml | | `cas.service.extraPorts` | Extra ports to expose in cas service (normally used with the `sidecars` value) | `[]` | | `cas.service.sessionAffinity` | Control where client requests go, to the same pod or round-robin | `None` | | `cas.service.sessionAffinityConfig` | Additional settings for the sessionAffinity | `{}` | diff --git a/deployment/chainloop/chart_test.go b/deployment/chainloop/chart_test.go index 50f0f45c5..6b1e35545 100644 --- a/deployment/chainloop/chart_test.go +++ b/deployment/chainloop/chart_test.go @@ -212,16 +212,21 @@ func testSharedCASIngress(t *testing.T) { } type chartResource struct { - Kind string `yaml:"kind"` + Kind string `yaml:"kind"` + Data map[string]string `yaml:"data"` Metadata struct { Name string `yaml:"name"` Annotations map[string]string `yaml:"annotations"` } `yaml:"metadata"` Spec struct { + Template struct { + Spec chartPodSpec `yaml:"spec"` + } `yaml:"template"` Ports []struct { - Name string `yaml:"name"` - Port int `yaml:"port"` - TargetPort string `yaml:"targetPort"` + Name string `yaml:"name"` + Port int `yaml:"port"` + TargetPort string `yaml:"targetPort"` + AppProtocol string `yaml:"appProtocol"` } `yaml:"ports"` TLS []struct { Hosts []string `yaml:"hosts"` @@ -246,6 +251,32 @@ type chartResource struct { } `yaml:"spec"` } +type chartPodSpec struct { + Containers []struct { + StartupProbe chartProbe `yaml:"startupProbe"` + LivenessProbe chartProbe `yaml:"livenessProbe"` + ReadinessProbe chartProbe `yaml:"readinessProbe"` + VolumeMounts []struct { + Name string `yaml:"name"` + MountPath string `yaml:"mountPath"` + } `yaml:"volumeMounts"` + } `yaml:"containers"` + Volumes []struct { + Name string `yaml:"name"` + Secret *struct { + SecretName string `yaml:"secretName"` + } `yaml:"secret"` + } `yaml:"volumes"` +} + +type chartProbe struct { + HTTPGet struct { + Path string `yaml:"path"` + Port string `yaml:"port"` + Scheme string `yaml:"scheme"` + } `yaml:"httpGet"` +} + func TestCASSharedIngressTLS(t *testing.T) { resources := renderChart(t, []string{helmSet, casIngressEnabled, helmSet, "cas.ingress.tls=true", helmSetString, `cas.ingress.annotations.cert-manager\.io/cluster-issuer=test-issuer`}) diff --git a/deployment/chainloop/shared_tls_test.go b/deployment/chainloop/shared_tls_test.go new file mode 100644 index 000000000..9f0bbb629 --- /dev/null +++ b/deployment/chainloop/shared_tls_test.go @@ -0,0 +1,171 @@ +// Copyright 2026 The Chainloop Authors. +// +// 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 chart_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// Pod TLS must agree across both listeners, certificate mounts, health probes, +// and proxy upstreams, independently of public ingress TLS termination. +func TestSharedListenerTLS(t *testing.T) { + for _, component := range []string{"controlplane", "cas"} { + t.Run(component, func(t *testing.T) { + for _, tc := range []struct { + name string + values []string + secret string + ingress bool + ingressTLS bool + override bool + }{ + {name: "plaintext", ingress: true}, + {name: "existing secret without ingress", values: []string{"tls.existingSecret=server-tls"}, secret: "server-tls"}, + {name: "legacy alias without ingress", values: []string{"tlsConfig.secret.name=legacy-tls"}, secret: "legacy-tls"}, + {name: "existing secret with ingress", values: []string{"tls.existingSecret=server-tls"}, secret: "server-tls", ingress: true}, + {name: "legacy alias with ingress", values: []string{"tlsConfig.secret.name=legacy-tls"}, secret: "legacy-tls", ingress: true}, + {name: "existing secret takes precedence", values: []string{"tls.existingSecret=server-tls", "tlsConfig.secret.name=legacy-tls"}, secret: "server-tls", ingress: true}, + {name: "public TLS only", ingress: true, ingressTLS: true}, + {name: "public and pod TLS", values: []string{"tls.existingSecret=server-tls"}, secret: "server-tls", ingress: true, ingressTLS: true}, + { + name: "explicit annotation overrides", + values: []string{ + "tls.existingSecret=server-tls", + `ingress.annotations.nginx\.ingress\.kubernetes\.io/backend-protocol=GRPC`, + `ingress.annotations.nginx\.org/ssl-services=custom-service`, + `service.annotations.traefik\.ingress\.kubernetes\.io/service\.serversscheme=custom`, + }, + secret: "server-tls", ingress: true, override: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + args := []string{} + for _, value := range tc.values { + args = append(args, helmSetString, component+"."+value) + } + if tc.ingress { + args = append(args, helmSet, component+".ingress.enabled=true") + } + if tc.ingressTLS { + args = append(args, helmSet, component+".ingress.tls=true", + helmSetString, component+`.ingress.annotations.cert-manager\.io/cluster-issuer=test-issuer`) + } + resources := renderChart(t, args) + name := "test-chainloop-" + component + assertListenerTLS(t, resources, component, tc.secret) + + // Enabling TLS for one component must not enable it for the other. + other := "cas" + if component == other { + other = "controlplane" + } + assertListenerTLS(t, resources, other, "") + + backend, scheme, sslService := "GRPC", "h2c", "" + if tc.secret != "" { + backend, scheme, sslService = "GRPCS", "https", name + } + if tc.override { + backend, scheme, sslService = "GRPC", "custom", "custom-service" + } + require.Equal(t, scheme, resources["Service/"+name].Metadata.Annotations["traefik.ingress.kubernetes.io/service.serversscheme"]) + ingress, ok := resources["Ingress/"+name] + require.Equal(t, tc.ingress, ok) + if ok { + require.Equal(t, backend, ingress.Metadata.Annotations["nginx.ingress.kubernetes.io/backend-protocol"]) + require.Equal(t, name, ingress.Metadata.Annotations["nginx.org/grpc-services"]) + require.Equal(t, sslService, ingress.Metadata.Annotations["nginx.org/ssl-services"]) + require.Equal(t, tc.ingressTLS, len(ingress.Spec.TLS) > 0) + } + legacy := resources["Ingress/"+name+"-api"] + require.Equal(t, "GRPC", legacy.Metadata.Annotations["nginx.ingress.kubernetes.io/backend-protocol"]) + require.Equal(t, name+"-api", legacy.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Name) + }) + } + }) + } +} + +func assertListenerTLS(t *testing.T, resources map[string]chartResource, component, secret string) { + t.Helper() + name := "test-chainloop-" + component + protocol := "kubernetes.io/h2c" + if secret != "" { + protocol = "kubernetes.io/h2" + } + ports := resources["Service/"+name].Spec.Ports + require.Len(t, ports, 1) + require.Equal(t, "http", ports[0].Name) + require.Equal(t, protocol, ports[0].AppProtocol) + configKey := "config.yaml" + if component == "cas" { + configKey = "server.yaml" + } + var config struct { + Server map[string]struct { + TLSConfig map[string]string `yaml:"tls_config"` + } `yaml:"server"` + } + require.NoError(t, yaml.Unmarshal([]byte(resources["ConfigMap/"+name].Data[configKey]), &config)) + require.Contains(t, config.Server, "http") + require.Contains(t, config.Server, "grpc") + for _, listener := range []string{"http", "grpc"} { + if secret == "" { + require.Empty(t, config.Server[listener].TLSConfig) + } else { + require.Equal(t, map[string]string{ + "certificate": "/data/server-certs/tls.crt", + "private_key": "/data/server-certs/tls.key", + }, config.Server[listener].TLSConfig) + } + } + require.Empty(t, config.Server["http_metrics"].TLSConfig) + + pod := resources["Deployment/"+name].Spec.Template.Spec + require.NotEmpty(t, pod.Containers) + container := pod.Containers[0] + scheme := "" + if secret != "" { + scheme = "HTTPS" + } + for _, probe := range []chartProbe{container.StartupProbe, container.LivenessProbe, container.ReadinessProbe} { + require.Equal(t, "http", probe.HTTPGet.Port) + require.Equal(t, scheme, probe.HTTPGet.Scheme) + } + require.Equal(t, "/statusz?readiness=1", container.ReadinessProbe.HTTPGet.Path) + mounts, volumes := 0, 0 + for _, mount := range container.VolumeMounts { + if mount.Name == "server-certs" { + mounts++ + require.Equal(t, "/data/server-certs", mount.MountPath) + } + } + for _, volume := range pod.Volumes { + if volume.Name == "server-certs" { + volumes++ + require.NotNil(t, volume.Secret) + require.Equal(t, secret, volume.Secret.SecretName) + } + } + expected := 0 + if secret != "" { + expected = 1 + } + require.Equal(t, expected, mounts) + require.Equal(t, expected, volumes) +} diff --git a/deployment/chainloop/templates/cas/configmap.yaml b/deployment/chainloop/templates/cas/configmap.yaml index fd0aa6e63..3fe4c86e8 100644 --- a/deployment/chainloop/templates/cas/configmap.yaml +++ b/deployment/chainloop/templates/cas/configmap.yaml @@ -19,6 +19,11 @@ data: staging_dir: {{ include "chainloop.cas.staging_dir" . | quote }} server: http: + {{- if include "cas.tls-secret-name" . }} + tls_config: + certificate: /data/server-certs/tls.crt + private_key: /data/server-certs/tls.key + {{- end }} addr: "0.0.0.0:{{ .Values.cas.containerPorts.http }}" # Timeouts for http downloads # grpc downloads/uploads don't require this because they don't have timeouts diff --git a/deployment/chainloop/templates/cas/deployment.yaml b/deployment/chainloop/templates/cas/deployment.yaml index b55a12e7d..5ee67ece9 100644 --- a/deployment/chainloop/templates/cas/deployment.yaml +++ b/deployment/chainloop/templates/cas/deployment.yaml @@ -89,15 +89,24 @@ spec: httpGet: path: /statusz port: http + {{- if include "cas.tls-secret-name" . }} + scheme: HTTPS + {{- end }} periodSeconds: 5 livenessProbe: httpGet: path: /statusz port: http + {{- if include "cas.tls-secret-name" . }} + scheme: HTTPS + {{- end }} readinessProbe: httpGet: path: /statusz?readiness=1 port: http + {{- if include "cas.tls-secret-name" . }} + scheme: HTTPS + {{- end }} {{- if .Values.cas.resources }} resources: {{- toYaml .Values.cas.resources | nindent 12 }} {{- else if ne .Values.cas.resourcesPreset "none" }} @@ -186,4 +195,4 @@ spec: {{- end }} {{- if .Values.cas.extraVolumes }} {{- include "common.tplvalues.render" (dict "value" .Values.cas.extraVolumes "context" $) | nindent 8 }} - {{- end }} \ No newline at end of file + {{- end }} diff --git a/deployment/chainloop/templates/cas/ingress.yaml b/deployment/chainloop/templates/cas/ingress.yaml index beb11b7e9..45821d920 100644 --- a/deployment/chainloop/templates/cas/ingress.yaml +++ b/deployment/chainloop/templates/cas/ingress.yaml @@ -11,8 +11,12 @@ metadata: name: {{ $fullName }} namespace: {{ include "common.names.namespace" . | quote }} labels: {{- include "chainloop.cas.labels" . | nindent 4 }} - {{- if or .Values.cas.ingress.annotations .Values.commonAnnotations }} - {{- $annotations := include "common.tplvalues.merge" (dict "values" (list .Values.cas.ingress.annotations .Values.commonAnnotations) "context" .) }} + {{- $tlsAnnotations := dict }} + {{- if include "cas.tls-secret-name" . }} + {{- $_ := set $tlsAnnotations "nginx.org/ssl-services" $fullName }} + {{- end }} + {{- if or .Values.cas.ingress.annotations .Values.commonAnnotations $tlsAnnotations }} + {{- $annotations := include "common.tplvalues.merge" (dict "values" (list .Values.cas.ingress.annotations .Values.commonAnnotations $tlsAnnotations) "context" .) }} annotations: {{- include "common.tplvalues.render" (dict "value" $annotations "context" $) | nindent 4 }} {{- end }} spec: @@ -57,4 +61,4 @@ spec: {{- include "common.tplvalues.render" (dict "value" .Values.cas.ingress.extraTls "context" $) | nindent 4 }} {{- end }} {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/deployment/chainloop/templates/cas/service-http.yaml b/deployment/chainloop/templates/cas/service-http.yaml index 8fad843f3..982c53677 100644 --- a/deployment/chainloop/templates/cas/service-http.yaml +++ b/deployment/chainloop/templates/cas/service-http.yaml @@ -39,6 +39,7 @@ spec: port: {{ $port }} targetPort: http protocol: TCP + appProtocol: {{ if include "cas.tls-secret-name" . }}kubernetes.io/h2{{ else }}kubernetes.io/h2c{{ end }} {{- if and (or (eq .Values.cas.service.type "NodePort") (eq .Values.cas.service.type "LoadBalancer")) (not (empty .Values.cas.service.nodePorts.http)) }} nodePort: {{ .Values.cas.service.nodePorts.http }} {{- else if eq .Values.cas.service.type "ClusterIP" }} diff --git a/deployment/chainloop/templates/controlplane/configmap.yaml b/deployment/chainloop/templates/controlplane/configmap.yaml index ea64c80e5..159d61cdb 100644 --- a/deployment/chainloop/templates/controlplane/configmap.yaml +++ b/deployment/chainloop/templates/controlplane/configmap.yaml @@ -28,6 +28,11 @@ data: enable_profiler: {{ .Values.controlplane.enableProfiler }} server: http: + {{- if include "controlplane.tls-secret-name" . }} + tls_config: + certificate: /data/server-certs/tls.crt + private_key: /data/server-certs/tls.key + {{- end }} addr: "0.0.0.0:{{ .Values.controlplane.containerPorts.http }}" timeout: 10s external_url: {{ include "chainloop.controlplane.external_url" . }} diff --git a/deployment/chainloop/templates/controlplane/deployment.yaml b/deployment/chainloop/templates/controlplane/deployment.yaml index 0a695a2c8..55c2b9798 100644 --- a/deployment/chainloop/templates/controlplane/deployment.yaml +++ b/deployment/chainloop/templates/controlplane/deployment.yaml @@ -106,6 +106,9 @@ spec: httpGet: path: /statusz port: http + {{- if include "controlplane.tls-secret-name" . }} + scheme: HTTPS + {{- end }} failureThreshold: 10 periodSeconds: 5 initialDelaySeconds: 10 @@ -113,6 +116,9 @@ spec: httpGet: path: /statusz port: http + {{- if include "controlplane.tls-secret-name" . }} + scheme: HTTPS + {{- end }} failureThreshold: 10 periodSeconds: 5 initialDelaySeconds: 10 @@ -120,6 +126,9 @@ spec: httpGet: path: /statusz?readiness=1 port: http + {{- if include "controlplane.tls-secret-name" . }} + scheme: HTTPS + {{- end }} periodSeconds: 5 {{- if .Values.controlplane.lifecycleHooks }} lifecycle: {{- include "common.tplvalues.render" (dict "value" .Values.controlplane.lifecycleHooks "context" $) | nindent 12 }} @@ -249,4 +258,4 @@ spec: - name: tsa-roots configMap: name: {{ include "chainloop.controlplane.fullname" $ }}-tsa - {{- end }} \ No newline at end of file + {{- end }} diff --git a/deployment/chainloop/templates/controlplane/ingress.yaml b/deployment/chainloop/templates/controlplane/ingress.yaml index e5725f5ac..468110927 100644 --- a/deployment/chainloop/templates/controlplane/ingress.yaml +++ b/deployment/chainloop/templates/controlplane/ingress.yaml @@ -11,8 +11,12 @@ metadata: name: {{ $fullName }} namespace: {{ include "common.names.namespace" . | quote }} labels: {{- include "chainloop.controlplane.labels" . | nindent 4 }} - {{- if or .Values.controlplane.ingress.annotations .Values.commonAnnotations }} - {{- $annotations := include "common.tplvalues.merge" (dict "values" (list .Values.controlplane.ingress.annotations .Values.commonAnnotations) "context" .) }} + {{- $tlsAnnotations := dict }} + {{- if include "controlplane.tls-secret-name" . }} + {{- $_ := set $tlsAnnotations "nginx.org/ssl-services" $fullName }} + {{- end }} + {{- if or .Values.controlplane.ingress.annotations .Values.commonAnnotations $tlsAnnotations }} + {{- $annotations := include "common.tplvalues.merge" (dict "values" (list .Values.controlplane.ingress.annotations .Values.commonAnnotations $tlsAnnotations) "context" .) }} annotations: {{- include "common.tplvalues.render" (dict "value" $annotations "context" $) | nindent 4 }} {{- end }} spec: diff --git a/deployment/chainloop/templates/controlplane/service-http.yaml b/deployment/chainloop/templates/controlplane/service-http.yaml index 5c6169901..044b8ab16 100644 --- a/deployment/chainloop/templates/controlplane/service-http.yaml +++ b/deployment/chainloop/templates/controlplane/service-http.yaml @@ -41,6 +41,7 @@ spec: targetPort: {{ .Values.controlplane.containerPorts.http }} {{- end }} protocol: TCP + appProtocol: {{ if include "controlplane.tls-secret-name" . }}kubernetes.io/h2{{ else }}kubernetes.io/h2c{{ end }} {{- if and (or (eq .Values.controlplane.service.type "NodePort") (eq .Values.controlplane.service.type "LoadBalancer")) (not (empty .Values.controlplane.service.nodePorts.http)) }} nodePort: {{ .Values.controlplane.service.nodePorts.http }} {{- else if eq .Values.controlplane.service.type "ClusterIP" }} diff --git a/deployment/chainloop/values.yaml b/deployment/chainloop/values.yaml index 6f8861577..983046a71 100644 --- a/deployment/chainloop/values.yaml +++ b/deployment/chainloop/values.yaml @@ -167,7 +167,7 @@ controlplane: ## TLS configuration ## tls: - ## @param controlplane.tls.existingSecret Existing secret name containing TLS certificate to be used by the controlplane grpc server. NOTE: When it's set it will disable secret creation. The secret must contains 2 keys: tls.crt and tls.key respectively containing the certificate and private key. + ## @param controlplane.tls.existingSecret Secret with tls.crt and tls.key for both listeners. Direct clients must use TLS. Public Ingress TLS termination remains separate. The legacy tlsConfig.secret.name value is also supported. existingSecret: "" ## @param controlplane.pluginsDir Directory where to look for plugins @@ -345,11 +345,11 @@ controlplane: ## ref http://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/#preserving-the-client-source-ip ## externalTrafficPolicy: Cluster - ## @extra controlplane.service.annotations Annotations for the shared HTTP and gRPC service. Traefik uses h2c for HTTP/2 upstream traffic. + ## @extra controlplane.service.annotations Annotations for the shared HTTP and gRPC service. Traefik uses h2c or HTTPS to match shared listener TLS. ## annotations: ## @skip controlplane.service.annotations.traefik.ingress.kubernetes.io/service.serversscheme - traefik.ingress.kubernetes.io/service.serversscheme: h2c + traefik.ingress.kubernetes.io/service.serversscheme: '{{ ternary "https" "h2c" (not (empty (include "controlplane.tls-secret-name" .))) }}' ## @param controlplane.service.extraPorts Extra ports to expose in controlplane service (normally used with the `sidecars` value) ## extraPorts: [] @@ -462,7 +462,7 @@ controlplane: ## annotations: ## @skip controlplane.ingress.annotations.nginx.ingress.kubernetes.io/backend-protocol - nginx.ingress.kubernetes.io/backend-protocol: GRPC + nginx.ingress.kubernetes.io/backend-protocol: '{{ ternary "GRPCS" "GRPC" (not (empty (include "controlplane.tls-secret-name" .))) }}' ## @skip controlplane.ingress.annotations.nginx.org/grpc-services nginx.org/grpc-services: '{{ include "chainloop.controlplane.fullname" . }}' ## @skip controlplane.ingress.annotations.nginx.org/http2 @@ -1077,7 +1077,7 @@ cas: ## TLS configuration ## tls: - ## @param cas.tls.existingSecret Existing secret name containing TLS certificate to be used by the cas grpc server. NOTE: When it's set it will disable secret creation. The secret must contains 2 keys: tls.crt and tls.key respectively containing the certificate and private key. + ## @param cas.tls.existingSecret Secret with tls.crt and tls.key for both listeners. Direct clients must use TLS. Public Ingress TLS termination remains separate. The legacy tlsConfig.secret.name value is also supported. existingSecret: "" ## @skip cas.serviceAccount @@ -1129,11 +1129,11 @@ cas: ## ref http://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/#preserving-the-client-source-ip ## externalTrafficPolicy: Cluster - ## @extra cas.service.annotations Annotations for the shared HTTP and gRPC service. Traefik uses h2c for HTTP/2 upstream traffic. + ## @extra cas.service.annotations Annotations for the shared HTTP and gRPC service. Traefik uses h2c or HTTPS to match shared listener TLS. ## annotations: ## @skip cas.service.annotations.traefik.ingress.kubernetes.io/service.serversscheme - traefik.ingress.kubernetes.io/service.serversscheme: h2c + traefik.ingress.kubernetes.io/service.serversscheme: '{{ ternary "https" "h2c" (not (empty (include "cas.tls-secret-name" .))) }}' ## @param cas.service.extraPorts Extra ports to expose in cas service (normally used with the `sidecars` value) ## extraPorts: [] @@ -1252,7 +1252,7 @@ cas: annotations: ## @skip cas.ingress.annotations.nginx.ingress.kubernetes.io/backend-protocol ## Tell nginx Ingress Controller to expect gRPC traffic - nginx.ingress.kubernetes.io/backend-protocol: "GRPC" + nginx.ingress.kubernetes.io/backend-protocol: '{{ ternary "GRPCS" "GRPC" (not (empty (include "cas.tls-secret-name" .))) }}' ## @skip cas.ingress.annotations.nginx.org/grpc-services nginx.org/grpc-services: '{{ include "chainloop.cas.fullname" . }}' ## @skip cas.ingress.annotations.nginx.org/http2 From a449595de564609de15e2b491284b806c64189df Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 21 Oct 2026 11:25:20 +0200 Subject: [PATCH 2/3] docs(helm): focus TLS guidance on user setup Signed-off-by: Miguel Martinez Trivino --- deployment/chainloop/README.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/deployment/chainloop/README.md b/deployment/chainloop/README.md index 2abb30014..16b341039 100644 --- a/deployment/chainloop/README.md +++ b/deployment/chainloop/README.md @@ -286,15 +286,11 @@ cas: existingSecret: cas-tls ``` -For each component, the chart reuses that Secret and its existing mount for both `server.http.tls_config` and `server.grpc.tls_config`. -The legacy `tlsConfig.secret.name` setting also enables both listeners; `tls.existingSecret` takes precedence when both are set. -Startup, readiness, and liveness probes switch to HTTPS. These settings also apply when no Ingress is enabled. -The chart selects `GRPCS` for ingress-nginx, adds `nginx.org/ssl-services` for F5 NGINX, and selects the `https` service scheme for Traefik. -Explicit annotation overrides take precedence. Configure the controller's certificate trust as required. -Other controllers need their own upstream protocol settings. - -The shared Service ports derive `appProtocol` from the same TLS setting: `kubernetes.io/h2` with TLS and `kubernetes.io/h2c` without TLS. -When routing through a Gateway, Gateway-to-pod TLS also requires infrastructure-managed `BackendTLSPolicy` resources with certificate hostname validation and CA trust. The Service protocol alone does not configure that trust or upstream TLS. +With pod TLS enabled, direct HTTP clients must use HTTPS and direct gRPC clients must use TLS, whether or not an Ingress is enabled. +The legacy `tlsConfig.secret.name` setting is also supported; `tls.existingSecret` takes precedence when both are set. + +The chart configures ingress-nginx, F5 NGINX, and Traefik to connect to the pods over TLS. Explicit annotation overrides take precedence. +Configure your ingress controller to trust the pod certificates. For other controllers or a Gateway, also configure TLS connections to the pods and certificate validation in your infrastructure. Public TLS termination remains controlled by `ingress.tls` and `ingressAPI.tls`, independently of pod TLS. When upgrading a deployment that already sets a pod TLS Secret for gRPC, the shared HTTP listener also switches to TLS. Direct clients of that listener must use TLS; HTTP requests must use HTTPS. From 20826f8b95dff48781029ce49adf02d9b629d615 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 21 Oct 2026 12:04:28 +0200 Subject: [PATCH 3/3] test(helm): remove shared listener TLS regression test Signed-off-by: Miguel Martinez Trivino --- deployment/chainloop/shared_tls_test.go | 171 ------------------------ 1 file changed, 171 deletions(-) delete mode 100644 deployment/chainloop/shared_tls_test.go diff --git a/deployment/chainloop/shared_tls_test.go b/deployment/chainloop/shared_tls_test.go deleted file mode 100644 index 9f0bbb629..000000000 --- a/deployment/chainloop/shared_tls_test.go +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright 2026 The Chainloop Authors. -// -// 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 chart_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" -) - -// Pod TLS must agree across both listeners, certificate mounts, health probes, -// and proxy upstreams, independently of public ingress TLS termination. -func TestSharedListenerTLS(t *testing.T) { - for _, component := range []string{"controlplane", "cas"} { - t.Run(component, func(t *testing.T) { - for _, tc := range []struct { - name string - values []string - secret string - ingress bool - ingressTLS bool - override bool - }{ - {name: "plaintext", ingress: true}, - {name: "existing secret without ingress", values: []string{"tls.existingSecret=server-tls"}, secret: "server-tls"}, - {name: "legacy alias without ingress", values: []string{"tlsConfig.secret.name=legacy-tls"}, secret: "legacy-tls"}, - {name: "existing secret with ingress", values: []string{"tls.existingSecret=server-tls"}, secret: "server-tls", ingress: true}, - {name: "legacy alias with ingress", values: []string{"tlsConfig.secret.name=legacy-tls"}, secret: "legacy-tls", ingress: true}, - {name: "existing secret takes precedence", values: []string{"tls.existingSecret=server-tls", "tlsConfig.secret.name=legacy-tls"}, secret: "server-tls", ingress: true}, - {name: "public TLS only", ingress: true, ingressTLS: true}, - {name: "public and pod TLS", values: []string{"tls.existingSecret=server-tls"}, secret: "server-tls", ingress: true, ingressTLS: true}, - { - name: "explicit annotation overrides", - values: []string{ - "tls.existingSecret=server-tls", - `ingress.annotations.nginx\.ingress\.kubernetes\.io/backend-protocol=GRPC`, - `ingress.annotations.nginx\.org/ssl-services=custom-service`, - `service.annotations.traefik\.ingress\.kubernetes\.io/service\.serversscheme=custom`, - }, - secret: "server-tls", ingress: true, override: true, - }, - } { - t.Run(tc.name, func(t *testing.T) { - args := []string{} - for _, value := range tc.values { - args = append(args, helmSetString, component+"."+value) - } - if tc.ingress { - args = append(args, helmSet, component+".ingress.enabled=true") - } - if tc.ingressTLS { - args = append(args, helmSet, component+".ingress.tls=true", - helmSetString, component+`.ingress.annotations.cert-manager\.io/cluster-issuer=test-issuer`) - } - resources := renderChart(t, args) - name := "test-chainloop-" + component - assertListenerTLS(t, resources, component, tc.secret) - - // Enabling TLS for one component must not enable it for the other. - other := "cas" - if component == other { - other = "controlplane" - } - assertListenerTLS(t, resources, other, "") - - backend, scheme, sslService := "GRPC", "h2c", "" - if tc.secret != "" { - backend, scheme, sslService = "GRPCS", "https", name - } - if tc.override { - backend, scheme, sslService = "GRPC", "custom", "custom-service" - } - require.Equal(t, scheme, resources["Service/"+name].Metadata.Annotations["traefik.ingress.kubernetes.io/service.serversscheme"]) - ingress, ok := resources["Ingress/"+name] - require.Equal(t, tc.ingress, ok) - if ok { - require.Equal(t, backend, ingress.Metadata.Annotations["nginx.ingress.kubernetes.io/backend-protocol"]) - require.Equal(t, name, ingress.Metadata.Annotations["nginx.org/grpc-services"]) - require.Equal(t, sslService, ingress.Metadata.Annotations["nginx.org/ssl-services"]) - require.Equal(t, tc.ingressTLS, len(ingress.Spec.TLS) > 0) - } - legacy := resources["Ingress/"+name+"-api"] - require.Equal(t, "GRPC", legacy.Metadata.Annotations["nginx.ingress.kubernetes.io/backend-protocol"]) - require.Equal(t, name+"-api", legacy.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Name) - }) - } - }) - } -} - -func assertListenerTLS(t *testing.T, resources map[string]chartResource, component, secret string) { - t.Helper() - name := "test-chainloop-" + component - protocol := "kubernetes.io/h2c" - if secret != "" { - protocol = "kubernetes.io/h2" - } - ports := resources["Service/"+name].Spec.Ports - require.Len(t, ports, 1) - require.Equal(t, "http", ports[0].Name) - require.Equal(t, protocol, ports[0].AppProtocol) - configKey := "config.yaml" - if component == "cas" { - configKey = "server.yaml" - } - var config struct { - Server map[string]struct { - TLSConfig map[string]string `yaml:"tls_config"` - } `yaml:"server"` - } - require.NoError(t, yaml.Unmarshal([]byte(resources["ConfigMap/"+name].Data[configKey]), &config)) - require.Contains(t, config.Server, "http") - require.Contains(t, config.Server, "grpc") - for _, listener := range []string{"http", "grpc"} { - if secret == "" { - require.Empty(t, config.Server[listener].TLSConfig) - } else { - require.Equal(t, map[string]string{ - "certificate": "/data/server-certs/tls.crt", - "private_key": "/data/server-certs/tls.key", - }, config.Server[listener].TLSConfig) - } - } - require.Empty(t, config.Server["http_metrics"].TLSConfig) - - pod := resources["Deployment/"+name].Spec.Template.Spec - require.NotEmpty(t, pod.Containers) - container := pod.Containers[0] - scheme := "" - if secret != "" { - scheme = "HTTPS" - } - for _, probe := range []chartProbe{container.StartupProbe, container.LivenessProbe, container.ReadinessProbe} { - require.Equal(t, "http", probe.HTTPGet.Port) - require.Equal(t, scheme, probe.HTTPGet.Scheme) - } - require.Equal(t, "/statusz?readiness=1", container.ReadinessProbe.HTTPGet.Path) - mounts, volumes := 0, 0 - for _, mount := range container.VolumeMounts { - if mount.Name == "server-certs" { - mounts++ - require.Equal(t, "/data/server-certs", mount.MountPath) - } - } - for _, volume := range pod.Volumes { - if volume.Name == "server-certs" { - volumes++ - require.NotNil(t, volume.Secret) - require.Equal(t, secret, volume.Secret.SecretName) - } - } - expected := 0 - if secret != "" { - expected = 1 - } - require.Equal(t, expected, mounts) - require.Equal(t, expected, volumes) -}