@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/package.json +14 -13
- package/src/backup.ts +187 -826
- package/src/index.ts +10 -1
- package/src/redaction.ts +2 -0
- package/src/upgrade.ts +5 -2
- package/src/backup-store.ts +0 -221
package/src/backup.ts
CHANGED
|
@@ -1,542 +1,77 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
5
|
+
type BackupDestination,
|
|
6
|
+
type BackupLog,
|
|
7
|
+
BackupShippingError,
|
|
24
8
|
backupDestinationFromEnv,
|
|
9
|
+
claimBackupDestination,
|
|
10
|
+
createBackup,
|
|
11
|
+
formatBytes,
|
|
25
12
|
isBundleName,
|
|
26
|
-
|
|
13
|
+
localBundles,
|
|
14
|
+
openBackupDestination,
|
|
15
|
+
type RetentionPolicy,
|
|
27
16
|
resolveKeep,
|
|
28
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
136
|
-
readonly
|
|
137
|
-
readonly
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
return await loadBackupSettings(getDb(), env)
|
|
289
59
|
} catch {
|
|
290
|
-
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
|
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
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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:
|
|
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
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
819
|
-
console.
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
|
890
|
-
const offsite =
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
|
911
|
-
|
|
912
|
-
console.log(
|
|
913
|
-
|
|
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 =
|
|
290
|
+
const offsite = (await boardBackupPlan()).destination
|
|
937
291
|
if (offsite === undefined) {
|
|
938
292
|
throw new ConfigurationError(
|
|
939
|
-
|
|
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
|
|
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(
|
|
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
|
}
|