@wpelevator/tug 0.4.0 → 0.6.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.
Files changed (53) hide show
  1. package/README.md +10 -11
  2. package/dist/agent-jobs.d.ts +41 -0
  3. package/dist/agent-jobs.js +161 -0
  4. package/dist/agent.d.ts +1 -3
  5. package/dist/agent.js +58 -35
  6. package/dist/backup-config.d.ts +47 -0
  7. package/dist/backup-config.js +256 -0
  8. package/dist/backup-destination.d.ts +22 -0
  9. package/dist/backup-destination.js +72 -0
  10. package/dist/backup-job.d.ts +32 -0
  11. package/dist/backup-job.js +352 -0
  12. package/dist/backup-restore.d.ts +67 -0
  13. package/dist/backup-restore.js +297 -0
  14. package/dist/backup-scheduler.d.ts +54 -0
  15. package/dist/backup-scheduler.js +403 -0
  16. package/dist/build.d.ts +2 -3
  17. package/dist/build.js +1 -3
  18. package/dist/cli-build.d.ts +3 -3
  19. package/dist/cli-build.js +18 -6
  20. package/dist/cli-config.d.ts +45 -5
  21. package/dist/cli-config.js +269 -19
  22. package/dist/cli-live-transport.d.ts +1 -1
  23. package/dist/cli-live-transport.js +2 -2
  24. package/dist/cli-live.d.ts +2 -0
  25. package/dist/cli-live.js +1 -0
  26. package/dist/cli.js +217 -49
  27. package/dist/compose.d.ts +1 -2
  28. package/dist/compose.js +7 -6
  29. package/dist/container-spec.d.ts +3 -2
  30. package/dist/container-spec.js +9 -34
  31. package/dist/control.d.ts +4 -2
  32. package/dist/control.js +591 -381
  33. package/dist/cron.d.ts +17 -0
  34. package/dist/cron.js +124 -0
  35. package/dist/db-driver.d.ts +20 -0
  36. package/dist/db-driver.js +11 -0
  37. package/dist/db-node.d.ts +3 -0
  38. package/dist/db-node.js +60 -0
  39. package/dist/db.d.ts +79 -6
  40. package/dist/db.js +47 -26
  41. package/dist/docker-api.d.ts +7 -0
  42. package/dist/docker-api.js +15 -3
  43. package/dist/live.d.ts +8 -0
  44. package/dist/live.js +84 -0
  45. package/dist/protocol.d.ts +121 -12
  46. package/dist/protocol.js +7 -0
  47. package/dist/registry.d.ts +17 -0
  48. package/dist/registry.js +133 -0
  49. package/dist/templates/control-compose.yml +0 -4
  50. package/dist/templates/node-install.sh +0 -1
  51. package/dist/tokens.d.ts +7 -7
  52. package/dist/tokens.js +34 -21
  53. package/package.json +2 -3
@@ -0,0 +1,352 @@
1
+ import { Transform } from 'node:stream';
2
+ import { redactSecrets } from "./backup-destination.js";
3
+ import { LABEL_JOB, LABEL_PROJECT, LABEL_SERVICE } from "./container-spec.js";
4
+ /** Docker volume holding restic's cache across jobs, so repeated runs do not re-read the repository index. */
5
+ export const RESTIC_CACHE_VOLUME = 'tug_restic_cache';
6
+ const TAIL_BYTES = 4096;
7
+ const OUTPUT_LIMIT = 64 * 1024;
8
+ /** `512m`, `2g` or a byte count. */
9
+ export function memoryBytes(value) {
10
+ const match = /^(\d+)([kmg]?)$/i.exec(value.trim());
11
+ if (!match)
12
+ throw new Error(`invalid memory limit ${JSON.stringify(value)}`);
13
+ const unit = { '': 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[match[2].toLowerCase()];
14
+ return Number(match[1]) * unit;
15
+ }
16
+ const tagArgs = (tags) => tags.flatMap((tag) => ['--tag', tag]);
17
+ /** `restic forget` arguments applying a retention policy to one target of one node. */
18
+ export function forgetArgs(payload, retention) {
19
+ const keep = ['last', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'].flatMap((key) => (retention[key] ? [`--keep-${key}`, String(retention[key])] : []));
20
+ if (!keep.length)
21
+ throw new Error('retention would keep no snapshots');
22
+ return [
23
+ 'forget',
24
+ '--tag',
25
+ `target:${payload.target}`,
26
+ '--host',
27
+ payload.node,
28
+ '--group-by',
29
+ 'host',
30
+ // A safety snapshot taken before a restore must outlive routine retention.
31
+ '--keep-tag',
32
+ 'trigger:pre-restore',
33
+ ...keep,
34
+ '--prune',
35
+ '--retry-lock',
36
+ '10m',
37
+ ];
38
+ }
39
+ /** `restic backup` arguments; `stdinName` selects a dump stream, otherwise `/data` is archived. */
40
+ export function backupArgs(payload, stdinName) {
41
+ const common = [
42
+ '--json',
43
+ '--host',
44
+ payload.node,
45
+ ...tagArgs(payload.tags),
46
+ '--retry-lock',
47
+ '10m',
48
+ ];
49
+ if (stdinName)
50
+ return ['backup', '--stdin', '--stdin-filename', stdinName, ...common];
51
+ const exclude = (payload.volume?.exclude ?? []).flatMap((pattern) => ['--exclude', pattern]);
52
+ return ['backup', '/data', ...exclude, ...common];
53
+ }
54
+ /** The `summary` line restic prints at the end of `backup --json`. */
55
+ export function parseSummary(output) {
56
+ for (const line of output.split('\n').reverse()) {
57
+ if (!line.includes('"summary"'))
58
+ continue;
59
+ try {
60
+ const value = JSON.parse(line);
61
+ if (value.message_type === 'summary')
62
+ return {
63
+ snapshotId: value.snapshot_id,
64
+ addedBytes: value.data_added,
65
+ totalBytes: value.total_bytes_processed,
66
+ };
67
+ }
68
+ catch {
69
+ /* a partial line; keep looking */
70
+ }
71
+ }
72
+ return {};
73
+ }
74
+ function collect(proc) {
75
+ const out = [];
76
+ const err = [];
77
+ let outSize = 0;
78
+ proc.stdout.on('data', (chunk) => {
79
+ // Progress output of a long backup would otherwise grow without bound.
80
+ if (outSize < OUTPUT_LIMIT * 4)
81
+ out.push(chunk);
82
+ outSize += chunk.length;
83
+ });
84
+ proc.stderr.on('data', (chunk) => err.push(chunk));
85
+ return new Promise((resolve, reject) => {
86
+ proc.on('error', reject);
87
+ proc.on('close', (code) => resolve({
88
+ code: code ?? -1,
89
+ out: Buffer.concat(out).toString('utf8'),
90
+ err: Buffer.concat(err).toString('utf8').slice(-OUTPUT_LIMIT),
91
+ }));
92
+ });
93
+ }
94
+ /** Keeps the end of a stream so the dump trailer can be checked after it passes through. */
95
+ class Tail extends Transform {
96
+ tail = Buffer.alloc(0);
97
+ bytes = 0;
98
+ onBytes;
99
+ constructor(onBytes) {
100
+ super();
101
+ this.onBytes = onBytes;
102
+ }
103
+ _transform(chunk, _encoding, callback) {
104
+ this.bytes += chunk.length;
105
+ this.tail = Buffer.concat([this.tail, chunk]).subarray(-TAIL_BYTES);
106
+ this.onBytes(this.bytes);
107
+ callback(null, chunk);
108
+ }
109
+ }
110
+ export class JobError extends Error {
111
+ }
112
+ async function ensureImage(docker, ref) {
113
+ if (await docker.inspectImage(ref))
114
+ return;
115
+ await docker.pullImage(ref);
116
+ }
117
+ function secretValues(credentials) {
118
+ return Object.values(credentials.env);
119
+ }
120
+ /** Runs one backup job to completion. Helper containers carry the `tug.job` label and are always removed. */
121
+ export async function runBackupJob(payload, commandId, ctx, kind = 'backup.run') {
122
+ const started = Date.now();
123
+ const { docker, credentials } = ctx;
124
+ const phase = (value, bytes) => ctx.onPhase?.(value, bytes);
125
+ const redact = (text) => redactSecrets(text, secretValues(credentials));
126
+ const created = [];
127
+ const labels = { [LABEL_JOB]: commandId };
128
+ const name = (suffix) => `tug-job-${payload.runId}-${suffix}`;
129
+ const fail = (what, detail) => new JobError(`${what}: ${redact(detail).trim().split('\n').slice(-6).join(' | ')}`);
130
+ const helper = async (suffix, body) => {
131
+ const id = await docker.createContainer(name(suffix), { ...body, Labels: labels });
132
+ created.push(id);
133
+ await docker.startContainer(id);
134
+ return id;
135
+ };
136
+ try {
137
+ phase('pulling');
138
+ await ensureImage(docker, payload.resticImage);
139
+ ctx.signal?.throwIfAborted();
140
+ const binds = [`${RESTIC_CACHE_VOLUME}:/cache`];
141
+ if (payload.repository.startsWith('/'))
142
+ binds.push(`${payload.repository}:${payload.repository}`);
143
+ if (payload.volume) {
144
+ const scratch = kind === 'backup.restore' && payload.restore?.mode === 'scratch';
145
+ const dockerVolume = scratch
146
+ ? (payload.restore?.scratchVolume ?? '')
147
+ : payload.volume.dockerVolume;
148
+ if (scratch) {
149
+ if (!dockerVolume)
150
+ throw new JobError('a scratch restore needs a scratch volume name');
151
+ if (!(await docker.inspectVolume(dockerVolume)))
152
+ await docker.createVolume(dockerVolume, {
153
+ labels: { 'tug.project': payload.project, 'tug.restore': payload.runId },
154
+ });
155
+ }
156
+ else if (!(await docker.inspectVolume(dockerVolume)))
157
+ // Docker would silently create an empty volume for a missing name and the backup would succeed with nothing in it.
158
+ throw new JobError(`volume ${dockerVolume} does not exist on this node; a volume is created when a service that uses it is placed here`);
159
+ binds.push(`${dockerVolume}:/data${kind === 'backup.restore' ? '' : ':ro'}`);
160
+ }
161
+ const restic = await helper('restic', {
162
+ Image: payload.resticImage,
163
+ Entrypoint: ['sleep'],
164
+ Cmd: ['86400'],
165
+ Env: [
166
+ ...Object.entries(credentials.env).map(([key, value]) => `${key}=${value}`),
167
+ `RESTIC_REPOSITORY=${payload.repository}`,
168
+ 'RESTIC_CACHE_DIR=/cache',
169
+ `GOMAXPROCS=2`,
170
+ ],
171
+ HostConfig: { Memory: memoryBytes(payload.limits.memory), Binds: binds },
172
+ });
173
+ const exec = async (cmd) => collect(await docker.execStart(restic, { cmd }));
174
+ const version = (await exec(['restic', 'version'])).out.match(/restic (\d+\.\d+\.\d+)/)?.[1];
175
+ phase('checking');
176
+ let repository = 'ok';
177
+ const probe = await exec(['restic', 'cat', 'config']);
178
+ if (probe.code !== 0) {
179
+ // Only a missing repository is initialized; a wrong password or an unreachable bucket must fail the job.
180
+ if (kind === 'backup.restore' ||
181
+ !/Is there a repository at the following location|does not exist|no such file/i.test(probe.err))
182
+ throw fail('repository check failed', probe.err || probe.out);
183
+ const init = await exec(['restic', 'init']);
184
+ if (init.code !== 0)
185
+ throw fail('repository init failed', init.err || init.out);
186
+ repository = 'initialized';
187
+ }
188
+ if (kind === 'backup.check')
189
+ return {
190
+ runId: payload.runId,
191
+ durationMs: Date.now() - started,
192
+ resticVersion: version,
193
+ repository,
194
+ };
195
+ if (kind === 'backup.restore') {
196
+ const restore = payload.restore;
197
+ if (!restore)
198
+ throw new JobError('the restore job names no snapshot');
199
+ phase('restoring');
200
+ // Only snapshots this target produced may be restored into it.
201
+ const listed = await exec(['restic', 'snapshots', restore.snapshotId, '--json']);
202
+ const snapshot = (() => {
203
+ try {
204
+ return JSON.parse(listed.out)[0];
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ })();
210
+ if (listed.code !== 0 || !snapshot)
211
+ throw fail(`snapshot ${restore.snapshotId} was not found in the repository`, listed.err);
212
+ for (const tag of [`project:${payload.project}`, `target:${payload.target}`])
213
+ if (!snapshot.tags?.includes(tag))
214
+ throw new JobError(`snapshot ${restore.snapshotId} is not a ${payload.target} backup of project ${payload.project} (missing tag ${tag})`);
215
+ if (payload.command) {
216
+ const { service, filename, restoreArgv } = payload.command;
217
+ if (!restoreArgv)
218
+ throw new JobError(`${service} has no x-tug.backup.restore command, so Tug does not know how to load the backup back`);
219
+ const running = (await docker.listContainers({
220
+ label: [`${LABEL_PROJECT}=${payload.project}`, `${LABEL_SERVICE}=${service}`],
221
+ })).find((container) => container.State === 'running');
222
+ if (!running)
223
+ throw new JobError(`service ${service} is not running on this node`);
224
+ const dump = await docker.execStart(restic, {
225
+ cmd: ['restic', 'dump', restore.snapshotId, filename],
226
+ });
227
+ const loader = await docker.execStart(running.Id, { cmd: restoreArgv, stdin: true });
228
+ const reading = collect(dump);
229
+ const loading = collect(loader);
230
+ dump.stdout.pipe(loader.stdin);
231
+ const [read, loaded] = await Promise.all([reading, loading]);
232
+ if (read.code !== 0)
233
+ throw fail('could not read the snapshot', read.err);
234
+ if (loaded.code !== 0)
235
+ throw fail(`the restore command exited with code ${loaded.code}`, loaded.err || loaded.out);
236
+ }
237
+ else {
238
+ // Replace the volume's contents with the snapshot's; a scratch volume starts empty.
239
+ if (restore.mode === 'in-place') {
240
+ const clear = await exec(['find', '/data', '-mindepth', '1', '-delete']);
241
+ if (clear.code !== 0)
242
+ throw fail('could not empty the volume', clear.err);
243
+ }
244
+ const result = await exec([
245
+ 'restic',
246
+ 'restore',
247
+ restore.snapshotId,
248
+ '--target',
249
+ '/',
250
+ '--verify',
251
+ ]);
252
+ if (result.code !== 0)
253
+ throw fail('restic restore failed', result.err || result.out);
254
+ }
255
+ return {
256
+ runId: payload.runId,
257
+ durationMs: Date.now() - started,
258
+ resticVersion: version,
259
+ };
260
+ }
261
+ let summary;
262
+ if (payload.command) {
263
+ phase('dumping');
264
+ const { service, argv, filename, verify } = payload.command;
265
+ // The command runs where the service runs: its environment, network, files and installed tools are all there.
266
+ const running = (await docker.listContainers({
267
+ label: [`${LABEL_PROJECT}=${payload.project}`, `${LABEL_SERVICE}=${service}`],
268
+ })).find((container) => container.State === 'running');
269
+ if (!running)
270
+ throw new JobError(`service ${service} is not running on this node, so there is nothing to back up`);
271
+ const producer = await docker.execStart(running.Id, { cmd: argv });
272
+ const proc = await docker.execStart(restic, {
273
+ cmd: ['restic', ...backupArgs(payload, filename)],
274
+ stdin: true,
275
+ });
276
+ const uploaded = collect(proc);
277
+ const tap = new Tail((bytes) => phase('uploading', bytes));
278
+ const commandStderr = [];
279
+ producer.stderr.on('data', (chunk) => commandStderr.push(chunk));
280
+ producer.stdout.pipe(tap).pipe(proc.stdin);
281
+ const exit = new Promise((resolve, reject) => {
282
+ producer.on('error', reject);
283
+ producer.on('close', (code) => resolve(code ?? -1));
284
+ });
285
+ const aborted = new Promise((_, reject) => ctx.signal?.addEventListener('abort', () => {
286
+ producer.kill();
287
+ reject(ctx.signal.reason);
288
+ }));
289
+ const [result, exitCode] = await Promise.race([Promise.all([uploaded, exit]), aborted]);
290
+ summary = parseSummary(result.out);
291
+ const output = tap.tail.toString('utf8');
292
+ const problem = exitCode !== 0
293
+ ? `the command exited with code ${exitCode}`
294
+ : tap.bytes === 0
295
+ ? 'the command produced no output'
296
+ : verify && !output.includes(verify)
297
+ ? `the end of the output does not contain ${JSON.stringify(verify)}, so it was cut short`
298
+ : result.code !== 0
299
+ ? 'the upload to the repository failed'
300
+ : undefined;
301
+ if (problem) {
302
+ // restic snapshots whatever it received, so a failed or cut-short stream must not stay in the catalog.
303
+ let note = '';
304
+ if (summary.snapshotId) {
305
+ const forget = await exec(['restic', 'forget', summary.snapshotId]);
306
+ if (forget.code !== 0)
307
+ note = ' (the partial snapshot could not be forgotten)';
308
+ }
309
+ throw fail(`${problem}${note}`, `${Buffer.concat(commandStderr).toString('utf8')}\n${result.err}`);
310
+ }
311
+ }
312
+ else {
313
+ phase('uploading');
314
+ const result = await exec(['restic', ...backupArgs(payload)]);
315
+ if (result.code !== 0)
316
+ throw fail('backup failed', result.err || result.out);
317
+ summary = parseSummary(result.out);
318
+ }
319
+ if (!summary.snapshotId)
320
+ throw fail('backup finished without a snapshot', 'restic reported no snapshot id');
321
+ const warnings = [];
322
+ if (payload.retention) {
323
+ phase('forgetting');
324
+ const forget = await exec(['restic', ...forgetArgs(payload, payload.retention)]);
325
+ // The snapshot is safe; failing to apply retention is reported without failing the backup.
326
+ if (forget.code !== 0)
327
+ warnings.push(`retention failed: ${redact(forget.err || forget.out)
328
+ .trim()
329
+ .slice(-300)}`);
330
+ }
331
+ return {
332
+ runId: payload.runId,
333
+ snapshotId: summary.snapshotId,
334
+ addedBytes: summary.addedBytes,
335
+ totalBytes: summary.totalBytes,
336
+ durationMs: Date.now() - started,
337
+ resticVersion: version,
338
+ ...(warnings.length ? { warnings } : {}),
339
+ };
340
+ }
341
+ finally {
342
+ for (const id of created.reverse())
343
+ await docker.removeContainer(id).catch(() => { });
344
+ }
345
+ }
346
+ /** Removes helper containers left behind by an agent that stopped mid-job. */
347
+ export async function removeStaleJobContainers(docker) {
348
+ const stale = await docker.listContainers({ label: [LABEL_JOB] }, true);
349
+ for (const container of stale)
350
+ await docker.removeContainer(container.Id).catch(() => { });
351
+ return stale.length;
352
+ }
@@ -0,0 +1,67 @@
1
+ import type { BackupService } from './backup-scheduler.ts';
2
+ import type { Db, RestoreRunRow } from './db.ts';
3
+ import type { ComposeFile } from './protocol.ts';
4
+ export interface RestoreRequest {
5
+ project: string;
6
+ target: string;
7
+ /** `latest`, a backup run id, or a restic snapshot id. */
8
+ from?: string;
9
+ scratch?: boolean;
10
+ safetyBackup?: boolean;
11
+ writers?: string[];
12
+ noWriters?: boolean;
13
+ actor: string;
14
+ }
15
+ export interface RestorePlan {
16
+ project: string;
17
+ target: string;
18
+ node: string;
19
+ snapshotId: string;
20
+ snapshotTime: string | null;
21
+ mode: 'in-place' | 'scratch';
22
+ scratchVolume?: string;
23
+ writers: string[];
24
+ safetyBackup: boolean;
25
+ }
26
+ export interface RestoreDeps {
27
+ db: Db;
28
+ backups: BackupService;
29
+ /** Wakes agents so a hold or command is picked up without waiting for the next poll. */
30
+ wake: (project: string) => void;
31
+ clock?: () => Date;
32
+ }
33
+ /**
34
+ * The services that write to a target. For a service they are the services that depend on it, transitively;
35
+ * for a volume, the services that mount it and everything depending on those. Stored `depends_on` is used,
36
+ * because the per-node copies drop cross-node dependencies.
37
+ */
38
+ export declare function discoverWriters(compose: ComposeFile, target: string): string[];
39
+ export declare function createRestoreService(deps: RestoreDeps): {
40
+ plan: (request: RestoreRequest) => RestorePlan | {
41
+ error: string;
42
+ status: number;
43
+ };
44
+ start: (request: RestoreRequest, restorePlan: RestorePlan) => {
45
+ restoreId: string;
46
+ scratchVolume?: string;
47
+ } | {
48
+ error: string;
49
+ status: number;
50
+ };
51
+ advance: () => void;
52
+ releaseInterrupted: (restoreId: string) => {
53
+ ok: true;
54
+ } | {
55
+ error: string;
56
+ status: number;
57
+ };
58
+ resume: (restoreId: string) => {
59
+ ok: true;
60
+ } | {
61
+ error: string;
62
+ status: number;
63
+ };
64
+ holds: () => Record<string, string[]>;
65
+ list: (project: string, limit?: number) => RestoreRunRow[];
66
+ };
67
+ export type RestoreService = ReturnType<typeof createRestoreService>;