Public: anyone can read this; the crew can change it.

Platform app move gap: A read-only switch for the old side of a migration #11720

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11704-platform-app-move-gap-a-read-only-switch → main 3 commits fork

  • Merged. mason/11704-platform-app-move-gap-a-read-only-switch was merged into main as 41318f0.

Review

Sign in to review this change.
1 file changed +55 −2
docs/features/platform-app-move.md +55 −2
@@ -62,13 +62,66 @@ counts. The old copy stays until that has passed.
6262
6363 ## 6. Cut-over (person, quiet hour, the owner says "go")
6464 1. Lower the DNS TTL a day ahead.
65 2. Put the old app read-only; take a last dump; load; compare counts.
65 2. Put the old app read-only (section 7, with its immediate check); take a last dump; load; compare counts.
6666 3. Point the domain at the forge (`app-own-domain.md` has the steps, the
6767 certificate and the revert); switch payment webhooks and live keys.
6868 4. Place one live order.
6969 5. **Way back:** the old DNS records, written down before step 3 (`app-own-domain.md`); restoring
7070 them is the one step. The old app stays up read-only for a week.
7171
72 ## 7. The old side, read-only (person on the old node)
73 The forge cannot reach the old node, so the switch is a procedure at the
74 old app's reverse proxy, not a forge feature. It is used twice: in step 6.2
75 (freeze before the last dump, in a quiet hour) and for the week after
76 cut-over. **The nginx and SQL below were not run** (no nginx or Postgres is
77 reachable from a crew run); try them on a scratch copy first.
78 1. Refuse every method except `GET`, `HEAD` and `OPTIONS` at the proxy,
79 before the request reaches the app, answering `503` with `Retry-After`
80 and a body that names the new address. Refusing there needs no change to
81 the old code and cannot be missed by a route the app forgot. nginx
82 (untested), inside the `server` block:
83
84 error_page 503 @frozen;
85 if ($request_method !~ ^(GET|HEAD|OPTIONS)$) { return 503; }
86 location @frozen {
87 add_header Retry-After 3600 always;
88 default_type text/plain;
89 return 503 "Read-only: moved to https://NEW-ADDRESS\n";
90 }
91
92 `if` with `return` in a `server` block runs in the rewrite phase, before
93 any `location` that would `proxy_pass`, so a refused write never reaches
94 the app. Keep it to `return`; other directives inside `if` are unreliable.
95 Reload (`nginx -t && nginx -s reload`).
96 2. Payment webhooks and background jobs are writes the proxy does not see
97 as users: point the webhooks at the forge (step 6.3) and disable the old
98 cron entries, or the old database still changes.
99 3. Make the database refuse writes too, so a missed path fails loudly.
100 **Postgres only**; for another engine do the equivalent (a read-only
101 user, or a read-only file mode for SQLite). Preferred: revoke write
102 grants from the app's role (`REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON
103 ALL TABLES IN SCHEMA public FROM app_role;`, untested). Alternative:
104 `ALTER ROLE app_role SET default_transaction_read_only = on;`. That
105 setting does not touch sessions already open and the app may override it
106 with `SET`, so afterwards end the open sessions: `SELECT
107 pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename =
108 'app_role' AND pid <> pg_backend_pid();` (untested). Grants hold against
109 an override; the setting does not.
110 4. Check, at once (the freeze, step 6.2): a `POST` to the old address
111 answers `503` with the `Retry-After` header and the new address in the
112 body, a `GET` answers `200`, and the step-3 row counts are equal across
113 two reads a minute apart. Then take the last dump.
114 5. Check, over the week (after cut-over): the same counts read once a day
115 do not move; any change means a write path was missed.
116 6. Sign-ins that write a session or a last-seen row will fail while it is on;
117 say so on the old site's page, or serve it from the new address.
118 7. After the week, with the step-5 backup restored once, switch the old app
119 off. Turning the switch off again (remove the rule, restore the grants,
120 reload) is the way back before that.
121
122 What stays open: nothing in the forge records that the switch is on, nor
123 reminds anyone when the week ends; both are a person's.
124
72125 ## What the forge lacks (each is an issue)
73126 - A database import for an app (`migrate` has no data path): upload a dump, load it, report counts and checksums. (#11697)
74127 - ~~A Postgres app has no home here.~~ Decided, not built: no managed Postgres; port to `bun:sqlite` or keep an external one by `egress` (docs/APPS.md). (#11698)
@@ -76,5 +129,5 @@ counts. The old copy stays until that has passed.
76129 - Per-app encrypted backup with a restore proof (`docs/features/backup-truth.md` covers the forge's own, not an app's). (#11701)
77130 - A guard for rehearsal: data copy and `preview`-scoped sandbox keys exist and are the procedure in step 4; nothing yet refuses a payment name left in scope `all`. (#11702)
78131 - Inbound payment webhooks that survive a preview's changing address. (#11703)
79 - A read-only switch for the old side of a migration. (#11704)
132 - ~~A read-only switch for the old side of a migration.~~ A procedure at the old proxy and database, section 7; the forge cannot reach the old node, so no feature is built. (#11704)
80133 - Importing an app's customer accounts (password-hash formats) without the owner reading them. (#11705)

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs