@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/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
- 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)); }
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 = process.env.TUG_CONTROL_URL || c.controlUrl || 'http://localhost:8080';
32
- const tok = process.env.TUG_TOKEN || c.operatorToken;
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} ${args.join(' ')} failed`);
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 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
+ 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`, `! ( 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
+ 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 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 });
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('--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 });
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: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 });
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:rotate-registry-token <name>').description('issue a new registry pull token for a node, invalidating the old one').action(async (name) => {
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: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) => {
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: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) => {
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:revoke-registry-token <name>').description('revoke a node\'s registry pull token, blocking future pulls until rotated').action(async (name) => {
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: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>')
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
- .requiredOption('--registry <host>', 'registry host used in desired images; bring your own registry, Tug does not provision one')
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
- const node = program.command('node');
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 agents use to reach control')
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.controlUrl || cfg.controlUrl || process.env.TUG_CONTROL_URL || 'http://localhost:8080';
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
- 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')
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 will connect to')
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('--json', 'print machine-readable JSON instead of the docker run snippet')
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.controlUrl || cfg.controlUrl || process.env.TUG_CONTROL_URL || 'http://localhost:8080';
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 (o.ca)
213
- keyPayload.ca = await fs.readFile(o.ca, 'utf8');
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(dockerRunSnippet(resolveImage(o.image), joinKey, o.mesh));
236
+ console.log(command);
221
237
  });
222
- program.command('secret:set <name>').option('--project <name>').option('--value <value>').action(async (name, o) => {
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: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')}`)));
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
- (await fs.readFile('.dockerignore', 'utf8')).split(/\r?\n/).map(l => l.trim()).filter(Boolean).forEach(l => ignores.add(l));
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('--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) => {
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.buildUpload) {
245
- const form = new FormData();
246
- form.append('payload', JSON.stringify({ project, version, compose }));
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
- 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 });
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').option('--project <name>').option('--limit <n>', 'number of events', '100').action(async (o) => {
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 queue diagnostics on registered agents').option('--node <name>', 'specific node for agent diagnostics').action(async (o) => {
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
- output.nodeDiagnostics = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
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('--project <name>').option('--force').option('--no-wait', 'return immediately after activating rollback').option('--timeout <seconds>', 'wait timeout', '300').action(async (o) => {
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: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) => {
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:rm <name>').option('--project <name>').option('--delete-data', 'delete underlying Docker volume data').action(async (name, o) => {
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
- 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)));
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('--project <name>').option('--timeout <seconds>', 'wait timeout', '60').action(async (svc, o) => {
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('--project <name>').option('--tail <n>', 'number of lines', '200').action(async (svc, o) => {
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.parseAsync().catch((e) => { console.error(errMessage(e)); process.exit(1); });
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
- export declare function composeForNode(desiredState: DesiredState, nodeName: string, secrets?: Record<string, string>, endpoints?: Record<string, ServiceEndpoint[]>): ComposeFile;
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;