@meith/backup 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/LICENSE.md +21 -0
- package/package.json +27 -0
- package/src/archive.ts +132 -0
- package/src/bundle.ts +129 -0
- package/src/capability.ts +10 -0
- package/src/create.ts +316 -0
- package/src/destination.ts +449 -0
- package/src/index.ts +105 -0
- package/src/postgres-client.ts +141 -0
- package/src/restore.ts +332 -0
- package/src/retention.ts +45 -0
- package/src/runs.ts +60 -0
- package/src/schedule.ts +75 -0
- package/src/uploads.ts +75 -0
- package/src/webdav.ts +247 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordan Harrison and the Meith contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meith/backup",
|
|
3
|
+
"version": "0.34.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/meith-dev/meith.git",
|
|
8
|
+
"directory": "packages/backup"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/*.test.*",
|
|
16
|
+
"!src/**/*.type-test.*"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@aws-sdk/client-s3": "3.1111.0",
|
|
23
|
+
"@aws-sdk/s3-request-presigner": "3.1111.0",
|
|
24
|
+
"@meith/core": "0.34.0",
|
|
25
|
+
"@meith/upgrade": "0.34.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/archive.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
import { run } from './postgres-client'
|
|
6
|
+
|
|
7
|
+
export interface RestoreLimits {
|
|
8
|
+
readonly archiveBytes: number
|
|
9
|
+
readonly members: number
|
|
10
|
+
readonly memberBytes: number
|
|
11
|
+
readonly expandedBytes: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RESTORE_LIMIT_DEFAULTS: RestoreLimits = {
|
|
15
|
+
archiveBytes: 2 * 1024 * 1024 * 1024,
|
|
16
|
+
members: 100_000,
|
|
17
|
+
memberBytes: 1024 * 1024 * 1024,
|
|
18
|
+
expandedBytes: 8 * 1024 * 1024 * 1024,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function positiveInteger(value: string | undefined, variable: string, fallback: number): number {
|
|
22
|
+
if (value === undefined || value === '') return fallback
|
|
23
|
+
if (!/^\d+$/.test(value)) {
|
|
24
|
+
throw new ValidationError(`${variable} must be a positive integer number of bytes or members.`)
|
|
25
|
+
}
|
|
26
|
+
const parsed = Number(value)
|
|
27
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
28
|
+
throw new ValidationError(`${variable} must be a positive integer number of bytes or members.`)
|
|
29
|
+
}
|
|
30
|
+
return parsed
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function restoreLimits(
|
|
34
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
35
|
+
): RestoreLimits {
|
|
36
|
+
return {
|
|
37
|
+
archiveBytes: positiveInteger(
|
|
38
|
+
environment.MEITH_RESTORE_MAX_ARCHIVE_BYTES,
|
|
39
|
+
'MEITH_RESTORE_MAX_ARCHIVE_BYTES',
|
|
40
|
+
RESTORE_LIMIT_DEFAULTS.archiveBytes,
|
|
41
|
+
),
|
|
42
|
+
members: positiveInteger(
|
|
43
|
+
environment.MEITH_RESTORE_MAX_MEMBERS,
|
|
44
|
+
'MEITH_RESTORE_MAX_MEMBERS',
|
|
45
|
+
RESTORE_LIMIT_DEFAULTS.members,
|
|
46
|
+
),
|
|
47
|
+
memberBytes: positiveInteger(
|
|
48
|
+
environment.MEITH_RESTORE_MAX_MEMBER_BYTES,
|
|
49
|
+
'MEITH_RESTORE_MAX_MEMBER_BYTES',
|
|
50
|
+
RESTORE_LIMIT_DEFAULTS.memberBytes,
|
|
51
|
+
),
|
|
52
|
+
expandedBytes: positiveInteger(
|
|
53
|
+
environment.MEITH_RESTORE_MAX_EXPANDED_BYTES,
|
|
54
|
+
'MEITH_RESTORE_MAX_EXPANDED_BYTES',
|
|
55
|
+
RESTORE_LIMIT_DEFAULTS.expandedBytes,
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ArchiveMember {
|
|
61
|
+
readonly name: string
|
|
62
|
+
readonly type: string
|
|
63
|
+
readonly size: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizedArchiveName(name: string): string | undefined {
|
|
67
|
+
if (name === '' || name.includes('\\') || name.includes('\0')) return undefined
|
|
68
|
+
const withoutDirectoryMarker = name.endsWith('/') ? name.slice(0, -1) : name
|
|
69
|
+
if (withoutDirectoryMarker === '.') return '.'
|
|
70
|
+
const normalized = withoutDirectoryMarker.replace(/^\.\//, '')
|
|
71
|
+
if (normalized === '' || path.posix.isAbsolute(normalized)) return undefined
|
|
72
|
+
const parts = normalized.split('/')
|
|
73
|
+
if (parts.some((part) => part === '' || part === '.' || part === '..')) return undefined
|
|
74
|
+
return normalized
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function memberSize(fields: readonly string[]): number {
|
|
78
|
+
return Number(fields[1]?.includes('/') ? fields[2] : fields[4])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function validateArchiveListing(
|
|
82
|
+
namesOutput: string,
|
|
83
|
+
verboseOutput: string,
|
|
84
|
+
limits: RestoreLimits,
|
|
85
|
+
allowedTypes: ReadonlySet<string>,
|
|
86
|
+
): readonly ArchiveMember[] {
|
|
87
|
+
const names = namesOutput === '' ? [] : namesOutput.replace(/\n$/, '').split('\n')
|
|
88
|
+
const verbose = verboseOutput === '' ? [] : verboseOutput.replace(/\n$/, '').split('\n')
|
|
89
|
+
if (names.length !== verbose.length) {
|
|
90
|
+
throw new ValidationError('The archive has malformed member names.')
|
|
91
|
+
}
|
|
92
|
+
if (names.length > limits.members) {
|
|
93
|
+
throw new ValidationError(`The archive has more than ${limits.members} members.`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const seen = new Set<string>()
|
|
97
|
+
let expandedBytes = 0
|
|
98
|
+
return names.map((rawName, index) => {
|
|
99
|
+
const name = normalizedArchiveName(rawName)
|
|
100
|
+
if (name === undefined || seen.has(name)) {
|
|
101
|
+
throw new ValidationError(`The archive contains an unsafe or duplicate member: ${rawName}`)
|
|
102
|
+
}
|
|
103
|
+
seen.add(name)
|
|
104
|
+
|
|
105
|
+
const fields = verbose[index]?.trim().split(/\s+/) ?? []
|
|
106
|
+
const type = fields[0]?.[0] ?? ''
|
|
107
|
+
const size = memberSize(fields)
|
|
108
|
+
if (!allowedTypes.has(type) || !Number.isSafeInteger(size) || size < 0) {
|
|
109
|
+
throw new ValidationError(`The archive contains an unsupported member: ${name}`)
|
|
110
|
+
}
|
|
111
|
+
if (size > limits.memberBytes) {
|
|
112
|
+
throw new ValidationError(`The archive member ${name} exceeds the per-member size limit.`)
|
|
113
|
+
}
|
|
114
|
+
expandedBytes += size
|
|
115
|
+
if (!Number.isSafeInteger(expandedBytes) || expandedBytes > limits.expandedBytes) {
|
|
116
|
+
throw new ValidationError('The archive exceeds the expanded-size limit.')
|
|
117
|
+
}
|
|
118
|
+
return { name, type, size }
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function inspectArchive(
|
|
123
|
+
archive: string,
|
|
124
|
+
limits: RestoreLimits,
|
|
125
|
+
allowedTypes: ReadonlySet<string>,
|
|
126
|
+
): Promise<readonly ArchiveMember[]> {
|
|
127
|
+
const [names, verbose] = await Promise.all([
|
|
128
|
+
run('tar', ['tzf', archive]),
|
|
129
|
+
run('tar', ['tvzf', archive]),
|
|
130
|
+
])
|
|
131
|
+
return validateArchiveListing(names, verbose, limits, allowedTypes)
|
|
132
|
+
}
|
package/src/bundle.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
export type FilestoreDriver = 'local' | 's3' | 'blob'
|
|
6
|
+
|
|
7
|
+
export type UploadsMode = 'include' | 'skip'
|
|
8
|
+
|
|
9
|
+
export interface BackupManifest {
|
|
10
|
+
readonly format: 1
|
|
11
|
+
readonly createdAt: string
|
|
12
|
+
readonly version: string
|
|
13
|
+
readonly filestore: FilestoreDriver
|
|
14
|
+
readonly uploads: 'included' | 'skipped'
|
|
15
|
+
readonly bucket?: string
|
|
16
|
+
readonly skippedKeys?: readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const BUNDLE_NAME_PATTERN =
|
|
20
|
+
/^meith-backup-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})Z\.tar\.gz$/
|
|
21
|
+
|
|
22
|
+
export function isBundleName(name: string): boolean {
|
|
23
|
+
return BUNDLE_NAME_PATTERN.test(name)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function bundleName(at: Date): string {
|
|
27
|
+
const stamp = at
|
|
28
|
+
.toISOString()
|
|
29
|
+
.replace(/\.\d+Z$/, 'Z')
|
|
30
|
+
.replaceAll(':', '-')
|
|
31
|
+
return `meith-backup-${stamp}.tar.gz`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function bundleTakenAt(name: string): Date | null {
|
|
35
|
+
const match = BUNDLE_NAME_PATTERN.exec(name)
|
|
36
|
+
if (match === null) return null
|
|
37
|
+
const at = new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}Z`)
|
|
38
|
+
return Number.isNaN(at.getTime()) ? null : at
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveUploadsMode(driver: FilestoreDriver, flag: string | undefined): UploadsMode {
|
|
42
|
+
if (flag === undefined || flag === 'auto') return driver === 's3' ? 'skip' : 'include'
|
|
43
|
+
if (flag === 'include' || flag === 'skip') return flag
|
|
44
|
+
throw new ValidationError(`--uploads must be "include" or "skip", got "${flag}".`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseManifest(raw: string): BackupManifest {
|
|
48
|
+
let parsed: unknown
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(raw)
|
|
51
|
+
} catch {
|
|
52
|
+
throw new ValidationError('The bundle manifest is not valid JSON.')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const manifest = parsed as Partial<BackupManifest>
|
|
56
|
+
if (manifest.format !== 1) {
|
|
57
|
+
throw new ValidationError(
|
|
58
|
+
`This bundle declares format ${JSON.stringify(manifest.format)}; this build restores format 1.`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
if (manifest.uploads !== 'included' && manifest.uploads !== 'skipped') {
|
|
62
|
+
throw new ValidationError('The bundle manifest does not say whether uploads are included.')
|
|
63
|
+
}
|
|
64
|
+
if (typeof manifest.createdAt !== 'string' || typeof manifest.version !== 'string') {
|
|
65
|
+
throw new ValidationError('The bundle manifest is missing createdAt or version.')
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
manifest.filestore !== 'local' &&
|
|
69
|
+
manifest.filestore !== 's3' &&
|
|
70
|
+
manifest.filestore !== 'blob'
|
|
71
|
+
) {
|
|
72
|
+
throw new ValidationError('The bundle manifest does not name a known file driver.')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const skippedKeys = manifest.skippedKeys
|
|
76
|
+
if (
|
|
77
|
+
skippedKeys !== undefined &&
|
|
78
|
+
(!Array.isArray(skippedKeys) || skippedKeys.some((key) => typeof key !== 'string'))
|
|
79
|
+
) {
|
|
80
|
+
throw new ValidationError('The bundle manifest lists skipped objects in a form it cannot read.')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
format: 1,
|
|
85
|
+
createdAt: manifest.createdAt,
|
|
86
|
+
version: manifest.version,
|
|
87
|
+
filestore: manifest.filestore,
|
|
88
|
+
uploads: manifest.uploads,
|
|
89
|
+
...(typeof manifest.bucket === 'string' ? { bucket: manifest.bucket } : {}),
|
|
90
|
+
...(skippedKeys === undefined || skippedKeys.length === 0 ? {} : { skippedKeys }),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const CONTENT_TYPES: ReadonlyMap<string, string> = new Map([
|
|
95
|
+
['.avif', 'image/avif'],
|
|
96
|
+
['.gif', 'image/gif'],
|
|
97
|
+
['.jpeg', 'image/jpeg'],
|
|
98
|
+
['.jpg', 'image/jpeg'],
|
|
99
|
+
['.png', 'image/png'],
|
|
100
|
+
['.svg', 'image/svg+xml'],
|
|
101
|
+
['.webp', 'image/webp'],
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
export function contentTypeFor(key: string): string {
|
|
105
|
+
return CONTENT_TYPES.get(path.extname(key).toLowerCase()) ?? 'application/octet-stream'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function formatBytes(size: number): string {
|
|
109
|
+
let value = size
|
|
110
|
+
let unit = 'B'
|
|
111
|
+
for (const next of ['KiB', 'MiB', 'GiB', 'TiB']) {
|
|
112
|
+
if (value < 1024) break
|
|
113
|
+
value /= 1024
|
|
114
|
+
unit = next
|
|
115
|
+
}
|
|
116
|
+
return unit === 'B' ? `${value} B` : `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const SKIPPED_KEYS_LISTED = 10
|
|
120
|
+
|
|
121
|
+
export function skippedKeyLines(keys: readonly string[]): readonly string[] {
|
|
122
|
+
const shown = keys.slice(0, SKIPPED_KEYS_LISTED)
|
|
123
|
+
return [
|
|
124
|
+
...shown.map((key) => ` ${JSON.stringify(key)}`),
|
|
125
|
+
...(keys.length > shown.length
|
|
126
|
+
? [` …and ${keys.length - shown.length} more, listed in the bundle's manifest.json.`]
|
|
127
|
+
: []),
|
|
128
|
+
]
|
|
129
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type BackupCapability = 'available' | 'fixture' | 'serverless'
|
|
2
|
+
|
|
3
|
+
export function backupCapability(environment: {
|
|
4
|
+
readonly DATA_SOURCE?: string | undefined
|
|
5
|
+
readonly VERCEL?: string | undefined
|
|
6
|
+
}): BackupCapability {
|
|
7
|
+
if (environment.DATA_SOURCE !== 'postgres') return 'fixture'
|
|
8
|
+
if (environment.VERCEL !== undefined && environment.VERCEL !== '') return 'serverless'
|
|
9
|
+
return 'available'
|
|
10
|
+
}
|
package/src/create.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { chmod, mkdir, mkdtemp, open, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { ValidationError } from '@meith/core'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type BackupManifest,
|
|
9
|
+
bundleName,
|
|
10
|
+
type FilestoreDriver,
|
|
11
|
+
formatBytes,
|
|
12
|
+
isBundleName,
|
|
13
|
+
type UploadsMode,
|
|
14
|
+
} from './bundle'
|
|
15
|
+
import type { BackupDestination } from './destination'
|
|
16
|
+
import { postgresClientEnvironment, run } from './postgres-client'
|
|
17
|
+
import { type RetentionPolicy, retentionCandidates } from './retention'
|
|
18
|
+
import { drainStoreToDirectory, type ListableStore } from './uploads'
|
|
19
|
+
|
|
20
|
+
export interface BackupLog {
|
|
21
|
+
info(line: string): void
|
|
22
|
+
warn(line: string): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const SILENT_LOG: BackupLog = { info: () => undefined, warn: () => undefined }
|
|
26
|
+
|
|
27
|
+
export interface BackupSource {
|
|
28
|
+
readonly databaseUrl: string
|
|
29
|
+
readonly databaseVariable: string
|
|
30
|
+
readonly version: string
|
|
31
|
+
readonly filestore: FilestoreDriver
|
|
32
|
+
readonly uploadsDir: string
|
|
33
|
+
readonly objectStore?:
|
|
34
|
+
| { readonly store: ListableStore; readonly origin: string; readonly bucket?: string }
|
|
35
|
+
| undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BackupTarget {
|
|
39
|
+
readonly out?: string | undefined
|
|
40
|
+
readonly dir?: string | undefined
|
|
41
|
+
readonly destination?: BackupDestination | undefined
|
|
42
|
+
readonly retention: RetentionPolicy
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CreateBackupInput {
|
|
46
|
+
readonly source: BackupSource
|
|
47
|
+
readonly target: BackupTarget
|
|
48
|
+
readonly uploads: UploadsMode
|
|
49
|
+
readonly now?: Date | undefined
|
|
50
|
+
readonly log?: BackupLog | undefined
|
|
51
|
+
readonly translateWriteError?: ((error: unknown, destination: string) => never) | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BackupOutcome {
|
|
55
|
+
readonly path: string
|
|
56
|
+
readonly name: string
|
|
57
|
+
readonly size: number
|
|
58
|
+
readonly createdAt: Date
|
|
59
|
+
readonly uploads: 'included' | 'skipped'
|
|
60
|
+
readonly skippedKeys: readonly string[]
|
|
61
|
+
readonly shipped: string | null
|
|
62
|
+
readonly prunedLocal: readonly string[]
|
|
63
|
+
readonly prunedRemote: readonly string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WrittenBundle {
|
|
67
|
+
readonly name: string
|
|
68
|
+
readonly size: number
|
|
69
|
+
readonly uploads: 'included' | 'skipped'
|
|
70
|
+
readonly skippedKeys: readonly string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class BackupShippingError extends Error {
|
|
74
|
+
constructor(
|
|
75
|
+
cause: unknown,
|
|
76
|
+
readonly bundle: WrittenBundle,
|
|
77
|
+
) {
|
|
78
|
+
super(
|
|
79
|
+
`${bundle.name} was written but not shipped: ${
|
|
80
|
+
cause instanceof Error ? cause.message : String(cause)
|
|
81
|
+
}`,
|
|
82
|
+
)
|
|
83
|
+
this.name = 'BackupShippingError'
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function reserveBackupDestination(destination: string): Promise<void> {
|
|
88
|
+
const file = await open(destination, 'wx', 0o600)
|
|
89
|
+
await file.close()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function claimBackupDestination(
|
|
93
|
+
destination: string,
|
|
94
|
+
translateWriteError?: (error: unknown, destination: string) => never,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
try {
|
|
97
|
+
await reserveBackupDestination(destination)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === 'EEXIST') {
|
|
100
|
+
throw new ValidationError(
|
|
101
|
+
`backup will not write over ${destination}: something is already there. Move it aside ` +
|
|
102
|
+
'or pass a different --out. A previous run killed part-way through can leave an ' +
|
|
103
|
+
'empty or truncated bundle at the path it had claimed; that file is not a backup ' +
|
|
104
|
+
'and is safe to delete.',
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
if (translateWriteError !== undefined) translateWriteError(error, destination)
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface StagedUploads {
|
|
113
|
+
readonly uploads: 'included' | 'skipped'
|
|
114
|
+
readonly skippedKeys: readonly string[]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function stageLocalUploads(
|
|
118
|
+
stage: string,
|
|
119
|
+
uploadsDir: string,
|
|
120
|
+
log: BackupLog,
|
|
121
|
+
): Promise<StagedUploads> {
|
|
122
|
+
const exists = await stat(uploadsDir).then(
|
|
123
|
+
(info) => info.isDirectory(),
|
|
124
|
+
() => false,
|
|
125
|
+
)
|
|
126
|
+
if (!exists) {
|
|
127
|
+
log.info(`No uploads directory at ${uploadsDir}; the bundle carries none.`)
|
|
128
|
+
return { uploads: 'skipped', skippedKeys: [] }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await run('tar', ['czf', path.join(stage, 'uploads.tar.gz'), '-C', uploadsDir, '.'])
|
|
132
|
+
return { uploads: 'included', skippedKeys: [] }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function stageObjectStoreUploads(
|
|
136
|
+
stage: string,
|
|
137
|
+
store: ListableStore,
|
|
138
|
+
origin: string,
|
|
139
|
+
log: BackupLog,
|
|
140
|
+
): Promise<StagedUploads> {
|
|
141
|
+
const dir = path.join(stage, 'uploads')
|
|
142
|
+
await mkdir(dir, { recursive: true })
|
|
143
|
+
|
|
144
|
+
const { pulled, skipped } = await drainStoreToDirectory(store, dir, (line) => log.warn(line))
|
|
145
|
+
|
|
146
|
+
if (pulled === 0) {
|
|
147
|
+
log.info(`Found no objects in ${origin}; the bundle carries no uploads.`)
|
|
148
|
+
await rm(dir, { recursive: true, force: true })
|
|
149
|
+
return { uploads: 'skipped', skippedKeys: skipped }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await run('tar', ['czf', path.join(stage, 'uploads.tar.gz'), '-C', dir, '.'])
|
|
153
|
+
await rm(dir, { recursive: true, force: true })
|
|
154
|
+
log.info(`Pulled ${pulled} object(s) from ${origin}.`)
|
|
155
|
+
return { uploads: 'included', skippedKeys: skipped }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function stageUploads(
|
|
159
|
+
stage: string,
|
|
160
|
+
source: BackupSource,
|
|
161
|
+
mode: UploadsMode,
|
|
162
|
+
log: BackupLog,
|
|
163
|
+
): Promise<StagedUploads> {
|
|
164
|
+
if (mode === 'skip') return { uploads: 'skipped', skippedKeys: [] }
|
|
165
|
+
|
|
166
|
+
if (source.filestore === 'local') return stageLocalUploads(stage, source.uploadsDir, log)
|
|
167
|
+
|
|
168
|
+
if (source.objectStore === undefined) {
|
|
169
|
+
throw new ValidationError(
|
|
170
|
+
`The uploads live in a ${source.filestore} store and no store was given to read them from.`,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
return stageObjectStoreUploads(stage, source.objectStore.store, source.objectStore.origin, log)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function createBackup(input: CreateBackupInput): Promise<BackupOutcome> {
|
|
177
|
+
const log = input.log ?? SILENT_LOG
|
|
178
|
+
const { source, target } = input
|
|
179
|
+
if (target.out !== undefined && target.dir !== undefined) {
|
|
180
|
+
throw new ValidationError('--out and --dir are two answers to one question; pass one.')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const now = input.now ?? new Date()
|
|
184
|
+
const name = bundleName(now)
|
|
185
|
+
if (target.dir !== undefined) await mkdir(path.resolve(target.dir), { recursive: true })
|
|
186
|
+
const out = path.resolve(
|
|
187
|
+
target.dir === undefined ? (target.out ?? name) : path.join(target.dir, name),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const stage = await mkdtemp(path.join(tmpdir(), 'meith-backup-'))
|
|
191
|
+
let destinationCreated = false
|
|
192
|
+
try {
|
|
193
|
+
await claimBackupDestination(out, input.translateWriteError)
|
|
194
|
+
destinationCreated = true
|
|
195
|
+
|
|
196
|
+
log.info(
|
|
197
|
+
source.databaseVariable === 'DIRECT_DATABASE_URL'
|
|
198
|
+
? 'Dumping the database over DIRECT_DATABASE_URL…'
|
|
199
|
+
: 'Dumping the database…',
|
|
200
|
+
)
|
|
201
|
+
const databaseEnvironment = postgresClientEnvironment(
|
|
202
|
+
source.databaseUrl,
|
|
203
|
+
source.databaseVariable,
|
|
204
|
+
)
|
|
205
|
+
await run(
|
|
206
|
+
'pg_dump',
|
|
207
|
+
['--format=custom', '--no-owner', '--no-privileges', '--file', path.join(stage, 'db.dump')],
|
|
208
|
+
databaseEnvironment,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
const { uploads, skippedKeys } = await stageUploads(stage, source, input.uploads, log)
|
|
212
|
+
|
|
213
|
+
const bucket = source.objectStore?.bucket
|
|
214
|
+
const manifest: BackupManifest = {
|
|
215
|
+
format: 1,
|
|
216
|
+
createdAt: now.toISOString(),
|
|
217
|
+
version: source.version,
|
|
218
|
+
filestore: source.filestore,
|
|
219
|
+
uploads,
|
|
220
|
+
...(bucket === undefined ? {} : { bucket }),
|
|
221
|
+
...(skippedKeys.length === 0 ? {} : { skippedKeys }),
|
|
222
|
+
}
|
|
223
|
+
await writeFile(path.join(stage, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
224
|
+
|
|
225
|
+
const members = ['manifest.json', 'db.dump']
|
|
226
|
+
if (uploads === 'included') members.push('uploads.tar.gz')
|
|
227
|
+
await run('tar', ['czf', out, '-C', stage, ...members])
|
|
228
|
+
await chmod(out, 0o600)
|
|
229
|
+
|
|
230
|
+
const size = (await stat(out)).size
|
|
231
|
+
destinationCreated = false
|
|
232
|
+
log.info(
|
|
233
|
+
`Wrote ${out} (${formatBytes(size)}): the database dump${
|
|
234
|
+
uploads === 'included' ? ' and the uploads' : ', no uploads'
|
|
235
|
+
}.`,
|
|
236
|
+
)
|
|
237
|
+
if (uploads === 'skipped' && source.filestore === 's3' && input.uploads !== 'include') {
|
|
238
|
+
log.info(
|
|
239
|
+
'The S3 bucket was not pulled — it has its own backup story. ' +
|
|
240
|
+
'Run with --uploads include for a bundle that carries every object.',
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
if (uploads === 'skipped' && input.uploads === 'skip') {
|
|
244
|
+
log.info('Restoring this bundle gives a board whose posts have broken images.')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let shipped: string | null = null
|
|
248
|
+
let prunedRemote: readonly string[] = []
|
|
249
|
+
if (target.destination !== undefined) {
|
|
250
|
+
try {
|
|
251
|
+
await target.destination.putFile(name, out, size)
|
|
252
|
+
shipped = target.destination.description
|
|
253
|
+
log.info(`Shipped ${name} to ${shipped}.`)
|
|
254
|
+
prunedRemote = await target.destination.prune(target.retention, now)
|
|
255
|
+
} catch (error) {
|
|
256
|
+
throw new BackupShippingError(error, { name, size, uploads, skippedKeys })
|
|
257
|
+
}
|
|
258
|
+
if (prunedRemote.length > 0) {
|
|
259
|
+
log.info(
|
|
260
|
+
`Pruned ${prunedRemote.length} bundle(s) there beyond the retention policy: ` +
|
|
261
|
+
`${prunedRemote.join(', ')}.`,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
log.info(
|
|
266
|
+
'Copy the bundle off this machine: a backup on the server is a backup of the ' +
|
|
267
|
+
'thing most likely to fail.',
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let prunedLocal: readonly string[] = []
|
|
272
|
+
if (target.dir !== undefined) {
|
|
273
|
+
const dir = path.resolve(target.dir)
|
|
274
|
+
prunedLocal = retentionCandidates(await readdir(dir), target.retention, now)
|
|
275
|
+
for (const staleName of prunedLocal) await rm(path.join(dir, staleName), { force: true })
|
|
276
|
+
if (prunedLocal.length > 0) {
|
|
277
|
+
log.info(
|
|
278
|
+
`Pruned ${prunedLocal.length} bundle(s) in ${dir} beyond the retention policy: ` +
|
|
279
|
+
`${prunedLocal.join(', ')}.`,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
path: out,
|
|
286
|
+
name,
|
|
287
|
+
size,
|
|
288
|
+
createdAt: now,
|
|
289
|
+
uploads,
|
|
290
|
+
skippedKeys,
|
|
291
|
+
shipped,
|
|
292
|
+
prunedLocal,
|
|
293
|
+
prunedRemote,
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
if (destinationCreated) await rm(out, { force: true })
|
|
297
|
+
await rm(stage, { recursive: true, force: true })
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export interface LocalBundle {
|
|
302
|
+
readonly name: string
|
|
303
|
+
readonly size: number
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function localBundles(dir: string): Promise<readonly LocalBundle[]> {
|
|
307
|
+
const names = await readdir(dir).catch((error: NodeJS.ErrnoException) => {
|
|
308
|
+
if (error.code === 'ENOENT') return []
|
|
309
|
+
throw error
|
|
310
|
+
})
|
|
311
|
+
const bundles: LocalBundle[] = []
|
|
312
|
+
for (const name of names.filter((entry) => isBundleName(entry)).sort()) {
|
|
313
|
+
bundles.push({ name, size: (await stat(path.join(dir, name))).size })
|
|
314
|
+
}
|
|
315
|
+
return bundles
|
|
316
|
+
}
|