Skip to content

Commit aed5978

Browse files
committed
Harden production control-plane deploys
1 parent 28039bf commit aed5978

24 files changed

Lines changed: 2397 additions & 157 deletions

.env.production.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ REND_PLAYER_PLAYBACK_BASE_URL=https://ash-1.play.rend.so
3030
REND_PLAYER_PLAYBACK_COOKIE_DOMAIN=rend.so
3131
REND_API_CORS_ALLOWED_ORIGINS=https://rend.so,https://www.rend.so
3232
REND_API_BIND_ADDR=0.0.0.0:4000
33-
REND_API_AUTO_MIGRATE=true
33+
REND_API_AUTO_MIGRATE=false
3434
REND_API_INLINE_MEDIA_PROCESSING=false
3535
REND_SITE_INTERNAL_TOKEN=replace-me
3636
REND_BILLING_MODE=autumn

.github/workflows/release-deploy.yml

Lines changed: 288 additions & 7 deletions
Large diffs are not rendered by default.

docs/deployment-automation.md

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,68 @@ The GitHub Actions workflow is `.github/workflows/release-deploy.yml`.
1212
3. Write a release manifest with immutable `@sha256:` image refs.
1313
4. Wait on the GitHub `Production` environment approval if that environment is
1414
configured with required reviewers.
15-
5. SSH to the control-plane host and run:
15+
5. SSH to the control-plane host and run a host-side blue/green transaction:
1616
- `scripts/preflight-control-plane-host.sh --skip-bind-port-check`
1717
- `scripts/deploy-control-plane-host.sh --dry-run`
1818
- `scripts/deploy-control-plane-host.sh`
19-
- local `rend-api` `/readyz` and `/healthz`
20-
The deploy helper waits for `rend-api` `/readyz` before starting
21-
`rend-media-worker`.
19+
Before preflight, the deploy wrapper installs the current
20+
`control-plane.compose.yml`, patches an existing concrete Caddyfile to use
21+
the managed upstream snippet, creates that snippet if it is missing, and
22+
leaves any existing upstream target intact. The bootstrap also removes
23+
legacy `admin off` Caddy settings and reloads Caddy while the upstream still
24+
points at the current slot; if that one-time reload fails, it restarts Caddy
25+
before the deploy transaction begins so later promotions can use normal
26+
reloads. The managed upstream snippet is written `0644` so the `caddy`
27+
service user can import it during reload. The env sync step also enforces
28+
`REND_API_AUTO_MIGRATE=false` for production serving containers.
29+
`scripts/deploy-release-over-ssh.sh` launches these control-plane commands
30+
through `sudo systemd-run --wait --collect --pipe`, so the transaction runs
31+
as a host-side transient unit. If the GitHub runner or SSH session dies after
32+
the unit starts, the host can still finish promotion rollback/cleanup. The
33+
deploy helper takes a host lock, runs one-shot `rend-api migrate`, starts the
34+
inactive API slot, probes private `/readyz` and `/healthz`, switches the
35+
managed Caddy upstream snippet, reloads Caddy, and rolls back the snippet if
36+
post-promotion checks fail. The previous API slot remains running.
2237
6. SSH to each configured edge host, serially, and run:
38+
- sync deploy-managed edge env keys from `REND_READINESS_EDGES`:
39+
`REND_EDGE_ID`, `REND_EDGE_REGION`, `REND_EDGE_BASE_URL`,
40+
`REND_EXPECTED_EDGES`, plus `REND_EDGE_CORS_ALLOWED_ORIGINS`
2341
- `scripts/preflight-edge-host.sh --skip-bind-port-check`
2442
- `scripts/deploy-edge-host.sh --dry-run`
2543
- `scripts/deploy-edge-host.sh`
2644
- local `rend-edge` `/readyz` and `/healthz`
27-
7. Check public API `/readyz`.
28-
8. Run the synthetic playback readiness gate when the required readiness secrets
45+
7. Monitor public API `/readyz` during the control-plane deploy, store the
46+
redacted JSONL monitor artifact, and check public API `/readyz`. Each monitor
47+
sample records the attempt count and only fails the workflow after three
48+
short failed attempts, so a single transport reset during Caddy's graceful
49+
reload is visible but not treated as a sustained outage.
50+
8. When `run_first_host_verifier` is enabled, verify the edge registry from the
51+
control-plane host with `scripts/verify-edge-registry-over-ssh.sh`, then run
52+
`scripts/verify-first-host-deploy.sh` against production API and edge targets.
53+
The workflow reuses the edge SSH tunnels for private `/readyz`,
54+
`/internal/warm`, and `/metrics` checks, then verifies public deny rules,
55+
warmed signed playback, telemetry analytics, and ClickHouse reachability.
56+
9. Run the synthetic playback readiness gate when the required readiness secrets
2957
and edge targets are configured.
30-
31-
Preflight skips bind-port checks during automated updates because the existing
32-
service should already own the port. It still validates env files, registry
33-
pullability, image platform, host dependencies, managed dependency reachability,
34-
edge control-plane registration/heartbeat, and telemetry ingest.
58+
10. For an explicit production rollback drill, set
59+
`verify_control_plane_rollback=true` on a manual workflow dispatch. The
60+
workflow switches Caddy back to the previous control-plane slot without a
61+
pull/build, checks public `/readyz`, then re-promotes the current digest and
62+
checks public `/readyz` again.
63+
64+
Control-plane preflight treats bound blue/green ports as expected because the
65+
old slot must keep serving. It still validates env files, Caddy upstream wiring,
66+
registry pullability, image platform, host dependencies, and managed dependency
67+
reachability. Edge preflight still validates edge control-plane
68+
registration/heartbeat and telemetry ingest.
69+
70+
## Host Requirements
71+
72+
Control-plane hosts must have `systemd-run` available to run deploy and rollback
73+
transactions as transient host-side units. If `systemd-run` is missing, the
74+
automated control-plane deploy fails before the transaction starts or traffic is
75+
changed. Edge deploys still run as direct SSH commands because the edge path is
76+
not yet transactional.
3577

3678
## Required GitHub Environment
3779

@@ -99,17 +141,51 @@ The GitHub workflow opens SSH tunnels to the configured `rend-edge-ash-1` and
99141
`rend-edge-ams-1` hosts and rewrites the private bases to runner-local tunnel
100142
URLs for the readiness gate. This keeps `/internal/*` and `/metrics` off the
101143
public edge hostnames while still allowing GitHub-hosted runners to run the
102-
synthetic playback checks.
144+
synthetic playback checks. During edge deploy, the workflow also syncs each
145+
host's edge identity from these entries so registry checks and edge heartbeat
146+
IDs use the same source of truth. The workflow verifies registry rows over SSH
147+
from the control-plane host, using that host's `/etc/rend/rend-api.env`
148+
`DATABASE_URL`; GitHub does not need a separate production Postgres URL for this
149+
automated check.
150+
151+
## Optional Secrets
152+
153+
- `DATABASE_URL`: only needed when running
154+
`scripts/verify-first-host-deploy.sh` registry checks directly from a laptop,
155+
bastion, or custom workflow instead of the host-side SSH registry verifier.
156+
Prefer a secret over a variable.
157+
158+
## Optional Variables
159+
160+
- `REND_VERIFY_ASSET_ID`: existing `hls_ready` synthetic/non-customer asset id
161+
for the deeper warmed-playback path in `scripts/verify-first-host-deploy.sh`.
162+
A manual workflow dispatch can override it with `verify_asset_id`. If omitted,
163+
the workflow runs the verifier with `--skip-playback` and relies on the
164+
synthetic playback readiness gate for upload/playback/telemetry proof.
103165

104166
## Rollback
105167

106168
Use the previous `rend-release-manifest` artifact from a successful workflow
107-
run, then run the same host deploy helper manually in this order:
169+
run for edge rollback. For the control plane, switch back to the previous API
170+
slot without pulling/building:
108171

109172
```sh
110173
scripts/deploy-release-over-ssh.sh --role edge --host <edge-host> --user <user> --manifest <previous-manifest>
111-
scripts/deploy-release-over-ssh.sh --role control-plane --host <control-host> --user <user> --manifest <previous-manifest>
174+
scripts/deploy-release-over-ssh.sh --role control-plane --host <control-host> --user <user> --rollback
112175
```
113176

114-
Roll back edges first, then the media worker/API control plane. Treat database
115-
migrations as forward-only unless a tested rollback migration exists.
177+
Roll back edges first, then the media worker/API control plane. The
178+
control-plane rollback assumes the previous API slot is still running. Treat
179+
database migrations as forward-only unless a tested rollback migration exists.
180+
181+
## Remaining Risk
182+
183+
This automation makes the single control-plane host action-safe at the Docker
184+
service/Caddy-upstream layer, but it does not remove the single-host SPOF:
185+
kernel, VM, disk, network, Docker daemon, and Caddy process failures can still
186+
take the host down. Edge deploys are still in-place per edge host; deploy one
187+
edge at a time and keep the other edge serving, then verify with the playback
188+
readiness gate. A full edge blue/green transaction should add per-edge
189+
blue/green ports, a managed Caddy upstream snippet, candidate private
190+
`/readyz`/`/healthz`, and automatic snippet rollback matching the control-plane
191+
pattern.

docs/deployment-v1.md

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,18 @@ liveness because the worker has no HTTP listener.
321321

322322
## Bootstrap And Migrations
323323

324-
Postgres migrations are applied by `rend-api` through SQLx when
325-
`REND_API_AUTO_MIGRATE=true`. In local Compose, the worker waits for the API and
326-
sets `REND_API_AUTO_MIGRATE=false` to avoid duplicate startup migration work.
327-
For production, deploy or run the API migration step before starting workers.
324+
Postgres migrations are applied through the explicit one-shot
325+
`rend-api migrate` command. Local Compose still sets
326+
`REND_API_AUTO_MIGRATE=true` on the local API for developer convenience and sets
327+
the worker to `false` to avoid duplicate startup migration work. Production
328+
serving API and worker containers must use `REND_API_AUTO_MIGRATE=false`; the
329+
control-plane deploy helper runs the candidate image's `rend-api-migrate`
330+
service before any Caddy traffic promotion.
331+
332+
Treat production Postgres migrations as expand/contract and rollback-hostile
333+
unless a tested rollback migration exists. A failed pre-promotion candidate
334+
keeps the old API slot serving, but an already-applied schema migration is not
335+
automatically reverted.
328336

329337
ClickHouse schema is applied by the local `clickhouse-init` one-shot service on
330338
every Compose startup. The schema uses `CREATE DATABASE IF NOT EXISTS` and
@@ -370,18 +378,20 @@ scripts/preflight-edge-host.sh \
370378
--manifest .rend/releases/production-001.json
371379
```
372380

373-
The control-plane preflight checks Docker/Compose, compose/env files, manifest
374-
digest refs, manifest platform metadata, manifest image pull readiness, pulled
375-
image OS/architecture, managed dependency connectivity where local tools allow
376-
it, and host bind ports. The edge preflight checks Docker/Compose, edge env,
381+
The control-plane preflight checks Docker/Compose, compose/env files, Caddy
382+
upstream template wiring, manifest digest refs, manifest platform metadata,
383+
manifest image pull readiness, pulled image OS/architecture, and managed
384+
dependency connectivity where local tools allow it. The active blue/green API
385+
slot is expected to keep one private port bound, so control-plane preflight does
386+
not require API ports to be free. The edge preflight checks Docker/Compose, edge env,
377387
manifest digest ref, manifest platform metadata, manifest image pull readiness,
378388
pulled image OS/architecture,
379389
private-by-default direct port publishing, uid/gid `10001` cache and spool
380390
writeability, object-store health, control-plane register/heartbeat
381391
reachability, telemetry ingest reachability, and host bind ports.
382392

383-
Use deploy helpers in dry-run mode first to print the exact Compose commands
384-
with manifest image refs:
393+
Use deploy helpers in dry-run mode first to print the exact Compose/Caddy
394+
transaction with manifest image refs:
385395

386396
```sh
387397
scripts/deploy-control-plane-host.sh \
@@ -393,6 +403,44 @@ scripts/deploy-edge-host.sh \
393403
--dry-run
394404
```
395405

406+
The control-plane helper is transactional on the host. It takes
407+
`/var/lock/rend-control-plane-deploy.lock`, records active/previous slot state
408+
under `/var/lib/rend/control-plane`, runs the one-shot migration, recreates only
409+
the inactive API slot (`rend-api-blue` or `rend-api-green`), probes candidate
410+
`/readyz` and `/healthz` directly, then atomically replaces
411+
`/etc/caddy/rend-control-plane-upstream.caddy` and reloads Caddy. If promotion
412+
or post-promotion checks fail, it restores the previous upstream. The previous
413+
API slot remains running after a successful promotion for immediate rollback.
414+
When invoked by `scripts/deploy-release-over-ssh.sh`, the control-plane
415+
transaction runs under `sudo systemd-run --wait --collect --pipe` so rollback can
416+
continue on the host if the GitHub runner or SSH session dies after the unit is
417+
started.
418+
419+
The SSH wrapper also bootstraps production host files before preflight: it
420+
installs the current control-plane Compose template, patches an existing
421+
concrete Caddyfile to use the managed upstream snippet, creates
422+
`/etc/caddy/rend-control-plane-upstream.caddy` only when missing, removes
423+
legacy `admin off` Caddy settings, and preserves an existing upstream target.
424+
The bootstrap reloads Caddy while the upstream still points at the current slot;
425+
if an older running config cannot reload because admin was disabled, it performs
426+
one restart before the transaction starts so later blue/green promotions can use
427+
normal Caddy reloads. The managed upstream snippet must stay `0644`; preflight
428+
fails if the file is root-only because the `caddy` service user imports it on
429+
reload.
430+
431+
The edge helper remains an in-place per-host deploy. For production, deploy
432+
edges serially and keep at least one edge serving while the other updates, then
433+
run the multi-edge verifier/readiness gate. A future edge hardening pass should
434+
mirror the control-plane slot model: `rend-edge-blue`/`rend-edge-green`, private
435+
candidate probes, a managed Caddy upstream snippet, and automatic rollback on
436+
post-promotion failures.
437+
438+
The production workflow derives each edge host's `REND_EDGE_ID`,
439+
`REND_EDGE_REGION`, `REND_EDGE_BASE_URL`, and shared `REND_EXPECTED_EDGES` from
440+
`REND_READINESS_EDGES` before restarting that edge. Keep those entries aligned
441+
with the intended registry IDs; the verifier treats the registry as authoritative
442+
after deploy.
443+
396444
After deploy, verify the first-host path:
397445

398446
```sh
@@ -528,24 +576,46 @@ usage or watch accounting.
528576
5. Run `scripts/validate-production-env.sh` and the relevant preflight script on
529577
each host.
530578
6. Run the deploy helper with `--dry-run`, then run it without `--dry-run`.
531-
7. Deploy `rend-api` with `REND_API_AUTO_MIGRATE=true` for the migration step.
532-
8. Start `rend-api` serving traffic after `/readyz` passes.
579+
7. On the control-plane host, let `scripts/deploy-control-plane-host.sh` run the
580+
candidate image's one-shot `rend-api migrate` service.
581+
8. Let the control-plane helper start the inactive API slot, verify private
582+
`/readyz` and `/healthz`, then promote Caddy to the candidate slot.
533583
9. Start `rend-edge` nodes with unique `REND_EDGE_ID`, `REND_EDGE_REGION`,
534584
API-reachable `REND_EDGE_BASE_URL`, cache volume, and telemetry spool volume.
535-
10. Start `rend-media-worker` with `REND_API_AUTO_MIGRATE=false`.
585+
10. Start or update `rend-media-worker` with `REND_API_AUTO_MIGRATE=false`.
536586
11. Run `scripts/verify-first-host-deploy.sh` with a provided `hls_ready` asset
537587
to confirm edge registration, signed playback, and telemetry analytics.
538588
12. Run `bun run playback:readiness -- --target configured --skip-local-stack`
539589
or pass `--run-readiness-gate` to the verifier before promoting traffic.
540590

591+
The production GitHub workflow runs the first-host verifier when
592+
`run_first_host_verifier=true`. It first verifies edge registry rows from the
593+
control-plane host with `scripts/verify-edge-registry-over-ssh.sh`, using the
594+
host's deployed `/etc/rend/rend-api.env` instead of a separate GitHub
595+
`DATABASE_URL`. It then rewrites private edge targets through SSH tunnels before
596+
running API/edge health, ClickHouse, and public deny checks with
597+
`scripts/verify-first-host-deploy.sh --skip-registration`.
598+
When `REND_VERIFY_ASSET_ID` or `verify_asset_id` points at an existing
599+
synthetic/non-customer `hls_ready` asset, the verifier also runs warmed
600+
playback and analytics checks. If no asset id is configured, the workflow runs
601+
the verifier with `--skip-playback` and relies on the synthetic playback
602+
readiness gate for upload, playback, and telemetry proof.
603+
541604
## Rollback Basics
542605

543606
Roll back services in dependency order from the edge inward:
544607

545608
1. Roll back `rend-edge` first if playback cache behavior regresses.
546609
2. Roll back `rend-media-worker` if artifact generation or warming regresses.
547-
3. Roll back `rend-api` last. Treat Postgres migrations as forward-only unless a
548-
tested rollback migration exists.
610+
3. Roll back `rend-api` last. For the control plane, prefer
611+
`scripts/deploy-control-plane-host.sh --rollback` to switch Caddy back to the
612+
previous slot without pulling or rebuilding. Treat Postgres migrations as
613+
forward-only unless a tested rollback migration exists.
614+
615+
For a production rollback drill in GitHub Actions, run the workflow manually
616+
with `verify_control_plane_rollback=true`. The workflow switches Caddy back to
617+
the previous control-plane slot, verifies public `/readyz`, then deploys the
618+
current digest manifest again to re-promote the candidate slot.
549619

550620
Edge cache can be purged or discarded during rollback. Telemetry spool files can
551621
be retained for replay or deleted if the ingest contract changed incompatibly.
@@ -562,3 +632,14 @@ US East and London edge nodes differ only by environment and attached volumes:
562632
- local telemetry spool volume
563633

564634
The same `rend-edge` image and command run in both regions.
635+
636+
## Residual SPOFs
637+
638+
The blue/green control-plane transaction prevents a failed deploy, failed
639+
candidate, failed Caddy reload, or failed post-promotion check from taking down
640+
the currently serving API process. It does not remove single-host or
641+
single-daemon failure modes. A kernel panic, VM outage, host network loss, disk
642+
failure, Docker daemon failure, or Caddy process failure on the control-plane
643+
host can still cause downtime. The current edge model is resilient only at the
644+
multi-edge operational level; each individual edge host still updates
645+
`rend-edge` in place.

0 commit comments

Comments
 (0)