@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,297 @@
1
+ import { id } from "./util.js";
2
+ import { volumeName } from "./container-spec.js";
3
+ const MINUTE = 60_000;
4
+ const LOCK_TTL_MS = 15 * MINUTE;
5
+ const HOLD_TIMEOUT_MS = 5 * MINUTE;
6
+ const TERMINAL = ['succeeded', 'failed', 'released'];
7
+ /**
8
+ * The services that write to a target. For a service they are the services that depend on it, transitively;
9
+ * for a volume, the services that mount it and everything depending on those. Stored `depends_on` is used,
10
+ * because the per-node copies drop cross-node dependencies.
11
+ */
12
+ export function discoverWriters(compose, target) {
13
+ const [kind, name] = [
14
+ target.slice(0, target.indexOf(':')),
15
+ target.slice(target.indexOf(':') + 1),
16
+ ];
17
+ const dependsOn = (service) => {
18
+ const deps = compose.services[service]?.depends_on;
19
+ if (Array.isArray(deps))
20
+ return deps.map(String);
21
+ return deps && typeof deps === 'object' ? Object.keys(deps) : [];
22
+ };
23
+ const seeds = kind === 'volume'
24
+ ? Object.entries(compose.services)
25
+ .filter(([, svc]) => (svc.volumes ?? []).some((mount) => typeof mount === 'object' && mount.type === 'volume' && mount.source === name))
26
+ .map(([service]) => service)
27
+ : [name];
28
+ const writers = new Set(kind === 'volume' ? seeds : []);
29
+ const queue = [...seeds];
30
+ while (queue.length) {
31
+ const current = queue.pop();
32
+ for (const service of Object.keys(compose.services))
33
+ if (!writers.has(service) && service !== name && dependsOn(service).includes(current)) {
34
+ writers.add(service);
35
+ queue.push(service);
36
+ }
37
+ }
38
+ return [...writers].sort();
39
+ }
40
+ export function createRestoreService(deps) {
41
+ const { db, backups } = deps;
42
+ const clock = deps.clock ?? (() => new Date());
43
+ const stamp = () => clock().toISOString();
44
+ const deployed = (project) => {
45
+ const row = db.get(`SELECT ds.state_json FROM projects p JOIN deployments d ON d.id=p.current_deployment_id AND d.state='succeeded'
46
+ JOIN desired_states ds ON ds.deployment_id=d.id WHERE p.id=?`, project);
47
+ return row ? JSON.parse(row.state_json) : undefined;
48
+ };
49
+ function event(level, type, restore, message, extra = {}) {
50
+ db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?,?,?,?)', id('evt'), restore.project_id, null, restore.node_id, level, type, message, JSON.stringify({
51
+ restoreId: restore.id,
52
+ target: restore.target,
53
+ snapshotId: restore.snapshot_id,
54
+ ...extra,
55
+ }), stamp(), restore.actor);
56
+ }
57
+ function resolveSnapshot(project, target, from) {
58
+ const latest = !from || from === 'latest';
59
+ if (!latest && !from.startsWith('bkr_'))
60
+ return { snapshotId: from, time: null };
61
+ const run = latest
62
+ ? db.get(`SELECT * FROM backup_runs WHERE project_id=? AND target=? AND state='succeeded' AND snapshot_id IS NOT NULL
63
+ ORDER BY finished_at DESC LIMIT 1`, project, target)
64
+ : db.get("SELECT * FROM backup_runs WHERE id=? AND project_id=? AND target=? AND state='succeeded' AND snapshot_id IS NOT NULL", from, project, target);
65
+ if (!run?.snapshot_id)
66
+ return {
67
+ error: latest
68
+ ? `${target} has no successful backup in the catalog; pass --from with a snapshot id`
69
+ : `backup run ${from} is not a successful backup of ${target}`,
70
+ };
71
+ return { snapshotId: run.snapshot_id, time: run.finished_at };
72
+ }
73
+ /** Validates a request and describes what a restore would do; nothing is changed. */
74
+ function plan(request) {
75
+ const desired = deployed(request.project);
76
+ if (!desired)
77
+ return { error: `project ${request.project} has no successful deployment`, status: 409 };
78
+ const schedule = db.get('SELECT node_id,type FROM backup_schedules WHERE project_id=? AND target=? AND enabled=1', request.project, request.target);
79
+ if (!schedule?.node_id)
80
+ return {
81
+ error: `${request.target} is not a backup target of project ${request.project}`,
82
+ status: 404,
83
+ };
84
+ const node = db.get('SELECT name FROM nodes WHERE id=?', schedule.node_id).name;
85
+ const mode = request.scratch ? 'scratch' : 'in-place';
86
+ if (mode === 'scratch' && schedule.type !== 'volume')
87
+ return {
88
+ error: 'a scratch restore is only available for volumes; restore a service in place or load the dump by hand',
89
+ status: 400,
90
+ };
91
+ const snapshot = resolveSnapshot(request.project, request.target, request.from);
92
+ if ('error' in snapshot)
93
+ return { error: snapshot.error, status: 404 };
94
+ let writers = [];
95
+ if (mode === 'in-place') {
96
+ writers = request.writers ?? discoverWriters(desired.compose, request.target);
97
+ for (const writer of writers)
98
+ if (!desired.compose.services[writer])
99
+ return { error: `writer ${writer} is not a service of ${request.project}`, status: 400 };
100
+ if (!writers.length && !request.noWriters && !request.writers)
101
+ return {
102
+ error: `no services were found that write to ${request.target}; pass --writers to name them or --no-writers to restore without holding any`,
103
+ status: 409,
104
+ };
105
+ }
106
+ return {
107
+ project: request.project,
108
+ target: request.target,
109
+ node,
110
+ snapshotId: snapshot.snapshotId,
111
+ snapshotTime: snapshot.time,
112
+ mode,
113
+ ...(mode === 'scratch'
114
+ ? { scratchVolume: volumeName(request.project, `restore-${id('r').slice(-6)}`) }
115
+ : {}),
116
+ writers,
117
+ safetyBackup: mode === 'in-place' && request.safetyBackup !== false,
118
+ };
119
+ }
120
+ function lock(restore) {
121
+ const name = `project:${restore.project_id}`;
122
+ const expires = new Date(clock().getTime() + LOCK_TTL_MS).toISOString();
123
+ db.run('DELETE FROM locks WHERE expires_at < ?', stamp());
124
+ const existing = db.get('SELECT owner FROM locks WHERE name=?', name);
125
+ if (existing && existing.owner !== `restore:${restore.id}`)
126
+ return false;
127
+ db.run(`INSERT INTO locks(name,owner,expires_at,created_at) VALUES(?,?,?,?)
128
+ ON CONFLICT(name) DO UPDATE SET expires_at=excluded.expires_at`, name, `restore:${restore.id}`, expires, stamp());
129
+ return true;
130
+ }
131
+ function unlock(restore) {
132
+ db.run('DELETE FROM locks WHERE name=? AND owner=?', `project:${restore.project_id}`, `restore:${restore.id}`);
133
+ }
134
+ function release(restore) {
135
+ db.run('DELETE FROM service_holds WHERE restore_id=?', restore.id);
136
+ unlock(restore);
137
+ deps.wake(restore.project_id);
138
+ }
139
+ function move(restore, state, extra = {}) {
140
+ db.run(`UPDATE restore_runs SET state=?,state_changed_at=?,error=COALESCE(?,error),command_id=COALESCE(?,command_id),safety_run_id=COALESCE(?,safety_run_id),
141
+ finished_at=CASE WHEN ? THEN ? ELSE finished_at END WHERE id=?`, state, stamp(), extra.error ?? null, extra.commandId ?? null, extra.safetyRunId ?? null, TERMINAL.includes(state) ? 1 : 0, stamp(), restore.id);
142
+ }
143
+ /** Starts a restore. Scratch restores run straight away; in-place restores lock, hold, snapshot, restore, release. */
144
+ function start(request, restorePlan) {
145
+ const restoreId = id('rst');
146
+ const ts = stamp();
147
+ db.run(`INSERT INTO restore_runs(id,project_id,target,snapshot_id,mode,scratch_volume,safety_backup,writers_json,state,actor,started_at,state_changed_at)
148
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, restoreId, restorePlan.project, restorePlan.target, restorePlan.snapshotId, restorePlan.mode, restorePlan.scratchVolume ?? null, restorePlan.safetyBackup ? 1 : 0, JSON.stringify(restorePlan.writers), 'locking', request.actor, ts, ts);
149
+ const restore = db.get('SELECT * FROM restore_runs WHERE id=?', restoreId);
150
+ if (restorePlan.mode === 'in-place' && !lock(restore)) {
151
+ db.run('DELETE FROM restore_runs WHERE id=?', restoreId);
152
+ const owner = db.get('SELECT owner FROM locks WHERE name=?', `project:${restore.project_id}`)?.owner;
153
+ return {
154
+ error: `project ${restore.project_id} is locked by ${owner}; try again when it finishes`,
155
+ status: 409,
156
+ };
157
+ }
158
+ event('info', 'restore.started', restore, `restore of ${restore.target} from snapshot ${restore.snapshot_id.slice(0, 8)} started (${restore.mode})`, { mode: restore.mode, writers: restorePlan.writers });
159
+ if (restore.mode === 'scratch')
160
+ queueRestoreJob(restore);
161
+ else
162
+ move(restore, 'holding');
163
+ if (restore.mode === 'in-place')
164
+ for (const service of restorePlan.writers)
165
+ db.run('INSERT OR REPLACE INTO service_holds(project_id,service,reason,restore_id,created_at) VALUES(?,?,?,?,?)', restore.project_id, service, `restore ${restoreId}`, restoreId, ts);
166
+ deps.wake(restore.project_id);
167
+ advance();
168
+ return {
169
+ restoreId,
170
+ ...(restorePlan.scratchVolume ? { scratchVolume: restorePlan.scratchVolume } : {}),
171
+ };
172
+ }
173
+ const heldOnNodes = (restore, writers) => {
174
+ const desired = deployed(restore.project_id);
175
+ if (!desired)
176
+ return false;
177
+ return writers.every((service) => {
178
+ const nodes = desired.services[service]?.placement.nodes ?? [];
179
+ return nodes.every((node) => {
180
+ const row = db.get('SELECT s.* FROM node_statuses s JOIN nodes n ON n.id=s.node_id WHERE n.name=? AND s.project_id=?', node, restore.project_id);
181
+ const status = row ? JSON.parse(row.status_json) : undefined;
182
+ return status?.serviceStates?.[service]?.rolloutState === 'held';
183
+ });
184
+ });
185
+ };
186
+ /** Fails a restore. Before anything changed, holds and the lock are dropped; after, they stay until released. */
187
+ function fail(restore, error, dataChanged) {
188
+ if (dataChanged)
189
+ move(restore, 'interrupted', { error });
190
+ else {
191
+ release(restore);
192
+ move(restore, 'failed', { error });
193
+ }
194
+ event('error', 'restore.failed', restore, `restore of ${restore.target} failed: ${error}${dataChanged ? '; the writers stay held until tug restore-release' : ''}`, { error, held: dataChanged });
195
+ }
196
+ function queueRestoreJob(restore) {
197
+ const queued = backups.queueRestore(restore.project_id, restore.target, {
198
+ snapshotId: restore.snapshot_id,
199
+ mode: restore.mode,
200
+ ...(restore.scratch_volume ? { scratchVolume: restore.scratch_volume } : {}),
201
+ }, restore.id);
202
+ if ('error' in queued)
203
+ return fail(restore, queued.error, restore.state === 'interrupted');
204
+ move(restore, 'restoring', { commandId: queued.commandId });
205
+ db.run('UPDATE restore_runs SET node_id=? WHERE id=?', queued.nodeId, restore.id);
206
+ }
207
+ /** Advances every restore that is waiting on something; safe to call often. */
208
+ function advance() {
209
+ for (const restore of db.all(`SELECT * FROM restore_runs WHERE state NOT IN ('succeeded','failed','released')`)) {
210
+ const age = clock().getTime() -
211
+ new Date(restore.state_changed_at ?? restore.started_at ?? stamp()).getTime();
212
+ // Keep the project locked while a restore is unfinished, across a control restart.
213
+ if (restore.mode === 'in-place')
214
+ lock(restore);
215
+ const writers = JSON.parse(restore.writers_json);
216
+ switch (restore.state) {
217
+ case 'holding': {
218
+ if (!heldOnNodes(restore, writers)) {
219
+ if (age > HOLD_TIMEOUT_MS)
220
+ fail(restore, 'the writers did not stop in time', false);
221
+ break;
222
+ }
223
+ if (!restore.safety_backup) {
224
+ queueRestoreJob(restore);
225
+ break;
226
+ }
227
+ const safety = backups.runNow(restore.project_id, [restore.target], restore.actor ?? 'restore', 'pre-restore');
228
+ if (!safety.runs.length)
229
+ return fail(restore, `the safety backup could not start: ${safety.errors.join('; ')}`, false);
230
+ move(restore, 'safety_backup', { safetyRunId: safety.runs[0].runId });
231
+ break;
232
+ }
233
+ case 'safety_backup': {
234
+ const run = db.get('SELECT * FROM backup_runs WHERE id=?', restore.safety_run_id);
235
+ if (!run || run.state === 'queued' || run.state === 'running')
236
+ break;
237
+ if (run.state === 'succeeded')
238
+ queueRestoreJob(restore);
239
+ else
240
+ fail(restore, `the safety backup ${run.state}${run.error ? ` (${run.error})` : ''}; nothing was changed`, false);
241
+ break;
242
+ }
243
+ case 'restoring': {
244
+ const command = db.get('SELECT * FROM commands WHERE id=?', restore.command_id);
245
+ if (!command || ['pending', 'running'].includes(command.state))
246
+ break;
247
+ if (command.state === 'succeeded') {
248
+ if (restore.mode === 'in-place')
249
+ release(restore);
250
+ move(restore, 'succeeded');
251
+ event('info', 'restore.completed', restore, `restore of ${restore.target} completed${restore.scratch_volume ? ` into volume ${restore.scratch_volume}` : ''}`, { scratchVolume: restore.scratch_volume });
252
+ }
253
+ else {
254
+ const error = JSON.parse(command.result_json ?? '{}').error ??
255
+ `the restore command ${command.state}`;
256
+ fail(restore, error, restore.mode === 'in-place');
257
+ }
258
+ break;
259
+ }
260
+ default:
261
+ }
262
+ }
263
+ }
264
+ /** Drops the holds and the lock of an interrupted restore; the data may be partly restored. */
265
+ function releaseInterrupted(restoreId) {
266
+ const restore = db.get('SELECT * FROM restore_runs WHERE id=?', restoreId);
267
+ if (!restore)
268
+ return { error: 'restore not found', status: 404 };
269
+ if (restore.state !== 'interrupted')
270
+ return { error: `restore is ${restore.state}, not interrupted`, status: 409 };
271
+ release(restore);
272
+ move(restore, 'released');
273
+ event('warn', 'restore.released', restore, `holds for restore of ${restore.target} released; its data may be partly restored`);
274
+ return { ok: true };
275
+ }
276
+ /** Runs the restore step again for an interrupted restore, with the holds still in place. */
277
+ function resume(restoreId) {
278
+ const restore = db.get('SELECT * FROM restore_runs WHERE id=?', restoreId);
279
+ if (!restore)
280
+ return { error: 'restore not found', status: 404 };
281
+ if (restore.state !== 'interrupted')
282
+ return { error: `restore is ${restore.state}, not interrupted`, status: 409 };
283
+ queueRestoreJob(restore);
284
+ return { ok: true };
285
+ }
286
+ /** Per-project holds delivered to agents in the work response. */
287
+ function holds() {
288
+ const result = {};
289
+ for (const row of db.all('SELECT project_id,service FROM service_holds'))
290
+ (result[row.project_id] ??= []).push(row.service);
291
+ return result;
292
+ }
293
+ function list(project, limit = 20) {
294
+ return db.all('SELECT * FROM restore_runs WHERE project_id=? ORDER BY started_at DESC LIMIT ?', project, limit);
295
+ }
296
+ return { plan, start, advance, releaseInterrupted, resume, holds, list };
297
+ }
@@ -0,0 +1,54 @@
1
+ import type { ResolvedBackup } from './backup-config.ts';
2
+ import type { Db } from './db.ts';
3
+ import type { BackupRunRow } from './db.ts';
4
+ import type { BackupJobPayload, CommandResultPayload, CommandType, DesiredState, JobStatus } from './protocol.ts';
5
+ /** Pinned by tag; override on control with `TUG_RESTIC_IMAGE` (a digest reference is recommended for production). */
6
+ export declare const DEFAULT_RESTIC_IMAGE = "restic/restic:0.17.3";
7
+ export type RunTrigger = 'schedule' | 'manual' | 'pre-restore' | 'catch-up' | 'retry';
8
+ export interface BackupServiceDeps {
9
+ db: Db;
10
+ queueCommand(nodeId: string, projectId: string | null, type: CommandType, payload: Record<string, unknown>, deadline?: string): string;
11
+ /** Removes credential values from error text before it is stored. */
12
+ redact?: (text: string) => string;
13
+ resticImage?: string;
14
+ /** Clock override for tests. */
15
+ clock?: () => Date;
16
+ }
17
+ export interface TargetStatus {
18
+ project: string;
19
+ target: string;
20
+ node: string | null;
21
+ type: string;
22
+ schedule: string;
23
+ enabled: boolean;
24
+ nextRunAt: string;
25
+ lastRunAt: string | null;
26
+ lastSuccessAt: string | null;
27
+ lastStatus: string | null;
28
+ }
29
+ /** Where a backup target runs, from the resolved desired state (labels are already resolved there). */
30
+ export declare function targetNode(target: ResolvedBackup, desired: DesiredState): string | undefined;
31
+ export declare function createBackupService(deps: BackupServiceDeps): {
32
+ sync: (at?: Date) => void;
33
+ tick: (at?: Date) => void;
34
+ onHeartbeat: (jobs: JobStatus[]) => void;
35
+ onResult: (commandId: string, state: string, result: CommandResultPayload) => void;
36
+ runNow: (project: string, targets: string[] | undefined, actor: string, trigger?: RunTrigger) => {
37
+ runs: {
38
+ runId: string;
39
+ target: string;
40
+ }[];
41
+ errors: string[];
42
+ };
43
+ list: (project: string, limit?: number) => {
44
+ targets: TargetStatus[];
45
+ runs: BackupRunRow[];
46
+ };
47
+ queueRestore: (project: string, target: string, restore: NonNullable<BackupJobPayload["restore"]>, runId: string) => {
48
+ commandId: string;
49
+ nodeId: string;
50
+ } | {
51
+ error: string;
52
+ };
53
+ };
54
+ export type BackupService = ReturnType<typeof createBackupService>;