@wpelevator/tug 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/dist/agent.d.ts +9 -5
- package/dist/agent.js +215 -52
- package/dist/archive.d.ts +2 -0
- package/dist/archive.js +20 -0
- package/dist/artifacts.d.ts +23 -0
- package/dist/artifacts.js +238 -0
- package/dist/build.js +17 -2
- package/dist/cli-build.d.ts +15 -0
- package/dist/cli-build.js +50 -0
- package/dist/cli-config.d.ts +20 -0
- package/dist/cli-config.js +47 -0
- package/dist/cli-install.d.ts +7 -0
- package/dist/cli-install.js +31 -0
- package/dist/cli.js +191 -93
- package/dist/compose.d.ts +12 -2
- package/dist/compose.js +85 -7
- package/dist/control.d.ts +15 -1
- package/dist/control.js +583 -144
- package/dist/db.d.ts +8 -0
- package/dist/db.js +12 -3
- package/dist/ingress.js +5 -3
- package/dist/protocol.d.ts +44 -0
- package/dist/rollout.d.ts +72 -0
- package/dist/rollout.js +319 -0
- package/dist/templates/control-install.sh +1 -1
- package/dist/templates/install-docker.sh +85 -0
- package/dist/templates/node-install.sh +1 -1
- package/dist/tokens.d.ts +91 -0
- package/dist/tokens.js +165 -0
- package/dist/util.d.ts +3 -0
- package/dist/util.js +11 -0
- package/package.json +5 -2
package/dist/cli.js
CHANGED
|
@@ -1,35 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Command } from 'commander';
|
|
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";
|
|
3
8
|
import { spawnSync } from 'node:child_process';
|
|
4
9
|
import fs from 'node:fs/promises';
|
|
5
10
|
import os from 'node:os';
|
|
6
11
|
import path from 'node:path';
|
|
7
12
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import * as tar from 'tar';
|
|
9
13
|
import { readCompose, composeHash } from "./compose.js";
|
|
10
14
|
import { errMessage, token, loadTemplate } from "./util.js";
|
|
15
|
+
import { PROJECT_NAME_PATTERN, ROLES, TOKEN_NAME_PATTERN } from "./tokens.js";
|
|
11
16
|
import { packageVersion, resolveImage } from "./image.js";
|
|
12
17
|
import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
|
|
13
18
|
// Templates live next to this file: src/templates in dev (`node src/cli.ts`), dist/templates in the
|
|
14
19
|
// built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
|
|
15
20
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
21
|
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
22
|
const program = new Command();
|
|
20
|
-
program.name('tug').description('Portable VPS deployments with Docker Compose').version(packageVersion())
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
} }
|
|
28
|
-
async function saveCfg(c) { await fs.writeFile(cfgPath(), JSON.stringify(c, null, 2)); }
|
|
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 }); }
|
|
29
31
|
async function request(method, route, body, multipart = false) {
|
|
30
32
|
const c = await loadCfg();
|
|
31
|
-
const base =
|
|
32
|
-
const tok =
|
|
33
|
+
const base = c.controlUrl;
|
|
34
|
+
const tok = c.operatorToken;
|
|
33
35
|
const headers = tok ? { Authorization: `Bearer ${tok}` } : {};
|
|
34
36
|
if (!multipart)
|
|
35
37
|
headers['content-type'] = 'application/json';
|
|
@@ -38,11 +40,10 @@ async function request(method, route, body, multipart = false) {
|
|
|
38
40
|
throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
|
|
39
41
|
return res.json();
|
|
40
42
|
}
|
|
41
|
-
function sh(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; }
|
|
42
43
|
function runLocal(cmd, args, opts = {}) {
|
|
43
44
|
const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
|
|
44
45
|
if (result.status !== 0)
|
|
45
|
-
throw new Error(`${cmd} ${
|
|
46
|
+
throw new Error(`${cmd} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}`);
|
|
46
47
|
}
|
|
47
48
|
function runCapture(cmd, args, opts = {}) {
|
|
48
49
|
const result = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
|
|
@@ -66,26 +67,34 @@ function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDis
|
|
|
66
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.`]);
|
|
67
68
|
}
|
|
68
69
|
if (mesh) {
|
|
69
|
-
checks.push(['wireguard.kernel', 'ip link add
|
|
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.']);
|
|
70
71
|
}
|
|
71
72
|
for (const port of udpPorts) {
|
|
72
|
-
checks.push([`udp.${port}.free`,
|
|
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.`]);
|
|
73
74
|
}
|
|
74
75
|
if (controlUrl)
|
|
75
76
|
checks.push(['control.reachable', `curl -fsS ${sh(controlUrl)}/healthz >/dev/null`, 'Verify DNS/firewall/TLS for the control URL from the node.']);
|
|
76
77
|
const script = `set -e
|
|
77
78
|
${checks.map(([name, cmd, fix]) => `echo "preflight: ${name}"; (${cmd}) || { echo "FAILED ${name}: ${fix}" >&2; exit 20; }`).join('\n')}
|
|
78
79
|
`;
|
|
79
|
-
runLocal('ssh', [target, script]);
|
|
80
|
+
runLocal('ssh', ['--', target, script]);
|
|
80
81
|
}
|
|
81
82
|
async function transferTugImage(target, imageTag) {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
}
|
|
88
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())}`]); }
|
|
89
98
|
async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
90
99
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
91
100
|
while (Date.now() < deadline) {
|
|
@@ -99,77 +108,83 @@ async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
|
99
108
|
}
|
|
100
109
|
throw new Error(`timed out waiting for deployment ${deploymentId}`);
|
|
101
110
|
}
|
|
102
|
-
program.command('init').option('--
|
|
103
|
-
const
|
|
104
|
-
|
|
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 });
|
|
105
115
|
console.log(`wrote ${cfgPath()}`);
|
|
106
116
|
});
|
|
107
|
-
program.command('node
|
|
108
|
-
const r = await request('POST', '/api/v1/nodes/register', { name
|
|
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 });
|
|
109
119
|
console.log(JSON.stringify(r, null, 2));
|
|
110
120
|
});
|
|
111
|
-
program.command('node
|
|
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) => {
|
|
112
122
|
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-registry-token`, {}), null, 2));
|
|
113
123
|
});
|
|
114
|
-
program.command('node
|
|
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) => {
|
|
115
125
|
const created = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-wg-key`, {});
|
|
116
126
|
const cmd = await waitForCommand(created.commandId, Number(o.timeout));
|
|
117
127
|
if (cmd.state === 'failed')
|
|
118
128
|
throw new Error(JSON.parse(cmd.result_json || '{}').error || 'key rotation failed');
|
|
119
129
|
console.log(JSON.stringify({ ok: true, node: name, ...JSON.parse(cmd.result_json || '{}') }, null, 2));
|
|
120
130
|
});
|
|
121
|
-
program.command('node
|
|
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) => {
|
|
122
132
|
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, { enabled: !o.disable }), null, 2));
|
|
123
133
|
});
|
|
124
|
-
program.command('node
|
|
134
|
+
program.command('node-revoke-registry-token <name>').description('revoke a node\'s registry pull token, blocking future pulls until rotated').action(async (name) => {
|
|
125
135
|
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/revoke-registry-token`, {}), null, 2));
|
|
126
136
|
});
|
|
127
|
-
program.command('control
|
|
128
|
-
|
|
129
|
-
control.command('install <sshTarget>')
|
|
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>')
|
|
130
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')
|
|
131
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)')
|
|
132
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')
|
|
133
143
|
.option('--token <token>', 'operator token')
|
|
134
144
|
.option('--port <port>', 'published API port', '8080')
|
|
135
|
-
.
|
|
145
|
+
.option('--registry <host>', 'registry host used in desired images; bring your own registry, Tug does not provision one')
|
|
136
146
|
.option('--registry-auth-path <path>', 'container path to an htpasswd file tug-control should manage credentials in, if your registry uses one')
|
|
137
147
|
.option('--registry-container <name>', 'docker container name to restart when a node\'s registry credentials change, if your registry uses an htpasswd file')
|
|
138
148
|
.action(async (sshTarget, o) => {
|
|
139
149
|
const operatorToken = o.token || process.env.TUG_TOKEN || token('tug_op');
|
|
150
|
+
if (o.installDocker)
|
|
151
|
+
await bootstrapDocker(sshTarget);
|
|
140
152
|
preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048 });
|
|
141
153
|
const image = resolveImage(o.image, o.buildLocalImage);
|
|
142
154
|
if (o.buildLocalImage)
|
|
143
155
|
await transferTugImage(sshTarget, image);
|
|
144
|
-
const registryHost = o.registry;
|
|
156
|
+
const registryHost = o.registry || '';
|
|
157
|
+
const yamlValue = (value) => JSON.stringify(value);
|
|
145
158
|
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 || ''
|
|
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 || '')
|
|
149
162
|
});
|
|
150
163
|
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
151
164
|
COMPOSE: compose, OPERATOR_TOKEN_SH: sh(operatorToken)
|
|
152
165
|
});
|
|
153
|
-
runLocal('ssh', [sshTarget, remote]);
|
|
166
|
+
runLocal('ssh', ['--', sshTarget, remote]);
|
|
154
167
|
console.log(JSON.stringify({ ok: true, sshTarget, operatorToken, url: `http://${remoteHost(sshTarget)}:${o.port}`, registry: registryHost }, null, 2));
|
|
155
168
|
});
|
|
156
|
-
|
|
157
|
-
node.command('install <sshTarget>')
|
|
169
|
+
program.command('node-install <sshTarget>')
|
|
158
170
|
.description('register and install an agent container on a Docker-capable host over SSH')
|
|
159
171
|
.requiredOption('--name <name>')
|
|
160
172
|
.option('--address <addr>')
|
|
161
173
|
.option('--public-address <addr>')
|
|
162
|
-
.option('--control-url <url>', 'URL
|
|
174
|
+
.option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
|
|
163
175
|
.option('--registry <host>', 'registry host for docker login')
|
|
164
176
|
.option('--http-only-dev', 'allow plaintext HTTP control URL for local/private development')
|
|
165
177
|
.option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
|
|
166
178
|
.option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
|
|
167
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)')
|
|
168
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')
|
|
169
182
|
.action(async (sshTarget, o) => {
|
|
170
183
|
const cfg = await loadCfg();
|
|
171
|
-
const controlUrl = o.
|
|
184
|
+
const controlUrl = normalizeControlUrl(o.agentControlUrl ?? cfg.controlUrl);
|
|
172
185
|
assertHttps(controlUrl, o.httpOnlyDev);
|
|
186
|
+
if (o.installDocker)
|
|
187
|
+
await bootstrapDocker(sshTarget);
|
|
173
188
|
preflightRemote(sshTarget, { controlUrl, mesh: o.mesh, udpPorts: o.mesh ? [Number(o.wgPort)] : [] });
|
|
174
189
|
const registered = await request('POST', '/api/v1/nodes/register', { name: o.name, address: o.address, publicAddress: o.publicAddress });
|
|
175
190
|
const image = resolveImage(o.image, o.buildLocalImage);
|
|
@@ -182,72 +197,104 @@ node.command('install <sshTarget>')
|
|
|
182
197
|
const remote = await loadTemplate(templatesDir, 'node-install.sh', {
|
|
183
198
|
ENV: env, DOCKER_LOGIN: dockerLogin, IMAGE_SH: sh(image), RUN_FLAGS: o.mesh ? `${MESH_RUN_FLAGS} ` : ''
|
|
184
199
|
});
|
|
185
|
-
runLocal('ssh', [sshTarget, remote]);
|
|
200
|
+
runLocal('ssh', ['--', sshTarget, remote]);
|
|
186
201
|
console.log(JSON.stringify({ ok: true, node: registered.node, registryToken: registered.registryToken }, null, 2));
|
|
187
202
|
});
|
|
188
|
-
|
|
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')
|
|
203
|
+
program.command('node-enroll')
|
|
196
204
|
.description('mint a single-use enrollment key so a fresh host can self-join with one docker run (no SSH)')
|
|
197
205
|
.option('--name <name>', 'pre-bind the node name (default: the agent uses its own hostname)')
|
|
198
|
-
.option('--control-url <url>', 'control URL the agent
|
|
206
|
+
.option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
|
|
199
207
|
.option('--ttl <seconds>', 'enrollment key lifetime', '3600')
|
|
200
208
|
.option('--uses <n>', 'how many hosts may enroll with this key', '1')
|
|
201
209
|
.option('--ca <file>', 'PEM CA cert to embed so agents trust a private/self-signed control')
|
|
202
210
|
.option('--http-only-dev', 'allow a plaintext HTTP control URL for local/private development')
|
|
203
211
|
.option('--image <tag>', 'agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI\'s version>)')
|
|
204
212
|
.option('--mesh', 'print a docker run that joins the WireGuard mesh (host networking, NET_ADMIN)')
|
|
205
|
-
.option('--
|
|
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')
|
|
206
215
|
.action(async (o) => {
|
|
207
216
|
const cfg = await loadCfg();
|
|
208
|
-
const controlUrl = o.
|
|
217
|
+
const controlUrl = normalizeControlUrl(o.agentControlUrl ?? cfg.controlUrl);
|
|
209
218
|
assertHttps(controlUrl, o.httpOnlyDev);
|
|
219
|
+
const ca = o.ca ? await fs.readFile(o.ca, 'utf8') : undefined;
|
|
210
220
|
const minted = await request('POST', '/api/v1/nodes/enroll-token', { name: o.name || null, ttlSeconds: Number(o.ttl), uses: Number(o.uses) });
|
|
211
221
|
const keyPayload = { v: 1, controlUrl, enrollToken: minted.enrollToken };
|
|
212
|
-
if (
|
|
213
|
-
keyPayload.ca =
|
|
222
|
+
if (ca)
|
|
223
|
+
keyPayload.ca = ca;
|
|
214
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;
|
|
215
227
|
if (o.json) {
|
|
216
|
-
console.log(JSON.stringify({ ...minted, controlUrl, joinKey }, null, 2));
|
|
228
|
+
console.log(JSON.stringify({ ...minted, controlUrl, joinKey, command, ...(cloudInit ? { cloudInit } : {}) }, null, 2));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (cloudInit) {
|
|
232
|
+
process.stdout.write(cloudInit);
|
|
217
233
|
return;
|
|
218
234
|
}
|
|
219
235
|
console.log(`# Enrollment key valid until ${minted.expiresAt} (${minted.usesRemaining} use(s)). Run on each fresh host:\n`);
|
|
220
|
-
console.log(
|
|
236
|
+
console.log(command);
|
|
221
237
|
});
|
|
222
|
-
program.command('secret
|
|
238
|
+
program.command('secret-set <name>').option('--value <value>').action(async (name, o) => {
|
|
223
239
|
const value = o.value ?? await new Promise(resolve => { process.stdout.write(`${name}: `); process.stdin.once('data', d => resolve(String(d).trim())); });
|
|
224
240
|
console.log(await request('POST', '/api/v1/secrets', { project: o.project || (await loadCfg()).project || 'site', name, value }));
|
|
225
241
|
});
|
|
226
|
-
program.command('secret
|
|
227
|
-
program.command('secret
|
|
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)));
|
|
228
256
|
async function makeContext() {
|
|
229
257
|
const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
|
|
230
|
-
const ignores = new Set(['.git', 'node_modules', 'data', 'registry-data']);
|
|
231
258
|
try {
|
|
232
|
-
|
|
259
|
+
await createSourceArchive(process.cwd(), tmp, [cfgPath()]);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
await fs.rm(tmp, { force: true });
|
|
263
|
+
throw error;
|
|
233
264
|
}
|
|
234
|
-
catch { }
|
|
235
|
-
await tar.c({ gzip: true, file: tmp, cwd: process.cwd(), filter: (p) => ![...ignores].some(i => p === i || p.startsWith(`${i}/`)) }, ['.']);
|
|
236
265
|
return tmp;
|
|
237
266
|
}
|
|
238
|
-
program.command('deploy').option('-f, --file <file>', 'compose file', 'docker-compose.yml').option('--
|
|
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) => {
|
|
239
268
|
const cfg = await loadCfg();
|
|
240
269
|
const compose = await readCompose(o.file);
|
|
241
270
|
const project = o.project || cfg.project || 'site';
|
|
242
271
|
const version = o.version || `git-${composeHash(compose)}`;
|
|
243
272
|
let response;
|
|
244
|
-
if (o.
|
|
245
|
-
const
|
|
246
|
-
|
|
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)) {
|
|
247
288
|
const tarball = await makeContext();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
}
|
|
251
298
|
}
|
|
252
299
|
else
|
|
253
300
|
response = await request('POST', '/api/v1/deployments', { project, version, compose });
|
|
@@ -256,13 +303,15 @@ program.command('deploy').option('-f, --file <file>', 'compose file', 'docker-co
|
|
|
256
303
|
console.log(JSON.stringify(response, null, 2));
|
|
257
304
|
});
|
|
258
305
|
program.command('status').action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/status'), null, 2)));
|
|
259
|
-
program.command('events').
|
|
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');
|
|
260
309
|
const cfg = await loadCfg();
|
|
261
|
-
const project = o.project || cfg.project;
|
|
310
|
+
const project = o.all ? undefined : o.project || cfg.project;
|
|
262
311
|
const query = `limit=${encodeURIComponent(o.limit)}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
|
|
263
312
|
console.log(JSON.stringify(await request('GET', `/api/v1/events?${query}`), null, 2));
|
|
264
313
|
});
|
|
265
|
-
program.command('doctor').description('run local and control-plane diagnostics').option('--nodes', 'also
|
|
314
|
+
program.command('doctor').description('run local and control-plane diagnostics').option('--nodes', 'also run diagnostics on every registered agent and wait').option('--node <name>', 'run diagnostics on one agent and wait').option('--timeout <seconds>', 'wait timeout for node diagnostics', '60').action(async (o) => {
|
|
266
315
|
const local = [];
|
|
267
316
|
for (const [name, cmd, args] of [['node', 'node', ['--version']], ['docker', 'docker', ['version', '--format', '{{.Server.Version}}']], ['docker_compose', 'docker', ['compose', 'version']]]) {
|
|
268
317
|
try {
|
|
@@ -274,30 +323,38 @@ program.command('doctor').description('run local and control-plane diagnostics')
|
|
|
274
323
|
}
|
|
275
324
|
const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
|
|
276
325
|
const output = { ok: local.every(c => c.ok) && controlDiagnostics.ok, local, control: controlDiagnostics };
|
|
277
|
-
if (o.nodes || o.node)
|
|
278
|
-
|
|
326
|
+
if (o.nodes || o.node) {
|
|
327
|
+
const queued = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
|
|
328
|
+
const commands = [];
|
|
329
|
+
for (const command of queued.commands) {
|
|
330
|
+
const finished = await waitForCommand(command.commandId, Number(o.timeout));
|
|
331
|
+
commands.push({ nodeId: command.nodeId, state: finished.state, result: JSON.parse(finished.result_json || '{}') });
|
|
332
|
+
}
|
|
333
|
+
output.nodeDiagnostics = { commands };
|
|
334
|
+
if (commands.some(command => command.state !== 'succeeded'))
|
|
335
|
+
output.ok = false;
|
|
336
|
+
}
|
|
279
337
|
console.log(JSON.stringify(output, null, 2));
|
|
280
338
|
});
|
|
281
|
-
program.command('rollback').option('--
|
|
339
|
+
program.command('rollback').option('--force').option('--no-wait', 'return immediately after activating rollback').option('--timeout <seconds>', 'wait timeout', '300').action(async (o) => {
|
|
282
340
|
const cfg = await loadCfg();
|
|
283
341
|
const response = await request('POST', '/api/v1/rollback', { project: o.project || cfg.project || 'site', force: Boolean(o.force) });
|
|
284
342
|
if (o.wait)
|
|
285
343
|
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
286
344
|
console.log(JSON.stringify(response, null, 2));
|
|
287
345
|
});
|
|
288
|
-
program.command('deploy
|
|
289
|
-
program.command('volume
|
|
346
|
+
program.command('deploy-cancel <id>').action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/deployments/${encodeURIComponent(id)}/cancel`, {}), null, 2)));
|
|
347
|
+
program.command('volume-list').action(async (o) => {
|
|
290
348
|
const cfg = await loadCfg();
|
|
291
349
|
console.log(JSON.stringify(await request('GET', `/api/v1/volumes?project=${encodeURIComponent(o.project || cfg.project || 'site')}`), null, 2));
|
|
292
350
|
});
|
|
293
|
-
program.command('volume
|
|
351
|
+
program.command('volume-remove <name>').option('--delete-data', 'delete underlying Docker volume data').action(async (name, o) => {
|
|
294
352
|
const cfg = await loadCfg();
|
|
295
353
|
const project = o.project || cfg.project || 'site';
|
|
296
354
|
console.log(JSON.stringify(await request('DELETE', `/api/v1/volumes/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}&deleteData=${Boolean(o.deleteData)}`), null, 2));
|
|
297
355
|
});
|
|
298
|
-
|
|
299
|
-
|
|
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)));
|
|
356
|
+
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)));
|
|
357
|
+
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)));
|
|
301
358
|
async function waitForCommand(commandId, timeoutSeconds = 60) {
|
|
302
359
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
303
360
|
while (Date.now() < deadline) {
|
|
@@ -308,7 +365,7 @@ async function waitForCommand(commandId, timeoutSeconds = 60) {
|
|
|
308
365
|
}
|
|
309
366
|
throw new Error(`timed out waiting for command ${commandId}`);
|
|
310
367
|
}
|
|
311
|
-
program.command('restart <service>').option('--
|
|
368
|
+
program.command('restart <service>').option('--timeout <seconds>', 'wait timeout', '60').action(async (svc, o) => {
|
|
312
369
|
const cfg = await loadCfg();
|
|
313
370
|
const project = o.project || cfg.project || 'site';
|
|
314
371
|
const created = await request('POST', '/api/v1/restart', { project, service: svc });
|
|
@@ -317,7 +374,7 @@ program.command('restart <service>').option('--project <name>').option('--timeou
|
|
|
317
374
|
throw new Error(JSON.parse(cmd.result_json || '{}').error || 'restart failed');
|
|
318
375
|
console.log(JSON.stringify(cmd, null, 2));
|
|
319
376
|
});
|
|
320
|
-
program.command('logs <service>').description('fetch logs from the assigned node agent').option('--
|
|
377
|
+
program.command('logs <service>').description('fetch logs from the assigned node agent').option('--tail <n>', 'number of lines', '200').action(async (svc, o) => {
|
|
321
378
|
const cfg = await loadCfg();
|
|
322
379
|
const project = o.project || cfg.project || 'site';
|
|
323
380
|
const created = await request('POST', '/api/v1/logs', { project, service: svc, tail: Number(o.tail) });
|
|
@@ -330,4 +387,45 @@ program.command('logs <service>').description('fetch logs from the assigned node
|
|
|
330
387
|
if (result.error)
|
|
331
388
|
throw new Error(result.error);
|
|
332
389
|
});
|
|
333
|
-
program.
|
|
390
|
+
program.hook('preAction', async (_root, command) => {
|
|
391
|
+
const options = command.opts();
|
|
392
|
+
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]]) {
|
|
393
|
+
if (options[key] !== undefined)
|
|
394
|
+
integerOption(String(options[key]), '--' + key.replace(/[A-Z]/g, c => '-' + c.toLowerCase()), min, max);
|
|
395
|
+
}
|
|
396
|
+
if (options.buildLocal && options.buildUpload === false)
|
|
397
|
+
throw new Error('--build-local and --no-build-upload cannot be combined');
|
|
398
|
+
if ((options.registryAuthPath || options.registryContainer) && !options.registry)
|
|
399
|
+
throw new Error('--registry-auth-path and --registry-container require --registry');
|
|
400
|
+
if (command.name() === 'control-restore' && !options.confirm)
|
|
401
|
+
throw new Error('control-restore requires --confirm');
|
|
402
|
+
for (const value of [...command.args, ...Object.values(options), ...Object.values(program.opts())])
|
|
403
|
+
if (typeof value === 'string' && /[\r\n\0]/.test(value))
|
|
404
|
+
throw new Error('arguments and option values must not contain line breaks or NUL');
|
|
405
|
+
if (options.name && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(options.name))
|
|
406
|
+
throw new Error('--name must contain only letters, numbers, dots, underscores or hyphens');
|
|
407
|
+
const connection = command.optsWithGlobals();
|
|
408
|
+
if (command.name() === 'token-create') {
|
|
409
|
+
// --project is repeatable here and scopes the new token, so it must not also select the connection project.
|
|
410
|
+
if (!TOKEN_NAME_PATTERN.test(command.args[0]))
|
|
411
|
+
throw new Error('token name must be 1-64 letters, numbers, dots, underscores or hyphens, starting with a letter or number');
|
|
412
|
+
for (const project of options.project)
|
|
413
|
+
if (!PROJECT_NAME_PATTERN.test(project))
|
|
414
|
+
throw new Error('--project must contain only letters, numbers, dots, underscores or hyphens');
|
|
415
|
+
if (options.role === 'admin' && options.project.length)
|
|
416
|
+
throw new Error('admin tokens cannot be limited to projects; use --role read or --role deploy with --project');
|
|
417
|
+
connection.project = undefined;
|
|
418
|
+
}
|
|
419
|
+
resolvedConfig = await resolveCliConfig(connection, process.env, process.cwd(), command.name() === 'init');
|
|
420
|
+
});
|
|
421
|
+
for (const command of program.commands) {
|
|
422
|
+
command
|
|
423
|
+
.option('--config <file>', 'CLI JSON config file (default: TUG_CONFIG or .tug.json)')
|
|
424
|
+
.option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)');
|
|
425
|
+
if (!command.options.some(option => option.long === '--project'))
|
|
426
|
+
command.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
|
|
427
|
+
}
|
|
428
|
+
for (const command of [program, ...program.commands])
|
|
429
|
+
command.allowExcessArguments(false);
|
|
430
|
+
program.showHelpAfterError();
|
|
431
|
+
program.parseAsync().catch((e) => { console.error(`${errMessage(e)}\nRun tug <command> --help for usage.`); process.exit(1); });
|
package/dist/compose.d.ts
CHANGED
|
@@ -6,18 +6,28 @@ export declare function composeHash(compose: ComposeFile): string;
|
|
|
6
6
|
* container port resolves to its published host port). Null when the port is not published.
|
|
7
7
|
*/
|
|
8
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;
|
|
9
11
|
export declare function validateCompose(compose: ComposeFile | undefined, nodes?: string[]): asserts compose is ComposeFile;
|
|
10
12
|
export interface BuildDesiredStateOptions {
|
|
11
13
|
project: string;
|
|
12
14
|
version: string;
|
|
13
15
|
compose: ComposeFile;
|
|
14
16
|
registry: string;
|
|
17
|
+
revision?: string;
|
|
15
18
|
defaultNode?: string | null;
|
|
16
19
|
}
|
|
17
|
-
export declare function buildDesiredState({ project, version, compose, registry, defaultNode }: BuildDesiredStateOptions): DesiredState;
|
|
20
|
+
export declare function buildDesiredState({ project, version, compose, registry, revision, defaultNode }: BuildDesiredStateOptions): DesiredState;
|
|
18
21
|
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
19
22
|
export declare function crossNodeHosts(project: string, endpoints: Record<string, ServiceEndpoint[]>, local: Set<string>): string[];
|
|
20
|
-
|
|
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;
|
|
21
31
|
export declare function serviceSecretNames(compose: ComposeFile | undefined, serviceNames: Iterable<string>): Set<string>;
|
|
22
32
|
export declare function serviceSecretVersions(compose: ComposeFile | undefined, serviceNames: Iterable<string>, versions?: Record<string, number>): Record<string, Record<string, number>>;
|
|
23
33
|
export declare function dumpYaml(obj: unknown): string;
|