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