diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e800689d7..618d42d07 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2497,7 +2497,6 @@ jobs: run: | args="$(kubectl --context "$CTX" --namespace gameplane-system get deployment gameplane-api \ -o jsonpath='{.spec.template.spec.containers[?(@.name=="api")].args}')" - echo "api args: ${args}" if ! grep -q -- '--db-driver=postgres' <<<"${args}"; then echo "the gameplane-api Deployment does not run with --db-driver=postgres" >&2 exit 1 @@ -2538,6 +2537,8 @@ jobs: GAMEPLANE_E2E_REUSE_CLUSTER=1 \ GAMEPLANE_E2E_CLUSTER="$CLUSTER" \ go test -tags=e2e -timeout 15m -v -parallel 1 -run "$RUN_REGEX" ./... + - name: verify Secret-backed PostgreSQL configuration after upgrade + run: bash deploy/kind/check-postgres-secret.sh "$CTX" - name: dump cluster state on failure (e2e-postgres) if: failure() uses: ./.github/actions/dump-cluster-state diff --git a/changelog/unreleased/887-postgres-secret.md b/changelog/unreleased/887-postgres-secret.md new file mode 100644 index 000000000..5fd41f0e8 --- /dev/null +++ b/changelog/unreleased/887-postgres-secret.md @@ -0,0 +1,5 @@ +# PostgreSQL connection secrets + +The API and `bootstrap-admin` can read the database connection string from an existing +Kubernetes Secret. Set `api.db.existingSecret` and `api.db.secretKey` to keep the DSN out of +Helm values. [PR #887](https://github.com/GameplanePanel/Gameplane/pull/887) diff --git a/charts/gameplane/templates/api.yaml b/charts/gameplane/templates/api.yaml index 2dc4b937f..45810eb43 100644 --- a/charts/gameplane/templates/api.yaml +++ b/charts/gameplane/templates/api.yaml @@ -270,7 +270,9 @@ spec: {{- end }} {{- end }} - --db-driver={{ .Values.api.db.driver }} + {{- if not .Values.api.db.existingSecret }} - --db-dsn={{ .Values.api.db.dsn }} + {{- end }} {{- if .Values.api.oidc.enabled }} - {{ printf "--oidc-issuer=%s" .Values.api.oidc.issuer | quote }} - {{ printf "--oidc-client-id=%s" .Values.api.oidc.clientID | quote }} @@ -385,7 +387,14 @@ spec: - name: GAMEPLANE_DB_DRIVER value: {{ .Values.api.db.driver | quote }} - name: GAMEPLANE_DB_DSN + {{- if .Values.api.db.existingSecret }} + valueFrom: + secretKeyRef: + name: {{ .Values.api.db.existingSecret | quote }} + key: {{ .Values.api.db.secretKey | default "dsn" | quote }} + {{- else }} value: {{ .Values.api.db.dsn | quote }} + {{- end }} {{- if and (not .Values.api.standalone) .Values.clusterOps.enabled }} - name: GAMEPLANE_CLUSTER_OPS value: "true" diff --git a/charts/gameplane/values.yaml b/charts/gameplane/values.yaml index d20745fd9..d6d63db0a 100644 --- a/charts/gameplane/values.yaml +++ b/charts/gameplane/values.yaml @@ -224,10 +224,11 @@ api: db: driver: sqlite # sqlite | postgres dsn: "file:/data/gameplane.db?_pragma=journal_mode(WAL)" - # When driver=postgres [experimental], set dsn to a postgres connection - # string and rebuild the api image with -tags postgres (the published - # image is built without it). Example: - # dsn: "postgres://gameplane:***@postgres/gameplane?sslmode=require" + # An existing Secret in the release namespace takes precedence over dsn. + # For postgres, store the connection string there and use an API image + # built with -tags postgres. Helm only needs the Secret name and key. + existingSecret: "" + secretKey: dsn # Standalone encryption keys use separate storage configured under panelKey. panelKey: diff --git a/deploy/kind/check-postgres-secret.sh b/deploy/kind/check-postgres-secret.sh new file mode 100644 index 000000000..9d5fdd21e --- /dev/null +++ b/deploy/kind/check-postgres-secret.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Runs after bootstrap, login and a populated Helm upgrade in e2e-postgres. +set -euo pipefail +k=(kubectl --context "$1" --namespace gameplane-system) +h=(helm --kube-context "$1" --namespace gameplane-system) +deployment="$("${k[@]}" get deployment gameplane-api -o json)" +api="$(jq -ce '.spec.template.spec.containers[] | select(.name == "api")' <<< "$deployment")" +jq -e ' + (.args | all(startswith("--db-dsn") | not)) and + ([.env[] | select(.name == "GAMEPLANE_DB_DSN")] == + [{"name":"GAMEPLANE_DB_DSN","valueFrom":{"secretKeyRef":{"name":"gameplane-postgres","key":"dsn"}}}]) +' <<< "$api" >/dev/null + +# The password marker belongs only in the separately provisioned Kind fixture. +canary=e2e-postgres-password +pod="$("${k[@]}" get pods -l app.kubernetes.io/name=gameplane-api -o json)" +logs="$("${k[@]}" logs deployment/gameplane-api -c api)" +revisions="$("${h[@]}" history gameplane -o json | jq -r '.[].revision')" +for revision in $revisions; do + values="$("${h[@]}" get values gameplane --revision "$revision" --all)" + manifest="$("${h[@]}" get manifest gameplane --revision "$revision")" + if [[ "$values$manifest" == *"$canary"* ]]; then + echo "PostgreSQL credential found in Helm release revision $revision" >&2 + exit 1 + fi +done +if [[ "$deployment$pod$logs" == *"$canary"* ]]; then + echo "PostgreSQL credential found in API metadata or logs" >&2 + exit 1 +fi + +# Use the installed API image and rendered env selector on disposable pods. +# Break one field at a time without disturbing the healthy API writer. +for field in name key; do + probe="missing-dsn-$field" + jq --arg field "$field" --arg probe "$probe" '{ + apiVersion: "v1", kind: "Pod", metadata: {name: $probe}, + spec: {restartPolicy: "Never", automountServiceAccountToken: false, + containers: [{name: "api", image, imagePullPolicy, args: ["--help"], + securityContext, env: [.env[] | select(.name == "GAMEPLANE_DB_DSN") + | .valueFrom.secretKeyRef[$field] = $probe]}]}}' <<< "$api" | "${k[@]}" create -f - + "${k[@]}" wait --for='jsonpath={.status.containerStatuses[0].state.waiting.reason}=CreateContainerConfigError' \ + "pod/$probe" --timeout=90s + "${k[@]}" delete pod "$probe" --wait=false +done diff --git a/deploy/kind/e2e.sh b/deploy/kind/e2e.sh index cb65e17b9..9b060d9d4 100755 --- a/deploy/kind/e2e.sh +++ b/deploy/kind/e2e.sh @@ -401,9 +401,7 @@ EOF --set networkPolicies.probePorts[0].port=25566) fi if [ "${PROFILE}" = postgres ]; then - dsn="$(kubectl get secret gameplane-postgres --namespace gameplane-system \ - -o jsonpath='{.data.dsn}' | base64 -d)" - DB_SETS=(--set "api.db.driver=postgres" --set-string "api.db.dsn=${dsn}") + DB_SETS=(--set api.db.driver=postgres --set api.db.existingSecret=gameplane-postgres) fi echo "helm upgrade --install gameplane" diff --git a/docs/install.md b/docs/install.md index 68dfff30e..d63613596 100644 --- a/docs/install.md +++ b/docs/install.md @@ -258,6 +258,7 @@ Top-level knobs (see `values.yaml` for the full list): `--set operator.gameDataStorage.storageClassName=fast-nvme` - `api.db.driver` — `sqlite` (default, production-tested) or `postgres` [experimental] (requires an api image built with `-tags postgres`; the published image is built without it). The CI `api (postgres)` job and the PostgreSQL E2E buckets, including `postgres-availability` (`TestUpgrade_PostgresAvailability`), exercise it. - `api.db.dsn` — connection string; SQLite default persists to a PVC +- `api.db.existingSecret` and `api.db.secretKey` — name of a pre-provisioned Secret in the release namespace and the key holding the complete DSN (default key `dsn`). A Secret reference takes precedence over the literal DSN for both the API and `bootstrap-admin`. A missing Secret or key prevents the API container from starting. - `api.storage.existingClaim` — pre-existing PVC for the API's SQLite database (default `""`). PostgreSQL standalone deployments create no local database PVC or mount. The chart annotates `gameplane-api-data` with `helm.sh/resource-policy: keep` so switching to an existing claim preserves the previous PVC. - `api.panelKey.storage.existingClaim` — pre-existing key-only PVC for generated standalone encryption keys; independent of database storage. `api.panelKey.storage.size` and `storageClassName` configure a new key PVC when no existing claim is selected. - `api.panelKey.existingSecret` — mount an externally provisioned standalone key read-only instead of a key PVC. `api.panelKey.secretKey` selects the Secret entry (default `panel.key`); `api.panelKey.fileName` selects its mounted filename. Back up the database and matching key separately under different access policies. See [key custody](standalone-panel.md#central-panel-on-kubernetes). @@ -1079,6 +1080,29 @@ a separate feature requiring database-level transaction serialization for audit append/prune and user-management invariants, plus coordinated background work and rate limiting. +### Moving a PostgreSQL DSN into a Secret + +Provision an Opaque Secret in the API release namespace through your secret +manager. Store the complete connection string under the key `dsn`. Apply +these Helm values with the rest of the installation's configuration: + +```yaml +api: + db: + driver: postgres + existingSecret: gameplane-postgres + secretKey: dsn +``` + +When migrating a literal DSN, remove the password-bearing `api.db.dsn` from +your values files and supply the full sanitized configuration with +`helm upgrade --reset-values`. Reusing old values can copy the old credential +into the next release record. Historical Helm revisions retain earlier +credentials. Rotate those credentials and restrict access to retained history. +After changing the Secret contents, restart the API Deployment so serve and +exec subcommands pick up the new environment. Check readiness and a database +login before revoking the previous credential. + ### API database readiness `/healthz` reports process liveness. `/readyz` pings the database with a