@wpelevator/tug 0.5.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.
@@ -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 {};
@@ -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>;