@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/index.ts
CHANGED
|
@@ -113,6 +113,10 @@ const commands: Command[] = [
|
|
|
113
113
|
)
|
|
114
114
|
|
|
115
115
|
const { runMigrations } = await import('@meith/db')
|
|
116
|
+
const { backupBeforeMigrating } = await import('@meith/runtime')
|
|
117
|
+
if ((await backupBeforeMigrating()) === 'taken') {
|
|
118
|
+
console.log('Took the backup the settings ask for before a migration.')
|
|
119
|
+
}
|
|
116
120
|
const applied = await runMigrations()
|
|
117
121
|
console.log(applied === 0 ? 'Already up to date.' : `Applied ${applied} migration(s).`)
|
|
118
122
|
return 0
|
|
@@ -182,7 +186,12 @@ const commands: Command[] = [
|
|
|
182
186
|
{
|
|
183
187
|
name: 'backup:list',
|
|
184
188
|
summary: 'List the backup bundles on local disk and at the off-site destination.',
|
|
185
|
-
usage:
|
|
189
|
+
usage: [
|
|
190
|
+
'meith backup:list [--dir <dir>]',
|
|
191
|
+
'',
|
|
192
|
+
'--dir defaults to BACKUP_DIR, the ring the admin panel and the scheduler use',
|
|
193
|
+
'(/backups in the shipped image).',
|
|
194
|
+
].join('\n'),
|
|
186
195
|
run: backupListCommand,
|
|
187
196
|
},
|
|
188
197
|
|
package/src/redaction.ts
CHANGED
|
@@ -11,6 +11,7 @@ export const SECRET_ENV_KEYS: ReadonlySet<string> = new Set([
|
|
|
11
11
|
'REDIS_URL',
|
|
12
12
|
'S3_SECRET_ACCESS_KEY',
|
|
13
13
|
'BACKUP_S3_SECRET_ACCESS_KEY',
|
|
14
|
+
'BACKUP_WEBDAV_PASSWORD',
|
|
14
15
|
'BLOB_READ_WRITE_TOKEN',
|
|
15
16
|
])
|
|
16
17
|
|
|
@@ -22,6 +23,7 @@ export const NOT_SECRET_DESPITE_THE_NAME: ReadonlySet<string> = new Set([
|
|
|
22
23
|
'S3_ACCESS_KEY_ID',
|
|
23
24
|
'BACKUP_S3_ACCESS_KEY_ID',
|
|
24
25
|
'BACKUP_S3_ENDPOINT',
|
|
26
|
+
'BACKUP_WEBDAV_URL',
|
|
25
27
|
'MAIL_SMTP_USERNAME',
|
|
26
28
|
])
|
|
27
29
|
|
package/src/upgrade.ts
CHANGED
|
@@ -9,10 +9,10 @@ import {
|
|
|
9
9
|
runMigrations,
|
|
10
10
|
} from '@meith/db'
|
|
11
11
|
import { type PluginDefinition, pluginNavigationPlacements } from '@meith/plugin-kit'
|
|
12
|
-
import { runPluginLifecycle } from '@meith/runtime'
|
|
12
|
+
import { backupBeforeMigrating, runPluginLifecycle } from '@meith/runtime'
|
|
13
13
|
import { type PluginUpgrade, planUpgrade, upgradeNotice } from '@meith/upgrade'
|
|
14
14
|
|
|
15
|
-
export const CODE_VERSION = '0.
|
|
15
|
+
export const CODE_VERSION = '0.34.0'
|
|
16
16
|
|
|
17
17
|
export function pluginUpgrades(plugins: readonly PluginDefinition[]): readonly PluginUpgrade[] {
|
|
18
18
|
return plugins.map((plugin) => ({
|
|
@@ -88,6 +88,9 @@ export async function upgrade(options: UpgradeOptions): Promise<number> {
|
|
|
88
88
|
return 0
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
if ((await backupBeforeMigrating()) === 'taken') {
|
|
92
|
+
options.log('Core: took the backup the settings ask for before a migration.')
|
|
93
|
+
}
|
|
91
94
|
const count = await runMigrations()
|
|
92
95
|
options.log(count === 0 ? 'Core: already up to date.' : `Core: applied ${count} migration(s).`)
|
|
93
96
|
|
package/src/backup-store.ts
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
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
|
-
}
|