@wpelevator/tug 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agent.d.ts +7 -0
- package/dist/agent.js +26 -2
- package/dist/backup-check.d.ts +11 -0
- package/dist/backup-check.js +10 -0
- package/dist/backup-config.d.ts +5 -0
- package/dist/backup-config.js +22 -2
- package/dist/backup-restore.js +28 -8
- package/dist/backup-scheduler.d.ts +7 -1
- package/dist/backup-scheduler.js +21 -6
- package/dist/cli.js +11 -0
- package/dist/normalize.d.ts +6 -0
- package/dist/normalize.js +15 -1
- package/dist/protocol.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your h
|
|
|
72
72
|
- [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
|
|
73
73
|
- [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
|
|
74
74
|
- [Control plane](docs/control-plane.md): install, TLS, image delivery, backup and restore.
|
|
75
|
-
- [Backups](docs/backups.md):
|
|
75
|
+
- [Backups](docs/backups.md): scheduled restic backups with `x-tug.backup`, restore, recipes for common databases, and a restore drill. See also the [WordPress example](examples/wordpress/README.md).
|
|
76
76
|
- [Access control](docs/access-control.md): scoped API tokens, roles, audit trail and a GitHub Actions deploy example.
|
|
77
77
|
- [Networking](docs/networking.md): WireGuard mesh, service DNS, ingress and TLS, firewall notes.
|
|
78
78
|
- [Nodes](docs/nodes.md): SSH install, self-service enrollment, registration and status.
|
package/dist/agent.d.ts
CHANGED
|
@@ -66,6 +66,13 @@ export declare class AgentLiveOperations {
|
|
|
66
66
|
}
|
|
67
67
|
/** The container's address on the project network, which health checks dial. */
|
|
68
68
|
export declare function containerIp(ctx: AgentContext, project: string, container: string): Promise<string>;
|
|
69
|
+
/** True when this process runs inside a container: Docker, containerd, Podman or Kubernetes. */
|
|
70
|
+
export declare function runsInContainer(): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Health checks dial the container's address on the project network. An agent that runs as a plain host process
|
|
73
|
+
* cannot reach it on a machine where Docker lives in a VM (macOS, Windows), and the bare timeout does not say why.
|
|
74
|
+
*/
|
|
75
|
+
export declare function outsideContainerHint(health: HealthCheck, inContainer?: boolean): string;
|
|
69
76
|
export declare function waitForHealth(ctx: AgentContext, project: string, container: string, service: string, health: HealthCheck | null | undefined): Promise<void>;
|
|
70
77
|
export { parseDuration };
|
|
71
78
|
export interface PublicListener {
|
package/dist/agent.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import { imageLocation } from "./registry.js";
|
|
4
|
-
import { createReadStream, createWriteStream } from 'node:fs';
|
|
4
|
+
import { createReadStream, createWriteStream, existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { Readable } from 'node:stream';
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
7
|
import { validateImageArchive, verifyDownload } from "./artifacts.js";
|
|
@@ -485,6 +485,30 @@ function tcpCheck(host, port, timeoutMs = 2000) {
|
|
|
485
485
|
s.on('error', reject);
|
|
486
486
|
});
|
|
487
487
|
}
|
|
488
|
+
let containerized;
|
|
489
|
+
/** True when this process runs inside a container: Docker, containerd, Podman or Kubernetes. */
|
|
490
|
+
export function runsInContainer() {
|
|
491
|
+
containerized ??= (() => {
|
|
492
|
+
if (process.env.container || existsSync('/.dockerenv'))
|
|
493
|
+
return true;
|
|
494
|
+
try {
|
|
495
|
+
return /docker|containerd|kubepods|libpod/.test(readFileSync('/proc/1/cgroup', 'utf8'));
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
})();
|
|
501
|
+
return containerized;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Health checks dial the container's address on the project network. An agent that runs as a plain host process
|
|
505
|
+
* cannot reach it on a machine where Docker lives in a VM (macOS, Windows), and the bare timeout does not say why.
|
|
506
|
+
*/
|
|
507
|
+
export function outsideContainerHint(health, inContainer = runsInContainer()) {
|
|
508
|
+
if (inContainer || health.type === 'docker')
|
|
509
|
+
return '';
|
|
510
|
+
return '; this agent is not running in a container, so it may be unable to reach container addresses (a host process on macOS cannot); run it with `docker run` as `tug node-install` does';
|
|
511
|
+
}
|
|
488
512
|
export async function waitForHealth(ctx, project, container, service, health) {
|
|
489
513
|
if (!ctx.apply || !health)
|
|
490
514
|
return;
|
|
@@ -518,7 +542,7 @@ export async function waitForHealth(ctx, project, container, service, health) {
|
|
|
518
542
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
519
543
|
}
|
|
520
544
|
}
|
|
521
|
-
throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}`);
|
|
545
|
+
throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}${outsideContainerHint(health)}`);
|
|
522
546
|
}
|
|
523
547
|
/** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
|
|
524
548
|
function describeError(error) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The part of `GET /api/v1/backups` that `tug backup-list --check` judges. */
|
|
2
|
+
export interface BackupCheckInput {
|
|
3
|
+
destinationConfigured: boolean;
|
|
4
|
+
targets: {
|
|
5
|
+
target: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
problem?: string | null;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
/** What a monitor should be told: one line per problem, empty when every backup target is healthy. */
|
|
11
|
+
export declare function backupCheckProblems(listing: BackupCheckInput): string[];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** What a monitor should be told: one line per problem, empty when every backup target is healthy. */
|
|
2
|
+
export function backupCheckProblems(listing) {
|
|
3
|
+
const active = listing.targets.filter((target) => target.enabled);
|
|
4
|
+
const problems = active
|
|
5
|
+
.filter((target) => target.problem)
|
|
6
|
+
.map((target) => `${target.target}: ${target.problem}`);
|
|
7
|
+
if (active.length && !listing.destinationConfigured)
|
|
8
|
+
problems.unshift('no backup destination is configured');
|
|
9
|
+
return problems;
|
|
10
|
+
}
|
package/dist/backup-config.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface ResolvedBackup {
|
|
|
11
11
|
retention: BackupRetention;
|
|
12
12
|
jitterSeconds: number;
|
|
13
13
|
timeoutSec: number;
|
|
14
|
+
/** Memory limit of the restic helper container. */
|
|
15
|
+
memory: string;
|
|
14
16
|
/** Service targets: the command run in the service's container, and the snapshot file it is stored as. */
|
|
15
17
|
command?: string[];
|
|
16
18
|
/** Run in the service container to restore; the stored file arrives on its standard input. */
|
|
@@ -26,8 +28,11 @@ export interface ResolvedBackup {
|
|
|
26
28
|
}
|
|
27
29
|
export declare const DEFAULT_RETENTION: BackupRetention;
|
|
28
30
|
export declare const DEFAULT_BACKUP_TIMEOUT_SEC: number;
|
|
31
|
+
export declare const DEFAULT_BACKUP_MEMORY = "512m";
|
|
29
32
|
/** `30m`, `2h`, `90s` or a number of seconds. */
|
|
30
33
|
export declare function parseBackupTimeout(value: unknown): number | null;
|
|
34
|
+
/** Bytes in a Docker-style memory size (`256m`, `1g`, `1048576`), or null when it is not one. */
|
|
35
|
+
export declare function parseMemorySize(value: unknown): number | null;
|
|
31
36
|
/** Deterministic delay in `[0, 900)` seconds so many sites on one node do not start together. */
|
|
32
37
|
export declare function defaultJitterSeconds(project: string, target: string): number;
|
|
33
38
|
/** Validates the project-level defaults in `x-tug.backup`. */
|
package/dist/backup-config.js
CHANGED
|
@@ -2,11 +2,15 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { parseCron } from "./cron.js";
|
|
3
3
|
export const DEFAULT_RETENTION = { daily: 7, weekly: 4, monthly: 6 };
|
|
4
4
|
export const DEFAULT_BACKUP_TIMEOUT_SEC = 2 * 3600;
|
|
5
|
+
export const DEFAULT_BACKUP_MEMORY = '512m';
|
|
6
|
+
/** Below this restic runs out of memory on any repository worth backing up. */
|
|
7
|
+
const MIN_MEMORY_BYTES = 128 * 1024 ** 2;
|
|
8
|
+
const MAX_MEMORY_BYTES = 16 * 1024 ** 3;
|
|
5
9
|
const MIN_TIMEOUT_SEC = 60;
|
|
6
10
|
const MAX_TIMEOUT_SEC = 24 * 3600;
|
|
7
11
|
const MAX_JITTER_SEC = 3600;
|
|
8
12
|
const RETENTION_KEYS = ['last', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
|
|
9
|
-
const COMMON_KEYS = ['schedule', 'retention', 'jitter', 'timeout', 'writers'];
|
|
13
|
+
const COMMON_KEYS = ['schedule', 'retention', 'jitter', 'timeout', 'memory', 'writers'];
|
|
10
14
|
const SERVICE_KEYS = [...COMMON_KEYS, 'command', 'restore', 'filename', 'verify'];
|
|
11
15
|
const VOLUME_KEYS = [...COMMON_KEYS, 'exclude'];
|
|
12
16
|
const FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
@@ -20,6 +24,14 @@ export function parseBackupTimeout(value) {
|
|
|
20
24
|
return null;
|
|
21
25
|
return Number(match[1]) * { s: 1, m: 60, h: 3600 }[match[2]];
|
|
22
26
|
}
|
|
27
|
+
/** Bytes in a Docker-style memory size (`256m`, `1g`, `1048576`), or null when it is not one. */
|
|
28
|
+
export function parseMemorySize(value) {
|
|
29
|
+
const match = typeof value === 'string' ? /^(\d+)([kmg]?)$/i.exec(value.trim()) : null;
|
|
30
|
+
if (!match)
|
|
31
|
+
return null;
|
|
32
|
+
return (Number(match[1]) *
|
|
33
|
+
{ '': 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[match[2].toLowerCase()]);
|
|
34
|
+
}
|
|
23
35
|
/** Deterministic delay in `[0, 900)` seconds so many sites on one node do not start together. */
|
|
24
36
|
export function defaultJitterSeconds(project, target) {
|
|
25
37
|
const digest = createHash('sha256').update(`${project}\0${target}`).digest();
|
|
@@ -152,6 +164,14 @@ export function resolveBackups(compose, project = '') {
|
|
|
152
164
|
else
|
|
153
165
|
timeoutSec = parsed;
|
|
154
166
|
}
|
|
167
|
+
let memory = DEFAULT_BACKUP_MEMORY;
|
|
168
|
+
if (spec.memory !== undefined) {
|
|
169
|
+
const bytes = parseMemorySize(spec.memory);
|
|
170
|
+
if (bytes === null || bytes < MIN_MEMORY_BYTES || bytes > MAX_MEMORY_BYTES)
|
|
171
|
+
errors.push(`${label}.memory must be a size from 128m to 16g such as 256m or 1g`);
|
|
172
|
+
else
|
|
173
|
+
memory = String(spec.memory).trim().toLowerCase();
|
|
174
|
+
}
|
|
155
175
|
if (spec.writers !== undefined) {
|
|
156
176
|
if (!Array.isArray(spec.writers) || spec.writers.some((w) => typeof w !== 'string'))
|
|
157
177
|
errors.push(`${label}.writers must be a list of service names`);
|
|
@@ -162,7 +182,7 @@ export function resolveBackups(compose, project = '') {
|
|
|
162
182
|
}
|
|
163
183
|
if (errors.length > before || !schedule || !retention)
|
|
164
184
|
return null;
|
|
165
|
-
return { schedule, retention, jitterSeconds, timeoutSec };
|
|
185
|
+
return { schedule, retention, jitterSeconds, timeoutSec, memory };
|
|
166
186
|
};
|
|
167
187
|
for (const [name, svc] of Object.entries(compose.services)) {
|
|
168
188
|
const config = svc['x-tug']?.backup;
|
package/dist/backup-restore.js
CHANGED
|
@@ -3,6 +3,10 @@ import { volumeName } from "./container-spec.js";
|
|
|
3
3
|
const MINUTE = 60_000;
|
|
4
4
|
const LOCK_TTL_MS = 15 * MINUTE;
|
|
5
5
|
const HOLD_TIMEOUT_MS = 5 * MINUTE;
|
|
6
|
+
/** How long a finished restore waits for its writers to serve again before it reports that they did not. */
|
|
7
|
+
const VERIFY_TIMEOUT_MS = 5 * MINUTE;
|
|
8
|
+
/** Restore states in which the project must stay locked. */
|
|
9
|
+
const LOCKED_STATES = ['locking', 'holding', 'safety_backup', 'restoring', 'interrupted'];
|
|
6
10
|
const TERMINAL = ['succeeded', 'failed', 'released'];
|
|
7
11
|
/**
|
|
8
12
|
* The services that write to a target. For a service they are the services that depend on it, transitively;
|
|
@@ -59,8 +63,9 @@ export function createRestoreService(deps) {
|
|
|
59
63
|
if (!latest && !from.startsWith('bkr_'))
|
|
60
64
|
return { snapshotId: from, time: null };
|
|
61
65
|
const run = latest
|
|
62
|
-
?
|
|
63
|
-
|
|
66
|
+
? // A safety snapshot records the state just before a restore, often a damaged one, so it is never "the latest backup".
|
|
67
|
+
db.get(`SELECT * FROM backup_runs WHERE project_id=? AND target=? AND state='succeeded' AND snapshot_id IS NOT NULL
|
|
68
|
+
AND trigger != 'pre-restore' ORDER BY finished_at DESC LIMIT 1`, project, target)
|
|
64
69
|
: 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
70
|
if (!run?.snapshot_id)
|
|
66
71
|
return {
|
|
@@ -170,7 +175,8 @@ export function createRestoreService(deps) {
|
|
|
170
175
|
...(restorePlan.scratchVolume ? { scratchVolume: restorePlan.scratchVolume } : {}),
|
|
171
176
|
};
|
|
172
177
|
}
|
|
173
|
-
|
|
178
|
+
/** True when every writer reports `rolloutState` on every node it is placed on. */
|
|
179
|
+
const writersReport = (restore, writers, rolloutState) => {
|
|
174
180
|
const desired = deployed(restore.project_id);
|
|
175
181
|
if (!desired)
|
|
176
182
|
return false;
|
|
@@ -179,7 +185,7 @@ export function createRestoreService(deps) {
|
|
|
179
185
|
return nodes.every((node) => {
|
|
180
186
|
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
187
|
const status = row ? JSON.parse(row.status_json) : undefined;
|
|
182
|
-
return status?.serviceStates?.[service]?.rolloutState ===
|
|
188
|
+
return status?.serviceStates?.[service]?.rolloutState === rolloutState;
|
|
183
189
|
});
|
|
184
190
|
});
|
|
185
191
|
};
|
|
@@ -210,12 +216,12 @@ export function createRestoreService(deps) {
|
|
|
210
216
|
const age = clock().getTime() -
|
|
211
217
|
new Date(restore.state_changed_at ?? restore.started_at ?? stamp()).getTime();
|
|
212
218
|
// Keep the project locked while a restore is unfinished, across a control restart.
|
|
213
|
-
if (restore.mode === 'in-place')
|
|
219
|
+
if (restore.mode === 'in-place' && LOCKED_STATES.includes(restore.state))
|
|
214
220
|
lock(restore);
|
|
215
221
|
const writers = JSON.parse(restore.writers_json);
|
|
216
222
|
switch (restore.state) {
|
|
217
223
|
case 'holding': {
|
|
218
|
-
if (!
|
|
224
|
+
if (!writersReport(restore, writers, 'held')) {
|
|
219
225
|
if (age > HOLD_TIMEOUT_MS)
|
|
220
226
|
fail(restore, 'the writers did not stop in time', false);
|
|
221
227
|
break;
|
|
@@ -247,8 +253,11 @@ export function createRestoreService(deps) {
|
|
|
247
253
|
if (command.state === 'succeeded') {
|
|
248
254
|
if (restore.mode === 'in-place')
|
|
249
255
|
release(restore);
|
|
250
|
-
|
|
251
|
-
|
|
256
|
+
// The data is back; the restore is done when the services that were stopped are serving again.
|
|
257
|
+
if (restore.mode === 'in-place' && writers.length)
|
|
258
|
+
move(restore, 'verifying');
|
|
259
|
+
else
|
|
260
|
+
complete(restore);
|
|
252
261
|
}
|
|
253
262
|
else {
|
|
254
263
|
const error = JSON.parse(command.result_json ?? '{}').error ??
|
|
@@ -257,10 +266,21 @@ export function createRestoreService(deps) {
|
|
|
257
266
|
}
|
|
258
267
|
break;
|
|
259
268
|
}
|
|
269
|
+
case 'verifying': {
|
|
270
|
+
if (writersReport(restore, writers, 'serving'))
|
|
271
|
+
complete(restore);
|
|
272
|
+
else if (age > VERIFY_TIMEOUT_MS)
|
|
273
|
+
complete(restore, `the data was restored, but ${writers.join(', ')} did not report serving within ${VERIFY_TIMEOUT_MS / MINUTE} minutes; check tug status`);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
260
276
|
default:
|
|
261
277
|
}
|
|
262
278
|
}
|
|
263
279
|
}
|
|
280
|
+
function complete(restore, warning) {
|
|
281
|
+
move(restore, 'succeeded', warning ? { error: warning } : {});
|
|
282
|
+
event(warning ? 'warn' : 'info', 'restore.completed', restore, `restore of ${restore.target} completed${restore.scratch_volume ? ` into volume ${restore.scratch_volume}` : ''}${warning ? `, but ${warning}` : ''}`, { scratchVolume: restore.scratch_volume, ...(warning ? { warning } : {}) });
|
|
283
|
+
}
|
|
264
284
|
/** Drops the holds and the lock of an interrupted restore; the data may be partly restored. */
|
|
265
285
|
function releaseInterrupted(restoreId) {
|
|
266
286
|
const restore = db.get('SELECT * FROM restore_runs WHERE id=?', restoreId);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResolvedBackup } from './backup-config.ts';
|
|
2
2
|
import type { Db } from './db.ts';
|
|
3
|
-
import type { BackupRunRow } from './db.ts';
|
|
3
|
+
import type { BackupRunRow, BackupScheduleRow } from './db.ts';
|
|
4
4
|
import type { BackupJobPayload, CommandResultPayload, CommandType, DesiredState, JobStatus } from './protocol.ts';
|
|
5
5
|
/** Pinned by tag; override on control with `TUG_RESTIC_IMAGE` (a digest reference is recommended for production). */
|
|
6
6
|
export declare const DEFAULT_RESTIC_IMAGE = "restic/restic:0.17.3";
|
|
@@ -25,7 +25,13 @@ export interface TargetStatus {
|
|
|
25
25
|
lastRunAt: string | null;
|
|
26
26
|
lastSuccessAt: string | null;
|
|
27
27
|
lastStatus: string | null;
|
|
28
|
+
/** Why this target needs attention (stale, last run failed or missed), or null when it is healthy. */
|
|
29
|
+
problem: string | null;
|
|
28
30
|
}
|
|
31
|
+
/** A target that has not succeeded for two periods is stale, even when every run failed alike. */
|
|
32
|
+
export declare function isStale(schedule: Pick<BackupScheduleRow, 'schedule' | 'last_success_at' | 'created_at'>, at: Date): boolean;
|
|
33
|
+
/** What a monitor should know about a target: null when it is healthy or switched off. */
|
|
34
|
+
export declare function backupProblem(row: BackupScheduleRow, at: Date): string | null;
|
|
29
35
|
/** Where a backup target runs, from the resolved desired state (labels are already resolved there). */
|
|
30
36
|
export declare function targetNode(target: ResolvedBackup, desired: DesiredState): string | undefined;
|
|
31
37
|
export declare function createBackupService(deps: BackupServiceDeps): {
|
package/dist/backup-scheduler.js
CHANGED
|
@@ -14,6 +14,24 @@ const MAX_DEADLINE_MS = 6 * 3600_000;
|
|
|
14
14
|
const MANUAL_DEADLINE_MS = 30 * MINUTE;
|
|
15
15
|
const STALE_REPEAT_MS = 24 * 3600_000;
|
|
16
16
|
const MAX_CATCH_UP_SLOTS = 20_000;
|
|
17
|
+
/** A target that has not succeeded for two periods is stale, even when every run failed alike. */
|
|
18
|
+
export function isStale(schedule, at) {
|
|
19
|
+
const period = cronPeriodMs(schedule.schedule, at);
|
|
20
|
+
if (!period)
|
|
21
|
+
return false;
|
|
22
|
+
const since = new Date(schedule.last_success_at ?? schedule.created_at ?? at.toISOString()).getTime();
|
|
23
|
+
return at.getTime() - since > 2 * period + 10 * MINUTE;
|
|
24
|
+
}
|
|
25
|
+
/** What a monitor should know about a target: null when it is healthy or switched off. */
|
|
26
|
+
export function backupProblem(row, at) {
|
|
27
|
+
if (!row.enabled)
|
|
28
|
+
return null;
|
|
29
|
+
if (isStale(row, at))
|
|
30
|
+
return `no successful backup since ${row.last_success_at ?? 'it was configured'}`;
|
|
31
|
+
if (row.last_status === 'failed' || row.last_status === 'missed')
|
|
32
|
+
return `the last run ${row.last_status}`;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
17
35
|
function parse(json, fallback) {
|
|
18
36
|
try {
|
|
19
37
|
return json ? JSON.parse(json) : fallback;
|
|
@@ -113,7 +131,7 @@ export function createBackupService(deps) {
|
|
|
113
131
|
`trigger:${trigger}`,
|
|
114
132
|
],
|
|
115
133
|
retention: config.retention,
|
|
116
|
-
limits: { memory: DEFAULT_MEMORY, timeoutSec: config.timeoutSec },
|
|
134
|
+
limits: { memory: config.memory ?? DEFAULT_MEMORY, timeoutSec: config.timeoutSec },
|
|
117
135
|
};
|
|
118
136
|
if (config.kind === 'service') {
|
|
119
137
|
payload.command = {
|
|
@@ -283,11 +301,7 @@ export function createBackupService(deps) {
|
|
|
283
301
|
}
|
|
284
302
|
// Independent of single runs: a target that has not succeeded for two periods is stale, even when each run failed alike.
|
|
285
303
|
for (const schedule of db.all('SELECT * FROM backup_schedules WHERE enabled=1')) {
|
|
286
|
-
|
|
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)
|
|
304
|
+
if (!isStale(schedule, at))
|
|
291
305
|
continue;
|
|
292
306
|
if (schedule.stale_notified_at &&
|
|
293
307
|
at.getTime() - new Date(schedule.stale_notified_at).getTime() < STALE_REPEAT_MS)
|
|
@@ -376,6 +390,7 @@ export function createBackupService(deps) {
|
|
|
376
390
|
lastRunAt: row.last_run_at,
|
|
377
391
|
lastSuccessAt: row.last_success_at,
|
|
378
392
|
lastStatus: row.last_status,
|
|
393
|
+
problem: backupProblem(row, clock()),
|
|
379
394
|
}));
|
|
380
395
|
const runs = db.all('SELECT * FROM backup_runs WHERE project_id=? ORDER BY COALESCE(finished_at,queued_at) DESC LIMIT ?', project, limit);
|
|
381
396
|
return { targets, runs };
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
13
|
import { composeHash } from "./compose.js";
|
|
14
14
|
import { normalizeCompose } from "./normalize.js";
|
|
15
15
|
import { errMessage, token, loadTemplate } from "./util.js";
|
|
16
|
+
import { backupCheckProblems } from "./backup-check.js";
|
|
16
17
|
import { PROJECT_NAME_PATTERN, ROLES, TOKEN_NAME_PATTERN } from "./tokens.js";
|
|
17
18
|
import { packageVersion, resolveImage } from "./image.js";
|
|
18
19
|
import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
|
|
@@ -451,6 +452,7 @@ program
|
|
|
451
452
|
.command('backup-list')
|
|
452
453
|
.description('show each backup target with its last success and next run, then recent runs')
|
|
453
454
|
.option('--limit <n>', 'number of recent runs to show', '20')
|
|
455
|
+
.option('--check', 'exit nonzero when a target is stale, or its last run failed or was missed, or there is no destination; for cron and monitors')
|
|
454
456
|
.action(async (o) => {
|
|
455
457
|
const project = (await loadCfg()).project || 'site';
|
|
456
458
|
const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=${encodeURIComponent(o.limit)}`);
|
|
@@ -462,6 +464,13 @@ program
|
|
|
462
464
|
console.log(`${t.target}\t${t.node ?? '-'}\t${t.schedule}\tlast success ${t.lastSuccessAt ?? 'never'}\tnext ${t.enabled ? t.nextRunAt : 'disabled'}`);
|
|
463
465
|
for (const r of listing.runs)
|
|
464
466
|
console.log(`${r.finished_at ?? r.queued_at}\t${r.target}\t${r.trigger}\t${r.state}\t${r.snapshot_id?.slice(0, 8) ?? '-'}\t${formatBytes(r.size_bytes)}${r.error ? `\t${r.error}` : ''}`);
|
|
467
|
+
if (o.check) {
|
|
468
|
+
const problems = backupCheckProblems(listing);
|
|
469
|
+
for (const problem of problems)
|
|
470
|
+
console.error(`PROBLEM ${problem}`);
|
|
471
|
+
if (problems.length)
|
|
472
|
+
process.exitCode = 1;
|
|
473
|
+
}
|
|
465
474
|
});
|
|
466
475
|
program
|
|
467
476
|
.command('restore <target>')
|
|
@@ -504,6 +513,8 @@ program
|
|
|
504
513
|
const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
|
|
505
514
|
const restore = restores.find((item) => item.id === result.restoreId);
|
|
506
515
|
if (restore?.state === 'succeeded') {
|
|
516
|
+
if (restore.error)
|
|
517
|
+
console.error(`warning: ${restore.error}`);
|
|
507
518
|
console.log(`restore ${restore.id} completed${restore.scratch_volume ? `; files are in volume ${restore.scratch_volume}` : ''}`);
|
|
508
519
|
return;
|
|
509
520
|
}
|
package/dist/normalize.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export declare function sentinelNames(value: unknown): string[];
|
|
|
10
10
|
export declare function substituteSentinels(value: string, secrets: Record<string, string>): string;
|
|
11
11
|
/** Arguments of the `docker compose` call that renders `file` canonically (as YAML, which keeps `x-tug`). */
|
|
12
12
|
export declare function composeConfigArgs(file: string, project?: string): string[];
|
|
13
|
+
/**
|
|
14
|
+
* `docker compose config` writes every literal `$` as `$$` so its output can be fed back to Compose. Tug does not
|
|
15
|
+
* interpolate again, so a command like `sh -c 'echo $$HOME'` would reach the container with the doubled dollar,
|
|
16
|
+
* where a shell reads `$$` as its own process ID. Undoing the escaping is exact: output never contains a bare `$`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function unescapeDollars<T>(value: T): T;
|
|
13
19
|
/**
|
|
14
20
|
* Renders `file` with Compose and returns the canonical definition. Every name in `secretNames` is set to
|
|
15
21
|
* its sentinel in the environment, so it wins over a same-named local variable.
|
package/dist/normalize.js
CHANGED
|
@@ -41,6 +41,20 @@ export function composeConfigArgs(file, project) {
|
|
|
41
41
|
'--no-path-resolution',
|
|
42
42
|
];
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* `docker compose config` writes every literal `$` as `$$` so its output can be fed back to Compose. Tug does not
|
|
46
|
+
* interpolate again, so a command like `sh -c 'echo $$HOME'` would reach the container with the doubled dollar,
|
|
47
|
+
* where a shell reads `$$` as its own process ID. Undoing the escaping is exact: output never contains a bare `$`.
|
|
48
|
+
*/
|
|
49
|
+
export function unescapeDollars(value) {
|
|
50
|
+
if (typeof value === 'string')
|
|
51
|
+
return value.replaceAll('$$', '$');
|
|
52
|
+
if (Array.isArray(value))
|
|
53
|
+
return value.map(unescapeDollars);
|
|
54
|
+
if (value && typeof value === 'object')
|
|
55
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unescapeDollars(item)]));
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
44
58
|
/**
|
|
45
59
|
* Renders `file` with Compose and returns the canonical definition. Every name in `secretNames` is set to
|
|
46
60
|
* its sentinel in the environment, so it wins over a same-named local variable.
|
|
@@ -71,5 +85,5 @@ export async function normalizeCompose(file, secretNames, options = {}) {
|
|
|
71
85
|
}
|
|
72
86
|
if (!parsed || typeof parsed !== 'object' || !parsed.services)
|
|
73
87
|
throw new Error(`${file} defines no services`);
|
|
74
|
-
return parsed;
|
|
88
|
+
return unescapeDollars(parsed);
|
|
75
89
|
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface BackupSpec {
|
|
|
44
44
|
jitter?: number;
|
|
45
45
|
/** Hard limit for one run, such as `30m` or `2h`. */
|
|
46
46
|
timeout?: string | number;
|
|
47
|
+
/** Memory limit of the restic helper container, such as `256m` (default `512m`). */
|
|
48
|
+
memory?: string;
|
|
47
49
|
/** Services only: run in the service's container; its standard output is the backup. A string runs through `sh -c`. */
|
|
48
50
|
command?: string | string[];
|
|
49
51
|
/** Services only: run in the service container to restore; the stored file arrives on its standard input. */
|