ReviewOS

also looking at this

stacks/ts-cloud

fix(fleet): carry the on-box database when a site moves

#178
Merged chrisbbreuer wants to merge feat/site-move-database into main
4 files +295 -0
packages/ts-cloud/src/operations/site-move.test.tsmodified+98-0
Changes to packages/ts-cloud/src/operations/site-move.test.ts
@@ -285,3 +285,101 @@ describe('remote scripts', () => {
285285 expect(siteMoveArchivePath('hq', 'bughq')).toBe('/tmp/ts-cloud-move-hq-bughq.tar.gz')
286286 })
287287})
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 */
296describe('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})