diff --git a/api/apps/v1alpha1/redis/types.go b/api/apps/v1alpha1/redis/types.go index e9a02c72..3e12687a 100644 --- a/api/apps/v1alpha1/redis/types.go +++ b/api/apps/v1alpha1/redis/types.go @@ -41,6 +41,9 @@ type ConfigSpec struct { // Enable password generation. // +kubebuilder:default:=true AuthEnabled bool `json:"authEnabled"` + // TLS configuration for client-facing connections. When enabled, an nginx stream sidecar terminates TLS on port 6380. Intra-cluster traffic on 6379 (operator probes, sentinel, replication) remains plaintext. + // +kubebuilder:default:={} + Tls TLS `json:"tls"` } type Resources struct { @@ -50,6 +53,27 @@ type Resources struct { Memory resource.Quantity `json:"memory,omitempty"` } +type TLS struct { + // Enable TLS termination via nginx stream sidecar. + // +kubebuilder:default:=false + Enabled bool `json:"enabled"` + // cert-manager Issuer reference. Used only when secretName is empty. + // +kubebuilder:default:={} + IssuerRef TLSIssuerRef `json:"issuerRef"` + // Name of an existing kubernetes.io/tls secret. When non-empty, cert-manager Certificate is NOT rendered and the user manages the secret. + // +kubebuilder:default:="" + SecretName string `json:"secretName"` +} + +type TLSIssuerRef struct { + // Either "Issuer" or "ClusterIssuer". + // +kubebuilder:default:="ClusterIssuer" + Kind string `json:"kind"` + // Issuer/ClusterIssuer resource name. + // +kubebuilder:default:="selfsigned-cluster-issuer" + Name string `json:"name"` +} + // +kubebuilder:validation:Enum="nano";"micro";"small";"medium";"large";"xlarge";"2xlarge" type ResourcesPreset string diff --git a/api/apps/v1alpha1/redis/zz_generated.deepcopy.go b/api/apps/v1alpha1/redis/zz_generated.deepcopy.go index 6390200c..4e5f844b 100644 --- a/api/apps/v1alpha1/redis/zz_generated.deepcopy.go +++ b/api/apps/v1alpha1/redis/zz_generated.deepcopy.go @@ -55,6 +55,7 @@ func (in *ConfigSpec) DeepCopyInto(out *ConfigSpec) { *out = *in in.Resources.DeepCopyInto(&out.Resources) out.Size = in.Size.DeepCopy() + out.Tls = in.Tls } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigSpec. @@ -83,3 +84,34 @@ func (in *Resources) DeepCopy() *Resources { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLS) DeepCopyInto(out *TLS) { + *out = *in + out.IssuerRef = in.IssuerRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLS. +func (in *TLS) DeepCopy() *TLS { + if in == nil { + return nil + } + out := new(TLS) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSIssuerRef) DeepCopyInto(out *TLSIssuerRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSIssuerRef. +func (in *TLSIssuerRef) DeepCopy() *TLSIssuerRef { + if in == nil { + return nil + } + out := new(TLSIssuerRef) + in.DeepCopyInto(out) + return out +} diff --git a/packages/apps/redis/README.md b/packages/apps/redis/README.md index 7f82d229..454ff91a 100644 --- a/packages/apps/redis/README.md +++ b/packages/apps/redis/README.md @@ -28,9 +28,15 @@ Service utilizes the Spotahome Redis Operator for efficient management and orche ### Application-specific parameters -| Name | Description | Type | Value | -| ------------- | --------------------------- | ------ | ------ | -| `authEnabled` | Enable password generation. | `bool` | `true` | +| Name | Description | Type | Value | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------------- | +| `authEnabled` | Enable password generation. | `bool` | `true` | +| `tls` | TLS configuration for client-facing connections. When enabled, an nginx stream sidecar terminates TLS on port 6380. Intra-cluster traffic on 6379 (operator probes, sentinel, replication) remains plaintext. | `object` | `{}` | +| `tls.enabled` | Enable TLS termination via nginx stream sidecar. | `bool` | `false` | +| `tls.secretName` | Name of an existing kubernetes.io/tls secret. When non-empty, cert-manager Certificate is NOT rendered and the user manages the secret. | `string` | `""` | +| `tls.issuerRef` | cert-manager Issuer reference. Used only when secretName is empty. | `object` | `{}` | +| `tls.issuerRef.name` | Issuer/ClusterIssuer resource name. | `string` | `selfsigned-cluster-issuer` | +| `tls.issuerRef.kind` | Either "Issuer" or "ClusterIssuer". | `string` | `ClusterIssuer` | ## Parameter examples and reference diff --git a/packages/apps/redis/templates/_tls.tpl b/packages/apps/redis/templates/_tls.tpl new file mode 100644 index 00000000..a5cbc5a7 --- /dev/null +++ b/packages/apps/redis/templates/_tls.tpl @@ -0,0 +1,35 @@ +{{/* +redis.tls.secretName — wraps cozy-lib.tls.secretName with redis-specific suffix. +Returns either the user-provided tls.secretName or "-redis-tls". +*/}} +{{- define "redis.tls.secretName" -}} +{{- include "cozy-lib.tls.secretName" (dict + "Release" .Release + "suffix" "redis-tls" + "secretName" .Values.tls.secretName +) -}} +{{- end -}} + +{{/* +redis.tls.dnsNames — DNS SANs for the Redis TLS certificate. +Covers the chart's TLS-only ClusterIP services (-master for writes, +-replicas for reads) and the external LoadBalancer service across +short, namespaced, and FQDN forms. The operator-managed rfs-* service is +intentionally excluded because it serves plaintext on 6379 only. +The external-lb variants are always included so toggling .Values.external +later does not require certificate reissue. +*/}} +{{- define "redis.tls.dnsNames" -}} +- {{ .Release.Name }}-master +- {{ .Release.Name }}-master.{{ .Release.Namespace }} +- {{ .Release.Name }}-master.{{ .Release.Namespace }}.svc +- {{ .Release.Name }}-master.{{ .Release.Namespace }}.svc.cluster.local +- {{ .Release.Name }}-replicas +- {{ .Release.Name }}-replicas.{{ .Release.Namespace }} +- {{ .Release.Name }}-replicas.{{ .Release.Namespace }}.svc +- {{ .Release.Name }}-replicas.{{ .Release.Namespace }}.svc.cluster.local +- {{ .Release.Name }}-external-lb +- {{ .Release.Name }}-external-lb.{{ .Release.Namespace }} +- {{ .Release.Name }}-external-lb.{{ .Release.Namespace }}.svc +- {{ .Release.Name }}-external-lb.{{ .Release.Namespace }}.svc.cluster.local +{{- end -}} diff --git a/packages/apps/redis/templates/dashboard-resourcemap.yaml b/packages/apps/redis/templates/dashboard-resourcemap.yaml index 63f82a7d..d427cf37 100644 --- a/packages/apps/redis/templates/dashboard-resourcemap.yaml +++ b/packages/apps/redis/templates/dashboard-resourcemap.yaml @@ -19,6 +19,9 @@ rules: - secrets resourceNames: - "{{ .Release.Name }}-auth" + {{- if .Values.tls.enabled }} + - "{{ include "redis.tls.secretName" . }}" + {{- end }} verbs: ["get", "list", "watch"] - apiGroups: - cozystack.io diff --git a/packages/apps/redis/templates/redisfailover.yaml b/packages/apps/redis/templates/redisfailover.yaml index 160e030d..b05d76a7 100644 --- a/packages/apps/redis/templates/redisfailover.yaml +++ b/packages/apps/redis/templates/redisfailover.yaml @@ -64,6 +64,48 @@ spec: - appendonly no - save "" {{- end }} + {{- if .Values.tls.enabled }} + extraContainers: + - name: tls-proxy + image: nginxinc/nginx-unprivileged:1.29-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 6380 + name: redis-tls + protocol: TCP + volumeMounts: + - name: tls-certs + mountPath: /etc/nginx/tls + readOnly: true + - name: nginx-tls-config + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + readOnly: true + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi + livenessProbe: + tcpSocket: + port: 6380 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + tcpSocket: + port: 6380 + initialDelaySeconds: 2 + periodSeconds: 5 + extraVolumes: + - name: tls-certs + secret: + secretName: {{ include "redis.tls.secretName" . }} + - name: nginx-tls-config + configMap: + name: {{ .Release.Name }}-redis-tls-nginx + {{- end }} {{- if .Values.authEnabled }} auth: secretPath: {{ .Release.Name }}-auth diff --git a/packages/apps/redis/templates/service.yaml b/packages/apps/redis/templates/service.yaml index 81a73761..321bf204 100644 --- a/packages/apps/redis/templates/service.yaml +++ b/packages/apps/redis/templates/service.yaml @@ -1,3 +1,47 @@ +{{- if .Values.tls.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-master + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + selector: + app.kubernetes.io/component: redis + app.kubernetes.io/name: {{ .Release.Name }} + app.kubernetes.io/part-of: redis-failover + redisfailovers-role: master + ports: + - name: redis-tls + port: 6380 + targetPort: redis-tls + protocol: TCP +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-replicas + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + selector: + app.kubernetes.io/component: redis + app.kubernetes.io/name: {{ .Release.Name }} + app.kubernetes.io/part-of: redis-failover + redisfailovers-role: slave + ports: + - name: redis-tls + port: 6380 + targetPort: redis-tls + protocol: TCP +{{- end }} {{- if .Values.external }} --- apiVersion: v1 @@ -21,7 +65,15 @@ spec: app.kubernetes.io/part-of: redis-failover redisfailovers-role: master ports: + {{- if .Values.tls.enabled }} + - name: redis-tls + port: 6380 + targetPort: redis-tls + protocol: TCP + {{- else }} - name: redis port: 6379 targetPort: redis + protocol: TCP + {{- end }} {{- end }} diff --git a/packages/apps/redis/templates/tls-certificate.yaml b/packages/apps/redis/templates/tls-certificate.yaml new file mode 100644 index 00000000..2c091acf --- /dev/null +++ b/packages/apps/redis/templates/tls-certificate.yaml @@ -0,0 +1,22 @@ +{{- if and .Values.tls.enabled (not .Values.tls.secretName) }} +{{- $dnsNames := list -}} +{{- $dnsNames = append $dnsNames (printf "%s-master" .Release.Name) -}} +{{- $dnsNames = append $dnsNames (printf "%s-master.%s" .Release.Name .Release.Namespace) -}} +{{- $dnsNames = append $dnsNames (printf "%s-master.%s.svc" .Release.Name .Release.Namespace) -}} +{{- $dnsNames = append $dnsNames (printf "%s-master.%s.svc.cluster.local" .Release.Name .Release.Namespace) -}} +{{- $dnsNames = append $dnsNames (printf "%s-replicas" .Release.Name) -}} +{{- $dnsNames = append $dnsNames (printf "%s-replicas.%s" .Release.Name .Release.Namespace) -}} +{{- $dnsNames = append $dnsNames (printf "%s-replicas.%s.svc" .Release.Name .Release.Namespace) -}} +{{- $dnsNames = append $dnsNames (printf "%s-replicas.%s.svc.cluster.local" .Release.Name .Release.Namespace) -}} +{{- $dnsNames = append $dnsNames (printf "%s-external-lb" .Release.Name) -}} +{{- $dnsNames = append $dnsNames (printf "%s-external-lb.%s" .Release.Name .Release.Namespace) -}} +{{- $dnsNames = append $dnsNames (printf "%s-external-lb.%s.svc" .Release.Name .Release.Namespace) -}} +{{- $dnsNames = append $dnsNames (printf "%s-external-lb.%s.svc.cluster.local" .Release.Name .Release.Namespace) -}} +{{- include "cozy-lib.tls.certificate" (dict + "Release" .Release + "name" (printf "%s-redis-tls" .Release.Name) + "secretName" (include "redis.tls.secretName" .) + "dnsNames" $dnsNames + "issuerRef" .Values.tls.issuerRef +) }} +{{- end }} diff --git a/packages/apps/redis/templates/tls-nginx-config.yaml b/packages/apps/redis/templates/tls-nginx-config.yaml new file mode 100644 index 00000000..cf08f1ea --- /dev/null +++ b/packages/apps/redis/templates/tls-nginx-config.yaml @@ -0,0 +1,34 @@ +{{- if .Values.tls.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-redis-tls-nginx + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/component: redis-tls-proxy +data: + nginx.conf: | + worker_processes 1; + error_log /dev/stderr notice; + pid /tmp/nginx.pid; + events { + worker_connections 1024; + } + stream { + upstream redis_backend { + server 127.0.0.1:6379; + } + server { + listen 6380 ssl; + ssl_certificate /etc/nginx/tls/tls.crt; + ssl_certificate_key /etc/nginx/tls/tls.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers off; + proxy_pass redis_backend; + proxy_timeout 1h; + proxy_connect_timeout 5s; + } + } +{{- end }} diff --git a/packages/apps/redis/tests/dashboard-resourcemap-tls_test.yaml b/packages/apps/redis/tests/dashboard-resourcemap-tls_test.yaml new file mode 100644 index 00000000..f984b051 --- /dev/null +++ b/packages/apps/redis/tests/dashboard-resourcemap-tls_test.yaml @@ -0,0 +1,29 @@ +suite: Dashboard RBAC includes TLS secret +templates: + - templates/dashboard-resourcemap.yaml +release: + namespace: tenant-test +tests: + - it: TLS secret not in RBAC when tls.enabled is false + set: + tls: + enabled: false + documentSelector: + path: kind + value: Role + asserts: + - notContains: + path: rules[?(@.resources[0]=='secrets')].resourceNames + content: RELEASE-NAME-redis-tls + + - it: TLS secret added to RBAC when tls.enabled is true + set: + tls: + enabled: true + documentSelector: + path: kind + value: Role + asserts: + - contains: + path: rules[?(@.resources[0]=='secrets')].resourceNames + content: RELEASE-NAME-redis-tls diff --git a/packages/apps/redis/tests/redisfailover-tls_test.yaml b/packages/apps/redis/tests/redisfailover-tls_test.yaml new file mode 100644 index 00000000..52b279e6 --- /dev/null +++ b/packages/apps/redis/tests/redisfailover-tls_test.yaml @@ -0,0 +1,84 @@ +suite: RedisFailover TLS sidecar injection +templates: + - templates/redisfailover.yaml +tests: + - it: does not inject extraContainers when tls.enabled is false + set: + tls: + enabled: false + documentSelector: + path: kind + value: RedisFailover + asserts: + - notExists: + path: spec.redis.extraContainers + - notExists: + path: spec.redis.extraVolumes + + - it: injects nginx tls-proxy sidecar when tls.enabled is true + set: + tls: + enabled: true + documentSelector: + path: kind + value: RedisFailover + asserts: + - equal: + path: spec.redis.extraContainers[0].name + value: tls-proxy + - matchRegex: + path: spec.redis.extraContainers[0].image + pattern: "nginx" + - equal: + path: spec.redis.extraContainers[0].ports[0].containerPort + value: 6380 + - equal: + path: spec.redis.extraContainers[0].ports[0].name + value: redis-tls + + - it: extraVolumes reference auto-generated TLS secret when secretName empty + set: + tls: + enabled: true + secretName: "" + documentSelector: + path: kind + value: RedisFailover + asserts: + - contains: + path: spec.redis.extraVolumes + content: + name: tls-certs + secret: + secretName: RELEASE-NAME-redis-tls + + - it: extraVolumes reference user-provided secret when secretName is set + set: + tls: + enabled: true + secretName: my-custom-secret + documentSelector: + path: kind + value: RedisFailover + asserts: + - contains: + path: spec.redis.extraVolumes + content: + name: tls-certs + secret: + secretName: my-custom-secret + + - it: extraVolumes include nginx config ConfigMap + set: + tls: + enabled: true + documentSelector: + path: kind + value: RedisFailover + asserts: + - contains: + path: spec.redis.extraVolumes + content: + name: nginx-tls-config + configMap: + name: RELEASE-NAME-redis-tls-nginx diff --git a/packages/apps/redis/tests/service-tls_test.yaml b/packages/apps/redis/tests/service-tls_test.yaml new file mode 100644 index 00000000..86d5ef9d --- /dev/null +++ b/packages/apps/redis/tests/service-tls_test.yaml @@ -0,0 +1,179 @@ +suite: TLS service architecture +templates: + - templates/service.yaml +tests: + ################################################################### + # External LoadBalancer service (existing behavior, port switches) # + ################################################################### + + - it: no service when external is false and TLS is false + set: + external: false + tls: + enabled: false + asserts: + - hasDocuments: + count: 0 + + - it: external true and TLS false renders LB on port 6379 plaintext + set: + external: true + tls: + enabled: false + asserts: + - hasDocuments: + count: 1 + - documentIndex: 0 + equal: + path: metadata.name + value: RELEASE-NAME-external-lb + - documentIndex: 0 + equal: + path: spec.type + value: LoadBalancer + - documentIndex: 0 + equal: + path: spec.ports[0].port + value: 6379 + - documentIndex: 0 + equal: + path: spec.ports[0].name + value: redis + - documentIndex: 0 + equal: + path: spec.ports[0].targetPort + value: redis + + - it: external true and TLS true renders LB on port 6380 TLS + set: + external: true + tls: + enabled: true + documentSelector: + path: metadata.name + value: RELEASE-NAME-external-lb + asserts: + - equal: + path: spec.type + value: LoadBalancer + - equal: + path: spec.ports[0].port + value: 6380 + - equal: + path: spec.ports[0].name + value: redis-tls + - equal: + path: spec.ports[0].targetPort + value: redis-tls + + ################################################################### + # Internal ClusterIP services (only when TLS enabled) # + ################################################################### + + - it: no master or replicas service when TLS disabled (external true) + set: + external: true + tls: + enabled: false + asserts: + - hasDocuments: + count: 1 + - documentIndex: 0 + equal: + path: metadata.name + value: RELEASE-NAME-external-lb + + - it: no services when TLS disabled and external false + set: + external: false + tls: + enabled: false + asserts: + - hasDocuments: + count: 0 + + - it: TLS enabled external false renders two ClusterIP services and no LB + set: + external: false + tls: + enabled: true + asserts: + - hasDocuments: + count: 2 + + - it: TLS enabled external true renders three services (master + replicas + LB) + set: + external: true + tls: + enabled: true + asserts: + - hasDocuments: + count: 3 + + ################### + # Master service # + ################### + + - it: master service is a TLS ClusterIP on port 6380 targeting redis-tls + set: + external: false + tls: + enabled: true + documentSelector: + path: metadata.name + value: RELEASE-NAME-master + asserts: + - isKind: + of: Service + - equal: + path: spec.type + value: ClusterIP + - equal: + path: spec.ports[0].port + value: 6380 + - equal: + path: spec.ports[0].name + value: redis-tls + - equal: + path: spec.ports[0].targetPort + value: redis-tls + - equal: + path: spec.selector["redisfailovers-role"] + value: master + - equal: + path: spec.selector["app.kubernetes.io/component"] + value: redis + + #################### + # Replicas service # + #################### + + - it: replicas service is a TLS ClusterIP on port 6380 selecting slave role + set: + external: false + tls: + enabled: true + documentSelector: + path: metadata.name + value: RELEASE-NAME-replicas + asserts: + - isKind: + of: Service + - equal: + path: spec.type + value: ClusterIP + - equal: + path: spec.ports[0].port + value: 6380 + - equal: + path: spec.ports[0].name + value: redis-tls + - equal: + path: spec.ports[0].targetPort + value: redis-tls + - equal: + path: spec.selector["redisfailovers-role"] + value: slave + - equal: + path: spec.selector["app.kubernetes.io/component"] + value: redis diff --git a/packages/apps/redis/tests/tls-certificate_test.yaml b/packages/apps/redis/tests/tls-certificate_test.yaml new file mode 100644 index 00000000..0fc7bd3f --- /dev/null +++ b/packages/apps/redis/tests/tls-certificate_test.yaml @@ -0,0 +1,76 @@ +suite: TLS Certificate +templates: + - templates/tls-certificate.yaml +tests: + - it: does not render when tls.enabled is false + set: + tls: + enabled: false + asserts: + - hasDocuments: { count: 0 } + + - it: does not render when tls.secretName is provided (user owns secret) + set: + tls: + enabled: true + secretName: my-existing-secret + asserts: + - hasDocuments: { count: 0 } + + - it: renders Certificate when tls.enabled and secretName empty + set: + tls: + enabled: true + secretName: "" + asserts: + - hasDocuments: { count: 1 } + - isKind: { of: Certificate } + - equal: { path: apiVersion, value: cert-manager.io/v1 } + - equal: { path: spec.secretName, value: RELEASE-NAME-redis-tls } + + - it: uses default issuerRef + set: + tls: + enabled: true + asserts: + - equal: { path: spec.issuerRef.name, value: selfsigned-cluster-issuer } + - equal: { path: spec.issuerRef.kind, value: ClusterIssuer } + + - it: respects custom issuerRef + set: + tls: + enabled: true + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + asserts: + - equal: { path: spec.issuerRef.name, value: letsencrypt-prod } + + - it: dnsNames include master, replicas and external-lb service variants + set: + tls: + enabled: true + asserts: + - contains: { path: spec.dnsNames, content: RELEASE-NAME-master } + - contains: { path: spec.dnsNames, content: RELEASE-NAME-master.NAMESPACE.svc.cluster.local } + - contains: { path: spec.dnsNames, content: RELEASE-NAME-replicas } + - contains: { path: spec.dnsNames, content: RELEASE-NAME-replicas.NAMESPACE.svc.cluster.local } + - contains: { path: spec.dnsNames, content: RELEASE-NAME-external-lb } + - contains: { path: spec.dnsNames, content: RELEASE-NAME-external-lb.NAMESPACE.svc.cluster.local } + + - it: dnsNames do not contain rfs- operator plaintext service + set: + tls: + enabled: true + asserts: + - notContains: { path: spec.dnsNames, content: rfs-RELEASE-NAME } + - notContains: { path: spec.dnsNames, content: rfs-RELEASE-NAME.NAMESPACE.svc.cluster.local } + + - it: dnsNames include external-lb variants even when external is false + set: + external: false + tls: + enabled: true + asserts: + - contains: { path: spec.dnsNames, content: RELEASE-NAME-external-lb } + - contains: { path: spec.dnsNames, content: RELEASE-NAME-external-lb.NAMESPACE.svc.cluster.local } diff --git a/packages/apps/redis/tests/tls-nginx-config_test.yaml b/packages/apps/redis/tests/tls-nginx-config_test.yaml new file mode 100644 index 00000000..c7d57b01 --- /dev/null +++ b/packages/apps/redis/tests/tls-nginx-config_test.yaml @@ -0,0 +1,44 @@ +suite: TLS nginx ConfigMap +templates: + - templates/tls-nginx-config.yaml +tests: + - it: does not render when tls.enabled is false + set: + tls: + enabled: false + asserts: + - hasDocuments: { count: 0 } + + - it: renders ConfigMap when tls.enabled is true + set: + tls: + enabled: true + asserts: + - hasDocuments: { count: 1 } + - isKind: { of: ConfigMap } + - equal: + path: metadata.name + value: RELEASE-NAME-redis-tls-nginx + - exists: + path: data["nginx.conf"] + + - it: nginx config contains required directives + set: + tls: + enabled: true + asserts: + - matchRegex: + path: data["nginx.conf"] + pattern: "listen 6380 ssl" + - matchRegex: + path: data["nginx.conf"] + pattern: "127\\.0\\.0\\.1:6379" + - matchRegex: + path: data["nginx.conf"] + pattern: "ssl_certificate /etc/nginx/tls/tls\\.crt" + - matchRegex: + path: data["nginx.conf"] + pattern: "ssl_certificate_key /etc/nginx/tls/tls\\.key" + - matchRegex: + path: data["nginx.conf"] + pattern: "ssl_protocols TLSv1\\.2 TLSv1\\.3" diff --git a/packages/apps/redis/values.schema.json b/packages/apps/redis/values.schema.json index a93d92ac..ad678339 100644 --- a/packages/apps/redis/values.schema.json +++ b/packages/apps/redis/values.schema.json @@ -91,6 +91,49 @@ "description": "Enable password generation.", "type": "boolean", "default": true + }, + "tls": { + "description": "TLS configuration for client-facing connections. When enabled, an nginx stream sidecar terminates TLS on port 6380. Intra-cluster traffic on 6379 (operator probes, sentinel, replication) remains plaintext.", + "type": "object", + "default": {}, + "required": [ + "enabled", + "issuerRef", + "secretName" + ], + "properties": { + "enabled": { + "description": "Enable TLS termination via nginx stream sidecar.", + "type": "boolean", + "default": false + }, + "issuerRef": { + "description": "cert-manager Issuer reference. Used only when secretName is empty.", + "type": "object", + "default": {}, + "required": [ + "kind", + "name" + ], + "properties": { + "kind": { + "description": "Either \"Issuer\" or \"ClusterIssuer\".", + "type": "string", + "default": "ClusterIssuer" + }, + "name": { + "description": "Issuer/ClusterIssuer resource name.", + "type": "string", + "default": "selfsigned-cluster-issuer" + } + } + }, + "secretName": { + "description": "Name of an existing kubernetes.io/tls secret. When non-empty, cert-manager Certificate is NOT rendered and the user manages the secret.", + "type": "string", + "default": "" + } + } } } } diff --git a/packages/apps/redis/values.yaml b/packages/apps/redis/values.yaml index efe80d07..736c3c75 100644 --- a/packages/apps/redis/values.yaml +++ b/packages/apps/redis/values.yaml @@ -46,3 +46,25 @@ version: v8 ## @param {bool} authEnabled - Enable password generation. authEnabled: true + +## @typedef {struct} TLSIssuerRef - cert-manager Issuer reference. +## @field {string} name - Issuer/ClusterIssuer resource name. +## @field {string} kind - Either "Issuer" or "ClusterIssuer". + +## @typedef {struct} TLS - TLS configuration for client-facing connections. +## @field {bool} enabled - Enable TLS termination via nginx stream sidecar. +## @field {string} secretName - Name of an existing kubernetes.io/tls secret. When non-empty, cert-manager Certificate is NOT rendered and the user manages the secret. +## @field {TLSIssuerRef} issuerRef - cert-manager Issuer reference. Used only when secretName is empty. + +## TLS limitations: +## - Internal connections must use -master:6380 (writes) or -replicas:6380 (reads). Direct connection only — no Sentinel-aware service discovery over TLS. +## - Sentinel HA clients (rfs-*:26379) operate on plaintext and resolve master IP as :6379 (the operator hardcodes the plaintext port). To use Sentinel-aware clients with TLS, the operator would need patching — out of scope. +## - For applications that need automatic master failover with TLS: use -master:6380 (the K8s Service's selector follows the master role label, so it auto-routes to the new master after failover). + +## @param {TLS} tls - TLS configuration for client-facing connections. When enabled, an nginx stream sidecar terminates TLS on port 6380. Intra-cluster traffic on 6379 (operator probes, sentinel, replication) remains plaintext. +tls: + enabled: false + secretName: "" + issuerRef: + name: "selfsigned-cluster-issuer" + kind: "ClusterIssuer" diff --git a/packages/library/cozy-lib/templates/_tls.tpl b/packages/library/cozy-lib/templates/_tls.tpl new file mode 100644 index 00000000..310ccfbb --- /dev/null +++ b/packages/library/cozy-lib/templates/_tls.tpl @@ -0,0 +1,110 @@ +{{/* +TLS certificate helpers for cert-manager integration. +These helpers render cert-manager Certificate CRs for services that need TLS. +*/}} + +{{/* +Render a cert-manager Certificate CR. + +Usage: + {{- include "cozy-lib.tls.certificate" (dict + "Release" .Release + "name" "my-redis-tls" + "secretName" "my-redis-tls-secret" + "dnsNames" (list "redis-svc" "redis-svc.ns.svc") + "issuerRef" (dict "name" "selfsigned-cluster-issuer" "kind" "ClusterIssuer") + ) }} + +Parameters: + - Release (required) - Helm release object, used for labels + - name (required) - Certificate CR metadata.name + - secretName (required) - TLS secret name to create (can be same as name). Required when calling directly. When using through tls.yaml, defaults to name. + - dnsNames (required) - list of DNS SANs + - issuerRef (required) - dict with "name" and "kind" (e.g. ClusterIssuer, Issuer) + - duration (optional) - certificate duration, defaults to 8760h (1 year); empty strings are treated as unset + - renewBefore (optional) - renewal window, defaults to 720h (30 days) + - usages (optional) - list of key usages, defaults to ["server auth"] (not cert-manager's default of "digital signature, key encipherment"; this is a deliberate choice for TLS server certificates) +*/}} +{{- define "cozy-lib.tls.certificate" -}} +{{- if not .Release -}} +{{- fail "ERROR: \"Release\" is required for cozy-lib.tls.certificate. Pass the Helm release object via the context dict." -}} +{{- end -}} +{{- if not .name -}} +{{- fail "ERROR: \"name\" is required for cozy-lib.tls.certificate. It sets the Certificate CR metadata.name." -}} +{{- end -}} +{{- if not .secretName -}} +{{- fail "ERROR: \"secretName\" is required for cozy-lib.tls.certificate. Specify the TLS secret name to create." -}} +{{- end -}} +{{- if not .dnsNames -}} +{{- fail "ERROR: \"dnsNames\" is required for cozy-lib.tls.certificate. Provide at least one DNS name as a list." -}} +{{- end -}} +{{- if not .issuerRef -}} +{{- fail "ERROR: \"issuerRef\" is required for cozy-lib.tls.certificate. Provide a dict with \"name\" and \"kind\"." -}} +{{- end -}} +{{- if not .issuerRef.name -}} +{{- fail "ERROR: \"issuerRef.name\" is required for cozy-lib.tls.certificate. Specify the ClusterIssuer or Issuer name." -}} +{{- end -}} +{{- if not .issuerRef.kind -}} +{{- fail "ERROR: \"issuerRef.kind\" is required for cozy-lib.tls.certificate. Specify \"ClusterIssuer\" or \"Issuer\"." -}} +{{- end -}} +{{- if not (or (eq .issuerRef.kind "Issuer") (eq .issuerRef.kind "ClusterIssuer")) -}} +{{- fail "ERROR: issuerRef.kind must be \"Issuer\" or \"ClusterIssuer\"" -}} +{{- end -}} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ .name }} + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +spec: + secretName: {{ .secretName }} + duration: {{ ternary "8760h" .duration (empty .duration) }} + renewBefore: {{ ternary "720h" .renewBefore (empty .renewBefore) }} + privateKey: + algorithm: RSA + size: 2048 + usages: + {{- if .usages }} + {{- range .usages }} + - {{ . }} + {{- end }} + {{- else }} + - server auth + {{- end }} + dnsNames: + {{- range .dnsNames }} + - {{ . }} + {{- end }} + issuerRef: + name: {{ .issuerRef.name }} + kind: {{ .issuerRef.kind }} + group: cert-manager.io +{{- end }} + +{{/* +Convenience helper for chart consumers. +Return a TLS secret name — either the user-provided value or a generated default. +Useful in conditionals to decide whether to mount a TLS secret or render a Certificate CR. + +Usage: + {{- $tlsSecret := include "cozy-lib.tls.secretName" (dict "Release" .Release "suffix" "tls" "secretName" .Values.tls.secretName) }} + +Parameters: + - Release (required) - Helm release object + - suffix (required) - suffix for generated name (e.g. "tls", "server-tls") + - secretName (optional) - user-provided secret name; if set, returned as-is +*/}} +{{- define "cozy-lib.tls.secretName" -}} +{{- if not .Release -}} +{{- fail "ERROR: \"Release\" is required for cozy-lib.tls.secretName. Pass the Helm release object via the context dict." -}} +{{- end -}} +{{- if not .suffix -}} +{{- fail "ERROR: \"suffix\" is required for cozy-lib.tls.secretName. Provide a suffix for the generated secret name (e.g. \"tls\", \"server-tls\")." -}} +{{- end -}} +{{- if not (empty .secretName) -}} +{{- .secretName -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name .suffix -}} +{{- end -}} +{{- end }} diff --git a/packages/system/redis-rd/cozyrds/redis.yaml b/packages/system/redis-rd/cozyrds/redis.yaml index e5191aa4..73711517 100644 --- a/packages/system/redis-rd/cozyrds/redis.yaml +++ b/packages/system/redis-rd/cozyrds/redis.yaml @@ -8,7 +8,7 @@ spec: plural: redises singular: redis openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"replicas":{"description":"Number of Redis replicas.","type":"integer","default":2},"resources":{"description":"Explicit CPU and memory configuration for each Redis replica. When omitted, the preset defined in `resourcesPreset` is applied.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available to each replica.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available to each replica.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Default sizing preset used when `resources` is omitted.","type":"string","default":"nano","enum":["nano","micro","small","medium","large","xlarge","2xlarge"]},"size":{"description":"Persistent Volume Claim size available for application data.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":""},"external":{"description":"Enable external access from outside the cluster.","type":"boolean","default":false},"version":{"description":"Redis major version to deploy","type":"string","default":"v8","enum":["v8","v7"]},"authEnabled":{"description":"Enable password generation.","type":"boolean","default":true}}} + {"title":"Chart Values","type":"object","properties":{"replicas":{"description":"Number of Redis replicas.","type":"integer","default":2},"resources":{"description":"Explicit CPU and memory configuration for each Redis replica. When omitted, the preset defined in `resourcesPreset` is applied.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available to each replica.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available to each replica.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Default sizing preset used when `resources` is omitted.","type":"string","default":"nano","enum":["nano","micro","small","medium","large","xlarge","2xlarge"]},"size":{"description":"Persistent Volume Claim size available for application data.","default":"1Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":""},"external":{"description":"Enable external access from outside the cluster.","type":"boolean","default":false},"version":{"description":"Redis major version to deploy","type":"string","default":"v8","enum":["v8","v7"]},"authEnabled":{"description":"Enable password generation.","type":"boolean","default":true},"tls":{"description":"TLS configuration for client-facing connections. When enabled, an nginx stream sidecar terminates TLS on port 6380. Intra-cluster traffic on 6379 (operator probes, sentinel, replication) remains plaintext.","type":"object","default":{},"required":["enabled","issuerRef","secretName"],"properties":{"enabled":{"description":"Enable TLS termination via nginx stream sidecar.","type":"boolean","default":false},"issuerRef":{"description":"cert-manager Issuer reference. Used only when secretName is empty.","type":"object","default":{},"required":["kind","name"],"properties":{"kind":{"description":"Either \"Issuer\" or \"ClusterIssuer\".","type":"string","default":"ClusterIssuer"},"name":{"description":"Issuer/ClusterIssuer resource name.","type":"string","default":"selfsigned-cluster-issuer"}}},"secretName":{"description":"Name of an existing kubernetes.io/tls secret. When non-empty, cert-manager Certificate is NOT rendered and the user manages the secret.","type":"string","default":""}}}}} release: prefix: redis- labels: @@ -25,12 +25,13 @@ spec: tags: - cache icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNDQiIGhlaWdodD0iMTQ0IiByeD0iMjQiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl82ODNfMzIxMykiLz4KPHBhdGggZD0iTTEyMC4xNDkgOTUuNTQ5MUMxMTQuNTg2IDk4LjQ0ODUgODUuNzcwOSAxMTAuMjk2IDc5LjYzNjMgMTEzLjQ5NUM3My41MDE2IDExNi42OTMgNzAuMDkzNyAxMTYuNjYyIDY1LjI0NzIgMTE0LjM0NkM2MC40MDEyIDExMi4wMjkgMjkuNzM2NCA5OS42NDIzIDI0LjIxMjUgOTcuMDAxOUMyMS40NTE5IDk1LjY4MjcgMjAgOTQuNTY4NyAyMCA5My41MTY2VjgyLjk4MDlDMjAgODIuOTgwOSA1OS45MjIgNzQuMjkwMSA2Ni4zNjY5IDcxLjk3NzhDNzIuODExNSA2OS42NjU2IDc1LjA0NzYgNjkuNTgyMSA4MC41MzIgNzEuNTkxQzg2LjAxNzMgNzMuNjAwOCAxMTguODEyIDc5LjUxNzYgMTI0LjIzMyA4MS41MDI5TDEyNC4yMyA5MS44ODk2QzEyNC4yMzEgOTIuOTMxMSAxMjIuOTggOTQuMDczNiAxMjAuMTQ5IDk1LjU0OTFaIiBmaWxsPSIjOTEyNjI2Ii8+CjxwYXRoIGQ9Ik0xMjAuMTQ3IDg1LjA3NTJDMTE0LjU4NSA4Ny45NzM0IDg1Ljc3IDk5LjgyMTggNzkuNjM1NCAxMDMuMDJDNzMuNTAxMSAxMDYuMjE5IDcwLjA5MzIgMTA2LjE4NyA2NS4yNDcyIDEwMy44NzFDNjAuNDAwNyAxMDEuNTU1IDI5LjczNzEgODkuMTY2OCAyNC4yMTM2IDg2LjUyOEMxOC42OTAxIDgzLjg4NzYgMTguNTc0NCA4Mi4wNzA0IDI0LjAwMDMgNzkuOTQ1OEMyOS40MjYxIDc3LjgyMDUgNTkuOTIxNSA2NS44NTYxIDY2LjM2NzIgNjMuNTQzOEM3Mi44MTE4IDYxLjIzMjQgNzUuMDQ3NSA2MS4xNDgxIDgwLjUzMTkgNjMuMTU3OEM4Ni4wMTY4IDY1LjE2NjggMTE0LjY2IDc2LjU2NzYgMTIwLjA3OSA3OC41NTI1QzEyNS41MDEgODAuNTM5OSAxMjUuNzA5IDgyLjE3NjMgMTIwLjE0NyA4NS4wNzUyWiIgZmlsbD0iI0M2MzAyQiIvPgo8cGF0aCBkPSJNMTIwLjE0OSA3OC41MDJDMTE0LjU4NiA4MS40MDE4IDg1Ljc3MDkgOTMuMjQ5MyA3OS42MzYzIDk2LjQ0ODhDNzMuNTAxNiA5OS42NDYyIDcwLjA5MzcgOTkuNjE1MiA2NS4yNDcyIDk3LjI5ODVDNjAuNDAwOCA5NC45ODMgMjkuNzM2NCA4Mi41OTUyIDI0LjIxMjUgNzkuOTU0N0MyMS40NTE5IDc4LjYzNTUgMjAgNzcuNTIzMiAyMCA3Ni40NzA3VjY1LjkzMzhDMjAgNjUuOTMzOCA1OS45MjIgNTcuMjQzNCA2Ni4zNjY5IDU0LjkzMTFDNzIuODExNSA1Mi42MTg5IDc1LjA0NzYgNTIuNTM1IDgwLjUzMiA1NC41NDQzQzg2LjAxNzcgNTYuNTUzNiAxMTguODEzIDYyLjQ2OTMgMTI0LjIzMyA2NC40NTVMMTI0LjIzIDc0Ljg0MjhDMTI0LjIzMSA3NS44ODQgMTIyLjk4IDc3LjAyNjQgMTIwLjE0OSA3OC41MDJaIiBmaWxsPSIjOTEyNjI2Ii8+CjxwYXRoIGQ9Ik0xMjAuMTQ3IDY4LjAyODJDMTE0LjU4NSA3MC45MjcxIDg1Ljc3IDgyLjc3NDcgNzkuNjM1NCA4NS45NzM3QzczLjUwMTEgODkuMTcxNiA3MC4wOTMyIDg5LjE0MDIgNjUuMjQ3MiA4Ni44MjM1QzYwLjQwMDcgODQuNTA4NCAyOS43MzcxIDcyLjEyMDEgMjQuMjEzNiA2OS40ODA5QzE4LjY5MDEgNjYuODQxMyAxOC41NzQ0IDY1LjAyMzcgMjQuMDAwMyA2Mi44OTg0QzI5LjQyNjEgNjAuNzc0MiA1OS45MjE5IDQ4LjgwOSA2Ni4zNjcyIDQ2LjQ5NzJDNzIuODExOCA0NC4xODUzIDc1LjA0NzUgNDQuMTAxNCA4MC41MzE5IDQ2LjExMDhDODYuMDE2OCA0OC4xMTk3IDExNC42NiA1OS41MTk3IDEyMC4wNzkgNjEuNTA1NUMxMjUuNTAxIDYzLjQ5MjQgMTI1LjcwOSA2NS4xMjkyIDEyMC4xNDcgNjguMDI4MloiIGZpbGw9IiNDNjMwMkIiLz4KPHBhdGggZD0iTTEyMC4xNDkgNjAuODIyNEMxMTQuNTg2IDYzLjcyMTQgODUuNzcwOSA3NS41Njk4IDc5LjYzNjMgNzguNzY5MkM3My41MDE2IDgxLjk2NzEgNzAuMDkzNyA4MS45MzU3IDY1LjI0NzIgNzkuNjE5QzYwLjQwMDggNzcuMzAzNSAyOS43MzY0IDY0LjkxNTIgMjQuMjEyNSA2Mi4yNzZDMjEuNDUxOSA2MC45NTU2IDIwIDU5Ljg0MjggMjAgNTguNzkxNVY0OC4yNTQyQzIwIDQ4LjI1NDIgNTkuOTIyIDM5LjU2NDIgNjYuMzY2OSAzNy4yNTI0QzcyLjgxMTUgMzQuOTM5NyA3NS4wNDc2IDM0Ljg1NjcgODAuNTMyIDM2Ljg2NTZDODYuMDE3NyAzOC44NzQ5IDExOC44MTMgNDQuNzkwNSAxMjQuMjMzIDQ2Ljc3NjNMMTI0LjIzIDU3LjE2MzdDMTI0LjIzMSA1OC4yMDQgMTIyLjk4IDU5LjM0NjUgMTIwLjE0OSA2MC44MjI0WiIgZmlsbD0iIzkxMjYyNiIvPgo8cGF0aCBkPSJNMTIwLjE0NyA1MC4zNDlDMTE0LjU4NSA1My4yNDc5IDg1Ljc2OTggNjUuMDk2MyA3OS42MzUyIDY4LjI5NDFDNzMuNTAwOSA3MS40OTIgNzAuMDkzIDcxLjQ2MDYgNjUuMjQ2OSA2OS4xNDUxQzYwLjQwMDkgNjYuODI4MyAyOS43MzY5IDU0LjQ0MDkgMjQuMjEzOCA1MS44MDEzQzE4LjY4OTkgNDkuMTYyMSAxOC41NzQ2IDQ3LjM0NDEgMjQgNDUuMjE5MkMyOS40MjU5IDQzLjA5NDYgNTkuOTIxNyAzMS4xMzEgNjYuMzY3IDI4LjgxODRDNzIuODExNiAyNi41MDYxIDc1LjA0NzMgMjYuNDIzIDgwLjUzMTcgMjguNDMyNEM4Ni4wMTY2IDMwLjQ0MTcgMTE0LjY1OSA0MS44NDE4IDEyMC4wNzkgNDMuODI3NUMxMjUuNTAxIDQ1LjgxMjggMTI1LjcwOSA0Ny40NSAxMjAuMTQ3IDUwLjM0OVoiIGZpbGw9IiNDNjMwMkIiLz4KPHBhdGggZD0iTTg0Ljg1NDEgNDAuMDk5NEw3NS44OTI2IDQxLjAyOThMNzMuODg2NSA0NS44NTdMNzAuNjQ2MyA0MC40NzAzTDYwLjI5ODMgMzkuNTQwNEw2OC4wMTk3IDM2Ljc1NThMNjUuNzAzIDMyLjQ4MTRMNzIuOTMyMSAzNS4zMDg4TDc5Ljc0NzEgMzMuMDc3NUw3Ny45MDUyIDM3LjQ5NzJMODQuODU0MSA0MC4wOTk0Wk03My4zNTE1IDYzLjUxODRMNTYuNjI2NiA1Ni41ODE2TDgwLjU5MiA1Mi45MDI5TDczLjM1MTUgNjMuNTE4NFpNNTAuMTYzNyA0Mi43ODI2QzU3LjIzODEgNDIuNzgyNiA2Mi45NzMgNDUuMDA1NyA2Mi45NzMgNDcuNzQ3NUM2Mi45NzMgNTAuNDkwMSA1Ny4yMzgxIDUyLjcxMjggNTAuMTYzNyA1Mi43MTI4QzQzLjA4OTMgNTIuNzEyOCAzNy4zNTQ1IDUwLjQ4OTcgMzcuMzU0NSA0Ny43NDc1QzM3LjM1NDUgNDUuMDA1NyA0My4wODkzIDQyLjc4MjYgNTAuMTYzNyA0Mi43ODI2WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTk1LjQ0MzQgNDEuNDE3NEwxMDkuNjI3IDQ3LjAyMjRMOTUuNDU1NiA1Mi42MjJMOTUuNDQzNCA0MS40MTc0WiIgZmlsbD0iIzYyMUIxQyIvPgo8cGF0aCBkPSJNNzkuNzUyOSA0Ny42MjYxTDk1LjQ0NDkgNDEuNDE4OUw5NS40NTcxIDUyLjYyMzZMOTMuOTE4NCA1My4yMjU0TDc5Ljc1MjkgNDcuNjI2MVoiIGZpbGw9IiM5QTI5MjgiLz4KPGRlZnM+CjxsaW5lYXJHcmFkaWVudCBpZD0icGFpbnQwX2xpbmVhcl82ODNfMzIxMyIgeDE9IjE4OSIgeTE9IjIxMC41IiB4Mj0iMCIgeTI9IjAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KPHN0b3Agc3RvcC1jb2xvcj0iI0E4MDAwMCIvPgo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGRkNGQ0YiLz4KPC9saW5lYXJHcmFkaWVudD4KPC9kZWZzPgo8L3N2Zz4K - keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "replicas"], ["spec", "resources"], ["spec", "resourcesPreset"], ["spec", "size"], ["spec", "storageClass"], ["spec", "external"], ["spec", "version"], ["spec", "authEnabled"]] + keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "replicas"], ["spec", "resources"], ["spec", "resourcesPreset"], ["spec", "size"], ["spec", "storageClass"], ["spec", "external"], ["spec", "version"], ["spec", "authEnabled"], ["spec", "tls"], ["spec", "tls", "enabled"], ["spec", "tls", "secretName"], ["spec", "tls", "issuerRef"], ["spec", "tls", "issuerRef", "name"], ["spec", "tls", "issuerRef", "kind"]] secrets: exclude: [] include: - resourceNames: - redis-{{ .name }}-auth + - redis-{{ .name }}-redis-tls services: exclude: [] include: diff --git a/packages/tests/cozy-lib-tests/templates/tests/tls-missing-release.yaml b/packages/tests/cozy-lib-tests/templates/tests/tls-missing-release.yaml new file mode 100644 index 00000000..ad51a301 --- /dev/null +++ b/packages/tests/cozy-lib-tests/templates/tests/tls-missing-release.yaml @@ -0,0 +1,6 @@ +{{/* +Test-only template: calls cozy-lib.tls.certificate without Release +to verify the validation guard triggers. +*/}} +{{- $certArgs := dict "name" .Values.tls.name "secretName" .Values.tls.secretName "dnsNames" .Values.tls.dnsNames "issuerRef" .Values.tls.issuerRef -}} +{{- include "cozy-lib.tls.certificate" $certArgs -}} diff --git a/packages/tests/cozy-lib-tests/templates/tests/tls-missing-secretname.yaml b/packages/tests/cozy-lib-tests/templates/tests/tls-missing-secretname.yaml new file mode 100644 index 00000000..92e28bcc --- /dev/null +++ b/packages/tests/cozy-lib-tests/templates/tests/tls-missing-secretname.yaml @@ -0,0 +1,6 @@ +{{/* +Test-only template: calls cozy-lib.tls.certificate without secretName +to verify the validation guard triggers. +*/}} +{{- $certArgs := dict "Release" .Release "name" .Values.tls.name "dnsNames" .Values.tls.dnsNames "issuerRef" .Values.tls.issuerRef -}} +{{- include "cozy-lib.tls.certificate" $certArgs -}} diff --git a/packages/tests/cozy-lib-tests/templates/tests/tls-secretname-missing-release.yaml b/packages/tests/cozy-lib-tests/templates/tests/tls-secretname-missing-release.yaml new file mode 100644 index 00000000..b361c611 --- /dev/null +++ b/packages/tests/cozy-lib-tests/templates/tests/tls-secretname-missing-release.yaml @@ -0,0 +1,11 @@ +{{/* +Test-only template: calls cozy-lib.tls.secretName without Release +to verify the validation guard triggers. +*/}} +{{- $secretName := include "cozy-lib.tls.secretName" (dict "suffix" "tls" "secretName" .Values.tlsSecretName) -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-secretname-missing-release +data: + secretName: {{ $secretName | quote }} diff --git a/packages/tests/cozy-lib-tests/templates/tests/tls-secretname-missing-suffix.yaml b/packages/tests/cozy-lib-tests/templates/tests/tls-secretname-missing-suffix.yaml new file mode 100644 index 00000000..199d7d6d --- /dev/null +++ b/packages/tests/cozy-lib-tests/templates/tests/tls-secretname-missing-suffix.yaml @@ -0,0 +1,11 @@ +{{/* +Test-only template: calls cozy-lib.tls.secretName without suffix +to verify the validation guard triggers. +*/}} +{{- $secretName := include "cozy-lib.tls.secretName" (dict "Release" .Release "secretName" .Values.tlsSecretName) -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-secretname-missing-suffix +data: + secretName: {{ $secretName | quote }} diff --git a/packages/tests/cozy-lib-tests/templates/tests/tls-secretname.yaml b/packages/tests/cozy-lib-tests/templates/tests/tls-secretname.yaml new file mode 100644 index 00000000..dc63eee9 --- /dev/null +++ b/packages/tests/cozy-lib-tests/templates/tests/tls-secretname.yaml @@ -0,0 +1,7 @@ +{{- $secretName := include "cozy-lib.tls.secretName" (dict "Release" .Release "suffix" .Values.tlsSuffix "secretName" .Values.tlsSecretName) -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-secretname +data: + secretName: {{ $secretName | quote }} diff --git a/packages/tests/cozy-lib-tests/templates/tests/tls.yaml b/packages/tests/cozy-lib-tests/templates/tests/tls.yaml new file mode 100644 index 00000000..3d38d7dc --- /dev/null +++ b/packages/tests/cozy-lib-tests/templates/tests/tls.yaml @@ -0,0 +1,6 @@ +{{- if .Values.tls }} +{{- $certArgs := dict "Release" .Release "name" .Values.tls.name "secretName" (default .Values.tls.name .Values.tls.secretName) "dnsNames" .Values.tls.dnsNames "issuerRef" .Values.tls.issuerRef "usages" (.Values.tls.usages | default list) -}} +{{- if and .Values.tls.duration (ne .Values.tls.duration "") }}{{- $_ := set $certArgs "duration" .Values.tls.duration }}{{- end -}} +{{- if and .Values.tls.renewBefore (ne .Values.tls.renewBefore "") }}{{- $_ := set $certArgs "renewBefore" .Values.tls.renewBefore }}{{- end -}} +{{- include "cozy-lib.tls.certificate" $certArgs -}} +{{- end }} diff --git a/packages/tests/cozy-lib-tests/tests/tls_missing_release_test.yaml b/packages/tests/cozy-lib-tests/tests/tls_missing_release_test.yaml new file mode 100644 index 00000000..5cfe9f5c --- /dev/null +++ b/packages/tests/cozy-lib-tests/tests/tls_missing_release_test.yaml @@ -0,0 +1,25 @@ +suite: TLS certificate helper - Release validation + +templates: + - templates/tests/tls-missing-release.yaml + +tests: + # Missing Release triggers a validation error + - it: errors when Release is not provided + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: missing-release-tls + secretName: missing-release-tls-secret + dnsNames: + - svc.example + issuerRef: + name: selfsigned-cluster-issuer + kind: ClusterIssuer + asserts: + - failedTemplate: + errorMessage: 'ERROR: "Release" is required for cozy-lib.tls.certificate. Pass the Helm release object via the context dict.' diff --git a/packages/tests/cozy-lib-tests/tests/tls_missing_secretname_test.yaml b/packages/tests/cozy-lib-tests/tests/tls_missing_secretname_test.yaml new file mode 100644 index 00000000..14cee109 --- /dev/null +++ b/packages/tests/cozy-lib-tests/tests/tls_missing_secretname_test.yaml @@ -0,0 +1,24 @@ +suite: TLS certificate helper - secretName validation + +templates: + - templates/tests/tls-missing-secretname.yaml + +tests: + # Missing secretName triggers a validation error + - it: errors when secretName is not provided + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: missing-secret-tls + dnsNames: + - svc.example + issuerRef: + name: selfsigned-cluster-issuer + kind: ClusterIssuer + asserts: + - failedTemplate: + errorMessage: 'ERROR: "secretName" is required for cozy-lib.tls.certificate. Specify the TLS secret name to create.' diff --git a/packages/tests/cozy-lib-tests/tests/tls_secretname_missing_release_test.yaml b/packages/tests/cozy-lib-tests/tests/tls_secretname_missing_release_test.yaml new file mode 100644 index 00000000..ecfcb9ea --- /dev/null +++ b/packages/tests/cozy-lib-tests/tests/tls_secretname_missing_release_test.yaml @@ -0,0 +1,17 @@ +suite: TLS secretName helper - Release validation + +templates: + - templates/tests/tls-secretname-missing-release.yaml + +tests: + - it: errors when Release is not provided + release: + name: my-app + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tlsSecretName: my-custom-tls-secret + asserts: + - failedTemplate: + errorMessage: 'ERROR: "Release" is required for cozy-lib.tls.secretName. Pass the Helm release object via the context dict.' diff --git a/packages/tests/cozy-lib-tests/tests/tls_secretname_missing_suffix_test.yaml b/packages/tests/cozy-lib-tests/tests/tls_secretname_missing_suffix_test.yaml new file mode 100644 index 00000000..586d0cee --- /dev/null +++ b/packages/tests/cozy-lib-tests/tests/tls_secretname_missing_suffix_test.yaml @@ -0,0 +1,17 @@ +suite: TLS secretName helper - suffix validation + +templates: + - templates/tests/tls-secretname-missing-suffix.yaml + +tests: + - it: errors when suffix is not provided + release: + name: my-app + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tlsSecretName: "" + asserts: + - failedTemplate: + errorMessage: 'ERROR: "suffix" is required for cozy-lib.tls.secretName. Provide a suffix for the generated secret name (e.g. "tls", "server-tls").' diff --git a/packages/tests/cozy-lib-tests/tests/tls_secretname_test.yaml b/packages/tests/cozy-lib-tests/tests/tls_secretname_test.yaml new file mode 100644 index 00000000..94386838 --- /dev/null +++ b/packages/tests/cozy-lib-tests/tests/tls_secretname_test.yaml @@ -0,0 +1,46 @@ +suite: TLS secretName helper + +templates: + - templates/tests/tls-secretname.yaml + +tests: + - it: returns user-provided secretName when set + release: + name: my-app + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tlsSecretName: my-custom-tls-secret + tlsSuffix: tls + asserts: + - equal: + path: data.secretName + value: my-custom-tls-secret + + - it: generates default secretName from release name and suffix when secretName is omitted + release: + name: my-app + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tlsSuffix: tls + asserts: + - equal: + path: data.secretName + value: my-app-tls + + - it: generates default secretName when secretName is an empty string + release: + name: my-app + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tlsSecretName: "" + tlsSuffix: tls + asserts: + - equal: + path: data.secretName + value: my-app-tls diff --git a/packages/tests/cozy-lib-tests/tests/tls_test.yaml b/packages/tests/cozy-lib-tests/tests/tls_test.yaml new file mode 100644 index 00000000..a1b33587 --- /dev/null +++ b/packages/tests/cozy-lib-tests/tests/tls_test.yaml @@ -0,0 +1,350 @@ +suite: TLS certificate helper + +templates: + - templates/tests/tls.yaml + +tests: + # Certificate CR renders with all required fields + - it: renders a Certificate CR with all required fields + values: + - tls_values.yaml + release: + name: test-release + namespace: tenant-test + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Certificate + - isAPIVersion: + of: cert-manager.io/v1 + - equal: + path: metadata.name + value: my-redis-tls + - equal: + path: spec.secretName + value: my-redis-tls-secret + - equal: + path: spec.privateKey.algorithm + value: RSA + - equal: + path: spec.privateKey.size + value: 2048 + + # dnsNames are correctly set + - it: sets dnsNames correctly + values: + - tls_values.yaml + release: + name: test-release + namespace: tenant-test + asserts: + - equal: + path: spec.dnsNames + value: + - redis-svc + - redis-svc.tenant-test.svc + + # issuerRef is correctly set + - it: sets issuerRef correctly + values: + - tls_values.yaml + release: + name: test-release + namespace: tenant-test + asserts: + - equal: + path: spec.issuerRef.name + value: selfsigned-cluster-issuer + - equal: + path: spec.issuerRef.kind + value: ClusterIssuer + - equal: + path: spec.issuerRef.group + value: cert-manager.io + + # Labels include release info + - it: includes standard labels + values: + - tls_values.yaml + release: + name: test-release + namespace: tenant-test + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/instance"] + value: test-release + - equal: + path: metadata.labels["app.kubernetes.io/managed-by"] + value: Helm + + # Default duration and renewBefore are used when not provided + - it: uses default duration and renewBefore + values: + - tls_values.yaml + release: + name: test-release + namespace: tenant-test + asserts: + - equal: + path: spec.duration + value: 8760h + - equal: + path: spec.renewBefore + value: 720h + + # Custom duration and renewBefore work when provided + - it: uses custom duration and renewBefore when provided + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: custom-tls + secretName: custom-tls-secret + dnsNames: + - svc.example + issuerRef: + name: my-issuer + kind: Issuer + duration: 43800h + renewBefore: 360h + asserts: + - equal: + path: spec.duration + value: 43800h + - equal: + path: spec.renewBefore + value: 360h + + # Default usages is server auth + - it: uses server auth as default usage + values: + - tls_values.yaml + release: + name: test-release + namespace: tenant-test + asserts: + - equal: + path: spec.usages + value: + - server auth + + # Custom usages work when provided + - it: uses custom usages when provided + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: client-tls + secretName: client-tls-secret + dnsNames: + - svc.example + issuerRef: + name: my-issuer + kind: ClusterIssuer + usages: + - server auth + - client auth + asserts: + - equal: + path: spec.usages + value: + - server auth + - client auth + + # secretName defaults to name when not provided + - it: uses name as secretName when secretName not provided + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: my-tls-cert + dnsNames: + - svc.example + issuerRef: + name: selfsigned-cluster-issuer + kind: ClusterIssuer + asserts: + - equal: + path: metadata.name + value: my-tls-cert + - equal: + path: spec.secretName + value: my-tls-cert + + # Issuer kind is passed through to Certificate CR + - it: passes issuerRef kind to Certificate CR + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: test-tls + dnsNames: + - svc.example + issuerRef: + name: selfsigned-cluster-issuer + kind: ClusterIssuer + asserts: + - equal: + path: spec.issuerRef.kind + value: ClusterIssuer + + # Namespace-scoped Issuer also works + - it: supports namespace-scoped Issuer + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: test-tls + dnsNames: + - svc.example + issuerRef: + name: my-namespace-issuer + kind: Issuer + asserts: + - equal: + path: spec.issuerRef.kind + value: Issuer + - equal: + path: spec.issuerRef.name + value: my-namespace-issuer + + # TLS disabled produces zero documents + - it: produces zero documents when tls is not set + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + asserts: + - hasDocuments: + count: 0 + + # TLS disabled when tls is explicitly null + - it: produces zero documents when tls is null + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: null + asserts: + - hasDocuments: + count: 0 + + # Empty dnsNames triggers a validation error + - it: errors when dnsNames is empty + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: empty-dns-tls + dnsNames: [] + issuerRef: + name: selfsigned-cluster-issuer + kind: ClusterIssuer + asserts: + - failedTemplate: + errorMessage: 'ERROR: "dnsNames" is required for cozy-lib.tls.certificate. Provide at least one DNS name as a list.' + + # Missing issuerRef name triggers a validation error + - it: errors when issuerRef name is not provided + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: missing-issuer-tls + dnsNames: + - svc.example + issuerRef: + kind: ClusterIssuer + asserts: + - failedTemplate: + errorMessage: 'ERROR: "issuerRef.name" is required for cozy-lib.tls.certificate. Specify the ClusterIssuer or Issuer name.' + + # Missing issuerRef kind triggers a validation error + - it: errors when issuerRef kind is not provided + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: missing-kind-tls + dnsNames: + - svc.example + issuerRef: + name: my-issuer + asserts: + - failedTemplate: + errorMessage: 'ERROR: "issuerRef.kind" is required for cozy-lib.tls.certificate. Specify "ClusterIssuer" or "Issuer".' + + # Invalid issuerRef kind triggers a validation error + - it: errors when issuerRef kind is not Issuer or ClusterIssuer + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: invalid-kind-tls + dnsNames: + - svc.example + issuerRef: + name: my-issuer + kind: InvalidKind + asserts: + - failedTemplate: + errorMessage: 'ERROR: issuerRef.kind must be "Issuer" or "ClusterIssuer"' + + # Empty string duration should fall back to default 8760h + - it: uses default duration when duration is an empty string + release: + name: test-release + namespace: tenant-test + set: + _cluster: {} + _namespace: {} + tls: + name: empty-duration-tls + dnsNames: + - svc.example + issuerRef: + name: selfsigned-cluster-issuer + kind: ClusterIssuer + duration: "" + renewBefore: "" + asserts: + - hasDocuments: + count: 1 + - equal: + path: spec.duration + value: 8760h + - equal: + path: spec.renewBefore + value: 720h diff --git a/packages/tests/cozy-lib-tests/tests/tls_values.yaml b/packages/tests/cozy-lib-tests/tests/tls_values.yaml new file mode 100644 index 00000000..6673dda1 --- /dev/null +++ b/packages/tests/cozy-lib-tests/tests/tls_values.yaml @@ -0,0 +1,12 @@ +tls: + name: my-redis-tls + secretName: my-redis-tls-secret + dnsNames: + - redis-svc + - redis-svc.tenant-test.svc + issuerRef: + name: selfsigned-cluster-issuer + kind: ClusterIssuer + +_cluster: {} +_namespace: {}