Skip to content

ci(preview): on-demand previews from any branch, teardown, ephemeral backend - #352

Merged
jirhiker merged 7 commits into
stagingfrom
ci/bdms-1173-on-demand-preview-deployments
Aug 21, 2026
Merged

ci(preview): on-demand previews from any branch, teardown, ephemeral backend#352
jirhiker merged 7 commits into
stagingfrom
ci/bdms-1173-on-demand-preview-deployments

Conversation

@jirhiker

@jirhiker jirhiker commented Aug 21, 2026

Copy link
Copy Markdown
Member

Closes BDMS-1173.

Why

Previews were PR-only and always pointed at the staging API. That leaves two gaps: there is no way to demo a branch that has no PR yet, and no way to review a frontend change that depends on unreleased OcotilloAPI work.

What

Split the build/deploy and teardown mechanics into two reusable workflows, then give them three callers.

File Role
.github/workflows/_preview_deploy.yml Reusable: build + deploy frontend, optional backend, authentik registration
.github/workflows/_preview_teardown.yml Reusable: delete services, images, authentik entry — idempotent
.github/workflows/CD_preview.yml PR trigger, now a thin caller
.github/workflows/CD_preview_ondemand.yml workflow_dispatch for any branch
.github/workflows/CD_preview_teardown.yml Manual + branch-delete + nightly sweep
.github/preview/api-service.tmpl.yaml Cloud Run spec for the ephemeral backend
docs/preview-deployments.md How to use all of it

Against the acceptance criteria:

  • Trigger a preview from a non-PR branchgh workflow run CD_preview_ondemand.yml --ref my-branch
  • Teardown removes preview resources — four entry points: PR close, branch deletion, nightly TTL sweep, manual dispatch
  • Backend-capable previews documented and functional-f backend=ephemeral

The backend deployment option

Selected per deploy, defaulting to today's behaviour:

  • backend=staging (default) — ocotillo-api-staging.newmexicowaterdata.org, unchanged.
  • backend=ephemeral — a throwaway API + database for this branch alone. backend_ref picks which OcotilloAPI branch to build, so a frontend branch can preview against unreleased API work.

The ephemeral path is the Cypress job's docker-compose stack re-expressed as one multi-container Cloud Run service. Same docker/app/Dockerfile, same postgis/postgis:17-3.5, deployed as sidecars rather than compose services. Sidecars share a network namespace, so compose's db host simply becomes 127.0.0.1:5432 — no OcotilloAPI changes required.

Three deltas from compose:

  1. Cloud Run has no exec, so docker compose exec app python -m transfers.seed becomes a container command override: wait for postgres, alembic upgrade head, seed, then exec uvicorn.
  2. Postgres lives on an in-memory tmpfs, so the service is pinned to minScale: 1, maxScale: 1 with CPU always allocated. Data is disposable by construction and re-seeds on restart.
  3. Render-time placeholders in the service template are TPL_-prefixed so envsubst cannot freeze a runtime variable — see the second commit, which fixes exactly that bug.

Please look closely at

Ephemeral backends default to AUTHENTIK_DISABLE_AUTHENTICATION=1, and the API is publicly reachable. This is not a shortcut: transfers.seed creates no users and no permission rows, so an enforcing backend locks every user out of its own preview. -f backend_auth=enabled opts out. The tradeoff is called out in the docs, in every job summary, and is worth a second opinion.

Other judgement calls:

  • PR previews deploy with ttl_hours: 0, exempting them from the nightly sweep, because the PR itself owns their lifetime. Only on-demand previews expire on a TTL.
  • PR previews now build refs/pull/N/merge explicitly. That preserves what actions/checkout did implicitly before the refactor, but it is now load-bearing rather than incidental.
  • The branch-name sanitizer is duplicated in both reusables. They must stay byte-identical or teardown computes a different service name than deploy did and silently leaks resources. Comments in both files say so. A shared composite action would be better; it felt like scope creep here.
  • minScale: 1 means an ephemeral preview bills continuously until torn down.

Testing

Exercised end to end against real infrastructure on this PR, not just linted. pull_request runs workflows from the merge ref, so every run below used this branch's own workflow definitions.

Static

  • actionlint 1.7.12 with shellcheck 0.10.0 — clean on all five workflows, including the reusable-workflow wiring and the sweep's fromJSON matrix.
  • Rendered the Cloud Run template with envsubst exactly as the workflow does. This caught the RUN_SEED collision fixed in the second commit.

Staging-backend preview — deployed, served HTTP 200, PR comment posted, Cloud Run labels applied, TTL sentinel resolved to never (torn down when the PR closes), authentik origin registered.

Ephemeral backend — took three runs to get right, and the failures were the useful part:

  1. gcloud run services replace accepted the multi-container spec first time, but the revision never became ready and the job surfaced nothing but the bare gcloud error. Added a failure()-gated log dump; the existing one was stranded in the readiness-probe step, which never runs when the deploy itself fails.
  2. That immediately showed the real cause: postgis came up in ~4s, migrations applied, Seed complete: 5 contacts, 5 locations, 5 things, 5 sensors, 5 samples, 10 observations, then uvicorn died on PYGEOAPI_POSTGRES_PASSWORD must be set. This template had dropped compose's PYGEOAPI_POSTGRES_* block.
  3. With that fixed: /health returns {"status":"ok","db":"ok"}, and /contact, /thing, /location all serve seeded rows. The frontend bundle has the ephemeral API URL baked in, not staging.

Teardown — closed this PR to fire it, then reopened.

Resource Before After
Cloud Run services 2 0
Artifact Registry images 4 + 4 0 + 0
authentik redirect URI 1 Removed 1 preview origin(s) from authentik.
Frontend / API URLs 200 404

The reopen redeployed cleanly with a fresh seed — the new rows carry a later created_at, which is the disposable database behaving as designed.

Not yet exercised: workflow_dispatch and the nightly sweep, because both only register once the file reaches the default branch (staging). The dispatch path calls the same _preview_deploy.yml that the six runs above went through; the sweep's discovery query is the untested part.

A note on cost

An ephemeral backend cannot scale to zero — the instance is the database — so it bills continuously from deploy until teardown. After the sizing commit that is 2 vCPU / 3.5 GiB, roughly $3–4 a day. This PR currently has one running. Dropping the preview-backend label and pushing now reaps it, which is the orphan gap the last commit closes.

🤖 Generated with Claude Code

jirhiker and others added 2 commits August 21, 2026 12:58
…backend

BDMS-1173. Previews were PR-only and always pointed at the staging API, so
there was no way to demo a branch that has no PR yet, and no way to review a
frontend change that depends on unreleased OcotilloAPI work.

Split the build/deploy and teardown mechanics into two reusable workflows and
give them three callers: the existing PR trigger, a new workflow_dispatch that
takes any branch, and a teardown workflow with manual, branch-delete, and
nightly-sweep entry points. Sweeping keys off preview-* Cloud Run labels;
preview-expires=0 exempts PR previews, whose lifetime the PR itself owns.

The ephemeral backend is the Cypress job's docker-compose stack re-expressed as
one multi-container Cloud Run service: the same OcotilloAPI image and the same
postgis image, with the compose "db" host becoming 127.0.0.1 because sidecars
share a network namespace. Cloud Run has no exec, so the seed step Cypress runs
as a follow-up command becomes a command override that migrates, seeds, then
execs uvicorn. Postgres lives on a tmpfs, so the service is pinned to a single
always-warm instance and the data is disposable by construction.

Ephemeral backends default to AUTHENTIK_DISABLE_AUTHENTICATION=1 because
transfers.seed creates no permission rows and an enforcing backend would lock
every user out. That makes them public sandboxes; docs say so in as many words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cript

Rendering the Cloud Run template locally showed `if [ "$RUN_SEED" = "true" ]`
coming out as `if [ "true" = "true" ]`. envsubst substitutes `$VAR` as readily
as `${VAR}`, so putting RUN_SEED on the allowlist to fill the env entry also
rewrote the identically-named reference inside the container startup script.
Behaviour happened to be correct either way, but the script read as if it
consulted the environment at runtime when it no longer did.

Prefix every render-time placeholder with TPL_ so the two namespaces cannot
overlap, and say why in the template header.

Also quote the Secret Manager reads, silence the deliberate SC2016 on the
envsubst allowlist, and correct the docs: this repo's default branch is
staging, not production, which is where the dispatch and schedule triggers
have to land before they register.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

@jirhiker
jirhiker marked this pull request as ready for review August 21, 2026 20:38
The authentik PATCH answers with the full provider object, client_secret
included, and curl was writing that response to stdout. Every preview deploy
has therefore been publishing the OAuth2 provider's client_secret into a run
log readable by anyone with read access to the repo. The provider is a public
client, but client_credentials is among its enabled grant types, so the value
is usable rather than merely cosmetic.

Discard the response body on both the add and the remove path. The GET earlier
in each step already writes to a temp file rather than stdout, so this was the
only leak.

Predates this branch -- the same curl is in the CD_preview.yml being replaced
here -- so the secret is in the logs of past preview runs too and should be
rotated independently of this fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jirhiker jirhiker added the preview-backend Preview deploys a throwaway API + database instead of using staging label Aug 21, 2026
@github-actions

Copy link
Copy Markdown

Empty commit. CD_preview.yml listens on synchronize, not labeled, so the
preview-backend label added to this PR needs a push to take effect.
…o start

A failed "gcloud run services replace" says only that the revision never became
ready on PORT=8000. Which container died, and why, is in Cloud Logging, and
nothing in the job went and got it -- the existing log dump lives in the
readiness-probe step, which never runs when the deploy itself fails.

Pull the revision's logs and condition list on failure. Both containers share
the revision log stream, tagged by container name, so this distinguishes a
postgis sidecar that never opened 5432 from an API container that crashed in
alembic or the seed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ephemeral backend got as far as migrating and seeding, then uvicorn died
importing main:app:

    RuntimeError: PYGEOAPI_POSTGRES_PASSWORD must be set to generate the
    pygeoapi configuration.

create_api_app() calls mount_pygeoapi() unconditionally, so every boot needs
these settings. docker-compose.yml supplies the whole PYGEOAPI_POSTGRES_* block
and this template dropped it when porting the environment across.

Only the password strictly matters -- host, port, db and user each fall back to
their POSTGRES_* equivalent, whereas the password is checked for None with no
fallback. Set all five regardless, so the template reads as a peer of the
compose file rather than something that happens to work.

Also drop MOUNT_PYGEOAPI_IN_API. It is copied from OcotilloAPI's app.yaml and
reads like an opt-out, but no Python in that repo reads it; leaving it in would
imply the mount is switchable when it is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Two cost fixes now that the ephemeral path is proven working.

The API container was provisioned 2 vCPU and 2Gi. The expensive part of startup
-- migrate plus seed -- measured in seconds on the first successful run, so the
headroom was buying nothing. Drop it to 1 vCPU and 1536Mi, taking the service
from 3 vCPU / 4Gi to 2 vCPU / 3.5Gi across both containers.

The sizing matters more than it looks: the database is the instance, so the
service cannot scale to zero and bills continuously from the moment it is
deployed until teardown.

Second, deploying a preview with backend=staging now deletes any ephemeral API
left over from an earlier ephemeral deploy of the same branch. Dropping the
preview-backend label and pushing used to redeploy the frontend against staging
and silently strand the old always-warm API, with nothing reclaiming it until
the PR closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

@jirhiker

Copy link
Copy Markdown
Member Author

Closing briefly to exercise the teardown workflow (BDMS-1173 acceptance criterion). Reopening immediately after.

@github-actions

Copy link
Copy Markdown

@jirhiker
jirhiker merged commit 55b4e26 into staging Aug 21, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview-backend Preview deploys a throwaway API + database instead of using staging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant