@wpelevator/tug 0.0.0-stage → 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 +58 -2
- package/dist/agent.d.ts +36 -0
- package/dist/agent.js +406 -0
- package/dist/build.d.ts +22 -0
- package/dist/build.js +29 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +333 -0
- package/dist/compose.d.ts +23 -0
- package/dist/compose.js +182 -0
- package/dist/control.d.ts +11 -0
- package/dist/control.js +729 -0
- package/dist/db.d.ts +115 -0
- package/dist/db.js +48 -0
- package/dist/image.d.ts +9 -0
- package/dist/image.js +20 -0
- package/dist/ingress.d.ts +50 -0
- package/dist/ingress.js +178 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +22 -0
- package/dist/mesh.d.ts +70 -0
- package/dist/mesh.js +183 -0
- package/dist/protocol.d.ts +282 -0
- package/dist/protocol.js +8 -0
- package/dist/runners.d.ts +12 -0
- package/dist/runners.js +10 -0
- package/dist/secrets.d.ts +3 -0
- package/dist/secrets.js +29 -0
- package/dist/templates/agent.env +6 -0
- package/dist/templates/control-compose.yml +18 -0
- package/dist/templates/control-install.sh +7 -0
- package/dist/templates/node-install.sh +8 -0
- package/dist/util.d.ts +12 -0
- package/dist/util.js +24 -0
- package/package.json +60 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import * as tar from 'tar';
|
|
9
|
+
import { readCompose, composeHash } from "./compose.js";
|
|
10
|
+
import { errMessage, token, loadTemplate } from "./util.js";
|
|
11
|
+
import { packageVersion, resolveImage } from "./image.js";
|
|
12
|
+
import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
|
|
13
|
+
// Templates live next to this file: src/templates in dev (`node src/cli.ts`), dist/templates in the
|
|
14
|
+
// built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const templatesDir = path.join(__dirname, 'templates');
|
|
17
|
+
/** `docker run` flags a mesh agent needs: host networking so `wg0` lives in the host's network namespace, and NET_ADMIN to manage it. */
|
|
18
|
+
const MESH_RUN_FLAGS = '--network host --cap-add NET_ADMIN';
|
|
19
|
+
const program = new Command();
|
|
20
|
+
program.name('tug').description('Portable VPS deployments with Docker Compose').version(packageVersion());
|
|
21
|
+
const cfgPath = () => process.env.TUG_CONFIG || path.join(process.cwd(), '.tug.json');
|
|
22
|
+
async function loadCfg() { try {
|
|
23
|
+
return JSON.parse(await fs.readFile(cfgPath(), 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
} }
|
|
28
|
+
async function saveCfg(c) { await fs.writeFile(cfgPath(), JSON.stringify(c, null, 2)); }
|
|
29
|
+
async function request(method, route, body, multipart = false) {
|
|
30
|
+
const c = await loadCfg();
|
|
31
|
+
const base = process.env.TUG_CONTROL_URL || c.controlUrl || 'http://localhost:8080';
|
|
32
|
+
const tok = process.env.TUG_TOKEN || c.operatorToken;
|
|
33
|
+
const headers = tok ? { Authorization: `Bearer ${tok}` } : {};
|
|
34
|
+
if (!multipart)
|
|
35
|
+
headers['content-type'] = 'application/json';
|
|
36
|
+
const res = await fetch(`${base}${route}`, { method, headers, body: multipart ? body : body ? JSON.stringify(body) : undefined });
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
|
|
39
|
+
return res.json();
|
|
40
|
+
}
|
|
41
|
+
function sh(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; }
|
|
42
|
+
function runLocal(cmd, args, opts = {}) {
|
|
43
|
+
const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
|
|
44
|
+
if (result.status !== 0)
|
|
45
|
+
throw new Error(`${cmd} ${args.join(' ')} failed`);
|
|
46
|
+
}
|
|
47
|
+
function runCapture(cmd, args, opts = {}) {
|
|
48
|
+
const result = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
|
|
49
|
+
if (result.status !== 0)
|
|
50
|
+
throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
|
|
51
|
+
return String(result.stdout);
|
|
52
|
+
}
|
|
53
|
+
function remoteHost(sshTarget) { return sshTarget.replace(/^[^@]+@/, '').split(':')[0]; }
|
|
54
|
+
function assertHttps(url, allowHttp) {
|
|
55
|
+
if (!allowHttp && !String(url).startsWith('https://'))
|
|
56
|
+
throw new Error(`${url} must use https:// outside local development (pass --http-only-dev to override)`);
|
|
57
|
+
}
|
|
58
|
+
function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDiskMb = 1024, mesh = false } = {}) {
|
|
59
|
+
const checks = [
|
|
60
|
+
['docker.version', 'docker version >/dev/null', 'Install Docker Engine and ensure the SSH user can access it.'],
|
|
61
|
+
['docker.compose.version', 'docker compose version >/dev/null', 'Install Docker Compose v2.'],
|
|
62
|
+
['paths', 'mkdir -p /opt/tug-preflight /var/lib/tug-preflight /etc/tug && test -w /opt/tug-preflight && test -w /var/lib/tug-preflight && test -w /etc/tug', 'Ensure /opt, /var/lib, and /etc/tug are writable by the installer.'],
|
|
63
|
+
['disk.space', `[ "$(df -Pm /var/lib | tail -1 | awk '{print $4}')" -ge ${Number(minDiskMb)} ]`, `Free at least ${minDiskMb}MB on the filesystem backing /var/lib.`]
|
|
64
|
+
];
|
|
65
|
+
for (const port of ports) {
|
|
66
|
+
checks.push([`port.${port}.free`, `! ( command -v ss >/dev/null 2>&1 && ss -ltn | awk '{print $4}' | grep -qE "[.:]${port}$" )`, `Port ${port} is already in use on the target host; stop the conflicting service or choose another port.`]);
|
|
67
|
+
}
|
|
68
|
+
if (mesh) {
|
|
69
|
+
checks.push(['wireguard.kernel', 'ip link add lpwgtest type wireguard && ip link del lpwgtest || modprobe -n wireguard 2>/dev/null', 'Install WireGuard (kernel 5.6+ has it built in; older kernels need the wireguard-dkms package) and ensure iproute2 is present.']);
|
|
70
|
+
}
|
|
71
|
+
for (const port of udpPorts) {
|
|
72
|
+
checks.push([`udp.${port}.free`, `! ( command -v ss >/dev/null 2>&1 && ss -lun | awk '{print $4}' | grep -qE "[.:]${port}$" )`, `UDP port ${port} is already in use on the target host; the WireGuard mesh needs it (set TUG_WG_PORT on control and open it between nodes).`]);
|
|
73
|
+
}
|
|
74
|
+
if (controlUrl)
|
|
75
|
+
checks.push(['control.reachable', `curl -fsS ${sh(controlUrl)}/healthz >/dev/null`, 'Verify DNS/firewall/TLS for the control URL from the node.']);
|
|
76
|
+
const script = `set -e
|
|
77
|
+
${checks.map(([name, cmd, fix]) => `echo "preflight: ${name}"; (${cmd}) || { echo "FAILED ${name}: ${fix}" >&2; exit 20; }`).join('\n')}
|
|
78
|
+
`;
|
|
79
|
+
runLocal('ssh', [target, script]);
|
|
80
|
+
}
|
|
81
|
+
async function transferTugImage(target, imageTag) {
|
|
82
|
+
const archive = path.join(os.tmpdir(), `tug-image-${Date.now()}.tar.gz`);
|
|
83
|
+
runLocal('docker', ['build', '-t', imageTag, '.']);
|
|
84
|
+
runLocal('bash', ['-lc', `docker save ${sh(imageTag)} | gzip > ${sh(archive)}`]);
|
|
85
|
+
runLocal('scp', [archive, `${target}:/tmp/tug-image.tar.gz`]);
|
|
86
|
+
runLocal('ssh', [target, 'gzip -dc /tmp/tug-image.tar.gz | docker load && rm -f /tmp/tug-image.tar.gz']);
|
|
87
|
+
await fs.rm(archive, { force: true });
|
|
88
|
+
}
|
|
89
|
+
async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
90
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
const deployment = await request('GET', `/api/v1/deployments/${encodeURIComponent(deploymentId)}`);
|
|
93
|
+
if (TERMINAL_DEPLOYMENT_STATES.includes(deployment.state)) {
|
|
94
|
+
if (deployment.state !== 'succeeded')
|
|
95
|
+
throw new Error(`deployment ${deploymentId} ${deployment.state}${deployment.error_message ? `: ${deployment.error_message}` : ''}`);
|
|
96
|
+
return deployment;
|
|
97
|
+
}
|
|
98
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`timed out waiting for deployment ${deploymentId}`);
|
|
101
|
+
}
|
|
102
|
+
program.command('init').option('--control-url <url>', 'control API URL', 'http://localhost:8080').option('--token <token>', 'operator token').option('--project <name>', 'project name', 'site').action(async (o) => {
|
|
103
|
+
const operatorToken = o.token || process.env.TUG_TOKEN || token('tug_op');
|
|
104
|
+
await saveCfg({ controlUrl: o.controlUrl, operatorToken, project: o.project });
|
|
105
|
+
console.log(`wrote ${cfgPath()}`);
|
|
106
|
+
});
|
|
107
|
+
program.command('node:register').description('register a node with the control plane').requiredOption('--name <name>').option('--address <addr>').option('--public-address <addr>').action(async (o) => {
|
|
108
|
+
const r = await request('POST', '/api/v1/nodes/register', { name: o.name, address: o.address, publicAddress: o.publicAddress });
|
|
109
|
+
console.log(JSON.stringify(r, null, 2));
|
|
110
|
+
});
|
|
111
|
+
program.command('node:rotate-registry-token <name>').description('issue a new registry pull token for a node, invalidating the old one').action(async (name) => {
|
|
112
|
+
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-registry-token`, {}), null, 2));
|
|
113
|
+
});
|
|
114
|
+
program.command('node:rotate-wg-key <name>').description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll').option('--timeout <seconds>', 'wait timeout', '60').action(async (name, o) => {
|
|
115
|
+
const created = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-wg-key`, {});
|
|
116
|
+
const cmd = await waitForCommand(created.commandId, Number(o.timeout));
|
|
117
|
+
if (cmd.state === 'failed')
|
|
118
|
+
throw new Error(JSON.parse(cmd.result_json || '{}').error || 'key rotation failed');
|
|
119
|
+
console.log(JSON.stringify({ ok: true, node: name, ...JSON.parse(cmd.result_json || '{}') }, null, 2));
|
|
120
|
+
});
|
|
121
|
+
program.command('node:ingress <name>').description('mark a node as an ingress node (managed Caddy on ports 80/443), or remove the role with --disable').option('--disable', 'stop acting as an ingress node').action(async (name, o) => {
|
|
122
|
+
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, { enabled: !o.disable }), null, 2));
|
|
123
|
+
});
|
|
124
|
+
program.command('node:revoke-registry-token <name>').description('revoke a node\'s registry pull token, blocking future pulls until rotated').action(async (name) => {
|
|
125
|
+
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/revoke-registry-token`, {}), null, 2));
|
|
126
|
+
});
|
|
127
|
+
program.command('control:token').description('print local dev operator token if available').action(async () => console.log((await fs.readFile('./data/operator.token', 'utf8')).trim()));
|
|
128
|
+
const control = program.command('control');
|
|
129
|
+
control.command('install <sshTarget>')
|
|
130
|
+
.description('install the control plane on a Docker-capable host over SSH; front it with your own reverse proxy/TLS if exposing it publicly')
|
|
131
|
+
.option('--image <tag>', 'Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI\'s version>)')
|
|
132
|
+
.option('--build-local-image', 'build the image from the current checkout and copy it to the host over SSH (tags it tug:local unless --image is set); for development')
|
|
133
|
+
.option('--token <token>', 'operator token')
|
|
134
|
+
.option('--port <port>', 'published API port', '8080')
|
|
135
|
+
.requiredOption('--registry <host>', 'registry host used in desired images; bring your own registry, Tug does not provision one')
|
|
136
|
+
.option('--registry-auth-path <path>', 'container path to an htpasswd file tug-control should manage credentials in, if your registry uses one')
|
|
137
|
+
.option('--registry-container <name>', 'docker container name to restart when a node\'s registry credentials change, if your registry uses an htpasswd file')
|
|
138
|
+
.action(async (sshTarget, o) => {
|
|
139
|
+
const operatorToken = o.token || process.env.TUG_TOKEN || token('tug_op');
|
|
140
|
+
preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048 });
|
|
141
|
+
const image = resolveImage(o.image, o.buildLocalImage);
|
|
142
|
+
if (o.buildLocalImage)
|
|
143
|
+
await transferTugImage(sshTarget, image);
|
|
144
|
+
const registryHost = o.registry;
|
|
145
|
+
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
146
|
+
IMAGE: image, OPERATOR_TOKEN: operatorToken, REGISTRY_HOST: registryHost, PORT: o.port,
|
|
147
|
+
REGISTRY_AUTH_PATH: o.registryAuthPath || '',
|
|
148
|
+
REGISTRY_CONTAINER: o.registryContainer || ''
|
|
149
|
+
});
|
|
150
|
+
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
151
|
+
COMPOSE: compose, OPERATOR_TOKEN_SH: sh(operatorToken)
|
|
152
|
+
});
|
|
153
|
+
runLocal('ssh', [sshTarget, remote]);
|
|
154
|
+
console.log(JSON.stringify({ ok: true, sshTarget, operatorToken, url: `http://${remoteHost(sshTarget)}:${o.port}`, registry: registryHost }, null, 2));
|
|
155
|
+
});
|
|
156
|
+
const node = program.command('node');
|
|
157
|
+
node.command('install <sshTarget>')
|
|
158
|
+
.description('register and install an agent container on a Docker-capable host over SSH')
|
|
159
|
+
.requiredOption('--name <name>')
|
|
160
|
+
.option('--address <addr>')
|
|
161
|
+
.option('--public-address <addr>')
|
|
162
|
+
.option('--control-url <url>', 'URL agents use to reach control')
|
|
163
|
+
.option('--registry <host>', 'registry host for docker login')
|
|
164
|
+
.option('--http-only-dev', 'allow plaintext HTTP control URL for local/private development')
|
|
165
|
+
.option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
|
|
166
|
+
.option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
|
|
167
|
+
.option('--image <tag>', 'Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI\'s version>)')
|
|
168
|
+
.option('--build-local-image', 'build the image from the current checkout and copy it to the host over SSH (tags it tug:local unless --image is set); for development')
|
|
169
|
+
.action(async (sshTarget, o) => {
|
|
170
|
+
const cfg = await loadCfg();
|
|
171
|
+
const controlUrl = o.controlUrl || cfg.controlUrl || process.env.TUG_CONTROL_URL || 'http://localhost:8080';
|
|
172
|
+
assertHttps(controlUrl, o.httpOnlyDev);
|
|
173
|
+
preflightRemote(sshTarget, { controlUrl, mesh: o.mesh, udpPorts: o.mesh ? [Number(o.wgPort)] : [] });
|
|
174
|
+
const registered = await request('POST', '/api/v1/nodes/register', { name: o.name, address: o.address, publicAddress: o.publicAddress });
|
|
175
|
+
const image = resolveImage(o.image, o.buildLocalImage);
|
|
176
|
+
if (o.buildLocalImage)
|
|
177
|
+
await transferTugImage(sshTarget, image);
|
|
178
|
+
const env = await loadTemplate(templatesDir, 'agent.env', {
|
|
179
|
+
CONTROL_URL: controlUrl, NODE_NAME: o.name, AGENT_TOKEN: registered.agentToken
|
|
180
|
+
}) + (o.mesh ? 'TUG_MESH=true\n' : '');
|
|
181
|
+
const dockerLogin = o.registry ? `printf %s ${sh(registered.registryToken)} | docker login ${sh(o.registry)} --username ${sh(o.name)} --password-stdin` : 'true';
|
|
182
|
+
const remote = await loadTemplate(templatesDir, 'node-install.sh', {
|
|
183
|
+
ENV: env, DOCKER_LOGIN: dockerLogin, IMAGE_SH: sh(image), RUN_FLAGS: o.mesh ? `${MESH_RUN_FLAGS} ` : ''
|
|
184
|
+
});
|
|
185
|
+
runLocal('ssh', [sshTarget, remote]);
|
|
186
|
+
console.log(JSON.stringify({ ok: true, node: registered.node, registryToken: registered.registryToken }, null, 2));
|
|
187
|
+
});
|
|
188
|
+
function dockerRunSnippet(image, joinKey, mesh = false) {
|
|
189
|
+
return `docker run -d --name tug-agent --restart unless-stopped \\
|
|
190
|
+
${mesh ? ` ${MESH_RUN_FLAGS} -e TUG_MESH=true \\\n` : ''} -v /var/run/docker.sock:/var/run/docker.sock \\
|
|
191
|
+
-v tug-agent:/var/lib/tug \\
|
|
192
|
+
-e TUG_JOIN_KEY=${joinKey} \\
|
|
193
|
+
${image} agent`;
|
|
194
|
+
}
|
|
195
|
+
node.command('enroll')
|
|
196
|
+
.description('mint a single-use enrollment key so a fresh host can self-join with one docker run (no SSH)')
|
|
197
|
+
.option('--name <name>', 'pre-bind the node name (default: the agent uses its own hostname)')
|
|
198
|
+
.option('--control-url <url>', 'control URL the agent will connect to')
|
|
199
|
+
.option('--ttl <seconds>', 'enrollment key lifetime', '3600')
|
|
200
|
+
.option('--uses <n>', 'how many hosts may enroll with this key', '1')
|
|
201
|
+
.option('--ca <file>', 'PEM CA cert to embed so agents trust a private/self-signed control')
|
|
202
|
+
.option('--http-only-dev', 'allow a plaintext HTTP control URL for local/private development')
|
|
203
|
+
.option('--image <tag>', 'agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI\'s version>)')
|
|
204
|
+
.option('--mesh', 'print a docker run that joins the WireGuard mesh (host networking, NET_ADMIN)')
|
|
205
|
+
.option('--json', 'print machine-readable JSON instead of the docker run snippet')
|
|
206
|
+
.action(async (o) => {
|
|
207
|
+
const cfg = await loadCfg();
|
|
208
|
+
const controlUrl = o.controlUrl || cfg.controlUrl || process.env.TUG_CONTROL_URL || 'http://localhost:8080';
|
|
209
|
+
assertHttps(controlUrl, o.httpOnlyDev);
|
|
210
|
+
const minted = await request('POST', '/api/v1/nodes/enroll-token', { name: o.name || null, ttlSeconds: Number(o.ttl), uses: Number(o.uses) });
|
|
211
|
+
const keyPayload = { v: 1, controlUrl, enrollToken: minted.enrollToken };
|
|
212
|
+
if (o.ca)
|
|
213
|
+
keyPayload.ca = await fs.readFile(o.ca, 'utf8');
|
|
214
|
+
const joinKey = Buffer.from(JSON.stringify(keyPayload)).toString('base64url');
|
|
215
|
+
if (o.json) {
|
|
216
|
+
console.log(JSON.stringify({ ...minted, controlUrl, joinKey }, null, 2));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
console.log(`# Enrollment key valid until ${minted.expiresAt} (${minted.usesRemaining} use(s)). Run on each fresh host:\n`);
|
|
220
|
+
console.log(dockerRunSnippet(resolveImage(o.image), joinKey, o.mesh));
|
|
221
|
+
});
|
|
222
|
+
program.command('secret:set <name>').option('--project <name>').option('--value <value>').action(async (name, o) => {
|
|
223
|
+
const value = o.value ?? await new Promise(resolve => { process.stdout.write(`${name}: `); process.stdin.once('data', d => resolve(String(d).trim())); });
|
|
224
|
+
console.log(await request('POST', '/api/v1/secrets', { project: o.project || (await loadCfg()).project || 'site', name, value }));
|
|
225
|
+
});
|
|
226
|
+
program.command('secret:list').option('--project <name>').action(async (o) => console.log(JSON.stringify(await request('GET', `/api/v1/secrets?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`), null, 2)));
|
|
227
|
+
program.command('secret:rm <name>').option('--project <name>').action(async (name, o) => console.log(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`)));
|
|
228
|
+
async function makeContext() {
|
|
229
|
+
const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
|
|
230
|
+
const ignores = new Set(['.git', 'node_modules', 'data', 'registry-data']);
|
|
231
|
+
try {
|
|
232
|
+
(await fs.readFile('.dockerignore', 'utf8')).split(/\r?\n/).map(l => l.trim()).filter(Boolean).forEach(l => ignores.add(l));
|
|
233
|
+
}
|
|
234
|
+
catch { }
|
|
235
|
+
await tar.c({ gzip: true, file: tmp, cwd: process.cwd(), filter: (p) => ![...ignores].some(i => p === i || p.startsWith(`${i}/`)) }, ['.']);
|
|
236
|
+
return tmp;
|
|
237
|
+
}
|
|
238
|
+
program.command('deploy').option('-f, --file <file>', 'compose file', 'docker-compose.yml').option('--project <name>').option('--version <version>').option('--no-build-upload', 'do not upload source tarball/build context').option('--no-wait', 'return after desired state is created').option('--timeout <seconds>', 'wait timeout', '300').action(async (o) => {
|
|
239
|
+
const cfg = await loadCfg();
|
|
240
|
+
const compose = await readCompose(o.file);
|
|
241
|
+
const project = o.project || cfg.project || 'site';
|
|
242
|
+
const version = o.version || `git-${composeHash(compose)}`;
|
|
243
|
+
let response;
|
|
244
|
+
if (o.buildUpload) {
|
|
245
|
+
const form = new FormData();
|
|
246
|
+
form.append('payload', JSON.stringify({ project, version, compose }));
|
|
247
|
+
const tarball = await makeContext();
|
|
248
|
+
form.append('context', new Blob([await fs.readFile(tarball)]), 'context.tgz');
|
|
249
|
+
response = await request('POST', '/api/v1/deployments', form, true);
|
|
250
|
+
await fs.rm(tarball, { force: true });
|
|
251
|
+
}
|
|
252
|
+
else
|
|
253
|
+
response = await request('POST', '/api/v1/deployments', { project, version, compose });
|
|
254
|
+
if (o.wait)
|
|
255
|
+
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
256
|
+
console.log(JSON.stringify(response, null, 2));
|
|
257
|
+
});
|
|
258
|
+
program.command('status').action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/status'), null, 2)));
|
|
259
|
+
program.command('events').option('--project <name>').option('--limit <n>', 'number of events', '100').action(async (o) => {
|
|
260
|
+
const cfg = await loadCfg();
|
|
261
|
+
const project = o.project || cfg.project;
|
|
262
|
+
const query = `limit=${encodeURIComponent(o.limit)}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
|
|
263
|
+
console.log(JSON.stringify(await request('GET', `/api/v1/events?${query}`), null, 2));
|
|
264
|
+
});
|
|
265
|
+
program.command('doctor').description('run local and control-plane diagnostics').option('--nodes', 'also queue diagnostics on registered agents').option('--node <name>', 'specific node for agent diagnostics').action(async (o) => {
|
|
266
|
+
const local = [];
|
|
267
|
+
for (const [name, cmd, args] of [['node', 'node', ['--version']], ['docker', 'docker', ['version', '--format', '{{.Server.Version}}']], ['docker_compose', 'docker', ['compose', 'version']]]) {
|
|
268
|
+
try {
|
|
269
|
+
local.push({ name, ok: true, output: runCapture(cmd, args).trim() });
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
local.push({ name, ok: false, error: errMessage(e) });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
|
|
276
|
+
const output = { ok: local.every(c => c.ok) && controlDiagnostics.ok, local, control: controlDiagnostics };
|
|
277
|
+
if (o.nodes || o.node)
|
|
278
|
+
output.nodeDiagnostics = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
|
|
279
|
+
console.log(JSON.stringify(output, null, 2));
|
|
280
|
+
});
|
|
281
|
+
program.command('rollback').option('--project <name>').option('--force').option('--no-wait', 'return immediately after activating rollback').option('--timeout <seconds>', 'wait timeout', '300').action(async (o) => {
|
|
282
|
+
const cfg = await loadCfg();
|
|
283
|
+
const response = await request('POST', '/api/v1/rollback', { project: o.project || cfg.project || 'site', force: Boolean(o.force) });
|
|
284
|
+
if (o.wait)
|
|
285
|
+
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
286
|
+
console.log(JSON.stringify(response, null, 2));
|
|
287
|
+
});
|
|
288
|
+
program.command('deploy:cancel <id>').action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/deployments/${encodeURIComponent(id)}/cancel`, {}), null, 2)));
|
|
289
|
+
program.command('volume:list').option('--project <name>').action(async (o) => {
|
|
290
|
+
const cfg = await loadCfg();
|
|
291
|
+
console.log(JSON.stringify(await request('GET', `/api/v1/volumes?project=${encodeURIComponent(o.project || cfg.project || 'site')}`), null, 2));
|
|
292
|
+
});
|
|
293
|
+
program.command('volume:rm <name>').option('--project <name>').option('--delete-data', 'delete underlying Docker volume data').action(async (name, o) => {
|
|
294
|
+
const cfg = await loadCfg();
|
|
295
|
+
const project = o.project || cfg.project || 'site';
|
|
296
|
+
console.log(JSON.stringify(await request('DELETE', `/api/v1/volumes/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}&deleteData=${Boolean(o.deleteData)}`), null, 2));
|
|
297
|
+
});
|
|
298
|
+
const controlBackup = program.command('control-backup');
|
|
299
|
+
controlBackup.command('run').description('create a control-plane SQLite/master-key backup').action(async () => console.log(JSON.stringify(await request('POST', '/api/v1/control/backups/run', {}), null, 2)));
|
|
300
|
+
controlBackup.command('restore <artifactRef>').description('restore a control-plane backup; requires control restart').option('--confirm').action(async (artifactRef, o) => console.log(JSON.stringify(await request('POST', '/api/v1/control/restore', { artifactRef, confirm: Boolean(o.confirm) }), null, 2)));
|
|
301
|
+
async function waitForCommand(commandId, timeoutSeconds = 60) {
|
|
302
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
303
|
+
while (Date.now() < deadline) {
|
|
304
|
+
const cmd = await request('GET', `/api/v1/commands/${commandId}`);
|
|
305
|
+
if (cmd?.state === 'succeeded' || cmd?.state === 'failed')
|
|
306
|
+
return cmd;
|
|
307
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
308
|
+
}
|
|
309
|
+
throw new Error(`timed out waiting for command ${commandId}`);
|
|
310
|
+
}
|
|
311
|
+
program.command('restart <service>').option('--project <name>').option('--timeout <seconds>', 'wait timeout', '60').action(async (svc, o) => {
|
|
312
|
+
const cfg = await loadCfg();
|
|
313
|
+
const project = o.project || cfg.project || 'site';
|
|
314
|
+
const created = await request('POST', '/api/v1/restart', { project, service: svc });
|
|
315
|
+
const cmd = await waitForCommand(created.commandId, Number(o.timeout));
|
|
316
|
+
if (cmd.state === 'failed')
|
|
317
|
+
throw new Error(JSON.parse(cmd.result_json || '{}').error || 'restart failed');
|
|
318
|
+
console.log(JSON.stringify(cmd, null, 2));
|
|
319
|
+
});
|
|
320
|
+
program.command('logs <service>').description('fetch logs from the assigned node agent').option('--project <name>').option('--tail <n>', 'number of lines', '200').action(async (svc, o) => {
|
|
321
|
+
const cfg = await loadCfg();
|
|
322
|
+
const project = o.project || cfg.project || 'site';
|
|
323
|
+
const created = await request('POST', '/api/v1/logs', { project, service: svc, tail: Number(o.tail) });
|
|
324
|
+
const cmd = await waitForCommand(created.commandId, 60);
|
|
325
|
+
const result = JSON.parse(cmd.result_json || '{}');
|
|
326
|
+
if (result.stdout)
|
|
327
|
+
process.stdout.write(result.stdout);
|
|
328
|
+
if (result.stderr)
|
|
329
|
+
process.stderr.write(result.stderr);
|
|
330
|
+
if (result.error)
|
|
331
|
+
throw new Error(result.error);
|
|
332
|
+
});
|
|
333
|
+
program.parseAsync().catch((e) => { console.error(errMessage(e)); process.exit(1); });
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ComposeFile, ComposeService, DesiredState, ServiceEndpoint } from './protocol.ts';
|
|
2
|
+
export declare function readCompose(file?: string): Promise<ComposeFile>;
|
|
3
|
+
export declare function composeHash(compose: ComposeFile): string;
|
|
4
|
+
/**
|
|
5
|
+
* The host port a service publishes for `port`, matching either side of a `ports` entry (a
|
|
6
|
+
* container port resolves to its published host port). Null when the port is not published.
|
|
7
|
+
*/
|
|
8
|
+
export declare function publishedPort(svc: ComposeService, port: number): number | null;
|
|
9
|
+
export declare function validateCompose(compose: ComposeFile | undefined, nodes?: string[]): asserts compose is ComposeFile;
|
|
10
|
+
export interface BuildDesiredStateOptions {
|
|
11
|
+
project: string;
|
|
12
|
+
version: string;
|
|
13
|
+
compose: ComposeFile;
|
|
14
|
+
registry: string;
|
|
15
|
+
defaultNode?: string | null;
|
|
16
|
+
}
|
|
17
|
+
export declare function buildDesiredState({ project, version, compose, registry, defaultNode }: BuildDesiredStateOptions): DesiredState;
|
|
18
|
+
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
19
|
+
export declare function crossNodeHosts(project: string, endpoints: Record<string, ServiceEndpoint[]>, local: Set<string>): string[];
|
|
20
|
+
export declare function composeForNode(desiredState: DesiredState, nodeName: string, secrets?: Record<string, string>, endpoints?: Record<string, ServiceEndpoint[]>): ComposeFile;
|
|
21
|
+
export declare function serviceSecretNames(compose: ComposeFile | undefined, serviceNames: Iterable<string>): Set<string>;
|
|
22
|
+
export declare function serviceSecretVersions(compose: ComposeFile | undefined, serviceNames: Iterable<string>, versions?: Record<string, number>): Record<string, Record<string, number>>;
|
|
23
|
+
export declare function dumpYaml(obj: unknown): string;
|
package/dist/compose.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
export async function readCompose(file = 'docker-compose.yml') {
|
|
5
|
+
return yaml.load(await fs.readFile(file, 'utf8'));
|
|
6
|
+
}
|
|
7
|
+
export function composeHash(compose) {
|
|
8
|
+
return crypto.createHash('sha1').update(JSON.stringify(compose)).digest('hex').slice(0, 12);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The host port a service publishes for `port`, matching either side of a `ports` entry (a
|
|
12
|
+
* container port resolves to its published host port). Null when the port is not published.
|
|
13
|
+
*/
|
|
14
|
+
export function publishedPort(svc, port) {
|
|
15
|
+
for (const entry of svc.ports || []) {
|
|
16
|
+
if (typeof entry === 'object') {
|
|
17
|
+
const published = Number(entry.published);
|
|
18
|
+
if ((Number(entry.target) === port || published === port) && published > 0)
|
|
19
|
+
return published;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const spec = String(entry).split('/')[0].split(':');
|
|
23
|
+
if (spec.length < 2)
|
|
24
|
+
continue;
|
|
25
|
+
const container = Number(spec[spec.length - 1]);
|
|
26
|
+
const host = Number(spec[spec.length - 2]);
|
|
27
|
+
if ((container === port || host === port) && host > 0)
|
|
28
|
+
return host;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const HOSTNAME_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
33
|
+
function validateIngress(name, svc, seen, errors) {
|
|
34
|
+
const ingress = svc['x-tug']?.ingress;
|
|
35
|
+
if (ingress === undefined)
|
|
36
|
+
return;
|
|
37
|
+
if (!ingress || typeof ingress !== 'object') {
|
|
38
|
+
errors.push(`${name}: x-tug.ingress must be an object`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const host = typeof ingress.host === 'string' ? ingress.host.toLowerCase() : '';
|
|
42
|
+
if (!HOSTNAME_RE.test(host))
|
|
43
|
+
errors.push(`${name}: ingress.host must be a fully qualified hostname`);
|
|
44
|
+
else if (seen.has(host))
|
|
45
|
+
errors.push(`${name}: ingress.host ${host} is already used by ${seen.get(host)}`);
|
|
46
|
+
else
|
|
47
|
+
seen.set(host, name);
|
|
48
|
+
const port = Number(ingress.port);
|
|
49
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
50
|
+
errors.push(`${name}: ingress.port must be a port number`);
|
|
51
|
+
else if (publishedPort(svc, port) == null)
|
|
52
|
+
errors.push(`${name}: ingress.port ${port} must be published in ports (e.g. "8080:${port}"); the edge reaches the service on its published host port`);
|
|
53
|
+
const tls = ingress.tls ?? 'auto';
|
|
54
|
+
if (!['auto', 'custom', 'off'].includes(tls))
|
|
55
|
+
errors.push(`${name}: ingress.tls must be auto, custom or off`);
|
|
56
|
+
if (tls === 'custom' && (!ingress.cert || !ingress.key))
|
|
57
|
+
errors.push(`${name}: ingress.tls custom requires cert and key (names of project secrets holding the PEM certificate and private key)`);
|
|
58
|
+
}
|
|
59
|
+
export function validateCompose(compose, nodes = []) {
|
|
60
|
+
if (!compose?.services || typeof compose.services !== 'object')
|
|
61
|
+
throw new Error('compose file must define services');
|
|
62
|
+
const errors = [];
|
|
63
|
+
const ingressHosts = new Map();
|
|
64
|
+
for (const [name, svc] of Object.entries(compose.services)) {
|
|
65
|
+
if (svc.container_name)
|
|
66
|
+
errors.push(`${name}: container_name is not supported`);
|
|
67
|
+
if (svc.deploy)
|
|
68
|
+
errors.push(`${name}: swarm deploy semantics are not supported`);
|
|
69
|
+
const lp = svc['x-tug'] || {};
|
|
70
|
+
const selected = lp.placement?.nodes || [];
|
|
71
|
+
if (nodes.length > 1 && selected.length === 0)
|
|
72
|
+
errors.push(`${name}: multi-node deployments require x-tug.placement.nodes`);
|
|
73
|
+
if (lp.replicas && selected.length && lp.replicas !== selected.length)
|
|
74
|
+
errors.push(`${name}: replicas must match placement node count in v1`);
|
|
75
|
+
validateIngress(name, svc, ingressHosts, errors);
|
|
76
|
+
}
|
|
77
|
+
if (errors.length)
|
|
78
|
+
throw new Error(errors.join('; '));
|
|
79
|
+
}
|
|
80
|
+
export function buildDesiredState({ project, version, compose, registry, defaultNode = null }) {
|
|
81
|
+
const services = {};
|
|
82
|
+
for (const [name, src] of Object.entries(compose.services || {})) {
|
|
83
|
+
const lp = src['x-tug'] || {};
|
|
84
|
+
const placement = lp.placement || (defaultNode ? { nodes: [defaultNode] } : {});
|
|
85
|
+
const image = src.build ? `${registry}/${project}/${name}:${version}` : src.image;
|
|
86
|
+
services[name] = { image, build: src.build || null, placement, replicas: lp.replicas || placement.nodes?.length || 1, health: lp.health || null };
|
|
87
|
+
}
|
|
88
|
+
return { project, version, services, compose };
|
|
89
|
+
}
|
|
90
|
+
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
91
|
+
export function crossNodeHosts(project, endpoints, local) {
|
|
92
|
+
const hosts = [];
|
|
93
|
+
for (const [service, list] of Object.entries(endpoints).sort(([a], [b]) => a.localeCompare(b))) {
|
|
94
|
+
if (local.has(service))
|
|
95
|
+
continue;
|
|
96
|
+
for (const ep of list)
|
|
97
|
+
hosts.push(`${service}:${ep.address}`, `${service}.${project}.tug:${ep.address}`);
|
|
98
|
+
}
|
|
99
|
+
return hosts;
|
|
100
|
+
}
|
|
101
|
+
function extraHostsList(value) {
|
|
102
|
+
if (!value)
|
|
103
|
+
return [];
|
|
104
|
+
return Array.isArray(value) ? value.map(String) : Object.entries(value).map(([k, v]) => `${k}:${v}`);
|
|
105
|
+
}
|
|
106
|
+
export function composeForNode(desiredState, nodeName, secrets = {}, endpoints = {}) {
|
|
107
|
+
const out = { services: {}, volumes: {} };
|
|
108
|
+
const outVolumes = out.volumes;
|
|
109
|
+
const usedVolumes = new Set();
|
|
110
|
+
for (const [name, src] of Object.entries(desiredState.compose.services || {})) {
|
|
111
|
+
const lp = src['x-tug'] || {};
|
|
112
|
+
const nodes = lp.placement?.nodes || [];
|
|
113
|
+
if (nodes.length && !nodes.includes(nodeName))
|
|
114
|
+
continue;
|
|
115
|
+
const svc = { ...src, image: desiredState.services[name].image };
|
|
116
|
+
delete svc.build;
|
|
117
|
+
delete svc['x-tug'];
|
|
118
|
+
delete svc.deploy;
|
|
119
|
+
delete svc.container_name;
|
|
120
|
+
if (Object.keys(secrets).length)
|
|
121
|
+
svc.env_file = Array.from(new Set([...(Array.isArray(svc.env_file) ? svc.env_file : svc.env_file ? [svc.env_file] : []), '.env']));
|
|
122
|
+
for (const vol of svc.volumes || []) {
|
|
123
|
+
const source = typeof vol === 'string' ? vol.split(':')[0] : vol.source;
|
|
124
|
+
if (source && desiredState.compose?.volumes?.[source])
|
|
125
|
+
usedVolumes.add(source);
|
|
126
|
+
}
|
|
127
|
+
out.services[name] = svc;
|
|
128
|
+
}
|
|
129
|
+
const hosts = crossNodeHosts(desiredState.project, endpoints, new Set(Object.keys(out.services)));
|
|
130
|
+
if (hosts.length) {
|
|
131
|
+
for (const svc of Object.values(out.services)) {
|
|
132
|
+
const existing = extraHostsList(svc.extra_hosts);
|
|
133
|
+
const taken = new Set(existing.map(h => h.split(':')[0]));
|
|
134
|
+
svc.extra_hosts = [...existing, ...hosts.filter(h => !taken.has(h.split(':')[0]))];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const name of usedVolumes) {
|
|
138
|
+
const vol = desiredState.compose.volumes?.[name] || {};
|
|
139
|
+
const nodes = vol['x-tug']?.placement?.nodes || [];
|
|
140
|
+
if (!nodes.length || nodes.includes(nodeName))
|
|
141
|
+
outVolumes[name] = vol;
|
|
142
|
+
}
|
|
143
|
+
if (!Object.keys(outVolumes).length)
|
|
144
|
+
delete out.volumes;
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
export function serviceSecretNames(compose, serviceNames) {
|
|
148
|
+
const allowed = new Set();
|
|
149
|
+
for (const serviceName of serviceNames) {
|
|
150
|
+
const env = compose?.services?.[serviceName]?.environment || {};
|
|
151
|
+
if (Array.isArray(env)) {
|
|
152
|
+
for (const entry of env) {
|
|
153
|
+
const [key, value = ''] = String(entry).split('=');
|
|
154
|
+
if (key)
|
|
155
|
+
allowed.add(key);
|
|
156
|
+
for (const match of String(value).matchAll(/\$\{?([A-Z_][A-Z0-9_]*)[^}]*\}?/g))
|
|
157
|
+
allowed.add(match[1]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
for (const [key, value] of Object.entries(env)) {
|
|
162
|
+
if (typeof value === 'string' && value.match(/^\$\{?[A-Z_][A-Z0-9_]*(?::-[^}]*)?\}?$/)) {
|
|
163
|
+
const m = value.match(/^\$\{?([A-Z_][A-Z0-9_]*)/);
|
|
164
|
+
if (m)
|
|
165
|
+
allowed.add(m[1]);
|
|
166
|
+
}
|
|
167
|
+
else
|
|
168
|
+
allowed.add(key);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return allowed;
|
|
173
|
+
}
|
|
174
|
+
export function serviceSecretVersions(compose, serviceNames, versions = {}) {
|
|
175
|
+
const out = {};
|
|
176
|
+
for (const service of serviceNames) {
|
|
177
|
+
const names = serviceSecretNames(compose, [service]);
|
|
178
|
+
out[service] = Object.fromEntries([...names].filter(name => versions[name] != null).map(name => [name, versions[name]]));
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
export function dumpYaml(obj) { return yaml.dump(obj, { noRefs: true, lineWidth: 120 }); }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import type { NodeRow } from './db.ts';
|
|
5
|
+
declare module 'express-serve-static-core' {
|
|
6
|
+
interface Request {
|
|
7
|
+
node?: NodeRow;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export declare function createApp(): Promise<import("express-serve-static-core").Express>;
|
|
11
|
+
export declare function listen(app: express.Express, port?: number): Promise<import('node:http').Server | https.Server>;
|