diff --git a/charts/ctrlplane/Chart.yaml b/charts/ctrlplane/Chart.yaml index 08d31ac..5f8a694 100644 --- a/charts/ctrlplane/Chart.yaml +++ b/charts/ctrlplane/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: ctrlplane description: Ctrlplane Helm chart for Kubernetes type: application -version: 0.10.2 +version: 0.11.0 appVersion: "1.0.0" maintainers: diff --git a/charts/ctrlplane/charts/api/templates/deployment.yaml b/charts/ctrlplane/charts/api/templates/deployment.yaml index ddfbe19..f5944c9 100644 --- a/charts/ctrlplane/charts/api/templates/deployment.yaml +++ b/charts/ctrlplane/charts/api/templates/deployment.yaml @@ -49,11 +49,17 @@ spec: - name: WORKSPACE_ENGINE_ROUTER_URL value: http://{{ .Release.Name }}-workspace-engine-router.{{ .Release.Namespace }}.svc.cluster.local:9090 + {{- if include "ctrlplane.isValueFrom" .Values.global.secrets.encryptionKey }} + - name: VARIABLES_AES_256_KEY + valueFrom: + {{- toYaml .Values.global.secrets.encryptionKey.valueFrom | nindent 16 }} + {{- else }} - name: VARIABLES_AES_256_KEY valueFrom: secretKeyRef: name: {{ .Release.Name }}-encryption-key key: AES_256_KEY + {{- end }} # Auth providers with valueFrom support {{- include "ctrlplane.authProviderEnvVars" . | nindent 12 }} @@ -68,11 +74,17 @@ spec: - name: KAFKA_BROKERS value: {{ .Values.global.kafka.brokers | quote }} + {{- if include "ctrlplane.isValueFrom" .Values.global.secrets.authSecret }} + - name: AUTH_SECRET + valueFrom: + {{- toYaml .Values.global.secrets.authSecret.valueFrom | nindent 16 }} + {{- else }} - name: AUTH_SECRET valueFrom: secretKeyRef: name: {{ include "api.fullname" . }} key: AUTH_SECRET + {{- end }} - name: GITHUB_URL value: {{ include "ctrlplane.githubUrl" . }} diff --git a/charts/ctrlplane/charts/api/templates/secrets.yaml b/charts/ctrlplane/charts/api/templates/secrets.yaml index 8fdc76d..c7c066d 100644 --- a/charts/ctrlplane/charts/api/templates/secrets.yaml +++ b/charts/ctrlplane/charts/api/templates/secrets.yaml @@ -1,5 +1,6 @@ -{{- $secretName := (include "api.fullname" .) }} -{{- $secret := (lookup "v1" "Secret" .Release.Namespace $secretName) }} +{{- if not (include "ctrlplane.isValueFrom" .Values.global.secrets.authSecret) }} +{{- $secretName := (include "api.fullname" .) }} +{{- $existing := (lookup "v1" "Secret" .Release.Namespace $secretName) }} apiVersion: v1 kind: Secret metadata: @@ -7,8 +8,9 @@ metadata: labels: {{- include "api.labels" . | nindent 4 }} data: -{{- if $secret }} - AUTH_SECRET: {{ $secret.data.AUTH_SECRET }} +{{- if and $existing (hasKey $existing.data "AUTH_SECRET") }} + AUTH_SECRET: {{ index $existing.data "AUTH_SECRET" }} {{- else }} AUTH_SECRET: {{ randAlphaNum 64 | b64enc }} -{{- end }} \ No newline at end of file +{{- end }} +{{- end }} diff --git a/charts/ctrlplane/charts/pty-proxy/templates/deployment.yaml b/charts/ctrlplane/charts/pty-proxy/templates/deployment.yaml index 308a712..8737173 100644 --- a/charts/ctrlplane/charts/pty-proxy/templates/deployment.yaml +++ b/charts/ctrlplane/charts/pty-proxy/templates/deployment.yaml @@ -56,11 +56,17 @@ spec: port: http env: {{- include "ctrlplane.postgresqlEnvVars" . | nindent 12 }} + {{- if include "ctrlplane.isValueFrom" .Values.global.secrets.encryptionKey }} + - name: VARIABLES_AES_256_KEY + valueFrom: + {{- toYaml .Values.global.secrets.encryptionKey.valueFrom | nindent 16 }} + {{- else }} - name: VARIABLES_AES_256_KEY valueFrom: secretKeyRef: name: {{ .Release.Name }}-encryption-key key: AES_256_KEY + {{- end }} {{- include "ctrlplane.extraEnv" . | nindent 12 }} {{- include "ctrlplane.extraEnvFrom" (dict "root" $ "local" .) | nindent 12 }} resources: diff --git a/charts/ctrlplane/charts/web/templates/secrets.yaml b/charts/ctrlplane/charts/web/templates/secrets.yaml index 6d25aba..2279cb5 100644 --- a/charts/ctrlplane/charts/web/templates/secrets.yaml +++ b/charts/ctrlplane/charts/web/templates/secrets.yaml @@ -1,5 +1,6 @@ -{{- $secretName := (include "web.fullname" .) }} -{{- $secret := (lookup "v1" "Secret" .Release.Namespace $secretName) }} +{{- if not (include "ctrlplane.isValueFrom" .Values.global.secrets.authSecret) }} +{{- $secretName := (include "web.fullname" .) }} +{{- $existing := (lookup "v1" "Secret" .Release.Namespace $secretName) }} apiVersion: v1 kind: Secret metadata: @@ -7,8 +8,9 @@ metadata: labels: {{- include "web.labels" . | nindent 4 }} data: -{{- if $secret }} - AUTH_SECRET: {{ $secret.data.AUTH_SECRET }} +{{- if and $existing (hasKey $existing.data "AUTH_SECRET") }} + AUTH_SECRET: {{ index $existing.data "AUTH_SECRET" }} {{- else }} AUTH_SECRET: {{ randAlphaNum 64 | b64enc }} -{{- end }} \ No newline at end of file +{{- end }} +{{- end }} diff --git a/charts/ctrlplane/charts/workspace-engine/templates/statefulset.yaml b/charts/ctrlplane/charts/workspace-engine/templates/statefulset.yaml index 773c4ce..93e4295 100644 --- a/charts/ctrlplane/charts/workspace-engine/templates/statefulset.yaml +++ b/charts/ctrlplane/charts/workspace-engine/templates/statefulset.yaml @@ -43,11 +43,17 @@ spec: containerPort: 8081 protocol: TCP env: + {{- if include "ctrlplane.isValueFrom" .Values.global.secrets.encryptionKey }} + - name: AES_256_KEY + valueFrom: + {{- toYaml .Values.global.secrets.encryptionKey.valueFrom | nindent 16 }} + {{- else }} - name: AES_256_KEY valueFrom: secretKeyRef: name: {{ .Release.Name }}-encryption-key key: AES_256_KEY + {{- end }} - name: KAFKA_BROKERS value: {{ .Values.global.kafka.brokers | quote }} - name: KAFKA_GROUP_ID @@ -71,11 +77,17 @@ spec: {{- include "ctrlplane.postgresqlEnvVars" . | nindent 12 }} {{- include "ctrlplane.githubBotEnvVars" . | nindent 12 }} + {{- if include "ctrlplane.isValueFrom" .Values.global.secrets.encryptionKey }} + - name: VARIABLES_AES_256_KEY + valueFrom: + {{- toYaml .Values.global.secrets.encryptionKey.valueFrom | nindent 16 }} + {{- else }} - name: VARIABLES_AES_256_KEY valueFrom: secretKeyRef: name: {{ .Release.Name }}-encryption-key key: AES_256_KEY + {{- end }} {{- include "ctrlplane.extraEnv" . | nindent 12 }} {{- include "ctrlplane.extraEnvFrom" (dict "root" $ "local" .) | nindent 12 }} resources: diff --git a/charts/ctrlplane/templates/NOTES.txt b/charts/ctrlplane/templates/NOTES.txt index 2b484c3..005cfc8 100644 --- a/charts/ctrlplane/templates/NOTES.txt +++ b/charts/ctrlplane/templates/NOTES.txt @@ -1,9 +1,11 @@ 1. Get the application URL by running these commands: {{- if .Values.ingress.create }} -{{- range $host := .Values.ingress.hosts }} - {{- range .paths }} - http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} - {{- end }} +{{- $fqdn := .Values.global.fqdn | trimPrefix "https://" | trimPrefix "http://" | trimSuffix "/" }} +{{- if $fqdn }} + http{{ if .Values.ingress.tls.enabled }}s{{ end }}://{{ $fqdn }} +{{- else }} + Ingress is enabled but no host is configured (global.fqdn is empty). + Access the service via the ingress IP or configure global.fqdn. {{- end }} {{- else if contains "NodePort" .Values.service.type }} export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "ctrlplane.fullname" . }}) @@ -30,6 +32,18 @@ ⚠ Password configured directly in values (less secure for production) {{- end }} + Secrets: +{{- if include "ctrlplane.isValueFrom" .Values.global.secrets.encryptionKey }} + ✓ Encryption key (AES_256_KEY) configured via secret reference +{{- else }} + ⚠ Encryption key auto-generated by chart (consider using ExternalSecrets for production) +{{- end }} +{{- if include "ctrlplane.isValueFrom" .Values.global.secrets.authSecret }} + ✓ Auth secret configured via secret reference +{{- else }} + ⚠ Auth secret auto-generated by chart (consider using ExternalSecrets for production) +{{- end }} + Auth Providers: {{- if include "ctrlplane.isValueFrom" .Values.global.authProviders.google.clientSecret }} ✓ Google OAuth configured via secret reference @@ -58,20 +72,30 @@ 3. Using valueFrom pattern for secrets: - Instead of: - postgresql: - password: "my-secret-password" + Instead of auto-generated secrets: + global: + secrets: + authSecret: "" + encryptionKey: "" - Use: - postgresql: - password: - valueFrom: - secretKeyRef: - name: "postgresql-secret" - key: "password" + Reference externally managed secrets: + global: + secrets: + authSecret: + valueFrom: + secretKeyRef: + name: "my-auth-secret" + key: "AUTH_SECRET" + encryptionKey: + valueFrom: + secretKeyRef: + name: "my-encryption-secret" + key: "AES_256_KEY" This pattern works for all sensitive configuration values including: + - Encryption key (AES_256_KEY) + - Auth secret (AUTH_SECRET) - PostgreSQL password - OAuth client secrets (Google, Okta) - GitHub bot credentials - - Azure app credentials \ No newline at end of file + - Azure app credentials diff --git a/charts/ctrlplane/templates/ingress.yaml b/charts/ctrlplane/templates/ingress.yaml index 3b7883a..853adaa 100644 --- a/charts/ctrlplane/templates/ingress.yaml +++ b/charts/ctrlplane/templates/ingress.yaml @@ -1,39 +1,52 @@ {{- if .Values.ingress.create }} +{{- $fqdn := .Values.global.fqdn | trimPrefix "https://" | trimPrefix "http://" | trimSuffix "/" -}} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "ctrlplane.fullname" . }} labels: - {{- if .Values.ingress.labels -}} - {{- toYaml .Values.ingress.labels | nindent 4 }} + {{- include "ctrlplane.labels" . | nindent 4 }} + {{- with .Values.ingress.labels }} + {{- toYaml . | nindent 4 }} {{- end }} + {{- with .Values.ingress.annotations }} annotations: - {{- if .Values.ingress.annotations -}} - {{- toYaml .Values.ingress.annotations | nindent 4 }} - {{- end }} + {{- toYaml . | nindent 4 }} + {{- end }} spec: + {{- if .Values.ingress.class }} ingressClassName: {{ .Values.ingress.class }} + {{- end }} + {{- if and $fqdn .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ $fqdn }} + secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" (include "ctrlplane.fullname" .)) }} + {{- end }} defaultBackend: service: - name: {{ .Release.Name }}-webservice + name: {{ .Release.Name }}-web port: number: 3000 rules: - - http: + - {{- if $fqdn }} + host: {{ $fqdn }} + {{- end }} + http: paths: - pathType: Prefix path: / backend: service: name: {{ .Release.Name }}-web - port: + port: number: 3000 - pathType: Prefix path: /api backend: service: name: {{ .Release.Name }}-api - port: + port: number: 8081 - pathType: Prefix path: /api/v1/resources/proxy @@ -42,4 +55,4 @@ spec: name: {{ .Release.Name }}-pty-proxy port: number: 4000 -{{- end }} \ No newline at end of file +{{- end }} diff --git a/charts/ctrlplane/templates/secrets.yaml b/charts/ctrlplane/templates/secrets.yaml index b24913a..54f98fa 100644 --- a/charts/ctrlplane/templates/secrets.yaml +++ b/charts/ctrlplane/templates/secrets.yaml @@ -1,6 +1,7 @@ +{{- if not (include "ctrlplane.isValueFrom" .Values.global.secrets.encryptionKey) }} --- {{- $secretName := (printf "%s-encryption-key" .Release.Name) }} -{{- $secret := (lookup "v1" "Secret" .Release.Namespace $secretName) }} +{{- $existing := (lookup "v1" "Secret" .Release.Namespace $secretName) }} apiVersion: v1 kind: Secret metadata: @@ -9,8 +10,9 @@ metadata: {{- include "ctrlplane.labels" . | nindent 4 }} type: Opaque data: - {{- if $secret }} - AES_256_KEY: {{ $secret.data.AES_256_KEY }} + {{- if and $existing (hasKey $existing.data "AES_256_KEY") }} + AES_256_KEY: {{ index $existing.data "AES_256_KEY" }} {{- else }} - AES_256_KEY: {{ randAlphaNum 64 | b64enc | quote }} + AES_256_KEY: {{ randAlphaNum 64 | b64enc }} {{- end }} +{{- end }} diff --git a/charts/ctrlplane/tests/ingress_test.yaml b/charts/ctrlplane/tests/ingress_test.yaml new file mode 100644 index 0000000..c0dfc63 --- /dev/null +++ b/charts/ctrlplane/tests/ingress_test.yaml @@ -0,0 +1,199 @@ +suite: ingress +templates: + - templates/ingress.yaml +release: + name: ctrlplane + +tests: + - it: creates an ingress by default + asserts: + - hasDocuments: + count: 1 + - equal: + path: kind + value: Ingress + + - it: does not create an ingress when disabled + set: + ingress: + create: false + asserts: + - hasDocuments: + count: 0 + + - it: strips the https scheme from the fqdn for the host + set: + global: + fqdn: "https://ctrlplane.example.com" + asserts: + - equal: + path: spec.rules[0].host + value: ctrlplane.example.com + + - it: strips the http scheme from the fqdn for the host + set: + global: + fqdn: "http://ctrlplane.example.com" + asserts: + - equal: + path: spec.rules[0].host + value: ctrlplane.example.com + + - it: strips a trailing slash from the fqdn + set: + global: + fqdn: "https://ctrlplane.example.com/" + asserts: + - equal: + path: spec.rules[0].host + value: ctrlplane.example.com + + - it: leaves out the host when fqdn is empty + set: + global: + fqdn: "" + asserts: + - isNull: + path: spec.rules[0].host + + - it: renders a TLS block when fqdn and tls are both set + set: + global: + fqdn: "https://ctrlplane.example.com" + ingress: + tls: + enabled: true + asserts: + - equal: + path: spec.tls[0].hosts[0] + value: ctrlplane.example.com + - equal: + path: spec.tls[0].secretName + value: ctrlplane-tls + + - it: honours a custom TLS secret name + set: + global: + fqdn: "https://ctrlplane.example.com" + ingress: + tls: + enabled: true + secretName: my-custom-tls-cert + asserts: + - equal: + path: spec.tls[0].secretName + value: my-custom-tls-cert + + - it: omits TLS when tls.enabled is false + set: + global: + fqdn: "https://ctrlplane.example.com" + ingress: + tls: + enabled: false + asserts: + - isNull: + path: spec.tls + + - it: omits TLS when fqdn is empty even if tls is enabled + set: + global: + fqdn: "" + ingress: + tls: + enabled: true + asserts: + - isNull: + path: spec.tls + + - it: sets the ingressClassName when a class is provided + set: + ingress: + class: traefik-internal + asserts: + - equal: + path: spec.ingressClassName + value: traefik-internal + + - it: leaves out ingressClassName when class is empty + set: + ingress: + class: "" + asserts: + - isNull: + path: spec.ingressClassName + + - it: applies annotations when provided + set: + ingress: + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + asserts: + - equal: + path: metadata.annotations["cert-manager.io/cluster-issuer"] + value: letsencrypt-prod + - equal: + path: metadata.annotations["nginx.ingress.kubernetes.io/ssl-redirect"] + value: "true" + + - it: applies extra labels when provided + set: + ingress: + labels: + env: production + asserts: + - equal: + path: metadata.labels.env + value: production + + - it: routes / to web on port 3000 + asserts: + - contains: + path: spec.rules[0].http.paths + content: + pathType: Prefix + path: / + backend: + service: + name: ctrlplane-web + port: + number: 3000 + any: true + + - it: routes /api to api on port 8081 + asserts: + - contains: + path: spec.rules[0].http.paths + content: + pathType: Prefix + path: /api + backend: + service: + name: ctrlplane-api + port: + number: 8081 + any: true + + - it: routes /api/v1/resources/proxy to pty-proxy on port 4000 + asserts: + - contains: + path: spec.rules[0].http.paths + content: + pathType: Prefix + path: /api/v1/resources/proxy + backend: + service: + name: ctrlplane-pty-proxy + port: + number: 4000 + any: true + + - it: falls back to web via defaultBackend + asserts: + - equal: + path: spec.defaultBackend.service.name + value: ctrlplane-web + - equal: + path: spec.defaultBackend.service.port.number + value: 3000 diff --git a/charts/ctrlplane/tests/secrets_test.yaml b/charts/ctrlplane/tests/secrets_test.yaml new file mode 100644 index 0000000..0335a45 --- /dev/null +++ b/charts/ctrlplane/tests/secrets_test.yaml @@ -0,0 +1,209 @@ +suite: secrets management +templates: + - templates/secrets.yaml + - charts/api/templates/secrets.yaml + - charts/web/templates/secrets.yaml + - charts/api/templates/deployment.yaml +release: + name: ctrlplane + +tests: + - it: auto-generates the encryption-key secret by default + template: templates/secrets.yaml + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: ctrlplane-encryption-key + - equal: + path: kind + value: Secret + - isNotEmpty: + path: data.AES_256_KEY + + - it: points the api at the auto-generated encryption-key secret + template: charts/api/templates/deployment.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: VARIABLES_AES_256_KEY + valueFrom: + secretKeyRef: + name: ctrlplane-encryption-key + key: AES_256_KEY + any: true + + - it: skips the encryption-key secret when valueFrom is provided + template: templates/secrets.yaml + set: + global: + secrets: + encryptionKey: + valueFrom: + secretKeyRef: + name: my-ext-aes + key: MY_AES + asserts: + - hasDocuments: + count: 0 + + - it: wires the api to an external encryption key via valueFrom + template: charts/api/templates/deployment.yaml + set: + global: + secrets: + encryptionKey: + valueFrom: + secretKeyRef: + name: my-ext-aes + key: MY_AES + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: VARIABLES_AES_256_KEY + valueFrom: + secretKeyRef: + name: my-ext-aes + key: MY_AES + any: true + + - it: auto-generates the api auth secret by default + template: charts/api/templates/secrets.yaml + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: ctrlplane-api + - isNotEmpty: + path: data.AUTH_SECRET + + - it: auto-generates the web auth secret by default + template: charts/web/templates/secrets.yaml + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: ctrlplane-web + - isNotEmpty: + path: data.AUTH_SECRET + + - it: points the api at the auto-generated auth secret + template: charts/api/templates/deployment.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: AUTH_SECRET + valueFrom: + secretKeyRef: + name: ctrlplane-api + key: AUTH_SECRET + any: true + + - it: skips the api auth secret when valueFrom is provided + template: charts/api/templates/secrets.yaml + set: + global: + secrets: + authSecret: + valueFrom: + secretKeyRef: + name: my-ext-auth + key: MY_AUTH + asserts: + - hasDocuments: + count: 0 + + - it: skips the web auth secret when valueFrom is provided + template: charts/web/templates/secrets.yaml + set: + global: + secrets: + authSecret: + valueFrom: + secretKeyRef: + name: my-ext-auth + key: MY_AUTH + asserts: + - hasDocuments: + count: 0 + + - it: wires the api to an external auth secret via valueFrom + template: charts/api/templates/deployment.yaml + set: + global: + secrets: + authSecret: + valueFrom: + secretKeyRef: + name: my-ext-auth + key: MY_AUTH + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: AUTH_SECRET + valueFrom: + secretKeyRef: + name: my-ext-auth + key: MY_AUTH + any: true + + - it: suppresses all secrets when both use valueFrom + template: templates/secrets.yaml + set: + global: + secrets: + authSecret: + valueFrom: + secretKeyRef: + name: ext-auth + key: AUTH + encryptionKey: + valueFrom: + secretKeyRef: + name: ext-aes + key: AES + asserts: + - hasDocuments: + count: 0 + + - it: wires both external secrets into the api at once + template: charts/api/templates/deployment.yaml + set: + global: + secrets: + authSecret: + valueFrom: + secretKeyRef: + name: ext-auth + key: AUTH + encryptionKey: + valueFrom: + secretKeyRef: + name: ext-aes + key: AES + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: VARIABLES_AES_256_KEY + valueFrom: + secretKeyRef: + name: ext-aes + key: AES + any: true + - contains: + path: spec.template.spec.containers[0].env + content: + name: AUTH_SECRET + valueFrom: + secretKeyRef: + name: ext-auth + key: AUTH + any: true diff --git a/charts/ctrlplane/values.yaml b/charts/ctrlplane/values.yaml index e812b67..1e2be32 100644 --- a/charts/ctrlplane/values.yaml +++ b/charts/ctrlplane/values.yaml @@ -5,6 +5,23 @@ global: extraEnv: {} enableNewPolicyEngine: false + # Secret management (propagated to subcharts via global). + # When empty, secrets are auto-generated by the chart. + # To reference externally managed secrets (e.g. ExternalSecrets), use valueFrom. + secrets: + authSecret: "" + # authSecret: + # valueFrom: + # secretKeyRef: + # name: "my-auth-secret" + # key: "AUTH_SECRET" + encryptionKey: "" + # encryptionKey: + # valueFrom: + # secretKeyRef: + # name: "my-encryption-secret" + # key: "AES_256_KEY" + authProviders: google: clientId: "" @@ -111,6 +128,9 @@ ingress: class: "" labels: {} annotations: {} + tls: + enabled: false + secretName: "" # defaults to -tls when empty otel: install: true