@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.
- package/README.md +63 -0
- package/dist/agent.d.ts +40 -0
- package/dist/agent.js +547 -0
- package/dist/archive.d.ts +2 -0
- package/dist/archive.js +20 -0
- package/dist/artifacts.d.ts +23 -0
- package/dist/artifacts.js +238 -0
- package/dist/build.d.ts +22 -0
- package/dist/build.js +44 -0
- package/dist/cli-build.d.ts +15 -0
- package/dist/cli-build.js +50 -0
- package/dist/cli-config.d.ts +20 -0
- package/dist/cli-config.js +47 -0
- package/dist/cli-install.d.ts +7 -0
- package/dist/cli-install.js +31 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +422 -0
- package/dist/compose.d.ts +33 -0
- package/dist/compose.js +260 -0
- package/dist/control.d.ts +24 -0
- package/dist/control.js +1105 -0
- package/dist/db.d.ts +117 -0
- package/dist/db.js +51 -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 +180 -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 +326 -0
- package/dist/protocol.js +8 -0
- package/dist/rollout.d.ts +72 -0
- package/dist/rollout.js +319 -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/install-docker.sh +85 -0
- package/dist/templates/node-install.sh +8 -0
- package/dist/tokens.d.ts +91 -0
- package/dist/tokens.js +165 -0
- package/dist/util.d.ts +15 -0
- package/dist/util.js +35 -0
- package/package.json +62 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, Option } from 'commander';
|
|
3
|
+
import { openAsBlob } from 'node:fs';
|
|
4
|
+
import { resolveCliConfig, integerOption, normalizeControlUrl } from "./cli-config.js";
|
|
5
|
+
import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, MESH_RUN_FLAGS, meshPortCheck } from "./cli-install.js";
|
|
6
|
+
import { createSourceArchive, prepareLocalImages } from "./cli-build.js";
|
|
7
|
+
import { saveImageArchive } from "./artifacts.js";
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { readCompose, composeHash } from "./compose.js";
|
|
14
|
+
import { errMessage, token, loadTemplate } from "./util.js";
|
|
15
|
+
import { PROJECT_NAME_PATTERN, ROLES, TOKEN_NAME_PATTERN } from "./tokens.js";
|
|
16
|
+
import { packageVersion, resolveImage } from "./image.js";
|
|
17
|
+
import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
|
|
18
|
+
// Templates live next to this file: src/templates in dev (`node src/cli.ts`), dist/templates in the
|
|
19
|
+
// built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const templatesDir = path.join(__dirname, 'templates');
|
|
22
|
+
const program = new Command();
|
|
23
|
+
program.name('tug').description('Portable VPS deployments with Docker Compose').version(packageVersion()).enablePositionalOptions()
|
|
24
|
+
.option('--config <file>', 'CLI JSON config file (default: TUG_CONFIG or .tug.json)')
|
|
25
|
+
.option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)')
|
|
26
|
+
.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
|
|
27
|
+
let resolvedConfig;
|
|
28
|
+
const cfgPath = () => resolvedConfig.file;
|
|
29
|
+
async function loadCfg() { return resolvedConfig; }
|
|
30
|
+
async function saveCfg(c) { await fs.writeFile(cfgPath(), JSON.stringify(c, null, 2), { mode: 0o600 }); }
|
|
31
|
+
async function request(method, route, body, multipart = false) {
|
|
32
|
+
const c = await loadCfg();
|
|
33
|
+
const base = c.controlUrl;
|
|
34
|
+
const tok = c.operatorToken;
|
|
35
|
+
const headers = tok ? { Authorization: `Bearer ${tok}` } : {};
|
|
36
|
+
if (!multipart)
|
|
37
|
+
headers['content-type'] = 'application/json';
|
|
38
|
+
const res = await fetch(`${base}${route}`, { method, headers, body: multipart ? body : body ? JSON.stringify(body) : undefined });
|
|
39
|
+
if (!res.ok)
|
|
40
|
+
throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
|
|
41
|
+
return res.json();
|
|
42
|
+
}
|
|
43
|
+
function runLocal(cmd, args, opts = {}) {
|
|
44
|
+
const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
|
|
45
|
+
if (result.status !== 0)
|
|
46
|
+
throw new Error(`${cmd} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}`);
|
|
47
|
+
}
|
|
48
|
+
function runCapture(cmd, args, opts = {}) {
|
|
49
|
+
const result = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
|
|
50
|
+
if (result.status !== 0)
|
|
51
|
+
throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
|
|
52
|
+
return String(result.stdout);
|
|
53
|
+
}
|
|
54
|
+
function remoteHost(sshTarget) { return sshTarget.replace(/^[^@]+@/, '').split(':')[0]; }
|
|
55
|
+
function assertHttps(url, allowHttp) {
|
|
56
|
+
if (!allowHttp && !String(url).startsWith('https://'))
|
|
57
|
+
throw new Error(`${url} must use https:// outside local development (pass --http-only-dev to override)`);
|
|
58
|
+
}
|
|
59
|
+
function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDiskMb = 1024, mesh = false } = {}) {
|
|
60
|
+
const checks = [
|
|
61
|
+
['docker.version', 'docker version >/dev/null', 'Install Docker Engine and ensure the SSH user can access it.'],
|
|
62
|
+
['docker.compose.version', 'docker compose version >/dev/null', 'Install Docker Compose v2.'],
|
|
63
|
+
['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.'],
|
|
64
|
+
['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.`]
|
|
65
|
+
];
|
|
66
|
+
for (const port of ports) {
|
|
67
|
+
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.`]);
|
|
68
|
+
}
|
|
69
|
+
if (mesh) {
|
|
70
|
+
checks.push(['wireguard.kernel', 'ip link add tugwgtest type wireguard && ip link del tugwgtest || 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.']);
|
|
71
|
+
}
|
|
72
|
+
for (const port of udpPorts) {
|
|
73
|
+
checks.push([`udp.${port}.free`, meshPortCheck(port), `UDP port ${port} is in use by something other than this node's running Tug mesh agent (or the ss tool is missing). Stop the conflicting service, or choose another port with --wg-port and set TUG_WG_PORT on control.`]);
|
|
74
|
+
}
|
|
75
|
+
if (controlUrl)
|
|
76
|
+
checks.push(['control.reachable', `curl -fsS ${sh(controlUrl)}/healthz >/dev/null`, 'Verify DNS/firewall/TLS for the control URL from the node.']);
|
|
77
|
+
const script = `set -e
|
|
78
|
+
${checks.map(([name, cmd, fix]) => `echo "preflight: ${name}"; (${cmd}) || { echo "FAILED ${name}: ${fix}" >&2; exit 20; }`).join('\n')}
|
|
79
|
+
`;
|
|
80
|
+
runLocal('ssh', ['--', target, script]);
|
|
81
|
+
}
|
|
82
|
+
async function transferTugImage(target, imageTag) {
|
|
83
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-runtime-'));
|
|
84
|
+
const archive = path.join(directory, 'image.tar.gz');
|
|
85
|
+
const remote = `/tmp/${path.basename(directory)}.tar.gz`;
|
|
86
|
+
try {
|
|
87
|
+
runLocal('docker', ['build', '-t', imageTag, '.']);
|
|
88
|
+
await saveImageArchive(imageTag, archive);
|
|
89
|
+
runLocal('scp', ['--', archive, `${target}:${remote}`]);
|
|
90
|
+
runLocal('ssh', ['--', target, `set -eu\ntrap ${sh(`rm -f ${sh(remote)}`)} EXIT\ndocker load --input ${sh(remote)}`]);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function dockerBootstrap() { return fs.readFile(path.join(templatesDir, 'install-docker.sh'), 'utf8'); }
|
|
97
|
+
async function bootstrapDocker(target) { runLocal('ssh', ['--', target, `sh -eu -c ${sh(await dockerBootstrap())}`]); }
|
|
98
|
+
async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
99
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
const deployment = await request('GET', `/api/v1/deployments/${encodeURIComponent(deploymentId)}`);
|
|
102
|
+
if (TERMINAL_DEPLOYMENT_STATES.includes(deployment.state)) {
|
|
103
|
+
if (deployment.state !== 'succeeded')
|
|
104
|
+
throw new Error(`deployment ${deploymentId} ${deployment.state}${deployment.error_message ? `: ${deployment.error_message}` : ''}`);
|
|
105
|
+
return deployment;
|
|
106
|
+
}
|
|
107
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`timed out waiting for deployment ${deploymentId}`);
|
|
110
|
+
}
|
|
111
|
+
program.command('init').option('--token <token>', 'operator token (prefer TUG_TOKEN to avoid process-list exposure)').action(async (o) => {
|
|
112
|
+
const cfg = await loadCfg();
|
|
113
|
+
const operatorToken = o.token || cfg.operatorToken || token('tug_op');
|
|
114
|
+
await saveCfg({ controlUrl: cfg.controlUrl, operatorToken, project: cfg.project });
|
|
115
|
+
console.log(`wrote ${cfgPath()}`);
|
|
116
|
+
});
|
|
117
|
+
program.command('node-register <name>').description('register a node with the control plane').option('--address <addr>').option('--public-address <addr>').action(async (name, o) => {
|
|
118
|
+
const r = await request('POST', '/api/v1/nodes/register', { name, address: o.address, publicAddress: o.publicAddress });
|
|
119
|
+
console.log(JSON.stringify(r, null, 2));
|
|
120
|
+
});
|
|
121
|
+
program.command('node-rotate-registry-token <name>').description('issue a new registry pull token for a node, invalidating the old one').action(async (name) => {
|
|
122
|
+
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-registry-token`, {}), null, 2));
|
|
123
|
+
});
|
|
124
|
+
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) => {
|
|
125
|
+
const created = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-wg-key`, {});
|
|
126
|
+
const cmd = await waitForCommand(created.commandId, Number(o.timeout));
|
|
127
|
+
if (cmd.state === 'failed')
|
|
128
|
+
throw new Error(JSON.parse(cmd.result_json || '{}').error || 'key rotation failed');
|
|
129
|
+
console.log(JSON.stringify({ ok: true, node: name, ...JSON.parse(cmd.result_json || '{}') }, null, 2));
|
|
130
|
+
});
|
|
131
|
+
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) => {
|
|
132
|
+
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, { enabled: !o.disable }), null, 2));
|
|
133
|
+
});
|
|
134
|
+
program.command('node-revoke-registry-token <name>').description('revoke a node\'s registry pull token, blocking future pulls until rotated').action(async (name) => {
|
|
135
|
+
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/revoke-registry-token`, {}), null, 2));
|
|
136
|
+
});
|
|
137
|
+
program.command('control-token').description('print local dev operator token if available').action(async () => console.log((await fs.readFile('./data/operator.token', 'utf8')).trim()));
|
|
138
|
+
program.command('control-install <sshTarget>')
|
|
139
|
+
.description('install the control plane on a Docker-capable host over SSH; front it with your own reverse proxy/TLS if exposing it publicly')
|
|
140
|
+
.option('--image <tag>', 'Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI\'s version>)')
|
|
141
|
+
.option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
|
|
142
|
+
.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')
|
|
143
|
+
.option('--token <token>', 'operator token')
|
|
144
|
+
.option('--port <port>', 'published API port', '8080')
|
|
145
|
+
.option('--registry <host>', 'registry host used in desired images; bring your own registry, Tug does not provision one')
|
|
146
|
+
.option('--registry-auth-path <path>', 'container path to an htpasswd file tug-control should manage credentials in, if your registry uses one')
|
|
147
|
+
.option('--registry-container <name>', 'docker container name to restart when a node\'s registry credentials change, if your registry uses an htpasswd file')
|
|
148
|
+
.action(async (sshTarget, o) => {
|
|
149
|
+
const operatorToken = o.token || process.env.TUG_TOKEN || token('tug_op');
|
|
150
|
+
if (o.installDocker)
|
|
151
|
+
await bootstrapDocker(sshTarget);
|
|
152
|
+
preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048 });
|
|
153
|
+
const image = resolveImage(o.image, o.buildLocalImage);
|
|
154
|
+
if (o.buildLocalImage)
|
|
155
|
+
await transferTugImage(sshTarget, image);
|
|
156
|
+
const registryHost = o.registry || '';
|
|
157
|
+
const yamlValue = (value) => JSON.stringify(value);
|
|
158
|
+
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
159
|
+
IMAGE: yamlValue(image), OPERATOR_TOKEN: yamlValue(operatorToken), REGISTRY_HOST: yamlValue(registryHost), PORT: o.port,
|
|
160
|
+
REGISTRY_AUTH_PATH: yamlValue(o.registryAuthPath || ''),
|
|
161
|
+
REGISTRY_CONTAINER: yamlValue(o.registryContainer || '')
|
|
162
|
+
});
|
|
163
|
+
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
164
|
+
COMPOSE: compose, OPERATOR_TOKEN_SH: sh(operatorToken)
|
|
165
|
+
});
|
|
166
|
+
runLocal('ssh', ['--', sshTarget, remote]);
|
|
167
|
+
console.log(JSON.stringify({ ok: true, sshTarget, operatorToken, url: `http://${remoteHost(sshTarget)}:${o.port}`, registry: registryHost }, null, 2));
|
|
168
|
+
});
|
|
169
|
+
program.command('node-install <sshTarget>')
|
|
170
|
+
.description('register and install an agent container on a Docker-capable host over SSH')
|
|
171
|
+
.requiredOption('--name <name>')
|
|
172
|
+
.option('--address <addr>')
|
|
173
|
+
.option('--public-address <addr>')
|
|
174
|
+
.option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
|
|
175
|
+
.option('--registry <host>', 'registry host for docker login')
|
|
176
|
+
.option('--http-only-dev', 'allow plaintext HTTP control URL for local/private development')
|
|
177
|
+
.option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
|
|
178
|
+
.option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
|
|
179
|
+
.option('--image <tag>', 'Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI\'s version>)')
|
|
180
|
+
.option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
|
|
181
|
+
.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')
|
|
182
|
+
.action(async (sshTarget, o) => {
|
|
183
|
+
const cfg = await loadCfg();
|
|
184
|
+
const controlUrl = normalizeControlUrl(o.agentControlUrl ?? cfg.controlUrl);
|
|
185
|
+
assertHttps(controlUrl, o.httpOnlyDev);
|
|
186
|
+
if (o.installDocker)
|
|
187
|
+
await bootstrapDocker(sshTarget);
|
|
188
|
+
preflightRemote(sshTarget, { controlUrl, mesh: o.mesh, udpPorts: o.mesh ? [Number(o.wgPort)] : [] });
|
|
189
|
+
const registered = await request('POST', '/api/v1/nodes/register', { name: o.name, address: o.address, publicAddress: o.publicAddress });
|
|
190
|
+
const image = resolveImage(o.image, o.buildLocalImage);
|
|
191
|
+
if (o.buildLocalImage)
|
|
192
|
+
await transferTugImage(sshTarget, image);
|
|
193
|
+
const env = await loadTemplate(templatesDir, 'agent.env', {
|
|
194
|
+
CONTROL_URL: controlUrl, NODE_NAME: o.name, AGENT_TOKEN: registered.agentToken
|
|
195
|
+
}) + (o.mesh ? 'TUG_MESH=true\n' : '');
|
|
196
|
+
const dockerLogin = o.registry ? `printf %s ${sh(registered.registryToken)} | docker login ${sh(o.registry)} --username ${sh(o.name)} --password-stdin` : 'true';
|
|
197
|
+
const remote = await loadTemplate(templatesDir, 'node-install.sh', {
|
|
198
|
+
ENV: env, DOCKER_LOGIN: dockerLogin, IMAGE_SH: sh(image), RUN_FLAGS: o.mesh ? `${MESH_RUN_FLAGS} ` : ''
|
|
199
|
+
});
|
|
200
|
+
runLocal('ssh', ['--', sshTarget, remote]);
|
|
201
|
+
console.log(JSON.stringify({ ok: true, node: registered.node, registryToken: registered.registryToken }, null, 2));
|
|
202
|
+
});
|
|
203
|
+
program.command('node-enroll')
|
|
204
|
+
.description('mint a single-use enrollment key so a fresh host can self-join with one docker run (no SSH)')
|
|
205
|
+
.option('--name <name>', 'pre-bind the node name (default: the agent uses its own hostname)')
|
|
206
|
+
.option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
|
|
207
|
+
.option('--ttl <seconds>', 'enrollment key lifetime', '3600')
|
|
208
|
+
.option('--uses <n>', 'how many hosts may enroll with this key', '1')
|
|
209
|
+
.option('--ca <file>', 'PEM CA cert to embed so agents trust a private/self-signed control')
|
|
210
|
+
.option('--http-only-dev', 'allow a plaintext HTTP control URL for local/private development')
|
|
211
|
+
.option('--image <tag>', 'agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI\'s version>)')
|
|
212
|
+
.option('--mesh', 'print a docker run that joins the WireGuard mesh (host networking, NET_ADMIN)')
|
|
213
|
+
.option('--install-docker', 'print cloud-init that installs Docker and starts the agent')
|
|
214
|
+
.option('--json', 'print JSON including the command or cloud-init payload')
|
|
215
|
+
.action(async (o) => {
|
|
216
|
+
const cfg = await loadCfg();
|
|
217
|
+
const controlUrl = normalizeControlUrl(o.agentControlUrl ?? cfg.controlUrl);
|
|
218
|
+
assertHttps(controlUrl, o.httpOnlyDev);
|
|
219
|
+
const ca = o.ca ? await fs.readFile(o.ca, 'utf8') : undefined;
|
|
220
|
+
const minted = await request('POST', '/api/v1/nodes/enroll-token', { name: o.name || null, ttlSeconds: Number(o.ttl), uses: Number(o.uses) });
|
|
221
|
+
const keyPayload = { v: 1, controlUrl, enrollToken: minted.enrollToken };
|
|
222
|
+
if (ca)
|
|
223
|
+
keyPayload.ca = ca;
|
|
224
|
+
const joinKey = Buffer.from(JSON.stringify(keyPayload)).toString('base64url');
|
|
225
|
+
const command = dockerRunSnippet(resolveImage(o.image), joinKey, o.mesh);
|
|
226
|
+
const cloudInit = o.installDocker ? enrollmentCloudInit(await dockerBootstrap(), command) : undefined;
|
|
227
|
+
if (o.json) {
|
|
228
|
+
console.log(JSON.stringify({ ...minted, controlUrl, joinKey, command, ...(cloudInit ? { cloudInit } : {}) }, null, 2));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (cloudInit) {
|
|
232
|
+
process.stdout.write(cloudInit);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
console.log(`# Enrollment key valid until ${minted.expiresAt} (${minted.usesRemaining} use(s)). Run on each fresh host:\n`);
|
|
236
|
+
console.log(command);
|
|
237
|
+
});
|
|
238
|
+
program.command('secret-set <name>').option('--value <value>').action(async (name, o) => {
|
|
239
|
+
const value = o.value ?? await new Promise(resolve => { process.stdout.write(`${name}: `); process.stdin.once('data', d => resolve(String(d).trim())); });
|
|
240
|
+
console.log(await request('POST', '/api/v1/secrets', { project: o.project || (await loadCfg()).project || 'site', name, value }));
|
|
241
|
+
});
|
|
242
|
+
program.command('secret-list').action(async (o) => console.log(JSON.stringify(await request('GET', `/api/v1/secrets?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`), null, 2)));
|
|
243
|
+
program.command('secret-remove <name>').action(async (name, o) => console.log(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`)));
|
|
244
|
+
program.command('token-create <name>')
|
|
245
|
+
.description('create a named API token for CI or a teammate; the secret is printed once and cannot be shown again')
|
|
246
|
+
.addOption(new Option('--role <role>', 'read: status and listings; deploy: read plus deploy, rollback, cancel, restart, logs; admin: everything').choices(ROLES).makeOptionMandatory())
|
|
247
|
+
.addOption(new Option('--project <name>', 'limit a read or deploy token to this project; repeat for several').default([], 'all projects').argParser((value, previous) => [...previous, value]))
|
|
248
|
+
.option('--expires-in-days <n>', 'expire the token after this many days (default: never)')
|
|
249
|
+
.action(async (name, o) => {
|
|
250
|
+
const created = await request('POST', '/api/v1/tokens', { name, role: o.role, projects: o.project.length ? o.project : undefined, expiresInSeconds: o.expiresInDays ? Number(o.expiresInDays) * 86400 : undefined });
|
|
251
|
+
console.error('Store this token now (for example as the TUG_TOKEN secret in CI); it is not shown again.');
|
|
252
|
+
console.log(JSON.stringify(created, null, 2));
|
|
253
|
+
});
|
|
254
|
+
program.command('token-list').description('list API tokens with role, projects, expiry and last use; secrets are never shown').action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/tokens'), null, 2)));
|
|
255
|
+
program.command('token-revoke <name>').description('revoke an API token immediately; its name stays reserved for the audit trail').action(async (name) => console.log(JSON.stringify(await request('DELETE', `/api/v1/tokens/${encodeURIComponent(name)}`), null, 2)));
|
|
256
|
+
async function makeContext() {
|
|
257
|
+
const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
|
|
258
|
+
try {
|
|
259
|
+
await createSourceArchive(process.cwd(), tmp, [cfgPath()]);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
await fs.rm(tmp, { force: true });
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
return tmp;
|
|
266
|
+
}
|
|
267
|
+
program.command('deploy').option('-f, --file <file>', 'compose file', 'docker-compose.yml').option('--version <version>', 'display version (default: git-<Compose hash>)').option('--build-local', 'build application images locally and upload them without source').option('--no-build-upload', 'deploy published images without uploading source; conflicts with --build-local').option('--no-wait', 'return after desired state is created').option('--timeout <seconds>', 'wait timeout in seconds', '300').action(async (o) => {
|
|
268
|
+
const cfg = await loadCfg();
|
|
269
|
+
const compose = await readCompose(o.file);
|
|
270
|
+
const project = o.project || cfg.project || 'site';
|
|
271
|
+
const version = o.version || `git-${composeHash(compose)}`;
|
|
272
|
+
let response;
|
|
273
|
+
if (o.buildLocal) {
|
|
274
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-local-build-'));
|
|
275
|
+
try {
|
|
276
|
+
const form = new FormData();
|
|
277
|
+
const localImages = await prepareLocalImages(compose, process.cwd(), directory);
|
|
278
|
+
for (const { field } of localImages)
|
|
279
|
+
form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
|
|
280
|
+
form.append('payload', JSON.stringify({ project, version, compose, localImages }));
|
|
281
|
+
response = await request('POST', '/api/v1/deployments', form, true);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else if (o.buildUpload && Object.values(compose.services).some(service => service.build)) {
|
|
288
|
+
const tarball = await makeContext();
|
|
289
|
+
try {
|
|
290
|
+
const form = new FormData();
|
|
291
|
+
form.append('payload', JSON.stringify({ project, version, compose }));
|
|
292
|
+
form.append('context', await openAsBlob(tarball), 'context.tgz');
|
|
293
|
+
response = await request('POST', '/api/v1/deployments', form, true);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
await fs.rm(tarball, { force: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else
|
|
300
|
+
response = await request('POST', '/api/v1/deployments', { project, version, compose });
|
|
301
|
+
if (o.wait)
|
|
302
|
+
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
303
|
+
console.log(JSON.stringify(response, null, 2));
|
|
304
|
+
});
|
|
305
|
+
program.command('status').action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/status'), null, 2)));
|
|
306
|
+
program.command('events').description('list recent events with the token that caused each control action; defaults to the resolved project').option('--limit <n>', 'number of events', '100').option('--all', 'list events across all projects (limited to your projects for scoped tokens)').action(async (o) => {
|
|
307
|
+
if (o.all && o.project)
|
|
308
|
+
throw new Error('--all and --project cannot be combined');
|
|
309
|
+
const cfg = await loadCfg();
|
|
310
|
+
const project = o.all ? undefined : o.project || cfg.project;
|
|
311
|
+
const query = `limit=${encodeURIComponent(o.limit)}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
|
|
312
|
+
console.log(JSON.stringify(await request('GET', `/api/v1/events?${query}`), null, 2));
|
|
313
|
+
});
|
|
314
|
+
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) => {
|
|
315
|
+
const local = [];
|
|
316
|
+
for (const [name, cmd, args] of [['node', 'node', ['--version']], ['docker', 'docker', ['version', '--format', '{{.Server.Version}}']], ['docker_compose', 'docker', ['compose', 'version']]]) {
|
|
317
|
+
try {
|
|
318
|
+
local.push({ name, ok: true, output: runCapture(cmd, args).trim() });
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
local.push({ name, ok: false, error: errMessage(e) });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
|
|
325
|
+
const output = { ok: local.every(c => c.ok) && controlDiagnostics.ok, local, control: controlDiagnostics };
|
|
326
|
+
if (o.nodes || o.node)
|
|
327
|
+
output.nodeDiagnostics = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
|
|
328
|
+
console.log(JSON.stringify(output, null, 2));
|
|
329
|
+
});
|
|
330
|
+
program.command('rollback').option('--force').option('--no-wait', 'return immediately after activating rollback').option('--timeout <seconds>', 'wait timeout', '300').action(async (o) => {
|
|
331
|
+
const cfg = await loadCfg();
|
|
332
|
+
const response = await request('POST', '/api/v1/rollback', { project: o.project || cfg.project || 'site', force: Boolean(o.force) });
|
|
333
|
+
if (o.wait)
|
|
334
|
+
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
335
|
+
console.log(JSON.stringify(response, null, 2));
|
|
336
|
+
});
|
|
337
|
+
program.command('deploy-cancel <id>').action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/deployments/${encodeURIComponent(id)}/cancel`, {}), null, 2)));
|
|
338
|
+
program.command('volume-list').action(async (o) => {
|
|
339
|
+
const cfg = await loadCfg();
|
|
340
|
+
console.log(JSON.stringify(await request('GET', `/api/v1/volumes?project=${encodeURIComponent(o.project || cfg.project || 'site')}`), null, 2));
|
|
341
|
+
});
|
|
342
|
+
program.command('volume-remove <name>').option('--delete-data', 'delete underlying Docker volume data').action(async (name, o) => {
|
|
343
|
+
const cfg = await loadCfg();
|
|
344
|
+
const project = o.project || cfg.project || 'site';
|
|
345
|
+
console.log(JSON.stringify(await request('DELETE', `/api/v1/volumes/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}&deleteData=${Boolean(o.deleteData)}`), null, 2));
|
|
346
|
+
});
|
|
347
|
+
program.command('control-backup').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)));
|
|
348
|
+
program.command('control-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)));
|
|
349
|
+
async function waitForCommand(commandId, timeoutSeconds = 60) {
|
|
350
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
351
|
+
while (Date.now() < deadline) {
|
|
352
|
+
const cmd = await request('GET', `/api/v1/commands/${commandId}`);
|
|
353
|
+
if (cmd?.state === 'succeeded' || cmd?.state === 'failed')
|
|
354
|
+
return cmd;
|
|
355
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
356
|
+
}
|
|
357
|
+
throw new Error(`timed out waiting for command ${commandId}`);
|
|
358
|
+
}
|
|
359
|
+
program.command('restart <service>').option('--timeout <seconds>', 'wait timeout', '60').action(async (svc, o) => {
|
|
360
|
+
const cfg = await loadCfg();
|
|
361
|
+
const project = o.project || cfg.project || 'site';
|
|
362
|
+
const created = await request('POST', '/api/v1/restart', { project, service: svc });
|
|
363
|
+
const cmd = await waitForCommand(created.commandId, Number(o.timeout));
|
|
364
|
+
if (cmd.state === 'failed')
|
|
365
|
+
throw new Error(JSON.parse(cmd.result_json || '{}').error || 'restart failed');
|
|
366
|
+
console.log(JSON.stringify(cmd, null, 2));
|
|
367
|
+
});
|
|
368
|
+
program.command('logs <service>').description('fetch logs from the assigned node agent').option('--tail <n>', 'number of lines', '200').action(async (svc, o) => {
|
|
369
|
+
const cfg = await loadCfg();
|
|
370
|
+
const project = o.project || cfg.project || 'site';
|
|
371
|
+
const created = await request('POST', '/api/v1/logs', { project, service: svc, tail: Number(o.tail) });
|
|
372
|
+
const cmd = await waitForCommand(created.commandId, 60);
|
|
373
|
+
const result = JSON.parse(cmd.result_json || '{}');
|
|
374
|
+
if (result.stdout)
|
|
375
|
+
process.stdout.write(result.stdout);
|
|
376
|
+
if (result.stderr)
|
|
377
|
+
process.stderr.write(result.stderr);
|
|
378
|
+
if (result.error)
|
|
379
|
+
throw new Error(result.error);
|
|
380
|
+
});
|
|
381
|
+
program.hook('preAction', async (_root, command) => {
|
|
382
|
+
const options = command.opts();
|
|
383
|
+
for (const [key, min, max] of [['port', 1, 65535], ['wgPort', 1, 65535], ['timeout', 1, 86400], ['ttl', 1, 31536000], ['uses', 1, 1000000], ['expiresInDays', 1, 3650], ['limit', 1, 500], ['tail', 1, 1000000]]) {
|
|
384
|
+
if (options[key] !== undefined)
|
|
385
|
+
integerOption(String(options[key]), '--' + key.replace(/[A-Z]/g, c => '-' + c.toLowerCase()), min, max);
|
|
386
|
+
}
|
|
387
|
+
if (options.buildLocal && options.buildUpload === false)
|
|
388
|
+
throw new Error('--build-local and --no-build-upload cannot be combined');
|
|
389
|
+
if ((options.registryAuthPath || options.registryContainer) && !options.registry)
|
|
390
|
+
throw new Error('--registry-auth-path and --registry-container require --registry');
|
|
391
|
+
if (command.name() === 'control-restore' && !options.confirm)
|
|
392
|
+
throw new Error('control-restore requires --confirm');
|
|
393
|
+
for (const value of [...command.args, ...Object.values(options), ...Object.values(program.opts())])
|
|
394
|
+
if (typeof value === 'string' && /[\r\n\0]/.test(value))
|
|
395
|
+
throw new Error('arguments and option values must not contain line breaks or NUL');
|
|
396
|
+
if (options.name && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(options.name))
|
|
397
|
+
throw new Error('--name must contain only letters, numbers, dots, underscores or hyphens');
|
|
398
|
+
const connection = command.optsWithGlobals();
|
|
399
|
+
if (command.name() === 'token-create') {
|
|
400
|
+
// --project is repeatable here and scopes the new token, so it must not also select the connection project.
|
|
401
|
+
if (!TOKEN_NAME_PATTERN.test(command.args[0]))
|
|
402
|
+
throw new Error('token name must be 1-64 letters, numbers, dots, underscores or hyphens, starting with a letter or number');
|
|
403
|
+
for (const project of options.project)
|
|
404
|
+
if (!PROJECT_NAME_PATTERN.test(project))
|
|
405
|
+
throw new Error('--project must contain only letters, numbers, dots, underscores or hyphens');
|
|
406
|
+
if (options.role === 'admin' && options.project.length)
|
|
407
|
+
throw new Error('admin tokens cannot be limited to projects; use --role read or --role deploy with --project');
|
|
408
|
+
connection.project = undefined;
|
|
409
|
+
}
|
|
410
|
+
resolvedConfig = await resolveCliConfig(connection, process.env, process.cwd(), command.name() === 'init');
|
|
411
|
+
});
|
|
412
|
+
for (const command of program.commands) {
|
|
413
|
+
command
|
|
414
|
+
.option('--config <file>', 'CLI JSON config file (default: TUG_CONFIG or .tug.json)')
|
|
415
|
+
.option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)');
|
|
416
|
+
if (!command.options.some(option => option.long === '--project'))
|
|
417
|
+
command.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
|
|
418
|
+
}
|
|
419
|
+
for (const command of [program, ...program.commands])
|
|
420
|
+
command.allowExcessArguments(false);
|
|
421
|
+
program.showHelpAfterError();
|
|
422
|
+
program.parseAsync().catch((e) => { console.error(`${errMessage(e)}\nRun tug <command> --help for usage.`); process.exit(1); });
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
/** True when the service opted in to `x-tug.rollout.strategy: rolling`. */
|
|
10
|
+
export declare function isRolling(svc: ComposeService | undefined): boolean;
|
|
11
|
+
export declare function validateCompose(compose: ComposeFile | undefined, nodes?: string[]): asserts compose is ComposeFile;
|
|
12
|
+
export interface BuildDesiredStateOptions {
|
|
13
|
+
project: string;
|
|
14
|
+
version: string;
|
|
15
|
+
compose: ComposeFile;
|
|
16
|
+
registry: string;
|
|
17
|
+
revision?: string;
|
|
18
|
+
defaultNode?: string | null;
|
|
19
|
+
}
|
|
20
|
+
export declare function buildDesiredState({ project, version, compose, registry, revision, defaultNode }: BuildDesiredStateOptions): DesiredState;
|
|
21
|
+
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
22
|
+
export declare function crossNodeHosts(project: string, endpoints: Record<string, ServiceEndpoint[]>, local: Set<string>): string[];
|
|
23
|
+
/** Removes `depends_on` references to `names` (services that live in another Compose project). */
|
|
24
|
+
export declare function dropDependsOn(svc: ComposeService, names: ReadonlySet<string>): void;
|
|
25
|
+
/**
|
|
26
|
+
* The Compose file for one node. `exclude` names services placed here that are managed outside the
|
|
27
|
+
* node's main project (rolling services run in their own revision projects); they still count as
|
|
28
|
+
* local, so they get no cross-node `extra_hosts` entry, and `depends_on` references to them are dropped.
|
|
29
|
+
*/
|
|
30
|
+
export declare function composeForNode(desiredState: DesiredState, nodeName: string, secrets?: Record<string, string>, endpoints?: Record<string, ServiceEndpoint[]>, exclude?: ReadonlySet<string>): ComposeFile;
|
|
31
|
+
export declare function serviceSecretNames(compose: ComposeFile | undefined, serviceNames: Iterable<string>): Set<string>;
|
|
32
|
+
export declare function serviceSecretVersions(compose: ComposeFile | undefined, serviceNames: Iterable<string>, versions?: Record<string, number>): Record<string, Record<string, number>>;
|
|
33
|
+
export declare function dumpYaml(obj: unknown): string;
|