@wpelevator/tug 0.5.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.
Files changed (43) hide show
  1. package/README.md +3 -3
  2. package/dist/agent-jobs.d.ts +41 -0
  3. package/dist/agent-jobs.js +161 -0
  4. package/dist/agent.d.ts +8 -1
  5. package/dist/agent.js +76 -7
  6. package/dist/backup-check.d.ts +11 -0
  7. package/dist/backup-check.js +10 -0
  8. package/dist/backup-config.d.ts +52 -0
  9. package/dist/backup-config.js +276 -0
  10. package/dist/backup-destination.d.ts +22 -0
  11. package/dist/backup-destination.js +72 -0
  12. package/dist/backup-job.d.ts +32 -0
  13. package/dist/backup-job.js +352 -0
  14. package/dist/backup-restore.d.ts +67 -0
  15. package/dist/backup-restore.js +317 -0
  16. package/dist/backup-scheduler.d.ts +60 -0
  17. package/dist/backup-scheduler.js +418 -0
  18. package/dist/cli-build.d.ts +1 -1
  19. package/dist/cli-build.js +15 -2
  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 +226 -16
  27. package/dist/compose.js +5 -2
  28. package/dist/container-spec.d.ts +3 -2
  29. package/dist/container-spec.js +9 -34
  30. package/dist/control.js +253 -19
  31. package/dist/cron.d.ts +17 -0
  32. package/dist/cron.js +124 -0
  33. package/dist/db.d.ts +70 -0
  34. package/dist/db.js +7 -0
  35. package/dist/docker-api.d.ts +7 -0
  36. package/dist/live.d.ts +8 -0
  37. package/dist/live.js +84 -0
  38. package/dist/normalize.d.ts +6 -0
  39. package/dist/normalize.js +15 -1
  40. package/dist/protocol.d.ts +118 -7
  41. package/dist/protocol.js +7 -0
  42. package/dist/tokens.js +10 -0
  43. package/package.json +2 -1
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): 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.
@@ -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
@@ -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 {
@@ -85,7 +92,7 @@ export declare function pruneManagedImages(ctx: AgentContext, retained: string[]
85
92
  export declare function resolveBindAddress(address: string | null, lookup?: (host: string) => Promise<{
86
93
  address: string;
87
94
  }>): Promise<string>;
88
- export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>, ports?: PortBinding): Promise<ProjectStatus>;
95
+ export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>, ports?: PortBinding, held?: string[]): Promise<ProjectStatus>;
89
96
  /** Reports this node's WireGuard public key when control has not seen the current one yet. */
90
97
  export declare function reportWgKey(ctx: AgentContext): Promise<void>;
91
98
  export declare function pollOnce(ctx?: AgentContext): Promise<ProjectStatus[]>;
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";
@@ -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,
@@ -466,6 +485,30 @@ function tcpCheck(host, port, timeoutMs = 2000) {
466
485
  s.on('error', reject);
467
486
  });
468
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
+ }
469
512
  export async function waitForHealth(ctx, project, container, service, health) {
470
513
  if (!ctx.apply || !health)
471
514
  return;
@@ -499,7 +542,7 @@ export async function waitForHealth(ctx, project, container, service, health) {
499
542
  await new Promise((r) => setTimeout(r, intervalMs));
500
543
  }
501
544
  }
502
- 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)}`);
503
546
  }
504
547
  /** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
505
548
  function describeError(error) {
@@ -802,7 +845,15 @@ export async function resolveBindAddress(address, lookup = (host) => dns.lookup(
802
845
  }
803
846
  // Last public-port warning per project, so the event is sent when it changes rather than on every poll.
804
847
  const publicPortWarnings = new Map();
805
- export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}, ports = { address: null, loopback: {} }) {
848
+ /** Stops every running container of a service, including the revisions of a rolling service. */
849
+ async function stopService(docker, project, service) {
850
+ const running = await docker.listContainers({
851
+ label: [`${LABEL_PROJECT}=${project}`, `${LABEL_SERVICE}=${service}`],
852
+ });
853
+ for (const container of running)
854
+ await docker.stopContainer(container.Id);
855
+ }
856
+ export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}, ports = { address: null, loopback: {} }, held = []) {
806
857
  const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
807
858
  await ensureDir(dir);
808
859
  const serviceNames = Object.keys(containerSpecs(desiredState, ctx.nodeName).specs);
@@ -839,9 +890,13 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
839
890
  const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
840
891
  const db = await agentDb(ctx.stateDir);
841
892
  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]));
893
+ // A held service is stopped and left alone (a restore is rewriting its data) until control drops the hold.
894
+ const heldSet = new Set(held.filter((service) => serviceNames.includes(service)));
895
+ for (const service of heldSet)
896
+ serviceStates[service].rolloutState = 'held';
897
+ const rolling = serviceNames.filter((service) => !heldSet.has(service) && isRolling(desiredState.compose.services[service]));
843
898
  const rollingSet = new Set(rolling);
844
- const mainServices = serviceNames.filter((service) => !rollingSet.has(service));
899
+ const mainServices = serviceNames.filter((service) => !rollingSet.has(service) && !heldSet.has(service));
845
900
  // Published images are pulled only for a new desired revision. Pulling on every poll would let a tag that moved
846
901
  // upstream (`mariadb:11`) recreate the container without a deploy.
847
902
  const sameDesiredVersion = previousRow
@@ -903,6 +958,8 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
903
958
  try {
904
959
  await ensureProjectResources(ctx.docker, desiredState.project, plan);
905
960
  await sweepOrphans(ctx.docker, desiredState.project, new Set(serviceNames));
961
+ for (const service of heldSet)
962
+ await stopService(ctx.docker, desiredState.project, service);
906
963
  if (mainServices.length)
907
964
  await reconcileRecreate(ctx.docker, {
908
965
  desired: desiredState,
@@ -1051,7 +1108,7 @@ export async function pollOnce(ctx = defaultContext()) {
1051
1108
  const projects = [];
1052
1109
  for (const desired of work.desiredStates || (work.desiredState ? [work.desiredState] : [])) {
1053
1110
  try {
1054
- projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project], work.ports));
1111
+ projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project], work.ports, work.holds?.[desired.project]));
1055
1112
  }
1056
1113
  catch (e) {
1057
1114
  projects.push({
@@ -1072,7 +1129,13 @@ export async function pollOnce(ctx = defaultContext()) {
1072
1129
  await removeIngress(ingressCtx).catch(() => false);
1073
1130
  }
1074
1131
  const commandResults = [];
1132
+ const jobs = jobRunnerFor(ctx);
1075
1133
  for (const command of work.commands || []) {
1134
+ // Backups can run for an hour, so they run in the background and report through later polls.
1135
+ if (command.type.startsWith('backup.')) {
1136
+ await jobs.start(command);
1137
+ continue;
1138
+ }
1076
1139
  try {
1077
1140
  commandResults.push(await executeCommand(command, ctx));
1078
1141
  }
@@ -1084,7 +1147,12 @@ export async function pollOnce(ctx = defaultContext()) {
1084
1147
  });
1085
1148
  }
1086
1149
  }
1150
+ const jobResults = jobs.pending();
1151
+ commandResults.push(...jobResults);
1087
1152
  const report = { node: ctx.nodeName, projects, commandResults };
1153
+ const running = jobs.statuses();
1154
+ if (running.length)
1155
+ report.jobs = running;
1088
1156
  if (ingress)
1089
1157
  report.ingress = ingress;
1090
1158
  if (work.retainedImages && projects.every((project) => project.health !== 'failed'))
@@ -1094,6 +1162,7 @@ export async function pollOnce(ctx = defaultContext()) {
1094
1162
  headers: { 'content-type': 'application/json' },
1095
1163
  body: JSON.stringify(report),
1096
1164
  });
1165
+ await jobs.acknowledge(jobResults);
1097
1166
  return projects;
1098
1167
  }
1099
1168
  const CREDS_FILE = () => path.join(STATE_DIR, 'agent.json');
@@ -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
+ }
@@ -0,0 +1,52 @@
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
+ /** Memory limit of the restic helper container. */
15
+ memory: string;
16
+ /** Service targets: the command run in the service's container, and the snapshot file it is stored as. */
17
+ command?: string[];
18
+ /** Run in the service container to restore; the stored file arrives on its standard input. */
19
+ restore?: string[];
20
+ filename?: string;
21
+ verify?: string;
22
+ /** Volume targets. */
23
+ exclude?: string[];
24
+ /** Services held during a restore; unset means "discover from the Compose file". */
25
+ writers?: string[];
26
+ /** The one node the backup runs on, when it is known from literal placement. Label placement resolves at deploy time. */
27
+ node?: string;
28
+ }
29
+ export declare const DEFAULT_RETENTION: BackupRetention;
30
+ export declare const DEFAULT_BACKUP_TIMEOUT_SEC: number;
31
+ export declare const DEFAULT_BACKUP_MEMORY = "512m";
32
+ /** `30m`, `2h`, `90s` or a number of seconds. */
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;
36
+ /** Deterministic delay in `[0, 900)` seconds so many sites on one node do not start together. */
37
+ export declare function defaultJitterSeconds(project: string, target: string): number;
38
+ /** Validates the project-level defaults in `x-tug.backup`. */
39
+ export declare function validateBackupDefaults(value: unknown, errors: string[]): void;
40
+ /** The argv of a `command`: a list is used as is, a string runs through `sh -c` like a shell one-liner. */
41
+ export declare function commandArgv(command: unknown): string[] | null;
42
+ interface Resolved {
43
+ targets: ResolvedBackup[];
44
+ errors: string[];
45
+ }
46
+ /**
47
+ * Resolves and validates every `x-tug.backup` in a Compose file. Tug knows nothing about what a service
48
+ * is: a service backup is a command whose output is stored, a volume backup is its files. `project`
49
+ * seeds the default jitter. Errors name the key, so they can be shown to the operator as they are.
50
+ */
51
+ export declare function resolveBackups(compose: ComposeFile, project?: string): Resolved;
52
+ export {};