also looking at this
fix(fleet): carry the on-box database when a site moves
#178
4 files
+295
-0
| @@ -285,3 +285,101 @@ describe('remote scripts', () => { | ||
| 285 | 285 | expect(siteMoveArchivePath('hq', 'bughq')).toBe('/tmp/ts-cloud-move-hq-bughq.tar.gz') |
| 286 | 286 | }) |
| 287 | 287 | }) |
| 288 | ||
| 289 | /** | |
| 290 | * A SQLite database rides along in the tree — it lives under `shared/`, which is | |
| 291 | * the whole point of `sharedPaths`. A Postgres or MySQL database does not: it | |
| 292 | * lives in the engine's own data directory, which belongs to the box rather than | |
| 293 | * the site. Moving the tree alone would pass every check in the plan and then | |
| 294 | * serve production an empty database. | |
| 295 | */ | |
| 296 | describe('planSiteMove with an on-box database', () => { | |
| 297 | function dbWorld() { | |
| 298 | const base = world() | |
| 299 | const db = { dumped: false, staged: false, restored: false, targetData: false } | |
| 300 | const effects = { | |
| 301 | ...base.effects, | |
| 302 | database: { | |
| 303 | dump: async () => { db.dumped = true }, | |
| 304 | transferDump: async () => { db.staged = true }, | |
| 305 | dumpStaged: async () => db.staged, | |
| 306 | restore: async () => { db.restored = true }, | |
| 307 | targetHasData: async () => db.targetData, | |
| 308 | }, | |
| 309 | } | |
| 310 | return { state: base.state, db, effects } | |
| 311 | } | |
| 312 | ||
| 313 | const withDb = { ...options, database: { name: 'bughq' } } | |
| 314 | ||
| 315 | it('refuses the move when there is no way to carry the database', async () => { | |
| 316 | await expect(planSiteMove(withDb, world().effects)).rejects.toThrow('no way to carry it') | |
| 317 | }) | |
| 318 | ||
| 319 | /** Loading a dump over data already there is the one destructive thing here. */ | |
| 320 | it('refuses when the target already holds a database of that name', async () => { | |
| 321 | const { db, effects } = dbWorld() | |
| 322 | db.targetData = true | |
| 323 | await expect(planSiteMove(withDb, effects)).rejects.toThrow('already has a database') | |
| 324 | }) | |
| 325 | ||
| 326 | it('carries the database, and loads it before the app starts', async () => { | |
| 327 | const p = await planSiteMove(withDb, dbWorld().effects) | |
| 328 | const ids = p.steps.map(step => step.id) | |
| 329 | expect(ids).toEqual([ | |
| 330 | 'pause-workers', | |
| 331 | 'database-dump', | |
| 332 | 'snapshot', | |
| 333 | 'transfer', | |
| 334 | 'database-transfer', | |
| 335 | 'database-restore', | |
| 336 | 'restore', | |
| 337 | 'health', | |
| 338 | 'gateway', | |
| 339 | 'dns', | |
| 340 | 'drain-source', | |
| 341 | ]) | |
| 342 | // The dump is taken while background work is stopped, and loaded before the | |
| 343 | // app on the target can serve a request against it. | |
| 344 | expect(ids.indexOf('database-dump')).toBeGreaterThan(ids.indexOf('pause-workers')) | |
| 345 | expect(ids.indexOf('database-restore')).toBeLessThan(ids.indexOf('restore')) | |
| 346 | }) | |
| 347 | ||
| 348 | it('moves the data end to end', async () => { | |
| 349 | const { state, db, effects } = dbWorld() | |
| 350 | const p = await planSiteMove(withDb, effects) | |
| 351 | expect((await applyPlan(p, await resolvePlan(p))).success).toBe(true) | |
| 352 | expect(db).toMatchObject({ dumped: true, staged: true, restored: true }) | |
| 353 | expect(state.sourceRunning).toBe(false) | |
| 354 | }) | |
| 355 | ||
| 356 | /** A dump from an earlier attempt predates what the source has committed since. */ | |
| 357 | it('re-dumps on a resume rather than shipping stale rows', async () => { | |
| 358 | const { effects } = dbWorld() | |
| 359 | const p = await planSiteMove(withDb, effects) | |
| 360 | await applyPlan(p, await resolvePlan(p)) | |
| 361 | const again = await resolvePlan(await planSiteMove(withDb, effects)) | |
| 362 | expect(again.find(item => item.step.id === 'database-dump')?.state).toBe('pending') | |
| 363 | // The transfer, though, resumes — the dump is already staged. | |
| 364 | expect(again.find(item => item.step.id === 'database-transfer')?.state).toBe('satisfied') | |
| 365 | }) | |
| 366 | ||
| 367 | /** An external database is reached at the same endpoint from either box. */ | |
| 368 | it('adds no database steps when the project has no on-box database', async () => { | |
| 369 | const p = await planSiteMove(options, world().effects) | |
| 370 | expect(p.steps.map(step => step.id).some(id => id.startsWith('database-'))).toBe(false) | |
| 371 | }) | |
| 372 | ||
| 373 | it('leaves the source serving when the restore fails', async () => { | |
| 374 | const { state, effects } = dbWorld() | |
| 375 | const p = await planSiteMove(withDb, { | |
| 376 | ...effects, | |
| 377 | database: { ...effects.database, restore: async () => { throw new Error('role does not exist') } }, | |
| 378 | }) | |
| 379 | const outcome = await applyPlan(p, await resolvePlan(p)) | |
| 380 | expect(outcome.success).toBe(false) | |
| 381 | expect(outcome.steps.at(-1)?.id).toBe('database-restore') | |
| 382 | expect(state.published).toBe('203.0.113.1') | |
| 383 | expect(state.sourceRunning).toBe(true) | |
| 384 | }) | |
| 385 | }) | |