@meith/cli 0.33.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/backup.ts CHANGED
@@ -1,542 +1,77 @@
1
- import { spawn } from 'node:child_process'
2
- import {
3
- chmod,
4
- copyFile,
5
- mkdir,
6
- mkdtemp,
7
- open,
8
- readdir,
9
- readFile,
10
- rm,
11
- stat,
12
- writeFile,
13
- } from 'node:fs/promises'
14
- import { tmpdir } from 'node:os'
1
+ import { rm, stat } from 'node:fs/promises'
15
2
  import path from 'node:path'
16
3
 
17
- import { ConfigurationError, env, type FileStore, ValidationError } from '@meith/core'
18
- import { migrationUrl, runMigrations } from '@meith/db'
19
- import { BlobFileStore, S3FileStore, unusableKeyReason } from '@meith/drivers'
20
-
21
- import { optional, parseFlags } from './args'
22
4
  import {
23
- BackupStore,
5
+ type BackupDestination,
6
+ type BackupLog,
7
+ BackupShippingError,
24
8
  backupDestinationFromEnv,
9
+ claimBackupDestination,
10
+ createBackup,
11
+ formatBytes,
25
12
  isBundleName,
26
- pruneCandidates,
13
+ localBundles,
14
+ openBackupDestination,
15
+ type RetentionPolicy,
27
16
  resolveKeep,
28
- } from './backup-store'
17
+ resolveUploadsMode,
18
+ restoreBackup,
19
+ restoreLimits,
20
+ skippedKeyLines,
21
+ } from '@meith/backup'
22
+ import { ConfigurationError, env, ValidationError } from '@meith/core'
23
+ import { getDb, PostgresBackupRunRepository, runMigrations } from '@meith/db'
24
+ import { BlobFileStore, S3FileStore } from '@meith/drivers'
25
+ import {
26
+ type BackupSettingsView,
27
+ backupDestinationFor,
28
+ backupRingDirectory,
29
+ backupSourceFrom,
30
+ loadBackupSettings,
31
+ } from '@meith/runtime'
32
+
33
+ import { optional, parseFlags } from './args'
29
34
  import { requirePostgres } from './context'
30
35
  import { CODE_VERSION } from './upgrade'
31
36
  import { translateWriteError } from './write-errors'
32
37
 
33
- export type UploadsMode = 'include' | 'skip'
34
-
35
- export type FilestoreDriver = 'local' | 's3' | 'blob'
36
-
37
- export interface BackupManifest {
38
- readonly format: 1
39
- readonly createdAt: string
40
- readonly version: string
41
- readonly filestore: FilestoreDriver
42
- readonly uploads: 'included' | 'skipped'
43
- readonly bucket?: string
44
- readonly skippedKeys?: readonly string[]
45
- }
46
-
47
38
  const INCOMPLETE_BUNDLE_EXIT_CODE = 2
48
39
 
49
- export function resolveUploadsMode(driver: FilestoreDriver, flag: string | undefined): UploadsMode {
50
- if (flag === undefined) return driver === 's3' ? 'skip' : 'include'
51
- if (flag === 'include' || flag === 'skip') return flag
52
- throw new ValidationError(`--uploads must be "include" or "skip", got "${flag}".`)
53
- }
54
-
55
- export function bundleName(at: Date): string {
56
- const stamp = at
57
- .toISOString()
58
- .replace(/\.\d+Z$/, 'Z')
59
- .replaceAll(':', '-')
60
- return `meith-backup-${stamp}.tar.gz`
61
- }
62
-
63
- export function parseManifest(raw: string): BackupManifest {
64
- let parsed: unknown
65
- try {
66
- parsed = JSON.parse(raw)
67
- } catch {
68
- throw new ValidationError('The bundle manifest is not valid JSON.')
69
- }
70
-
71
- const manifest = parsed as Partial<BackupManifest>
72
- if (manifest.format !== 1) {
73
- throw new ValidationError(
74
- `This bundle declares format ${JSON.stringify(manifest.format)}; this build restores format 1.`,
75
- )
76
- }
77
- if (manifest.uploads !== 'included' && manifest.uploads !== 'skipped') {
78
- throw new ValidationError('The bundle manifest does not say whether uploads are included.')
79
- }
80
- if (typeof manifest.createdAt !== 'string' || typeof manifest.version !== 'string') {
81
- throw new ValidationError('The bundle manifest is missing createdAt or version.')
82
- }
83
- if (
84
- manifest.filestore !== 'local' &&
85
- manifest.filestore !== 's3' &&
86
- manifest.filestore !== 'blob'
87
- ) {
88
- throw new ValidationError('The bundle manifest does not name a known file driver.')
89
- }
90
-
91
- const skippedKeys = manifest.skippedKeys
92
- if (
93
- skippedKeys !== undefined &&
94
- (!Array.isArray(skippedKeys) || skippedKeys.some((key) => typeof key !== 'string'))
95
- ) {
96
- throw new ValidationError('The bundle manifest lists skipped objects in a form it cannot read.')
97
- }
98
-
99
- return {
100
- format: 1,
101
- createdAt: manifest.createdAt,
102
- version: manifest.version,
103
- filestore: manifest.filestore,
104
- uploads: manifest.uploads,
105
- ...(typeof manifest.bucket === 'string' ? { bucket: manifest.bucket } : {}),
106
- ...(skippedKeys === undefined || skippedKeys.length === 0 ? {} : { skippedKeys }),
107
- }
40
+ const CONSOLE_LOG: BackupLog = {
41
+ info: (line) => console.log(line),
42
+ warn: (line) => console.warn(line),
108
43
  }
109
44
 
110
- const CONTENT_TYPES: ReadonlyMap<string, string> = new Map([
111
- ['.avif', 'image/avif'],
112
- ['.gif', 'image/gif'],
113
- ['.jpeg', 'image/jpeg'],
114
- ['.jpg', 'image/jpeg'],
115
- ['.png', 'image/png'],
116
- ['.svg', 'image/svg+xml'],
117
- ['.webp', 'image/webp'],
118
- ])
119
-
120
- export function contentTypeFor(key: string): string {
121
- return CONTENT_TYPES.get(path.extname(key).toLowerCase()) ?? 'application/octet-stream'
122
- }
123
-
124
- export function formatBytes(size: number): string {
125
- let value = size
126
- let unit = 'B'
127
- for (const next of ['KiB', 'MiB', 'GiB', 'TiB']) {
128
- if (value < 1024) break
129
- value /= 1024
130
- unit = next
131
- }
132
- return unit === 'B' ? `${value} B` : `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`
133
- }
45
+ const NO_DESTINATION_HINT =
46
+ 'set BACKUP_S3_BUCKET, BACKUP_S3_REGION, BACKUP_S3_ACCESS_KEY_ID and ' +
47
+ 'BACKUP_S3_SECRET_ACCESS_KEY, or BACKUP_WEBDAV_URL with its username and password, ' +
48
+ 'or name one under Admin → Settings → Backups'
134
49
 
135
- export interface RestoreLimits {
136
- readonly archiveBytes: number
137
- readonly members: number
138
- readonly memberBytes: number
139
- readonly expandedBytes: number
140
- }
141
-
142
- const RESTORE_LIMIT_DEFAULTS: RestoreLimits = {
143
- archiveBytes: 2 * 1024 * 1024 * 1024,
144
- members: 100_000,
145
- memberBytes: 1024 * 1024 * 1024,
146
- expandedBytes: 8 * 1024 * 1024 * 1024,
147
- }
148
-
149
- function positiveInteger(value: string | undefined, variable: string, fallback: number): number {
150
- if (value === undefined || value === '') return fallback
151
- if (!/^\d+$/.test(value)) {
152
- throw new ValidationError(`${variable} must be a positive integer number of bytes or members.`)
153
- }
154
- const parsed = Number(value)
155
- if (!Number.isSafeInteger(parsed) || parsed < 1) {
156
- throw new ValidationError(`${variable} must be a positive integer number of bytes or members.`)
157
- }
158
- return parsed
50
+ interface BoardBackupPlan {
51
+ readonly destination: BackupDestination | undefined
52
+ readonly retention: RetentionPolicy | undefined
159
53
  }
160
54
 
161
- export function restoreLimits(environment: NodeJS.ProcessEnv): RestoreLimits {
162
- return {
163
- archiveBytes: positiveInteger(
164
- environment.MEITH_RESTORE_MAX_ARCHIVE_BYTES,
165
- 'MEITH_RESTORE_MAX_ARCHIVE_BYTES',
166
- RESTORE_LIMIT_DEFAULTS.archiveBytes,
167
- ),
168
- members: positiveInteger(
169
- environment.MEITH_RESTORE_MAX_MEMBERS,
170
- 'MEITH_RESTORE_MAX_MEMBERS',
171
- RESTORE_LIMIT_DEFAULTS.members,
172
- ),
173
- memberBytes: positiveInteger(
174
- environment.MEITH_RESTORE_MAX_MEMBER_BYTES,
175
- 'MEITH_RESTORE_MAX_MEMBER_BYTES',
176
- RESTORE_LIMIT_DEFAULTS.memberBytes,
177
- ),
178
- expandedBytes: positiveInteger(
179
- environment.MEITH_RESTORE_MAX_EXPANDED_BYTES,
180
- 'MEITH_RESTORE_MAX_EXPANDED_BYTES',
181
- RESTORE_LIMIT_DEFAULTS.expandedBytes,
182
- ),
183
- }
184
- }
185
-
186
- interface ArchiveMember {
187
- readonly name: string
188
- readonly type: string
189
- readonly size: number
190
- }
191
-
192
- function normalizedArchiveName(name: string): string | undefined {
193
- if (name === '' || name.includes('\\') || name.includes('\0')) return undefined
194
- const withoutDirectoryMarker = name.endsWith('/') ? name.slice(0, -1) : name
195
- if (withoutDirectoryMarker === '.') return '.'
196
- const normalized = withoutDirectoryMarker.replace(/^\.\//, '')
197
- if (normalized === '' || path.posix.isAbsolute(normalized)) return undefined
198
- const parts = normalized.split('/')
199
- if (parts.some((part) => part === '' || part === '.' || part === '..')) return undefined
200
- return normalized
201
- }
202
-
203
- export function validateArchiveListing(
204
- namesOutput: string,
205
- verboseOutput: string,
206
- limits: RestoreLimits,
207
- allowedTypes: ReadonlySet<string>,
208
- ): readonly ArchiveMember[] {
209
- const names = namesOutput === '' ? [] : namesOutput.replace(/\n$/, '').split('\n')
210
- const verbose = verboseOutput === '' ? [] : verboseOutput.replace(/\n$/, '').split('\n')
211
- if (names.length !== verbose.length) {
212
- throw new ValidationError('The archive has malformed member names.')
213
- }
214
- if (names.length > limits.members) {
215
- throw new ValidationError(`The archive has more than ${limits.members} members.`)
216
- }
217
-
218
- const seen = new Set<string>()
219
- let expandedBytes = 0
220
- return names.map((rawName, index) => {
221
- const name = normalizedArchiveName(rawName)
222
- if (name === undefined || seen.has(name)) {
223
- throw new ValidationError(`The archive contains an unsafe or duplicate member: ${rawName}`)
224
- }
225
- seen.add(name)
226
-
227
- const fields = verbose[index]?.trim().split(/\s+/) ?? []
228
- const type = fields[0]?.[0] ?? ''
229
- const size = Number(fields[2])
230
- if (!allowedTypes.has(type) || !Number.isSafeInteger(size) || size < 0) {
231
- throw new ValidationError(`The archive contains an unsupported member: ${name}`)
232
- }
233
- if (size > limits.memberBytes) {
234
- throw new ValidationError(`The archive member ${name} exceeds the per-member size limit.`)
235
- }
236
- expandedBytes += size
237
- if (!Number.isSafeInteger(expandedBytes) || expandedBytes > limits.expandedBytes) {
238
- throw new ValidationError('The archive exceeds the expanded-size limit.')
239
- }
240
- return { name, type, size }
241
- })
242
- }
243
-
244
- async function inspectArchive(
245
- archive: string,
246
- limits: RestoreLimits,
247
- allowedTypes: ReadonlySet<string>,
248
- ): Promise<readonly ArchiveMember[]> {
249
- const [names, verbose] = await Promise.all([
250
- run('tar', ['tzf', archive]),
251
- run('tar', ['tvzf', archive]),
252
- ])
253
- return validateArchiveListing(names, verbose, limits, allowedTypes)
254
- }
255
-
256
- function missingToolError(command: string): ConfigurationError {
257
- return new ConfigurationError(
258
- `${command} was not found on PATH. The shipped image carries the postgres client ` +
259
- 'tools; elsewhere install them (postgresql18-client on Alpine, ' +
260
- 'postgresql-client on Debian and Ubuntu).',
261
- )
262
- }
263
-
264
- const POSTGRES_PARAMETERS: Readonly<Record<string, string>> = {
265
- application_name: 'PGAPPNAME',
266
- channel_binding: 'PGCHANNELBINDING',
267
- connect_timeout: 'PGCONNECT_TIMEOUT',
268
- gssencmode: 'PGGSSENCMODE',
269
- options: 'PGOPTIONS',
270
- requirepeer: 'PGREQUIREPEER',
271
- sslcert: 'PGSSLCERT',
272
- sslcompression: 'PGSSLCOMPRESSION',
273
- sslcrl: 'PGSSLCRL',
274
- sslcrldir: 'PGSSLCRLDIR',
275
- sslkey: 'PGSSLKEY',
276
- sslmode: 'PGSSLMODE',
277
- sslpassword: 'PGSSLPASSWORD',
278
- sslrootcert: 'PGSSLROOTCERT',
279
- target_session_attrs: 'PGTARGETSESSIONATTRS',
280
- }
281
-
282
- export function postgresClientEnvironment(
283
- connectionString: string,
284
- variable: string,
285
- ): NodeJS.ProcessEnv {
286
- let url: URL
55
+ async function boardBackupSettings(): Promise<BackupSettingsView | null> {
56
+ if (env.DATA_SOURCE !== 'postgres') return null
287
57
  try {
288
- url = new URL(connectionString)
58
+ return await loadBackupSettings(getDb(), env)
289
59
  } catch {
290
- throw new ValidationError(`${variable} must be a valid postgres:// connection string.`)
60
+ return null
291
61
  }
292
-
293
- if (url.protocol !== 'postgres:' && url.protocol !== 'postgresql:') {
294
- throw new ValidationError(`${variable} must be a postgres:// connection string.`)
295
- }
296
- let database: string
297
- let username: string
298
- let password: string
299
- try {
300
- database = decodeURIComponent(url.pathname.replace(/^\//, ''))
301
- username = decodeURIComponent(url.username)
302
- password = decodeURIComponent(url.password)
303
- } catch {
304
- throw new ValidationError(`${variable} contains invalid percent-encoding.`)
305
- }
306
- if (url.hostname === '' || username === '' || database === '') {
307
- throw new ValidationError(`${variable} must include a host, user, and database name.`)
308
- }
309
-
310
- const childEnv: NodeJS.ProcessEnv = { ...process.env }
311
- for (const environmentVariable of [
312
- 'PGAPPNAME',
313
- 'PGCHANNELBINDING',
314
- 'PGCONNECT_TIMEOUT',
315
- 'PGDATABASE',
316
- 'PGGSSENCMODE',
317
- 'PGHOST',
318
- 'PGHOSTADDR',
319
- 'PGOPTIONS',
320
- 'PGPASSWORD',
321
- 'PGPORT',
322
- 'PGREQUIREPEER',
323
- 'PGSERVICE',
324
- 'PGSERVICEFILE',
325
- 'PGSSLCERT',
326
- 'PGSSLCOMPRESSION',
327
- 'PGSSLCRL',
328
- 'PGSSLCRLDIR',
329
- 'PGSSLKEY',
330
- 'PGSSLMODE',
331
- 'PGSSLPASSWORD',
332
- 'PGSSLROOTCERT',
333
- 'PGTARGETSESSIONATTRS',
334
- 'PGUSER',
335
- ]) {
336
- delete childEnv[environmentVariable]
337
- }
338
- Object.assign(childEnv, {
339
- PGHOST: url.hostname.replace(/^\[|\]$/g, ''),
340
- PGPORT: url.port || '5432',
341
- PGUSER: username,
342
- PGDATABASE: database,
343
- })
344
- if (password !== '') childEnv.PGPASSWORD = password
345
-
346
- for (const [parameter, environmentVariable] of Object.entries(POSTGRES_PARAMETERS)) {
347
- const value = url.searchParams.get(parameter)
348
- if (value !== null) childEnv[environmentVariable] = value
349
- }
350
- return childEnv
351
62
  }
352
63
 
353
- async function run(
354
- command: string,
355
- args: readonly string[],
356
- childEnv: NodeJS.ProcessEnv = process.env,
357
- ): Promise<string> {
358
- return new Promise<string>((resolvePromise, reject) => {
359
- const child = spawn(command, args, {
360
- env: childEnv,
361
- stdio: ['ignore', 'pipe', 'pipe'],
362
- })
363
- let stdout = ''
364
- let stderr = ''
365
- child.stdout.on('data', (chunk: Buffer) => {
366
- stdout += String(chunk)
367
- })
368
- child.stderr.on('data', (chunk: Buffer) => {
369
- stderr += String(chunk)
370
- })
371
- child.on('error', (error) => {
372
- reject((error as NodeJS.ErrnoException).code === 'ENOENT' ? missingToolError(command) : error)
373
- })
374
- child.on('close', (code) => {
375
- if (code === 0) resolvePromise(stdout)
376
- else {
377
- reject(
378
- new ConfigurationError(
379
- `${command} exited with ${code === null ? 'a signal' : `code ${code}`}.` +
380
- (stderr.trim() === '' ? '' : `\n${stderr.trim()}`),
381
- ),
382
- )
383
- }
384
- })
385
- })
386
- }
387
-
388
- export async function reserveBackupDestination(destination: string): Promise<void> {
389
- const file = await open(destination, 'wx', 0o600)
390
- await file.close()
391
- }
392
-
393
- export async function claimBackupDestination(destination: string): Promise<void> {
394
- try {
395
- await reserveBackupDestination(destination)
396
- } catch (error) {
397
- if ((error as NodeJS.ErrnoException | undefined)?.code === 'EEXIST') {
398
- throw new ValidationError(
399
- `backup will not write over ${destination}: something is already there. Move it aside ` +
400
- 'or pass a different --out. A previous run killed part-way through can leave an ' +
401
- 'empty or truncated bundle at the path it had claimed; that file is not a backup ' +
402
- 'and is safe to delete.',
403
- )
404
- }
405
- translateWriteError(error, {
406
- command: 'backup',
407
- path: destination,
408
- target: path.dirname(destination),
409
- reference: 'docs/guides/operations/operating.md, "Backup"',
410
- })
64
+ async function boardBackupPlan(): Promise<BoardBackupPlan> {
65
+ const fromEnvironment = backupDestinationFromEnv(process.env)
66
+ if (fromEnvironment !== undefined) {
67
+ return { destination: openBackupDestination(fromEnvironment), retention: undefined }
411
68
  }
412
- }
413
-
414
- interface StagedUploads {
415
- readonly uploads: 'included' | 'skipped'
416
- readonly skippedKeys: readonly string[]
417
- }
418
-
419
- const SKIPPED_KEYS_LISTED = 10
420
-
421
- export function skippedKeyLines(keys: readonly string[]): readonly string[] {
422
- const shown = keys.slice(0, SKIPPED_KEYS_LISTED)
423
- return [
424
- ...shown.map((key) => ` ${JSON.stringify(key)}`),
425
- ...(keys.length > shown.length
426
- ? [` …and ${keys.length - shown.length} more, listed in the bundle's manifest.json.`]
427
- : []),
428
- ]
429
- }
430
-
431
- async function stageLocalUploads(stage: string): Promise<StagedUploads> {
432
- const exists = await stat(env.UPLOADS_DIR).then(
433
- (info) => info.isDirectory(),
434
- () => false,
435
- )
436
- if (!exists) {
437
- console.log(`No uploads directory at ${env.UPLOADS_DIR}; the bundle carries none.`)
438
- return { uploads: 'skipped', skippedKeys: [] }
439
- }
440
-
441
- await run('tar', ['czf', path.join(stage, 'uploads.tar.gz'), '-C', env.UPLOADS_DIR, '.'])
442
- return { uploads: 'included', skippedKeys: [] }
443
- }
444
-
445
- export interface ListableStore {
446
- listKeys(): AsyncGenerator<string>
447
- get(key: string): Promise<Uint8Array | undefined>
448
- }
449
-
450
- export interface DrainedStore {
451
- readonly pulled: number
452
- readonly skipped: readonly string[]
453
- }
454
-
455
- export async function drainStoreToDirectory(
456
- store: ListableStore,
457
- dir: string,
458
- ): Promise<DrainedStore> {
459
- let pulled = 0
460
- const skipped: string[] = []
461
-
462
- for await (const key of store.listKeys()) {
463
- const target = path.resolve(dir, key)
464
- if (target !== dir && !target.startsWith(dir + path.sep)) {
465
- console.warn(`Skipping the object at ${JSON.stringify(key)}: its key escapes ${dir}.`)
466
- skipped.push(key)
467
- continue
468
- }
469
- const unusable = unusableKeyReason(key)
470
- if (unusable !== undefined) {
471
- console.warn(`Skipping the object at ${JSON.stringify(key)}: its key ${unusable}.`)
472
- skipped.push(key)
473
- continue
474
- }
475
- const body = await store.get(key)
476
- if (body === undefined) continue
477
- await mkdir(path.dirname(target), { recursive: true })
478
- await writeFile(target, body)
479
- pulled++
480
- }
481
-
482
- return { pulled, skipped }
483
- }
484
-
485
- export async function uploadDirectoryToStore(
486
- store: { put: FileStore['put'] },
487
- dir: string,
488
- ): Promise<number> {
489
- let pushed = 0
490
-
491
- for (const file of await walk(dir)) {
492
- const key = path.relative(dir, file).split(path.sep).join('/')
493
- await store.put(key, await readFile(file), {
494
- contentType: contentTypeFor(key),
495
- visibility: 'public',
496
- })
497
- pushed++
498
- }
499
-
500
- return pushed
501
- }
502
-
503
- async function stageObjectStoreUploads(
504
- stage: string,
505
- store: ListableStore,
506
- origin: string,
507
- ): Promise<StagedUploads> {
508
- const dir = path.join(stage, 'uploads')
509
- await mkdir(dir, { recursive: true })
510
-
511
- const { pulled, skipped } = await drainStoreToDirectory(store, dir)
512
-
513
- if (pulled === 0) {
514
- console.log(`Found no objects in ${origin}; the bundle carries no uploads.`)
515
- await rm(dir, { recursive: true, force: true })
516
- return { uploads: 'skipped', skippedKeys: skipped }
517
- }
518
-
519
- await run('tar', ['czf', path.join(stage, 'uploads.tar.gz'), '-C', dir, '.'])
520
- await rm(dir, { recursive: true, force: true })
521
- console.log(`Pulled ${pulled} object(s) from ${origin}.`)
522
- return { uploads: 'included', skippedKeys: skipped }
523
- }
524
-
525
- async function stageUploads(stage: string, mode: UploadsMode): Promise<StagedUploads> {
526
- if (mode === 'skip') return { uploads: 'skipped', skippedKeys: [] }
527
-
528
- switch (env.FILESTORE_DRIVER) {
529
- case 's3':
530
- return await stageObjectStoreUploads(
531
- stage,
532
- S3FileStore.fromEnv(env),
533
- `the ${env.S3_BUCKET} bucket`,
534
- )
535
- case 'blob':
536
- return await stageObjectStoreUploads(stage, BlobFileStore.fromEnv(env), 'the Blob store')
537
- case 'local':
538
- return await stageLocalUploads(stage)
69
+ const settings = await boardBackupSettings()
70
+ if (settings === null) return { destination: undefined, retention: undefined }
71
+ if (settings.destination.problem !== null) {
72
+ console.warn(`The board's off-site destination is unusable: ${settings.destination.problem}`)
539
73
  }
74
+ return { destination: backupDestinationFor(settings.destination), retention: settings.retention }
540
75
  }
541
76
 
542
77
  export async function backupCommand(args: readonly string[]): Promise<number> {
@@ -549,7 +84,8 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
549
84
  if (outFlag !== undefined && dirFlag !== undefined) {
550
85
  throw new ValidationError('--out and --dir are two answers to one question; pass one.')
551
86
  }
552
- const offsite = backupDestinationFromEnv(process.env)
87
+ const plan = await boardBackupPlan()
88
+ const offsite = plan.destination
553
89
  const keepFlag = optional(flags, 'keep')
554
90
  if (keepFlag !== undefined && dirFlag === undefined && offsite === undefined) {
555
91
  throw new ValidationError(
@@ -557,170 +93,81 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
557
93
  '(BACKUP_S3_*), or both.',
558
94
  )
559
95
  }
560
- const keep = resolveKeep(keepFlag)
561
- const now = new Date()
562
- const name = bundleName(now)
563
- if (dirFlag !== undefined) await mkdir(path.resolve(dirFlag), { recursive: true })
564
- const out = path.resolve(dirFlag === undefined ? (outFlag ?? name) : path.join(dirFlag, name))
565
-
566
- const stage = await mkdtemp(path.join(tmpdir(), 'meith-backup-'))
567
- let destinationCreated = false
568
- try {
569
- await claimBackupDestination(out)
570
- destinationCreated = true
571
-
572
- console.log(
573
- env.DIRECT_DATABASE_URL === undefined
574
- ? 'Dumping the database…'
575
- : 'Dumping the database over DIRECT_DATABASE_URL…',
576
- )
577
- const databaseVariable =
578
- env.DIRECT_DATABASE_URL === undefined ? 'DATABASE_URL' : 'DIRECT_DATABASE_URL'
579
- const databaseEnvironment = postgresClientEnvironment(migrationUrl(env), databaseVariable)
580
- await run(
581
- 'pg_dump',
582
- ['--format=custom', '--no-owner', '--no-privileges', '--file', path.join(stage, 'db.dump')],
583
- databaseEnvironment,
584
- )
585
-
586
- const { uploads, skippedKeys } = await stageUploads(stage, mode)
587
-
588
- const manifest: BackupManifest = {
589
- format: 1,
590
- createdAt: now.toISOString(),
591
- version: CODE_VERSION,
592
- filestore: env.FILESTORE_DRIVER,
593
- uploads,
594
- ...(env.S3_BUCKET === undefined ? {} : { bucket: env.S3_BUCKET }),
595
- ...(skippedKeys.length === 0 ? {} : { skippedKeys }),
596
- }
597
- await writeFile(path.join(stage, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
598
-
599
- const members = ['manifest.json', 'db.dump']
600
- if (uploads === 'included') members.push('uploads.tar.gz')
601
- await run('tar', ['czf', out, '-C', stage, ...members])
602
- await chmod(out, 0o600)
603
-
604
- const size = (await stat(out)).size
605
- destinationCreated = false
606
- console.log(
607
- `Wrote ${out} (${formatBytes(size)}): the database dump${
608
- uploads === 'included' ? ' and the uploads' : ', no uploads'
609
- }.`,
610
- )
611
- if (uploads === 'skipped' && env.FILESTORE_DRIVER === 's3' && mode !== 'include') {
612
- console.log(
613
- 'The S3 bucket was not pulled — it has its own backup story. ' +
614
- 'Run with --uploads include for a bundle that carries every object.',
615
- )
616
- }
617
- if (uploads === 'skipped' && mode === 'skip') {
618
- console.log('Restoring this bundle gives a board whose posts have broken images.')
619
- }
620
-
621
- if (offsite !== undefined) {
622
- const store = new BackupStore(offsite)
623
- await store.putFile(name, out, size)
624
- console.log(`Shipped ${name} to ${store.destination}.`)
625
- const prunedRemote = await store.prune(keep)
626
- if (prunedRemote.length > 0) {
627
- console.log(
628
- `Pruned ${prunedRemote.length} bundle(s) there beyond the newest ${keep}: ` +
629
- `${prunedRemote.join(', ')}.`,
630
- )
631
- }
632
- } else {
633
- console.log(
634
- 'Copy the bundle off this machine: a backup on the server is a backup of the ' +
635
- 'thing most likely to fail.',
636
- )
96
+ const retention: RetentionPolicy =
97
+ keepFlag === undefined && plan.retention !== undefined
98
+ ? plan.retention
99
+ : { keep: resolveKeep(keepFlag) }
100
+ const startedAt = new Date()
101
+
102
+ const record = async (
103
+ outcome: Parameters<PostgresBackupRunRepository['record']>[0]['outcome'],
104
+ ) => {
105
+ try {
106
+ await new PostgresBackupRunRepository(getDb()).record({ trigger: 'cli', startedAt, outcome })
107
+ } catch {
108
+ console.log('The board did not record this run.')
637
109
  }
638
-
639
- if (dirFlag !== undefined) {
640
- const dir = path.resolve(dirFlag)
641
- const stale = pruneCandidates(await readdir(dir), keep)
642
- for (const staleName of stale) await rm(path.join(dir, staleName), { force: true })
643
- if (stale.length > 0) {
644
- console.log(
645
- `Pruned ${stale.length} bundle(s) in ${dir} beyond the newest ${keep}: ` +
646
- `${stale.join(', ')}.`,
647
- )
648
- }
649
- }
650
-
651
- if (skippedKeys.length === 0) return 0
652
-
653
- console.warn(
654
- `\nThis bundle is missing ${skippedKeys.length} object(s) whose keys nothing can read:`,
655
- )
656
- for (const line of skippedKeyLines(skippedKeys)) console.warn(line)
657
- console.warn(
658
- 'The bundle itself is sound and restores normally — posts referring to those ' +
659
- 'objects will have broken images. The manifest carries the list, so the ' +
660
- `restore says so too. Exiting ${INCOMPLETE_BUNDLE_EXIT_CODE} rather than 0 so a ` +
661
- 'scheduled backup does not record this run as a clean one.',
662
- )
663
- return INCOMPLETE_BUNDLE_EXIT_CODE
664
- } finally {
665
- if (destinationCreated) await rm(out, { force: true })
666
- await rm(stage, { recursive: true, force: true })
667
- }
668
- }
669
-
670
- async function walk(dir: string): Promise<readonly string[]> {
671
- const files: string[] = []
672
- for (const entry of await readdir(dir, { withFileTypes: true })) {
673
- const full = path.join(dir, entry.name)
674
- if (entry.isDirectory()) files.push(...(await walk(full)))
675
- else if (entry.isFile()) files.push(full)
676
110
  }
677
- return files
678
- }
679
111
 
680
- async function validateUploadsArchive(stage: string, limits: RestoreLimits): Promise<void> {
681
- const members = await inspectArchive(
682
- path.join(stage, 'uploads.tar.gz'),
683
- limits,
684
- new Set(['-', 'd']),
685
- )
686
- for (const member of members) {
687
- if (member.name === '.' && member.type !== 'd') {
688
- throw new ValidationError('The uploads archive root is not a directory.')
112
+ let outcome: Awaited<ReturnType<typeof createBackup>>
113
+ try {
114
+ outcome = await createBackup({
115
+ source: backupSourceFrom(env, CODE_VERSION),
116
+ target: {
117
+ ...(outFlag === undefined ? {} : { out: outFlag }),
118
+ ...(dirFlag === undefined ? {} : { dir: dirFlag }),
119
+ destination: offsite,
120
+ retention,
121
+ },
122
+ uploads: mode,
123
+ now: startedAt,
124
+ log: CONSOLE_LOG,
125
+ translateWriteError: (error, destination) =>
126
+ translateWriteError(error, {
127
+ command: 'backup',
128
+ path: destination,
129
+ target: path.dirname(destination),
130
+ reference: 'docs/guides/operations/backups.md, "From the command line"',
131
+ }),
132
+ })
133
+ } catch (error) {
134
+ if (error instanceof BackupShippingError) {
135
+ await record({
136
+ status: 'failed',
137
+ finishedAt: new Date(),
138
+ error: error.message,
139
+ bundleName: error.bundle.name,
140
+ sizeBytes: error.bundle.size,
141
+ uploads: error.bundle.uploads,
142
+ skippedKeys: error.bundle.skippedKeys.length,
143
+ })
689
144
  }
145
+ throw error
690
146
  }
691
- }
692
147
 
693
- async function extractUploads(stage: string, dir: string, limits: RestoreLimits): Promise<void> {
694
- await validateUploadsArchive(stage, limits)
695
- const existing = await readdir(dir).catch((error: NodeJS.ErrnoException) => {
696
- if (error.code === 'ENOENT') return undefined
697
- throw error
148
+ await record({
149
+ status: outcome.skippedKeys.length === 0 ? 'done' : 'incomplete',
150
+ finishedAt: new Date(),
151
+ bundleName: outcome.name,
152
+ sizeBytes: outcome.size,
153
+ uploads: outcome.uploads,
154
+ shipped: outcome.shipped !== null,
155
+ skippedKeys: outcome.skippedKeys.length,
698
156
  })
699
- if (existing !== undefined && existing.length > 0) {
700
- throw new ValidationError(
701
- `${dir} is not empty. Restore the uploads into a fresh directory (--uploads-dir), ` +
702
- 'the same way the database goes into a fresh database.',
703
- )
704
- }
705
157
 
706
- await mkdir(dir, { recursive: true })
707
- await run('tar', ['xzf', path.join(stage, 'uploads.tar.gz'), '-C', dir])
708
- console.log(`Restored the uploads into ${dir}.`)
709
- }
158
+ if (outcome.skippedKeys.length === 0) return 0
710
159
 
711
- async function pushUploadsToStore(
712
- stage: string,
713
- limits: RestoreLimits,
714
- store: { put: FileStore['put'] },
715
- destination: string,
716
- ): Promise<void> {
717
- await validateUploadsArchive(stage, limits)
718
- const dir = path.join(stage, 'uploads-extract')
719
- await mkdir(dir, { recursive: true })
720
- await run('tar', ['xzf', path.join(stage, 'uploads.tar.gz'), '-C', dir])
721
-
722
- const pushed = await uploadDirectoryToStore(store, dir)
723
- console.log(`Uploaded ${pushed} object(s) to ${destination}.`)
160
+ console.warn(
161
+ `\nThis bundle is missing ${outcome.skippedKeys.length} object(s) whose keys nothing can read:`,
162
+ )
163
+ for (const line of skippedKeyLines(outcome.skippedKeys)) console.warn(line)
164
+ console.warn(
165
+ 'The bundle itself is sound and restores normally — posts referring to those ' +
166
+ 'objects will have broken images. The manifest carries the list, so the ' +
167
+ `restore says so too. Exiting ${INCOMPLETE_BUNDLE_EXIT_CODE} rather than 0 so a ` +
168
+ 'scheduled backup does not record this run as a clean one.',
169
+ )
170
+ return INCOMPLETE_BUNDLE_EXIT_CODE
724
171
  }
725
172
 
726
173
  const RESTORE_USAGE =
@@ -729,7 +176,7 @@ const RESTORE_USAGE =
729
176
 
730
177
  export function restoreDatabaseUrl(
731
178
  args: readonly string[],
732
- environment: NodeJS.ProcessEnv,
179
+ environment: Readonly<Record<string, string | undefined>>,
733
180
  ): string {
734
181
  const { flags } = parseFlags(args)
735
182
  if (flags.has('database-url')) {
@@ -752,168 +199,75 @@ export async function restoreCommand(args: readonly string[]): Promise<number> {
752
199
  if (bundle === undefined) throw new ValidationError(RESTORE_USAGE)
753
200
 
754
201
  const target = restoreDatabaseUrl(args, process.env)
755
- const databaseEnvironment = postgresClientEnvironment(target, 'RESTORE_DATABASE_URL')
756
-
757
- const bundleInfo = await stat(bundle).catch(() => undefined)
758
- if (bundleInfo === undefined || !bundleInfo.isFile()) {
759
- throw new ValidationError(`No such bundle: ${bundle}`)
760
- }
761
-
762
- const limits = restoreLimits(process.env)
763
- if (bundleInfo.size > limits.archiveBytes) {
764
- throw new ValidationError('The backup bundle exceeds MEITH_RESTORE_MAX_ARCHIVE_BYTES.')
765
- }
766
-
767
- const stage = await mkdtemp(path.join(tmpdir(), 'meith-restore-'))
768
- try {
769
- const stagedBundle = path.join(stage, 'bundle.tar.gz')
770
- await copyFile(path.resolve(bundle), stagedBundle)
771
- const members = await inspectArchive(stagedBundle, limits, new Set(['-']))
772
- const possibleMembers = new Set(['manifest.json', 'db.dump', 'uploads.tar.gz'])
773
- if (members.some((member) => !possibleMembers.has(member.name))) {
774
- throw new ValidationError('The backup bundle contains an unexpected member.')
775
- }
776
- await run('tar', ['xzf', stagedBundle, '-C', stage, 'manifest.json'])
777
- const manifest = parseManifest(await readFile(path.join(stage, 'manifest.json'), 'utf8'))
778
- const expectedMembers = new Set(['manifest.json', 'db.dump'])
779
- if (manifest.uploads === 'included') expectedMembers.add('uploads.tar.gz')
780
- if (
781
- members.length !== expectedMembers.size ||
782
- members.some((member) => !expectedMembers.has(member.name))
783
- ) {
784
- throw new ValidationError('The backup bundle members do not match its manifest.')
785
- }
786
- const restoreMembers = ['db.dump']
787
- if (manifest.uploads === 'included') restoreMembers.push('uploads.tar.gz')
788
- await run('tar', ['xzf', stagedBundle, '-C', stage, ...restoreMembers])
789
-
790
- const tables = (
791
- await run(
792
- 'psql',
793
- ['-tAc', "select count(*) from information_schema.tables where table_schema = 'public'"],
794
- databaseEnvironment,
795
- )
796
- ).trim()
797
- if (tables !== '0') {
798
- throw new ValidationError(
799
- `The target database already holds ${tables} table(s). Restore into a new, ` +
800
- 'empty database — a restore over a live board is how a bad backup becomes ' +
801
- 'two lost boards.',
802
- )
803
- }
804
-
805
- console.log(`Restoring the backup taken ${manifest.createdAt} (version ${manifest.version})…`)
806
- await run(
807
- 'pg_restore',
808
- [
809
- '--no-owner',
810
- '--no-privileges',
811
- '--dbname',
812
- databaseEnvironment.PGDATABASE ?? '',
813
- path.join(stage, 'db.dump'),
814
- ],
815
- databaseEnvironment,
816
- )
202
+ const uploadsDir = optional(flags, 'uploads-dir')
203
+ const skipUploads = flags.get('skip-uploads') === 'true'
204
+
205
+ const uploads = skipUploads
206
+ ? ({ mode: 'skip' } as const)
207
+ : env.FILESTORE_DRIVER === 's3' && uploadsDir === undefined
208
+ ? ({
209
+ mode: 'store',
210
+ store: S3FileStore.fromEnv(env),
211
+ description: `${env.S3_BUCKET}`,
212
+ } as const)
213
+ : env.FILESTORE_DRIVER === 'blob' && uploadsDir === undefined
214
+ ? ({
215
+ mode: 'store',
216
+ store: BlobFileStore.fromEnv(env),
217
+ description: 'the Blob store',
218
+ } as const)
219
+ : ({ mode: 'directory', dir: uploadsDir ?? env.UPLOADS_DIR } as const)
220
+
221
+ const outcome = await restoreBackup({
222
+ bundle,
223
+ target: { url: target, variable: 'RESTORE_DATABASE_URL', mode: 'empty-database' },
224
+ codeVersion: CODE_VERSION,
225
+ migrate: (url) => runMigrations({ url }),
226
+ uploads,
227
+ limits: restoreLimits(process.env),
228
+ log: CONSOLE_LOG,
229
+ })
817
230
 
818
- const applied = await runMigrations({ url: target })
819
- console.log(
820
- applied === 0
821
- ? 'Migrations: nothing to do the dump matches this build.'
822
- : `Migrations: applied ${applied} migration(s) the dump predates.`,
231
+ if (outcome.manifest.skippedKeys !== undefined) {
232
+ console.warn(
233
+ `\nThe backup that made this bundle could not read ${outcome.manifest.skippedKeys.length} ` +
234
+ 'object(s), so they are not here:',
823
235
  )
824
-
825
- const posts = (
826
- await run('psql', ['-tAc', 'select count(*) from posts'], databaseEnvironment)
827
- ).trim()
828
- console.log(`The restored board holds ${posts} post(s).`)
829
-
830
- if (manifest.uploads === 'included' && flags.get('skip-uploads') !== 'true') {
831
- const uploadsDir = optional(flags, 'uploads-dir')
832
- if (env.FILESTORE_DRIVER === 's3' && uploadsDir === undefined) {
833
- await pushUploadsToStore(stage, limits, S3FileStore.fromEnv(env), `${env.S3_BUCKET}`)
834
- } else if (env.FILESTORE_DRIVER === 'blob' && uploadsDir === undefined) {
835
- await pushUploadsToStore(stage, limits, BlobFileStore.fromEnv(env), 'the Blob store')
836
- } else {
837
- await extractUploads(stage, uploadsDir ?? env.UPLOADS_DIR, limits)
838
- }
839
- } else if (manifest.uploads === 'skipped') {
840
- console.log(
841
- manifest.filestore === 's3'
842
- ? `This bundle carries no uploads — they live in the S3 bucket${
843
- manifest.bucket === undefined ? '' : ` (${manifest.bucket})`
844
- }.`
845
- : manifest.filestore === 'blob'
846
- ? 'This bundle carries no uploads, and a Vercel Blob store is not ' +
847
- 'something you can copy out by hand. Take another backup with ' +
848
- '--uploads include while the old board still exists.'
849
- : 'This bundle carries no uploads.',
850
- )
851
- }
852
-
853
- if (manifest.skippedKeys !== undefined) {
854
- console.warn(
855
- `\nThe backup that made this bundle could not read ${manifest.skippedKeys.length} ` +
856
- 'object(s), so they are not here:',
857
- )
858
- for (const line of skippedKeyLines(manifest.skippedKeys)) console.warn(line)
859
- console.warn(
860
- 'Posts referring to them have broken images. Those keys were unusable in the ' +
861
- 'source store, so another backup of the same board would skip them again.',
862
- )
863
- }
864
-
865
- console.log(
866
- 'Point a staging deployment at the restored database, sign in as an ' +
867
- 'administrator, and open a thread with attachments before trusting it.',
236
+ for (const line of skippedKeyLines(outcome.manifest.skippedKeys)) console.warn(line)
237
+ console.warn(
238
+ 'Posts referring to them have broken images. Those keys were unusable in the ' +
239
+ 'source store, so another backup of the same board would skip them again.',
868
240
  )
869
- return 0
870
- } finally {
871
- await rm(stage, { recursive: true, force: true })
872
241
  }
873
- }
874
242
 
875
- async function localBundles(dir: string): Promise<readonly { name: string; size: number }[]> {
876
- const names = await readdir(dir).catch((error: NodeJS.ErrnoException) => {
877
- if (error.code === 'ENOENT') return []
878
- throw error
879
- })
880
- const bundles = []
881
- for (const name of names.filter((entry) => isBundleName(entry)).sort()) {
882
- bundles.push({ name, size: (await stat(path.join(dir, name))).size })
883
- }
884
- return bundles
243
+ console.log(
244
+ 'Point a staging deployment at the restored database, sign in as an ' +
245
+ 'administrator, and open a thread with attachments before trusting it.',
246
+ )
247
+ return 0
885
248
  }
886
249
 
887
250
  export async function backupListCommand(args: readonly string[]): Promise<number> {
888
251
  const { flags } = parseFlags(args)
889
- const dirFlag = optional(flags, 'dir')
890
- const offsite = backupDestinationFromEnv(process.env)
891
- if (dirFlag === undefined && offsite === undefined) {
892
- throw new ValidationError(
893
- 'Nothing to list: pass --dir <dir> for a local ring, or set BACKUP_S3_BUCKET, ' +
894
- 'BACKUP_S3_REGION, BACKUP_S3_ACCESS_KEY_ID and BACKUP_S3_SECRET_ACCESS_KEY ' +
895
- 'for the off-site destination.',
896
- )
897
- }
252
+ const dir = path.resolve(optional(flags, 'dir') ?? backupRingDirectory(env))
253
+ const offsite = (await boardBackupPlan()).destination
898
254
 
899
- if (dirFlag !== undefined) {
900
- const dir = path.resolve(dirFlag)
901
- const bundles = await localBundles(dir)
902
- console.log(`${dir}:`)
903
- if (bundles.length === 0) console.log(' no bundles')
904
- for (const bundle of bundles) {
905
- console.log(` ${bundle.name} ${formatBytes(bundle.size)}`)
906
- }
255
+ const bundles = await localBundles(dir)
256
+ console.log(`${dir}:`)
257
+ if (bundles.length === 0) console.log(' no bundles')
258
+ for (const bundle of bundles) {
259
+ console.log(` ${bundle.name} ${formatBytes(bundle.size)}`)
907
260
  }
908
261
 
909
262
  if (offsite !== undefined) {
910
- const store = new BackupStore(offsite)
911
- const bundles = await store.list()
912
- console.log(`${store.destination}:`)
913
- if (bundles.length === 0) console.log(' no bundles')
914
- for (const bundle of bundles) {
263
+ const remote = await offsite.list()
264
+ console.log(`${offsite.description}:`)
265
+ if (remote.length === 0) console.log(' no bundles')
266
+ for (const bundle of remote) {
915
267
  console.log(` ${bundle.name} ${formatBytes(bundle.size)}`)
916
268
  }
269
+ } else {
270
+ console.log(`No off-site destination: ${NO_DESTINATION_HINT}, to list one.`)
917
271
  }
918
272
 
919
273
  return 0
@@ -933,25 +287,32 @@ export async function backupFetchCommand(args: readonly string[]): Promise<numbe
933
287
  )
934
288
  }
935
289
 
936
- const offsite = backupDestinationFromEnv(process.env)
290
+ const offsite = (await boardBackupPlan()).destination
937
291
  if (offsite === undefined) {
938
292
  throw new ConfigurationError(
939
- 'backup:fetch downloads from the off-site destination, so it needs BACKUP_S3_BUCKET, ' +
940
- 'BACKUP_S3_REGION, BACKUP_S3_ACCESS_KEY_ID and BACKUP_S3_SECRET_ACCESS_KEY.',
293
+ `backup:fetch downloads from the off-site destination, so it needs one: ${NO_DESTINATION_HINT}.`,
941
294
  )
942
295
  }
943
296
 
944
- const store = new BackupStore(offsite)
945
297
  const out = path.resolve(optional(flags, 'out') ?? path.basename(name))
946
- await claimBackupDestination(out)
298
+ await claimBackupDestination(out, (error, destination) =>
299
+ translateWriteError(error, {
300
+ command: 'backup:fetch',
301
+ path: destination,
302
+ target: path.dirname(destination),
303
+ reference: 'docs/guides/operations/backups.md, "From the command line"',
304
+ }),
305
+ )
947
306
  try {
948
- await store.getToFile(path.basename(name), out)
307
+ await offsite.getToFile(path.basename(name), out)
949
308
  } catch (error) {
950
309
  await rm(out, { force: true })
951
310
  throw error
952
311
  }
953
312
 
954
- console.log(`Fetched ${out} (${formatBytes((await stat(out)).size)}) from ${store.destination}.`)
313
+ console.log(
314
+ `Fetched ${out} (${formatBytes((await stat(out)).size)}) from ${offsite.description}.`,
315
+ )
955
316
  console.log(`Restore it with: ${RESTORE_USAGE.replace('Usage: ', '')}`)
956
317
  return 0
957
318
  }