@wpelevator/tug 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +63 -0
  2. package/dist/agent.d.ts +40 -0
  3. package/dist/agent.js +547 -0
  4. package/dist/archive.d.ts +2 -0
  5. package/dist/archive.js +20 -0
  6. package/dist/artifacts.d.ts +23 -0
  7. package/dist/artifacts.js +238 -0
  8. package/dist/build.d.ts +22 -0
  9. package/dist/build.js +44 -0
  10. package/dist/cli-build.d.ts +15 -0
  11. package/dist/cli-build.js +50 -0
  12. package/dist/cli-config.d.ts +20 -0
  13. package/dist/cli-config.js +47 -0
  14. package/dist/cli-install.d.ts +7 -0
  15. package/dist/cli-install.js +31 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +422 -0
  18. package/dist/compose.d.ts +33 -0
  19. package/dist/compose.js +260 -0
  20. package/dist/control.d.ts +24 -0
  21. package/dist/control.js +1105 -0
  22. package/dist/db.d.ts +117 -0
  23. package/dist/db.js +51 -0
  24. package/dist/image.d.ts +9 -0
  25. package/dist/image.js +20 -0
  26. package/dist/ingress.d.ts +50 -0
  27. package/dist/ingress.js +180 -0
  28. package/dist/main.d.ts +2 -0
  29. package/dist/main.js +22 -0
  30. package/dist/mesh.d.ts +70 -0
  31. package/dist/mesh.js +183 -0
  32. package/dist/protocol.d.ts +326 -0
  33. package/dist/protocol.js +8 -0
  34. package/dist/rollout.d.ts +72 -0
  35. package/dist/rollout.js +319 -0
  36. package/dist/runners.d.ts +12 -0
  37. package/dist/runners.js +10 -0
  38. package/dist/secrets.d.ts +3 -0
  39. package/dist/secrets.js +29 -0
  40. package/dist/templates/agent.env +6 -0
  41. package/dist/templates/control-compose.yml +18 -0
  42. package/dist/templates/control-install.sh +7 -0
  43. package/dist/templates/install-docker.sh +85 -0
  44. package/dist/templates/node-install.sh +8 -0
  45. package/dist/tokens.d.ts +91 -0
  46. package/dist/tokens.js +165 -0
  47. package/dist/util.d.ts +15 -0
  48. package/dist/util.js +35 -0
  49. package/package.json +62 -3
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # Tug
2
+
3
+ The name: tugboats pull ships into place, like the pull-based agents here; `tug` is the CLI.
4
+
5
+ Deploy Docker Compose projects to one or more ordinary Linux VPSes through a small self-hosted control plane and a pull-based agent on each node, without Kubernetes or a hosted PaaS. The original specification is in [plan.md](plan.md).
6
+
7
+ ## Features
8
+
9
+ - Control API with bearer-token auth, scoped `read`/`deploy`/`admin` API tokens for CI and teammates, an audit trail naming who acted, and SQLite metadata.
10
+ - Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` (`node-enroll`), with per-node agent and registry tokens. Agents only dial out, so nodes behind NAT work.
11
+ - Agents pull desired state from control, apply a generated per-node Compose file, run TCP/HTTP/Docker health checks and report status.
12
+ - `x-tug` placement of services and volumes on nodes, with immutable desired-state records per deployment.
13
+ - Build application images on control from uploaded source, or locally with `deploy --build-local`. Agents download saved images directly from control by default; a bring-your-own registry is optional.
14
+ - Secrets encrypted at rest, released only to the nodes whose services reference them, with targeted service recreation when only a secret changes.
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).
17
+ - Per-node registry pull tokens enforced by an htpasswd-backed registry you provide, with rotation and revocation.
18
+ - Optional WireGuard mesh between nodes, health-aware cross-node service names and a managed Caddy ingress with automatic HTTPS, each opt-in. See [Networking](docs/networking.md).
19
+ - Control-plane backup and restore, and `tug doctor` diagnostics.
20
+
21
+ ## Quick start
22
+
23
+ Install the CLI (Node >= 22.18), or run it without installing through `npx @wpelevator/tug`:
24
+
25
+ ```bash
26
+ npm install -g @wpelevator/tug
27
+ ```
28
+
29
+ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts.
30
+
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).
32
+ 2. Point the CLI at it: `tug init --control-url https://control.example.com --token "$TUG_TOKEN"`.
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.
35
+ 5. Deploy: `tug deploy -f docker-compose.yml`. It waits until every node reports the new version healthy.
36
+ 6. Operate with `tug status`, `events`, `logs`, `restart`, `rollback` and `doctor`.
37
+
38
+ ## Documentation
39
+
40
+ - [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
41
+ - [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
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.
44
+ - [Networking](docs/networking.md): WireGuard mesh, service DNS, ingress and TLS, firewall notes.
45
+ - [Nodes](docs/nodes.md): SSH install, self-service enrollment, registration and status.
46
+ - [CLI reference](docs/cli.md): every `tug` command and option.
47
+ - [Configuration](docs/configuration.md): CLI, control and agent environment variables.
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
+ - [Releases](docs/releases.md): changesets, npm and Docker image publishing.
51
+ - [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution.
52
+ - [Roadmap](docs/roadmap.md): planned milestones toward parity with and beyond uncloud.
53
+
54
+ ## Development
55
+
56
+ ```bash
57
+ npm install
58
+ npm test
59
+ npm run lint
60
+ npm run test:integration
61
+ ```
62
+
63
+ See [Development](docs/development.md) for details.
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ import { parseDuration } from './util.ts';
3
+ import type { DockerRunner, HostRunner, HttpClient } from './runners.ts';
4
+ import type { Command, CommandResult, DesiredState, HealthCheck, ProjectStatus, ImageArtifact, ServiceEndpoint } from './protocol.ts';
5
+ export interface AgentContext {
6
+ docker: DockerRunner;
7
+ http: HttpClient;
8
+ nodeName: string;
9
+ stateDir: string;
10
+ apply: boolean;
11
+ controlUrl: string;
12
+ token: string | undefined;
13
+ /** Runs `wg` / `ip` for the mesh. */
14
+ host?: HostRunner;
15
+ /** Manage the WireGuard mesh (generate a key, report it, reconcile `wg0`). */
16
+ mesh?: boolean;
17
+ caddyImage?: string;
18
+ /** Host port range (`LOW-HIGH`) for rolling-rollout revisions; default 20000-20999. */
19
+ rolloutPorts?: string;
20
+ }
21
+ export declare function defaultContext(): AgentContext;
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 };
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>;
28
+ export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>): Promise<ProjectStatus>;
29
+ /** Reports this node's WireGuard public key when control has not seen the current one yet. */
30
+ export declare function reportWgKey(ctx: AgentContext): Promise<void>;
31
+ export declare function pollOnce(ctx?: AgentContext): Promise<ProjectStatus[]>;
32
+ interface Creds {
33
+ agentToken: string;
34
+ nodeName?: string;
35
+ controlUrl?: string;
36
+ registry?: string | null;
37
+ registryToken?: string | null;
38
+ }
39
+ export declare function loadOrEnroll(): Promise<Creds>;
40
+ export declare function main(): Promise<void>;
package/dist/agent.js ADDED
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env node
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";
7
+ import path from 'node:path';
8
+ import { spawn, spawnSync } from 'node:child_process';
9
+ import net from 'node:net';
10
+ import { composeForNode, dumpYaml, isRolling, serviceSecretVersions } from "./compose.js";
11
+ import { activeRollingStates, composeBase, hasLegacyContainers, reconcileRolling, retireAll } from "./rollout.js";
12
+ import { ensureDir, errMessage, now, parseDuration } from "./util.js";
13
+ import { openAgentDb } from "./db.js";
14
+ import { createDockerRunner, createHostRunner } from "./runners.js";
15
+ import { CADDY_CONTAINER, reconcileIngress, removeIngress } from "./ingress.js";
16
+ import { WG_INTERFACE, ensureWgKeys, readReportedKey, reconcileMesh, rotateWgKeys, writeReportedKey } from "./mesh.js";
17
+ const CONTROL_URL_FROM_ENV = Boolean(process.env.TUG_CONTROL_URL);
18
+ let CONTROL_URL = process.env.TUG_CONTROL_URL || 'http://localhost:8080';
19
+ let TOKEN = process.env.TUG_AGENT_TOKEN;
20
+ let NODE_NAME = process.env.TUG_NODE_NAME || process.env.HOSTNAME || 'node1';
21
+ const STATE_DIR = process.env.TUG_STATE_DIR || '/var/lib/tug';
22
+ const APPLY = process.env.TUG_APPLY !== 'false';
23
+ const INTERVAL = Number(process.env.TUG_POLL_INTERVAL_MS || 5000);
24
+ // Opt-in WireGuard mesh: needs `wg`/`ip` in the agent image, host networking and NET_ADMIN.
25
+ const MESH = process.env.TUG_MESH === 'true';
26
+ export function defaultContext() {
27
+ 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 };
28
+ }
29
+ const dbsByStateDir = new Map();
30
+ async function agentDb(stateDir) {
31
+ let db = dbsByStateDir.get(stateDir);
32
+ if (!db) {
33
+ await ensureDir(stateDir);
34
+ db = await openAgentDb(path.join(stateDir, 'agent.db'));
35
+ dbsByStateDir.set(stateDir, db);
36
+ }
37
+ return db;
38
+ }
39
+ function authHeaders(ctx) { if (!ctx.token)
40
+ throw new Error('TUG_AGENT_TOKEN required'); return { Authorization: `Bearer ${ctx.token}` }; }
41
+ async function api(ctx, route, opts = {}) {
42
+ const res = await ctx.http(`${ctx.controlUrl}${route}`, { ...opts, headers: { ...authHeaders(ctx), ...(opts.headers || {}) } });
43
+ if (!res.ok)
44
+ throw new Error(`${opts.method || 'GET'} ${route}: ${res.status} ${await res.text()}`);
45
+ return res.json();
46
+ }
47
+ async function writeEnv(file, secrets) {
48
+ const body = Object.entries(secrets).map(([k, v]) => `${k}=${String(v).replace(/\n/g, '\\n')}`).join('\n') + '\n';
49
+ await fs.writeFile(file, body, { mode: 0o600 });
50
+ }
51
+ /** `docker compose` prefix for a project; rolling revision projects pass their own Compose file. */
52
+ const composeCmd = (project, cwd, composeFile) => composeFile ? composeBase(project, cwd, composeFile) : ['compose', '-p', project];
53
+ export async function containerIp(ctx, project, service, cwd, composeFile) {
54
+ const ids = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out.trim().split(/\s+/).filter(Boolean);
55
+ if (!ids.length)
56
+ throw new Error(`${service}: no container found`);
57
+ const inspected = JSON.parse((await ctx.docker(['inspect', ids[0]], { cwd })).out)[0];
58
+ const networks = inspected?.NetworkSettings?.Networks || {};
59
+ return Object.values(networks)[0]?.IPAddress || '127.0.0.1';
60
+ }
61
+ function tcpCheck(host, port, timeoutMs = 2000) {
62
+ return new Promise((resolve, reject) => {
63
+ const s = net.createConnection({ host, port: Number(port), timeout: timeoutMs }, () => { s.destroy(); resolve(true); });
64
+ s.on('timeout', () => { s.destroy(); reject(new Error(`tcp timeout ${host}:${port}`)); });
65
+ s.on('error', reject);
66
+ });
67
+ }
68
+ export async function waitForHealth(ctx, project, service, health, cwd, composeFile) {
69
+ if (!ctx.apply || !health)
70
+ return;
71
+ const retries = Number(health.retries || 10);
72
+ const intervalMs = parseDuration(health.interval || '2s');
73
+ let last;
74
+ for (let i = 0; i < retries; i++) {
75
+ try {
76
+ if (health.type === 'docker') {
77
+ const id = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out.trim().split(/\s+/)[0];
78
+ const state = JSON.parse((await ctx.docker(['inspect', id], { cwd })).out)[0]?.State;
79
+ if (!state?.Running)
80
+ throw new Error(`${service}: not running`);
81
+ if (state.Health && state.Health.Status !== 'healthy')
82
+ throw new Error(`${service}: docker health ${state.Health.Status}`);
83
+ }
84
+ else if (health.type === 'tcp') {
85
+ await tcpCheck(await containerIp(ctx, project, service, cwd, composeFile), health.port, parseDuration(health.timeout || '2s'));
86
+ }
87
+ else if (health.type === 'http') {
88
+ const ip = await containerIp(ctx, project, service, cwd, composeFile);
89
+ const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, { signal: AbortSignal.timeout(parseDuration(health.timeout || '2s')) });
90
+ if (!res.ok)
91
+ throw new Error(`${service}: http ${res.status}`);
92
+ }
93
+ return;
94
+ }
95
+ catch (e) {
96
+ last = e;
97
+ await new Promise(r => setTimeout(r, intervalMs));
98
+ }
99
+ }
100
+ throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}`);
101
+ }
102
+ /** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
103
+ function describeError(error) {
104
+ const e = error;
105
+ const cause = e?.cause?.code || e?.cause?.message;
106
+ return cause ? `${e?.message ?? 'error'} (${cause})` : (e?.message ?? 'no response');
107
+ }
108
+ function describeHealthCheck(health) {
109
+ if (health.type === 'http')
110
+ return `http health check on port ${health.port || 80}${health.path || '/'}`;
111
+ if (health.type === 'tcp')
112
+ return `tcp health check on port ${health.port}`;
113
+ return 'docker health check';
114
+ }
115
+ export { parseDuration };
116
+ export async function executeCommand(command, ctx = defaultContext()) {
117
+ const payload = typeof command.payload_json === 'string' ? JSON.parse(command.payload_json || '{}') : command.payload || {};
118
+ if (command.type === 'diagnostics.run') {
119
+ const checks = [];
120
+ for (const [name, args] of [
121
+ ['docker.info', ['info']],
122
+ ['docker.version', ['version']],
123
+ ['docker.compose.version', ['compose', 'version']]
124
+ ]) {
125
+ try {
126
+ const result = await ctx.docker([...args]);
127
+ checks.push({ name, ok: true, stdout: result.out.slice(-4000), stderr: result.err.slice(-4000) });
128
+ }
129
+ catch (e) {
130
+ checks.push({ name, ok: false, error: errMessage(e).slice(-4000) });
131
+ }
132
+ }
133
+ const extra = [];
134
+ if (ctx.mesh && ctx.host) {
135
+ const host = ctx.host;
136
+ extra.push(['wireguard.version', () => host('wg', ['--version'])], ['wireguard.interface', () => host('wg', ['show', WG_INTERFACE])]);
137
+ }
138
+ if (await fs.stat(path.join(ctx.stateDir, 'ingress')).then(() => true, () => false)) {
139
+ extra.push(['ingress.caddy', () => ctx.docker(['container', 'inspect', '-f', '{{.State.Status}}', CADDY_CONTAINER])]);
140
+ }
141
+ for (const [name, fn] of extra) {
142
+ try {
143
+ const result = await fn();
144
+ checks.push({ name, ok: true, stdout: result.out.slice(-4000), stderr: result.err.slice(-4000) });
145
+ }
146
+ catch (e) {
147
+ checks.push({ name, ok: false, error: errMessage(e).slice(-4000) });
148
+ }
149
+ }
150
+ return { commandId: command.id, state: checks.every(c => c.ok) ? 'succeeded' : 'failed', result: { checks, node: ctx.nodeName } };
151
+ }
152
+ if (command.type === 'service.restart') {
153
+ const project = payload.project;
154
+ const service = payload.service;
155
+ const dir = path.join(ctx.stateDir, 'projects', project);
156
+ await ctx.docker(['compose', '-p', project, 'restart', service], { cwd: dir });
157
+ return { commandId: command.id, state: 'succeeded', result: { project, service, restarted: true } };
158
+ }
159
+ if (command.type === 'logs.fetch') {
160
+ const project = payload.project;
161
+ const service = payload.service;
162
+ const tail = String(payload.tail || 200);
163
+ const dir = path.join(ctx.stateDir, 'projects', project);
164
+ const result = await ctx.docker(['compose', '-p', project, 'logs', '--no-color', '--tail', tail, service], { cwd: dir });
165
+ return { commandId: command.id, state: 'succeeded', result: { stdout: result.out, stderr: result.err } };
166
+ }
167
+ if (command.type === 'volume.delete') {
168
+ const project = payload.project;
169
+ const volume = payload.volume;
170
+ const composeVolume = `${project}_${volume}`;
171
+ try {
172
+ await ctx.docker(['volume', 'rm', composeVolume]);
173
+ }
174
+ catch (e) {
175
+ if (!errMessage(e).includes('No such volume'))
176
+ throw e;
177
+ }
178
+ return { commandId: command.id, state: 'succeeded', result: { project, volume, deleted: true } };
179
+ }
180
+ if (command.type === 'wg.rotate-key') {
181
+ if (!ctx.mesh)
182
+ throw new Error('the WireGuard mesh is not enabled on this agent (TUG_MESH=true)');
183
+ const { publicKey } = await rotateWgKeys(ctx.stateDir);
184
+ await api(ctx, '/agent/v1/wireguard/key', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ publicKey }) });
185
+ await writeReportedKey(ctx.stateDir, publicKey);
186
+ return { commandId: command.id, state: 'succeeded', result: { publicKey } };
187
+ }
188
+ return { commandId: command.id, state: 'failed', result: { error: `unsupported command ${command.type}` } };
189
+ }
190
+ async function managedImageFile(ctx) { await ensureDir(ctx.stateDir); return path.join(ctx.stateDir, 'managed-images.json'); }
191
+ async function managedImages(ctx) {
192
+ try {
193
+ const refs = JSON.parse(await fs.readFile(await managedImageFile(ctx), 'utf8'));
194
+ return Array.isArray(refs) ? refs.filter((ref) => typeof ref === 'string' && ref.startsWith('tug-build/')) : [];
195
+ }
196
+ catch {
197
+ return [];
198
+ }
199
+ }
200
+ export async function ensureArtifact(ctx, artifact) {
201
+ const identityFile = path.join(ctx.stateDir, 'image-identities.json');
202
+ let identities = {};
203
+ try {
204
+ identities = JSON.parse(await fs.readFile(identityFile, 'utf8'));
205
+ }
206
+ catch { }
207
+ const remember = async (daemonId) => {
208
+ identities[artifact.imageRef] = { daemonId, artifactId: artifact.imageId, sha256: artifact.sha256 };
209
+ await fs.writeFile(identityFile, JSON.stringify(identities), { mode: 0o600 });
210
+ const refs = await managedImages(ctx);
211
+ if (!refs.includes(artifact.imageRef))
212
+ await fs.writeFile(await managedImageFile(ctx), JSON.stringify([...refs, artifact.imageRef]), { mode: 0o600 });
213
+ };
214
+ const inspect = async () => JSON.parse((await ctx.docker(['image', 'inspect', artifact.imageRef])).out)[0];
215
+ try {
216
+ const image = await inspect();
217
+ 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) {
218
+ await remember(image.Id);
219
+ return;
220
+ }
221
+ }
222
+ catch { }
223
+ const host = JSON.parse((await ctx.docker(['info', '--format', '{{json .}}'])).out);
224
+ const arch = { x86_64: 'amd64', aarch64: 'arm64' }[host.Architecture] || host.Architecture;
225
+ if (host.OSType && host.Architecture && `${host.OSType}/${arch}` !== artifact.platform)
226
+ throw new Error(`image platform ${artifact.platform} does not match node ${host.OSType}/${arch}; build for the target platform`);
227
+ const downloadDir = await fs.mkdtemp(path.join(ctx.stateDir, 'image-download-'));
228
+ const file = path.join(downloadDir, 'image.tar.gz');
229
+ try {
230
+ const response = await ctx.http(`${ctx.controlUrl}/agent/v1/images/${encodeURIComponent(artifact.buildId)}`, { headers: authHeaders(ctx), redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000) });
231
+ if (!response.ok || !response.body)
232
+ throw new Error(`image download failed: HTTP ${response.status}`);
233
+ await pipeline(Readable.fromWeb(response.body), verifyDownload(artifact), createWriteStream(file, { mode: 0o600 }));
234
+ const archive = await validateImageArchive(file, artifact.imageRef);
235
+ if (archive.imageId !== artifact.imageId || archive.platform !== artifact.platform)
236
+ throw new Error('downloaded archive image metadata differs from desired artifact');
237
+ await ctx.docker(['load', '-i', file]);
238
+ const image = await inspect();
239
+ if (!image?.Id || `${image.Os}/${image.Architecture}` !== artifact.platform)
240
+ throw new Error('loaded image identity or platform does not match desired artifact');
241
+ await remember(image.Id);
242
+ }
243
+ finally {
244
+ await fs.rm(downloadDir, { recursive: true, force: true });
245
+ }
246
+ }
247
+ export async function pruneManagedImages(ctx, retained) {
248
+ if (!ctx.apply)
249
+ return;
250
+ const keep = new Set(retained);
251
+ const remaining = [];
252
+ for (const ref of await managedImages(ctx)) {
253
+ if (keep.has(ref)) {
254
+ remaining.push(ref);
255
+ continue;
256
+ }
257
+ try {
258
+ const used = (await ctx.docker(['ps', '-aq', '--filter', `ancestor=${ref}`])).out.trim();
259
+ if (used) {
260
+ remaining.push(ref);
261
+ continue;
262
+ }
263
+ await ctx.docker(['image', 'rm', ref]);
264
+ }
265
+ catch {
266
+ remaining.push(ref);
267
+ }
268
+ }
269
+ await fs.writeFile(await managedImageFile(ctx), JSON.stringify(remaining), { mode: 0o600 });
270
+ }
271
+ export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}) {
272
+ const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
273
+ await ensureDir(dir);
274
+ const plannedCompose = composeForNode(desiredState, ctx.nodeName, {});
275
+ const serviceNames = Object.keys(plannedCompose.services || {});
276
+ if (!serviceNames.length) {
277
+ if (ctx.apply)
278
+ await retireAll(ctx, dir, desiredState).catch(() => { });
279
+ return { project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'skipped', services: [], serviceStates: {} };
280
+ }
281
+ const serviceStates = Object.fromEntries(serviceNames.map(service => [service, { rolloutState: 'pending', image: desiredState.services[service]?.image }]));
282
+ 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: 'info', type: `service.${rolloutState}`, message, metadata: { service, version: desiredState.version, node: ctx.nodeName } }) }).catch(() => { });
283
+ const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
284
+ const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
285
+ const db = await agentDb(ctx.stateDir);
286
+ const previousRow = await db.get('SELECT version, revision, secret_versions_json FROM applied_state WHERE project = ?', desiredState.project);
287
+ const previous = previousRow ? { version: previousRow.version, revision: previousRow.revision, secretVersions: JSON.parse(previousRow.secret_versions_json) } : {};
288
+ const rolling = serviceNames.filter(service => isRolling(desiredState.compose.services[service]));
289
+ const rollingSet = new Set(rolling);
290
+ const mainServices = serviceNames.filter(service => !rollingSet.has(service));
291
+ const secretChangedServices = mainServices.filter(service => JSON.stringify(previous.secretVersions?.[service] || {}) !== JSON.stringify(currentSecretVersions[service] || {}));
292
+ const sameDesiredVersion = desiredState.revision ? previous.revision === desiredState.revision : previous.version === desiredState.version;
293
+ // Rolling services run in their own revision projects; the main project holds only the recreated ones.
294
+ const nodeCompose = composeForNode(desiredState, ctx.nodeName, sec.secrets || {}, endpoints, rollingSet);
295
+ await fs.writeFile(path.join(dir, 'docker-compose.yml'), dumpYaml(nodeCompose));
296
+ await writeEnv(path.join(dir, '.env'), sec.secrets || {});
297
+ const failedStatus = (error) => ({ project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'failed', services: serviceNames, serviceStates, error });
298
+ if (ctx.apply) {
299
+ try {
300
+ for (const service of mainServices) {
301
+ serviceStates[service].rolloutState = 'pulling';
302
+ await emit(service, 'pulling');
303
+ }
304
+ for (const service of mainServices) {
305
+ const artifact = desiredState.services[service].artifact;
306
+ if (artifact)
307
+ await ensureArtifact(ctx, artifact);
308
+ }
309
+ const pullServices = mainServices.filter(service => !desiredState.services[service].artifact);
310
+ if (pullServices.length)
311
+ await ctx.docker(['compose', '-p', desiredState.project, 'pull', ...(pullServices.length === mainServices.length ? [] : pullServices)], { cwd: dir });
312
+ for (const service of mainServices) {
313
+ serviceStates[service].rolloutState = 'starting';
314
+ await emit(service, 'starting');
315
+ }
316
+ if (mainServices.length) {
317
+ // Services that just became rolling still run here until their replacement takes over; keep them.
318
+ const keepLegacy = rolling.length > 0 && await hasLegacyContainers(ctx, desiredState.project, rolling);
319
+ await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', ...(keepLegacy ? [] : ['--remove-orphans'])], { cwd: dir });
320
+ }
321
+ if (sameDesiredVersion && secretChangedServices.length) {
322
+ for (const service of secretChangedServices)
323
+ await emit(service, 'secret_recreate', 'secret version changed; recreating service');
324
+ await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', '--no-deps', '--force-recreate', ...secretChangedServices], { cwd: dir });
325
+ }
326
+ for (const service of mainServices) {
327
+ serviceStates[service].rolloutState = 'health_checking';
328
+ await emit(service, 'health_checking');
329
+ await waitForHealth(ctx, desiredState.project, service, desiredState.services[service]?.health, dir);
330
+ serviceStates[service].rolloutState = 'serving';
331
+ serviceStates[service].healthyAt = now();
332
+ await emit(service, 'serving');
333
+ }
334
+ }
335
+ catch (e) {
336
+ // Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
337
+ if (!rolling.length)
338
+ throw e;
339
+ Object.assign(serviceStates, await activeRollingStates(dir, rolling));
340
+ return failedStatus(errMessage(e));
341
+ }
342
+ if (rolling.length) {
343
+ const rolled = await reconcileRolling(ctx, {
344
+ desired: desiredState, dir, services: rolling, secretVersions: currentSecretVersions,
345
+ full: composeForNode(desiredState, ctx.nodeName, sec.secrets || {}, endpoints),
346
+ deps: {
347
+ waitForHealth: (project, service, composeFile) => waitForHealth(ctx, project, service, desiredState.services[service]?.health, dir, composeFile),
348
+ ensureArtifact: async (service) => { const artifact = desiredState.services[service].artifact; if (artifact)
349
+ await ensureArtifact(ctx, artifact); },
350
+ emit
351
+ }
352
+ });
353
+ Object.assign(serviceStates, rolled.serviceStates);
354
+ const errors = Object.entries(rolled.errors);
355
+ if (errors.length)
356
+ return failedStatus(errors.map(([service, error]) => `${service}: ${error}`).join('; '));
357
+ }
358
+ else
359
+ await retireAll(ctx, dir, desiredState);
360
+ }
361
+ else {
362
+ for (const service of serviceNames)
363
+ serviceStates[service].rolloutState = 'serving';
364
+ }
365
+ await db.run(`INSERT INTO applied_state(project, version, revision, applied_at, service_states_json, secret_versions_json)
366
+ VALUES(?,?,?,?,?,?)
367
+ ON CONFLICT(project) DO UPDATE SET version=excluded.version, revision=excluded.revision, applied_at=excluded.applied_at,
368
+ 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));
369
+ return { project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'serving', services: serviceNames, serviceStates };
370
+ }
371
+ /** Reports this node's WireGuard public key when control has not seen the current one yet. */
372
+ export async function reportWgKey(ctx) {
373
+ const { publicKey } = await ensureWgKeys(ctx.stateDir);
374
+ if (await readReportedKey(ctx.stateDir) === publicKey)
375
+ return;
376
+ await api(ctx, '/agent/v1/wireguard/key', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ publicKey }) });
377
+ await writeReportedKey(ctx.stateDir, publicKey);
378
+ }
379
+ let lastNetworkError = '';
380
+ async function networkStep(label, fn) {
381
+ try {
382
+ await fn();
383
+ lastNetworkError = '';
384
+ }
385
+ catch (e) {
386
+ const message = `${label}: ${errMessage(e)}`;
387
+ if (message !== lastNetworkError)
388
+ console.error(message);
389
+ lastNetworkError = message;
390
+ }
391
+ }
392
+ export async function pollOnce(ctx = defaultContext()) {
393
+ if (ctx.mesh)
394
+ await networkStep('wireguard key report failed', () => reportWgKey(ctx));
395
+ const work = await api(ctx, '/agent/v1/work');
396
+ // The mesh comes up first so cross-node service endpoints are routable when containers start.
397
+ if (ctx.mesh && work.network) {
398
+ const network = work.network;
399
+ await networkStep('wireguard reconcile failed', () => reconcileMesh(ctx.host ?? createHostRunner(), ctx.stateDir, network));
400
+ }
401
+ const projects = [];
402
+ for (const desired of work.desiredStates || (work.desiredState ? [work.desiredState] : [])) {
403
+ try {
404
+ projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project]));
405
+ }
406
+ catch (e) {
407
+ projects.push({ project: desired.project, observedVersion: desired.version, ...(desired.revision ? { observedRevision: desired.revision } : {}), health: 'failed', error: errMessage(e) });
408
+ }
409
+ }
410
+ let ingress;
411
+ if (ctx.apply) {
412
+ const ingressCtx = { docker: ctx.docker, stateDir: ctx.stateDir, caddyImage: ctx.caddyImage };
413
+ if (work.ingress)
414
+ ingress = await reconcileIngress(ingressCtx, work.ingress);
415
+ else
416
+ await removeIngress(ingressCtx).catch(() => false);
417
+ }
418
+ const commandResults = [];
419
+ for (const command of work.commands || []) {
420
+ try {
421
+ commandResults.push(await executeCommand(command, ctx));
422
+ }
423
+ catch (e) {
424
+ commandResults.push({ commandId: command.id, state: 'failed', result: { error: errMessage(e) } });
425
+ }
426
+ }
427
+ const report = { node: ctx.nodeName, projects, commandResults };
428
+ if (ingress)
429
+ report.ingress = ingress;
430
+ if (work.retainedImages && projects.every(project => project.health !== 'failed'))
431
+ await pruneManagedImages(ctx, work.retainedImages);
432
+ await api(ctx, '/agent/v1/status', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(report) });
433
+ return projects;
434
+ }
435
+ const CREDS_FILE = () => path.join(STATE_DIR, 'agent.json');
436
+ async function loadCreds() {
437
+ try {
438
+ return JSON.parse(await fs.readFile(CREDS_FILE(), 'utf8'));
439
+ }
440
+ catch {
441
+ return null;
442
+ }
443
+ }
444
+ async function saveCreds(creds) {
445
+ await ensureDir(STATE_DIR);
446
+ await fs.writeFile(CREDS_FILE(), JSON.stringify(creds), { mode: 0o600 });
447
+ }
448
+ async function readJoinKey() {
449
+ if (process.env.TUG_JOIN_KEY)
450
+ return process.env.TUG_JOIN_KEY.trim();
451
+ const file = process.env.TUG_JOIN_KEY_FILE;
452
+ if (file) {
453
+ try {
454
+ return (await fs.readFile(file, 'utf8')).trim();
455
+ }
456
+ catch (e) {
457
+ throw new Error(`cannot read TUG_JOIN_KEY_FILE ${file}: ${errMessage(e)}`, { cause: e });
458
+ }
459
+ }
460
+ return null;
461
+ }
462
+ function dockerLogin(registry, user, password) {
463
+ return new Promise((resolve, reject) => {
464
+ const p = spawn('docker', ['login', registry, '-u', user, '--password-stdin'], { stdio: ['pipe', 'ignore', 'pipe'] });
465
+ let err = '';
466
+ p.stderr.on('data', d => err += d);
467
+ p.on('error', reject);
468
+ p.on('close', code => code === 0 ? resolve() : reject(new Error(err.trim() || `exit ${code}`)));
469
+ p.stdin.write(password);
470
+ p.stdin.end();
471
+ });
472
+ }
473
+ async function enroll(joinKey) {
474
+ let decoded;
475
+ try {
476
+ decoded = JSON.parse(Buffer.from(joinKey, 'base64url').toString('utf8'));
477
+ }
478
+ catch (e) {
479
+ throw new Error('invalid TUG_JOIN_KEY (expected base64url-encoded JSON)', { cause: e });
480
+ }
481
+ if (!decoded.enrollToken)
482
+ throw new Error('join key is missing enrollToken');
483
+ const controlUrl = (!CONTROL_URL_FROM_ENV && decoded.controlUrl) ? decoded.controlUrl : CONTROL_URL;
484
+ // node's fetch reads NODE_EXTRA_CA_CERTS only at startup, so apply an embedded CA by
485
+ // persisting it and re-execing ourselves once with the env var set.
486
+ if (decoded.ca && !process.env.NODE_EXTRA_CA_CERTS) {
487
+ await ensureDir(STATE_DIR);
488
+ const caPath = path.join(STATE_DIR, 'control-ca.pem');
489
+ await fs.writeFile(caPath, decoded.ca);
490
+ const r = spawnSync(process.execPath, process.argv.slice(1), { stdio: 'inherit', env: { ...process.env, NODE_EXTRA_CA_CERTS: caPath } });
491
+ process.exit(r.status ?? 1);
492
+ }
493
+ console.log(`enrolling with ${controlUrl} as ${NODE_NAME}`);
494
+ const wgPublicKey = MESH ? (await ensureWgKeys(STATE_DIR)).publicKey : undefined;
495
+ const res = await fetch(`${controlUrl}/agent/v1/enroll`, {
496
+ method: 'POST', headers: { 'content-type': 'application/json' },
497
+ body: JSON.stringify({ enrollToken: decoded.enrollToken, name: NODE_NAME, address: process.env.TUG_NODE_ADDRESS, publicAddress: process.env.TUG_NODE_PUBLIC_ADDRESS, wgPublicKey })
498
+ });
499
+ if (!res.ok)
500
+ throw new Error(`enroll failed: ${res.status} ${await res.text()}`);
501
+ const out = await res.json();
502
+ const creds = { agentToken: out.agentToken, nodeName: out.node?.name || NODE_NAME, controlUrl, registry: out.registry || null, registryToken: out.registryToken || null };
503
+ await saveCreds(creds);
504
+ if (wgPublicKey)
505
+ await writeReportedKey(STATE_DIR, wgPublicKey);
506
+ console.log(`enrolled as node ${creds.nodeName}`);
507
+ return creds;
508
+ }
509
+ // Establish durable credentials before polling: explicit env token (SSH/push install) wins,
510
+ // then credentials persisted on the state volume, then a one-time enroll via the join key.
511
+ export async function loadOrEnroll() {
512
+ let creds = TOKEN ? { agentToken: TOKEN, nodeName: NODE_NAME, controlUrl: CONTROL_URL } : await loadCreds();
513
+ if (!creds) {
514
+ const joinKey = await readJoinKey();
515
+ if (!joinKey)
516
+ throw new Error('no TUG_AGENT_TOKEN, no persisted credentials, and no TUG_JOIN_KEY to enroll with');
517
+ creds = await enroll(joinKey);
518
+ }
519
+ TOKEN = creds.agentToken;
520
+ NODE_NAME = creds.nodeName || NODE_NAME;
521
+ if (!CONTROL_URL_FROM_ENV && creds.controlUrl)
522
+ CONTROL_URL = creds.controlUrl;
523
+ if (APPLY && creds.registry && creds.registryToken) {
524
+ try {
525
+ await dockerLogin(creds.registry, NODE_NAME, creds.registryToken);
526
+ }
527
+ catch (e) {
528
+ console.error(`registry login failed (pulls from ${creds.registry} may fail): ${errMessage(e)}`);
529
+ }
530
+ }
531
+ return creds;
532
+ }
533
+ export async function main() {
534
+ await loadOrEnroll();
535
+ console.log(`tug-agent node=${NODE_NAME} control=${CONTROL_URL} apply=${APPLY}`);
536
+ for (;;) {
537
+ try {
538
+ await pollOnce();
539
+ }
540
+ catch (e) {
541
+ console.error(errMessage(e));
542
+ }
543
+ await new Promise(r => setTimeout(r, INTERVAL));
544
+ }
545
+ }
546
+ if (import.meta.url === `file://${process.argv[1]}`)
547
+ main();
@@ -0,0 +1,2 @@
1
+ import * as tar from 'tar';
2
+ export declare function scanArchive(file: string, inspect: (entry: tar.ReadEntry) => void): Promise<void>;