@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.
- package/README.md +10 -11
- package/dist/agent-jobs.d.ts +41 -0
- package/dist/agent-jobs.js +161 -0
- package/dist/agent.d.ts +1 -3
- package/dist/agent.js +58 -35
- package/dist/backup-config.d.ts +47 -0
- package/dist/backup-config.js +256 -0
- package/dist/backup-destination.d.ts +22 -0
- package/dist/backup-destination.js +72 -0
- package/dist/backup-job.d.ts +32 -0
- package/dist/backup-job.js +352 -0
- package/dist/backup-restore.d.ts +67 -0
- package/dist/backup-restore.js +297 -0
- package/dist/backup-scheduler.d.ts +54 -0
- package/dist/backup-scheduler.js +403 -0
- package/dist/build.d.ts +2 -3
- package/dist/build.js +1 -3
- package/dist/cli-build.d.ts +3 -3
- package/dist/cli-build.js +18 -6
- package/dist/cli-config.d.ts +45 -5
- package/dist/cli-config.js +269 -19
- package/dist/cli-live-transport.d.ts +1 -1
- package/dist/cli-live-transport.js +2 -2
- package/dist/cli-live.d.ts +2 -0
- package/dist/cli-live.js +1 -0
- package/dist/cli.js +217 -49
- package/dist/compose.d.ts +1 -2
- package/dist/compose.js +7 -6
- package/dist/container-spec.d.ts +3 -2
- package/dist/container-spec.js +9 -34
- package/dist/control.d.ts +4 -2
- package/dist/control.js +591 -381
- package/dist/cron.d.ts +17 -0
- package/dist/cron.js +124 -0
- package/dist/db-driver.d.ts +20 -0
- package/dist/db-driver.js +11 -0
- package/dist/db-node.d.ts +3 -0
- package/dist/db-node.js +60 -0
- package/dist/db.d.ts +79 -6
- package/dist/db.js +47 -26
- package/dist/docker-api.d.ts +7 -0
- package/dist/docker-api.js +15 -3
- package/dist/live.d.ts +8 -0
- package/dist/live.js +84 -0
- package/dist/protocol.d.ts +121 -12
- package/dist/protocol.js +7 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +133 -0
- package/dist/templates/control-compose.yml +0 -4
- package/dist/templates/node-install.sh +0 -1
- package/dist/tokens.d.ts +7 -7
- package/dist/tokens.js +34 -21
- 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
|
|
11
|
-
- Agents pull desired state from control,
|
|
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
|
|
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
|
-
-
|
|
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,
|
|
76
|
-
- [Backups](docs/backups.md): interim WordPress backup sidecar with restic, per-node
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
841
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|