@wpelevator/tug 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +8 -1
- package/dist/agent.js +76 -7
- package/dist/backup-check.d.ts +11 -0
- package/dist/backup-check.js +10 -0
- package/dist/backup-config.d.ts +52 -0
- package/dist/backup-config.js +276 -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 +317 -0
- package/dist/backup-scheduler.d.ts +60 -0
- package/dist/backup-scheduler.js +418 -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 +226 -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/normalize.d.ts +6 -0
- package/dist/normalize.js +15 -1
- package/dist/protocol.d.ts +118 -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):
|
|
75
|
+
- [Backups](docs/backups.md): scheduled restic backups with `x-tug.backup`, restore, recipes for common databases, and a restore drill. See also the [WordPress example](examples/wordpress/README.md).
|
|
76
76
|
- [Access control](docs/access-control.md): scoped API tokens, roles, audit trail and a GitHub Actions deploy example.
|
|
77
77
|
- [Networking](docs/networking.md): WireGuard mesh, service DNS, ingress and TLS, firewall notes.
|
|
78
78
|
- [Nodes](docs/nodes.md): SSH install, self-service enrollment, registration and status.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { DockerApi } from './docker-api.ts';
|
|
2
|
+
import type { BackupCredentialsResponse, Command, CommandResult, JobStatus } from './protocol.ts';
|
|
3
|
+
export interface JobHost {
|
|
4
|
+
docker: DockerApi;
|
|
5
|
+
stateDir: string;
|
|
6
|
+
fetchCredentials(commandId: string): Promise<BackupCredentialsResponse>;
|
|
7
|
+
/** Parallel jobs on this node; extra jobs wait queued. */
|
|
8
|
+
concurrency?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The agent's background jobs. Reconciliation must not wait for an hour-long backup, so commands of
|
|
12
|
+
* these types are queued here, report a heartbeat through `statuses()`, and deliver their result
|
|
13
|
+
* through `drain()` on a later poll, even if the agent restarted in between.
|
|
14
|
+
*/
|
|
15
|
+
export declare class JobRunner {
|
|
16
|
+
private readonly host;
|
|
17
|
+
private readonly active;
|
|
18
|
+
private readonly queue;
|
|
19
|
+
private results;
|
|
20
|
+
private ready;
|
|
21
|
+
private settled;
|
|
22
|
+
constructor(host: JobHost);
|
|
23
|
+
/** Called whenever a job finishes, so the agent can report its result without waiting a full interval. */
|
|
24
|
+
onSettled(callback: () => void): void;
|
|
25
|
+
private file;
|
|
26
|
+
private save;
|
|
27
|
+
/** First use: remove helpers of a previous run and fail the jobs it never finished. */
|
|
28
|
+
private init;
|
|
29
|
+
/** Starts cleanup of a previous process's leftovers; safe to call more than once. */
|
|
30
|
+
warm(): Promise<void>;
|
|
31
|
+
/** Queues a job; the command is acknowledged immediately and its result arrives later. */
|
|
32
|
+
start(command: Command): Promise<void>;
|
|
33
|
+
private pump;
|
|
34
|
+
private execute;
|
|
35
|
+
/** Jobs that are running or waiting, for the status report's heartbeat. */
|
|
36
|
+
statuses(): JobStatus[];
|
|
37
|
+
/** Finished results to put in the next status report. They are removed only after `acknowledge`. */
|
|
38
|
+
pending(): CommandResult[];
|
|
39
|
+
/** Forgets results control has stored. */
|
|
40
|
+
acknowledge(delivered: CommandResult[]): Promise<void>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { runBackupJob, removeStaleJobContainers } from "./backup-job.js";
|
|
4
|
+
import { redactSecrets } from "./backup-destination.js";
|
|
5
|
+
import { ensureDir, errMessage, now } from "./util.js";
|
|
6
|
+
const RESULTS_FILE = 'job-results.json';
|
|
7
|
+
/**
|
|
8
|
+
* The agent's background jobs. Reconciliation must not wait for an hour-long backup, so commands of
|
|
9
|
+
* these types are queued here, report a heartbeat through `statuses()`, and deliver their result
|
|
10
|
+
* through `drain()` on a later poll, even if the agent restarted in between.
|
|
11
|
+
*/
|
|
12
|
+
export class JobRunner {
|
|
13
|
+
host;
|
|
14
|
+
active = new Map();
|
|
15
|
+
queue = [];
|
|
16
|
+
results = [];
|
|
17
|
+
ready;
|
|
18
|
+
settled;
|
|
19
|
+
constructor(host) {
|
|
20
|
+
this.host = host;
|
|
21
|
+
}
|
|
22
|
+
/** Called whenever a job finishes, so the agent can report its result without waiting a full interval. */
|
|
23
|
+
onSettled(callback) {
|
|
24
|
+
this.settled = callback;
|
|
25
|
+
}
|
|
26
|
+
file() {
|
|
27
|
+
return path.join(this.host.stateDir, RESULTS_FILE);
|
|
28
|
+
}
|
|
29
|
+
async save() {
|
|
30
|
+
const data = {
|
|
31
|
+
results: this.results,
|
|
32
|
+
running: [...this.active.keys(), ...this.queue.map((job) => job.command.id)],
|
|
33
|
+
};
|
|
34
|
+
await ensureDir(this.host.stateDir);
|
|
35
|
+
await fs.writeFile(this.file(), JSON.stringify(data), { mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
/** First use: remove helpers of a previous run and fail the jobs it never finished. */
|
|
38
|
+
init() {
|
|
39
|
+
this.ready ??= (async () => {
|
|
40
|
+
await removeStaleJobContainers(this.host.docker).catch(() => 0);
|
|
41
|
+
try {
|
|
42
|
+
const saved = JSON.parse(await fs.readFile(this.file(), 'utf8'));
|
|
43
|
+
this.results = saved.results ?? [];
|
|
44
|
+
for (const commandId of saved.running ?? [])
|
|
45
|
+
this.results.push({
|
|
46
|
+
commandId,
|
|
47
|
+
state: 'failed',
|
|
48
|
+
result: { error: 'the agent restarted while the job was running' },
|
|
49
|
+
});
|
|
50
|
+
if (saved.running?.length)
|
|
51
|
+
await this.save();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* no saved state */
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
return this.ready;
|
|
58
|
+
}
|
|
59
|
+
/** Starts cleanup of a previous process's leftovers; safe to call more than once. */
|
|
60
|
+
warm() {
|
|
61
|
+
return this.init();
|
|
62
|
+
}
|
|
63
|
+
/** Queues a job; the command is acknowledged immediately and its result arrives later. */
|
|
64
|
+
async start(command) {
|
|
65
|
+
await this.init();
|
|
66
|
+
let payload;
|
|
67
|
+
try {
|
|
68
|
+
payload = JSON.parse(command.payload_json ?? '{}');
|
|
69
|
+
if (!payload.runId || !payload.repository || !payload.resticImage)
|
|
70
|
+
throw new Error('the job payload is incomplete');
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
this.results.push({
|
|
74
|
+
commandId: command.id,
|
|
75
|
+
state: 'failed',
|
|
76
|
+
result: { error: errMessage(error) },
|
|
77
|
+
});
|
|
78
|
+
await this.save();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.queue.push({ command, payload });
|
|
82
|
+
await this.save();
|
|
83
|
+
this.pump();
|
|
84
|
+
}
|
|
85
|
+
pump() {
|
|
86
|
+
const limit = this.host.concurrency ?? 1;
|
|
87
|
+
while (this.active.size < limit && this.queue.length) {
|
|
88
|
+
const job = this.queue.shift();
|
|
89
|
+
void this.execute(job.command, job.payload);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async execute(command, payload) {
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const status = {
|
|
95
|
+
commandId: command.id,
|
|
96
|
+
runId: payload.runId,
|
|
97
|
+
phase: 'pulling',
|
|
98
|
+
startedAt: now(),
|
|
99
|
+
controller,
|
|
100
|
+
};
|
|
101
|
+
this.active.set(command.id, status);
|
|
102
|
+
const timeout = setTimeout(() => controller.abort(new Error(`the job exceeded its ${payload.limits.timeoutSec}s limit`)), payload.limits.timeoutSec * 1000);
|
|
103
|
+
timeout.unref();
|
|
104
|
+
let credentials;
|
|
105
|
+
try {
|
|
106
|
+
credentials = await this.host.fetchCredentials(command.id);
|
|
107
|
+
const result = await runBackupJob(payload, command.id, {
|
|
108
|
+
docker: this.host.docker,
|
|
109
|
+
credentials,
|
|
110
|
+
signal: controller.signal,
|
|
111
|
+
onPhase: (phase, bytes) => {
|
|
112
|
+
status.phase = phase;
|
|
113
|
+
if (bytes !== undefined)
|
|
114
|
+
status.bytes = bytes;
|
|
115
|
+
},
|
|
116
|
+
}, command.type === 'backup.check'
|
|
117
|
+
? 'backup.check'
|
|
118
|
+
: command.type === 'backup.restore'
|
|
119
|
+
? 'backup.restore'
|
|
120
|
+
: 'backup.run');
|
|
121
|
+
this.results.push({ commandId: command.id, state: 'succeeded', result });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const values = credentials ? Object.values(credentials.env) : [];
|
|
125
|
+
this.results.push({
|
|
126
|
+
commandId: command.id,
|
|
127
|
+
state: 'failed',
|
|
128
|
+
result: { error: redactSecrets(errMessage(controller.signal.reason ?? error), values) },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
clearTimeout(timeout);
|
|
133
|
+
this.active.delete(command.id);
|
|
134
|
+
await this.save().catch(() => { });
|
|
135
|
+
this.settled?.();
|
|
136
|
+
this.pump();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Jobs that are running or waiting, for the status report's heartbeat. */
|
|
140
|
+
statuses() {
|
|
141
|
+
return [
|
|
142
|
+
...[...this.active.values()].map(({ controller: _controller, ...status }) => status),
|
|
143
|
+
...this.queue.map(({ command, payload }) => ({
|
|
144
|
+
commandId: command.id,
|
|
145
|
+
runId: payload.runId,
|
|
146
|
+
phase: 'queued',
|
|
147
|
+
startedAt: now(),
|
|
148
|
+
})),
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
/** Finished results to put in the next status report. They are removed only after `acknowledge`. */
|
|
152
|
+
pending() {
|
|
153
|
+
return [...this.results];
|
|
154
|
+
}
|
|
155
|
+
/** Forgets results control has stored. */
|
|
156
|
+
async acknowledge(delivered) {
|
|
157
|
+
const ids = new Set(delivered.map((result) => result.commandId));
|
|
158
|
+
this.results = this.results.filter((result) => !ids.has(result.commandId));
|
|
159
|
+
await this.save().catch(() => { });
|
|
160
|
+
}
|
|
161
|
+
}
|
package/dist/agent.d.ts
CHANGED
|
@@ -66,6 +66,13 @@ export declare class AgentLiveOperations {
|
|
|
66
66
|
}
|
|
67
67
|
/** The container's address on the project network, which health checks dial. */
|
|
68
68
|
export declare function containerIp(ctx: AgentContext, project: string, container: string): Promise<string>;
|
|
69
|
+
/** True when this process runs inside a container: Docker, containerd, Podman or Kubernetes. */
|
|
70
|
+
export declare function runsInContainer(): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Health checks dial the container's address on the project network. An agent that runs as a plain host process
|
|
73
|
+
* cannot reach it on a machine where Docker lives in a VM (macOS, Windows), and the bare timeout does not say why.
|
|
74
|
+
*/
|
|
75
|
+
export declare function outsideContainerHint(health: HealthCheck, inContainer?: boolean): string;
|
|
69
76
|
export declare function waitForHealth(ctx: AgentContext, project: string, container: string, service: string, health: HealthCheck | null | undefined): Promise<void>;
|
|
70
77
|
export { parseDuration };
|
|
71
78
|
export interface PublicListener {
|
|
@@ -85,7 +92,7 @@ export declare function pruneManagedImages(ctx: AgentContext, retained: string[]
|
|
|
85
92
|
export declare function resolveBindAddress(address: string | null, lookup?: (host: string) => Promise<{
|
|
86
93
|
address: string;
|
|
87
94
|
}>): Promise<string>;
|
|
88
|
-
export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>, ports?: PortBinding): Promise<ProjectStatus>;
|
|
95
|
+
export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>, ports?: PortBinding, held?: string[]): Promise<ProjectStatus>;
|
|
89
96
|
/** Reports this node's WireGuard public key when control has not seen the current one yet. */
|
|
90
97
|
export declare function reportWgKey(ctx: AgentContext): Promise<void>;
|
|
91
98
|
export declare function pollOnce(ctx?: AgentContext): Promise<ProjectStatus[]>;
|
package/dist/agent.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import { imageLocation } from "./registry.js";
|
|
4
|
-
import { createReadStream, createWriteStream } from 'node:fs';
|
|
4
|
+
import { createReadStream, createWriteStream, existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { Readable } from 'node:stream';
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
7
|
import { validateImageArchive, verifyDownload } from "./artifacts.js";
|
|
@@ -12,8 +12,9 @@ import dns from 'node:dns/promises';
|
|
|
12
12
|
import { WebSocket } from 'ws';
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
14
|
import { addressBoundServices, isRolling, serviceSecretVersions } from "./compose.js";
|
|
15
|
-
import { containerSpecs, networkName, volumeName } from "./container-spec.js";
|
|
15
|
+
import { LABEL_PROJECT, LABEL_SERVICE, containerSpecs, networkName, volumeName, } from "./container-spec.js";
|
|
16
16
|
import { API_VERSION, DockerEngine, isNotFound } from "./docker-api.js";
|
|
17
|
+
import { JobRunner } from "./agent-jobs.js";
|
|
17
18
|
import { LIVE_MAX_FRAME_BYTES, LIVE_START_TIMEOUT_MS } from "./protocol.js";
|
|
18
19
|
import { LiveOperationEngine } from "./live.js";
|
|
19
20
|
import { activeRollingStates, clearRolloutState, ensureProjectResources, loadRolloutState, projectContainers, reconcileRecreate, reconcileRolling, sweepOrphans, } from "./reconciler.js";
|
|
@@ -46,6 +47,23 @@ export function defaultContext() {
|
|
|
46
47
|
rolloutPorts: process.env.TUG_ROLLOUT_PORTS,
|
|
47
48
|
};
|
|
48
49
|
}
|
|
50
|
+
const jobRunners = new Map();
|
|
51
|
+
/** One job runner per state directory, so a background backup outlives the polls that started it. */
|
|
52
|
+
function jobRunnerFor(ctx) {
|
|
53
|
+
let runner = jobRunners.get(ctx.stateDir);
|
|
54
|
+
if (!runner) {
|
|
55
|
+
runner = new JobRunner({
|
|
56
|
+
docker: ctx.docker,
|
|
57
|
+
stateDir: ctx.stateDir,
|
|
58
|
+
concurrency: Number(process.env.TUG_BACKUP_CONCURRENCY) || 1,
|
|
59
|
+
fetchCredentials: (commandId) => api(ctx, `/agent/v1/backup/credentials?command=${encodeURIComponent(commandId)}`),
|
|
60
|
+
});
|
|
61
|
+
jobRunners.set(ctx.stateDir, runner);
|
|
62
|
+
// Clear helper containers a previous agent process left behind, before any live session can start one.
|
|
63
|
+
void runner.warm();
|
|
64
|
+
}
|
|
65
|
+
return runner;
|
|
66
|
+
}
|
|
49
67
|
const dbsByStateDir = new Map();
|
|
50
68
|
async function agentDb(stateDir) {
|
|
51
69
|
let db = dbsByStateDir.get(stateDir);
|
|
@@ -426,6 +444,7 @@ export class AgentLiveOperations {
|
|
|
426
444
|
container,
|
|
427
445
|
command: request.command,
|
|
428
446
|
interactive: request.interactive,
|
|
447
|
+
oneOff: request.oneOff,
|
|
429
448
|
tty: request.tty,
|
|
430
449
|
columns: request.columns,
|
|
431
450
|
rows: request.rows,
|
|
@@ -466,6 +485,30 @@ function tcpCheck(host, port, timeoutMs = 2000) {
|
|
|
466
485
|
s.on('error', reject);
|
|
467
486
|
});
|
|
468
487
|
}
|
|
488
|
+
let containerized;
|
|
489
|
+
/** True when this process runs inside a container: Docker, containerd, Podman or Kubernetes. */
|
|
490
|
+
export function runsInContainer() {
|
|
491
|
+
containerized ??= (() => {
|
|
492
|
+
if (process.env.container || existsSync('/.dockerenv'))
|
|
493
|
+
return true;
|
|
494
|
+
try {
|
|
495
|
+
return /docker|containerd|kubepods|libpod/.test(readFileSync('/proc/1/cgroup', 'utf8'));
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
})();
|
|
501
|
+
return containerized;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Health checks dial the container's address on the project network. An agent that runs as a plain host process
|
|
505
|
+
* cannot reach it on a machine where Docker lives in a VM (macOS, Windows), and the bare timeout does not say why.
|
|
506
|
+
*/
|
|
507
|
+
export function outsideContainerHint(health, inContainer = runsInContainer()) {
|
|
508
|
+
if (inContainer || health.type === 'docker')
|
|
509
|
+
return '';
|
|
510
|
+
return '; this agent is not running in a container, so it may be unable to reach container addresses (a host process on macOS cannot); run it with `docker run` as `tug node-install` does';
|
|
511
|
+
}
|
|
469
512
|
export async function waitForHealth(ctx, project, container, service, health) {
|
|
470
513
|
if (!ctx.apply || !health)
|
|
471
514
|
return;
|
|
@@ -499,7 +542,7 @@ export async function waitForHealth(ctx, project, container, service, health) {
|
|
|
499
542
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
500
543
|
}
|
|
501
544
|
}
|
|
502
|
-
throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}`);
|
|
545
|
+
throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}${outsideContainerHint(health)}`);
|
|
503
546
|
}
|
|
504
547
|
/** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
|
|
505
548
|
function describeError(error) {
|
|
@@ -802,7 +845,15 @@ export async function resolveBindAddress(address, lookup = (host) => dns.lookup(
|
|
|
802
845
|
}
|
|
803
846
|
// Last public-port warning per project, so the event is sent when it changes rather than on every poll.
|
|
804
847
|
const publicPortWarnings = new Map();
|
|
805
|
-
|
|
848
|
+
/** Stops every running container of a service, including the revisions of a rolling service. */
|
|
849
|
+
async function stopService(docker, project, service) {
|
|
850
|
+
const running = await docker.listContainers({
|
|
851
|
+
label: [`${LABEL_PROJECT}=${project}`, `${LABEL_SERVICE}=${service}`],
|
|
852
|
+
});
|
|
853
|
+
for (const container of running)
|
|
854
|
+
await docker.stopContainer(container.Id);
|
|
855
|
+
}
|
|
856
|
+
export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}, ports = { address: null, loopback: {} }, held = []) {
|
|
806
857
|
const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
|
|
807
858
|
await ensureDir(dir);
|
|
808
859
|
const serviceNames = Object.keys(containerSpecs(desiredState, ctx.nodeName).specs);
|
|
@@ -839,9 +890,13 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
839
890
|
const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
|
|
840
891
|
const db = await agentDb(ctx.stateDir);
|
|
841
892
|
const previousRow = db.get('SELECT version, revision FROM applied_state WHERE project = ?', desiredState.project);
|
|
842
|
-
|
|
893
|
+
// A held service is stopped and left alone (a restore is rewriting its data) until control drops the hold.
|
|
894
|
+
const heldSet = new Set(held.filter((service) => serviceNames.includes(service)));
|
|
895
|
+
for (const service of heldSet)
|
|
896
|
+
serviceStates[service].rolloutState = 'held';
|
|
897
|
+
const rolling = serviceNames.filter((service) => !heldSet.has(service) && isRolling(desiredState.compose.services[service]));
|
|
843
898
|
const rollingSet = new Set(rolling);
|
|
844
|
-
const mainServices = serviceNames.filter((service) => !rollingSet.has(service));
|
|
899
|
+
const mainServices = serviceNames.filter((service) => !rollingSet.has(service) && !heldSet.has(service));
|
|
845
900
|
// Published images are pulled only for a new desired revision. Pulling on every poll would let a tag that moved
|
|
846
901
|
// upstream (`mariadb:11`) recreate the container without a deploy.
|
|
847
902
|
const sameDesiredVersion = previousRow
|
|
@@ -903,6 +958,8 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
903
958
|
try {
|
|
904
959
|
await ensureProjectResources(ctx.docker, desiredState.project, plan);
|
|
905
960
|
await sweepOrphans(ctx.docker, desiredState.project, new Set(serviceNames));
|
|
961
|
+
for (const service of heldSet)
|
|
962
|
+
await stopService(ctx.docker, desiredState.project, service);
|
|
906
963
|
if (mainServices.length)
|
|
907
964
|
await reconcileRecreate(ctx.docker, {
|
|
908
965
|
desired: desiredState,
|
|
@@ -1051,7 +1108,7 @@ export async function pollOnce(ctx = defaultContext()) {
|
|
|
1051
1108
|
const projects = [];
|
|
1052
1109
|
for (const desired of work.desiredStates || (work.desiredState ? [work.desiredState] : [])) {
|
|
1053
1110
|
try {
|
|
1054
|
-
projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project], work.ports));
|
|
1111
|
+
projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project], work.ports, work.holds?.[desired.project]));
|
|
1055
1112
|
}
|
|
1056
1113
|
catch (e) {
|
|
1057
1114
|
projects.push({
|
|
@@ -1072,7 +1129,13 @@ export async function pollOnce(ctx = defaultContext()) {
|
|
|
1072
1129
|
await removeIngress(ingressCtx).catch(() => false);
|
|
1073
1130
|
}
|
|
1074
1131
|
const commandResults = [];
|
|
1132
|
+
const jobs = jobRunnerFor(ctx);
|
|
1075
1133
|
for (const command of work.commands || []) {
|
|
1134
|
+
// Backups can run for an hour, so they run in the background and report through later polls.
|
|
1135
|
+
if (command.type.startsWith('backup.')) {
|
|
1136
|
+
await jobs.start(command);
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1076
1139
|
try {
|
|
1077
1140
|
commandResults.push(await executeCommand(command, ctx));
|
|
1078
1141
|
}
|
|
@@ -1084,7 +1147,12 @@ export async function pollOnce(ctx = defaultContext()) {
|
|
|
1084
1147
|
});
|
|
1085
1148
|
}
|
|
1086
1149
|
}
|
|
1150
|
+
const jobResults = jobs.pending();
|
|
1151
|
+
commandResults.push(...jobResults);
|
|
1087
1152
|
const report = { node: ctx.nodeName, projects, commandResults };
|
|
1153
|
+
const running = jobs.statuses();
|
|
1154
|
+
if (running.length)
|
|
1155
|
+
report.jobs = running;
|
|
1088
1156
|
if (ingress)
|
|
1089
1157
|
report.ingress = ingress;
|
|
1090
1158
|
if (work.retainedImages && projects.every((project) => project.health !== 'failed'))
|
|
@@ -1094,6 +1162,7 @@ export async function pollOnce(ctx = defaultContext()) {
|
|
|
1094
1162
|
headers: { 'content-type': 'application/json' },
|
|
1095
1163
|
body: JSON.stringify(report),
|
|
1096
1164
|
});
|
|
1165
|
+
await jobs.acknowledge(jobResults);
|
|
1097
1166
|
return projects;
|
|
1098
1167
|
}
|
|
1099
1168
|
const CREDS_FILE = () => path.join(STATE_DIR, 'agent.json');
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The part of `GET /api/v1/backups` that `tug backup-list --check` judges. */
|
|
2
|
+
export interface BackupCheckInput {
|
|
3
|
+
destinationConfigured: boolean;
|
|
4
|
+
targets: {
|
|
5
|
+
target: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
problem?: string | null;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
/** What a monitor should be told: one line per problem, empty when every backup target is healthy. */
|
|
11
|
+
export declare function backupCheckProblems(listing: BackupCheckInput): string[];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** What a monitor should be told: one line per problem, empty when every backup target is healthy. */
|
|
2
|
+
export function backupCheckProblems(listing) {
|
|
3
|
+
const active = listing.targets.filter((target) => target.enabled);
|
|
4
|
+
const problems = active
|
|
5
|
+
.filter((target) => target.problem)
|
|
6
|
+
.map((target) => `${target.target}: ${target.problem}`);
|
|
7
|
+
if (active.length && !listing.destinationConfigured)
|
|
8
|
+
problems.unshift('no backup destination is configured');
|
|
9
|
+
return problems;
|
|
10
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { BackupRetention, BackupType, ComposeFile } from './protocol.ts';
|
|
2
|
+
/** One backup target after defaults and placement are resolved. */
|
|
3
|
+
export interface ResolvedBackup {
|
|
4
|
+
/** `service:db` or `volume:uploads`, unique within a project. */
|
|
5
|
+
target: string;
|
|
6
|
+
kind: 'service' | 'volume';
|
|
7
|
+
name: string;
|
|
8
|
+
/** `command` for a service, `volume` for a volume. */
|
|
9
|
+
type: BackupType;
|
|
10
|
+
schedule: string;
|
|
11
|
+
retention: BackupRetention;
|
|
12
|
+
jitterSeconds: number;
|
|
13
|
+
timeoutSec: number;
|
|
14
|
+
/** Memory limit of the restic helper container. */
|
|
15
|
+
memory: string;
|
|
16
|
+
/** Service targets: the command run in the service's container, and the snapshot file it is stored as. */
|
|
17
|
+
command?: string[];
|
|
18
|
+
/** Run in the service container to restore; the stored file arrives on its standard input. */
|
|
19
|
+
restore?: string[];
|
|
20
|
+
filename?: string;
|
|
21
|
+
verify?: string;
|
|
22
|
+
/** Volume targets. */
|
|
23
|
+
exclude?: string[];
|
|
24
|
+
/** Services held during a restore; unset means "discover from the Compose file". */
|
|
25
|
+
writers?: string[];
|
|
26
|
+
/** The one node the backup runs on, when it is known from literal placement. Label placement resolves at deploy time. */
|
|
27
|
+
node?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare const DEFAULT_RETENTION: BackupRetention;
|
|
30
|
+
export declare const DEFAULT_BACKUP_TIMEOUT_SEC: number;
|
|
31
|
+
export declare const DEFAULT_BACKUP_MEMORY = "512m";
|
|
32
|
+
/** `30m`, `2h`, `90s` or a number of seconds. */
|
|
33
|
+
export declare function parseBackupTimeout(value: unknown): number | null;
|
|
34
|
+
/** Bytes in a Docker-style memory size (`256m`, `1g`, `1048576`), or null when it is not one. */
|
|
35
|
+
export declare function parseMemorySize(value: unknown): number | null;
|
|
36
|
+
/** Deterministic delay in `[0, 900)` seconds so many sites on one node do not start together. */
|
|
37
|
+
export declare function defaultJitterSeconds(project: string, target: string): number;
|
|
38
|
+
/** Validates the project-level defaults in `x-tug.backup`. */
|
|
39
|
+
export declare function validateBackupDefaults(value: unknown, errors: string[]): void;
|
|
40
|
+
/** The argv of a `command`: a list is used as is, a string runs through `sh -c` like a shell one-liner. */
|
|
41
|
+
export declare function commandArgv(command: unknown): string[] | null;
|
|
42
|
+
interface Resolved {
|
|
43
|
+
targets: ResolvedBackup[];
|
|
44
|
+
errors: string[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolves and validates every `x-tug.backup` in a Compose file. Tug knows nothing about what a service
|
|
48
|
+
* is: a service backup is a command whose output is stored, a volume backup is its files. `project`
|
|
49
|
+
* seeds the default jitter. Errors name the key, so they can be shown to the operator as they are.
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveBackups(compose: ComposeFile, project?: string): Resolved;
|
|
52
|
+
export {};
|