@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
|
@@ -0,0 +1,449 @@
|
|
|
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
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
12
|
+
|
|
13
|
+
import { ConfigurationError, ValidationError } from '@meith/core'
|
|
14
|
+
|
|
15
|
+
import { isBundleName } from './bundle'
|
|
16
|
+
import { type RetentionPolicy, retentionCandidates } from './retention'
|
|
17
|
+
import { WebDavBackupDestination } from './webdav'
|
|
18
|
+
|
|
19
|
+
export interface RemoteBundle {
|
|
20
|
+
readonly name: string
|
|
21
|
+
readonly size: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RemoteBundleBody {
|
|
25
|
+
readonly body: ReadableStream<Uint8Array>
|
|
26
|
+
readonly size: number | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BackupDestination {
|
|
30
|
+
readonly description: string
|
|
31
|
+
list(): Promise<readonly RemoteBundle[]>
|
|
32
|
+
putFile(name: string, filePath: string, size: number): Promise<void>
|
|
33
|
+
getToFile(name: string, outPath: string): Promise<void>
|
|
34
|
+
open(name: string): Promise<RemoteBundleBody | null>
|
|
35
|
+
delete(name: string): Promise<void>
|
|
36
|
+
prune(policy: RetentionPolicy, now?: Date): Promise<readonly string[]>
|
|
37
|
+
downloadUrl?(name: string, expiresInSeconds: number): Promise<string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface S3DestinationConfig {
|
|
41
|
+
readonly kind: 's3'
|
|
42
|
+
readonly bucket: string
|
|
43
|
+
readonly region: string
|
|
44
|
+
readonly accessKeyId: string
|
|
45
|
+
readonly secretAccessKey: string
|
|
46
|
+
readonly endpoint?: string | undefined
|
|
47
|
+
readonly prefix?: string | undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WebDavDestinationConfig {
|
|
51
|
+
readonly kind: 'webdav'
|
|
52
|
+
readonly url: string
|
|
53
|
+
readonly username: string
|
|
54
|
+
readonly password: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type BackupDestinationConfig = S3DestinationConfig | WebDavDestinationConfig
|
|
58
|
+
|
|
59
|
+
export type BackupDestinationKind = BackupDestinationConfig['kind']
|
|
60
|
+
|
|
61
|
+
export type BackupDestinationSource = 'environment' | 'board' | 'none'
|
|
62
|
+
|
|
63
|
+
export interface BackupDestinationResolution {
|
|
64
|
+
readonly source: BackupDestinationSource
|
|
65
|
+
readonly config: BackupDestinationConfig | null
|
|
66
|
+
readonly problem: string | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const BACKUP_DESTINATION_KEYS = [
|
|
70
|
+
'BACKUP_S3_BUCKET',
|
|
71
|
+
'BACKUP_S3_REGION',
|
|
72
|
+
'BACKUP_S3_ACCESS_KEY_ID',
|
|
73
|
+
'BACKUP_S3_SECRET_ACCESS_KEY',
|
|
74
|
+
] as const
|
|
75
|
+
|
|
76
|
+
export const BACKUP_WEBDAV_KEYS = [
|
|
77
|
+
'BACKUP_WEBDAV_URL',
|
|
78
|
+
'BACKUP_WEBDAV_USERNAME',
|
|
79
|
+
'BACKUP_WEBDAV_PASSWORD',
|
|
80
|
+
] as const
|
|
81
|
+
|
|
82
|
+
export type BackupDestinationEnvironment = {
|
|
83
|
+
readonly [K in
|
|
84
|
+
| (typeof BACKUP_DESTINATION_KEYS)[number]
|
|
85
|
+
| (typeof BACKUP_WEBDAV_KEYS)[number]
|
|
86
|
+
| 'BACKUP_S3_ENDPOINT'
|
|
87
|
+
| 'BACKUP_S3_PREFIX']?: string | undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function usableWebDavUrl(value: string): string | null {
|
|
91
|
+
let url: URL
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(value.trim())
|
|
94
|
+
} catch {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
|
|
98
|
+
if (url.search !== '' || url.hash !== '') return null
|
|
99
|
+
return url.href.endsWith('/') ? url.href : `${url.href}/`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function webDavFromEnv(
|
|
103
|
+
environment: BackupDestinationEnvironment,
|
|
104
|
+
): WebDavDestinationConfig | undefined {
|
|
105
|
+
const url = environment.BACKUP_WEBDAV_URL
|
|
106
|
+
const username = environment.BACKUP_WEBDAV_USERNAME ?? ''
|
|
107
|
+
const password = environment.BACKUP_WEBDAV_PASSWORD ?? ''
|
|
108
|
+
if (url === undefined || url === '') {
|
|
109
|
+
if (username !== '' || password !== '') {
|
|
110
|
+
throw new ConfigurationError(
|
|
111
|
+
'BACKUP_WEBDAV_USERNAME or BACKUP_WEBDAV_PASSWORD is set without BACKUP_WEBDAV_URL.',
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
const usable = usableWebDavUrl(url)
|
|
117
|
+
if (usable === null) {
|
|
118
|
+
throw new ConfigurationError(
|
|
119
|
+
'BACKUP_WEBDAV_URL must be an http:// or https:// address of a collection, ' +
|
|
120
|
+
'with no query string.',
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
if ((username === '') !== (password === '')) {
|
|
124
|
+
throw new ConfigurationError(
|
|
125
|
+
'BACKUP_WEBDAV_USERNAME and BACKUP_WEBDAV_PASSWORD are set together, or not at all.',
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
return { kind: 'webdav', url: usable, username, password }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalisePrefix(raw: string | undefined): string | undefined {
|
|
132
|
+
const prefix = raw?.replace(/^\/+|\/+$/g, '')
|
|
133
|
+
if (prefix === undefined || prefix === '') return undefined
|
|
134
|
+
if (
|
|
135
|
+
prefix.split('/').some((segment) => !/^[\w!.*'()-]+$/.test(segment) || /^\.+$/.test(segment))
|
|
136
|
+
) {
|
|
137
|
+
throw new ConfigurationError(
|
|
138
|
+
'The backup prefix must be one or more path segments of unreserved characters.',
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return prefix
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function backupDestinationFromEnv(
|
|
145
|
+
environment: BackupDestinationEnvironment,
|
|
146
|
+
): BackupDestinationConfig | undefined {
|
|
147
|
+
const set = BACKUP_DESTINATION_KEYS.filter(
|
|
148
|
+
(key) => environment[key] !== undefined && environment[key] !== '',
|
|
149
|
+
)
|
|
150
|
+
if (set.length === 0) return webDavFromEnv(environment)
|
|
151
|
+
if (set.length < BACKUP_DESTINATION_KEYS.length) {
|
|
152
|
+
const missing = BACKUP_DESTINATION_KEYS.filter((key) => !set.includes(key))
|
|
153
|
+
throw new ConfigurationError(
|
|
154
|
+
`An off-site backup destination is partly configured: ${set.join(', ')} without ` +
|
|
155
|
+
`${missing.join(', ')}. Set all four, or none.`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (webDavFromEnv(environment) !== undefined) {
|
|
160
|
+
throw new ConfigurationError(
|
|
161
|
+
'Both BACKUP_S3_* and BACKUP_WEBDAV_* are set. A board ships its bundles to one ' +
|
|
162
|
+
'destination; unset one of the two.',
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
kind: 's3',
|
|
168
|
+
bucket: environment.BACKUP_S3_BUCKET as string,
|
|
169
|
+
region: environment.BACKUP_S3_REGION as string,
|
|
170
|
+
accessKeyId: environment.BACKUP_S3_ACCESS_KEY_ID as string,
|
|
171
|
+
secretAccessKey: environment.BACKUP_S3_SECRET_ACCESS_KEY as string,
|
|
172
|
+
endpoint: environment.BACKUP_S3_ENDPOINT || undefined,
|
|
173
|
+
prefix: normalisePrefix(environment.BACKUP_S3_PREFIX),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface BackupDestinationSettings {
|
|
178
|
+
readonly kind: 'none' | 's3' | 'webdav'
|
|
179
|
+
readonly bucket: string
|
|
180
|
+
readonly region: string
|
|
181
|
+
readonly accessKeyId: string
|
|
182
|
+
readonly secretAccessKey: string
|
|
183
|
+
readonly endpoint: string
|
|
184
|
+
readonly prefix: string
|
|
185
|
+
readonly webdavUrl: string
|
|
186
|
+
readonly webdavUsername: string
|
|
187
|
+
readonly webdavPassword: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function webDavFromSettings(settings: BackupDestinationSettings): BackupDestinationResolution {
|
|
191
|
+
const url = usableWebDavUrl(settings.webdavUrl)
|
|
192
|
+
if (settings.webdavUrl.trim() === '') {
|
|
193
|
+
return { source: 'board', config: null, problem: 'The WebDAV destination has no address.' }
|
|
194
|
+
}
|
|
195
|
+
if (url === null) {
|
|
196
|
+
return {
|
|
197
|
+
source: 'board',
|
|
198
|
+
config: null,
|
|
199
|
+
problem:
|
|
200
|
+
'The WebDAV address must be an http:// or https:// address of a folder, with no ' +
|
|
201
|
+
'query string.',
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const username = settings.webdavUsername.trim()
|
|
205
|
+
if ((username === '') !== (settings.webdavPassword === '')) {
|
|
206
|
+
return {
|
|
207
|
+
source: 'board',
|
|
208
|
+
config: null,
|
|
209
|
+
problem: 'The WebDAV username and password go together: fill in both, or neither.',
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
source: 'board',
|
|
214
|
+
config: { kind: 'webdav', url, username, password: settings.webdavPassword },
|
|
215
|
+
problem: null,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function backupDestinationFromSettings(
|
|
220
|
+
settings: BackupDestinationSettings,
|
|
221
|
+
): BackupDestinationResolution {
|
|
222
|
+
if (settings.kind === 'none') return { source: 'none', config: null, problem: null }
|
|
223
|
+
if (settings.kind === 'webdav') return webDavFromSettings(settings)
|
|
224
|
+
|
|
225
|
+
const bucket = settings.bucket.trim()
|
|
226
|
+
if (bucket === '') {
|
|
227
|
+
return { source: 'board', config: null, problem: 'The S3 destination names no bucket.' }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const missing: string[] = []
|
|
231
|
+
if (settings.region.trim() === '') missing.push('a region')
|
|
232
|
+
if (settings.accessKeyId.trim() === '') missing.push('an access key id')
|
|
233
|
+
if (settings.secretAccessKey === '') missing.push('a secret access key')
|
|
234
|
+
if (missing.length > 0) {
|
|
235
|
+
return {
|
|
236
|
+
source: 'board',
|
|
237
|
+
config: null,
|
|
238
|
+
problem: `The destination names the ${bucket} bucket without ${missing.join(', ')}.`,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
return {
|
|
244
|
+
source: 'board',
|
|
245
|
+
config: {
|
|
246
|
+
kind: 's3',
|
|
247
|
+
bucket,
|
|
248
|
+
region: settings.region.trim(),
|
|
249
|
+
accessKeyId: settings.accessKeyId.trim(),
|
|
250
|
+
secretAccessKey: settings.secretAccessKey,
|
|
251
|
+
endpoint: settings.endpoint.trim() === '' ? undefined : settings.endpoint.trim(),
|
|
252
|
+
prefix: normalisePrefix(settings.prefix),
|
|
253
|
+
},
|
|
254
|
+
problem: null,
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return {
|
|
258
|
+
source: 'board',
|
|
259
|
+
config: null,
|
|
260
|
+
problem: error instanceof Error ? error.message : String(error),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function resolveBackupDestination(input: {
|
|
266
|
+
readonly environment: BackupDestinationEnvironment
|
|
267
|
+
readonly settings: BackupDestinationSettings | null
|
|
268
|
+
}): BackupDestinationResolution {
|
|
269
|
+
try {
|
|
270
|
+
const fromEnvironment = backupDestinationFromEnv(input.environment)
|
|
271
|
+
if (fromEnvironment !== undefined) {
|
|
272
|
+
return { source: 'environment', config: fromEnvironment, problem: null }
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return {
|
|
276
|
+
source: 'environment',
|
|
277
|
+
config: null,
|
|
278
|
+
problem: error instanceof Error ? error.message : String(error),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (input.settings === null) return { source: 'none', config: null, problem: null }
|
|
283
|
+
return backupDestinationFromSettings(input.settings)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface S3Like {
|
|
287
|
+
send(command: unknown): Promise<unknown>
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isNotFound(error: unknown): boolean {
|
|
291
|
+
const name = (error as { name?: string } | null)?.name
|
|
292
|
+
const status = (error as { $metadata?: { httpStatusCode?: number } } | null)?.$metadata
|
|
293
|
+
?.httpStatusCode
|
|
294
|
+
|
|
295
|
+
return name === 'NoSuchKey' || name === 'NotFound' || status === 404
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export class S3BackupDestination implements BackupDestination {
|
|
299
|
+
private readonly sender: S3Like
|
|
300
|
+
|
|
301
|
+
private readonly signingClient: S3Client
|
|
302
|
+
|
|
303
|
+
constructor(
|
|
304
|
+
private readonly config: S3DestinationConfig,
|
|
305
|
+
sender?: S3Like,
|
|
306
|
+
) {
|
|
307
|
+
this.signingClient = new S3Client({
|
|
308
|
+
region: config.region,
|
|
309
|
+
credentials: {
|
|
310
|
+
accessKeyId: config.accessKeyId,
|
|
311
|
+
secretAccessKey: config.secretAccessKey,
|
|
312
|
+
},
|
|
313
|
+
...(config.endpoint === undefined
|
|
314
|
+
? {}
|
|
315
|
+
: ({
|
|
316
|
+
endpoint: config.endpoint,
|
|
317
|
+
forcePathStyle: true,
|
|
318
|
+
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
319
|
+
} as const)),
|
|
320
|
+
})
|
|
321
|
+
this.sender = sender ?? this.signingClient
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
get description(): string {
|
|
325
|
+
return this.config.prefix === undefined
|
|
326
|
+
? `the ${this.config.bucket} bucket`
|
|
327
|
+
: `the ${this.config.bucket} bucket under ${this.config.prefix}/`
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private key(name: string): string {
|
|
331
|
+
if (!isBundleName(name)) {
|
|
332
|
+
throw new ValidationError(`Not a backup bundle name: ${JSON.stringify(name)}`)
|
|
333
|
+
}
|
|
334
|
+
return this.config.prefix === undefined ? name : `${this.config.prefix}/${name}`
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async putFile(name: string, filePath: string, size: number): Promise<void> {
|
|
338
|
+
await this.sender.send(
|
|
339
|
+
new PutObjectCommand({
|
|
340
|
+
Bucket: this.config.bucket,
|
|
341
|
+
Key: this.key(name),
|
|
342
|
+
Body: createReadStream(filePath),
|
|
343
|
+
ContentLength: size,
|
|
344
|
+
ContentType: 'application/gzip',
|
|
345
|
+
}),
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async list(): Promise<readonly RemoteBundle[]> {
|
|
350
|
+
const prefix = this.config.prefix === undefined ? '' : `${this.config.prefix}/`
|
|
351
|
+
const bundles: RemoteBundle[] = []
|
|
352
|
+
let continuationToken: string | undefined
|
|
353
|
+
|
|
354
|
+
do {
|
|
355
|
+
const response = (await this.sender.send(
|
|
356
|
+
new ListObjectsV2Command({
|
|
357
|
+
Bucket: this.config.bucket,
|
|
358
|
+
...(prefix === '' ? {} : { Prefix: prefix }),
|
|
359
|
+
...(continuationToken === undefined ? {} : { ContinuationToken: continuationToken }),
|
|
360
|
+
}),
|
|
361
|
+
)) as {
|
|
362
|
+
Contents?: readonly { Key?: string; Size?: number }[]
|
|
363
|
+
IsTruncated?: boolean
|
|
364
|
+
NextContinuationToken?: string
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const object of response.Contents ?? []) {
|
|
368
|
+
if (object.Key === undefined || !object.Key.startsWith(prefix)) continue
|
|
369
|
+
const name = object.Key.slice(prefix.length)
|
|
370
|
+
if (isBundleName(name)) bundles.push({ name, size: object.Size ?? 0 })
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined
|
|
374
|
+
} while (continuationToken !== undefined)
|
|
375
|
+
|
|
376
|
+
return bundles.sort((a, b) => a.name.localeCompare(b.name))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async getToFile(name: string, outPath: string): Promise<void> {
|
|
380
|
+
let response: { Body?: NodeJS.ReadableStream }
|
|
381
|
+
try {
|
|
382
|
+
response = (await this.sender.send(
|
|
383
|
+
new GetObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }),
|
|
384
|
+
)) as { Body?: NodeJS.ReadableStream }
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (isNotFound(error)) {
|
|
387
|
+
throw new ValidationError(
|
|
388
|
+
`${this.description} has no bundle named ${name}. meith backup:list names what it holds.`,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
throw error
|
|
392
|
+
}
|
|
393
|
+
if (response.Body === undefined) {
|
|
394
|
+
throw new ConfigurationError(`${this.description} answered without a body for ${name}.`)
|
|
395
|
+
}
|
|
396
|
+
await pipeline(response.Body, createWriteStream(outPath, { mode: 0o600 }))
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async open(name: string): Promise<RemoteBundleBody | null> {
|
|
400
|
+
let response: {
|
|
401
|
+
Body?: { transformToWebStream(): ReadableStream<Uint8Array> }
|
|
402
|
+
ContentLength?: number
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
response = (await this.sender.send(
|
|
406
|
+
new GetObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }),
|
|
407
|
+
)) as typeof response
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (isNotFound(error)) return null
|
|
410
|
+
throw error
|
|
411
|
+
}
|
|
412
|
+
if (response.Body === undefined) return null
|
|
413
|
+
return { body: response.Body.transformToWebStream(), size: response.ContentLength ?? null }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async delete(name: string): Promise<void> {
|
|
417
|
+
await this.sender.send(
|
|
418
|
+
new DeleteObjectCommand({ Bucket: this.config.bucket, Key: this.key(name) }),
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async prune(policy: RetentionPolicy, now: Date = new Date()): Promise<readonly string[]> {
|
|
423
|
+
const stale = retentionCandidates(
|
|
424
|
+
(await this.list()).map((bundle) => bundle.name),
|
|
425
|
+
policy,
|
|
426
|
+
now,
|
|
427
|
+
)
|
|
428
|
+
for (const name of stale) await this.delete(name)
|
|
429
|
+
return stale
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async downloadUrl(name: string, expiresInSeconds: number): Promise<string> {
|
|
433
|
+
return getSignedUrl(
|
|
434
|
+
this.signingClient,
|
|
435
|
+
new GetObjectCommand({
|
|
436
|
+
Bucket: this.config.bucket,
|
|
437
|
+
Key: this.key(name),
|
|
438
|
+
ResponseContentDisposition: `attachment; filename="${name}"`,
|
|
439
|
+
}),
|
|
440
|
+
{ expiresIn: expiresInSeconds },
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function openBackupDestination(config: BackupDestinationConfig): BackupDestination {
|
|
446
|
+
return config.kind === 'webdav'
|
|
447
|
+
? new WebDavBackupDestination(config)
|
|
448
|
+
: new S3BackupDestination(config)
|
|
449
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ArchiveMember,
|
|
3
|
+
inspectArchive,
|
|
4
|
+
type RestoreLimits,
|
|
5
|
+
restoreLimits,
|
|
6
|
+
validateArchiveListing,
|
|
7
|
+
} from './archive'
|
|
8
|
+
export {
|
|
9
|
+
type BackupManifest,
|
|
10
|
+
BUNDLE_NAME_PATTERN,
|
|
11
|
+
bundleName,
|
|
12
|
+
bundleTakenAt,
|
|
13
|
+
contentTypeFor,
|
|
14
|
+
type FilestoreDriver,
|
|
15
|
+
formatBytes,
|
|
16
|
+
isBundleName,
|
|
17
|
+
parseManifest,
|
|
18
|
+
resolveUploadsMode,
|
|
19
|
+
skippedKeyLines,
|
|
20
|
+
type UploadsMode,
|
|
21
|
+
} from './bundle'
|
|
22
|
+
export { type BackupCapability, backupCapability } from './capability'
|
|
23
|
+
export {
|
|
24
|
+
type BackupLog,
|
|
25
|
+
type BackupOutcome,
|
|
26
|
+
BackupShippingError,
|
|
27
|
+
type BackupSource,
|
|
28
|
+
type BackupTarget,
|
|
29
|
+
type CreateBackupInput,
|
|
30
|
+
claimBackupDestination,
|
|
31
|
+
createBackup,
|
|
32
|
+
type LocalBundle,
|
|
33
|
+
localBundles,
|
|
34
|
+
reserveBackupDestination,
|
|
35
|
+
SILENT_LOG,
|
|
36
|
+
type WrittenBundle,
|
|
37
|
+
} from './create'
|
|
38
|
+
export {
|
|
39
|
+
BACKUP_DESTINATION_KEYS,
|
|
40
|
+
BACKUP_WEBDAV_KEYS,
|
|
41
|
+
type BackupDestination,
|
|
42
|
+
type BackupDestinationConfig,
|
|
43
|
+
type BackupDestinationEnvironment,
|
|
44
|
+
type BackupDestinationKind,
|
|
45
|
+
type BackupDestinationResolution,
|
|
46
|
+
type BackupDestinationSettings,
|
|
47
|
+
type BackupDestinationSource,
|
|
48
|
+
backupDestinationFromEnv,
|
|
49
|
+
backupDestinationFromSettings,
|
|
50
|
+
openBackupDestination,
|
|
51
|
+
type RemoteBundle,
|
|
52
|
+
type RemoteBundleBody,
|
|
53
|
+
resolveBackupDestination,
|
|
54
|
+
S3BackupDestination,
|
|
55
|
+
type S3DestinationConfig,
|
|
56
|
+
type S3Like,
|
|
57
|
+
usableWebDavUrl,
|
|
58
|
+
type WebDavDestinationConfig,
|
|
59
|
+
} from './destination'
|
|
60
|
+
export { postgresClientEnvironment } from './postgres-client'
|
|
61
|
+
export {
|
|
62
|
+
type RestoreInput,
|
|
63
|
+
type RestoreOutcome,
|
|
64
|
+
type RestoreTarget,
|
|
65
|
+
type RestoreTargetMode,
|
|
66
|
+
type RestoreUploadsPlan,
|
|
67
|
+
restoreBackup,
|
|
68
|
+
versionRefusal,
|
|
69
|
+
} from './restore'
|
|
70
|
+
export {
|
|
71
|
+
DEFAULT_KEEP,
|
|
72
|
+
pruneCandidates,
|
|
73
|
+
type RetentionPolicy,
|
|
74
|
+
resolveKeep,
|
|
75
|
+
retentionCandidates,
|
|
76
|
+
} from './retention'
|
|
77
|
+
export type {
|
|
78
|
+
BackupRunFinish,
|
|
79
|
+
BackupRunRecord,
|
|
80
|
+
BackupRunRepository,
|
|
81
|
+
BackupRunStatus,
|
|
82
|
+
BackupTrigger,
|
|
83
|
+
} from './runs'
|
|
84
|
+
export {
|
|
85
|
+
type BackupFrequency,
|
|
86
|
+
type BackupSchedule,
|
|
87
|
+
formatScheduleTime,
|
|
88
|
+
latestSlotAtOrBefore,
|
|
89
|
+
nextSlotAfter,
|
|
90
|
+
parseScheduleTime,
|
|
91
|
+
SCHEDULE_TIME_PATTERN,
|
|
92
|
+
scheduledBackupDue,
|
|
93
|
+
} from './schedule'
|
|
94
|
+
export {
|
|
95
|
+
type DrainedStore,
|
|
96
|
+
drainStoreToDirectory,
|
|
97
|
+
type ListableStore,
|
|
98
|
+
uploadDirectoryToStore,
|
|
99
|
+
} from './uploads'
|
|
100
|
+
export {
|
|
101
|
+
parsePropfind,
|
|
102
|
+
WebDavBackupDestination,
|
|
103
|
+
type WebDavRequester,
|
|
104
|
+
type WebDavResponse,
|
|
105
|
+
} from './webdav'
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
import { ConfigurationError, processEnvironment, ValidationError } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
export function missingToolError(command: string): ConfigurationError {
|
|
6
|
+
return new ConfigurationError(
|
|
7
|
+
`${command} was not found on PATH. The shipped image carries the postgres client ` +
|
|
8
|
+
'tools; elsewhere install them (postgresql18-client on Alpine, ' +
|
|
9
|
+
'postgresql-client on Debian and Ubuntu).',
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const POSTGRES_PARAMETERS: Readonly<Record<string, string>> = {
|
|
14
|
+
application_name: 'PGAPPNAME',
|
|
15
|
+
channel_binding: 'PGCHANNELBINDING',
|
|
16
|
+
connect_timeout: 'PGCONNECT_TIMEOUT',
|
|
17
|
+
gssencmode: 'PGGSSENCMODE',
|
|
18
|
+
options: 'PGOPTIONS',
|
|
19
|
+
requirepeer: 'PGREQUIREPEER',
|
|
20
|
+
sslcert: 'PGSSLCERT',
|
|
21
|
+
sslcompression: 'PGSSLCOMPRESSION',
|
|
22
|
+
sslcrl: 'PGSSLCRL',
|
|
23
|
+
sslcrldir: 'PGSSLCRLDIR',
|
|
24
|
+
sslkey: 'PGSSLKEY',
|
|
25
|
+
sslmode: 'PGSSLMODE',
|
|
26
|
+
sslpassword: 'PGSSLPASSWORD',
|
|
27
|
+
sslrootcert: 'PGSSLROOTCERT',
|
|
28
|
+
target_session_attrs: 'PGTARGETSESSIONATTRS',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const INHERITED_POSTGRES_VARIABLES = [
|
|
32
|
+
'PGAPPNAME',
|
|
33
|
+
'PGCHANNELBINDING',
|
|
34
|
+
'PGCONNECT_TIMEOUT',
|
|
35
|
+
'PGDATABASE',
|
|
36
|
+
'PGGSSENCMODE',
|
|
37
|
+
'PGHOST',
|
|
38
|
+
'PGHOSTADDR',
|
|
39
|
+
'PGOPTIONS',
|
|
40
|
+
'PGPASSWORD',
|
|
41
|
+
'PGPORT',
|
|
42
|
+
'PGREQUIREPEER',
|
|
43
|
+
'PGSERVICE',
|
|
44
|
+
'PGSERVICEFILE',
|
|
45
|
+
'PGSSLCERT',
|
|
46
|
+
'PGSSLCOMPRESSION',
|
|
47
|
+
'PGSSLCRL',
|
|
48
|
+
'PGSSLCRLDIR',
|
|
49
|
+
'PGSSLKEY',
|
|
50
|
+
'PGSSLMODE',
|
|
51
|
+
'PGSSLPASSWORD',
|
|
52
|
+
'PGSSLROOTCERT',
|
|
53
|
+
'PGTARGETSESSIONATTRS',
|
|
54
|
+
'PGUSER',
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
export function postgresClientEnvironment(
|
|
58
|
+
connectionString: string,
|
|
59
|
+
variable: string,
|
|
60
|
+
): NodeJS.ProcessEnv {
|
|
61
|
+
let url: URL
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(connectionString)
|
|
64
|
+
} catch {
|
|
65
|
+
throw new ValidationError(`${variable} must be a valid postgres:// connection string.`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (url.protocol !== 'postgres:' && url.protocol !== 'postgresql:') {
|
|
69
|
+
throw new ValidationError(`${variable} must be a postgres:// connection string.`)
|
|
70
|
+
}
|
|
71
|
+
let database: string
|
|
72
|
+
let username: string
|
|
73
|
+
let password: string
|
|
74
|
+
try {
|
|
75
|
+
database = decodeURIComponent(url.pathname.replace(/^\//, ''))
|
|
76
|
+
username = decodeURIComponent(url.username)
|
|
77
|
+
password = decodeURIComponent(url.password)
|
|
78
|
+
} catch {
|
|
79
|
+
throw new ValidationError(`${variable} contains invalid percent-encoding.`)
|
|
80
|
+
}
|
|
81
|
+
if (url.hostname === '' || username === '' || database === '') {
|
|
82
|
+
throw new ValidationError(`${variable} must include a host, user, and database name.`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const childEnv: NodeJS.ProcessEnv = processEnvironment()
|
|
86
|
+
for (const environmentVariable of INHERITED_POSTGRES_VARIABLES) {
|
|
87
|
+
delete childEnv[environmentVariable]
|
|
88
|
+
}
|
|
89
|
+
Object.assign(childEnv, {
|
|
90
|
+
PGHOST: url.hostname.replace(/^\[|\]$/g, ''),
|
|
91
|
+
PGPORT: url.port || '5432',
|
|
92
|
+
PGUSER: username,
|
|
93
|
+
PGDATABASE: database,
|
|
94
|
+
})
|
|
95
|
+
if (password !== '') childEnv.PGPASSWORD = password
|
|
96
|
+
|
|
97
|
+
for (const [parameter, environmentVariable] of Object.entries(POSTGRES_PARAMETERS)) {
|
|
98
|
+
const value = url.searchParams.get(parameter)
|
|
99
|
+
if (value !== null) childEnv[environmentVariable] = value
|
|
100
|
+
}
|
|
101
|
+
return childEnv
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function run(
|
|
105
|
+
command: string,
|
|
106
|
+
args: readonly string[],
|
|
107
|
+
childEnv: NodeJS.ProcessEnv = processEnvironment(),
|
|
108
|
+
input?: string,
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
return new Promise<string>((resolvePromise, reject) => {
|
|
111
|
+
const child = spawn(command, args, {
|
|
112
|
+
env: childEnv,
|
|
113
|
+
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
114
|
+
})
|
|
115
|
+
let stdout = ''
|
|
116
|
+
let stderr = ''
|
|
117
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
118
|
+
stdout += String(chunk)
|
|
119
|
+
})
|
|
120
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
121
|
+
stderr += String(chunk)
|
|
122
|
+
})
|
|
123
|
+
child.on('error', (error) => {
|
|
124
|
+
reject((error as NodeJS.ErrnoException).code === 'ENOENT' ? missingToolError(command) : error)
|
|
125
|
+
})
|
|
126
|
+
child.on('close', (code) => {
|
|
127
|
+
if (code === 0) resolvePromise(stdout)
|
|
128
|
+
else {
|
|
129
|
+
reject(
|
|
130
|
+
new ConfigurationError(
|
|
131
|
+
`${command} exited with ${code === null ? 'a signal' : `code ${code}`}.` +
|
|
132
|
+
(stderr.trim() === '' ? '' : `\n${stderr.trim()}`),
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
if (input !== undefined && child.stdin !== null) {
|
|
138
|
+
child.stdin.end(input)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
}
|