@wpelevator/tug 0.0.0 → 0.1.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 ADDED
@@ -0,0 +1,59 @@
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 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
+ - Optional remote Docker builds on the control host from an uploaded source tarball.
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
+ - Per-node registry pull tokens enforced by an htpasswd-backed registry you provide, with rotation and revocation.
17
+ - 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
+ - Control-plane backup and restore, and `tug doctor` diagnostics.
19
+
20
+ ## Quick start
21
+
22
+ Install the CLI (Node >= 22.18), or run it without installing through `npx @wpelevator/tug`:
23
+
24
+ ```bash
25
+ npm install -g @wpelevator/tug
26
+ ```
27
+
28
+ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts.
29
+
30
+ 1. Install the control plane on a Docker host, pointing it at a registry you already run: `tug control install root@control.example.com --registry registry.example.com`.
31
+ 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:set DB_PASSWORD`.
33
+ 4. Add nodes, either `tug node install root@app1.example.com --name app1 --control-url https://control.example.com --registry registry.example.com`, or `tug node enroll --name app1` and run the printed `docker run` on the host.
34
+ 5. Deploy: `tug deploy -f docker-compose.yml`. It waits until every node reports the new version healthy.
35
+ 6. Operate with `tug status`, `events`, `logs`, `restart`, `rollback` and `doctor`.
36
+
37
+ ## Documentation
38
+
39
+ - [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
40
+ - [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
41
+ - [Control plane](docs/control-plane.md): install, TLS, registry credentials, backup and restore.
42
+ - [Networking](docs/networking.md): WireGuard mesh, service DNS, ingress and TLS, firewall notes.
43
+ - [Nodes](docs/nodes.md): SSH install, self-service enrollment, registration and status.
44
+ - [CLI reference](docs/cli.md): every `tug` command and option.
45
+ - [Configuration](docs/configuration.md): CLI, control and agent environment variables.
46
+ - [Development](docs/development.md): local setup, CI, Compose stack, host mode and the integration test.
47
+ - [Releases](docs/releases.md): changesets, npm and Docker image publishing.
48
+ - [Roadmap](docs/roadmap.md): planned milestones toward parity with and beyond uncloud.
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ npm install
54
+ npm test
55
+ npm run lint
56
+ npm run test:integration
57
+ ```
58
+
59
+ See [Development](docs/development.md) for details.
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import type { DockerRunner, HostRunner, HttpClient } from './runners.ts';
3
+ import type { Command, CommandResult, DesiredState, HealthCheck, ProjectStatus, ServiceEndpoint } from './protocol.ts';
4
+ export interface AgentContext {
5
+ docker: DockerRunner;
6
+ http: HttpClient;
7
+ nodeName: string;
8
+ stateDir: string;
9
+ apply: boolean;
10
+ controlUrl: string;
11
+ token: string | undefined;
12
+ /** Runs `wg` / `ip` for the mesh. */
13
+ host?: HostRunner;
14
+ /** Manage the WireGuard mesh (generate a key, report it, reconcile `wg0`). */
15
+ mesh?: boolean;
16
+ caddyImage?: string;
17
+ }
18
+ 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 declare function parseDuration(value: string | number): number;
22
+ export declare function executeCommand(command: Command, ctx?: AgentContext): Promise<CommandResult>;
23
+ export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>): Promise<ProjectStatus>;
24
+ /** Reports this node's WireGuard public key when control has not seen the current one yet. */
25
+ export declare function reportWgKey(ctx: AgentContext): Promise<void>;
26
+ export declare function pollOnce(ctx?: AgentContext): Promise<ProjectStatus[]>;
27
+ interface Creds {
28
+ agentToken: string;
29
+ nodeName?: string;
30
+ controlUrl?: string;
31
+ registry?: string | null;
32
+ registryToken?: string | null;
33
+ }
34
+ export declare function loadOrEnroll(): Promise<Creds>;
35
+ export declare function main(): Promise<void>;
36
+ export {};
package/dist/agent.js ADDED
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import net from 'node:net';
6
+ import { composeForNode, dumpYaml, serviceSecretVersions } from "./compose.js";
7
+ import { ensureDir, errMessage, now } from "./util.js";
8
+ import { openAgentDb } from "./db.js";
9
+ import { createDockerRunner, createHostRunner } from "./runners.js";
10
+ import { CADDY_CONTAINER, reconcileIngress, removeIngress } from "./ingress.js";
11
+ import { WG_INTERFACE, ensureWgKeys, readReportedKey, reconcileMesh, rotateWgKeys, writeReportedKey } from "./mesh.js";
12
+ const CONTROL_URL_FROM_ENV = Boolean(process.env.TUG_CONTROL_URL);
13
+ let CONTROL_URL = process.env.TUG_CONTROL_URL || 'http://localhost:8080';
14
+ let TOKEN = process.env.TUG_AGENT_TOKEN;
15
+ let NODE_NAME = process.env.TUG_NODE_NAME || process.env.HOSTNAME || 'node1';
16
+ const STATE_DIR = process.env.TUG_STATE_DIR || '/var/lib/tug';
17
+ const APPLY = process.env.TUG_APPLY !== 'false';
18
+ const INTERVAL = Number(process.env.TUG_POLL_INTERVAL_MS || 5000);
19
+ // Opt-in WireGuard mesh: needs `wg`/`ip` in the agent image, host networking and NET_ADMIN.
20
+ const MESH = process.env.TUG_MESH === 'true';
21
+ 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 };
23
+ }
24
+ const dbsByStateDir = new Map();
25
+ async function agentDb(stateDir) {
26
+ let db = dbsByStateDir.get(stateDir);
27
+ if (!db) {
28
+ await ensureDir(stateDir);
29
+ db = await openAgentDb(path.join(stateDir, 'agent.db'));
30
+ dbsByStateDir.set(stateDir, db);
31
+ }
32
+ return db;
33
+ }
34
+ function authHeaders(ctx) { if (!ctx.token)
35
+ throw new Error('TUG_AGENT_TOKEN required'); return { Authorization: `Bearer ${ctx.token}` }; }
36
+ async function api(ctx, route, opts = {}) {
37
+ const res = await ctx.http(`${ctx.controlUrl}${route}`, { ...opts, headers: { ...authHeaders(ctx), ...(opts.headers || {}) } });
38
+ if (!res.ok)
39
+ throw new Error(`${opts.method || 'GET'} ${route}: ${res.status} ${await res.text()}`);
40
+ return res.json();
41
+ }
42
+ async function writeEnv(file, secrets) {
43
+ const body = Object.entries(secrets).map(([k, v]) => `${k}=${String(v).replace(/\n/g, '\\n')}`).join('\n') + '\n';
44
+ await fs.writeFile(file, body, { mode: 0o600 });
45
+ }
46
+ export async function containerIp(ctx, project, service, cwd) {
47
+ const ids = (await ctx.docker(['compose', '-p', project, 'ps', '-q', service], { cwd })).out.trim().split(/\s+/).filter(Boolean);
48
+ if (!ids.length)
49
+ throw new Error(`${service}: no container found`);
50
+ const inspected = JSON.parse((await ctx.docker(['inspect', ids[0]], { cwd })).out)[0];
51
+ const networks = inspected?.NetworkSettings?.Networks || {};
52
+ return Object.values(networks)[0]?.IPAddress || '127.0.0.1';
53
+ }
54
+ function tcpCheck(host, port, timeoutMs = 2000) {
55
+ return new Promise((resolve, reject) => {
56
+ const s = net.createConnection({ host, port: Number(port), timeout: timeoutMs }, () => { s.destroy(); resolve(true); });
57
+ s.on('timeout', () => { s.destroy(); reject(new Error(`tcp timeout ${host}:${port}`)); });
58
+ s.on('error', reject);
59
+ });
60
+ }
61
+ export async function waitForHealth(ctx, project, service, health, cwd) {
62
+ if (!ctx.apply || !health)
63
+ return;
64
+ const retries = Number(health.retries || 10);
65
+ const intervalMs = parseDuration(health.interval || '2s');
66
+ let last;
67
+ for (let i = 0; i < retries; i++) {
68
+ try {
69
+ if (health.type === 'docker') {
70
+ const id = (await ctx.docker(['compose', '-p', project, 'ps', '-q', service], { cwd })).out.trim().split(/\s+/)[0];
71
+ const state = JSON.parse((await ctx.docker(['inspect', id], { cwd })).out)[0]?.State;
72
+ if (!state?.Running)
73
+ throw new Error(`${service}: not running`);
74
+ if (state.Health && state.Health.Status !== 'healthy')
75
+ throw new Error(`${service}: docker health ${state.Health.Status}`);
76
+ }
77
+ else if (health.type === 'tcp') {
78
+ await tcpCheck(await containerIp(ctx, project, service, cwd), health.port, parseDuration(health.timeout || '2s'));
79
+ }
80
+ else if (health.type === 'http') {
81
+ const ip = await containerIp(ctx, project, service, cwd);
82
+ const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, { signal: AbortSignal.timeout(parseDuration(health.timeout || '2s')) });
83
+ if (!res.ok)
84
+ throw new Error(`${service}: http ${res.status}`);
85
+ }
86
+ return;
87
+ }
88
+ catch (e) {
89
+ last = e;
90
+ await new Promise(r => setTimeout(r, intervalMs));
91
+ }
92
+ }
93
+ throw last || new Error(`${service}: health check failed`);
94
+ }
95
+ export function parseDuration(value) {
96
+ if (typeof value === 'number')
97
+ return value;
98
+ const m = String(value).match(/^(\d+)(ms|s|m)?$/);
99
+ if (!m)
100
+ return 2000;
101
+ const n = Number(m[1]);
102
+ return m[2] === 'm' ? n * 60000 : m[2] === 'ms' ? n : n * 1000;
103
+ }
104
+ export async function executeCommand(command, ctx = defaultContext()) {
105
+ const payload = typeof command.payload_json === 'string' ? JSON.parse(command.payload_json || '{}') : command.payload || {};
106
+ if (command.type === 'diagnostics.run') {
107
+ const checks = [];
108
+ for (const [name, args] of [
109
+ ['docker.info', ['info']],
110
+ ['docker.version', ['version']],
111
+ ['docker.compose.version', ['compose', 'version']]
112
+ ]) {
113
+ try {
114
+ const result = await ctx.docker([...args]);
115
+ checks.push({ name, ok: true, stdout: result.out.slice(-4000), stderr: result.err.slice(-4000) });
116
+ }
117
+ catch (e) {
118
+ checks.push({ name, ok: false, error: errMessage(e).slice(-4000) });
119
+ }
120
+ }
121
+ const extra = [];
122
+ if (ctx.mesh && ctx.host) {
123
+ const host = ctx.host;
124
+ extra.push(['wireguard.version', () => host('wg', ['--version'])], ['wireguard.interface', () => host('wg', ['show', WG_INTERFACE])]);
125
+ }
126
+ if (await fs.stat(path.join(ctx.stateDir, 'ingress')).then(() => true, () => false)) {
127
+ extra.push(['ingress.caddy', () => ctx.docker(['container', 'inspect', '-f', '{{.State.Status}}', CADDY_CONTAINER])]);
128
+ }
129
+ for (const [name, fn] of extra) {
130
+ try {
131
+ const result = await fn();
132
+ checks.push({ name, ok: true, stdout: result.out.slice(-4000), stderr: result.err.slice(-4000) });
133
+ }
134
+ catch (e) {
135
+ checks.push({ name, ok: false, error: errMessage(e).slice(-4000) });
136
+ }
137
+ }
138
+ return { commandId: command.id, state: checks.every(c => c.ok) ? 'succeeded' : 'failed', result: { checks, node: ctx.nodeName } };
139
+ }
140
+ if (command.type === 'service.restart') {
141
+ const project = payload.project;
142
+ const service = payload.service;
143
+ const dir = path.join(ctx.stateDir, 'projects', project);
144
+ await ctx.docker(['compose', '-p', project, 'restart', service], { cwd: dir });
145
+ return { commandId: command.id, state: 'succeeded', result: { project, service, restarted: true } };
146
+ }
147
+ if (command.type === 'logs.fetch') {
148
+ const project = payload.project;
149
+ const service = payload.service;
150
+ const tail = String(payload.tail || 200);
151
+ const dir = path.join(ctx.stateDir, 'projects', project);
152
+ const result = await ctx.docker(['compose', '-p', project, 'logs', '--no-color', '--tail', tail, service], { cwd: dir });
153
+ return { commandId: command.id, state: 'succeeded', result: { stdout: result.out, stderr: result.err } };
154
+ }
155
+ if (command.type === 'volume.delete') {
156
+ const project = payload.project;
157
+ const volume = payload.volume;
158
+ const composeVolume = `${project}_${volume}`;
159
+ try {
160
+ await ctx.docker(['volume', 'rm', composeVolume]);
161
+ }
162
+ catch (e) {
163
+ if (!errMessage(e).includes('No such volume'))
164
+ throw e;
165
+ }
166
+ return { commandId: command.id, state: 'succeeded', result: { project, volume, deleted: true } };
167
+ }
168
+ if (command.type === 'wg.rotate-key') {
169
+ if (!ctx.mesh)
170
+ throw new Error('the WireGuard mesh is not enabled on this agent (TUG_MESH=true)');
171
+ const { publicKey } = await rotateWgKeys(ctx.stateDir);
172
+ await api(ctx, '/agent/v1/wireguard/key', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ publicKey }) });
173
+ await writeReportedKey(ctx.stateDir, publicKey);
174
+ return { commandId: command.id, state: 'succeeded', result: { publicKey } };
175
+ }
176
+ return { commandId: command.id, state: 'failed', result: { error: `unsupported command ${command.type}` } };
177
+ }
178
+ export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}) {
179
+ const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
180
+ await ensureDir(dir);
181
+ const plannedCompose = composeForNode(desiredState, ctx.nodeName, {});
182
+ const serviceNames = Object.keys(plannedCompose.services || {});
183
+ if (!serviceNames.length)
184
+ return { project: desiredState.project, observedVersion: desiredState.version, health: 'skipped', services: [], serviceStates: {} };
185
+ 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: 'info', type: `service.${rolloutState}`, message, metadata: { service, version: desiredState.version, node: ctx.nodeName } }) }).catch(() => { });
187
+ const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
188
+ const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
189
+ 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 secretChangedServices = serviceNames.filter(service => JSON.stringify(previous.secretVersions?.[service] || {}) !== JSON.stringify(currentSecretVersions[service] || {}));
193
+ const sameDesiredVersion = previous.version === desiredState.version;
194
+ const nodeCompose = composeForNode(desiredState, ctx.nodeName, sec.secrets || {}, endpoints);
195
+ await fs.writeFile(path.join(dir, 'docker-compose.yml'), dumpYaml(nodeCompose));
196
+ await writeEnv(path.join(dir, '.env'), sec.secrets || {});
197
+ if (ctx.apply) {
198
+ for (const service of serviceNames) {
199
+ serviceStates[service].rolloutState = 'pulling';
200
+ await emit(service, 'pulling');
201
+ }
202
+ await ctx.docker(['compose', '-p', desiredState.project, 'pull'], { cwd: dir });
203
+ for (const service of serviceNames) {
204
+ serviceStates[service].rolloutState = 'starting';
205
+ await emit(service, 'starting');
206
+ }
207
+ await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', '--remove-orphans'], { cwd: dir });
208
+ if (sameDesiredVersion && secretChangedServices.length) {
209
+ for (const service of secretChangedServices)
210
+ await emit(service, 'secret_recreate', 'secret version changed; recreating service');
211
+ await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', '--no-deps', '--force-recreate', ...secretChangedServices], { cwd: dir });
212
+ }
213
+ for (const service of serviceNames) {
214
+ serviceStates[service].rolloutState = 'health_checking';
215
+ await emit(service, 'health_checking');
216
+ await waitForHealth(ctx, desiredState.project, service, desiredState.services[service]?.health, dir);
217
+ serviceStates[service].rolloutState = 'serving';
218
+ serviceStates[service].healthyAt = now();
219
+ await emit(service, 'serving');
220
+ }
221
+ }
222
+ else {
223
+ for (const service of serviceNames)
224
+ serviceStates[service].rolloutState = 'serving';
225
+ }
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 };
231
+ }
232
+ /** Reports this node's WireGuard public key when control has not seen the current one yet. */
233
+ export async function reportWgKey(ctx) {
234
+ const { publicKey } = await ensureWgKeys(ctx.stateDir);
235
+ if (await readReportedKey(ctx.stateDir) === publicKey)
236
+ return;
237
+ await api(ctx, '/agent/v1/wireguard/key', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ publicKey }) });
238
+ await writeReportedKey(ctx.stateDir, publicKey);
239
+ }
240
+ let lastNetworkError = '';
241
+ async function networkStep(label, fn) {
242
+ try {
243
+ await fn();
244
+ lastNetworkError = '';
245
+ }
246
+ catch (e) {
247
+ const message = `${label}: ${errMessage(e)}`;
248
+ if (message !== lastNetworkError)
249
+ console.error(message);
250
+ lastNetworkError = message;
251
+ }
252
+ }
253
+ export async function pollOnce(ctx = defaultContext()) {
254
+ if (ctx.mesh)
255
+ await networkStep('wireguard key report failed', () => reportWgKey(ctx));
256
+ const work = await api(ctx, '/agent/v1/work');
257
+ // The mesh comes up first so cross-node service endpoints are routable when containers start.
258
+ if (ctx.mesh && work.network) {
259
+ const network = work.network;
260
+ await networkStep('wireguard reconcile failed', () => reconcileMesh(ctx.host ?? createHostRunner(), ctx.stateDir, network));
261
+ }
262
+ const projects = [];
263
+ for (const desired of work.desiredStates || (work.desiredState ? [work.desiredState] : [])) {
264
+ try {
265
+ projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project]));
266
+ }
267
+ catch (e) {
268
+ projects.push({ project: desired.project, observedVersion: desired.version, health: 'failed', error: errMessage(e) });
269
+ }
270
+ }
271
+ let ingress;
272
+ if (ctx.apply) {
273
+ const ingressCtx = { docker: ctx.docker, stateDir: ctx.stateDir, caddyImage: ctx.caddyImage };
274
+ if (work.ingress)
275
+ ingress = await reconcileIngress(ingressCtx, work.ingress);
276
+ else
277
+ await removeIngress(ingressCtx).catch(() => false);
278
+ }
279
+ const commandResults = [];
280
+ for (const command of work.commands || []) {
281
+ try {
282
+ commandResults.push(await executeCommand(command, ctx));
283
+ }
284
+ catch (e) {
285
+ commandResults.push({ commandId: command.id, state: 'failed', result: { error: errMessage(e) } });
286
+ }
287
+ }
288
+ const report = { node: ctx.nodeName, projects, commandResults };
289
+ if (ingress)
290
+ report.ingress = ingress;
291
+ await api(ctx, '/agent/v1/status', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(report) });
292
+ return projects;
293
+ }
294
+ const CREDS_FILE = () => path.join(STATE_DIR, 'agent.json');
295
+ async function loadCreds() {
296
+ try {
297
+ return JSON.parse(await fs.readFile(CREDS_FILE(), 'utf8'));
298
+ }
299
+ catch {
300
+ return null;
301
+ }
302
+ }
303
+ async function saveCreds(creds) {
304
+ await ensureDir(STATE_DIR);
305
+ await fs.writeFile(CREDS_FILE(), JSON.stringify(creds), { mode: 0o600 });
306
+ }
307
+ async function readJoinKey() {
308
+ if (process.env.TUG_JOIN_KEY)
309
+ return process.env.TUG_JOIN_KEY.trim();
310
+ const file = process.env.TUG_JOIN_KEY_FILE;
311
+ if (file) {
312
+ try {
313
+ return (await fs.readFile(file, 'utf8')).trim();
314
+ }
315
+ catch (e) {
316
+ throw new Error(`cannot read TUG_JOIN_KEY_FILE ${file}: ${errMessage(e)}`, { cause: e });
317
+ }
318
+ }
319
+ return null;
320
+ }
321
+ function dockerLogin(registry, user, password) {
322
+ return new Promise((resolve, reject) => {
323
+ const p = spawn('docker', ['login', registry, '-u', user, '--password-stdin'], { stdio: ['pipe', 'ignore', 'pipe'] });
324
+ let err = '';
325
+ p.stderr.on('data', d => err += d);
326
+ p.on('error', reject);
327
+ p.on('close', code => code === 0 ? resolve() : reject(new Error(err.trim() || `exit ${code}`)));
328
+ p.stdin.write(password);
329
+ p.stdin.end();
330
+ });
331
+ }
332
+ async function enroll(joinKey) {
333
+ let decoded;
334
+ try {
335
+ decoded = JSON.parse(Buffer.from(joinKey, 'base64url').toString('utf8'));
336
+ }
337
+ catch (e) {
338
+ throw new Error('invalid TUG_JOIN_KEY (expected base64url-encoded JSON)', { cause: e });
339
+ }
340
+ if (!decoded.enrollToken)
341
+ throw new Error('join key is missing enrollToken');
342
+ const controlUrl = (!CONTROL_URL_FROM_ENV && decoded.controlUrl) ? decoded.controlUrl : CONTROL_URL;
343
+ // node's fetch reads NODE_EXTRA_CA_CERTS only at startup, so apply an embedded CA by
344
+ // persisting it and re-execing ourselves once with the env var set.
345
+ if (decoded.ca && !process.env.NODE_EXTRA_CA_CERTS) {
346
+ await ensureDir(STATE_DIR);
347
+ const caPath = path.join(STATE_DIR, 'control-ca.pem');
348
+ await fs.writeFile(caPath, decoded.ca);
349
+ const r = spawnSync(process.execPath, process.argv.slice(1), { stdio: 'inherit', env: { ...process.env, NODE_EXTRA_CA_CERTS: caPath } });
350
+ process.exit(r.status ?? 1);
351
+ }
352
+ console.log(`enrolling with ${controlUrl} as ${NODE_NAME}`);
353
+ const wgPublicKey = MESH ? (await ensureWgKeys(STATE_DIR)).publicKey : undefined;
354
+ const res = await fetch(`${controlUrl}/agent/v1/enroll`, {
355
+ method: 'POST', headers: { 'content-type': 'application/json' },
356
+ body: JSON.stringify({ enrollToken: decoded.enrollToken, name: NODE_NAME, address: process.env.TUG_NODE_ADDRESS, publicAddress: process.env.TUG_NODE_PUBLIC_ADDRESS, wgPublicKey })
357
+ });
358
+ if (!res.ok)
359
+ throw new Error(`enroll failed: ${res.status} ${await res.text()}`);
360
+ const out = await res.json();
361
+ const creds = { agentToken: out.agentToken, nodeName: out.node?.name || NODE_NAME, controlUrl, registry: out.registry || null, registryToken: out.registryToken || null };
362
+ await saveCreds(creds);
363
+ if (wgPublicKey)
364
+ await writeReportedKey(STATE_DIR, wgPublicKey);
365
+ console.log(`enrolled as node ${creds.nodeName}`);
366
+ return creds;
367
+ }
368
+ // Establish durable credentials before polling: explicit env token (SSH/push install) wins,
369
+ // then credentials persisted on the state volume, then a one-time enroll via the join key.
370
+ export async function loadOrEnroll() {
371
+ let creds = TOKEN ? { agentToken: TOKEN, nodeName: NODE_NAME, controlUrl: CONTROL_URL } : await loadCreds();
372
+ if (!creds) {
373
+ const joinKey = await readJoinKey();
374
+ if (!joinKey)
375
+ throw new Error('no TUG_AGENT_TOKEN, no persisted credentials, and no TUG_JOIN_KEY to enroll with');
376
+ creds = await enroll(joinKey);
377
+ }
378
+ TOKEN = creds.agentToken;
379
+ NODE_NAME = creds.nodeName || NODE_NAME;
380
+ if (!CONTROL_URL_FROM_ENV && creds.controlUrl)
381
+ CONTROL_URL = creds.controlUrl;
382
+ if (APPLY && creds.registry && creds.registryToken) {
383
+ try {
384
+ await dockerLogin(creds.registry, NODE_NAME, creds.registryToken);
385
+ }
386
+ catch (e) {
387
+ console.error(`registry login failed (pulls from ${creds.registry} may fail): ${errMessage(e)}`);
388
+ }
389
+ }
390
+ return creds;
391
+ }
392
+ export async function main() {
393
+ await loadOrEnroll();
394
+ console.log(`tug-agent node=${NODE_NAME} control=${CONTROL_URL} apply=${APPLY}`);
395
+ for (;;) {
396
+ try {
397
+ await pollOnce();
398
+ }
399
+ catch (e) {
400
+ console.error(errMessage(e));
401
+ }
402
+ await new Promise(r => setTimeout(r, INTERVAL));
403
+ }
404
+ }
405
+ if (import.meta.url === `file://${process.argv[1]}`)
406
+ main();
@@ -0,0 +1,22 @@
1
+ export interface RunResult {
2
+ out: string;
3
+ err: string;
4
+ }
5
+ export interface RunOptions {
6
+ cwd?: string;
7
+ env?: NodeJS.ProcessEnv;
8
+ }
9
+ export interface BuildSpec {
10
+ context?: string;
11
+ dockerfile?: string;
12
+ }
13
+ export declare function run(cmd: string, args: string[], opts?: RunOptions): Promise<RunResult>;
14
+ export declare function unpackContext(tarball: string, dest: string): Promise<void>;
15
+ export interface BuildAndPushOptions {
16
+ contextDir: string;
17
+ service: string;
18
+ buildSpec: string | BuildSpec;
19
+ imageRef: string;
20
+ push?: boolean;
21
+ }
22
+ export declare function buildAndPush({ contextDir, service, buildSpec, imageRef, push }: BuildAndPushOptions): Promise<void>;
package/dist/build.js ADDED
@@ -0,0 +1,29 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import * as tar from 'tar';
5
+ import { ensureDir, exists } from "./util.js";
6
+ export function run(cmd, args, opts = {}) {
7
+ return new Promise((resolve, reject) => {
8
+ const p = spawn(cmd, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
9
+ let out = '', err = '';
10
+ p.stdout.on('data', d => out += d);
11
+ p.stderr.on('data', d => err += d);
12
+ p.on('close', code => code === 0 ? resolve({ out, err }) : reject(new Error(`${cmd} ${args.join(' ')} failed (${code})\n${out}\n${err}`)));
13
+ });
14
+ }
15
+ export async function unpackContext(tarball, dest) {
16
+ await fs.rm(dest, { recursive: true, force: true });
17
+ await ensureDir(dest);
18
+ await tar.x({ file: tarball, cwd: dest });
19
+ }
20
+ export async function buildAndPush({ contextDir, service, buildSpec, imageRef, push = true }) {
21
+ const buildPath = typeof buildSpec === 'string' ? buildSpec : buildSpec?.context || '.';
22
+ const dockerfile = typeof buildSpec === 'object' && buildSpec.dockerfile ? ['-f', path.join(contextDir, buildPath, buildSpec.dockerfile)] : [];
23
+ const fullContext = path.resolve(contextDir, buildPath);
24
+ if (!(await exists(fullContext)))
25
+ throw new Error(`build context not found for ${service}: ${buildPath}`);
26
+ await run('docker', ['build', ...dockerfile, '-t', imageRef, fullContext]);
27
+ if (push)
28
+ await run('docker', ['push', imageRef]);
29
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};