also looking at this
fix(fleet): carry the on-box database when a site moves
#178
4 files
+295
-0
| @@ -243,6 +243,41 @@ export interface SiteMoveEffects { | ||
| 243 | 243 | refreshTargetGateway: () => Promise<void> |
| 244 | 244 | /** Does the target's gateway already route this site? */ |
| 245 | 245 | targetRoutesSite: () => Promise<boolean> |
| 246 | /** | |
| 247 | * How to carry an ON-BOX database, when the project has one. | |
| 248 | * | |
| 249 | * A SQLite database rides along in the tree already — it lives under | |
| 250 | * `shared/`, which is the whole point of `sharedPaths`. An engine database | |
| 251 | * does not: Postgres and MySQL keep their data in the engine's own directory, | |
| 252 | * which belongs to the box rather than to the site. Moving the tree and not | |
| 253 | * the database would cut DNS over to a target serving an empty app, so the | |
| 254 | * plan REFUSES to be built when a project has one of these and no way to | |
| 255 | * carry it — see {@link SiteMoveOptions.database}. | |
| 256 | * | |
| 257 | * Absent when the database is external (RDS, a managed host): the target | |
| 258 | * connects to the same endpoint the source did, and there is nothing to move. | |
| 259 | */ | |
| 260 | database?: SiteMoveDatabaseEffects | |
| 261 | } | |
| 262 | ||
| 263 | /** Carrying an on-box engine database between boxes, in resumable pieces. */ | |
| 264 | export interface SiteMoveDatabaseEffects { | |
| 265 | /** Dump it on the source. */ | |
| 266 | dump: () => Promise<void> | |
| 267 | /** Carry the dump to the target. */ | |
| 268 | transferDump: () => Promise<void> | |
| 269 | /** Is the dump already staged on the target? Lets the transfer resume. */ | |
| 270 | dumpStaged: () => Promise<boolean> | |
| 271 | /** Create the role + database on the target, then load the dump into it. */ | |
| 272 | restore: () => Promise<void> | |
| 273 | /** | |
| 274 | * Does the target ALREADY hold a database of this name with tables in it? | |
| 275 | * | |
| 276 | * Checked as a precondition, not as a step: loading a dump over someone | |
| 277 | * else's data is the one genuinely destructive thing a move could do, and the | |
| 278 | * answer decides whether the operation may run at all. | |
| 279 | */ | |
| 280 | targetHasData: () => Promise<boolean> | |
| 246 | 281 | } |
| 247 | 282 | |
| 248 | 283 | export interface SiteMoveOptions { |
| @@ -259,6 +294,13 @@ export interface SiteMoveOptions { | ||
| 259 | 294 | port?: number |
| 260 | 295 | /** Health-check path, defaulting to `/`. */ |
| 261 | 296 | healthCheckPath?: string |
| 297 | /** | |
| 298 | * The on-box engine database this site's project owns, when it has one — | |
| 299 | * `resolveAppDatabase(config)` narrowed by `isLocalDatabase`. Naming it here | |
| 300 | * is what makes the move refuse to run without a way to carry it, rather than | |
| 301 | * moving the app and quietly leaving its data behind. | |
| 302 | */ | |
| 303 | database?: { name: string } | |
| 262 | 304 | } |
| 263 | 305 | |
| 264 | 306 | /** |
| @@ -273,6 +315,27 @@ export async function planSiteMove(options: SiteMoveOptions, effects: SiteMoveEf | ||
| 273 | 315 | |
| 274 | 316 | if (from === to) throw new Error(`'${siteName}' is already on ${to}.`) |
| 275 | 317 | |
| 318 | // A project with an on-box engine database and no way to carry it must not | |
| 319 | // move at all. Moving the tree alone would pass every check in this plan — | |
| 320 | // the app starts, answers its health gate, takes the DNS cutover — and serve | |
| 321 | // an empty database to production. Refusing here is the only honest answer. | |
| 322 | if (options.database && !effects.database) | |
| 323 | throw new Error( | |
| 324 | `'${siteName}' uses the on-box database '${options.database.name}', which lives in the engine's own ` | |
| 325 | + 'data directory rather than in the site tree, and this move has no way to carry it. ' | |
| 326 | + 'Moving the site without it would cut traffic over to an empty database.', | |
| 327 | ) | |
| 328 | ||
| 329 | // Loading a dump over data already on the target is the one genuinely | |
| 330 | // destructive thing this operation could do, so it is a precondition something | |
| 331 | // the operator resolves, not a step with a confirmation flag on it. | |
| 332 | if (options.database && effects.database && (await effects.database.targetHasData())) | |
| 333 | throw new Error( | |
| 334 | `${to} already has a database named '${options.database.name}' with tables in it. ` | |
| 335 | + 'Restoring this site\'s dump over it would destroy that data. ' | |
| 336 | + 'Rename or drop it on the target first, or point this project at a different database name.', | |
| 337 | ) | |
| 338 | ||
| 276 | 339 | const steps: OperationStep[] = [ |
| 277 | 340 | { |
| 278 | 341 | id: 'pause-workers', |
| @@ -282,6 +345,20 @@ export async function planSiteMove(options: SiteMoveOptions, effects: SiteMoveEf | ||
| 282 | 345 | await effects.runOnSource(buildPauseWorkersScript(slug, siteName)) |
| 283 | 346 | }, |
| 284 | 347 | }, |
| 348 | ...(effects.database | |
| 349 | ? [ | |
| 350 | { | |
| 351 | id: 'database-dump', | |
| 352 | title: `Dump the '${options.database?.name}' database on ${from}`, | |
| 353 | // Never skipped, for the same reason the tree snapshot is not: a | |
| 354 | // dump from an earlier attempt predates whatever the source has | |
| 355 | // committed since, and shipping stale rows is worse than dumping | |
| 356 | // twice. | |
| 357 | satisfied: async () => false, | |
| 358 | apply: () => effects.database!.dump(), | |
| 359 | } satisfies OperationStep, | |
| 360 | ] | |
| 361 | : []), | |
| 285 | 362 | { |
| 286 | 363 | id: 'snapshot', |
| 287 | 364 | title: `Archive the site's tree and units on ${from}`, |
| @@ -301,6 +378,26 @@ export async function planSiteMove(options: SiteMoveOptions, effects: SiteMoveEf | ||
| 301 | 378 | satisfied: () => effects.archiveStaged(), |
| 302 | 379 | apply: () => effects.transferArchive(), |
| 303 | 380 | }, |
| 381 | ...(effects.database | |
| 382 | ? [ | |
| 383 | { | |
| 384 | id: 'database-transfer', | |
| 385 | title: `Carry the database dump to ${to}`, | |
| 386 | change: { from, to }, | |
| 387 | satisfied: () => effects.database!.dumpStaged(), | |
| 388 | apply: () => effects.database!.transferDump(), | |
| 389 | } satisfies OperationStep, | |
| 390 | { | |
| 391 | id: 'database-restore', | |
| 392 | title: `Create and load '${options.database?.name}' on ${to}`, | |
| 393 | // Before the app starts, so its first request finds its data — | |
| 394 | // and never skipped, because the precondition above already | |
| 395 | // established the target holds nothing this could overwrite. | |
| 396 | satisfied: async () => false, | |
| 397 | apply: () => effects.database!.restore(), | |
| 398 | } satisfies OperationStep, | |
| 399 | ] | |
| 400 | : []), | |
| 304 | 401 | { |
| 305 | 402 | id: 'restore', |
| 306 | 403 | title: `Unpack the site on ${to} and start it`, |