@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,403 @@
1
+ import { cronPeriodMs, nextCronRun } from "./cron.js";
2
+ import { resolveBackups } from "./backup-config.js";
3
+ import { projectRepository } from "./backup-destination.js";
4
+ import { volumeName } from "./container-spec.js";
5
+ import { id } from "./util.js";
6
+ import { isUniqueViolation } from "./db-driver.js";
7
+ /** Pinned by tag; override on control with `TUG_RESTIC_IMAGE` (a digest reference is recommended for production). */
8
+ export const DEFAULT_RESTIC_IMAGE = 'restic/restic:0.17.3';
9
+ const MINUTE = 60_000;
10
+ const DEFAULT_MEMORY = '512m';
11
+ const RETRY_DELAY_MS = 10 * MINUTE;
12
+ const AGENT_LOST_MS = 10 * MINUTE;
13
+ const MAX_DEADLINE_MS = 6 * 3600_000;
14
+ const MANUAL_DEADLINE_MS = 30 * MINUTE;
15
+ const STALE_REPEAT_MS = 24 * 3600_000;
16
+ const MAX_CATCH_UP_SLOTS = 20_000;
17
+ function parse(json, fallback) {
18
+ try {
19
+ return json ? JSON.parse(json) : fallback;
20
+ }
21
+ catch {
22
+ return fallback;
23
+ }
24
+ }
25
+ /** Where a backup target runs, from the resolved desired state (labels are already resolved there). */
26
+ export function targetNode(target, desired) {
27
+ if (target.kind === 'service') {
28
+ const nodes = desired.services[target.name]?.placement.nodes ?? [];
29
+ return nodes.length === 1 ? nodes[0] : undefined;
30
+ }
31
+ const placed = desired.compose.volumes?.[target.name]?.['x-tug']?.placement?.nodes ?? [];
32
+ if (placed.length)
33
+ return placed.length === 1 ? placed[0] : undefined;
34
+ const mounting = Object.entries(desired.compose.services).filter(([, svc]) => (svc.volumes ?? []).some((mount) => typeof mount === 'object' && mount.type === 'volume' && mount.source === target.name));
35
+ const nodes = [
36
+ ...new Set(mounting.flatMap(([name]) => desired.services[name]?.placement.nodes ?? [])),
37
+ ];
38
+ return nodes.length === 1 ? nodes[0] : undefined;
39
+ }
40
+ export function createBackupService(deps) {
41
+ const { db } = deps;
42
+ const clock = deps.clock ?? (() => new Date());
43
+ const resticImage = deps.resticImage || DEFAULT_RESTIC_IMAGE;
44
+ const redact = deps.redact ?? ((text) => text);
45
+ const started = clock().getTime();
46
+ const stamp = () => clock().toISOString();
47
+ const heartbeats = new Map();
48
+ const destination = () => db.get("SELECT * FROM backup_destination WHERE id='default'");
49
+ const nodeId = (name) => db.get('SELECT id FROM nodes WHERE name=?', name)?.id;
50
+ const nodeName = (nodeIdValue) => nodeIdValue
51
+ ? (db.get('SELECT name FROM nodes WHERE id=?', nodeIdValue)?.name ?? null)
52
+ : null;
53
+ function event(level, type, projectId, message, metadata, node, actor) {
54
+ db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?,?,?,?)', id('evt'), projectId, null, node ?? null, level, type, message, JSON.stringify(metadata), stamp(), actor ?? null);
55
+ }
56
+ /** Desired states of projects whose current deployment succeeded. */
57
+ function deployed(projectId) {
58
+ const rows = db.all(`SELECT p.id AS project, ds.state_json, d.id AS deployment_id FROM projects p
59
+ JOIN deployments d ON d.id=p.current_deployment_id AND d.state='succeeded'
60
+ JOIN desired_states ds ON ds.deployment_id=d.id ${projectId ? 'WHERE p.id=?' : ''}`, ...(projectId ? [projectId] : []));
61
+ return rows.map((row) => ({
62
+ project: row.project,
63
+ desired: JSON.parse(row.state_json),
64
+ deploymentId: row.deployment_id,
65
+ }));
66
+ }
67
+ /** Brings `backup_schedules` in line with the current successful deployments. */
68
+ function sync(at = clock()) {
69
+ for (const { project, desired, deploymentId } of deployed()) {
70
+ const { targets } = resolveBackups(desired.compose, project);
71
+ const seen = new Set();
72
+ for (const target of targets) {
73
+ const node = targetNode(target, desired);
74
+ const config = JSON.stringify({ ...target, node });
75
+ const existing = db.get('SELECT * FROM backup_schedules WHERE project_id=? AND target=?', project, target.target);
76
+ seen.add(target.target);
77
+ const ts = stamp();
78
+ if (!existing) {
79
+ const next = nextCronRun(target.schedule, at);
80
+ if (!next)
81
+ continue;
82
+ db.run(`INSERT INTO backup_schedules(id,project_id,target,type,node_id,config_json,schedule,jitter_seconds,next_run_at,deployment_id,enabled,created_at,updated_at)
83
+ VALUES(?,?,?,?,?,?,?,?,?,?,1,?,?)`, id('bks'), project, target.target, target.type, node ? (nodeId(node) ?? null) : null, config, target.schedule, target.jitterSeconds, next.toISOString(), deploymentId, ts, ts);
84
+ continue;
85
+ }
86
+ const scheduleChanged = existing.schedule !== target.schedule;
87
+ const next = scheduleChanged ? nextCronRun(target.schedule, at) : null;
88
+ db.run(`UPDATE backup_schedules SET type=?,node_id=?,config_json=?,schedule=?,jitter_seconds=?,deployment_id=?,enabled=1,next_run_at=?,updated_at=? WHERE id=?`, target.type, node ? (nodeId(node) ?? null) : null, config, target.schedule, target.jitterSeconds, deploymentId, next ? next.toISOString() : existing.next_run_at, ts, existing.id);
89
+ }
90
+ // A target that left the Compose file stops running; its snapshots stay in the repository.
91
+ for (const row of db.all('SELECT * FROM backup_schedules WHERE project_id=? AND enabled=1', project))
92
+ if (!seen.has(row.target))
93
+ db.run('UPDATE backup_schedules SET enabled=0,updated_at=? WHERE id=?', stamp(), row.id);
94
+ }
95
+ }
96
+ function buildPayload(schedule, runId, trigger, desired, node) {
97
+ const config = parse(schedule.config_json, {});
98
+ const dest = destination();
99
+ const payload = {
100
+ runId,
101
+ project: schedule.project_id,
102
+ target: schedule.target,
103
+ type: config.type,
104
+ repository: projectRepository(dest.url, schedule.project_id),
105
+ resticImage,
106
+ node,
107
+ tags: [
108
+ 'tug',
109
+ `project:${schedule.project_id}`,
110
+ `target:${schedule.target}`,
111
+ `type:${config.type}`,
112
+ `run:${runId}`,
113
+ `trigger:${trigger}`,
114
+ ],
115
+ retention: config.retention,
116
+ limits: { memory: DEFAULT_MEMORY, timeoutSec: config.timeoutSec },
117
+ };
118
+ if (config.kind === 'service') {
119
+ payload.command = {
120
+ service: config.name,
121
+ argv: config.command ?? [],
122
+ filename: config.filename ?? `${config.name}.out`,
123
+ verify: config.verify,
124
+ restoreArgv: config.restore,
125
+ };
126
+ }
127
+ else {
128
+ payload.volume = {
129
+ name: config.name,
130
+ dockerVolume: volumeName(schedule.project_id, config.name),
131
+ exclude: config.exclude,
132
+ };
133
+ }
134
+ return payload;
135
+ }
136
+ function insertRun(schedule, trigger, scheduledFor, state, extra = {}) {
137
+ const runId = extra.runId ?? id('bkr');
138
+ try {
139
+ db.run(`INSERT INTO backup_runs(id,schedule_id,project_id,target,type,node_id,command_id,trigger,state,scheduled_for,queued_at,finished_at,error,actor,deployment_version)
140
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, runId, schedule.id, schedule.project_id, schedule.target, schedule.type, extra.nodeId ?? schedule.node_id, extra.commandId ?? null, trigger, state, scheduledFor, stamp(), state === 'queued' ? null : stamp(), extra.error ?? null, extra.actor ?? null, null);
141
+ }
142
+ catch (error) {
143
+ if (isUniqueViolation(error))
144
+ return null; // this slot already has a run
145
+ throw error;
146
+ }
147
+ return runId;
148
+ }
149
+ /** Queues one backup of a schedule's target; returns the run id, or why it could not be queued. */
150
+ function queueRun(schedule, trigger, scheduledFor, at, actor) {
151
+ const config = parse(schedule.config_json, {});
152
+ const fail = (error, type) => {
153
+ const runId = insertRun(schedule, trigger, scheduledFor, 'missed', { error, actor });
154
+ if (runId)
155
+ event('error', type, schedule.project_id, `backup of ${schedule.target} could not start: ${error}`, {
156
+ runId,
157
+ target: schedule.target,
158
+ reason: error,
159
+ }, nodeName(schedule.node_id), actor);
160
+ return { error };
161
+ };
162
+ // A restore rewrites this target's data; a backup taken mid-restore would capture a half-restored state.
163
+ if (trigger !== 'pre-restore' &&
164
+ db.get("SELECT 1 FROM restore_runs WHERE project_id=? AND target=? AND mode='in-place' AND state NOT IN ('succeeded','failed','released')", schedule.project_id, schedule.target)) {
165
+ insertRun(schedule, trigger, scheduledFor, 'cancelled', { error: 'restore_in_progress' });
166
+ return { error: 'a restore of this target is in progress' };
167
+ }
168
+ if (!destination()) {
169
+ const runId = insertRun(schedule, trigger, scheduledFor, 'missed', {
170
+ error: 'no backup destination is configured',
171
+ actor,
172
+ });
173
+ if (runId)
174
+ event('warn', 'backup.no_destination', schedule.project_id, `backup of ${schedule.target} skipped: no backup destination is configured; run tug backup-destination-set`, {
175
+ runId,
176
+ target: schedule.target,
177
+ }, null, actor);
178
+ return { error: 'no backup destination is configured' };
179
+ }
180
+ const current = deployed(schedule.project_id)[0];
181
+ const node = current ? targetNode(config, current.desired) : undefined;
182
+ if (!current || !node || !schedule.node_id)
183
+ return fail('the target is not placed on exactly one node', 'backup.failed');
184
+ const runId = id('bkr');
185
+ const payload = buildPayload(schedule, runId, trigger, current.desired, node);
186
+ const period = cronPeriodMs(schedule.schedule, at) ?? MAX_DEADLINE_MS * 2;
187
+ const window = trigger === 'manual' || trigger === 'pre-restore'
188
+ ? MANUAL_DEADLINE_MS
189
+ : Math.min(period / 2, MAX_DEADLINE_MS);
190
+ const commandId = deps.queueCommand(schedule.node_id, schedule.project_id, 'backup.run', payload, new Date(at.getTime() + window).toISOString());
191
+ const inserted = insertRun(schedule, trigger, scheduledFor, 'queued', {
192
+ runId,
193
+ commandId,
194
+ actor,
195
+ nodeId: schedule.node_id,
196
+ });
197
+ if (!inserted) {
198
+ db.run("UPDATE commands SET state='cancelled' WHERE id=?", commandId);
199
+ return { error: 'this slot already has a run' };
200
+ }
201
+ return { runId };
202
+ }
203
+ /** Marks a run's schedule row with its latest outcome. */
204
+ function noteOutcome(run, status, at, success) {
205
+ if (!run.schedule_id)
206
+ return;
207
+ db.run(`UPDATE backup_schedules SET last_run_at=?,last_status=?${success ? ',last_success_at=?' : ''} WHERE id=?`, ...(success ? [at, status, at, run.schedule_id] : [at, status, run.schedule_id]));
208
+ }
209
+ function tick(at = clock()) {
210
+ sync(at);
211
+ const iso = at.toISOString();
212
+ // Commands the node never fetched before their deadline: the slot is missed, not silently dropped.
213
+ for (const run of db.all(`SELECT r.*, c.deadline FROM backup_runs r JOIN commands c ON c.id=r.command_id
214
+ WHERE r.state='queued' AND c.state IN ('pending','expired') AND c.deadline IS NOT NULL AND c.deadline<?`, iso)) {
215
+ db.run("UPDATE commands SET state='expired',updated_at=? WHERE id=?", iso, run.command_id);
216
+ db.run("UPDATE backup_runs SET state='missed',finished_at=?,error=? WHERE id=?", iso, 'node_offline', run.id);
217
+ noteOutcome(run, 'missed', iso, false);
218
+ event('error', 'backup.missed', run.project_id, `backup of ${run.target} missed: node ${nodeName(run.node_id) ?? ''} did not pick it up before its deadline`, {
219
+ runId: run.id,
220
+ target: run.target,
221
+ reason: 'node_offline',
222
+ lastSeenAt: db.get('SELECT last_seen_at FROM nodes WHERE id=?', run.node_id)?.last_seen_at ?? null,
223
+ }, nodeName(run.node_id));
224
+ }
225
+ // A job whose agent stopped reporting for ten minutes is lost.
226
+ for (const run of db.all("SELECT * FROM backup_runs WHERE state='running'")) {
227
+ const last = heartbeats.get(run.command_id ?? '') ?? started;
228
+ if (at.getTime() - last < AGENT_LOST_MS)
229
+ continue;
230
+ db.run("UPDATE backup_runs SET state='failed',finished_at=?,error=? WHERE id=?", iso, 'agent_lost', run.id);
231
+ db.run("UPDATE commands SET state='failed',updated_at=? WHERE id=? AND state='running'", iso, run.command_id);
232
+ noteOutcome(run, 'failed', iso, false);
233
+ event('error', 'backup.failed', run.project_id, `backup of ${run.target} failed: the agent stopped reporting`, {
234
+ runId: run.id,
235
+ target: run.target,
236
+ reason: 'agent_lost',
237
+ }, nodeName(run.node_id));
238
+ }
239
+ // Due slots. A slot long overdue (control was down) becomes one missed event and one catch-up run, never a burst.
240
+ for (const schedule of db.all('SELECT * FROM backup_schedules WHERE enabled=1 AND next_run_at<=?', iso)) {
241
+ const jitterMs = schedule.jitter_seconds * 1000;
242
+ const slot = new Date(schedule.next_run_at);
243
+ if (slot.getTime() + jitterMs > at.getTime())
244
+ continue;
245
+ const grace = Math.max(10 * MINUTE, jitterMs);
246
+ const lateness = at.getTime() - (slot.getTime() + jitterMs);
247
+ let next = nextCronRun(schedule.schedule, slot);
248
+ let latest = slot;
249
+ let missedSlots = 0;
250
+ if (lateness > grace) {
251
+ // Find the most recent slot that is not in the future; every earlier one is part of the gap.
252
+ for (let i = 0; next && next.getTime() <= at.getTime() && i < MAX_CATCH_UP_SLOTS; i++) {
253
+ latest = next;
254
+ missedSlots++;
255
+ next = nextCronRun(schedule.schedule, next);
256
+ }
257
+ const runId = insertRun(schedule, 'schedule', latest.toISOString(), 'missed', {
258
+ error: 'control_down',
259
+ });
260
+ if (runId)
261
+ event('error', 'backup.missed', schedule.project_id, `backup of ${schedule.target} missed ${missedSlots + 1} scheduled run${missedSlots ? 's' : ''} while control was not running`, {
262
+ runId,
263
+ target: schedule.target,
264
+ reason: 'control_down',
265
+ gapMinutes: Math.round((at.getTime() - slot.getTime()) / MINUTE),
266
+ }, nodeName(schedule.node_id));
267
+ noteOutcome({ schedule_id: schedule.id }, 'missed', iso, false);
268
+ queueRun(schedule, 'catch-up', null, at);
269
+ }
270
+ else {
271
+ queueRun(schedule, 'schedule', slot.toISOString(), at);
272
+ }
273
+ db.run('UPDATE backup_schedules SET next_run_at=? WHERE id=?', (next ?? new Date(at.getTime() + 24 * 3600_000)).toISOString(), schedule.id);
274
+ }
275
+ // One automatic retry, ten minutes after a scheduled run failed.
276
+ for (const run of db.all(`SELECT r.* FROM backup_runs r WHERE r.state='failed' AND r.trigger IN ('schedule','catch-up') AND r.finished_at<=?
277
+ AND NOT EXISTS (SELECT 1 FROM backup_runs x WHERE x.schedule_id=r.schedule_id AND x.trigger='retry' AND x.scheduled_for IS r.scheduled_for AND x.queued_at>=r.queued_at)`, new Date(at.getTime() - RETRY_DELAY_MS).toISOString())) {
278
+ const schedule = run.schedule_id
279
+ ? db.get('SELECT * FROM backup_schedules WHERE id=? AND enabled=1', run.schedule_id)
280
+ : undefined;
281
+ if (schedule)
282
+ queueRun(schedule, 'retry', run.scheduled_for, at);
283
+ }
284
+ // Independent of single runs: a target that has not succeeded for two periods is stale, even when each run failed alike.
285
+ for (const schedule of db.all('SELECT * FROM backup_schedules WHERE enabled=1')) {
286
+ const period = cronPeriodMs(schedule.schedule, at);
287
+ if (!period)
288
+ continue;
289
+ const since = new Date(schedule.last_success_at ?? schedule.created_at ?? iso).getTime();
290
+ if (at.getTime() - since <= 2 * period + 10 * MINUTE)
291
+ continue;
292
+ if (schedule.stale_notified_at &&
293
+ at.getTime() - new Date(schedule.stale_notified_at).getTime() < STALE_REPEAT_MS)
294
+ continue;
295
+ db.run('UPDATE backup_schedules SET stale_notified_at=? WHERE id=?', iso, schedule.id);
296
+ event('error', 'backup.stale', schedule.project_id, `backup of ${schedule.target} has not succeeded since ${schedule.last_success_at ?? 'it was configured'}`, {
297
+ target: schedule.target,
298
+ lastSuccessAt: schedule.last_success_at,
299
+ }, nodeName(schedule.node_id));
300
+ }
301
+ }
302
+ /** Heartbeats from `StatusReport.jobs`: a queued run becomes running, and a running one stays alive. */
303
+ function onHeartbeat(jobs) {
304
+ const at = clock().getTime();
305
+ for (const job of jobs) {
306
+ heartbeats.set(job.commandId, at);
307
+ db.run("UPDATE backup_runs SET state='running',started_at=COALESCE(started_at,?) WHERE command_id=? AND state='queued'", job.startedAt, job.commandId);
308
+ }
309
+ }
310
+ function onResult(commandId, state, result) {
311
+ const run = db.get('SELECT * FROM backup_runs WHERE command_id=?', commandId);
312
+ if (!run || ['succeeded', 'failed', 'missed', 'cancelled'].includes(run.state))
313
+ return;
314
+ const at = stamp();
315
+ heartbeats.delete(commandId);
316
+ if (state === 'succeeded') {
317
+ const r = result;
318
+ db.run(`UPDATE backup_runs SET state='succeeded',finished_at=?,snapshot_id=?,size_bytes=?,added_bytes=?,duration_ms=?,restic_version=?,metadata_json=? WHERE id=?`, at, r.snapshotId ?? null, r.totalBytes ?? null, r.addedBytes ?? null, r.durationMs ?? null, r.resticVersion ?? null, JSON.stringify({ warnings: r.warnings ?? [] }), run.id);
319
+ noteOutcome(run, 'succeeded', at, true);
320
+ db.run('UPDATE backup_schedules SET stale_notified_at=NULL WHERE id=?', run.schedule_id);
321
+ event('info', 'backup.completed', run.project_id, `backup of ${run.target} completed${r.snapshotId ? ` as snapshot ${r.snapshotId.slice(0, 8)}` : ''}`, {
322
+ runId: run.id,
323
+ target: run.target,
324
+ snapshotId: r.snapshotId,
325
+ addedBytes: r.addedBytes,
326
+ totalBytes: r.totalBytes,
327
+ durationMs: r.durationMs,
328
+ warnings: r.warnings,
329
+ }, nodeName(run.node_id), run.actor ?? undefined);
330
+ return;
331
+ }
332
+ const error = redact(result.error ?? 'the backup job failed').slice(0, 2000);
333
+ db.run("UPDATE backup_runs SET state='failed',finished_at=?,error=? WHERE id=?", at, error, run.id);
334
+ noteOutcome(run, 'failed', at, false);
335
+ event('error', 'backup.failed', run.project_id, `backup of ${run.target} failed: ${error}`, {
336
+ runId: run.id,
337
+ target: run.target,
338
+ error,
339
+ }, nodeName(run.node_id), run.actor ?? undefined);
340
+ }
341
+ /** Queues manual backups of the named targets, or every target of the project when none are named. */
342
+ function runNow(project, targets, actor, trigger = 'manual') {
343
+ sync();
344
+ const schedules = db.all('SELECT * FROM backup_schedules WHERE project_id=? AND enabled=1 ORDER BY target', project);
345
+ const wanted = targets?.length
346
+ ? targets.map((name) => (name.includes(':') ? name : undefined) ?? name)
347
+ : schedules.map((row) => row.target);
348
+ const runs = [];
349
+ const errors = [];
350
+ for (const name of wanted) {
351
+ const schedule = schedules.find((row) => row.target === name) ??
352
+ schedules.find((row) => row.target.endsWith(`:${name}`));
353
+ if (!schedule) {
354
+ errors.push(`${name} is not a backup target of project ${project}`);
355
+ continue;
356
+ }
357
+ const queued = queueRun(schedule, trigger, null, clock(), actor);
358
+ if ('error' in queued)
359
+ errors.push(`${schedule.target}: ${queued.error}`);
360
+ else
361
+ runs.push({ runId: queued.runId, target: schedule.target });
362
+ }
363
+ return { runs, errors };
364
+ }
365
+ function list(project, limit = 50) {
366
+ const targets = db
367
+ .all('SELECT * FROM backup_schedules WHERE project_id=? ORDER BY target', project)
368
+ .map((row) => ({
369
+ project: row.project_id,
370
+ target: row.target,
371
+ node: nodeName(row.node_id),
372
+ type: row.type,
373
+ schedule: row.schedule,
374
+ enabled: row.enabled === 1,
375
+ nextRunAt: row.next_run_at,
376
+ lastRunAt: row.last_run_at,
377
+ lastSuccessAt: row.last_success_at,
378
+ lastStatus: row.last_status,
379
+ }));
380
+ const runs = db.all('SELECT * FROM backup_runs WHERE project_id=? ORDER BY COALESCE(finished_at,queued_at) DESC LIMIT ?', project, limit);
381
+ return { targets, runs };
382
+ }
383
+ /** Queues the `backup.restore` job for a target on its node. */
384
+ function queueRestore(project, target, restore, runId) {
385
+ const schedule = db.get('SELECT * FROM backup_schedules WHERE project_id=? AND target=? AND enabled=1', project, target);
386
+ const current = deployed(project)[0];
387
+ if (!schedule || !current)
388
+ return { error: `${target} is not a backup target of project ${project}` };
389
+ if (!destination())
390
+ return { error: 'no backup destination is configured' };
391
+ const config = parse(schedule.config_json, {});
392
+ const node = targetNode(config, current.desired);
393
+ if (!node || !schedule.node_id)
394
+ return { error: 'the target is not placed on exactly one node' };
395
+ const payload = buildPayload(schedule, runId, 'manual', current.desired, node);
396
+ payload.restore = restore;
397
+ delete payload.retention;
398
+ const deadline = new Date(clock().getTime() + (config.timeoutSec + 10 * 60) * 1000);
399
+ const commandId = deps.queueCommand(schedule.node_id, project, 'backup.restore', payload, deadline.toISOString());
400
+ return { commandId, nodeId: schedule.node_id };
401
+ }
402
+ return { sync, tick, onHeartbeat, onResult, runNow, list, queueRestore };
403
+ }
package/dist/build.d.ts CHANGED
@@ -12,11 +12,10 @@ export interface BuildSpec {
12
12
  }
13
13
  export declare function run(cmd: string, args: string[], opts?: RunOptions): Promise<RunResult>;
14
14
  export declare function unpackContext(tarball: string, dest: string): Promise<void>;
15
- export interface BuildAndPushOptions {
15
+ export interface BuildImageOptions {
16
16
  contextDir: string;
17
17
  service: string;
18
18
  buildSpec: string | BuildSpec;
19
19
  imageRef: string;
20
- push?: boolean;
21
20
  }
22
- export declare function buildAndPush({ contextDir, service, buildSpec, imageRef, push, }: BuildAndPushOptions): Promise<void>;
21
+ export declare function buildImage({ contextDir, service, buildSpec, imageRef, }: BuildImageOptions): Promise<void>;
package/dist/build.js CHANGED
@@ -33,7 +33,7 @@ export async function unpackContext(tarball, dest) {
33
33
  });
34
34
  await tar.x({ file: tarball, cwd: dest, strict: true });
35
35
  }
36
- export async function buildAndPush({ contextDir, service, buildSpec, imageRef, push = true, }) {
36
+ export async function buildImage({ contextDir, service, buildSpec, imageRef, }) {
37
37
  const buildPath = typeof buildSpec === 'string' ? buildSpec : buildSpec?.context || '.';
38
38
  const dockerfilePath = typeof buildSpec === 'object' && buildSpec.dockerfile
39
39
  ? path.resolve(contextDir, buildPath, buildSpec.dockerfile)
@@ -48,6 +48,4 @@ export async function buildAndPush({ contextDir, service, buildSpec, imageRef, p
48
48
  if (!(await exists(fullContext)))
49
49
  throw new Error(`build context not found for ${service}: ${buildPath}`);
50
50
  await run('docker', ['build', ...dockerfile, '-t', imageRef, fullContext]);
51
- if (push)
52
- await run('docker', ['push', imageRef]);
53
51
  }
@@ -1,14 +1,14 @@
1
- import { buildAndPush } from './build.ts';
1
+ import { buildImage } from './build.ts';
2
2
  import { saveImageArchive } from './artifacts.ts';
3
3
  import type { ComposeFile, LocalImageUpload } from './protocol.ts';
4
4
  export interface LocalBuildDependencies {
5
- build: typeof buildAndPush;
5
+ build: typeof buildImage;
6
6
  save: typeof saveImageArchive;
7
7
  nonce: () => string;
8
8
  remove: (imageRef: string) => Promise<void>;
9
9
  cleanupWarning?: (message: string) => void;
10
10
  }
11
- /** Credential exclusions are mandatory and cannot be re-included by source ignore rules. */
11
+ /** Credential exclusions are mandatory and cannot be re-included by source ignore rules. The tracked config names are excluded at the context root only, so an app's own nested `tug.json` is kept. */
12
12
  export declare function sourceArchiveFilter(entry: string, configPaths: string[], ignores: string[]): boolean;
13
13
  export declare function createSourceArchive(contextDir: string, archive: string, configFiles: string[]): Promise<void>;
14
14
  /** Build only application build services; callers own temporary files and upload transport. */
package/dist/cli-build.js CHANGED
@@ -2,15 +2,28 @@ import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import * as tar from 'tar';
5
- import { buildAndPush, run } from "./build.js";
5
+ import { buildImage, run } from "./build.js";
6
6
  import { saveImageArchive } from "./artifacts.js";
7
- /** Credential exclusions are mandatory and cannot be re-included by source ignore rules. */
7
+ import { CONFIG_NAMES } from "./cli-config.js";
8
+ /** At any depth: the git directory and every file that can hold a token (the untracked config overlays). */
9
+ const ALWAYS_EXCLUDED = [
10
+ '.git',
11
+ '.tug.json',
12
+ '.tug.local.json',
13
+ 'tug.local.yml',
14
+ 'tug.local.yaml',
15
+ 'tug.local.json',
16
+ ];
17
+ /** Credential exclusions are mandatory and cannot be re-included by source ignore rules. The tracked config names are excluded at the context root only, so an app's own nested `tug.json` is kept. */
8
18
  export function sourceArchiveFilter(entry, configPaths, ignores) {
9
19
  const relative = entry
10
20
  .replaceAll('\\', '/')
11
21
  .replace(/^(\.\/)+/, '')
12
22
  .replace(/\/$/, '');
13
- if (relative.split('/').some((part) => ['.git', '.tug.json'].includes(part)))
23
+ const parts = relative.split('/');
24
+ if (parts.some((part) => ALWAYS_EXCLUDED.includes(part)))
25
+ return false;
26
+ if (parts.length === 1 && CONFIG_NAMES.includes(relative))
14
27
  return false;
15
28
  return ![...configPaths, ...ignores].some((ignore) => relative === ignore || relative.startsWith(`${ignore}/`));
16
29
  }
@@ -18,7 +31,7 @@ export async function createSourceArchive(contextDir, archive, configFiles) {
18
31
  const configPaths = configFiles
19
32
  .map((file) => path.relative(contextDir, file).split(path.sep).join('/'))
20
33
  .filter((file) => file !== '..' && !file.startsWith('../') && !path.isAbsolute(file));
21
- const ignores = ['node_modules', 'data', 'registry-data'];
34
+ const ignores = ['node_modules', 'data'];
22
35
  try {
23
36
  ignores.push(...(await fs.readFile(path.join(contextDir, '.dockerignore'), 'utf8'))
24
37
  .split(/\r?\n/)
@@ -38,7 +51,7 @@ export async function createSourceArchive(contextDir, archive, configFiles) {
38
51
  }
39
52
  /** Build only application build services; callers own temporary files and upload transport. */
40
53
  export async function prepareLocalImages(compose, contextDir, directory, dependencies = {
41
- build: buildAndPush,
54
+ build: buildImage,
42
55
  save: saveImageArchive,
43
56
  nonce: randomUUID,
44
57
  remove: async (imageRef) => {
@@ -58,7 +71,6 @@ export async function prepareLocalImages(compose, contextDir, directory, depende
58
71
  service,
59
72
  buildSpec: spec.build,
60
73
  imageRef,
61
- push: false,
62
74
  });
63
75
  const archive = await dependencies.save(imageRef, path.join(directory, `${field}.tar.gz`));
64
76
  images.push({ service, imageRef, ...archive, field });
@@ -1,20 +1,60 @@
1
- export interface CliConfig {
2
- controlUrl?: string;
3
- operatorToken?: string;
4
- project?: string;
1
+ export type SettingType = 'string' | 'boolean' | 'port' | 'list' | 'path';
2
+ export interface Setting {
3
+ type: SettingType;
4
+ /** The flag or positional argument the key stands for; the key is the camelCase of the long flag. */
5
+ flag: string;
6
+ /** Commands that read the key as a default for `flag`. */
7
+ commands: string[];
5
8
  }
9
+ /** Defaults for options of commands that work on the project (top level of the config file). */
10
+ export declare const COMMAND_SETTINGS: Record<string, Setting>;
11
+ /** Per-node connection data (`nodes.<name>`); `nodeDefaults` takes the subset that is not node-specific. */
12
+ export declare const NODE_SETTINGS: Record<string, Setting>;
13
+ /** Default file names, in lookup order; more than one present is an error. */
14
+ export declare const CONFIG_NAMES: string[];
15
+ export type SettingValues = Record<string, string | boolean | string[]>;
6
16
  export interface ConnectionOptions {
7
17
  config?: string;
8
18
  controlUrl?: string;
9
19
  project?: string;
10
20
  }
11
21
  export interface ResolvedCliConfig {
22
+ /** The tracked config: selected, discovered, or where `init` creates it. */
12
23
  file: string;
24
+ fileExists: boolean;
25
+ /** The untracked overlay next to `file`, the only place a token may be stored. */
26
+ localFile: string;
13
27
  controlUrl: string;
14
- operatorToken?: string;
28
+ token?: string;
15
29
  project: string;
30
+ commandDefaults: SettingValues;
31
+ nodeDefaults: SettingValues;
32
+ nodes: Record<string, SettingValues>;
16
33
  }
34
+ /** `tug.yml` becomes `tug.local.yml`, `.tug.json` becomes `.tug.local.json`. */
35
+ export declare function localConfigPath(file: string): string;
17
36
  /** Resolve adapter settings once; domain operations receive these explicit values. */
18
37
  export declare function resolveCliConfig(options: ConnectionOptions, env: NodeJS.ProcessEnv, cwd: string, creating?: boolean): Promise<ResolvedCliConfig>;
38
+ /** `nodeDefaults` under the node's own keys; a list in the node replaces the default list. */
39
+ export declare function nodeSettings(config: ResolvedCliConfig, name?: string): SettingValues;
40
+ interface OptionSource {
41
+ name(): string;
42
+ options: readonly {
43
+ attributeName(): string;
44
+ }[];
45
+ getOptionValueSource(key: string): string | undefined;
46
+ }
47
+ /**
48
+ * Use config values as defaults for the options of a command: a key applies to a command that lists it
49
+ * and has an option of that name, and never over a value given on the command line.
50
+ */
51
+ export declare function applyConfigDefaults(command: OptionSource, values: object, settings: SettingValues): void;
52
+ /**
53
+ * `tug init`: create the tracked file when it is missing and always store the token in the untracked
54
+ * overlay, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
55
+ * connection flags passed to `init` are saved to the overlay instead.
56
+ */
57
+ export declare function writeInitConfig(config: ResolvedCliConfig, token: string, explicit: ConnectionOptions): Promise<string[]>;
19
58
  export declare function normalizeControlUrl(value: string): string;
20
59
  export declare function integerOption(value: string, name: string, minimum?: number, maximum?: number): number;
60
+ export {};