diff --git a/Chart.yaml b/Chart.yaml index 867cfee..fa9e103 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: pgdog-control description: PgDog Control type: application -version: 0.3.1 +version: 0.3.2 appVersion: "f477677f" diff --git a/README.md b/README.md index 1f169b4..c1860a4 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Pods start in parallel and update one at a time. | Option | Description | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `raft.enabled` | Enable the three-member Raft StatefulSet (default `false`). | -| `raft.token` | Shared peer token. Empty generates a token stored in `-raft` Secret and reused by Helm on upgrades. The token is also written to `control.toml` in the ConfigMap. For offline/GitOps rendering, supply a stable token explicitly (default `""`). | +| `raft.token` | Shared peer token. Empty generates a token stored in the `-raft` Secret and reused by Helm on upgrades, or reads it from external-secrets when `raft.externalSecrets.enabled` is set. The token is also written to `control.toml` in the ConfigMap. For offline/GitOps rendering, supply a stable token explicitly (default `""`). | | `raft.cluster_name` | Raft cluster name. Empty defaults to the Helm release name (default `""`). | | `raft.sequence_cache_size` | Positive number of sequence values reserved per Raft write (default `1000`). | | `raft.persistence.size` | Storage requested by each of the three PVCs (default `1Gi`). | @@ -115,6 +115,64 @@ Raft configuration is omitted when disabled, preserving the existing Deployment. Switching an existing release to Raft replaces its control workload and can interrupt service while the new pods and volumes start. +#### Sourcing the peer token from external-secrets + +Instead of the chart's generate-and-reuse token (see above), you can source it +from a Secret managed by the [external-secrets](https://external-secrets.io) +operator. The Secret must carry the token under a `token` key. + +**Option 1: Create the ExternalSecret with the chart** + +```yaml +raft: + enabled: true + externalSecrets: + enabled: true + create: true + secretStoreRef: + name: aws-secrets-manager + kind: SecretStore + remoteRefs: + - secretKey: token + remoteRef: + key: pgdog/raft + property: token +``` + +**Option 2: Use an existing ExternalSecret** + +```yaml +raft: + enabled: true + externalSecrets: + enabled: true + create: false + secretName: "my-secret" # Name of Secret you created/manage +``` + +In both cases, leave `raft.token` unset — the chart looks up the `token` key of +the target Secret (`raft.externalSecrets.secretName`, default `-raft`) +at render time and inlines it into `control.toml`, the same way it does for its +own generated token. The chart stops rendering its own `-raft` Secret +so it doesn't fight the ExternalSecret for ownership. + +Because the value is resolved by a `lookup`, a render that can't reach the +cluster — plain `helm template` — falls back to a freshly generated token, and +so does the first `helm install` if the operator hasn't synced the Secret yet. +The three members always agree, since they share one ConfigMap; the next +`helm upgrade` picks up the synced token and rolls the pods through +`checksum/config`. + +| Option | Description | +|-|-| +| `raft.externalSecrets.enabled` | Source the peer token from a Secret instead of the chart's generated one (bool, default `false`). | +| `raft.externalSecrets.create` | Render an `ExternalSecret` resource (bool, default `true`). Set to `false` to reference one you manage yourself. | +| `raft.externalSecrets.name` | Name of the `ExternalSecret` resource (only used when `create: true`; defaults to `-raft`). | +| `raft.externalSecrets.secretName` | Name of the target `Secret` populated by the `ExternalSecret`, expected to contain a `token` key (defaults to `-raft`). | +| `raft.externalSecrets.refreshInterval` | How often the operator resyncs from the external store (only used when `create: true`; default `1h`). | +| `raft.externalSecrets.secretStoreRef` | `{name, kind}` of the `SecretStore`/`ClusterSecretStore` to use (only used when `create: true`). | +| `raft.externalSecrets.remoteRefs` | List of `{secretKey, remoteRef: {key, property}}` entries defining what to fetch. The entry feeding the token must use `secretKey: token` (only used when `create: true`). | + ### Ingress The PgDog control plane has a web dashboard. It can be accessed through the Ingress or HTTPRoute the chart creates. The chart supports 4 presets (called modes): diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index ae10bac..f5e221e 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -56,28 +56,41 @@ the same cluster don't collide. {{- printf "%s-raft" .Release.Name | trunc 63 | trimSuffix "-" -}} {{- end }} +{{/* +Name of the Secret expected to hold the raft `token` key: either the chart's +own generated Secret, or the Secret targeted by raft.externalSecrets +(populated by the chart-managed ExternalSecret, or by one the user manages +themselves). +*/}} +{{- define "pgdog-control.raft.secretName" -}} +{{- default (include "pgdog-control.raft.fullname" .) .Values.raft.externalSecrets.secretName -}} +{{- end }} + {{/* Reuse the installed token on upgrades. Cache a newly generated token in the render context so the Secret, ConfigMap, and pod checksum all agree. */}} {{- define "pgdog-control.raft.token" -}} +{{- $token := "" -}} {{- if .Values.raft.token -}} -{{- if not (regexMatch "^[!-~]+$" .Values.raft.token) -}} -{{- fail "raft.token must contain only non-whitespace printable ASCII characters" -}} -{{- end -}} -{{- .Values.raft.token -}} +{{- $token = .Values.raft.token -}} {{- else -}} {{- if not (hasKey .Values.raft "_generatedToken") -}} -{{- $existing := lookup "v1" "Secret" .Release.Namespace (include "pgdog-control.raft.fullname" .) | default dict -}} +{{- $existing := lookup "v1" "Secret" .Release.Namespace (include "pgdog-control.raft.secretName" .) | default dict -}} {{- $data := $existing.data | default dict -}} -{{- $token := index $data "token" | default "" | b64dec -}} -{{- if not $token -}} -{{- $token = randAlphaNum 64 -}} +{{- $stored := index $data "token" | default "" | b64dec -}} +{{- if not $stored -}} +{{- $stored = randAlphaNum 64 -}} +{{- end -}} +{{- $_ := set .Values.raft "_generatedToken" $stored -}} {{- end -}} -{{- $_ := set .Values.raft "_generatedToken" $token -}} +{{- $token = .Values.raft._generatedToken -}} {{- end -}} -{{- .Values.raft._generatedToken -}} +{{/* Also guards tokens read from a Secret, which often carry a trailing newline. */}} +{{- if not (regexMatch "^[!-~]+$" $token) -}} +{{- fail "raft token must contain only non-whitespace printable ASCII characters" -}} {{- end -}} +{{- $token -}} {{- end }} {{/* diff --git a/templates/raft-externalsecret.yaml b/templates/raft-externalsecret.yaml new file mode 100644 index 0000000..749d8e5 --- /dev/null +++ b/templates/raft-externalsecret.yaml @@ -0,0 +1,31 @@ +{{- if and .Values.raft.enabled .Values.raft.externalSecrets.enabled .Values.raft.externalSecrets.create }} +apiVersion: {{ if .Capabilities.APIVersions.Has "external-secrets.io/v1" }}external-secrets.io/v1{{ else }}external-secrets.io/v1beta1{{ end }} +kind: ExternalSecret +metadata: + name: {{ default (include "pgdog-control.raft.fullname" .) .Values.raft.externalSecrets.name }} + labels: + {{- include "pgdog-control.labels" . | nindent 4 }} +spec: + refreshInterval: {{ .Values.raft.externalSecrets.refreshInterval }} + secretStoreRef: + name: {{ .Values.raft.externalSecrets.secretStoreRef.name }} + kind: {{ .Values.raft.externalSecrets.secretStoreRef.kind }} + target: + name: {{ include "pgdog-control.raft.secretName" . }} + creationPolicy: Owner + {{- if .Values.raft.externalSecrets.remoteRefs }} + data: + {{- range .Values.raft.externalSecrets.remoteRefs }} + - secretKey: {{ .secretKey }} + remoteRef: + key: {{ .remoteRef.key }} + {{- if .remoteRef.property }} + property: {{ .remoteRef.property }} + {{- end }} + {{- end }} + {{- else }} + # Default: fetch the peer token from the external secret store. + # Configure remoteRefs in values.yaml for your specific secret store. + data: [] + {{- end }} +{{- end }} diff --git a/templates/raft-secret.yaml b/templates/raft-secret.yaml index 57f9fe8..24411a3 100644 --- a/templates/raft-secret.yaml +++ b/templates/raft-secret.yaml @@ -1,8 +1,9 @@ -{{- if .Values.raft.enabled }} +{{/* Skip the chart-generated secret when externalSecrets supplies the token instead. */}} +{{- if and .Values.raft.enabled (not .Values.raft.externalSecrets.enabled) }} apiVersion: v1 kind: Secret metadata: - name: {{ include "pgdog-control.raft.fullname" . }} + name: {{ include "pgdog-control.raft.secretName" . }} labels: {{- include "pgdog-control.labels" . | nindent 4 }} type: Opaque diff --git a/test/test.sh b/test/test.sh index d8c3ebe..fedb1f6 100755 --- a/test/test.sh +++ b/test/test.sh @@ -41,5 +41,15 @@ grep -q 'image: "registry.example.com/platform/redis:8-alpine"' <<< "$image_rend grep -q 'imagePullPolicy: Always' <<< "$image_render" grep -q 'name: registry-credentials' <<< "$image_render" +echo "" +echo "==> Verifying raft token sourced from external-secrets..." +raft_es_render=$(helm template test-release "$CHART_DIR" -f "$TEST_DIR/values-raft-external-secrets.yaml") +grep -q '^kind: ExternalSecret$' <<< "$raft_es_render" +grep -q '^ name: test-release-raft$' <<< "$raft_es_render" +if grep -q '^ token: ' <<< "$raft_es_render"; then + echo "chart-generated raft Secret rendered while raft.externalSecrets.enabled=true" >&2 + exit 1 +fi + echo "" echo "==> All chart tests passed!" diff --git a/test/values-raft-external-secrets.yaml b/test/values-raft-external-secrets.yaml new file mode 100644 index 0000000..0832811 --- /dev/null +++ b/test/values-raft-external-secrets.yaml @@ -0,0 +1,13 @@ +raft: + enabled: true + externalSecrets: + enabled: true + create: true + secretStoreRef: + name: aws-secrets-manager + kind: SecretStore + remoteRefs: + - secretKey: token + remoteRef: + key: pgdog/raft + property: token diff --git a/values.yaml b/values.yaml index 4c4e7d2..0cd4406 100644 --- a/values.yaml +++ b/values.yaml @@ -15,8 +15,45 @@ raft: # Run three control pods with stable identities and durable Raft storage. # Prefer separate machines, but allow pods to share a node when needed. enabled: false - # Shared peer token. Empty generates a token retained in a Kubernetes Secret. + # Shared peer token. Empty generates a token retained in a Kubernetes Secret, + # unless raft.externalSecrets below supplies one instead. token: "" + # externalSecrets sources the shared peer token from a Secret managed by the + # external-secrets operator (https://external-secrets.io) instead of the + # chart's own generated one. Leave raft.token unset when using it. + externalSecrets: + # enabled switches token sourcing to the Secret named below, instead of + # the chart-managed generated secret. + enabled: false + # create controls whether the chart renders an ExternalSecret resource. + # Set to false to reference an ExternalSecret you already manage + # yourself (only secretName is then used). + create: true + # name of the ExternalSecret resource to create (only used when + # create: true). Defaults to `-raft`. + name: "" + # secretName is the target Secret populated by the ExternalSecret (or by + # your own ExternalSecret when create: false). Must contain a `token` + # key. Defaults to `-raft`, the same name the chart would + # otherwise use for its own generated secret. + secretName: "" + # refreshInterval defines how often to sync secrets from the external + # source (only used when create: true) + refreshInterval: 1h + # secretStoreRef references the SecretStore to use + # (only used when create: true) + secretStoreRef: + name: "" + kind: SecretStore + # remoteRefs defines the external secret references. The entry feeding + # the peer token must use `secretKey: token` + # (only used when create: true) + remoteRefs: [] + # Example structure: + # - secretKey: token + # remoteRef: + # key: pgdog/raft + # property: token # Empty defaults to the Helm release name. cluster_name: "" sequence_cache_size: 1000