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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11704-platform-app-move-gap-a-read-only-switch → main 3 commits fork
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. | ||
| 62 | 62 | |
| 63 | 63 | ## 6. Cut-over (person, quiet hour, the owner says "go") |
| 64 | 64 | 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. | |
| 66 | 66 | 3. Point the domain at the forge (`app-own-domain.md` has the steps, the |
| 67 | 67 | certificate and the revert); switch payment webhooks and live keys. |
| 68 | 68 | 4. Place one live order. |
| 69 | 69 | 5. **Way back:** the old DNS records, written down before step 3 (`app-own-domain.md`); restoring |
| 70 | 70 | them is the one step. The old app stays up read-only for a week. |
| 71 | 71 | |
| 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 | ||
| 72 | 125 | ## What the forge lacks (each is an issue) |
| 73 | 126 | - A database import for an app (`migrate` has no data path): upload a dump, load it, report counts and checksums. (#11697) |
| 74 | 127 | - ~~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. | ||
| 76 | 129 | - Per-app encrypted backup with a restore proof (`docs/features/backup-truth.md` covers the forge's own, not an app's). (#11701) |
| 77 | 130 | - 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) |
| 78 | 131 | - 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) | |
| 80 | 133 | - Importing an app's customer accounts (password-hash formats) without the owner reading them. (#11705) |
new above
offline