@wpelevator/tug 0.1.0 → 0.2.1
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 +11 -6
- package/dist/agent.d.ts +9 -5
- package/dist/agent.js +215 -52
- package/dist/archive.d.ts +2 -0
- package/dist/archive.js +20 -0
- package/dist/artifacts.d.ts +23 -0
- package/dist/artifacts.js +238 -0
- package/dist/build.js +17 -2
- package/dist/cli-build.d.ts +15 -0
- package/dist/cli-build.js +50 -0
- package/dist/cli-config.d.ts +20 -0
- package/dist/cli-config.js +47 -0
- package/dist/cli-install.d.ts +7 -0
- package/dist/cli-install.js +31 -0
- package/dist/cli.js +191 -93
- package/dist/compose.d.ts +12 -2
- package/dist/compose.js +85 -7
- package/dist/control.d.ts +15 -1
- package/dist/control.js +583 -144
- package/dist/db.d.ts +8 -0
- package/dist/db.js +12 -3
- package/dist/ingress.js +5 -3
- package/dist/protocol.d.ts +44 -0
- package/dist/rollout.d.ts +72 -0
- package/dist/rollout.js +319 -0
- package/dist/templates/control-install.sh +1 -1
- package/dist/templates/install-docker.sh +85 -0
- package/dist/templates/node-install.sh +1 -1
- package/dist/tokens.d.ts +91 -0
- package/dist/tokens.js +165 -0
- package/dist/util.d.ts +3 -0
- package/dist/util.js +11 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -6,13 +6,14 @@ 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 and SQLite metadata.
|
|
10
|
-
- Nodes join over SSH (`node
|
|
9
|
+
- Control API with bearer-token auth, scoped `read`/`deploy`/`admin` API tokens for CI and teammates, an audit trail naming who acted, and SQLite metadata.
|
|
10
|
+
- Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` (`node-enroll`), with per-node agent and registry tokens. Agents only dial out, so nodes behind NAT work.
|
|
11
11
|
- Agents pull desired state from control, apply a generated per-node Compose file, run TCP/HTTP/Docker health checks and report status.
|
|
12
12
|
- `x-tug` placement of services and volumes on nodes, with immutable desired-state records per deployment.
|
|
13
|
-
-
|
|
13
|
+
- Build application images on control from uploaded source, or locally with `deploy --build-local`. Agents download saved images directly from control by default; a bring-your-own registry is optional.
|
|
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
|
+
- 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).
|
|
16
17
|
- Per-node registry pull tokens enforced by an htpasswd-backed registry you provide, with rotation and revocation.
|
|
17
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).
|
|
18
19
|
- Control-plane backup and restore, and `tug doctor` diagnostics.
|
|
@@ -27,10 +28,10 @@ npm install -g @wpelevator/tug
|
|
|
27
28
|
|
|
28
29
|
The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts.
|
|
29
30
|
|
|
30
|
-
1. Install the control plane
|
|
31
|
+
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).
|
|
31
32
|
2. Point the CLI at it: `tug init --control-url https://control.example.com --token "$TUG_TOKEN"`.
|
|
32
|
-
3. Set the secrets your services need: `tug secret
|
|
33
|
-
4. Add nodes
|
|
33
|
+
3. Set the secrets your services need: `tug secret-set DB_PASSWORD`.
|
|
34
|
+
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.
|
|
34
35
|
5. Deploy: `tug deploy -f docker-compose.yml`. It waits until every node reports the new version healthy.
|
|
35
36
|
6. Operate with `tug status`, `events`, `logs`, `restart`, `rollback` and `doctor`.
|
|
36
37
|
|
|
@@ -39,12 +40,16 @@ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your h
|
|
|
39
40
|
- [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
|
|
40
41
|
- [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
|
|
41
42
|
- [Control plane](docs/control-plane.md): install, TLS, registry credentials, backup and restore.
|
|
43
|
+
- [Access control](docs/access-control.md): scoped API tokens, roles, audit trail and a GitHub Actions deploy example.
|
|
42
44
|
- [Networking](docs/networking.md): WireGuard mesh, service DNS, ingress and TLS, firewall notes.
|
|
43
45
|
- [Nodes](docs/nodes.md): SSH install, self-service enrollment, registration and status.
|
|
44
46
|
- [CLI reference](docs/cli.md): every `tug` command and option.
|
|
45
47
|
- [Configuration](docs/configuration.md): CLI, control and agent environment variables.
|
|
46
48
|
- [Development](docs/development.md): local setup, CI, Compose stack, host mode and the integration test.
|
|
49
|
+
- [Testing](docs/testing.md): test layers, the numbers rollouts are held to, and the backlog of mocked tests.
|
|
50
|
+
- [Observability](docs/observability.md): what can be observed today, and the proposed logs, metrics and alerting.
|
|
47
51
|
- [Releases](docs/releases.md): changesets, npm and Docker image publishing.
|
|
52
|
+
- [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution.
|
|
48
53
|
- [Roadmap](docs/roadmap.md): planned milestones toward parity with and beyond uncloud.
|
|
49
54
|
|
|
50
55
|
## Development
|
package/dist/agent.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { parseDuration } from './util.ts';
|
|
2
3
|
import type { DockerRunner, HostRunner, HttpClient } from './runners.ts';
|
|
3
|
-
import type { Command, CommandResult, DesiredState, HealthCheck, ProjectStatus, ServiceEndpoint } from './protocol.ts';
|
|
4
|
+
import type { Command, CommandResult, DesiredState, HealthCheck, ProjectStatus, ImageArtifact, ServiceEndpoint } from './protocol.ts';
|
|
4
5
|
export interface AgentContext {
|
|
5
6
|
docker: DockerRunner;
|
|
6
7
|
http: HttpClient;
|
|
@@ -14,12 +15,16 @@ export interface AgentContext {
|
|
|
14
15
|
/** Manage the WireGuard mesh (generate a key, report it, reconcile `wg0`). */
|
|
15
16
|
mesh?: boolean;
|
|
16
17
|
caddyImage?: string;
|
|
18
|
+
/** Host port range (`LOW-HIGH`) for rolling-rollout revisions; default 20000-20999. */
|
|
19
|
+
rolloutPorts?: string;
|
|
17
20
|
}
|
|
18
21
|
export declare function defaultContext(): AgentContext;
|
|
19
|
-
export declare function containerIp(ctx: AgentContext, project: string, service: string, cwd: string): Promise<string>;
|
|
20
|
-
export declare function waitForHealth(ctx: AgentContext, project: string, service: string, health: HealthCheck | null | undefined, cwd: string): Promise<void>;
|
|
21
|
-
export
|
|
22
|
+
export declare function containerIp(ctx: AgentContext, project: string, service: string, cwd: string, composeFile?: string): Promise<string>;
|
|
23
|
+
export declare function waitForHealth(ctx: AgentContext, project: string, service: string, health: HealthCheck | null | undefined, cwd: string, composeFile?: string): Promise<void>;
|
|
24
|
+
export { parseDuration };
|
|
22
25
|
export declare function executeCommand(command: Command, ctx?: AgentContext): Promise<CommandResult>;
|
|
26
|
+
export declare function ensureArtifact(ctx: AgentContext, artifact: ImageArtifact): Promise<void>;
|
|
27
|
+
export declare function pruneManagedImages(ctx: AgentContext, retained: string[]): Promise<void>;
|
|
23
28
|
export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>): Promise<ProjectStatus>;
|
|
24
29
|
/** Reports this node's WireGuard public key when control has not seen the current one yet. */
|
|
25
30
|
export declare function reportWgKey(ctx: AgentContext): Promise<void>;
|
|
@@ -33,4 +38,3 @@ interface Creds {
|
|
|
33
38
|
}
|
|
34
39
|
export declare function loadOrEnroll(): Promise<Creds>;
|
|
35
40
|
export declare function main(): Promise<void>;
|
|
36
|
-
export {};
|
package/dist/agent.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import { createWriteStream } from 'node:fs';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { validateImageArchive, verifyDownload } from "./artifacts.js";
|
|
3
7
|
import path from 'node:path';
|
|
4
8
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
9
|
import net from 'node:net';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { composeForNode, dumpYaml, isRolling, serviceSecretVersions } from "./compose.js";
|
|
12
|
+
import { activeRollingStates, composeBase, hasLegacyContainers, reconcileRolling, retireAll } from "./rollout.js";
|
|
13
|
+
import { ensureDir, errMessage, now, parseDuration } from "./util.js";
|
|
8
14
|
import { openAgentDb } from "./db.js";
|
|
9
15
|
import { createDockerRunner, createHostRunner } from "./runners.js";
|
|
10
16
|
import { CADDY_CONTAINER, reconcileIngress, removeIngress } from "./ingress.js";
|
|
@@ -19,7 +25,7 @@ const INTERVAL = Number(process.env.TUG_POLL_INTERVAL_MS || 5000);
|
|
|
19
25
|
// Opt-in WireGuard mesh: needs `wg`/`ip` in the agent image, host networking and NET_ADMIN.
|
|
20
26
|
const MESH = process.env.TUG_MESH === 'true';
|
|
21
27
|
export function defaultContext() {
|
|
22
|
-
return { docker: createDockerRunner(), http: fetch, nodeName: NODE_NAME, stateDir: STATE_DIR, apply: APPLY, controlUrl: CONTROL_URL, token: TOKEN, host: createHostRunner(), mesh: MESH, caddyImage: process.env.TUG_CADDY_IMAGE };
|
|
28
|
+
return { docker: createDockerRunner(), http: fetch, nodeName: NODE_NAME, stateDir: STATE_DIR, apply: APPLY, controlUrl: CONTROL_URL, token: TOKEN, host: createHostRunner(), mesh: MESH, caddyImage: process.env.TUG_CADDY_IMAGE, rolloutPorts: process.env.TUG_ROLLOUT_PORTS };
|
|
23
29
|
}
|
|
24
30
|
const dbsByStateDir = new Map();
|
|
25
31
|
async function agentDb(stateDir) {
|
|
@@ -43,8 +49,10 @@ async function writeEnv(file, secrets) {
|
|
|
43
49
|
const body = Object.entries(secrets).map(([k, v]) => `${k}=${String(v).replace(/\n/g, '\\n')}`).join('\n') + '\n';
|
|
44
50
|
await fs.writeFile(file, body, { mode: 0o600 });
|
|
45
51
|
}
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
/** `docker compose` prefix for a project; rolling revision projects pass their own Compose file. */
|
|
53
|
+
const composeCmd = (project, cwd, composeFile) => composeFile ? composeBase(project, cwd, composeFile) : ['compose', '-p', project];
|
|
54
|
+
export async function containerIp(ctx, project, service, cwd, composeFile) {
|
|
55
|
+
const ids = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out.trim().split(/\s+/).filter(Boolean);
|
|
48
56
|
if (!ids.length)
|
|
49
57
|
throw new Error(`${service}: no container found`);
|
|
50
58
|
const inspected = JSON.parse((await ctx.docker(['inspect', ids[0]], { cwd })).out)[0];
|
|
@@ -58,7 +66,7 @@ function tcpCheck(host, port, timeoutMs = 2000) {
|
|
|
58
66
|
s.on('error', reject);
|
|
59
67
|
});
|
|
60
68
|
}
|
|
61
|
-
export async function waitForHealth(ctx, project, service, health, cwd) {
|
|
69
|
+
export async function waitForHealth(ctx, project, service, health, cwd, composeFile) {
|
|
62
70
|
if (!ctx.apply || !health)
|
|
63
71
|
return;
|
|
64
72
|
const retries = Number(health.retries || 10);
|
|
@@ -67,7 +75,7 @@ export async function waitForHealth(ctx, project, service, health, cwd) {
|
|
|
67
75
|
for (let i = 0; i < retries; i++) {
|
|
68
76
|
try {
|
|
69
77
|
if (health.type === 'docker') {
|
|
70
|
-
const id = (await ctx.docker([
|
|
78
|
+
const id = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out.trim().split(/\s+/)[0];
|
|
71
79
|
const state = JSON.parse((await ctx.docker(['inspect', id], { cwd })).out)[0]?.State;
|
|
72
80
|
if (!state?.Running)
|
|
73
81
|
throw new Error(`${service}: not running`);
|
|
@@ -75,10 +83,10 @@ export async function waitForHealth(ctx, project, service, health, cwd) {
|
|
|
75
83
|
throw new Error(`${service}: docker health ${state.Health.Status}`);
|
|
76
84
|
}
|
|
77
85
|
else if (health.type === 'tcp') {
|
|
78
|
-
await tcpCheck(await containerIp(ctx, project, service, cwd), health.port, parseDuration(health.timeout || '2s'));
|
|
86
|
+
await tcpCheck(await containerIp(ctx, project, service, cwd, composeFile), health.port, parseDuration(health.timeout || '2s'));
|
|
79
87
|
}
|
|
80
88
|
else if (health.type === 'http') {
|
|
81
|
-
const ip = await containerIp(ctx, project, service, cwd);
|
|
89
|
+
const ip = await containerIp(ctx, project, service, cwd, composeFile);
|
|
82
90
|
const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, { signal: AbortSignal.timeout(parseDuration(health.timeout || '2s')) });
|
|
83
91
|
if (!res.ok)
|
|
84
92
|
throw new Error(`${service}: http ${res.status}`);
|
|
@@ -90,17 +98,22 @@ export async function waitForHealth(ctx, project, service, health, cwd) {
|
|
|
90
98
|
await new Promise(r => setTimeout(r, intervalMs));
|
|
91
99
|
}
|
|
92
100
|
}
|
|
93
|
-
throw
|
|
101
|
+
throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}`);
|
|
94
102
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
return 2000;
|
|
101
|
-
const n = Number(m[1]);
|
|
102
|
-
return m[2] === 'm' ? n * 60000 : m[2] === 'ms' ? n : n * 1000;
|
|
103
|
+
/** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
|
|
104
|
+
function describeError(error) {
|
|
105
|
+
const e = error;
|
|
106
|
+
const cause = e?.cause?.code || e?.cause?.message;
|
|
107
|
+
return cause ? `${e?.message ?? 'error'} (${cause})` : (e?.message ?? 'no response');
|
|
103
108
|
}
|
|
109
|
+
function describeHealthCheck(health) {
|
|
110
|
+
if (health.type === 'http')
|
|
111
|
+
return `http health check on port ${health.port || 80}${health.path || '/'}`;
|
|
112
|
+
if (health.type === 'tcp')
|
|
113
|
+
return `tcp health check on port ${health.port}`;
|
|
114
|
+
return 'docker health check';
|
|
115
|
+
}
|
|
116
|
+
export { parseDuration };
|
|
104
117
|
export async function executeCommand(command, ctx = defaultContext()) {
|
|
105
118
|
const payload = typeof command.payload_json === 'string' ? JSON.parse(command.payload_json || '{}') : command.payload || {};
|
|
106
119
|
if (command.type === 'diagnostics.run') {
|
|
@@ -175,59 +188,207 @@ export async function executeCommand(command, ctx = defaultContext()) {
|
|
|
175
188
|
}
|
|
176
189
|
return { commandId: command.id, state: 'failed', result: { error: `unsupported command ${command.type}` } };
|
|
177
190
|
}
|
|
191
|
+
async function managedImageFile(ctx) { await ensureDir(ctx.stateDir); return path.join(ctx.stateDir, 'managed-images.json'); }
|
|
192
|
+
async function managedImages(ctx) {
|
|
193
|
+
try {
|
|
194
|
+
const refs = JSON.parse(await fs.readFile(await managedImageFile(ctx), 'utf8'));
|
|
195
|
+
return Array.isArray(refs) ? refs.filter((ref) => typeof ref === 'string' && ref.startsWith('tug-build/')) : [];
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export async function ensureArtifact(ctx, artifact) {
|
|
202
|
+
const identityFile = path.join(ctx.stateDir, 'image-identities.json');
|
|
203
|
+
let identities = {};
|
|
204
|
+
try {
|
|
205
|
+
identities = JSON.parse(await fs.readFile(identityFile, 'utf8'));
|
|
206
|
+
}
|
|
207
|
+
catch { }
|
|
208
|
+
const remember = async (daemonId) => {
|
|
209
|
+
identities[artifact.imageRef] = { daemonId, artifactId: artifact.imageId, sha256: artifact.sha256 };
|
|
210
|
+
await fs.writeFile(identityFile, JSON.stringify(identities), { mode: 0o600 });
|
|
211
|
+
const refs = await managedImages(ctx);
|
|
212
|
+
if (!refs.includes(artifact.imageRef))
|
|
213
|
+
await fs.writeFile(await managedImageFile(ctx), JSON.stringify([...refs, artifact.imageRef]), { mode: 0o600 });
|
|
214
|
+
};
|
|
215
|
+
const inspect = async () => JSON.parse((await ctx.docker(['image', 'inspect', artifact.imageRef])).out)[0];
|
|
216
|
+
try {
|
|
217
|
+
const image = await inspect();
|
|
218
|
+
if ((image?.Id === artifact.imageId || (identities[artifact.imageRef]?.daemonId === image?.Id && identities[artifact.imageRef]?.artifactId === artifact.imageId && identities[artifact.imageRef]?.sha256 === artifact.sha256)) && `${image.Os}/${image.Architecture}` === artifact.platform) {
|
|
219
|
+
await remember(image.Id);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch { }
|
|
224
|
+
const host = JSON.parse((await ctx.docker(['info', '--format', '{{json .}}'])).out);
|
|
225
|
+
const arch = { x86_64: 'amd64', aarch64: 'arm64' }[host.Architecture] || host.Architecture;
|
|
226
|
+
if (host.OSType && host.Architecture && `${host.OSType}/${arch}` !== artifact.platform)
|
|
227
|
+
throw new Error(`image platform ${artifact.platform} does not match node ${host.OSType}/${arch}; build for the target platform`);
|
|
228
|
+
const downloadDir = await fs.mkdtemp(path.join(ctx.stateDir, 'image-download-'));
|
|
229
|
+
const file = path.join(downloadDir, 'image.tar.gz');
|
|
230
|
+
try {
|
|
231
|
+
const response = await ctx.http(`${ctx.controlUrl}/agent/v1/images/${encodeURIComponent(artifact.buildId)}`, { headers: authHeaders(ctx), redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000) });
|
|
232
|
+
if (!response.ok || !response.body)
|
|
233
|
+
throw new Error(`image download failed: HTTP ${response.status}`);
|
|
234
|
+
await pipeline(Readable.fromWeb(response.body), verifyDownload(artifact), createWriteStream(file, { mode: 0o600 }));
|
|
235
|
+
const archive = await validateImageArchive(file, artifact.imageRef);
|
|
236
|
+
if (archive.imageId !== artifact.imageId || archive.platform !== artifact.platform)
|
|
237
|
+
throw new Error('downloaded archive image metadata differs from desired artifact');
|
|
238
|
+
await ctx.docker(['load', '-i', file]);
|
|
239
|
+
const image = await inspect();
|
|
240
|
+
if (!image?.Id || `${image.Os}/${image.Architecture}` !== artifact.platform)
|
|
241
|
+
throw new Error('loaded image identity or platform does not match desired artifact');
|
|
242
|
+
await remember(image.Id);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await fs.rm(downloadDir, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export async function pruneManagedImages(ctx, retained) {
|
|
249
|
+
if (!ctx.apply)
|
|
250
|
+
return;
|
|
251
|
+
const keep = new Set(retained);
|
|
252
|
+
const remaining = [];
|
|
253
|
+
for (const ref of await managedImages(ctx)) {
|
|
254
|
+
if (keep.has(ref)) {
|
|
255
|
+
remaining.push(ref);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const used = (await ctx.docker(['ps', '-aq', '--filter', `ancestor=${ref}`])).out.trim();
|
|
260
|
+
if (used) {
|
|
261
|
+
remaining.push(ref);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
await ctx.docker(['image', 'rm', ref]);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
remaining.push(ref);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
await fs.writeFile(await managedImageFile(ctx), JSON.stringify(remaining), { mode: 0o600 });
|
|
271
|
+
}
|
|
178
272
|
export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}) {
|
|
179
273
|
const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
|
|
180
274
|
await ensureDir(dir);
|
|
181
275
|
const plannedCompose = composeForNode(desiredState, ctx.nodeName, {});
|
|
182
276
|
const serviceNames = Object.keys(plannedCompose.services || {});
|
|
183
|
-
if (!serviceNames.length)
|
|
184
|
-
|
|
277
|
+
if (!serviceNames.length) {
|
|
278
|
+
if (ctx.apply)
|
|
279
|
+
await retireAll(ctx, dir, desiredState).catch(() => { });
|
|
280
|
+
return { project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'skipped', services: [], serviceStates: {} };
|
|
281
|
+
}
|
|
185
282
|
const serviceStates = Object.fromEntries(serviceNames.map(service => [service, { rolloutState: 'pending', image: desiredState.services[service]?.image }]));
|
|
186
|
-
const emit = (service, rolloutState, message = rolloutState) => api(ctx, '/agent/v1/events', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: desiredState.project, level
|
|
283
|
+
const emit = (service, rolloutState, message = rolloutState, level = 'info') => api(ctx, '/agent/v1/events', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: desiredState.project, level, type: `service.${rolloutState}`, message, metadata: { service, version: desiredState.version, node: ctx.nodeName } }) }).catch(() => { });
|
|
187
284
|
const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
|
|
188
285
|
const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
|
|
189
286
|
const db = await agentDb(ctx.stateDir);
|
|
190
|
-
const previousRow = await db.get('SELECT version, secret_versions_json FROM applied_state WHERE project = ?', desiredState.project);
|
|
191
|
-
const previous = previousRow ? { version: previousRow.version, secretVersions: JSON.parse(previousRow.secret_versions_json) } : {};
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
const
|
|
287
|
+
const previousRow = await db.get('SELECT version, revision, secret_versions_json FROM applied_state WHERE project = ?', desiredState.project);
|
|
288
|
+
const previous = previousRow ? { version: previousRow.version, revision: previousRow.revision, secretVersions: JSON.parse(previousRow.secret_versions_json) } : {};
|
|
289
|
+
const rolling = serviceNames.filter(service => isRolling(desiredState.compose.services[service]));
|
|
290
|
+
const rollingSet = new Set(rolling);
|
|
291
|
+
const mainServices = serviceNames.filter(service => !rollingSet.has(service));
|
|
292
|
+
const secretChangedServices = mainServices.filter(service => JSON.stringify(previous.secretVersions?.[service] || {}) !== JSON.stringify(currentSecretVersions[service] || {}));
|
|
293
|
+
const sameDesiredVersion = desiredState.revision ? previous.revision === desiredState.revision : previous.version === desiredState.version;
|
|
294
|
+
// Rolling services run in their own revision projects; the main project holds only the recreated ones.
|
|
295
|
+
const nodeCompose = composeForNode(desiredState, ctx.nodeName, sec.secrets || {}, endpoints, rollingSet);
|
|
195
296
|
await fs.writeFile(path.join(dir, 'docker-compose.yml'), dumpYaml(nodeCompose));
|
|
196
297
|
await writeEnv(path.join(dir, '.env'), sec.secrets || {});
|
|
298
|
+
const failedStatus = (error) => ({ project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'failed', services: serviceNames, serviceStates, error });
|
|
299
|
+
// Lifecycle events say what changed, not that the agent is still polling. They are sent when the desired revision,
|
|
300
|
+
// this node's rendered configuration or the secrets change, when an apply fails (once) and when it recovers (once).
|
|
301
|
+
const eventFingerprint = createHash('sha256').update(JSON.stringify([desiredState.revision || desiredState.version, dumpYaml(nodeCompose), mainServices.map(service => currentSecretVersions[service] || {})])).digest('hex').slice(0, 16);
|
|
302
|
+
const knownEvents = mainServices.length ? await db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project) : undefined;
|
|
303
|
+
const announce = knownEvents?.fingerprint !== eventFingerprint;
|
|
304
|
+
const wasFailed = !announce && knownEvents?.phase === 'failed';
|
|
305
|
+
const markEvents = (phase) => db.run('INSERT INTO event_state(project, fingerprint, phase) VALUES(?,?,?) ON CONFLICT(project) DO UPDATE SET fingerprint=excluded.fingerprint, phase=excluded.phase', desiredState.project, eventFingerprint, phase);
|
|
306
|
+
let failing;
|
|
197
307
|
if (ctx.apply) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
308
|
+
try {
|
|
309
|
+
for (const service of mainServices) {
|
|
310
|
+
serviceStates[service].rolloutState = 'pulling';
|
|
311
|
+
if (announce)
|
|
312
|
+
await emit(service, 'pulling');
|
|
313
|
+
}
|
|
314
|
+
for (const service of mainServices) {
|
|
315
|
+
const artifact = desiredState.services[service].artifact;
|
|
316
|
+
if (artifact)
|
|
317
|
+
await ensureArtifact(ctx, artifact);
|
|
318
|
+
}
|
|
319
|
+
const pullServices = mainServices.filter(service => !desiredState.services[service].artifact);
|
|
320
|
+
if (pullServices.length)
|
|
321
|
+
await ctx.docker(['compose', '-p', desiredState.project, 'pull', ...(pullServices.length === mainServices.length ? [] : pullServices)], { cwd: dir });
|
|
322
|
+
for (const service of mainServices) {
|
|
323
|
+
serviceStates[service].rolloutState = 'starting';
|
|
324
|
+
if (announce)
|
|
325
|
+
await emit(service, 'starting');
|
|
326
|
+
}
|
|
327
|
+
if (mainServices.length) {
|
|
328
|
+
// Services that just became rolling still run here until their replacement takes over; keep them.
|
|
329
|
+
const keepLegacy = rolling.length > 0 && await hasLegacyContainers(ctx, desiredState.project, rolling);
|
|
330
|
+
await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', ...(keepLegacy ? [] : ['--remove-orphans'])], { cwd: dir });
|
|
331
|
+
}
|
|
332
|
+
if (sameDesiredVersion && secretChangedServices.length) {
|
|
333
|
+
for (const service of secretChangedServices)
|
|
334
|
+
await emit(service, 'secret_recreate', 'secret version changed; recreating service');
|
|
335
|
+
await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', '--no-deps', '--force-recreate', ...secretChangedServices], { cwd: dir });
|
|
336
|
+
}
|
|
337
|
+
for (const service of mainServices) {
|
|
338
|
+
serviceStates[service].rolloutState = 'health_checking';
|
|
339
|
+
if (announce)
|
|
340
|
+
await emit(service, 'health_checking');
|
|
341
|
+
failing = service;
|
|
342
|
+
await waitForHealth(ctx, desiredState.project, service, desiredState.services[service]?.health, dir);
|
|
343
|
+
failing = undefined;
|
|
344
|
+
serviceStates[service].rolloutState = 'serving';
|
|
345
|
+
serviceStates[service].healthyAt = now();
|
|
346
|
+
if (announce || wasFailed)
|
|
347
|
+
await emit(service, 'serving', wasFailed ? 'serving again after a failure' : 'serving');
|
|
348
|
+
}
|
|
349
|
+
if (mainServices.length && (announce || wasFailed))
|
|
350
|
+
await markEvents('serving');
|
|
206
351
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
await emit(
|
|
211
|
-
|
|
352
|
+
catch (e) {
|
|
353
|
+
if (mainServices.length && !wasFailed) {
|
|
354
|
+
// Announce a failure once; retries of the same failure stay quiet until it recovers or the inputs change.
|
|
355
|
+
await emit(failing ?? mainServices.join(', '), 'failed', errMessage(e), 'error');
|
|
356
|
+
await markEvents('failed');
|
|
357
|
+
}
|
|
358
|
+
// Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
|
|
359
|
+
if (!rolling.length)
|
|
360
|
+
throw e;
|
|
361
|
+
Object.assign(serviceStates, await activeRollingStates(dir, rolling));
|
|
362
|
+
return failedStatus(errMessage(e));
|
|
212
363
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
364
|
+
if (rolling.length) {
|
|
365
|
+
const rolled = await reconcileRolling(ctx, {
|
|
366
|
+
desired: desiredState, dir, services: rolling, secretVersions: currentSecretVersions,
|
|
367
|
+
full: composeForNode(desiredState, ctx.nodeName, sec.secrets || {}, endpoints),
|
|
368
|
+
deps: {
|
|
369
|
+
waitForHealth: (project, service, composeFile) => waitForHealth(ctx, project, service, desiredState.services[service]?.health, dir, composeFile),
|
|
370
|
+
ensureArtifact: async (service) => { const artifact = desiredState.services[service].artifact; if (artifact)
|
|
371
|
+
await ensureArtifact(ctx, artifact); },
|
|
372
|
+
emit
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
Object.assign(serviceStates, rolled.serviceStates);
|
|
376
|
+
const errors = Object.entries(rolled.errors);
|
|
377
|
+
if (errors.length)
|
|
378
|
+
return failedStatus(errors.map(([service, error]) => `${service}: ${error}`).join('; '));
|
|
220
379
|
}
|
|
380
|
+
else
|
|
381
|
+
await retireAll(ctx, dir, desiredState);
|
|
221
382
|
}
|
|
222
383
|
else {
|
|
223
384
|
for (const service of serviceNames)
|
|
224
385
|
serviceStates[service].rolloutState = 'serving';
|
|
225
386
|
}
|
|
226
|
-
await db.run(`INSERT INTO applied_state(project, version, applied_at, service_states_json, secret_versions_json)
|
|
227
|
-
VALUES(
|
|
228
|
-
ON CONFLICT(project) DO UPDATE SET version=excluded.version, applied_at=excluded.applied_at,
|
|
229
|
-
service_states_json=excluded.service_states_json, secret_versions_json=excluded.secret_versions_json`, desiredState.project, desiredState.version, now(), JSON.stringify(serviceStates), JSON.stringify(currentSecretVersions));
|
|
230
|
-
return { project: desiredState.project, observedVersion: desiredState.version, health: 'serving', services: serviceNames, serviceStates };
|
|
387
|
+
await db.run(`INSERT INTO applied_state(project, version, revision, applied_at, service_states_json, secret_versions_json)
|
|
388
|
+
VALUES(?,?,?,?,?,?)
|
|
389
|
+
ON CONFLICT(project) DO UPDATE SET version=excluded.version, revision=excluded.revision, applied_at=excluded.applied_at,
|
|
390
|
+
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));
|
|
391
|
+
return { project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'serving', services: serviceNames, serviceStates };
|
|
231
392
|
}
|
|
232
393
|
/** Reports this node's WireGuard public key when control has not seen the current one yet. */
|
|
233
394
|
export async function reportWgKey(ctx) {
|
|
@@ -265,7 +426,7 @@ export async function pollOnce(ctx = defaultContext()) {
|
|
|
265
426
|
projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project]));
|
|
266
427
|
}
|
|
267
428
|
catch (e) {
|
|
268
|
-
projects.push({ project: desired.project, observedVersion: desired.version, health: 'failed', error: errMessage(e) });
|
|
429
|
+
projects.push({ project: desired.project, observedVersion: desired.version, ...(desired.revision ? { observedRevision: desired.revision } : {}), health: 'failed', error: errMessage(e) });
|
|
269
430
|
}
|
|
270
431
|
}
|
|
271
432
|
let ingress;
|
|
@@ -288,6 +449,8 @@ export async function pollOnce(ctx = defaultContext()) {
|
|
|
288
449
|
const report = { node: ctx.nodeName, projects, commandResults };
|
|
289
450
|
if (ingress)
|
|
290
451
|
report.ingress = ingress;
|
|
452
|
+
if (work.retainedImages && projects.every(project => project.health !== 'failed'))
|
|
453
|
+
await pruneManagedImages(ctx, work.retainedImages);
|
|
291
454
|
await api(ctx, '/agent/v1/status', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(report) });
|
|
292
455
|
return projects;
|
|
293
456
|
}
|
package/dist/archive.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import * as tar from 'tar';
|
|
3
|
+
export async function scanArchive(file, inspect) {
|
|
4
|
+
await new Promise((resolve, reject) => {
|
|
5
|
+
const input = createReadStream(file);
|
|
6
|
+
const parser = new tar.Parser({ strict: true, onReadEntry(entry) {
|
|
7
|
+
try {
|
|
8
|
+
inspect(entry);
|
|
9
|
+
entry.resume();
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
parser.abort(error);
|
|
13
|
+
}
|
|
14
|
+
} });
|
|
15
|
+
input.once('error', reject);
|
|
16
|
+
parser.once('error', error => { input.destroy(); reject(error); });
|
|
17
|
+
parser.once('end', resolve);
|
|
18
|
+
input.pipe(parser);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Transform } from 'node:stream';
|
|
2
|
+
import type { RunResult } from './build.ts';
|
|
3
|
+
export type ImageRunner = (cmd: string, args: string[]) => Promise<RunResult>;
|
|
4
|
+
export interface ImageInfo {
|
|
5
|
+
imageId: string;
|
|
6
|
+
platform: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ArchiveInfo extends ImageInfo {
|
|
9
|
+
sha256: string;
|
|
10
|
+
sizeBytes: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function inspectImage(imageRef: string, runner?: ImageRunner): Promise<ImageInfo>;
|
|
13
|
+
export declare function hashArchive(file: string): Promise<{
|
|
14
|
+
sha256: string;
|
|
15
|
+
sizeBytes: number;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function saveImageArchive(imageRef: string, file: string): Promise<ArchiveInfo>;
|
|
18
|
+
/** Validate Docker save metadata before Docker can import attacker-selected repository tags. */
|
|
19
|
+
export declare function validateImageArchive(file: string, expectedRef: string, maxExpandedBytes?: number, requireManagedReference?: boolean): Promise<ArchiveInfo>;
|
|
20
|
+
export declare function verifyDownload(expected: {
|
|
21
|
+
sha256: string;
|
|
22
|
+
sizeBytes: number;
|
|
23
|
+
}): Transform;
|