@wpelevator/tug 0.5.0 → 0.7.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/README.md +3 -3
- package/dist/agent-jobs.d.ts +41 -0
- package/dist/agent-jobs.js +161 -0
- package/dist/agent.d.ts +8 -1
- package/dist/agent.js +76 -7
- package/dist/backup-check.d.ts +11 -0
- package/dist/backup-check.js +10 -0
- package/dist/backup-config.d.ts +52 -0
- package/dist/backup-config.js +276 -0
- package/dist/backup-destination.d.ts +22 -0
- package/dist/backup-destination.js +72 -0
- package/dist/backup-job.d.ts +32 -0
- package/dist/backup-job.js +352 -0
- package/dist/backup-restore.d.ts +67 -0
- package/dist/backup-restore.js +317 -0
- package/dist/backup-scheduler.d.ts +60 -0
- package/dist/backup-scheduler.js +418 -0
- package/dist/cli-build.d.ts +1 -1
- package/dist/cli-build.js +15 -2
- package/dist/cli-config.d.ts +45 -5
- package/dist/cli-config.js +269 -19
- package/dist/cli-live-transport.d.ts +1 -1
- package/dist/cli-live-transport.js +2 -2
- package/dist/cli-live.d.ts +2 -0
- package/dist/cli-live.js +1 -0
- package/dist/cli.js +226 -16
- package/dist/compose.js +5 -2
- package/dist/container-spec.d.ts +3 -2
- package/dist/container-spec.js +9 -34
- package/dist/control.js +253 -19
- package/dist/cron.d.ts +17 -0
- package/dist/cron.js +124 -0
- package/dist/db.d.ts +70 -0
- package/dist/db.js +7 -0
- package/dist/docker-api.d.ts +7 -0
- package/dist/live.d.ts +8 -0
- package/dist/live.js +84 -0
- package/dist/normalize.d.ts +6 -0
- package/dist/normalize.js +15 -1
- package/dist/protocol.d.ts +118 -7
- package/dist/protocol.js +7 -0
- package/dist/tokens.js +10 -0
- package/package.json +2 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parseCron } from "./cron.js";
|
|
3
|
+
export const DEFAULT_RETENTION = { daily: 7, weekly: 4, monthly: 6 };
|
|
4
|
+
export const DEFAULT_BACKUP_TIMEOUT_SEC = 2 * 3600;
|
|
5
|
+
export const DEFAULT_BACKUP_MEMORY = '512m';
|
|
6
|
+
/** Below this restic runs out of memory on any repository worth backing up. */
|
|
7
|
+
const MIN_MEMORY_BYTES = 128 * 1024 ** 2;
|
|
8
|
+
const MAX_MEMORY_BYTES = 16 * 1024 ** 3;
|
|
9
|
+
const MIN_TIMEOUT_SEC = 60;
|
|
10
|
+
const MAX_TIMEOUT_SEC = 24 * 3600;
|
|
11
|
+
const MAX_JITTER_SEC = 3600;
|
|
12
|
+
const RETENTION_KEYS = ['last', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
|
|
13
|
+
const COMMON_KEYS = ['schedule', 'retention', 'jitter', 'timeout', 'memory', 'writers'];
|
|
14
|
+
const SERVICE_KEYS = [...COMMON_KEYS, 'command', 'restore', 'filename', 'verify'];
|
|
15
|
+
const VOLUME_KEYS = [...COMMON_KEYS, 'exclude'];
|
|
16
|
+
const FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
17
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
/** `30m`, `2h`, `90s` or a number of seconds. */
|
|
19
|
+
export function parseBackupTimeout(value) {
|
|
20
|
+
if (typeof value === 'number')
|
|
21
|
+
return Number.isInteger(value) ? value : null;
|
|
22
|
+
const match = typeof value === 'string' ? value.match(/^(\d+)(s|m|h)$/) : null;
|
|
23
|
+
if (!match)
|
|
24
|
+
return null;
|
|
25
|
+
return Number(match[1]) * { s: 1, m: 60, h: 3600 }[match[2]];
|
|
26
|
+
}
|
|
27
|
+
/** Bytes in a Docker-style memory size (`256m`, `1g`, `1048576`), or null when it is not one. */
|
|
28
|
+
export function parseMemorySize(value) {
|
|
29
|
+
const match = typeof value === 'string' ? /^(\d+)([kmg]?)$/i.exec(value.trim()) : null;
|
|
30
|
+
if (!match)
|
|
31
|
+
return null;
|
|
32
|
+
return (Number(match[1]) *
|
|
33
|
+
{ '': 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[match[2].toLowerCase()]);
|
|
34
|
+
}
|
|
35
|
+
/** Deterministic delay in `[0, 900)` seconds so many sites on one node do not start together. */
|
|
36
|
+
export function defaultJitterSeconds(project, target) {
|
|
37
|
+
const digest = createHash('sha256').update(`${project}\0${target}`).digest();
|
|
38
|
+
return digest.readUInt32BE(0) % 900;
|
|
39
|
+
}
|
|
40
|
+
function validateRetention(label, value, errors) {
|
|
41
|
+
if (!isObject(value)) {
|
|
42
|
+
errors.push(`${label}.retention must be an object such as { daily: 7, weekly: 4 }`);
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const retention = {};
|
|
46
|
+
for (const [key, count] of Object.entries(value)) {
|
|
47
|
+
if (!RETENTION_KEYS.includes(key))
|
|
48
|
+
errors.push(`${label}.retention.${key} is not supported (supported: ${RETENTION_KEYS.join(', ')})`);
|
|
49
|
+
else if (!Number.isInteger(count) || count < 1)
|
|
50
|
+
errors.push(`${label}.retention.${key} must be a whole number of at least 1`);
|
|
51
|
+
else
|
|
52
|
+
retention[key] = count;
|
|
53
|
+
}
|
|
54
|
+
if (!Object.keys(value).length)
|
|
55
|
+
errors.push(`${label}.retention is empty, which would keep no snapshots; set at least one of ${RETENTION_KEYS.join(', ')}`);
|
|
56
|
+
return retention;
|
|
57
|
+
}
|
|
58
|
+
function validateSchedule(label, value, errors) {
|
|
59
|
+
if (typeof value !== 'string') {
|
|
60
|
+
errors.push(`${label}.schedule must be a cron string such as "0 3 * * *" or @daily`);
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
parseCron(value);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
errors.push(`${label}.schedule: ${error instanceof Error ? error.message : String(error)}`);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return value.trim();
|
|
71
|
+
}
|
|
72
|
+
/** Validates the project-level defaults in `x-tug.backup`. */
|
|
73
|
+
export function validateBackupDefaults(value, errors) {
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
return;
|
|
76
|
+
if (!isObject(value)) {
|
|
77
|
+
errors.push('x-tug.backup must be an object');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const key of Object.keys(value))
|
|
81
|
+
if (key !== 'schedule' && key !== 'retention')
|
|
82
|
+
errors.push(`x-tug.backup.${key} is not supported (supported: schedule, retention)`);
|
|
83
|
+
if (value.schedule !== undefined)
|
|
84
|
+
validateSchedule('x-tug.backup', value.schedule, errors);
|
|
85
|
+
if (value.retention !== undefined)
|
|
86
|
+
validateRetention('x-tug.backup', value.retention, errors);
|
|
87
|
+
}
|
|
88
|
+
function normalizeConfig(config) {
|
|
89
|
+
if (config === false)
|
|
90
|
+
return null;
|
|
91
|
+
if (config === true)
|
|
92
|
+
return {};
|
|
93
|
+
if (typeof config === 'string')
|
|
94
|
+
return { schedule: config };
|
|
95
|
+
return config;
|
|
96
|
+
}
|
|
97
|
+
/** The argv of a `command`: a list is used as is, a string runs through `sh -c` like a shell one-liner. */
|
|
98
|
+
export function commandArgv(command) {
|
|
99
|
+
if (typeof command === 'string')
|
|
100
|
+
return command.trim() ? ['sh', '-c', command] : null;
|
|
101
|
+
return Array.isArray(command) &&
|
|
102
|
+
command.length &&
|
|
103
|
+
command.every((part) => typeof part === 'string' && part)
|
|
104
|
+
? command
|
|
105
|
+
: null;
|
|
106
|
+
}
|
|
107
|
+
function serviceNodes(svc) {
|
|
108
|
+
const lp = svc['x-tug'] || {};
|
|
109
|
+
const placement = lp.placement || {};
|
|
110
|
+
return {
|
|
111
|
+
nodes: placement.nodes || [],
|
|
112
|
+
dynamic: Array.isArray(placement.labels) && placement.labels.length > 0,
|
|
113
|
+
global: lp.global === true || placement.global === true,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function mountedVolumes(svc) {
|
|
117
|
+
return (svc.volumes || []).flatMap((mount) => typeof mount === 'object' && mount && mount.type === 'volume' && mount.source
|
|
118
|
+
? [String(mount.source)]
|
|
119
|
+
: []);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolves and validates every `x-tug.backup` in a Compose file. Tug knows nothing about what a service
|
|
123
|
+
* is: a service backup is a command whose output is stored, a volume backup is its files. `project`
|
|
124
|
+
* seeds the default jitter. Errors name the key, so they can be shown to the operator as they are.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveBackups(compose, project = '') {
|
|
127
|
+
const errors = [];
|
|
128
|
+
const targets = [];
|
|
129
|
+
const defaults = compose['x-tug']?.backup;
|
|
130
|
+
const defaultSchedule = isObject(defaults) ? defaults.schedule : undefined;
|
|
131
|
+
const defaultRetention = (isObject(defaults) && isObject(defaults.retention) ? defaults.retention : DEFAULT_RETENTION);
|
|
132
|
+
const common = (label, kind, name, spec) => {
|
|
133
|
+
const allowed = kind === 'service' ? SERVICE_KEYS : VOLUME_KEYS;
|
|
134
|
+
for (const key of Object.keys(spec))
|
|
135
|
+
if (!allowed.includes(key))
|
|
136
|
+
errors.push(`${label}.${key} is not supported${key === 'command' || key === 'restore' || key === 'filename' || key === 'verify'
|
|
137
|
+
? ' on a volume; a volume backup archives its files'
|
|
138
|
+
: key === 'exclude'
|
|
139
|
+
? ' on a service; it applies to volumes'
|
|
140
|
+
: ''} (supported: ${allowed.join(', ')})`);
|
|
141
|
+
const before = errors.length;
|
|
142
|
+
const schedule = spec.schedule !== undefined
|
|
143
|
+
? validateSchedule(label, spec.schedule, errors)
|
|
144
|
+
: typeof defaultSchedule === 'string'
|
|
145
|
+
? defaultSchedule.trim()
|
|
146
|
+
: undefined;
|
|
147
|
+
if (spec.schedule === undefined && schedule === undefined)
|
|
148
|
+
errors.push(`${label} needs a schedule: set ${label}.schedule or x-tug.backup.schedule`);
|
|
149
|
+
const retention = spec.retention !== undefined
|
|
150
|
+
? validateRetention(label, spec.retention, errors)
|
|
151
|
+
: defaultRetention;
|
|
152
|
+
let jitterSeconds = defaultJitterSeconds(project, `${kind}:${name}`);
|
|
153
|
+
if (spec.jitter !== undefined) {
|
|
154
|
+
if (!Number.isInteger(spec.jitter) || spec.jitter < 0 || spec.jitter > MAX_JITTER_SEC)
|
|
155
|
+
errors.push(`${label}.jitter must be a whole number of seconds from 0 to ${MAX_JITTER_SEC}`);
|
|
156
|
+
else
|
|
157
|
+
jitterSeconds = spec.jitter;
|
|
158
|
+
}
|
|
159
|
+
let timeoutSec = DEFAULT_BACKUP_TIMEOUT_SEC;
|
|
160
|
+
if (spec.timeout !== undefined) {
|
|
161
|
+
const parsed = parseBackupTimeout(spec.timeout);
|
|
162
|
+
if (parsed === null || parsed < MIN_TIMEOUT_SEC || parsed > MAX_TIMEOUT_SEC)
|
|
163
|
+
errors.push(`${label}.timeout must be a duration from 1m to 24h such as 30m or 2h`);
|
|
164
|
+
else
|
|
165
|
+
timeoutSec = parsed;
|
|
166
|
+
}
|
|
167
|
+
let memory = DEFAULT_BACKUP_MEMORY;
|
|
168
|
+
if (spec.memory !== undefined) {
|
|
169
|
+
const bytes = parseMemorySize(spec.memory);
|
|
170
|
+
if (bytes === null || bytes < MIN_MEMORY_BYTES || bytes > MAX_MEMORY_BYTES)
|
|
171
|
+
errors.push(`${label}.memory must be a size from 128m to 16g such as 256m or 1g`);
|
|
172
|
+
else
|
|
173
|
+
memory = String(spec.memory).trim().toLowerCase();
|
|
174
|
+
}
|
|
175
|
+
if (spec.writers !== undefined) {
|
|
176
|
+
if (!Array.isArray(spec.writers) || spec.writers.some((w) => typeof w !== 'string'))
|
|
177
|
+
errors.push(`${label}.writers must be a list of service names`);
|
|
178
|
+
else
|
|
179
|
+
for (const writer of spec.writers)
|
|
180
|
+
if (!compose.services[writer])
|
|
181
|
+
errors.push(`${label}.writers: ${writer} is not a service`);
|
|
182
|
+
}
|
|
183
|
+
if (errors.length > before || !schedule || !retention)
|
|
184
|
+
return null;
|
|
185
|
+
return { schedule, retention, jitterSeconds, timeoutSec, memory };
|
|
186
|
+
};
|
|
187
|
+
for (const [name, svc] of Object.entries(compose.services)) {
|
|
188
|
+
const config = svc['x-tug']?.backup;
|
|
189
|
+
if (config === undefined)
|
|
190
|
+
continue;
|
|
191
|
+
const label = `${name}: x-tug.backup`;
|
|
192
|
+
const spec = normalizeConfig(config);
|
|
193
|
+
if (spec === null)
|
|
194
|
+
continue;
|
|
195
|
+
if (typeof spec !== 'object' || Array.isArray(spec)) {
|
|
196
|
+
errors.push(`${label} must be true, false, a cron string or an object`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const before = errors.length;
|
|
200
|
+
const base = common(label, 'service', name, spec);
|
|
201
|
+
const argv = spec.command === undefined ? null : commandArgv(spec.command);
|
|
202
|
+
if (spec.command === undefined)
|
|
203
|
+
errors.push(`${label}.command is required: the command to run in the ${name} container, whose output is stored (see the recipes in docs/backups.md)`);
|
|
204
|
+
else if (!argv)
|
|
205
|
+
errors.push(`${label}.command must be a non-empty string or list of strings`);
|
|
206
|
+
const restoreArgv = spec.restore === undefined ? undefined : commandArgv(spec.restore);
|
|
207
|
+
if (spec.restore !== undefined && !restoreArgv)
|
|
208
|
+
errors.push(`${label}.restore must be a non-empty string or list of strings`);
|
|
209
|
+
if (spec.filename !== undefined &&
|
|
210
|
+
(typeof spec.filename !== 'string' || !FILENAME_RE.test(spec.filename)))
|
|
211
|
+
errors.push(`${label}.filename must be a plain file name such as ${name}.sql`);
|
|
212
|
+
if (spec.verify !== undefined && (typeof spec.verify !== 'string' || !spec.verify))
|
|
213
|
+
errors.push(`${label}.verify must be text that appears at the end of the output`);
|
|
214
|
+
const { nodes, dynamic, global } = serviceNodes(svc);
|
|
215
|
+
const replicas = svc['x-tug']?.replicas ?? 1;
|
|
216
|
+
if (global || replicas > 1 || nodes.length > 1)
|
|
217
|
+
errors.push(`${label} needs a single instance; ${name} is global, replicated or placed on several nodes`);
|
|
218
|
+
if (errors.length > before || !base || !argv)
|
|
219
|
+
continue;
|
|
220
|
+
targets.push({
|
|
221
|
+
target: `service:${name}`,
|
|
222
|
+
kind: 'service',
|
|
223
|
+
name,
|
|
224
|
+
type: 'command',
|
|
225
|
+
...base,
|
|
226
|
+
command: argv,
|
|
227
|
+
restore: restoreArgv ?? undefined,
|
|
228
|
+
filename: spec.filename ?? `${name}.out`,
|
|
229
|
+
verify: spec.verify,
|
|
230
|
+
writers: spec.writers,
|
|
231
|
+
node: !dynamic && nodes.length === 1 ? nodes[0] : undefined,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
for (const [name, def] of Object.entries(compose.volumes || {})) {
|
|
235
|
+
const config = isObject(def) ? def['x-tug']?.backup : undefined;
|
|
236
|
+
if (config === undefined)
|
|
237
|
+
continue;
|
|
238
|
+
const label = `volumes.${name}: x-tug.backup`;
|
|
239
|
+
const spec = normalizeConfig(config);
|
|
240
|
+
if (spec === null)
|
|
241
|
+
continue;
|
|
242
|
+
if (typeof spec !== 'object' || Array.isArray(spec)) {
|
|
243
|
+
errors.push(`${label} must be true, false, a cron string or an object`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const before = errors.length;
|
|
247
|
+
const base = common(label, 'volume', name, spec);
|
|
248
|
+
if (spec.exclude !== undefined &&
|
|
249
|
+
(!Array.isArray(spec.exclude) ||
|
|
250
|
+
spec.exclude.some((pattern) => typeof pattern !== 'string' || !pattern)))
|
|
251
|
+
errors.push(`${label}.exclude must be a list of restic exclude patterns`);
|
|
252
|
+
const volumeExt = isObject(def) ? def['x-tug'] : undefined;
|
|
253
|
+
const placed = volumeExt?.placement?.nodes || [];
|
|
254
|
+
const mounting = Object.entries(compose.services).filter(([, svc]) => mountedVolumes(svc).includes(name));
|
|
255
|
+
const mountingNodes = [...new Set(mounting.flatMap(([, svc]) => serviceNodes(svc).nodes))];
|
|
256
|
+
const dynamicPlacement = mounting.some(([, svc]) => serviceNodes(svc).dynamic || serviceNodes(svc).global);
|
|
257
|
+
const candidates = placed.length ? placed : mountingNodes;
|
|
258
|
+
if (candidates.length > 1)
|
|
259
|
+
errors.push(`${label} needs one node: volume ${name} is ${placed.length ? 'placed' : 'used'} on ${candidates.join(', ')}; pin it with x-tug.placement.nodes or back up per node`);
|
|
260
|
+
else if (!placed.length && dynamicPlacement)
|
|
261
|
+
errors.push(`${label} needs x-tug.placement.nodes on volume ${name}, since its services are placed by label or global`);
|
|
262
|
+
if (errors.length > before || !base)
|
|
263
|
+
continue;
|
|
264
|
+
targets.push({
|
|
265
|
+
target: `volume:${name}`,
|
|
266
|
+
kind: 'volume',
|
|
267
|
+
name,
|
|
268
|
+
type: 'volume',
|
|
269
|
+
...base,
|
|
270
|
+
exclude: spec.exclude,
|
|
271
|
+
writers: spec.writers,
|
|
272
|
+
node: candidates.length === 1 ? candidates[0] : undefined,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return { targets, errors };
|
|
276
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Reserved secrets-table project holding the backup destination's credentials. */
|
|
2
|
+
export declare const BACKUP_SECRET_PROJECT = "_backup";
|
|
3
|
+
export interface BackupDestinationInput {
|
|
4
|
+
url: string;
|
|
5
|
+
/** Variable name to value; stored encrypted and never returned. */
|
|
6
|
+
env: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export declare const isBackupProject: (project: string) => boolean;
|
|
9
|
+
/** A name starting with `_` is reserved for control's own records and is never a deployable project. */
|
|
10
|
+
export declare const isReservedProject: (project: string) => boolean;
|
|
11
|
+
/** Throws an `Error` naming the first problem with a destination, or returns it normalized. */
|
|
12
|
+
export declare function parseBackupDestination(input: unknown): BackupDestinationInput;
|
|
13
|
+
/** The restic repository of one project: every node of the project shares it, other projects never do. */
|
|
14
|
+
export declare function projectRepository(destinationUrl: string, project: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* The repository password of a project, derived from the control master key so nothing extra is stored and
|
|
17
|
+
* a node only ever holds the passwords of projects it runs backups for. Losing the master key loses every
|
|
18
|
+
* repository, which is why `tug backup-key` exists for offline escrow.
|
|
19
|
+
*/
|
|
20
|
+
export declare function repositoryPassword(masterKey: string, project: string): string;
|
|
21
|
+
/** Removes every known secret value from text that is about to be stored, shown or put in an event. */
|
|
22
|
+
export declare function redactSecrets(text: string, values: Iterable<string>): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
/** Reserved secrets-table project holding the backup destination's credentials. */
|
|
3
|
+
export const BACKUP_SECRET_PROJECT = '_backup';
|
|
4
|
+
const MAX_ENV_VARIABLES = 16;
|
|
5
|
+
const ENV_NAME_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
6
|
+
const RESERVED_ENV = ['RESTIC_PASSWORD', 'RESTIC_REPOSITORY', 'RESTIC_PASSWORD_FILE'];
|
|
7
|
+
/** Shorter values would redact ordinary words out of error text. */
|
|
8
|
+
const MIN_REDACTED_LENGTH = 4;
|
|
9
|
+
const REQUIRED_ENV = {
|
|
10
|
+
s3: ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'],
|
|
11
|
+
};
|
|
12
|
+
export const isBackupProject = (project) => project === BACKUP_SECRET_PROJECT;
|
|
13
|
+
/** A name starting with `_` is reserved for control's own records and is never a deployable project. */
|
|
14
|
+
export const isReservedProject = (project) => project.startsWith('_');
|
|
15
|
+
/** Throws an `Error` naming the first problem with a destination, or returns it normalized. */
|
|
16
|
+
export function parseBackupDestination(input) {
|
|
17
|
+
const body = (input && typeof input === 'object' ? input : {});
|
|
18
|
+
const url = typeof body.url === 'string' ? body.url.trim().replace(/\/+$/, '') : '';
|
|
19
|
+
if (!url)
|
|
20
|
+
throw new Error('url is required, such as s3:https://s3.example.com/bucket/tug-backups');
|
|
21
|
+
if (/\s/.test(url))
|
|
22
|
+
throw new Error('url must not contain whitespace');
|
|
23
|
+
const scheme = url.startsWith('/') ? 'local' : url.split(':')[0];
|
|
24
|
+
if (!['s3', 'sftp', 'rest', 'local'].includes(scheme))
|
|
25
|
+
throw new Error('url must start with s3:, sftp:, rest: or be an absolute path (a path is for development only: it is read on each node, not shared)');
|
|
26
|
+
if (/:\/\/[^/@\s]*:[^/@\s]*@/.test(url))
|
|
27
|
+
throw new Error('url must not contain a username and password; pass credentials as env variables so they are stored encrypted');
|
|
28
|
+
if (scheme === 's3' && !/^s3:https?:\/\/[^/]+\/[^/]+/.test(url))
|
|
29
|
+
throw new Error('s3 urls look like s3:https://host/bucket[/prefix] and must name a bucket; AWS S3 uses s3:https://s3.amazonaws.com/bucket');
|
|
30
|
+
const rawEnv = body.env && typeof body.env === 'object' ? body.env : {};
|
|
31
|
+
const names = Object.keys(rawEnv);
|
|
32
|
+
if (names.length > MAX_ENV_VARIABLES)
|
|
33
|
+
throw new Error(`at most ${MAX_ENV_VARIABLES} env variables are supported`);
|
|
34
|
+
const env = {};
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
if (!ENV_NAME_RE.test(name))
|
|
37
|
+
throw new Error(`env variable ${JSON.stringify(name)} must be upper case letters, digits and underscores`);
|
|
38
|
+
if (RESERVED_ENV.includes(name))
|
|
39
|
+
throw new Error(`${name} is managed by Tug and cannot be set`);
|
|
40
|
+
const value = rawEnv[name];
|
|
41
|
+
if (typeof value !== 'string' || !value)
|
|
42
|
+
throw new Error(`env variable ${name} needs a value`);
|
|
43
|
+
env[name] = value;
|
|
44
|
+
}
|
|
45
|
+
const missing = (REQUIRED_ENV[scheme] ?? []).filter((name) => !(name in env));
|
|
46
|
+
if (missing.length)
|
|
47
|
+
throw new Error(`${scheme} destinations need ${missing.join(' and ')}`);
|
|
48
|
+
return { url, env };
|
|
49
|
+
}
|
|
50
|
+
/** The restic repository of one project: every node of the project shares it, other projects never do. */
|
|
51
|
+
export function projectRepository(destinationUrl, project) {
|
|
52
|
+
return `${destinationUrl.replace(/\/+$/, '')}/${project}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The repository password of a project, derived from the control master key so nothing extra is stored and
|
|
56
|
+
* a node only ever holds the passwords of projects it runs backups for. Losing the master key loses every
|
|
57
|
+
* repository, which is why `tug backup-key` exists for offline escrow.
|
|
58
|
+
*/
|
|
59
|
+
export function repositoryPassword(masterKey, project) {
|
|
60
|
+
return createHmac('sha256', masterKey).update(`tug-restic:${project}`).digest('hex');
|
|
61
|
+
}
|
|
62
|
+
/** Removes every known secret value from text that is about to be stored, shown or put in an event. */
|
|
63
|
+
export function redactSecrets(text, values) {
|
|
64
|
+
const secrets = [...new Set(values)]
|
|
65
|
+
.filter((value) => value.length >= MIN_REDACTED_LENGTH)
|
|
66
|
+
.sort((a, b) => b.length - a.length);
|
|
67
|
+
let result = text;
|
|
68
|
+
for (const secret of secrets)
|
|
69
|
+
result = result.split(secret).join('[redacted]');
|
|
70
|
+
// A credential embedded in a URL, such as one echoed back in a backend error.
|
|
71
|
+
return result.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s@]+@/gi, '$1[redacted]@');
|
|
72
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DockerApi } from './docker-api.ts';
|
|
2
|
+
import type { BackupCredentialsResponse, BackupJobPayload, BackupJobResult, BackupRetention, JobPhase } from './protocol.ts';
|
|
3
|
+
/** Docker volume holding restic's cache across jobs, so repeated runs do not re-read the repository index. */
|
|
4
|
+
export declare const RESTIC_CACHE_VOLUME = "tug_restic_cache";
|
|
5
|
+
export interface JobContext {
|
|
6
|
+
docker: DockerApi;
|
|
7
|
+
credentials: BackupCredentialsResponse;
|
|
8
|
+
/** Called as the job moves between phases; feeds the agent's heartbeat. */
|
|
9
|
+
onPhase?: (phase: JobPhase, bytes?: number) => void;
|
|
10
|
+
/** Aborts the job (timeout or cancellation); helper containers are removed either way. */
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
/** `512m`, `2g` or a byte count. */
|
|
14
|
+
export declare function memoryBytes(value: string): number;
|
|
15
|
+
/** `restic forget` arguments applying a retention policy to one target of one node. */
|
|
16
|
+
export declare function forgetArgs(payload: BackupJobPayload, retention: BackupRetention): string[];
|
|
17
|
+
/** `restic backup` arguments; `stdinName` selects a dump stream, otherwise `/data` is archived. */
|
|
18
|
+
export declare function backupArgs(payload: BackupJobPayload, stdinName?: string): string[];
|
|
19
|
+
interface Summary {
|
|
20
|
+
snapshotId?: string;
|
|
21
|
+
addedBytes?: number;
|
|
22
|
+
totalBytes?: number;
|
|
23
|
+
}
|
|
24
|
+
/** The `summary` line restic prints at the end of `backup --json`. */
|
|
25
|
+
export declare function parseSummary(output: string): Summary;
|
|
26
|
+
export declare class JobError extends Error {
|
|
27
|
+
}
|
|
28
|
+
/** Runs one backup job to completion. Helper containers carry the `tug.job` label and are always removed. */
|
|
29
|
+
export declare function runBackupJob(payload: BackupJobPayload, commandId: string, ctx: JobContext, kind?: 'backup.run' | 'backup.check' | 'backup.restore'): Promise<BackupJobResult>;
|
|
30
|
+
/** Removes helper containers left behind by an agent that stopped mid-job. */
|
|
31
|
+
export declare function removeStaleJobContainers(docker: DockerApi): Promise<number>;
|
|
32
|
+
export {};
|