From d15ca922ec82b38ddc410b3bc2650d079d36767b Mon Sep 17 00:00:00 2001 From: Baylem Date: Tue, 10 Nov 2026 20:43:38 -0500 Subject: [PATCH 1/3] fix(chart): read PostgreSQL connection strings from an existing Secret Signed-off-by: Baylem --- .github/workflows/ci.yaml | 3 +- charts/gameplane/templates/api.yaml | 14 ++++++--- charts/gameplane/values.yaml | 9 +++--- deploy/kind/check-postgres-secret.sh | 45 ++++++++++++++++++++++++++++ deploy/kind/e2e.sh | 4 +-- docs/install.md | 28 +++++++++++++++-- 6 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 deploy/kind/check-postgres-secret.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8ce0fa057..8f25bc91d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2483,7 +2483,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 @@ -2524,6 +2523,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/charts/gameplane/templates/api.yaml b/charts/gameplane/templates/api.yaml index 1ba3e8209..34a80a018 100644 --- a/charts/gameplane/templates/api.yaml +++ b/charts/gameplane/templates/api.yaml @@ -291,7 +291,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 }} @@ -395,14 +397,18 @@ spec: - name: GAMEPLANE_NAMESPACE valueFrom: fieldRef: { fieldPath: metadata.namespace } - # Mirror --db-driver/--db-dsn for subcommands run with - # `kubectl exec deploy/gameplane-api -- /api bootstrap-admin`: - # they don't see the container args, so without these they fall - # back to the SQLite default even on a PostgreSQL install. + # Exec subcommands such as bootstrap-admin inherit this environment. - 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 503c95481..6cf846b9a 100644 --- a/charts/gameplane/values.yaml +++ b/charts/gameplane/values.yaml @@ -205,10 +205,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 0830fbf19..2ac203fa0 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 f0b5767d7..2e4b4d6e2 100644 --- a/docs/install.md +++ b/docs/install.md @@ -176,8 +176,9 @@ Top-level knobs (see `values.yaml` for the full list): GameServer enters Pending with a `PVCProvisioningFailed` condition (visible in the dashboard); no pod starts until resolved. Example: `--set operator.gameDataStorage.storageClassName=fast-nvme` -- `api.db.driver` — `sqlite` (default, production-tested) or `postgres` [experimental] (requires an api image built with `-tags postgres`; not yet covered by e2e or upgrade tests) -- `api.db.dsn` — connection string; SQLite default persists to a PVC +- `api.db.driver` — `sqlite` (default) or `postgres` (experimental). PostgreSQL requires an API image built with `-tags postgres`. +- `api.db.dsn` — literal connection string. The 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). @@ -995,6 +996,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 From 910e6f5a8df2e8b2c8372f8c6a88bbbf8ffee19d Mon Sep 17 00:00:00 2001 From: Baylem Date: Tue, 10 Nov 2026 21:10:50 -0500 Subject: [PATCH 2/3] chore(chart): preserve the independent API identity migration context Signed-off-by: Baylem --- charts/gameplane/templates/api.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/charts/gameplane/templates/api.yaml b/charts/gameplane/templates/api.yaml index 34a80a018..c157ea10d 100644 --- a/charts/gameplane/templates/api.yaml +++ b/charts/gameplane/templates/api.yaml @@ -397,7 +397,10 @@ spec: - name: GAMEPLANE_NAMESPACE valueFrom: fieldRef: { fieldPath: metadata.namespace } - # Exec subcommands such as bootstrap-admin inherit this environment. + # Mirror --db-driver/--db-dsn for subcommands run with + # `kubectl exec deploy/gameplane-api -- /api bootstrap-admin`: + # they don't see the container args, so without these they fall + # back to the SQLite default even on a PostgreSQL install. - name: GAMEPLANE_DB_DRIVER value: {{ .Values.api.db.driver | quote }} - name: GAMEPLANE_DB_DSN From eada629429ec766448ee40010d647a838c73a14a Mon Sep 17 00:00:00 2001 From: Baylem Date: Wed, 11 Nov 2026 16:58:25 -0500 Subject: [PATCH 3/3] docs(postgres): preserve upstream driver and test guidance Signed-off-by: Baylem --- docs/install.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install.md b/docs/install.md index fa372c215..1e52f2d14 100644 --- a/docs/install.md +++ b/docs/install.md @@ -256,8 +256,8 @@ Top-level knobs (see `values.yaml` for the full list): GameServer enters Pending with a `PVCProvisioningFailed` condition (visible in the dashboard); no pod starts until resolved. Example: `--set operator.gameDataStorage.storageClassName=fast-nvme` -- `api.db.driver` — `sqlite` (default) or `postgres` (experimental). PostgreSQL requires an API image built with `-tags postgres`. -- `api.db.dsn` — literal connection string. The SQLite default persists to a PVC. +- `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.