ReviewOS

also looking at this

stacks/ts-cloud

fix(db): name a dump once, so the file written is the file reported

#186
Merged glennmichael123 wants to merge fix/backup-dump-timestamp into main
2 files +37 -2
packages/ts-cloud/src/deploy/dashboard-database.test.tsmodified+27-1
Changes to packages/ts-cloud/src/deploy/dashboard-database.test.ts
@@ -119,7 +119,7 @@ describe('buildListScript + parseDbList', () => {
119119 it('builds a per-database dump script (mysqldump / pg_dump) to a timestamped file', () => {
120120 const my = buildBackupScript('mysql', 'acme').join('\n')
121121 expect(my).toContain('mysqldump --socket=')
122 expect(my).toContain('acme-$(date +%Y%m%d-%H%M%S).sql.gz')
122 expect(my).toContain('acme-$TS_CLOUD_BACKUP_STAMP.sql.gz')
123123 // Local pantry engine: socket (no -h) — TCP loopback demands md5.
124124 const pg = buildBackupScript('postgres', 'acme').join('\n')
125125 expect(pg).toContain('pg_dump -p 5432 -U postgres acme')
@@ -135,6 +135,32 @@ describe('buildListScript + parseDbList', () => {
135135 expect(ext).toContain(`PGPASSWORD='pw' pg_dump -h db.example.com -p 5432 -U admin -w acme`)
136136 })
137137
138 /**
139 * The timestamp has to be taken ONCE. Written inline as `$(date …)` it runs
140 * again in every command that mentions the filename, so a dump crossing a
141 * second boundary writes one file and then reports — and lists — a different
142 * one that does not exist: a backup that succeeds and names a path nobody can
143 * find. Caught on CI by the docker end-to-end test, which is slower than a
144 * laptop and so actually crossed the boundary.
145 */
146 it('names the dump once, so the file it writes is the file it reports', () => {
147 for (const engine of ['postgres', 'mysql', 'mariadb'] as const) {
148 const script = buildBackupScript(engine, 'acme')
149 expect(script.filter(line => line.includes('$(date'))).toHaveLength(1)
150 expect(script.some(line => line.startsWith('TS_CLOUD_BACKUP_STAMP='))).toBe(true)
151
152 // The write, the report and the listing must all name the same thing.
153 const named = script.filter(line => line.includes('acme-'))
154 expect(named).toHaveLength(3)
155 for (const line of named) expect(line).toContain('acme-$TS_CLOUD_BACKUP_STAMP.sql.gz')
156
157 // And the stamp is set before anything uses it.
158 expect(script.findIndex(l => l.startsWith('TS_CLOUD_BACKUP_STAMP=')))
159 .toBeLessThan(script.findIndex(l => l.includes('acme-$TS_CLOUD_BACKUP_STAMP')))
160 }
161 })
162
163
138164 it('parses BACKUP= lines into database + file, deriving the db from the filename', () => {
139165 const parsed = parseBackups(
140166 'BACKUP=/var/backups/ts-cloud/databases/acme-20260702-101500.sql.gz\nnoise\nBACKUP=/var/backups/ts-cloud/databases/blog-20260701-090000.sql.gz',
packages/ts-cloud/src/deploy/dashboard-database.tsmodified+10-1
Changes to packages/ts-cloud/src/deploy/dashboard-database.ts
@@ -440,11 +440,19 @@ export function buildBackupScript(
440440 destDir: string = DB_BACKUP_DIR,
441441 database?: DatabaseConfig,
442442): string[] {
443 const file = `${destDir}/${name}-$(date +%Y%m%d-%H%M%S).sql.gz`
443 // The timestamp is taken ONCE into a variable rather than embedded as a
444 // command substitution. Written inline, `$(date …)` runs again in every
445 // command that mentions the filename — so a dump that happens to cross a
446 // second boundary writes one file and then reports, and lists, a different
447 // one that does not exist. The backup succeeds and names a path nobody can
448 // find, which is the worst way for a backup to fail.
449 const stamp = 'TS_CLOUD_BACKUP_STAMP="$(date +%Y%m%d-%H%M%S)"'
450 const file = `${destDir}/${name}-$TS_CLOUD_BACKUP_STAMP.sql.gz`
444451 const mkdir = `mkdir -p ${destDir}`
445452 if (engine === 'postgres')
446453 return [
447454 mkdir,
455 stamp,
448456 `${pgAdminCommand(database, 'pg_dump')} ${name} | gzip > "${file}"`,
449457 `echo "BACKUP=${file}"`,
450458 `ls -l "${file}"`,
@@ -452,6 +460,7 @@ export function buildBackupScript(
452460 const sock = engine === 'mariadb' ? SOCKETS.mariadb : SOCKETS.mysql
453461 return [
454462 mkdir,
463 stamp,
455464 `mysqldump --socket=${sock} -u root ${name} | gzip > "${file}"`,
456465 `echo "BACKUP=${file}"`,
457466 `ls -l "${file}"`,