@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.
package/README.md CHANGED
@@ -61,18 +61,18 @@ npm install -g @wpelevator/tug
61
61
  The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts.
62
62
 
63
63
  1. Install the control plane: `tug control-install root@control.example.com --install-docker`. The flag installs missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts; omit it when Docker is ready. Put HTTPS in front of the control API as described in [Control plane](docs/control-plane.md).
64
- 2. Point the CLI at it: `tug init --control-url https://control.example.com --token "$TUG_TOKEN"`.
64
+ 2. Point the CLI at it: `tug init --control-url https://control.example.com --token "$TUG_TOKEN"`. It writes `tug.yml` (commit it, and list your servers in it) and keeps the token in the git-ignored `tug.local.yml`; see [CLI reference](docs/cli.md#config-file).
65
65
  3. Set the secrets your services need: `tug secret-set DB_PASSWORD`.
66
66
  4. Add nodes with `tug node-install root@app1.example.com --name app1 --install-docker`, or `tug node-enroll --name app1` and run the printed Docker command. For a bare cloud host, `tug node-enroll --name app1 --install-docker` prints cloud-init user-data.
67
67
  5. Deploy: `tug deploy -f docker-compose.yml`. It waits until every node reports the new version healthy.
68
- 6. Operate with `tug status`, `events`, `exec`, `logs`, `ps`, `inspect`, `restart`, `rollback` and `doctor`.
68
+ 6. Operate with `tug status`, `events`, `exec`, `run`, `logs`, `ps`, `inspect`, `restart`, `rollback`, `backup-run`, `restore` and `doctor`.
69
69
 
70
70
  ## Documentation
71
71
 
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): interim WordPress backup sidecar with restic, per-node and NFS variants, and a restore drill until `x-tug.backup` (M11).
75
+ - [Backups](docs/backups.md): interim WordPress backup sidecar with restic, per-node variants, and a restore drill until `x-tug.backup` (M11).
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.
@@ -0,0 +1,41 @@
1
+ import type { DockerApi } from './docker-api.ts';
2
+ import type { BackupCredentialsResponse, Command, CommandResult, JobStatus } from './protocol.ts';
3
+ export interface JobHost {
4
+ docker: DockerApi;
5
+ stateDir: string;
6
+ fetchCredentials(commandId: string): Promise<BackupCredentialsResponse>;
7
+ /** Parallel jobs on this node; extra jobs wait queued. */
8
+ concurrency?: number;
9
+ }
10
+ /**
11
+ * The agent's background jobs. Reconciliation must not wait for an hour-long backup, so commands of
12
+ * these types are queued here, report a heartbeat through `statuses()`, and deliver their result
13
+ * through `drain()` on a later poll, even if the agent restarted in between.
14
+ */
15
+ export declare class JobRunner {
16
+ private readonly host;
17
+ private readonly active;
18
+ private readonly queue;
19
+ private results;
20
+ private ready;
21
+ private settled;
22
+ constructor(host: JobHost);
23
+ /** Called whenever a job finishes, so the agent can report its result without waiting a full interval. */
24
+ onSettled(callback: () => void): void;
25
+ private file;
26
+ private save;
27
+ /** First use: remove helpers of a previous run and fail the jobs it never finished. */
28
+ private init;
29
+ /** Starts cleanup of a previous process's leftovers; safe to call more than once. */
30
+ warm(): Promise<void>;
31
+ /** Queues a job; the command is acknowledged immediately and its result arrives later. */
32
+ start(command: Command): Promise<void>;
33
+ private pump;
34
+ private execute;
35
+ /** Jobs that are running or waiting, for the status report's heartbeat. */
36
+ statuses(): JobStatus[];
37
+ /** Finished results to put in the next status report. They are removed only after `acknowledge`. */
38
+ pending(): CommandResult[];
39
+ /** Forgets results control has stored. */
40
+ acknowledge(delivered: CommandResult[]): Promise<void>;
41
+ }
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { runBackupJob, removeStaleJobContainers } from "./backup-job.js";
4
+ import { redactSecrets } from "./backup-destination.js";
5
+ import { ensureDir, errMessage, now } from "./util.js";
6
+ const RESULTS_FILE = 'job-results.json';
7
+ /**
8
+ * The agent's background jobs. Reconciliation must not wait for an hour-long backup, so commands of
9
+ * these types are queued here, report a heartbeat through `statuses()`, and deliver their result
10
+ * through `drain()` on a later poll, even if the agent restarted in between.
11
+ */
12
+ export class JobRunner {
13
+ host;
14
+ active = new Map();
15
+ queue = [];
16
+ results = [];
17
+ ready;
18
+ settled;
19
+ constructor(host) {
20
+ this.host = host;
21
+ }
22
+ /** Called whenever a job finishes, so the agent can report its result without waiting a full interval. */
23
+ onSettled(callback) {
24
+ this.settled = callback;
25
+ }
26
+ file() {
27
+ return path.join(this.host.stateDir, RESULTS_FILE);
28
+ }
29
+ async save() {
30
+ const data = {
31
+ results: this.results,
32
+ running: [...this.active.keys(), ...this.queue.map((job) => job.command.id)],
33
+ };
34
+ await ensureDir(this.host.stateDir);
35
+ await fs.writeFile(this.file(), JSON.stringify(data), { mode: 0o600 });
36
+ }
37
+ /** First use: remove helpers of a previous run and fail the jobs it never finished. */
38
+ init() {
39
+ this.ready ??= (async () => {
40
+ await removeStaleJobContainers(this.host.docker).catch(() => 0);
41
+ try {
42
+ const saved = JSON.parse(await fs.readFile(this.file(), 'utf8'));
43
+ this.results = saved.results ?? [];
44
+ for (const commandId of saved.running ?? [])
45
+ this.results.push({
46
+ commandId,
47
+ state: 'failed',
48
+ result: { error: 'the agent restarted while the job was running' },
49
+ });
50
+ if (saved.running?.length)
51
+ await this.save();
52
+ }
53
+ catch {
54
+ /* no saved state */
55
+ }
56
+ })();
57
+ return this.ready;
58
+ }
59
+ /** Starts cleanup of a previous process's leftovers; safe to call more than once. */
60
+ warm() {
61
+ return this.init();
62
+ }
63
+ /** Queues a job; the command is acknowledged immediately and its result arrives later. */
64
+ async start(command) {
65
+ await this.init();
66
+ let payload;
67
+ try {
68
+ payload = JSON.parse(command.payload_json ?? '{}');
69
+ if (!payload.runId || !payload.repository || !payload.resticImage)
70
+ throw new Error('the job payload is incomplete');
71
+ }
72
+ catch (error) {
73
+ this.results.push({
74
+ commandId: command.id,
75
+ state: 'failed',
76
+ result: { error: errMessage(error) },
77
+ });
78
+ await this.save();
79
+ return;
80
+ }
81
+ this.queue.push({ command, payload });
82
+ await this.save();
83
+ this.pump();
84
+ }
85
+ pump() {
86
+ const limit = this.host.concurrency ?? 1;
87
+ while (this.active.size < limit && this.queue.length) {
88
+ const job = this.queue.shift();
89
+ void this.execute(job.command, job.payload);
90
+ }
91
+ }
92
+ async execute(command, payload) {
93
+ const controller = new AbortController();
94
+ const status = {
95
+ commandId: command.id,
96
+ runId: payload.runId,
97
+ phase: 'pulling',
98
+ startedAt: now(),
99
+ controller,
100
+ };
101
+ this.active.set(command.id, status);
102
+ const timeout = setTimeout(() => controller.abort(new Error(`the job exceeded its ${payload.limits.timeoutSec}s limit`)), payload.limits.timeoutSec * 1000);
103
+ timeout.unref();
104
+ let credentials;
105
+ try {
106
+ credentials = await this.host.fetchCredentials(command.id);
107
+ const result = await runBackupJob(payload, command.id, {
108
+ docker: this.host.docker,
109
+ credentials,
110
+ signal: controller.signal,
111
+ onPhase: (phase, bytes) => {
112
+ status.phase = phase;
113
+ if (bytes !== undefined)
114
+ status.bytes = bytes;
115
+ },
116
+ }, command.type === 'backup.check'
117
+ ? 'backup.check'
118
+ : command.type === 'backup.restore'
119
+ ? 'backup.restore'
120
+ : 'backup.run');
121
+ this.results.push({ commandId: command.id, state: 'succeeded', result });
122
+ }
123
+ catch (error) {
124
+ const values = credentials ? Object.values(credentials.env) : [];
125
+ this.results.push({
126
+ commandId: command.id,
127
+ state: 'failed',
128
+ result: { error: redactSecrets(errMessage(controller.signal.reason ?? error), values) },
129
+ });
130
+ }
131
+ finally {
132
+ clearTimeout(timeout);
133
+ this.active.delete(command.id);
134
+ await this.save().catch(() => { });
135
+ this.settled?.();
136
+ this.pump();
137
+ }
138
+ }
139
+ /** Jobs that are running or waiting, for the status report's heartbeat. */
140
+ statuses() {
141
+ return [
142
+ ...[...this.active.values()].map(({ controller: _controller, ...status }) => status),
143
+ ...this.queue.map(({ command, payload }) => ({
144
+ commandId: command.id,
145
+ runId: payload.runId,
146
+ phase: 'queued',
147
+ startedAt: now(),
148
+ })),
149
+ ];
150
+ }
151
+ /** Finished results to put in the next status report. They are removed only after `acknowledge`. */
152
+ pending() {
153
+ return [...this.results];
154
+ }
155
+ /** Forgets results control has stored. */
156
+ async acknowledge(delivered) {
157
+ const ids = new Set(delivered.map((result) => result.commandId));
158
+ this.results = this.results.filter((result) => !ids.has(result.commandId));
159
+ await this.save().catch(() => { });
160
+ }
161
+ }
package/dist/agent.d.ts CHANGED
@@ -85,7 +85,7 @@ export declare function pruneManagedImages(ctx: AgentContext, retained: string[]
85
85
  export declare function resolveBindAddress(address: string | null, lookup?: (host: string) => Promise<{
86
86
  address: string;
87
87
  }>): Promise<string>;
88
- export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>, ports?: PortBinding): Promise<ProjectStatus>;
88
+ export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>, ports?: PortBinding, held?: string[]): Promise<ProjectStatus>;
89
89
  /** Reports this node's WireGuard public key when control has not seen the current one yet. */
90
90
  export declare function reportWgKey(ctx: AgentContext): Promise<void>;
91
91
  export declare function pollOnce(ctx?: AgentContext): Promise<ProjectStatus[]>;
package/dist/agent.js CHANGED
@@ -12,8 +12,9 @@ import dns from 'node:dns/promises';
12
12
  import { WebSocket } from 'ws';
13
13
  import { createHash } from 'node:crypto';
14
14
  import { addressBoundServices, isRolling, serviceSecretVersions } from "./compose.js";
15
- import { containerSpecs, networkName, volumeName } from "./container-spec.js";
15
+ import { LABEL_PROJECT, LABEL_SERVICE, containerSpecs, networkName, volumeName, } from "./container-spec.js";
16
16
  import { API_VERSION, DockerEngine, isNotFound } from "./docker-api.js";
17
+ import { JobRunner } from "./agent-jobs.js";
17
18
  import { LIVE_MAX_FRAME_BYTES, LIVE_START_TIMEOUT_MS } from "./protocol.js";
18
19
  import { LiveOperationEngine } from "./live.js";
19
20
  import { activeRollingStates, clearRolloutState, ensureProjectResources, loadRolloutState, projectContainers, reconcileRecreate, reconcileRolling, sweepOrphans, } from "./reconciler.js";
@@ -46,6 +47,23 @@ export function defaultContext() {
46
47
  rolloutPorts: process.env.TUG_ROLLOUT_PORTS,
47
48
  };
48
49
  }
50
+ const jobRunners = new Map();
51
+ /** One job runner per state directory, so a background backup outlives the polls that started it. */
52
+ function jobRunnerFor(ctx) {
53
+ let runner = jobRunners.get(ctx.stateDir);
54
+ if (!runner) {
55
+ runner = new JobRunner({
56
+ docker: ctx.docker,
57
+ stateDir: ctx.stateDir,
58
+ concurrency: Number(process.env.TUG_BACKUP_CONCURRENCY) || 1,
59
+ fetchCredentials: (commandId) => api(ctx, `/agent/v1/backup/credentials?command=${encodeURIComponent(commandId)}`),
60
+ });
61
+ jobRunners.set(ctx.stateDir, runner);
62
+ // Clear helper containers a previous agent process left behind, before any live session can start one.
63
+ void runner.warm();
64
+ }
65
+ return runner;
66
+ }
49
67
  const dbsByStateDir = new Map();
50
68
  async function agentDb(stateDir) {
51
69
  let db = dbsByStateDir.get(stateDir);
@@ -426,6 +444,7 @@ export class AgentLiveOperations {
426
444
  container,
427
445
  command: request.command,
428
446
  interactive: request.interactive,
447
+ oneOff: request.oneOff,
429
448
  tty: request.tty,
430
449
  columns: request.columns,
431
450
  rows: request.rows,
@@ -802,7 +821,15 @@ export async function resolveBindAddress(address, lookup = (host) => dns.lookup(
802
821
  }
803
822
  // Last public-port warning per project, so the event is sent when it changes rather than on every poll.
804
823
  const publicPortWarnings = new Map();
805
- export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}, ports = { address: null, loopback: {} }) {
824
+ /** Stops every running container of a service, including the revisions of a rolling service. */
825
+ async function stopService(docker, project, service) {
826
+ const running = await docker.listContainers({
827
+ label: [`${LABEL_PROJECT}=${project}`, `${LABEL_SERVICE}=${service}`],
828
+ });
829
+ for (const container of running)
830
+ await docker.stopContainer(container.Id);
831
+ }
832
+ export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}, ports = { address: null, loopback: {} }, held = []) {
806
833
  const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
807
834
  await ensureDir(dir);
808
835
  const serviceNames = Object.keys(containerSpecs(desiredState, ctx.nodeName).specs);
@@ -839,9 +866,13 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
839
866
  const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
840
867
  const db = await agentDb(ctx.stateDir);
841
868
  const previousRow = db.get('SELECT version, revision FROM applied_state WHERE project = ?', desiredState.project);
842
- const rolling = serviceNames.filter((service) => isRolling(desiredState.compose.services[service]));
869
+ // A held service is stopped and left alone (a restore is rewriting its data) until control drops the hold.
870
+ const heldSet = new Set(held.filter((service) => serviceNames.includes(service)));
871
+ for (const service of heldSet)
872
+ serviceStates[service].rolloutState = 'held';
873
+ const rolling = serviceNames.filter((service) => !heldSet.has(service) && isRolling(desiredState.compose.services[service]));
843
874
  const rollingSet = new Set(rolling);
844
- const mainServices = serviceNames.filter((service) => !rollingSet.has(service));
875
+ const mainServices = serviceNames.filter((service) => !rollingSet.has(service) && !heldSet.has(service));
845
876
  // Published images are pulled only for a new desired revision. Pulling on every poll would let a tag that moved
846
877
  // upstream (`mariadb:11`) recreate the container without a deploy.
847
878
  const sameDesiredVersion = previousRow
@@ -903,6 +934,8 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
903
934
  try {
904
935
  await ensureProjectResources(ctx.docker, desiredState.project, plan);
905
936
  await sweepOrphans(ctx.docker, desiredState.project, new Set(serviceNames));
937
+ for (const service of heldSet)
938
+ await stopService(ctx.docker, desiredState.project, service);
906
939
  if (mainServices.length)
907
940
  await reconcileRecreate(ctx.docker, {
908
941
  desired: desiredState,
@@ -1051,7 +1084,7 @@ export async function pollOnce(ctx = defaultContext()) {
1051
1084
  const projects = [];
1052
1085
  for (const desired of work.desiredStates || (work.desiredState ? [work.desiredState] : [])) {
1053
1086
  try {
1054
- projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project], work.ports));
1087
+ projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project], work.ports, work.holds?.[desired.project]));
1055
1088
  }
1056
1089
  catch (e) {
1057
1090
  projects.push({
@@ -1072,7 +1105,13 @@ export async function pollOnce(ctx = defaultContext()) {
1072
1105
  await removeIngress(ingressCtx).catch(() => false);
1073
1106
  }
1074
1107
  const commandResults = [];
1108
+ const jobs = jobRunnerFor(ctx);
1075
1109
  for (const command of work.commands || []) {
1110
+ // Backups can run for an hour, so they run in the background and report through later polls.
1111
+ if (command.type.startsWith('backup.')) {
1112
+ await jobs.start(command);
1113
+ continue;
1114
+ }
1076
1115
  try {
1077
1116
  commandResults.push(await executeCommand(command, ctx));
1078
1117
  }
@@ -1084,7 +1123,12 @@ export async function pollOnce(ctx = defaultContext()) {
1084
1123
  });
1085
1124
  }
1086
1125
  }
1126
+ const jobResults = jobs.pending();
1127
+ commandResults.push(...jobResults);
1087
1128
  const report = { node: ctx.nodeName, projects, commandResults };
1129
+ const running = jobs.statuses();
1130
+ if (running.length)
1131
+ report.jobs = running;
1088
1132
  if (ingress)
1089
1133
  report.ingress = ingress;
1090
1134
  if (work.retainedImages && projects.every((project) => project.health !== 'failed'))
@@ -1094,6 +1138,7 @@ export async function pollOnce(ctx = defaultContext()) {
1094
1138
  headers: { 'content-type': 'application/json' },
1095
1139
  body: JSON.stringify(report),
1096
1140
  });
1141
+ await jobs.acknowledge(jobResults);
1097
1142
  return projects;
1098
1143
  }
1099
1144
  const CREDS_FILE = () => path.join(STATE_DIR, 'agent.json');
@@ -0,0 +1,47 @@
1
+ import type { BackupRetention, BackupType, ComposeFile } from './protocol.ts';
2
+ /** One backup target after defaults and placement are resolved. */
3
+ export interface ResolvedBackup {
4
+ /** `service:db` or `volume:uploads`, unique within a project. */
5
+ target: string;
6
+ kind: 'service' | 'volume';
7
+ name: string;
8
+ /** `command` for a service, `volume` for a volume. */
9
+ type: BackupType;
10
+ schedule: string;
11
+ retention: BackupRetention;
12
+ jitterSeconds: number;
13
+ timeoutSec: number;
14
+ /** Service targets: the command run in the service's container, and the snapshot file it is stored as. */
15
+ command?: string[];
16
+ /** Run in the service container to restore; the stored file arrives on its standard input. */
17
+ restore?: string[];
18
+ filename?: string;
19
+ verify?: string;
20
+ /** Volume targets. */
21
+ exclude?: string[];
22
+ /** Services held during a restore; unset means "discover from the Compose file". */
23
+ writers?: string[];
24
+ /** The one node the backup runs on, when it is known from literal placement. Label placement resolves at deploy time. */
25
+ node?: string;
26
+ }
27
+ export declare const DEFAULT_RETENTION: BackupRetention;
28
+ export declare const DEFAULT_BACKUP_TIMEOUT_SEC: number;
29
+ /** `30m`, `2h`, `90s` or a number of seconds. */
30
+ export declare function parseBackupTimeout(value: unknown): number | null;
31
+ /** Deterministic delay in `[0, 900)` seconds so many sites on one node do not start together. */
32
+ export declare function defaultJitterSeconds(project: string, target: string): number;
33
+ /** Validates the project-level defaults in `x-tug.backup`. */
34
+ export declare function validateBackupDefaults(value: unknown, errors: string[]): void;
35
+ /** The argv of a `command`: a list is used as is, a string runs through `sh -c` like a shell one-liner. */
36
+ export declare function commandArgv(command: unknown): string[] | null;
37
+ interface Resolved {
38
+ targets: ResolvedBackup[];
39
+ errors: string[];
40
+ }
41
+ /**
42
+ * Resolves and validates every `x-tug.backup` in a Compose file. Tug knows nothing about what a service
43
+ * is: a service backup is a command whose output is stored, a volume backup is its files. `project`
44
+ * seeds the default jitter. Errors name the key, so they can be shown to the operator as they are.
45
+ */
46
+ export declare function resolveBackups(compose: ComposeFile, project?: string): Resolved;
47
+ export {};
@@ -0,0 +1,256 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { parseCron } from "./cron.js";
3
+ export const DEFAULT_RETENTION = { daily: 7, weekly: 4, monthly: 6 };
4
+ export const DEFAULT_BACKUP_TIMEOUT_SEC = 2 * 3600;
5
+ const MIN_TIMEOUT_SEC = 60;
6
+ const MAX_TIMEOUT_SEC = 24 * 3600;
7
+ const MAX_JITTER_SEC = 3600;
8
+ const RETENTION_KEYS = ['last', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
9
+ const COMMON_KEYS = ['schedule', 'retention', 'jitter', 'timeout', 'writers'];
10
+ const SERVICE_KEYS = [...COMMON_KEYS, 'command', 'restore', 'filename', 'verify'];
11
+ const VOLUME_KEYS = [...COMMON_KEYS, 'exclude'];
12
+ const FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
13
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ /** `30m`, `2h`, `90s` or a number of seconds. */
15
+ export function parseBackupTimeout(value) {
16
+ if (typeof value === 'number')
17
+ return Number.isInteger(value) ? value : null;
18
+ const match = typeof value === 'string' ? value.match(/^(\d+)(s|m|h)$/) : null;
19
+ if (!match)
20
+ return null;
21
+ return Number(match[1]) * { s: 1, m: 60, h: 3600 }[match[2]];
22
+ }
23
+ /** Deterministic delay in `[0, 900)` seconds so many sites on one node do not start together. */
24
+ export function defaultJitterSeconds(project, target) {
25
+ const digest = createHash('sha256').update(`${project}\0${target}`).digest();
26
+ return digest.readUInt32BE(0) % 900;
27
+ }
28
+ function validateRetention(label, value, errors) {
29
+ if (!isObject(value)) {
30
+ errors.push(`${label}.retention must be an object such as { daily: 7, weekly: 4 }`);
31
+ return undefined;
32
+ }
33
+ const retention = {};
34
+ for (const [key, count] of Object.entries(value)) {
35
+ if (!RETENTION_KEYS.includes(key))
36
+ errors.push(`${label}.retention.${key} is not supported (supported: ${RETENTION_KEYS.join(', ')})`);
37
+ else if (!Number.isInteger(count) || count < 1)
38
+ errors.push(`${label}.retention.${key} must be a whole number of at least 1`);
39
+ else
40
+ retention[key] = count;
41
+ }
42
+ if (!Object.keys(value).length)
43
+ errors.push(`${label}.retention is empty, which would keep no snapshots; set at least one of ${RETENTION_KEYS.join(', ')}`);
44
+ return retention;
45
+ }
46
+ function validateSchedule(label, value, errors) {
47
+ if (typeof value !== 'string') {
48
+ errors.push(`${label}.schedule must be a cron string such as "0 3 * * *" or @daily`);
49
+ return undefined;
50
+ }
51
+ try {
52
+ parseCron(value);
53
+ }
54
+ catch (error) {
55
+ errors.push(`${label}.schedule: ${error instanceof Error ? error.message : String(error)}`);
56
+ return undefined;
57
+ }
58
+ return value.trim();
59
+ }
60
+ /** Validates the project-level defaults in `x-tug.backup`. */
61
+ export function validateBackupDefaults(value, errors) {
62
+ if (value === undefined)
63
+ return;
64
+ if (!isObject(value)) {
65
+ errors.push('x-tug.backup must be an object');
66
+ return;
67
+ }
68
+ for (const key of Object.keys(value))
69
+ if (key !== 'schedule' && key !== 'retention')
70
+ errors.push(`x-tug.backup.${key} is not supported (supported: schedule, retention)`);
71
+ if (value.schedule !== undefined)
72
+ validateSchedule('x-tug.backup', value.schedule, errors);
73
+ if (value.retention !== undefined)
74
+ validateRetention('x-tug.backup', value.retention, errors);
75
+ }
76
+ function normalizeConfig(config) {
77
+ if (config === false)
78
+ return null;
79
+ if (config === true)
80
+ return {};
81
+ if (typeof config === 'string')
82
+ return { schedule: config };
83
+ return config;
84
+ }
85
+ /** The argv of a `command`: a list is used as is, a string runs through `sh -c` like a shell one-liner. */
86
+ export function commandArgv(command) {
87
+ if (typeof command === 'string')
88
+ return command.trim() ? ['sh', '-c', command] : null;
89
+ return Array.isArray(command) &&
90
+ command.length &&
91
+ command.every((part) => typeof part === 'string' && part)
92
+ ? command
93
+ : null;
94
+ }
95
+ function serviceNodes(svc) {
96
+ const lp = svc['x-tug'] || {};
97
+ const placement = lp.placement || {};
98
+ return {
99
+ nodes: placement.nodes || [],
100
+ dynamic: Array.isArray(placement.labels) && placement.labels.length > 0,
101
+ global: lp.global === true || placement.global === true,
102
+ };
103
+ }
104
+ function mountedVolumes(svc) {
105
+ return (svc.volumes || []).flatMap((mount) => typeof mount === 'object' && mount && mount.type === 'volume' && mount.source
106
+ ? [String(mount.source)]
107
+ : []);
108
+ }
109
+ /**
110
+ * Resolves and validates every `x-tug.backup` in a Compose file. Tug knows nothing about what a service
111
+ * is: a service backup is a command whose output is stored, a volume backup is its files. `project`
112
+ * seeds the default jitter. Errors name the key, so they can be shown to the operator as they are.
113
+ */
114
+ export function resolveBackups(compose, project = '') {
115
+ const errors = [];
116
+ const targets = [];
117
+ const defaults = compose['x-tug']?.backup;
118
+ const defaultSchedule = isObject(defaults) ? defaults.schedule : undefined;
119
+ const defaultRetention = (isObject(defaults) && isObject(defaults.retention) ? defaults.retention : DEFAULT_RETENTION);
120
+ const common = (label, kind, name, spec) => {
121
+ const allowed = kind === 'service' ? SERVICE_KEYS : VOLUME_KEYS;
122
+ for (const key of Object.keys(spec))
123
+ if (!allowed.includes(key))
124
+ errors.push(`${label}.${key} is not supported${key === 'command' || key === 'restore' || key === 'filename' || key === 'verify'
125
+ ? ' on a volume; a volume backup archives its files'
126
+ : key === 'exclude'
127
+ ? ' on a service; it applies to volumes'
128
+ : ''} (supported: ${allowed.join(', ')})`);
129
+ const before = errors.length;
130
+ const schedule = spec.schedule !== undefined
131
+ ? validateSchedule(label, spec.schedule, errors)
132
+ : typeof defaultSchedule === 'string'
133
+ ? defaultSchedule.trim()
134
+ : undefined;
135
+ if (spec.schedule === undefined && schedule === undefined)
136
+ errors.push(`${label} needs a schedule: set ${label}.schedule or x-tug.backup.schedule`);
137
+ const retention = spec.retention !== undefined
138
+ ? validateRetention(label, spec.retention, errors)
139
+ : defaultRetention;
140
+ let jitterSeconds = defaultJitterSeconds(project, `${kind}:${name}`);
141
+ if (spec.jitter !== undefined) {
142
+ if (!Number.isInteger(spec.jitter) || spec.jitter < 0 || spec.jitter > MAX_JITTER_SEC)
143
+ errors.push(`${label}.jitter must be a whole number of seconds from 0 to ${MAX_JITTER_SEC}`);
144
+ else
145
+ jitterSeconds = spec.jitter;
146
+ }
147
+ let timeoutSec = DEFAULT_BACKUP_TIMEOUT_SEC;
148
+ if (spec.timeout !== undefined) {
149
+ const parsed = parseBackupTimeout(spec.timeout);
150
+ if (parsed === null || parsed < MIN_TIMEOUT_SEC || parsed > MAX_TIMEOUT_SEC)
151
+ errors.push(`${label}.timeout must be a duration from 1m to 24h such as 30m or 2h`);
152
+ else
153
+ timeoutSec = parsed;
154
+ }
155
+ if (spec.writers !== undefined) {
156
+ if (!Array.isArray(spec.writers) || spec.writers.some((w) => typeof w !== 'string'))
157
+ errors.push(`${label}.writers must be a list of service names`);
158
+ else
159
+ for (const writer of spec.writers)
160
+ if (!compose.services[writer])
161
+ errors.push(`${label}.writers: ${writer} is not a service`);
162
+ }
163
+ if (errors.length > before || !schedule || !retention)
164
+ return null;
165
+ return { schedule, retention, jitterSeconds, timeoutSec };
166
+ };
167
+ for (const [name, svc] of Object.entries(compose.services)) {
168
+ const config = svc['x-tug']?.backup;
169
+ if (config === undefined)
170
+ continue;
171
+ const label = `${name}: x-tug.backup`;
172
+ const spec = normalizeConfig(config);
173
+ if (spec === null)
174
+ continue;
175
+ if (typeof spec !== 'object' || Array.isArray(spec)) {
176
+ errors.push(`${label} must be true, false, a cron string or an object`);
177
+ continue;
178
+ }
179
+ const before = errors.length;
180
+ const base = common(label, 'service', name, spec);
181
+ const argv = spec.command === undefined ? null : commandArgv(spec.command);
182
+ if (spec.command === undefined)
183
+ errors.push(`${label}.command is required: the command to run in the ${name} container, whose output is stored (see the recipes in docs/backups.md)`);
184
+ else if (!argv)
185
+ errors.push(`${label}.command must be a non-empty string or list of strings`);
186
+ const restoreArgv = spec.restore === undefined ? undefined : commandArgv(spec.restore);
187
+ if (spec.restore !== undefined && !restoreArgv)
188
+ errors.push(`${label}.restore must be a non-empty string or list of strings`);
189
+ if (spec.filename !== undefined &&
190
+ (typeof spec.filename !== 'string' || !FILENAME_RE.test(spec.filename)))
191
+ errors.push(`${label}.filename must be a plain file name such as ${name}.sql`);
192
+ if (spec.verify !== undefined && (typeof spec.verify !== 'string' || !spec.verify))
193
+ errors.push(`${label}.verify must be text that appears at the end of the output`);
194
+ const { nodes, dynamic, global } = serviceNodes(svc);
195
+ const replicas = svc['x-tug']?.replicas ?? 1;
196
+ if (global || replicas > 1 || nodes.length > 1)
197
+ errors.push(`${label} needs a single instance; ${name} is global, replicated or placed on several nodes`);
198
+ if (errors.length > before || !base || !argv)
199
+ continue;
200
+ targets.push({
201
+ target: `service:${name}`,
202
+ kind: 'service',
203
+ name,
204
+ type: 'command',
205
+ ...base,
206
+ command: argv,
207
+ restore: restoreArgv ?? undefined,
208
+ filename: spec.filename ?? `${name}.out`,
209
+ verify: spec.verify,
210
+ writers: spec.writers,
211
+ node: !dynamic && nodes.length === 1 ? nodes[0] : undefined,
212
+ });
213
+ }
214
+ for (const [name, def] of Object.entries(compose.volumes || {})) {
215
+ const config = isObject(def) ? def['x-tug']?.backup : undefined;
216
+ if (config === undefined)
217
+ continue;
218
+ const label = `volumes.${name}: x-tug.backup`;
219
+ const spec = normalizeConfig(config);
220
+ if (spec === null)
221
+ continue;
222
+ if (typeof spec !== 'object' || Array.isArray(spec)) {
223
+ errors.push(`${label} must be true, false, a cron string or an object`);
224
+ continue;
225
+ }
226
+ const before = errors.length;
227
+ const base = common(label, 'volume', name, spec);
228
+ if (spec.exclude !== undefined &&
229
+ (!Array.isArray(spec.exclude) ||
230
+ spec.exclude.some((pattern) => typeof pattern !== 'string' || !pattern)))
231
+ errors.push(`${label}.exclude must be a list of restic exclude patterns`);
232
+ const volumeExt = isObject(def) ? def['x-tug'] : undefined;
233
+ const placed = volumeExt?.placement?.nodes || [];
234
+ const mounting = Object.entries(compose.services).filter(([, svc]) => mountedVolumes(svc).includes(name));
235
+ const mountingNodes = [...new Set(mounting.flatMap(([, svc]) => serviceNodes(svc).nodes))];
236
+ const dynamicPlacement = mounting.some(([, svc]) => serviceNodes(svc).dynamic || serviceNodes(svc).global);
237
+ const candidates = placed.length ? placed : mountingNodes;
238
+ if (candidates.length > 1)
239
+ errors.push(`${label} needs one node: volume ${name} is ${placed.length ? 'placed' : 'used'} on ${candidates.join(', ')}; pin it with x-tug.placement.nodes or back up per node`);
240
+ else if (!placed.length && dynamicPlacement)
241
+ errors.push(`${label} needs x-tug.placement.nodes on volume ${name}, since its services are placed by label or global`);
242
+ if (errors.length > before || !base)
243
+ continue;
244
+ targets.push({
245
+ target: `volume:${name}`,
246
+ kind: 'volume',
247
+ name,
248
+ type: 'volume',
249
+ ...base,
250
+ exclude: spec.exclude,
251
+ writers: spec.writers,
252
+ node: candidates.length === 1 ? candidates[0] : undefined,
253
+ });
254
+ }
255
+ return { targets, errors };
256
+ }