@meith/cli 0.30.0 → 0.31.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-store.ts +221 -0
- package/src/backup.ts +140 -6
- package/src/index.ts +25 -2
- package/src/redaction.ts +3 -0
- package/src/upgrade.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "The operator CLI: migrations, backup and restore, imports, users and settings — and the meith bin that runs it against an external board workspace.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -22,19 +22,20 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"@aws-sdk/client-s3": "3.1111.0",
|
|
25
26
|
"tsx": "^4.23.12",
|
|
26
|
-
"@meith/accounts": "0.
|
|
27
|
-
"@meith/core": "0.
|
|
28
|
-
"@meith/db": "0.
|
|
29
|
-
"@meith/demo": "0.
|
|
30
|
-
"@meith/drivers": "0.
|
|
31
|
-
"@meith/forums": "0.
|
|
32
|
-
"@meith/plugin-kit": "0.
|
|
33
|
-
"@meith/profile-fields": "0.
|
|
34
|
-
"@meith/
|
|
35
|
-
"@meith/
|
|
36
|
-
"@meith/tasks": "0.
|
|
37
|
-
"create-meith": "0.
|
|
27
|
+
"@meith/accounts": "0.31.0",
|
|
28
|
+
"@meith/core": "0.31.0",
|
|
29
|
+
"@meith/db": "0.31.0",
|
|
30
|
+
"@meith/demo": "0.31.0",
|
|
31
|
+
"@meith/drivers": "0.31.0",
|
|
32
|
+
"@meith/forums": "0.31.0",
|
|
33
|
+
"@meith/plugin-kit": "0.31.0",
|
|
34
|
+
"@meith/profile-fields": "0.31.0",
|
|
35
|
+
"@meith/runtime": "0.31.0",
|
|
36
|
+
"@meith/settings": "0.31.0",
|
|
37
|
+
"@meith/tasks": "0.31.0",
|
|
38
|
+
"create-meith": "0.31.0"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"esbuild": "^0.28.2"
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs'
|
|
2
|
+
import { pipeline } from 'node:stream/promises'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DeleteObjectCommand,
|
|
6
|
+
GetObjectCommand,
|
|
7
|
+
ListObjectsV2Command,
|
|
8
|
+
PutObjectCommand,
|
|
9
|
+
S3Client,
|
|
10
|
+
} from '@aws-sdk/client-s3'
|
|
11
|
+
|
|
12
|
+
import { ConfigurationError, ValidationError } from '@meith/core'
|
|
13
|
+
|
|
14
|
+
export const BUNDLE_NAME_PATTERN = /^meith-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z\.tar\.gz$/
|
|
15
|
+
|
|
16
|
+
export function isBundleName(name: string): boolean {
|
|
17
|
+
return BUNDLE_NAME_PATTERN.test(name)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function pruneCandidates(names: readonly string[], keep: number): readonly string[] {
|
|
21
|
+
return names
|
|
22
|
+
.filter((name) => isBundleName(name))
|
|
23
|
+
.sort()
|
|
24
|
+
.reverse()
|
|
25
|
+
.slice(keep)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_KEEP = 7
|
|
29
|
+
|
|
30
|
+
export function resolveKeep(flag: string | undefined): number {
|
|
31
|
+
if (flag === undefined) return DEFAULT_KEEP
|
|
32
|
+
if (!/^\d+$/.test(flag) || Number(flag) < 1 || !Number.isSafeInteger(Number(flag))) {
|
|
33
|
+
throw new ValidationError(`--keep must be a whole number of bundles, 1 or more, got "${flag}".`)
|
|
34
|
+
}
|
|
35
|
+
return Number(flag)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BackupDestinationConfig {
|
|
39
|
+
readonly bucket: string
|
|
40
|
+
readonly region: string
|
|
41
|
+
readonly accessKeyId: string
|
|
42
|
+
readonly secretAccessKey: string
|
|
43
|
+
readonly endpoint?: string | undefined
|
|
44
|
+
readonly prefix?: string | undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BACKUP_DESTINATION_KEYS = [
|
|
48
|
+
'BACKUP_S3_BUCKET',
|
|
49
|
+
'BACKUP_S3_REGION',
|
|
50
|
+
'BACKUP_S3_ACCESS_KEY_ID',
|
|
51
|
+
'BACKUP_S3_SECRET_ACCESS_KEY',
|
|
52
|
+
] as const
|
|
53
|
+
|
|
54
|
+
export function backupDestinationFromEnv(
|
|
55
|
+
environment: NodeJS.ProcessEnv,
|
|
56
|
+
): BackupDestinationConfig | undefined {
|
|
57
|
+
const set = BACKUP_DESTINATION_KEYS.filter(
|
|
58
|
+
(key) => environment[key] !== undefined && environment[key] !== '',
|
|
59
|
+
)
|
|
60
|
+
if (set.length === 0) return undefined
|
|
61
|
+
if (set.length < BACKUP_DESTINATION_KEYS.length) {
|
|
62
|
+
const missing = BACKUP_DESTINATION_KEYS.filter((key) => !set.includes(key))
|
|
63
|
+
throw new ConfigurationError(
|
|
64
|
+
`An off-site backup destination is partly configured: ${set.join(', ')} without ` +
|
|
65
|
+
`${missing.join(', ')}. Set all four, or none.`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const prefix = environment.BACKUP_S3_PREFIX?.replace(/^\/+|\/+$/g, '')
|
|
70
|
+
if (
|
|
71
|
+
prefix !== undefined &&
|
|
72
|
+
prefix !== '' &&
|
|
73
|
+
prefix.split('/').some((segment) => !/^[\w!.*'()-]+$/.test(segment) || /^\.+$/.test(segment))
|
|
74
|
+
) {
|
|
75
|
+
throw new ConfigurationError(
|
|
76
|
+
'BACKUP_S3_PREFIX must be one or more path segments of unreserved characters.',
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
bucket: environment.BACKUP_S3_BUCKET as string,
|
|
82
|
+
region: environment.BACKUP_S3_REGION as string,
|
|
83
|
+
accessKeyId: environment.BACKUP_S3_ACCESS_KEY_ID as string,
|
|
84
|
+
secretAccessKey: environment.BACKUP_S3_SECRET_ACCESS_KEY as string,
|
|
85
|
+
endpoint: environment.BACKUP_S3_ENDPOINT || undefined,
|
|
86
|
+
prefix: prefix === '' ? undefined : prefix,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface S3Like {
|
|
91
|
+
send(command: unknown): Promise<unknown>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isNotFound(error: unknown): boolean {
|
|
95
|
+
const name = (error as { name?: string } | null)?.name
|
|
96
|
+
const status = (error as { $metadata?: { httpStatusCode?: number } } | null)?.$metadata
|
|
97
|
+
?.httpStatusCode
|
|
98
|
+
|
|
99
|
+
return name === 'NoSuchKey' || name === 'NotFound' || status === 404
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface RemoteBundle {
|
|
103
|
+
readonly name: string
|
|
104
|
+
readonly size: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class BackupStore {
|
|
108
|
+
private readonly sender: S3Like
|
|
109
|
+
|
|
110
|
+
constructor(
|
|
111
|
+
private readonly config: BackupDestinationConfig,
|
|
112
|
+
sender?: S3Like,
|
|
113
|
+
) {
|
|
114
|
+
this.sender =
|
|
115
|
+
sender ??
|
|
116
|
+
new S3Client({
|
|
117
|
+
region: config.region,
|
|
118
|
+
credentials: {
|
|
119
|
+
accessKeyId: config.accessKeyId,
|
|
120
|
+
secretAccessKey: config.secretAccessKey,
|
|
121
|
+
},
|
|
122
|
+
...(config.endpoint === undefined
|
|
123
|
+
? {}
|
|
124
|
+
: ({
|
|
125
|
+
endpoint: config.endpoint,
|
|
126
|
+
forcePathStyle: true,
|
|
127
|
+
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
128
|
+
} as const)),
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get destination(): string {
|
|
133
|
+
return this.config.prefix === undefined
|
|
134
|
+
? `the ${this.config.bucket} bucket`
|
|
135
|
+
: `the ${this.config.bucket} bucket under ${this.config.prefix}/`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private key(name: string): string {
|
|
139
|
+
if (!isBundleName(name)) {
|
|
140
|
+
throw new ValidationError(`Not a backup bundle name: ${JSON.stringify(name)}`)
|
|
141
|
+
}
|
|
142
|
+
return this.config.prefix === undefined ? name : `${this.config.prefix}/${name}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async putFile(name: string, filePath: string, size: number): Promise<void> {
|
|
146
|
+
await this.sender.send(
|
|
147
|
+
new PutObjectCommand({
|
|
148
|
+
Bucket: this.config.bucket,
|
|
149
|
+
Key: this.key(name),
|
|
150
|
+
Body: createReadStream(filePath),
|
|
151
|
+
ContentLength: size,
|
|
152
|
+
ContentType: 'application/gzip',
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async list(): Promise<readonly RemoteBundle[]> {
|
|
158
|
+
const prefix = this.config.prefix === undefined ? '' : `${this.config.prefix}/`
|
|
159
|
+
const bundles: RemoteBundle[] = []
|
|
160
|
+
let continuationToken: string | undefined
|
|
161
|
+
|
|
162
|
+
do {
|
|
163
|
+
const response = (await this.sender.send(
|
|
164
|
+
new ListObjectsV2Command({
|
|
165
|
+
Bucket: this.config.bucket,
|
|
166
|
+
...(prefix === '' ? {} : { Prefix: prefix }),
|
|
167
|
+
...(continuationToken === undefined ? {} : { ContinuationToken: continuationToken }),
|
|
168
|
+
}),
|
|
169
|
+
)) as {
|
|
170
|
+
Contents?: readonly { Key?: string; Size?: number }[]
|
|
171
|
+
IsTruncated?: boolean
|
|
172
|
+
NextContinuationToken?: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const object of response.Contents ?? []) {
|
|
176
|
+
if (object.Key === undefined || !object.Key.startsWith(prefix)) continue
|
|
177
|
+
const name = object.Key.slice(prefix.length)
|
|
178
|
+
if (isBundleName(name)) bundles.push({ name, size: object.Size ?? 0 })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined
|
|
182
|
+
} while (continuationToken !== undefined)
|
|
183
|
+
|
|
184
|
+
return bundles.sort((a, b) => a.name.localeCompare(b.name))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async getToFile(name: string, outPath: string): Promise<void> {
|
|
188
|
+
let response: { Body?: NodeJS.ReadableStream }
|
|
189
|
+
try {
|
|
190
|
+
response = (await this.sender.send(
|
|
191
|
+
new GetObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }),
|
|
192
|
+
)) as { Body?: NodeJS.ReadableStream }
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (isNotFound(error)) {
|
|
195
|
+
throw new ValidationError(
|
|
196
|
+
`${this.destination} has no bundle named ${name}. meith backup:list names what it holds.`,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
throw error
|
|
200
|
+
}
|
|
201
|
+
if (response.Body === undefined) {
|
|
202
|
+
throw new ConfigurationError(`${this.destination} answered without a body for ${name}.`)
|
|
203
|
+
}
|
|
204
|
+
await pipeline(response.Body, createWriteStream(outPath, { mode: 0o600 }))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async delete(name: string): Promise<void> {
|
|
208
|
+
await this.sender.send(
|
|
209
|
+
new DeleteObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }),
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async prune(keep: number): Promise<readonly string[]> {
|
|
214
|
+
const stale = pruneCandidates(
|
|
215
|
+
(await this.list()).map((bundle) => bundle.name),
|
|
216
|
+
keep,
|
|
217
|
+
)
|
|
218
|
+
for (const name of stale) await this.delete(name)
|
|
219
|
+
return stale
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/backup.ts
CHANGED
|
@@ -19,6 +19,13 @@ import { migrationUrl, runMigrations } from '@meith/db'
|
|
|
19
19
|
import { BlobFileStore, S3FileStore, unusableKeyReason } from '@meith/drivers'
|
|
20
20
|
|
|
21
21
|
import { optional, parseFlags } from './args'
|
|
22
|
+
import {
|
|
23
|
+
BackupStore,
|
|
24
|
+
backupDestinationFromEnv,
|
|
25
|
+
isBundleName,
|
|
26
|
+
pruneCandidates,
|
|
27
|
+
resolveKeep,
|
|
28
|
+
} from './backup-store'
|
|
22
29
|
import { requirePostgres } from './context'
|
|
23
30
|
import { CODE_VERSION } from './upgrade'
|
|
24
31
|
import { translateWriteError } from './write-errors'
|
|
@@ -537,8 +544,24 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
|
|
|
537
544
|
requirePostgres()
|
|
538
545
|
|
|
539
546
|
const mode = resolveUploadsMode(env.FILESTORE_DRIVER, optional(flags, 'uploads'))
|
|
547
|
+
const outFlag = optional(flags, 'out')
|
|
548
|
+
const dirFlag = optional(flags, 'dir')
|
|
549
|
+
if (outFlag !== undefined && dirFlag !== undefined) {
|
|
550
|
+
throw new ValidationError('--out and --dir are two answers to one question; pass one.')
|
|
551
|
+
}
|
|
552
|
+
const offsite = backupDestinationFromEnv(process.env)
|
|
553
|
+
const keepFlag = optional(flags, 'keep')
|
|
554
|
+
if (keepFlag !== undefined && dirFlag === undefined && offsite === undefined) {
|
|
555
|
+
throw new ValidationError(
|
|
556
|
+
'--keep prunes a ring of bundles, so it needs --dir, an off-site destination ' +
|
|
557
|
+
'(BACKUP_S3_*), or both.',
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
const keep = resolveKeep(keepFlag)
|
|
540
561
|
const now = new Date()
|
|
541
|
-
const
|
|
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))
|
|
542
565
|
|
|
543
566
|
const stage = await mkdtemp(path.join(tmpdir(), 'meith-backup-'))
|
|
544
567
|
let destinationCreated = false
|
|
@@ -579,6 +602,7 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
|
|
|
579
602
|
await chmod(out, 0o600)
|
|
580
603
|
|
|
581
604
|
const size = (await stat(out)).size
|
|
605
|
+
destinationCreated = false
|
|
582
606
|
console.log(
|
|
583
607
|
`Wrote ${out} (${formatBytes(size)}): the database dump${
|
|
584
608
|
uploads === 'included' ? ' and the uploads' : ', no uploads'
|
|
@@ -593,11 +617,37 @@ export async function backupCommand(args: readonly string[]): Promise<number> {
|
|
|
593
617
|
if (uploads === 'skipped' && mode === 'skip') {
|
|
594
618
|
console.log('Restoring this bundle gives a board whose posts have broken images.')
|
|
595
619
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
+
)
|
|
637
|
+
}
|
|
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
|
+
|
|
601
651
|
if (skippedKeys.length === 0) return 0
|
|
602
652
|
|
|
603
653
|
console.warn(
|
|
@@ -821,3 +871,87 @@ export async function restoreCommand(args: readonly string[]): Promise<number> {
|
|
|
821
871
|
await rm(stage, { recursive: true, force: true })
|
|
822
872
|
}
|
|
823
873
|
}
|
|
874
|
+
|
|
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
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export async function backupListCommand(args: readonly string[]): Promise<number> {
|
|
888
|
+
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
|
+
}
|
|
898
|
+
|
|
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
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
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) {
|
|
915
|
+
console.log(` ${bundle.name} ${formatBytes(bundle.size)}`)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return 0
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const FETCH_USAGE = 'Usage: meith backup:fetch <meith-backup-….tar.gz> [--out <path>]'
|
|
923
|
+
|
|
924
|
+
export async function backupFetchCommand(args: readonly string[]): Promise<number> {
|
|
925
|
+
const { flags, positional } = parseFlags(args)
|
|
926
|
+
|
|
927
|
+
const name = positional[0]
|
|
928
|
+
if (name === undefined) throw new ValidationError(FETCH_USAGE)
|
|
929
|
+
if (!isBundleName(path.basename(name))) {
|
|
930
|
+
throw new ValidationError(
|
|
931
|
+
`Not a backup bundle name: ${JSON.stringify(name)}. meith backup:list names what ` +
|
|
932
|
+
'the destination holds.',
|
|
933
|
+
)
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const offsite = backupDestinationFromEnv(process.env)
|
|
937
|
+
if (offsite === undefined) {
|
|
938
|
+
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.',
|
|
941
|
+
)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const store = new BackupStore(offsite)
|
|
945
|
+
const out = path.resolve(optional(flags, 'out') ?? path.basename(name))
|
|
946
|
+
await claimBackupDestination(out)
|
|
947
|
+
try {
|
|
948
|
+
await store.getToFile(path.basename(name), out)
|
|
949
|
+
} catch (error) {
|
|
950
|
+
await rm(out, { force: true })
|
|
951
|
+
throw error
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
console.log(`Fetched ${out} (${formatBytes((await stat(out)).size)}) from ${store.destination}.`)
|
|
955
|
+
console.log(`Restore it with: ${RESTORE_USAGE.replace('Usage: ', '')}`)
|
|
956
|
+
return 0
|
|
957
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import process from 'node:process'
|
|
|
3
3
|
|
|
4
4
|
import { type LoadedEnvFiles, loadEnvFiles } from '@meith/core/env-files'
|
|
5
5
|
|
|
6
|
-
import { backupCommand, restoreCommand } from './backup'
|
|
6
|
+
import { backupCommand, backupFetchCommand, backupListCommand, restoreCommand } from './backup'
|
|
7
7
|
import {
|
|
8
8
|
forumCreate,
|
|
9
9
|
settingDisplayValue,
|
|
@@ -166,10 +166,33 @@ const commands: Command[] = [
|
|
|
166
166
|
{
|
|
167
167
|
name: 'backup',
|
|
168
168
|
summary: 'Dump the database and the uploads into one restorable bundle.',
|
|
169
|
-
usage:
|
|
169
|
+
usage: [
|
|
170
|
+
'meith backup [--out <path> | --dir <dir>] [--keep <n>] [--uploads include|skip]',
|
|
171
|
+
'',
|
|
172
|
+
'--dir writes a timestamped bundle into a directory and, after a successful',
|
|
173
|
+
'write, prunes bundles there beyond the newest --keep (7 unless set).',
|
|
174
|
+
'With BACKUP_S3_BUCKET, BACKUP_S3_REGION, BACKUP_S3_ACCESS_KEY_ID and',
|
|
175
|
+
'BACKUP_S3_SECRET_ACCESS_KEY set (BACKUP_S3_ENDPOINT and BACKUP_S3_PREFIX',
|
|
176
|
+
'optional), every bundle is also shipped to that S3-compatible destination',
|
|
177
|
+
'and pruned there to the same --keep.',
|
|
178
|
+
].join('\n'),
|
|
170
179
|
run: backupCommand,
|
|
171
180
|
},
|
|
172
181
|
|
|
182
|
+
{
|
|
183
|
+
name: 'backup:list',
|
|
184
|
+
summary: 'List the backup bundles on local disk and at the off-site destination.',
|
|
185
|
+
usage: 'meith backup:list [--dir <dir>]',
|
|
186
|
+
run: backupListCommand,
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
name: 'backup:fetch',
|
|
191
|
+
summary: 'Download one bundle from the off-site destination (BACKUP_S3_*).',
|
|
192
|
+
usage: 'meith backup:fetch <meith-backup-….tar.gz> [--out <path>]',
|
|
193
|
+
run: backupFetchCommand,
|
|
194
|
+
},
|
|
195
|
+
|
|
173
196
|
{
|
|
174
197
|
name: 'restore',
|
|
175
198
|
summary: 'Restore a backup bundle into a new, empty database.',
|
package/src/redaction.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const SECRET_ENV_KEYS: ReadonlySet<string> = new Set([
|
|
|
10
10
|
'RESEND_API_KEY',
|
|
11
11
|
'REDIS_URL',
|
|
12
12
|
'S3_SECRET_ACCESS_KEY',
|
|
13
|
+
'BACKUP_S3_SECRET_ACCESS_KEY',
|
|
13
14
|
'BLOB_READ_WRITE_TOKEN',
|
|
14
15
|
])
|
|
15
16
|
|
|
@@ -19,6 +20,8 @@ export const NOT_SECRET_DESPITE_THE_NAME: ReadonlySet<string> = new Set([
|
|
|
19
20
|
'S3_PUBLIC_BASE_URL',
|
|
20
21
|
'APP_URL',
|
|
21
22
|
'S3_ACCESS_KEY_ID',
|
|
23
|
+
'BACKUP_S3_ACCESS_KEY_ID',
|
|
24
|
+
'BACKUP_S3_ENDPOINT',
|
|
22
25
|
'MAIL_SMTP_USERNAME',
|
|
23
26
|
])
|
|
24
27
|
|
package/src/upgrade.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { type PluginDefinition, pluginNavigationPlacements } from '@meith/plugin
|
|
|
11
11
|
import { runPluginLifecycle } from '@meith/runtime'
|
|
12
12
|
import { type PluginUpgrade, planUpgrade, upgradeNotice } from '@meith/upgrade'
|
|
13
13
|
|
|
14
|
-
export const CODE_VERSION = '0.
|
|
14
|
+
export const CODE_VERSION = '0.31.0'
|
|
15
15
|
|
|
16
16
|
export function pluginUpgrades(plugins: readonly PluginDefinition[]): readonly PluginUpgrade[] {
|
|
17
17
|
return plugins.map((plugin) => ({
|