Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 67 additions & 38 deletions .github/workflows/ops-set-user-role.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
# Manually-triggered ops helper: sets a user's role directly in the prod DB.
# Exists because the API refuses to touch Owner rows (PUT /users/:id/role →
# "The Owner role cannot be changed"), so demoting a duplicate Owner can only
# happen at the SQL level. Runs psql inside the CNPG primary using the same
# KUBECONFIG_B64 the deploy job uses. Guards: the target must exist, and the
# last remaining Owner can never be demoted. Inputs travel through env vars
# and psql variables (set_config) — never string-interpolated into SQL.
# happen at the SQL level. Guards: the target must exist, and the last
# remaining Owner can never be demoted. Inputs travel through env vars and
# psql variables (\getenv + set_config) — never string-interpolated into SQL.
#
# The DB is reached from an ephemeral psql client pod that reads DATABASE_URL
# from the ranch-api-env secret via secretKeyRef — the exact connection string
# the running API uses; it never leaves the cluster and is never printed
# (only its host/db part, with credentials stripped, is echoed for the log).
# Run 31169336292's diagnostics showed the platform namespace has NO CNPG
# cluster despite the k8s/deploy + k8s/infrastructure manifests (the known
# ArgoCD sync gap), so discovering a DB pod or Cluster CR is a dead end —
# dialing the API's own DATABASE_URL works wherever the database lives.
#
# NOTE: the role claim lives in the JWT (RolesGuard reads the token, not the
# DB), so a token issued before this run keeps its old role until it expires
Expand Down Expand Up @@ -52,46 +60,60 @@ jobs:
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config

- name: Locate CNPG primary and database name
# TARGET_ID / NEW_ROLE are safe to embed in the manifest: both are
# validated above (strict charset / fixed choice list). DATABASE_URL is
# injected by Kubernetes from the secret — it never reaches the runner.
- name: Launch psql client pod
run: |
# The CNPG Cluster CR records its primary in status.currentPrimary —
# authoritative and stable across operator versions, unlike the pod
# labels (older CNPG used postgresql=<name>/role=primary instead of
# the cnpg.io/* labels, which is how the first version of this step
# came up empty). Errors are deliberately NOT suppressed: if
# discovery fails, the log must show why.
PRIMARY=$(kubectl -n platform get clusters.postgresql.cnpg.io ranch-db \
-o jsonpath='{.status.currentPrimary}' || true)
if [ -z "$PRIMARY" ]; then
echo "cluster CR gave no currentPrimary, falling back to pod names"
PRIMARY=$(kubectl -n platform get pods -o name \
| grep -E '^pod/ranch-db-[0-9]+$' | head -1 | cut -d/ -f2 || true)
fi
if [ -z "$PRIMARY" ]; then
echo "--- clusters.postgresql.cnpg.io in platform ---"
kubectl -n platform get clusters.postgresql.cnpg.io 2>&1 || true
echo "--- pods in platform (with labels) ---"
kubectl -n platform get pods --show-labels 2>&1 | head -40 || true
echo "::error::no primary pod found for cluster ranch-db — see diagnostics above"
POD="ops-psql-${{ github.run_id }}"
echo "POD=$POD" >> "$GITHUB_ENV"
kubectl -n platform apply -f - <<MANIFEST
apiVersion: v1
kind: Pod
metadata:
name: $POD
labels:
app: ops-psql
spec:
restartPolicy: Never
activeDeadlineSeconds: 600
containers:
- name: psql
image: postgres:16-alpine
command: ["sleep", "300"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: ranch-api-env
key: DATABASE_URL
- name: TARGET_UID
value: "$TARGET_ID"
- name: TARGET_ROLE
value: "$NEW_ROLE"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
MANIFEST
if ! kubectl -n platform wait --for=condition=Ready "pod/$POD" --timeout=3m; then
kubectl -n platform describe pod "$POD" | tail -25
echo "::error::psql client pod failed to start — see describe output above"
exit 1
fi
# DB name comes from the API's own DATABASE_URL so we always hit the
# database the API actually uses. The URL itself is never printed.
DBNAME=$(kubectl -n platform get secret ranch-api-env \
-o jsonpath='{.data.DATABASE_URL}' \
| base64 -d | sed -E 's#^.*/([^/?]+)(\?.*)?$#\1#' || true)
DBNAME=${DBNAME:-ranch}
echo "primary pod: $PRIMARY"
echo "database: $DBNAME"
echo "PRIMARY=$PRIMARY" >> "$GITHUB_ENV"
echo "DBNAME=$DBNAME" >> "$GITHUB_ENV"

- name: Set role (guarded)
run: |
kubectl -n platform exec -i "$PRIMARY" -- \
psql -U postgres -d "$DBNAME" \
-v ON_ERROR_STOP=1 -v uid="$TARGET_ID" -v newrole="$NEW_ROLE" \
-f - <<'SQL'
kubectl -n platform exec -i "$POD" -- sh -c '
case "$DATABASE_URL" in
*@*) echo "db target: ${DATABASE_URL#*@}" ;;
esac
exec psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f -
' <<'SQL'
\getenv uid TARGET_UID
\getenv newrole TARGET_ROLE
SELECT set_config('ops.uid', :'uid', false);
SELECT set_config('ops.newrole', :'newrole', false);

Expand Down Expand Up @@ -127,3 +149,10 @@ jobs:
\echo '--- owners after ---'
SELECT id, email, role FROM "User" WHERE role = 'Owner' ORDER BY "createdAt";
SQL

- name: Cleanup client pod
if: always()
run: |
if [ -n "$POD" ]; then
kubectl -n platform delete pod "$POD" --ignore-not-found --wait=false || true
fi
Loading