diff --git a/packaging/helm/openwork-ee/README.md b/packaging/helm/openwork-ee/README.md index 66a2f41b97..3309b5e906 100644 --- a/packaging/helm/openwork-ee/README.md +++ b/packaging/helm/openwork-ee/README.md @@ -334,9 +334,10 @@ git and in rendered manifests), use ESO mode. The chart renders an provider in-cluster: The chart renders `spec.data` — the oldest stable ESO shape, unchanged since -`external-secrets.io/v1beta1` — pulling every `secret.keys.*` entry from -`/` in the provider. The key list is generated from -`secret.keys`, so it can never drift from what the workloads consume: +`external-secrets.io/v1beta1` — pulling keys from `/` in +the provider. Only the three boot-critical keys (`DATABASE_URL`, +`BETTER_AUTH_SECRET`, `DEN_DB_ENCRYPTION_KEY`) are rendered by default; add +more by name via `optionalKeys`: ```yaml secret: @@ -350,19 +351,43 @@ externalSecrets: name: external-secrets kind: ClusterSecretStore refreshInterval: 5m - # Every secret.keys.* value must exist as a JSON property under this trunk, - # e.g. eks/openwork/prod/den/DATABASE_URL. + # The three boot-critical keys must exist under this trunk, e.g. + # eks/openwork/prod/den/DATABASE_URL. pathPrefix: "eks/openwork/prod/den" + # Additional keys to pull, by secret.keys.* name. Only listed keys are + # rendered, so keys you do not list need not exist in the provider. + optionalKeys: + - databaseRedisUrl + - emailFrom + - smtpHost + - smtpPort + - smtpUser + - smtpPass + - smtpSecure ``` -Every property the workloads consume must exist in your provider under -`pathPrefix`, named like `secret.keys.*` values (`DATABASE_URL`, -`BETTER_AUTH_SECRET`, ...) — the chart pulls each key by name and cannot -invent missing ones. `target.deletionPolicy` defaults to `Retain`, so -uninstalling the release keeps the materialized Secret. ESO must be installed -on the destination cluster with a `SecretStore`/`ClusterSecretStore`; the -chart selects `external-secrets.io/v1` or `v1beta1` from cluster capabilities -and fails loudly at sync time if the CRDs are missing. +The three required keys must exist in your provider under `pathPrefix` — a +missing one fails the ExternalSecret loudly. ESO's `remoteRef` has no +"skip-if-missing" field, so optional keys are opt-in: any `secret.keys.*` name +you list under `optionalKeys` must exist in the provider, and keys you omit do +not land in the Secret. Omitted keys are simply absent from the workload +environment, so check each consumer before omitting one. Two illustrative +cases: `DAYTONA_API_KEY` is *required* when `config.provisioner.mode` is +`daytona` — Den API rejects startup without it, so omitting it there breaks +boot, whereas it is safe to omit under the default `stub` provisioner; and +omitted `SMTP_PORT`/`SMTP_SECURE` fall back to the application's own defaults +(`587` / `false`) whenever they are absent, regardless of whether the Secret +carries them. `optionalKeys` +entries are `secret.keys` **property names** (camelCase, e.g. `smtpPass`); the +provider path and the target Secret key use the corresponding **value** +(`SMTP_PASS` by default, overridable via `secret.keys.smtpPass`). So list +`smtpPass` here, ensure `eks/.../SMTP_PASS` (or your overridden value) exists +in the provider, and the Secret key will be `SMTP_PASS`. +`target.deletionPolicy` defaults to `Retain`, so uninstalling the release keeps +the materialized Secret. ESO must be installed on the destination cluster with +a `SecretStore`/`ClusterSecretStore`; the chart selects +`external-secrets.io/v1` or `v1beta1` from cluster capabilities and fails +loudly at sync time if the CRDs are missing. Set optional `DATABASE_REDIS_URL` to enable Den API Redis-backed session and query caching. Set `DAYTONA_API_KEY` when `config.provisioner.mode` is `daytona`. Set `POLAR_ACCESS_TOKEN` when Polar feature gating is enabled. Set `OPENROUTER_MANAGEMENT_API_KEY` when enabling OpenWork Models management. diff --git a/packaging/helm/openwork-ee/templates/_helpers.tpl b/packaging/helm/openwork-ee/templates/_helpers.tpl index e842fb3794..239783af23 100644 --- a/packaging/helm/openwork-ee/templates/_helpers.tpl +++ b/packaging/helm/openwork-ee/templates/_helpers.tpl @@ -89,6 +89,123 @@ app.kubernetes.io/component: {{ .component }} {{- include "openwork-ee.namespace" . | trimAll "\"" -}} {{- end -}} +{{/* + Workload roll trigger for secret content. In inline mode the rendered + secret.yaml hash already changes with secret.values. In externalSecrets mode + secret.yaml renders empty, so hash the inputs that change the Secret ESO + materializes — the resolved key set, pathPrefix, the secretStoreRef, and the + conversion/decoding strategies (which change the decoded bytes). envFrom keys + and values are fixed at pod start, so any of these changing must roll the + workloads. Non-content ExternalSecret fields (refreshInterval, metadataPolicy, + hook annotations, target policies) are deliberately excluded so they do not + cause spurious rolls. existingSecret mode is operator-managed — no chart + values drive its content, so no trigger is possible there. +*/}} +{{- define "openwork-ee.secretChecksum" -}} +{{- if eq .Values.secret.secretsMode "externalSecrets" -}} +{{- $requiredKeys := list "databaseUrl" "betterAuthSecret" "denDbEncryptionKey" -}} +{{- $optionalKeys := .Values.externalSecrets.optionalKeys | default (list) -}} +{{- $resolvedKeys := list -}} +{{- range $name := concat $requiredKeys $optionalKeys | uniq | sortAlpha -}} +{{- $resolvedKeys = append $resolvedKeys (index $.Values.secret.keys $name) -}} +{{- end -}} +{{- $store := .Values.externalSecrets.secretStoreRef | default dict -}} +{{- $input := dict + "keys" ($resolvedKeys | uniq | sortAlpha) + "pathPrefix" (.Values.externalSecrets.pathPrefix | toString | trim | trimSuffix "/") + "secretStoreName" ($store.name | default "") + "secretStoreKind" ($store.kind | default "") + "conversionStrategy" .Values.externalSecrets.conversionStrategy + "decodingStrategy" .Values.externalSecrets.decodingStrategy -}} +{{- $input | toJson | sha256sum -}} +{{- else -}} +{{- include (print $.Template.BasePath "/secret.yaml") . | sha256sum -}} +{{- end -}} +{{- end -}} + +{{/* + Resolved provider key set the workloads expect in the Secret: the three + boot-critical keys plus opted-in optionalKeys. Drives the wait-for-secret + init container. Only meaningful in externalSecrets mode. +*/}} +{{- define "openwork-ee.expectedSecretKeys" -}} +{{- $requiredKeys := list "databaseUrl" "betterAuthSecret" "denDbEncryptionKey" -}} +{{- $optionalKeys := .Values.externalSecrets.optionalKeys | default (list) -}} +{{- $resolved := list -}} +{{- range $name := concat $requiredKeys $optionalKeys | uniq | sortAlpha -}} +{{- $resolved = append $resolved (index $.Values.secret.keys $name) -}} +{{- end -}} +{{- $resolved | uniq | sortAlpha | join " " -}} +{{- end -}} + +{{/* + Init container that blocks until the workload Secret exists and, in + externalSecrets mode, contains the full expected key set. envFrom imports the + keys present at pod start and never refreshes, so a pod that starts before + ESO reconciles a newly-added optional key would hold a stale env until its + next restart. Required keys block indefinitely (the workload cannot boot + without them); the optional remainder is bounded by + externalSecrets.optionalKeyWaitSeconds so a typo'd optional key degrades + (pod starts without it) rather than bricking the Deployment. +*/}} +{{- define "openwork-ee.waitForSecretInitContainer" -}} +- name: wait-for-secret + image: "{{ .Values.migrations.kubectlImage.repository }}:{{ .Values.migrations.kubectlImage.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - sh + - -c + - | + set -u + SECRET="{{ include "openwork-ee.secretNameRaw" . }}" + NS="{{ include "openwork-ee.namespaceRaw" . }}" + until kubectl get secret "$SECRET" -n "$NS" > /dev/null 2>&1; do + echo "waiting for secret $SECRET..." + sleep 3 + done + {{- if eq .Values.secret.secretsMode "externalSecrets" }} + # Wait without bound for the three boot-critical keys. + for key in {{ include "openwork-ee.requiredSecretKeys" . }}; do + until kubectl get secret "$SECRET" -n "$NS" -o jsonpath="{.data.$key}" 2>/dev/null | grep -q .; do + echo "waiting for required key $key in secret $SECRET..." + sleep 3 + done + done + # Bounded wait for the optional remainder, then proceed. Rendered + # directly (not via `default`) so an explicit 0 truly skips the wait — + # `default 60` treats numeric 0 as empty and would force 60. + deadline=$(( $(date +%s) + {{ .Values.externalSecrets.optionalKeyWaitSeconds }} )) + for key in {{ include "openwork-ee.optionalSecretKeys" . }}; do + while ! kubectl get secret "$SECRET" -n "$NS" -o jsonpath="{.data.$key}" 2>/dev/null | grep -q .; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "proceeding without optional key $key (waited {{ .Values.externalSecrets.optionalKeyWaitSeconds }}s)" + break + fi + echo "waiting for optional key $key in secret $SECRET..." + sleep 3 + done + done + {{- end }} +{{- end -}} + +{{/* Required provider key names (env names) in externalSecrets mode. */}} +{{- define "openwork-ee.requiredSecretKeys" -}} +{{- $out := list -}} +{{- range $name := list "databaseUrl" "betterAuthSecret" "denDbEncryptionKey" -}} +{{- $out = append $out (index $.Values.secret.keys $name) -}} +{{- end -}} +{{- $out | join " " -}} +{{- end -}} + +{{/* Opt-in optional provider key names (env names) in externalSecrets mode. */}} +{{- define "openwork-ee.optionalSecretKeys" -}} +{{- $out := list -}} +{{- range $name := .Values.externalSecrets.optionalKeys | default (list) -}} +{{- $out = append $out (index $.Values.secret.keys $name) -}} +{{- end -}} +{{- $out | join " " -}} +{{- end -}} + {{- define "openwork-ee.secretsMode.validate" -}} {{- if not (has .Values.secret.secretsMode (list "inline" "existingSecret" "externalSecrets")) -}} {{- fail "secretsMode must be one of inline, existingSecret, externalSecrets" -}} @@ -160,6 +277,11 @@ external-secrets.io/v1beta1 {{- if not (.Values.externalSecrets.pathPrefix | toString | trim) -}} {{- fail "externalSecrets.pathPrefix is required when secretsMode=externalSecrets" -}} {{- end -}} +{{- range $key := .Values.externalSecrets.optionalKeys | default (list) -}} +{{- if not (hasKey $.Values.secret.keys $key) -}} +{{- fail (printf "externalSecrets.optionalKeys contains %q, which is not a known secret.keys.* name" $key) -}} +{{- end -}} +{{- end -}} {{- end -}} {{- end -}} diff --git a/packaging/helm/openwork-ee/templates/den-api.yaml b/packaging/helm/openwork-ee/templates/den-api.yaml index 3d3e693f00..c4cf247ade 100644 --- a/packaging/helm/openwork-ee/templates/den-api.yaml +++ b/packaging/helm/openwork-ee/templates/den-api.yaml @@ -47,7 +47,7 @@ spec: {{- end }} annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} - checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + checksum/secret: {{ include "openwork-ee.secretChecksum" . }} {{- with .Values.denApi.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} @@ -81,6 +81,11 @@ spec: {{- include "openwork-ee.customCa.volume" . | nindent 8 }} {{- end }} {{- end }} + {{- if ne .Values.secret.secretsMode "inline" }} + serviceAccountName: {{ include "openwork-ee.fullname" . }}-workload + initContainers: + {{- include "openwork-ee.waitForSecretInitContainer" . | nindent 8 }} + {{- end }} containers: - name: den-api image: "{{ .Values.denApi.image.repository }}:{{ default .Values.image.tag .Values.denApi.image.tag }}" diff --git a/packaging/helm/openwork-ee/templates/den-web.yaml b/packaging/helm/openwork-ee/templates/den-web.yaml index 11158f5f34..11ba007167 100644 --- a/packaging/helm/openwork-ee/templates/den-web.yaml +++ b/packaging/helm/openwork-ee/templates/den-web.yaml @@ -47,7 +47,7 @@ spec: {{- end }} annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} - checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + checksum/secret: {{ include "openwork-ee.secretChecksum" . }} {{- with .Values.denWeb.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} @@ -60,6 +60,11 @@ spec: volumes: {{- include "openwork-ee.customCa.volume" . | nindent 8 }} {{- end }} + {{- if ne .Values.secret.secretsMode "inline" }} + serviceAccountName: {{ include "openwork-ee.fullname" . }}-workload + initContainers: + {{- include "openwork-ee.waitForSecretInitContainer" . | nindent 8 }} + {{- end }} containers: - name: den-web image: "{{ .Values.denWeb.image.repository }}:{{ default .Values.image.tag .Values.denWeb.image.tag }}" diff --git a/packaging/helm/openwork-ee/templates/externalsecret.yaml b/packaging/helm/openwork-ee/templates/externalsecret.yaml index caa803c8fd..352f06763d 100644 --- a/packaging/helm/openwork-ee/templates/externalsecret.yaml +++ b/packaging/helm/openwork-ee/templates/externalsecret.yaml @@ -29,9 +29,18 @@ spec: name: {{ include "openwork-ee.secretName" . }} creationPolicy: {{ .Values.externalSecrets.target.creationPolicy | default "Owner" }} deletionPolicy: {{ .Values.externalSecrets.target.deletionPolicy | default "Retain" }} + {{- /* + Render the three boot-critical keys plus whatever optional keys the + operator lists in externalSecrets.optionalKeys. ESO's spec.data remoteRef + has no "skip if missing" field, so optionality is expressed by simply not + emitting a data entry for keys the operator does not have in the provider — + a missing rendered key would fail the whole ExternalSecret. + */}} + {{- $requiredKeys := list "databaseUrl" "betterAuthSecret" "denDbEncryptionKey" }} + {{- $optionalKeys := .Values.externalSecrets.optionalKeys | default (list) }} data: {{- $prefix := .Values.externalSecrets.pathPrefix | toString | trim | trimSuffix "/" }} - {{- range $name := keys .Values.secret.keys | sortAlpha }} + {{- range $name := concat $requiredKeys $optionalKeys | uniq | sortAlpha }} {{- $envKey := index $.Values.secret.keys $name }} - secretKey: {{ $envKey | quote }} remoteRef: diff --git a/packaging/helm/openwork-ee/templates/inference.yaml b/packaging/helm/openwork-ee/templates/inference.yaml index cd9023a93f..0507a887e4 100644 --- a/packaging/helm/openwork-ee/templates/inference.yaml +++ b/packaging/helm/openwork-ee/templates/inference.yaml @@ -48,7 +48,7 @@ spec: {{- end }} annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} - checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + checksum/secret: {{ include "openwork-ee.secretChecksum" . }} {{- with .Values.inference.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} @@ -61,6 +61,11 @@ spec: volumes: {{- include "openwork-ee.customCa.volume" . | nindent 8 }} {{- end }} + {{- if ne .Values.secret.secretsMode "inline" }} + serviceAccountName: {{ include "openwork-ee.fullname" . }}-workload + initContainers: + {{- include "openwork-ee.waitForSecretInitContainer" . | nindent 8 }} + {{- end }} containers: - name: inference image: "{{ .Values.inference.image.repository }}:{{ default .Values.image.tag .Values.inference.image.tag }}" diff --git a/packaging/helm/openwork-ee/templates/migration-job.yaml b/packaging/helm/openwork-ee/templates/migration-job.yaml index bbdd32a92a..77c4bc5750 100644 --- a/packaging/helm/openwork-ee/templates/migration-job.yaml +++ b/packaging/helm/openwork-ee/templates/migration-job.yaml @@ -44,22 +44,13 @@ spec: {{- /* When the Secret is materialized asynchronously (ESO or out-of-band creation), the Job must not fail on a missing Secret before it exists. - Block in an init container until it appears instead of erroring on the - env secretKeyRef. + Block in an init container until it appears (and, in externalSecrets + mode, holds the expected keys) instead of erroring on the env + secretKeyRef. */}} {{- if ne .Values.secret.secretsMode "inline" }} initContainers: - - name: wait-for-secret - image: "{{ .Values.migrations.kubectlImage.repository }}:{{ .Values.migrations.kubectlImage.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - command: - - sh - - -c - - | - until kubectl get secret {{ include "openwork-ee.secretNameRaw" . }} -n {{ include "openwork-ee.namespaceRaw" . }} > /dev/null 2>&1; do - echo "waiting for secret {{ include "openwork-ee.secretNameRaw" . }}..." - sleep 3 - done + {{- include "openwork-ee.waitForSecretInitContainer" . | nindent 8 }} {{- end }} containers: - name: migrate diff --git a/packaging/helm/openwork-ee/templates/workload-rbac.yaml b/packaging/helm/openwork-ee/templates/workload-rbac.yaml new file mode 100644 index 0000000000..6da10372e5 --- /dev/null +++ b/packaging/helm/openwork-ee/templates/workload-rbac.yaml @@ -0,0 +1,44 @@ +{{- /* + ServiceAccount + Role + RoleBinding for the workload Deployments + (den-api, den-web, inference) so their wait-for-secret init containers can + read the workload Secret. Rendered only outside inline mode. Not hook + resources — these must persist for the life of the Deployments. +*/}} +{{- if ne .Values.secret.secretsMode "inline" }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "openwork-ee.fullname" . }}-workload + namespace: {{ include "openwork-ee.namespace" . }} + labels: + {{- include "openwork-ee.labels" . | nindent 4 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "openwork-ee.fullname" . }}-workload + namespace: {{ include "openwork-ee.namespace" . }} + labels: + {{- include "openwork-ee.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: [{{ include "openwork-ee.secretName" . }}] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "openwork-ee.fullname" . }}-workload + namespace: {{ include "openwork-ee.namespace" . }} + labels: + {{- include "openwork-ee.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "openwork-ee.fullname" . }}-workload +subjects: + - kind: ServiceAccount + name: {{ include "openwork-ee.fullname" . }}-workload + namespace: {{ include "openwork-ee.namespace" . }} +{{- end }} diff --git a/packaging/helm/openwork-ee/tests/external-secrets.sh b/packaging/helm/openwork-ee/tests/external-secrets.sh index ed740ba970..02db4245f8 100755 --- a/packaging/helm/openwork-ee/tests/external-secrets.sh +++ b/packaging/helm/openwork-ee/tests/external-secrets.sh @@ -78,19 +78,77 @@ assert_count "$enabled_rendered" 'refreshInterval: "5m"' 1 assert_contains "$enabled_rendered" 'creationPolicy: Owner' assert_contains "$enabled_rendered" 'deletionPolicy: Retain' assert_count "$enabled_rendered" 'dataFrom:' 0 -# spec.data rendered from secret.keys: one entry per key (20 in values.yaml). -assert_count "$enabled_rendered" 'secretKey:' 20 -assert_contains "$enabled_rendered" 'secretKey: "DATABASE_URL' -assert_contains "$enabled_rendered" 'secretKey: "BETTER_AUTH_SECRET' -assert_contains "$enabled_rendered" 'secretKey: "DEN_DB_ENCRYPTION_KEY' -assert_contains "$enabled_rendered" 'secretKey: "DEN_INITIAL_ADMIN_BOOTSTRAP_CODE' +# spec.data renders the three boot-critical keys only by default (ESO remoteRef +# has no skip-if-missing, so optional keys are opt-in via optionalKeys). +assert_count "$enabled_rendered" 'secretKey:' 3 +assert_contains "$enabled_rendered" 'secretKey: "DATABASE_URL"' +assert_contains "$enabled_rendered" 'secretKey: "BETTER_AUTH_SECRET"' +assert_contains "$enabled_rendered" 'secretKey: "DEN_DB_ENCRYPTION_KEY"' +assert_not_contains "$enabled_rendered" 'secretKey: "SMTP_PASS"' +assert_not_contains "$enabled_rendered" 'secretKey: "DAYTONA_API_KEY"' # Remote keys resolve to pathPrefix + env key name. assert_contains "$enabled_rendered" 'key: "eks/openwork/prod/den/DATABASE_URL"' -assert_contains "$enabled_rendered" 'key: "eks/openwork/prod/den/DEN_INITIAL_ADMIN_BOOTSTRAP_CODE"' +assert_contains "$enabled_rendered" 'key: "eks/openwork/prod/den/DEN_DB_ENCRYPTION_KEY"' # Uniform strategies on every entry. -assert_count "$enabled_rendered" 'conversionStrategy: Default' 20 -assert_count "$enabled_rendered" 'decodingStrategy: None' 20 -assert_count "$enabled_rendered" 'metadataPolicy: None' 20 +assert_count "$enabled_rendered" 'conversionStrategy: Default' 3 +assert_count "$enabled_rendered" 'decodingStrategy: None' 3 +assert_count "$enabled_rendered" 'metadataPolicy: None' 3 +# remoteRef.optional does not exist in the ESO CRD; it must never render. +assert_not_contains "$enabled_rendered" 'optional:' + +# optionalKeys pulls additional keys; sorted with the required three. +optional_keys_values="$tmp_dir/optional-keys-values.yaml" +cat > "$optional_keys_values" <<'YAML' +secret: + secretsMode: externalSecrets + create: false +externalSecrets: + secretStoreRef: + name: external-secrets + kind: ClusterSecretStore + pathPrefix: "eks/openwork/prod/den" + optionalKeys: + - smtpPass + - databaseRedisUrl +YAML +optional_keys_rendered="$tmp_dir/optional-keys.yaml" +helm template openwork-ee "$chart_dir" -f "$optional_keys_values" > "$optional_keys_rendered" +assert_count "$optional_keys_rendered" 'secretKey:' 5 +assert_contains "$optional_keys_rendered" 'secretKey: "SMTP_PASS"' +assert_contains "$optional_keys_rendered" 'secretKey: "DATABASE_REDIS_URL"' +assert_contains "$optional_keys_rendered" 'key: "eks/openwork/prod/den/SMTP_PASS"' + +# Adding an optionalKeys entry must roll the workloads: in ESO mode secret.yaml +# renders empty, so checksum/secret hashes the resolved key set (required + +# optionalKeys) to change the pod template when the key set changes. envFrom +# keys are fixed at pod start, so without this the new key never reaches pods. +checksum_annotation() { + grep 'checksum/secret:' "$1" | sort -u +} +if [[ "$(checksum_annotation "$enabled_rendered")" == "$(checksum_annotation "$optional_keys_rendered")" ]]; then + printf 'Expected checksum/secret to change when optionalKeys changes\n' >&2 + exit 1 +fi +# Stable across renders of the same values (no spurious rolls). +enabled_rendered_2="$tmp_dir/enabled-2.yaml" +helm template openwork-ee "$chart_dir" -f "$enabled_values" > "$enabled_rendered_2" +if [[ "$(checksum_annotation "$enabled_rendered")" != "$(checksum_annotation "$enabled_rendered_2")" ]]; then + printf 'Expected checksum/secret to be stable across identical renders\n' >&2 + exit 1 +fi + +# Unknown optionalKeys entries fail fast. +bad_optional_values="$tmp_dir/bad-optional-values.yaml" +cat > "$bad_optional_values" <<'YAML' +secret: + secretsMode: externalSecrets + create: false +externalSecrets: + pathPrefix: trunk + optionalKeys: + - notARealKey +YAML +assert_failure "$bad_optional_values" 'externalSecrets.optionalKeys contains "notARealKey", which is not a known secret.keys.* name' # Target Secret keeps the chart secret name so envFrom/secretKeyRef wiring holds. # 7 name: occurrences: ExternalSecret metadata.name + target.name, envFrom in assert_count "$enabled_rendered" 'name: "openwork-ee-secret"' 7 @@ -99,9 +157,33 @@ assert_count "$enabled_rendered" 'secretKeyRef:' 2 # failing on a missing one, and the ExternalSecret applies before the Job # (hook weight -10 precedes the Job's -5). assert_contains "$enabled_rendered" 'name: wait-for-secret' -assert_contains "$enabled_rendered" 'until kubectl get secret openwork-ee-secret' +# Workloads + migration Job all get the wait initContainer in ESO mode (inference +# is off by default): den-api, den-web, migrate Job. +assert_count "$enabled_rendered" 'name: wait-for-secret' 3 +assert_contains "$enabled_rendered" 'until kubectl get secret "$SECRET" -n "$NS"' +# Workloads run under the dedicated SA so the initContainer can read the Secret. +assert_count "$enabled_rendered" 'serviceAccountName: openwork-ee-workload' 2 assert_contains "$enabled_rendered" 'image: "bitnami/kubectl:1.33.4"' assert_not_contains "$enabled_rendered" 'kubectl:latest' +# Default optional-key wait is 60s. +assert_contains "$enabled_rendered" '+ 60 ))' + +# optionalKeyWaitSeconds: 0 must render 0, not be coerced to the 60s default +# (Helm `default` treats numeric 0 as empty). +zero_wait_values="$tmp_dir/zero-wait-values.yaml" +cat > "$zero_wait_values" <<'YAML' +secret: + secretsMode: externalSecrets + create: false +externalSecrets: + pathPrefix: trunk + optionalKeys: [smtpPass] + optionalKeyWaitSeconds: 0 +YAML +zero_wait_rendered="$tmp_dir/zero-wait.yaml" +helm template openwork-ee "$chart_dir" -f "$zero_wait_values" > "$zero_wait_rendered" +assert_contains "$zero_wait_rendered" '+ 0 ))' +assert_not_contains "$zero_wait_rendered" '+ 60 ))' # Whitespace is trimmed at render, matching validation: a padded store name, # prefix, and existingSecret render trimmed rather than failing or embedding @@ -133,8 +215,12 @@ secret: YAML padded_existing_rendered="$tmp_dir/padded-existing.yaml" helm template openwork-ee "$chart_dir" -f "$padded_existing_values" > "$padded_existing_rendered" -assert_count "$padded_existing_rendered" 'name: padded-secret' 5 +assert_count "$padded_existing_rendered" 'name: "padded-secret"' 5 assert_not_contains "$padded_existing_rendered" ' padded-secret' + +# Hook ordering for the migration chain: Namespace (-11) -> ExternalSecret +# (-10) -> migration RBAC (-6) -> migration Job (-5). +assert_count "$enabled_rendered" 'helm.sh/hook-weight": "-11"' 1 assert_count "$enabled_rendered" 'helm.sh/hook-weight": "-10"' 1 assert_count "$enabled_rendered" 'helm.sh/hook-weight": "-6"' 3 assert_count "$enabled_rendered" 'helm.sh/hook-weight": "-5"' 1 @@ -184,7 +270,7 @@ externalSecrets: YAML strategy_rendered="$tmp_dir/strategy.yaml" helm template openwork-ee "$chart_dir" -f "$strategy_values" > "$strategy_rendered" -assert_count "$strategy_rendered" 'decodingStrategy: Base64' 20 +assert_count "$strategy_rendered" 'decodingStrategy: Base64' 3 assert_contains "$strategy_rendered" 'deletionPolicy: Delete' # Capability-aware apiVersion selection: v1 served -> v1 rendered. @@ -392,6 +478,6 @@ YAML helm template openwork-ee "$chart_dir" -f "$tmp_dir/existing-values.yaml" > "$existing_rendered" assert_count "$existing_rendered" 'kind: Secret' 0 assert_count "$existing_rendered" 'kind: ExternalSecret' 0 -assert_count "$existing_rendered" 'name: manually-managed' 5 +assert_count "$existing_rendered" 'name: "manually-managed"' 5 printf 'external-secrets chart checks passed\n' diff --git a/packaging/helm/openwork-ee/values.yaml b/packaging/helm/openwork-ee/values.yaml index 95f6c4c997..60ab1554ed 100644 --- a/packaging/helm/openwork-ee/values.yaml +++ b/packaging/helm/openwork-ee/values.yaml @@ -243,9 +243,11 @@ secret: # materializes the workload Secret from the provider. Requires ESO and a # SecretStore/ClusterSecretStore on the destination cluster. externalSecrets: - # ATTENTION: every secret.keys.* value the workloads consume must exist as - # a JSON property in your provider under pathPrefix — the chart pulls each - # key by name from / and cannot invent missing ones. + # ATTENTION: the three boot-critical keys (DATABASE_URL, BETTER_AUTH_SECRET, + # DEN_DB_ENCRYPTION_KEY) and every key you list under optionalKeys must exist + # in your provider under pathPrefix — the chart pulls each rendered key by + # name from / and a missing rendered key fails the + # ExternalSecret. Keys you neither need nor list are not pulled. # How often ESO re-syncs the Secret from the provider. refreshInterval: 1h # Store holding the remote keys. For AWS Secrets Manager the store's @@ -263,13 +265,29 @@ externalSecrets: creationPolicy: Owner # Retain keeps the Secret if the ExternalSecret is deleted. deletionPolicy: Retain - # Remote path trunk: every secret.keys.* value (DATABASE_URL, - # BETTER_AUTH_SECRET, ...) is pulled from /. The - # chart renders spec.data from secret.keys, so the key list here can never - # drift from what the workloads consume. + # Remote path trunk: keys are pulled from /. The chart + # renders spec.data for the three boot-critical keys (DATABASE_URL, + # BETTER_AUTH_SECRET, DEN_DB_ENCRYPTION_KEY) plus the entries explicitly + # selected in optionalKeys. The optional list is opt-in and maintained by + # hand — if a future feature starts consuming a new secret key, add its + # secret.keys.* name to optionalKeys and create the provider entry. # REQUIRED when secretsMode: externalSecrets (render fails if blank). # Example (AWS Secrets Manager): pathPrefix: "eks/openwork/prod/den" pathPrefix: "" + # Optional keys to also pull from the provider. Entries are secret.keys + # property names (camelCase, e.g. smtpPass, databaseRedisUrl); the provider + # path and target Secret key use the corresponding value (SMTP_PASS). + # Only listed keys get a spec.data entry — ESO's remoteRef has no "skip if + # missing", so keys you do not list here need not exist in the provider and + # simply do not land in the Secret. + optionalKeys: [] + # Seconds the workload init container waits for an optional key to appear in + # the materialized Secret before starting without it. envFrom snapshots keys + # at pod start, so a pod that boots before ESO reconciles a newly added + # optional key would otherwise hold a stale environment until its next + # restart. Required keys always block indefinitely. Set 0 to skip waiting + # for optional keys entirely. + optionalKeyWaitSeconds: 60 # Strategy fields applied uniformly to every pulled key (ESO defaults, # matching the external-secrets.io/v1 schema). conversionStrategy: Default