From b712e38c862b9d44e02e43d200a189b5a51f2fbe Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Fri, 5 Jun 2026 18:00:11 -0500 Subject: [PATCH] fix(migrate): use PGHOST/PGUSER/PGPASSWORD instead of URL secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Brief 1+2 validation run failed on the migrate job with `psql: invalid option -- '/'` despite the secret being set correctly and a direct CT-111 → CT-102 psql connection working fine. The URL-parse path in `psql "$PGBASE/postgres"` was the fragile bit. Splitting the connection into discrete `PG*` env vars (which psql picks up automatically) sidesteps URL parsing entirely. The `HOMELAB_CI_POSTGRES_BASE_URL` repo secret is now `HOMELAB_CI_POSTGRES_PASSWORD` — password only — and the workflow hardcodes the (non-sensitive) host/port/user. `node-pg-migrate` still reads `POSTGRES_URL` from `.env.local`, so we assemble that URL inline for it; the runner is ephemeral so the leaked-to-disk password is bounded to one job. Convoy doc updated to reflect the shipped approach + lesson learned in prerequisites. Co-authored-by: Cursor --- .convoys/migrate-ci-to-self-hosted.md | 23 ++++++++++++++--------- .github/workflows/ci.yml | 18 ++++++++++++------ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/.convoys/migrate-ci-to-self-hosted.md b/.convoys/migrate-ci-to-self-hosted.md index 2ed5124..d6c3d2b 100644 --- a/.convoys/migrate-ci-to-self-hosted.md +++ b/.convoys/migrate-ci-to-self-hosted.md @@ -7,7 +7,7 @@ prerequisites: - CT 111 (`ci-runner`) provisioned and online on axiom (`192.168.68.111`) - 2× `axiom-runner-*` registered + Idle in Settings → Actions → Runners - `deckhearth_ci` Postgres user + tracking script wired on CT 102 - - `HOMELAB_CI_POSTGRES_BASE_URL` set as a GitHub Actions repo secret (value: `postgres://deckhearth_ci:@192.168.68.102:5432`) + - `HOMELAB_CI_POSTGRES_PASSWORD` set as a GitHub Actions repo secret (password only — `PGHOST`/`PGUSER`/`PGPORT` are hardcoded in the workflow). Earlier draft of this convoy used a single `HOMELAB_CI_POSTGRES_BASE_URL` URL secret, but during Brief 1+2 validation `psql "$URL/postgres"` failed with `invalid option -- '/'` — the URL-parse path was fragile. Split secret + standard `PG*` env vars sidesteps it. - Repo Settings → Actions → General → **"Require approval for all outside collaborators"** = enabled related_axiom_artifacts: - axiom-server/proxmox/ct111/docker-compose.yml @@ -80,18 +80,21 @@ env: POSTGRES_URL: postgres://postgres:postgres@localhost:5432/deckhearth_test ``` -Proposed: +Shipped (post-validation revision): ```yaml env: - # HOMELAB_CI_POSTGRES_BASE_URL = postgres://deckhearth_ci:@192.168.68.102:5432 - # (no DB name — we create a per-run DB to keep parallel runs isolated) - PGBASE: ${{ secrets.HOMELAB_CI_POSTGRES_BASE_URL }} + PGHOST: 192.168.68.102 + PGPORT: '5432' + PGUSER: deckhearth_ci + PGPASSWORD: ${{ secrets.HOMELAB_CI_POSTGRES_PASSWORD }} DBNAME: ci_run_${{ github.run_id }}_${{ github.run_attempt }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20', cache: npm } + - name: Install postgresql-client + run: sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client - name: Cache node_modules uses: actions/cache@v4 with: @@ -100,14 +103,16 @@ steps: - run: npm ci - name: Create per-run database run: | - psql "$PGBASE/postgres" -c "CREATE DATABASE \"$DBNAME\";" - echo "POSTGRES_URL=$PGBASE/$DBNAME" >> .env.local + psql -d postgres -c "CREATE DATABASE \"$DBNAME\";" + echo "POSTGRES_URL=postgres://$PGUSER:$PGPASSWORD@$PGHOST:$PGPORT/$DBNAME" >> .env.local - run: npm run migrate up - name: Drop per-run database (always) if: always() - run: psql "$PGBASE/postgres" -c "DROP DATABASE IF EXISTS \"$DBNAME\";" + run: psql -d postgres -c "DROP DATABASE IF EXISTS \"$DBNAME\";" ``` +`psql` reads `PG*` env vars automatically so we never need to assemble a connection URL on the psql command line (which was the failure mode in the first validation attempt). `node-pg-migrate` still wants a `POSTGRES_URL`, hence the inline URL written to `.env.local`. The runner is ephemeral so leaking the password into `.env.local` is bounded to that single job. + Why per-run DB: - Two PRs migrating in parallel don't collide. - A failed migration leaves a dirty DB behind — `if: always()` ensures cleanup. @@ -144,7 +149,7 @@ Why per-run DB: |---|---|---|---| | 1 | CT 111 down → PRs queue indefinitely | medium | Beszel alerts on CT 111 down; D5 revert path documented | | 2 | PAT expires silently → new jobs fail registration | medium | Calendar reminder at PAT mint time (90 days); runner logs surface failure on next restart | -| 3 | Malicious PR exfiltrates from runner | low (repo-scoped, "require approval" enabled) | Repo Settings gate; runner has no creds beyond `secrets.HOMELAB_CI_POSTGRES_BASE_URL` (scoped to `deckhearth_ci` schema, no other DB access) | +| 3 | Malicious PR exfiltrates from runner | low (repo-scoped, "require approval" enabled) | Repo Settings gate; runner has no creds beyond `secrets.HOMELAB_CI_POSTGRES_PASSWORD` (scoped to `deckhearth_ci`, CREATEDB but no superuser, no access to other apps' databases) | | 4 | Per-run DB litter on CT 102 if `if: always()` cleanup itself fails | low | Add a weekly cron on CT 102: `psql ... -c "DROP DATABASE IF EXISTS …" FOREACH ci_run_* older than 7d` | | 5 | Cache poisoning across runs (shared `~/.npm` between runner-1 and runner-2) | low | `npm ci` validates against `package-lock.json` checksum; corrupt cache is self-healing | | 6 | Two ephemeral runners insufficient for peak load (5+ jobs per PR) | medium | Add `runner-3:` block in CT 111 compose; ~200 MB RAM per slot | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6071c9e..119fbeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -371,12 +371,18 @@ jobs: runs-on: [self-hosted, axiom] # Postgres lives on CT 102 (axiom homelab). Per-run database keeps parallel # PRs isolated and a `DROP DATABASE` in `always()` keeps the catalog tidy. - # `HOMELAB_CI_POSTGRES_BASE_URL` is a repo secret of the shape - # `postgres://deckhearth_ci:@192.168.68.102:5432` — no DB name. The + # `HOMELAB_CI_POSTGRES_PASSWORD` is a repo secret — password only. The # `deckhearth_ci` Postgres role has CREATEDB but no superuser; a # compromised runner can't reach other apps' databases. + # + # psql picks PGHOST/PGUSER/PGPASSWORD/PGPORT up automatically, so we + # don't build a URL and we sidestep any URL-parsing quirks around + # special chars in the connection string. env: - PGBASE: ${{ secrets.HOMELAB_CI_POSTGRES_BASE_URL }} + PGHOST: 192.168.68.102 + PGPORT: '5432' + PGUSER: deckhearth_ci + PGPASSWORD: ${{ secrets.HOMELAB_CI_POSTGRES_PASSWORD }} DBNAME: ci_run_${{ github.run_id }}_${{ github.run_attempt }} steps: - uses: actions/checkout@v4 @@ -400,12 +406,12 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit != 'true' - name: Create per-run database run: | - psql "$PGBASE/postgres" -c "CREATE DATABASE \"$DBNAME\";" - echo "POSTGRES_URL=$PGBASE/$DBNAME" >> .env.local + psql -d postgres -c "CREATE DATABASE \"$DBNAME\";" + echo "POSTGRES_URL=postgres://$PGUSER:$PGPASSWORD@$PGHOST:$PGPORT/$DBNAME" >> .env.local - run: npm run migrate up - name: Drop per-run database if: always() - run: psql "$PGBASE/postgres" -c "DROP DATABASE IF EXISTS \"$DBNAME\";" + run: psql -d postgres -c "DROP DATABASE IF EXISTS \"$DBNAME\";" test: name: Unit tests (vitest)