Skip to content

Commit 0db94f4

Browse files
jouwdanclaude
andauthored
fix(cli): claim the backup destination before dumping, not after (#273)
backupCommand ran pg_dump and staged the uploads before it first tested whether --out could be written at all. MEI-104 made that failure legible; it was still late. An operator following the upgrade runbook — where the next instruction destroys the pgdata volume — waited out a full production dump to learn their path was wrong, which invites re-running under sudo (producing the root-owned bundle MEI-104 fixed) or skipping the backup. Claiming the destination first also makes two backups aimed at one path safe: the second is refused immediately rather than dumping and then losing the race. The cost is a wider window in which a killed run leaves an empty file at the claimed path, so EEXIST stops being a raw errno from open(…, 'wx') and says what is there and what to do about it. reserveBackupDestination keeps its untranslated contract; the translation sits in claimBackupDestination beside MEI-104's, and a refusal never touches the occupying file. Both documents that described the failure as arriving after the dump now describe it as arriving before one. Claude-Session: https://claude.ai/code/session_01GVdrZfcwVhvJpZWUKWxYs9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent befdd97 commit 0db94f4

4 files changed

Lines changed: 45 additions & 8 deletions

File tree

apps/cli/src/backup.test.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'
1+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
22
import { tmpdir } from 'node:os'
33
import path from 'node:path'
44

@@ -257,12 +257,37 @@ describe('claimBackupDestination', () => {
257257
}
258258
})
259259

260-
it('lets a non-permission failure through undisguised — an existing bundle is still EEXIST', async () => {
260+
it('refuses an occupied path with the remedy, not a bare errno (MEI-149)', async () => {
261261
const dir = await mkdtemp(path.join(tmpdir(), 'meith-backup-test-'))
262262
try {
263263
const destination = path.join(dir, 'board.tar.gz')
264264
await claimBackupDestination(destination)
265-
await expect(claimBackupDestination(destination)).rejects.toMatchObject({ code: 'EEXIST' })
265+
266+
let caught: unknown
267+
try {
268+
await claimBackupDestination(destination)
269+
} catch (error) {
270+
caught = error
271+
}
272+
273+
const message = (caught as Error).message
274+
expect(message).toContain(destination)
275+
expect(message).toContain('something is already there')
276+
expect(message).toContain('--out')
277+
expect(message).not.toContain('EEXIST')
278+
} finally {
279+
await rm(dir, { recursive: true, force: true })
280+
}
281+
})
282+
283+
it("leaves the occupying file alone — refusing is not deleting somebody's bundle", async () => {
284+
const dir = await mkdtemp(path.join(tmpdir(), 'meith-backup-test-'))
285+
try {
286+
const destination = path.join(dir, 'board.tar.gz')
287+
await writeFile(destination, 'an earlier bundle')
288+
289+
await expect(claimBackupDestination(destination)).rejects.toThrow()
290+
expect(await readFile(destination, 'utf8')).toBe('an earlier bundle')
266291
} finally {
267292
await rm(dir, { recursive: true, force: true })
268293
}

apps/cli/src/backup.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,14 @@ export async function claimBackupDestination(destination: string): Promise<void>
387387
try {
388388
await reserveBackupDestination(destination)
389389
} catch (error) {
390+
if ((error as NodeJS.ErrnoException | undefined)?.code === 'EEXIST') {
391+
throw new ValidationError(
392+
`backup will not write over ${destination}: something is already there. Move it aside ` +
393+
'or pass a different --out. A previous run killed part-way through can leave an ' +
394+
'empty or truncated bundle at the path it had claimed; that file is not a backup ' +
395+
'and is safe to delete.',
396+
)
397+
}
390398
translateWriteError(error, {
391399
command: 'backup',
392400
path: destination,
@@ -535,6 +543,9 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
535543
const stage = await mkdtemp(path.join(tmpdir(), 'meith-backup-'))
536544
let destinationCreated = false
537545
try {
546+
await claimBackupDestination(out)
547+
destinationCreated = true
548+
538549
console.log(
539550
env.DIRECT_DATABASE_URL === undefined
540551
? 'Dumping the database…'
@@ -564,8 +575,6 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
564575

565576
const members = ['manifest.json', 'db.dump']
566577
if (uploads === 'included') members.push('uploads.tar.gz')
567-
await claimBackupDestination(out)
568-
destinationCreated = true
569578
await run('tar', ['czf', out, '-C', stage, ...members])
570579
await chmod(out, 0o600)
571580

docs/guides/operations/operating.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,9 @@ docker compose run --rm --no-deps --user "$(id -u):$(id -g)" -v "$PWD/backups":/
147147

148148
`community backup --help` prints the flags the installed release actually has — include uploads when they use the local volume, and copy at least one version off the server.
149149

150-
**Neither the `mkdir` nor the `--user` is optional**, and they are needed for the same reason `board:eject` needs them (see [the marketplace](../../customization/marketplace.md#moving-to-a-custom-board)). The image runs as a fixed, non-root account — `nextjs`, uid 1001 — which owns nothing on your host, so it can only write into a directory that account can already write to. Creating `backups` yourself first means it belongs to you rather than being auto-created root-owned by Docker; `--user "$(id -u):$(id -g)"` then makes the account doing the writing the account that owns the directory. Without them the run ends in `EACCES: permission denied` — and it ends there *after* the database dump, with no bundle to show for it. The command says so if it happens, and points back here.
150+
**Neither the `mkdir` nor the `--user` is optional**, and they are needed for the same reason `board:eject` needs them (see [the marketplace](../../customization/marketplace.md#moving-to-a-custom-board)). The image runs as a fixed, non-root account — `nextjs`, uid 1001 — which owns nothing on your host, so it can only write into a directory that account can already write to. Creating `backups` yourself first means it belongs to you rather than being auto-created root-owned by Docker; `--user "$(id -u):$(id -g)"` then makes the account doing the writing the account that owns the directory. Without them the run ends in `EACCES: permission denied`, saying which directory needs write access and pointing back here.
151+
152+
**It ends there before dumping anything.** `backup` claims its destination as its first act — creating the file, empty and mode `0600`, before it connects to the database — so a path it cannot write is refused in under a second rather than after a dump that may take minutes. That also means two backups aimed at the same path cannot both run: the second is refused immediately rather than dumping and then losing. The command **never writes over an existing file**; if a previous run was killed part-way through it can leave an empty or truncated file at the path it had claimed, and the next run refuses that path by name. Such a file is not a backup and is safe to delete.
151153

152154
Reading a bundle back needs neither addition: files inside the image are world-readable, so [Restore](#restore) and [disaster recovery](./disaster-recovery.md) mount `/backup` and read from it as they are.
153155

docs/guides/operations/upgrading.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,9 @@ docker compose up -d --build
219219
The `mkdir` and the `--user` are what let the container write the bundle
220220
onto your host at all — the image runs as uid 1001, which owns nothing
221221
there; [Backup](./operating.md#backup) explains it in full. Get them
222-
wrong and the run fails with `EACCES` *after* the dump, two lines before
223-
this runbook destroys the volume.
222+
wrong and the run refuses immediately, before it dumps anything, naming
223+
the directory that needs write access — so you find out here rather than
224+
two lines further down, where this runbook destroys the volume.
224225

225226
The new image's `pg_dump` reads the old server fine — clients dump any
226227
older server, which is why the backup comes from the *new* build against

0 commit comments

Comments
 (0)