@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
package/README.md CHANGED
@@ -6,15 +6,15 @@ Deploy Docker Compose projects to one or more ordinary Linux VPSes through a sma
6
6
 
7
7
  ## Features
8
8
 
9
- - Control API with bearer-token auth, scoped `read`/`deploy`/`admin` API tokens for CI and teammates, an audit trail naming who acted, and SQLite metadata.
10
- - Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` (`node-enroll`), with per-node agent and registry tokens. Agents only dial out, so nodes behind NAT work.
11
- - Agents pull desired state from control, apply a generated per-node Compose file, run TCP/HTTP/Docker health checks and report status. An outbound WebSocket wakes them as soon as control queues work and carries live `exec`, followed logs, `ps` and `inspect` sessions, with long-poll and periodic-poll fallbacks for ordinary work.
9
+ - Control API with bearer-token auth, scoped `read`/`deploy`/`admin` API tokens for CI and teammates, an audit trail naming who acted, and SQLite metadata through Node's built-in driver.
10
+ - Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` (`node-enroll`), with one agent token per node. Agents only dial out, so nodes behind NAT work.
11
+ - Agents pull desired state from control, reconcile containers through the Docker Engine API, run TCP/HTTP/Docker health checks and report status. An outbound WebSocket wakes them as soon as control queues work and carries live `exec`, followed logs, `ps` and `inspect` sessions, with long-poll and periodic-poll fallbacks for ordinary work.
12
12
  - `x-tug` placement of services and volumes on nodes, with immutable desired-state records per deployment.
13
- - Build application images on control from uploaded source, or locally with `deploy --build-local`. Agents download saved images directly from control by default; a bring-your-own registry is optional.
13
+ - Build application images on control from uploaded source, or locally with `deploy --build-local`. Agents download saved images directly from control. Published images are pinned to registry digests at deployment creation.
14
14
  - Secrets encrypted at rest, released only to the nodes whose services reference them, with targeted service recreation when only a secret changes.
15
15
  - Deployment convergence, per-project operation locks, cancellation, rollback, per-service rollout events and log fetching through the agents.
16
16
  - Opt-in zero-downtime rolling rollouts for ingress-fronted services (`x-tug.rollout`) and automatic rollback of failed deployments (`x-tug.rollback: auto`). See [Compose extensions](docs/compose.md#rolling-rollouts).
17
- - Per-node registry pull tokens enforced by an htpasswd-backed registry you provide, with rotation and revocation.
17
+ - Private published images use encrypted project credentials for digest resolution and Engine API pulls.
18
18
  - Optional WireGuard mesh between nodes, health-aware cross-node service names and a managed Caddy ingress with automatic HTTPS, each opt-in. See [Networking](docs/networking.md).
19
19
  - Private-by-default published ports: generated Compose binds them to the overlay IP, loopback or the node address, never every interface unless you write `0.0.0.0`. See [Networking](docs/networking.md#published-ports-private-by-default).
20
20
  - Control-plane backup and restore, and `tug doctor` diagnostics.
@@ -31,7 +31,6 @@ Deploy Docker Compose projects to one or more ordinary Linux VPSes through a sma
31
31
  | Control API (role-checked) |
32
32
  | SQLite: desired states, secrets |
33
33
  | image builds, saved images |
34
- | registry (optional) |
35
34
  +-------------------------------------+
36
35
  ^ ^
37
36
  | agents dial out: work, status,
@@ -62,25 +61,25 @@ npm install -g @wpelevator/tug
62
61
  The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts.
63
62
 
64
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).
65
- 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).
66
65
  3. Set the secrets your services need: `tug secret-set DB_PASSWORD`.
67
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.
68
67
  5. Deploy: `tug deploy -f docker-compose.yml`. It waits until every node reports the new version healthy.
69
- 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`.
70
69
 
71
70
  ## Documentation
72
71
 
73
72
  - [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
74
73
  - [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
75
- - [Control plane](docs/control-plane.md): install, TLS, registry credentials, backup and restore.
76
- - [Backups](docs/backups.md): interim WordPress backup sidecar with restic, per-node and NFS variants, and a restore drill until `x-tug.backup` (M11).
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 variants, and a restore drill until `x-tug.backup` (M11).
77
76
  - [Access control](docs/access-control.md): scoped API tokens, roles, audit trail and a GitHub Actions deploy example.
78
77
  - [Networking](docs/networking.md): WireGuard mesh, service DNS, ingress and TLS, firewall notes.
79
78
  - [Nodes](docs/nodes.md): SSH install, self-service enrollment, registration and status.
80
79
  - [CLI reference](docs/cli.md): every `tug` command and option.
81
80
  - [Configuration](docs/configuration.md): CLI, control and agent environment variables.
82
81
  - [Development](docs/development.md): local setup, CI, Compose stack, host mode and the integration test.
83
- - [Testing](docs/testing.md): test layers, the numbers rollouts are held to, and the backlog of mocked tests.
82
+ - [Testing](docs/testing.md): test layers, rollout metrics, mocked test backlog, and the [Caddy/PHP and static-file benchmark](docs/testing.md#caddy-proxy-overhead-benchmark).
84
83
  - [Observability](docs/observability.md): what can be observed today, and the proposed logs, metrics and alerting.
85
84
  - [Releases](docs/releases.md): changesets, npm and Docker image publishing.
86
85
  - [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution.
@@ -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[]>;
@@ -93,8 +93,6 @@ interface Creds {
93
93
  agentToken: string;
94
94
  nodeName?: string;
95
95
  controlUrl?: string;
96
- registry?: string | null;
97
- registryToken?: string | null;
98
96
  }
99
97
  export declare function loadOrEnroll(): Promise<Creds>;
100
98
  export declare function main(): Promise<void>;
package/dist/agent.js CHANGED
@@ -1,18 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
+ import { imageLocation } from "./registry.js";
3
4
  import { createReadStream, createWriteStream } from 'node:fs';
4
5
  import { Readable } from 'node:stream';
5
6
  import { pipeline } from 'node:stream/promises';
6
7
  import { validateImageArchive, verifyDownload } from "./artifacts.js";
7
8
  import path from 'node:path';
8
- import { spawn, spawnSync } from 'node:child_process';
9
+ import { spawnSync } from 'node:child_process';
9
10
  import net from 'node:net';
10
11
  import dns from 'node:dns/promises';
11
12
  import { WebSocket } from 'ws';
12
13
  import { createHash } from 'node:crypto';
13
14
  import { addressBoundServices, isRolling, serviceSecretVersions } from "./compose.js";
14
- import { containerSpecs, networkName, volumeName } from "./container-spec.js";
15
+ import { LABEL_PROJECT, LABEL_SERVICE, containerSpecs, networkName, volumeName, } from "./container-spec.js";
15
16
  import { API_VERSION, DockerEngine, isNotFound } from "./docker-api.js";
17
+ import { JobRunner } from "./agent-jobs.js";
16
18
  import { LIVE_MAX_FRAME_BYTES, LIVE_START_TIMEOUT_MS } from "./protocol.js";
17
19
  import { LiveOperationEngine } from "./live.js";
18
20
  import { activeRollingStates, clearRolloutState, ensureProjectResources, loadRolloutState, projectContainers, reconcileRecreate, reconcileRolling, sweepOrphans, } from "./reconciler.js";
@@ -45,6 +47,23 @@ export function defaultContext() {
45
47
  rolloutPorts: process.env.TUG_ROLLOUT_PORTS,
46
48
  };
47
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
+ }
48
67
  const dbsByStateDir = new Map();
49
68
  async function agentDb(stateDir) {
50
69
  let db = dbsByStateDir.get(stateDir);
@@ -425,6 +444,7 @@ export class AgentLiveOperations {
425
444
  container,
426
445
  command: request.command,
427
446
  interactive: request.interactive,
447
+ oneOff: request.oneOff,
428
448
  tty: request.tty,
429
449
  columns: request.columns,
430
450
  rows: request.rows,
@@ -801,7 +821,15 @@ export async function resolveBindAddress(address, lookup = (host) => dns.lookup(
801
821
  }
802
822
  // Last public-port warning per project, so the event is sent when it changes rather than on every poll.
803
823
  const publicPortWarnings = new Map();
804
- 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 = []) {
805
833
  const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
806
834
  await ensureDir(dir);
807
835
  const serviceNames = Object.keys(containerSpecs(desiredState, ctx.nodeName).specs);
@@ -837,10 +865,14 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
837
865
  const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
838
866
  const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
839
867
  const db = await agentDb(ctx.stateDir);
840
- const previousRow = await db.get('SELECT version, revision FROM applied_state WHERE project = ?', desiredState.project);
841
- const rolling = serviceNames.filter((service) => isRolling(desiredState.compose.services[service]));
868
+ const previousRow = db.get('SELECT version, revision FROM applied_state WHERE project = ?', desiredState.project);
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]));
842
874
  const rollingSet = new Set(rolling);
843
- const mainServices = serviceNames.filter((service) => !rollingSet.has(service));
875
+ const mainServices = serviceNames.filter((service) => !rollingSet.has(service) && !heldSet.has(service));
844
876
  // Published images are pulled only for a new desired revision. Pulling on every poll would let a tag that moved
845
877
  // upstream (`mariadb:11`) recreate the container without a deploy.
846
878
  const sameDesiredVersion = previousRow
@@ -882,7 +914,7 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
882
914
  .digest('hex')
883
915
  .slice(0, 16);
884
916
  const knownEvents = mainServices.length
885
- ? await db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project)
917
+ ? db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project)
886
918
  : undefined;
887
919
  const announce = knownEvents?.fingerprint !== eventFingerprint;
888
920
  const wasFailed = !announce && knownEvents?.phase === 'failed';
@@ -894,7 +926,7 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
894
926
  if (artifact)
895
927
  await ensureArtifact(ctx, artifact);
896
928
  },
897
- pullImage: (service) => ctx.docker.pullImage(plan.specs[service].body.Image),
929
+ pullImage: (service) => ctx.docker.pullImage(plan.specs[service].body.Image, sec.registryAuth?.[imageLocation(plan.specs[service].body.Image).host]),
898
930
  emit,
899
931
  };
900
932
  let failing;
@@ -902,6 +934,8 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
902
934
  try {
903
935
  await ensureProjectResources(ctx.docker, desiredState.project, plan);
904
936
  await sweepOrphans(ctx.docker, desiredState.project, new Set(serviceNames));
937
+ for (const service of heldSet)
938
+ await stopService(ctx.docker, desiredState.project, service);
905
939
  if (mainServices.length)
906
940
  await reconcileRecreate(ctx.docker, {
907
941
  desired: desiredState,
@@ -938,13 +972,13 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
938
972
  },
939
973
  });
940
974
  if (mainServices.length && (announce || wasFailed))
941
- await markEvents('serving');
975
+ markEvents('serving');
942
976
  }
943
977
  catch (e) {
944
978
  if (mainServices.length && !wasFailed) {
945
979
  // Announce a failure once; retries of the same failure stay quiet until it recovers or the inputs change.
946
980
  await emit(failing ?? mainServices.join(', '), 'failed', errMessage(e), 'error');
947
- await markEvents('failed');
981
+ markEvents('failed');
948
982
  }
949
983
  // Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
950
984
  if (!rolling.length)
@@ -974,7 +1008,7 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
974
1008
  for (const service of serviceNames)
975
1009
  serviceStates[service].rolloutState = 'serving';
976
1010
  }
977
- await db.run(`INSERT INTO applied_state(project, version, revision, applied_at, service_states_json, secret_versions_json)
1011
+ db.run(`INSERT INTO applied_state(project, version, revision, applied_at, service_states_json, secret_versions_json)
978
1012
  VALUES(?,?,?,?,?,?)
979
1013
  ON CONFLICT(project) DO UPDATE SET version=excluded.version, revision=excluded.revision, applied_at=excluded.applied_at,
980
1014
  service_states_json=excluded.service_states_json, secret_versions_json=excluded.secret_versions_json`, desiredState.project, desiredState.version, desiredState.revision || null, now(), JSON.stringify(serviceStates), JSON.stringify(currentSecretVersions));
@@ -1050,7 +1084,7 @@ export async function pollOnce(ctx = defaultContext()) {
1050
1084
  const projects = [];
1051
1085
  for (const desired of work.desiredStates || (work.desiredState ? [work.desiredState] : [])) {
1052
1086
  try {
1053
- 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]));
1054
1088
  }
1055
1089
  catch (e) {
1056
1090
  projects.push({
@@ -1071,7 +1105,13 @@ export async function pollOnce(ctx = defaultContext()) {
1071
1105
  await removeIngress(ingressCtx).catch(() => false);
1072
1106
  }
1073
1107
  const commandResults = [];
1108
+ const jobs = jobRunnerFor(ctx);
1074
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
+ }
1075
1115
  try {
1076
1116
  commandResults.push(await executeCommand(command, ctx));
1077
1117
  }
@@ -1083,7 +1123,12 @@ export async function pollOnce(ctx = defaultContext()) {
1083
1123
  });
1084
1124
  }
1085
1125
  }
1126
+ const jobResults = jobs.pending();
1127
+ commandResults.push(...jobResults);
1086
1128
  const report = { node: ctx.nodeName, projects, commandResults };
1129
+ const running = jobs.statuses();
1130
+ if (running.length)
1131
+ report.jobs = running;
1087
1132
  if (ingress)
1088
1133
  report.ingress = ingress;
1089
1134
  if (work.retainedImages && projects.every((project) => project.health !== 'failed'))
@@ -1093,6 +1138,7 @@ export async function pollOnce(ctx = defaultContext()) {
1093
1138
  headers: { 'content-type': 'application/json' },
1094
1139
  body: JSON.stringify(report),
1095
1140
  });
1141
+ await jobs.acknowledge(jobResults);
1096
1142
  return projects;
1097
1143
  }
1098
1144
  const CREDS_FILE = () => path.join(STATE_DIR, 'agent.json');
@@ -1122,19 +1168,6 @@ async function readJoinKey() {
1122
1168
  }
1123
1169
  return null;
1124
1170
  }
1125
- function dockerLogin(registry, user, password) {
1126
- return new Promise((resolve, reject) => {
1127
- const p = spawn('docker', ['login', registry, '-u', user, '--password-stdin'], {
1128
- stdio: ['pipe', 'ignore', 'pipe'],
1129
- });
1130
- let err = '';
1131
- p.stderr.on('data', (d) => (err += d));
1132
- p.on('error', reject);
1133
- p.on('close', (code) => code === 0 ? resolve() : reject(new Error(err.trim() || `exit ${code}`)));
1134
- p.stdin.write(password);
1135
- p.stdin.end();
1136
- });
1137
- }
1138
1171
  async function enroll(joinKey) {
1139
1172
  let decoded;
1140
1173
  try {
@@ -1184,8 +1217,6 @@ async function enroll(joinKey) {
1184
1217
  agentToken: out.agentToken,
1185
1218
  nodeName: out.node?.name || NODE_NAME,
1186
1219
  controlUrl,
1187
- registry: out.registry || null,
1188
- registryToken: out.registryToken || null,
1189
1220
  };
1190
1221
  await saveCreds(creds);
1191
1222
  if (wgPublicKey)
@@ -1209,14 +1240,6 @@ export async function loadOrEnroll() {
1209
1240
  NODE_NAME = creds.nodeName || NODE_NAME;
1210
1241
  if (!CONTROL_URL_FROM_ENV && creds.controlUrl)
1211
1242
  CONTROL_URL = creds.controlUrl;
1212
- if (APPLY && creds.registry && creds.registryToken) {
1213
- try {
1214
- await dockerLogin(creds.registry, NODE_NAME, creds.registryToken);
1215
- }
1216
- catch (e) {
1217
- console.error(`registry login failed (pulls from ${creds.registry} may fail): ${errMessage(e)}`);
1218
- }
1219
- }
1220
1243
  return creds;
1221
1244
  }
1222
1245
  export async function main() {
@@ -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 {};