@wpelevator/tug 0.9.0 → 0.11.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.
Files changed (78) hide show
  1. package/README.md +100 -35
  2. package/dist/agent-credential.js +3 -3
  3. package/dist/agent.d.ts +5 -7
  4. package/dist/agent.js +45 -67
  5. package/dist/backup-check.d.ts +1 -1
  6. package/dist/backup-config.js +32 -24
  7. package/dist/backup-destination.d.ts +1 -1
  8. package/dist/backup-destination.js +1 -1
  9. package/dist/backup-job.js +17 -5
  10. package/dist/backup-restore.js +69 -69
  11. package/dist/backup-scheduler.js +3 -1
  12. package/dist/cli-build.d.ts +2 -0
  13. package/dist/cli-build.js +15 -2
  14. package/dist/cli-config.d.ts +10 -2
  15. package/dist/cli-config.js +28 -49
  16. package/dist/cli-install.d.ts +10 -5
  17. package/dist/cli-install.js +27 -24
  18. package/dist/cli-live.js +4 -6
  19. package/dist/cli-onboarding.d.ts +31 -0
  20. package/dist/cli-onboarding.js +66 -0
  21. package/dist/cli-prompt.d.ts +9 -0
  22. package/dist/cli-prompt.js +60 -0
  23. package/dist/cli-secrets.d.ts +12 -0
  24. package/dist/cli-secrets.js +71 -0
  25. package/dist/cli-status.d.ts +40 -0
  26. package/dist/cli-status.js +91 -0
  27. package/dist/cli.js +63 -989
  28. package/dist/commands/backup.d.ts +2 -0
  29. package/dist/commands/backup.js +109 -0
  30. package/dist/commands/context.d.ts +54 -0
  31. package/dist/commands/context.js +96 -0
  32. package/dist/commands/control.d.ts +2 -0
  33. package/dist/commands/control.js +110 -0
  34. package/dist/commands/credential.d.ts +2 -0
  35. package/dist/commands/credential.js +36 -0
  36. package/dist/commands/deploy.d.ts +2 -0
  37. package/dist/commands/deploy.js +139 -0
  38. package/dist/commands/host.d.ts +18 -0
  39. package/dist/commands/host.js +99 -0
  40. package/dist/commands/init.d.ts +11 -0
  41. package/dist/commands/init.js +40 -0
  42. package/dist/commands/node.d.ts +28 -0
  43. package/dist/commands/node.js +320 -0
  44. package/dist/commands/observe.d.ts +2 -0
  45. package/dist/commands/observe.js +71 -0
  46. package/dist/commands/restore.d.ts +2 -0
  47. package/dist/commands/restore.js +85 -0
  48. package/dist/commands/secret.d.ts +2 -0
  49. package/dist/commands/secret.js +26 -0
  50. package/dist/commands/service.d.ts +2 -0
  51. package/dist/commands/service.js +62 -0
  52. package/dist/commands/volume.d.ts +2 -0
  53. package/dist/commands/volume.js +13 -0
  54. package/dist/container-spec.d.ts +5 -0
  55. package/dist/container-spec.js +11 -2
  56. package/dist/control-credential.d.ts +2 -0
  57. package/dist/control-credential.js +4 -4
  58. package/dist/control.d.ts +1 -1
  59. package/dist/control.js +96 -143
  60. package/dist/credentials.js +1 -1
  61. package/dist/db.d.ts +0 -10
  62. package/dist/db.js +1 -2
  63. package/dist/diagnostics.js +1 -1
  64. package/dist/ingress.d.ts +1 -2
  65. package/dist/ingress.js +1 -1
  66. package/dist/main.js +1 -1
  67. package/dist/node-address.d.ts +2 -0
  68. package/dist/node-address.js +6 -0
  69. package/dist/pki.d.ts +1 -1
  70. package/dist/pki.js +2 -2
  71. package/dist/protocol.d.ts +2 -34
  72. package/dist/reconciler.js +40 -7
  73. package/dist/runners.d.ts +0 -6
  74. package/dist/runners.js +1 -6
  75. package/dist/templates/control-compose.yml +1 -2
  76. package/package.json +1 -1
  77. package/dist/mesh.d.ts +0 -74
  78. package/dist/mesh.js +0 -219
package/dist/cli.js CHANGED
@@ -1,31 +1,22 @@
1
1
  #!/usr/bin/env node
2
- import { Command, Option } from 'commander';
3
- import { openAsBlob } from 'node:fs';
4
- import { applyConfigDefaults, integerOption, normalizeControlUrl, nodeSettings, resolveCliConfig, writeInitConfig, } from "./cli-config.js";
5
- import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, MESH_RUN_FLAGS, CONTROL_CONTAINER, controlPortCheck, localImageBuildArgs, 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 { composeHash } from "./compose.js";
14
- import { normalizeCompose } from "./normalize.js";
15
- import { errMessage, loadTemplate } from "./util.js";
16
- import { createTransport } from "./agent-credential.js";
17
- import { decodeCredential, writeFileDurable } from "./pki.js";
18
- import { backupCheckProblems } from "./backup-check.js";
19
- import { PROJECT_NAME_PATTERN, ROLES, CREDENTIAL_NAME_PATTERN } from "./credentials.js";
20
- import { packageVersion, resolveImage } from "./image.js";
21
- import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
22
- import { CliLiveError, parseLiveRequest, runCliLive } from "./cli-live.js";
23
- import { createCliLiveTransport } from "./cli-live-transport.js";
24
- // Templates live next to this file: src/templates in dev (`node src/cli.ts`), dist/templates in the
25
- // built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
26
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
- const templatesDir = path.join(__dirname, 'templates');
28
- const CONFIG_HELP = 'CLI config file, YAML or JSON (default: TUG_CONFIG, else tug.yml, tug.yaml, tug.json or .tug.json)';
2
+ import { Command } from 'commander';
3
+ import { applyConfigDefaults, commandPath, integerOption, nodeSettings, resolveCliConfig, } from "./cli-config.js";
4
+ import { CliLiveError } from "./cli-live.js";
5
+ import { CREDENTIAL_NAME_PATTERN, PROJECT_NAME_PATTERN } from "./credentials.js";
6
+ import { packageVersion } from "./image.js";
7
+ import { errMessage } from "./util.js";
8
+ import { CONFIG_HELP, setResolvedConfig } from "./commands/context.js";
9
+ import { registerBackup } from "./commands/backup.js";
10
+ import { registerControl } from "./commands/control.js";
11
+ import { registerCredential } from "./commands/credential.js";
12
+ import { registerDeploy } from "./commands/deploy.js";
13
+ import { registerInit } from "./commands/init.js";
14
+ import { registerNode } from "./commands/node.js";
15
+ import { registerObserve } from "./commands/observe.js";
16
+ import { registerRestore } from "./commands/restore.js";
17
+ import { registerSecret } from "./commands/secret.js";
18
+ import { registerService } from "./commands/service.js";
19
+ import { registerVolume } from "./commands/volume.js";
29
20
  const program = new Command();
30
21
  program
31
22
  .name('tug')
@@ -36,955 +27,26 @@ program
36
27
  .option('--control-url <url>', 'connect target for the control API (the credential still verifies its own public URL)')
37
28
  .option('--credential <file>', 'credential file (overrides TUG_CREDENTIAL, TUG_CREDENTIAL_FILE and config)')
38
29
  .option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
39
- let resolvedConfig;
40
- async function loadCfg() {
41
- return resolvedConfig;
42
- }
43
- const collect = (v, a) => [...a, v];
44
- async function readStdin() {
45
- const chunks = [];
46
- for await (const chunk of process.stdin)
47
- chunks.push(Buffer.from(chunk));
48
- return Buffer.concat(chunks).toString('utf8');
49
- }
50
- /** The pinned mTLS transport for the resolved credential; an address override only changes the connect target. */
51
- async function transportFor(c) {
52
- if (!c.credential)
53
- throw new Error('no credential: run `tug init <credential-file>`, set TUG_CREDENTIAL_FILE or pass --credential');
54
- return createTransport(c.credential, c.controlUrl);
55
- }
56
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
- async function request(method, route, body, multipart = false) {
58
- const c = await loadCfg();
59
- const transport = await transportFor(c);
60
- const headers = {};
61
- if (!multipart)
62
- headers['content-type'] = 'application/json';
63
- try {
64
- const res = await transport.http(`${transport.base}${route}`, {
65
- method,
66
- headers,
67
- body: multipart ? body : body ? JSON.stringify(body) : undefined,
68
- });
69
- if (!res.ok)
70
- throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
71
- return (await res.json());
72
- }
73
- finally {
74
- void transport.close();
75
- }
76
- }
77
- function runLocal(cmd, args, opts = {}) {
78
- const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
79
- if (result.status !== 0)
80
- throw new Error(`${cmd} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}`);
81
- }
82
- function runCapture(cmd, args, opts = {}) {
83
- const result = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
84
- if (result.status !== 0)
85
- throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
86
- return String(result.stdout);
87
- }
88
- function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDiskMb = 1024, mesh = false, compose = false, } = {}) {
89
- const checks = [
90
- [
91
- 'docker.version',
92
- 'docker version >/dev/null',
93
- 'Install Docker Engine and ensure the SSH user can access it.',
94
- ],
95
- ...(compose
96
- ? [
97
- [
98
- 'docker.compose.version',
99
- 'docker compose version >/dev/null',
100
- 'Install Docker Compose v2.',
101
- ],
102
- ]
103
- : []),
104
- [
105
- 'paths',
106
- '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',
107
- 'Ensure /opt, /var/lib, and /etc/tug are writable by the installer.',
108
- ],
109
- [
110
- 'disk.space',
111
- `[ "$(df -Pm /var/lib | tail -1 | awk '{print $4}')" -ge ${Number(minDiskMb)} ]`,
112
- `Free at least ${minDiskMb}MB on the filesystem backing /var/lib.`,
113
- ],
114
- ];
115
- for (const port of ports) {
116
- checks.push([
117
- `port.${port}.free`,
118
- controlPortCheck(port),
119
- `Port ${port} is already in use on the target host by something other than the Tug control container; stop the conflicting service or choose another port.`,
120
- ]);
121
- }
122
- if (mesh) {
123
- checks.push([
124
- 'wireguard.kernel',
125
- 'ip link add tugwgtest type wireguard && ip link del tugwgtest || modprobe -n wireguard 2>/dev/null',
126
- 'Install WireGuard (kernel 5.6+ has it built in; older kernels need the wireguard-dkms package) and ensure iproute2 is present.',
127
- ]);
128
- }
129
- for (const port of udpPorts) {
130
- checks.push([
131
- `udp.${port}.free`,
132
- meshPortCheck(port),
133
- `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.`,
134
- ]);
135
- }
136
- if (controlUrl)
137
- checks.push([
138
- 'control.reachable',
139
- // TCP reachability only: no credential is copied to the host, and real TLS authorization happens when the agent connects.
140
- `timeout 5 bash -c ${sh(`exec 3<>/dev/tcp/${new URL(controlUrl).hostname.replace(/^\[|\]$/g, '')}/${new URL(controlUrl).port || 443}`)}`,
141
- 'Verify DNS and the firewall from the node to the control port (control speaks mTLS only, so only TCP reachability is checked here).',
142
- ]);
143
- const script = `set -e
144
- ${checks.map(([name, cmd, fix]) => `echo "preflight: ${name}"; (${cmd}) || { echo "FAILED ${name}: ${fix}" >&2; exit 20; }`).join('\n')}
145
- `;
146
- runLocal('ssh', ['--', target, script]);
147
- }
148
- async function transferTugImage(target, imageTag) {
149
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-runtime-'));
150
- const archive = path.join(directory, 'image.tar.gz');
151
- const remote = `/tmp/${path.basename(directory)}.tar.gz`;
152
- try {
153
- const uname = runCapture('ssh', ['--', target, 'uname -m']);
154
- runLocal('docker', localImageBuildArgs(imageTag, uname));
155
- await saveImageArchive(imageTag, archive);
156
- runLocal('scp', ['--', archive, `${target}:${remote}`]);
157
- runLocal('ssh', [
158
- '--',
159
- target,
160
- `set -eu\ntrap ${sh(`rm -f ${sh(remote)}`)} EXIT\ndocker load --input ${sh(remote)}`,
161
- ]);
162
- }
163
- finally {
164
- await fs.rm(directory, { recursive: true, force: true });
165
- }
166
- }
167
- async function dockerBootstrap() {
168
- return fs.readFile(path.join(templatesDir, 'install-docker.sh'), 'utf8');
169
- }
170
- /** Installs Docker over SSH; `compose` also installs the Compose plugin, which only the control host needs. */
171
- async function bootstrapDocker(target, compose = false) {
172
- runLocal('ssh', [
173
- '--',
174
- target,
175
- `${compose ? 'TUG_INSTALL_COMPOSE=1 ' : ''}sh -eu -c ${sh(await dockerBootstrap())}`,
176
- ]);
177
- }
178
- async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
179
- const deadline = Date.now() + timeoutSeconds * 1000;
180
- while (Date.now() < deadline) {
181
- const deployment = await request('GET', `/api/v1/deployments/${encodeURIComponent(deploymentId)}`);
182
- if (TERMINAL_DEPLOYMENT_STATES.includes(deployment.state)) {
183
- if (deployment.state !== 'succeeded')
184
- throw new Error(`deployment ${deploymentId} ${deployment.state}${deployment.error_message ? `: ${deployment.error_message}` : ''}`);
185
- return deployment;
186
- }
187
- await new Promise((r) => setTimeout(r, 2000));
188
- }
189
- throw new Error(`timed out waiting for deployment ${deploymentId}`);
190
- }
191
- program
192
- .command('init [credentialFile]')
193
- .description('create the config file if missing and import a credential (a file, or - for stdin) into a 0600 user-config file that the git-ignored .local overlay points at')
194
- .action(async (credentialFile, _o, command) => {
195
- const cfg = await loadCfg();
196
- const { controlUrl, project } = command.optsWithGlobals();
197
- let text;
198
- if (credentialFile === '-')
199
- text = await readStdin();
200
- else if (credentialFile)
201
- text = await fs.readFile(credentialFile, 'utf8');
202
- else if (cfg.credential)
203
- text = JSON.stringify(cfg.credential);
204
- if (!text)
205
- throw new Error('tug init needs a credential file (or - for stdin)');
206
- const credential = decodeCredential(text);
207
- // TUG_CREDENTIALS_DIR keeps scripts and tests out of the real home directory.
208
- const dir = process.env.TUG_CREDENTIALS_DIR ||
209
- path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'tug', 'credentials');
210
- const source = path.join(dir, `${new URL(credential.url).host.replace(/[^A-Za-z0-9.-]/g, '_')}.json`);
211
- await writeFileDurable(source, `${JSON.stringify(credential, null, 2)}\n`, 0o600);
212
- for (const file of await writeInitConfig(cfg, source, { controlUrl, project }))
213
- console.log(`wrote ${file}`);
214
- console.log(`imported credential to ${source}`);
215
- });
216
- program
217
- .command('node-register <name>')
218
- .description('sign a node-generated CSR and print the identity response (the primitive behind node-install; the private key stays with whoever made the CSR)')
219
- .requiredOption('--csr <file>', 'PEM certificate request generated on the node (- for stdin)')
220
- .option('--mode <mode>', 'create a new node or replace an existing node key', 'create')
221
- .option('--address <addr>')
222
- .option('--public-address <addr>')
223
- .option('--label <label>', 'node label (repeatable)', collect, [])
224
- .action(async (name, o) => {
225
- if (o.mode !== 'create' && o.mode !== 'replace')
226
- throw new Error("--mode must be 'create' or 'replace'");
227
- const csr = o.csr === '-' ? await readStdin() : await fs.readFile(o.csr, 'utf8');
228
- const r = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode: o.mode, address: o.address, publicAddress: o.publicAddress, labels: o.label });
229
- console.log(JSON.stringify(r, null, 2));
230
- });
231
- program
232
- .command('node-disable <name>')
233
- .description("block a node's key immediately without deleting its metadata or placement (no SSH needed); bring it back with node-replace-identity")
234
- .action(async (name) => console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/disable`, {}), null, 2)));
235
- program
236
- .command('node-rotate-wg-key <name>')
237
- .description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll')
238
- .option('--timeout <seconds>', 'wait timeout', '60')
239
- .action(async (name, o) => {
240
- const created = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-wg-key`, {});
241
- const cmd = await waitForCommand(created.commandId, Number(o.timeout));
242
- if (cmd.state === 'failed')
243
- throw new Error(JSON.parse(cmd.result_json || '{}').error || 'key rotation failed');
244
- console.log(JSON.stringify({ ok: true, node: name, ...JSON.parse(cmd.result_json || '{}') }, null, 2));
245
- });
246
- program
247
- .command('node-remove <name>')
248
- .description('remove a retired node from the control plane (its identity, WireGuard peer, statuses and queued commands); stop its agent and remove its containers on the host yourself')
249
- .option('--force', 'remove even if the node is still reporting')
250
- .action(async (name, o) => {
251
- console.log(JSON.stringify(await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}${o.force ? '?force=true' : ''}`), null, 2));
252
- });
253
- program
254
- .command('node-ingress <name>')
255
- .description('mark a node as an ingress node (managed Caddy on ports 80/443), or remove the role with --disable')
256
- .option('--disable', 'stop acting as an ingress node')
257
- .action(async (name, o) => {
258
- console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, {
259
- enabled: !o.disable,
260
- }), null, 2));
261
- });
262
- program
263
- .command('scale <service> <replicas>')
264
- .description('scale a service to N replicas across matching nodes')
265
- .action(async (service, replicas, o) => {
266
- const cfg = await loadCfg();
267
- const project = o.project || cfg.project || 'site';
268
- if (!/^[1-9]\d*$/.test(replicas))
269
- throw new Error('replicas must be a positive integer');
270
- console.log(JSON.stringify(await request('POST', '/api/v1/scale', { project, service, replicas: Number(replicas) }), null, 2));
271
- });
272
- program
273
- .command('control-install <sshTarget>')
274
- .description('install the control plane on a Docker-capable host over SSH and issue the root credential into a local 0600 file; it listens for mTLS only, so front it with TCP passthrough rather than a terminating proxy')
275
- .requiredOption('--advertise-url <url>', 'credential-free https origin clients use, including the published port')
276
- .option('--extra-san <name>', 'additional DNS name or IP for the server certificate (repeatable)', collect, [])
277
- .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
278
- .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
279
- .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')
280
- .option('--port <port>', 'published mTLS port', '8443')
281
- .option('--credential-out <file>', 'where to write the root credential', 'tug-root.credential.json')
282
- .option('--recover-root', "replace root's key from the control host without the API (lost credential); other admins are unaffected")
283
- .option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
284
- .action(async (sshTarget, o) => {
285
- const advertiseUrl = normalizeControlUrl(o.advertiseUrl);
286
- if (o.installDocker)
287
- await bootstrapDocker(sshTarget, true);
288
- preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
289
- const image = resolveImage(o.image, o.buildLocalImage);
290
- if (o.buildLocalImage)
291
- await transferTugImage(sshTarget, image);
292
- const yamlValue = (value) => JSON.stringify(value);
293
- const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
294
- IMAGE: yamlValue(image),
295
- ADVERTISE_URL: yamlValue(new URL(advertiseUrl).origin),
296
- EXTRA_SANS: yamlValue(o.extraSan.join(',')),
297
- PORT: o.port,
298
- MESH: yamlValue(o.mesh ? 'true' : 'false'),
299
- });
300
- const remote = await loadTemplate(templatesDir, 'control-install.sh', {
301
- COMPOSE: compose,
302
- CONTAINER_SH: sh(CONTROL_CONTAINER),
303
- });
304
- runLocal('ssh', ['--', sshTarget, remote]);
305
- // Upgrades never reissue root: fresh installs create it, `--recover-root` replaces its key. Output goes straight to a file.
306
- const issue = `docker exec ${sh(CONTROL_CONTAINER)} node dist/main.js control issue-credential --name root --role admin${o.recoverRoot ? ' --replace' : ''}`;
307
- const out = spawnSync('ssh', ['--', sshTarget, issue], { encoding: 'utf8' });
308
- if (out.status !== 0) {
309
- if (!o.recoverRoot && /already used/.test(String(out.stderr)))
310
- console.error('control already has a root credential; upgrade complete (pass --recover-root to replace it)');
311
- else
312
- throw new Error(`issuing the root credential failed: ${String(out.stderr).trim()}`);
313
- }
314
- else {
315
- decodeCredential(out.stdout);
316
- await writeFileDurable(o.credentialOut, `${String(out.stdout).trim()}\n`, 0o600);
317
- console.error(`root credential written to ${o.credentialOut}; import it with: tug init ${o.credentialOut}`);
318
- }
319
- console.log(JSON.stringify({ ok: true, sshTarget, url: advertiseUrl }, null, 2));
320
- });
321
- const AGENT_STATE = '/var/lib/tug';
322
- function addNodeIdentityOptions(command) {
323
- return command
324
- .option('--name <name>', 'node name')
325
- .option('--all', 'walk every node with an ssh target in the config file, continuing after failures')
326
- .option('--agent-control-url <url>', 'connect-only URL the agent dials (for example a private address); the certificate is still verified against the credential URL')
327
- .option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
328
- .option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
329
- .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
330
- .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
331
- .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')
332
- .option('--no-wait', 'do not wait for the node to report after the install');
333
- }
334
- /**
335
- * The SSH-assisted flow: the node makes its own key and CSR, an admin credential authorizes the identity at this moment and
336
- * the certificate is installed back over SSH, so no bootstrap secret travels and the private key never leaves the node.
337
- */
338
- async function installNodeIdentity(sshTarget, name, mode, o, repair = false) {
339
- const cfg = await loadCfg();
340
- const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
341
- await transportFor(cfg).then((t) => t.close()); // fail before touching the host when there is no credential
342
- if (o.installDocker)
343
- await bootstrapDocker(sshTarget);
344
- preflightRemote(sshTarget, {
345
- controlUrl: connectUrl ?? cfg.credential?.url,
346
- mesh: o.mesh,
347
- udpPorts: o.mesh ? [Number(o.wgPort)] : [],
348
- });
349
- const image = resolveImage(o.image, o.buildLocalImage);
350
- if (o.buildLocalImage)
351
- await transferTugImage(sshTarget, image);
352
- const started = new Date().toISOString();
353
- if (repair)
354
- runLocal('ssh', [
355
- '--',
356
- sshTarget,
357
- `mkdir -p ${AGENT_STATE} && cd ${AGENT_STATE} && for f in agent.json agent-pending.json; do [ -e "$f" ] && mv "$f" "$f.corrupt-$(date +%s)"; done; true`,
358
- ]);
359
- const agent = (command, interactive = false) => `mkdir -p ${AGENT_STATE} && docker run --rm ${interactive ? '-i ' : ''}-v ${AGENT_STATE}:${AGENT_STATE} ${sh(image)} agent ${command}`;
360
- const csr = runCapture('ssh', ['--', sshTarget, agent('identity')]);
361
- const response = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode, address: o.address, publicAddress: o.publicAddress, labels: o.label });
362
- runCapture('ssh', ['--', sshTarget, agent('install-cert', true)], {
363
- input: JSON.stringify(response),
364
- });
365
- const env = (await loadTemplate(templatesDir, 'agent.env', {
366
- CONTROL_URL_LINE: connectUrl ? `TUG_CONTROL_URL=${connectUrl}\n` : '',
367
- NODE_NAME: name,
368
- })) + (o.mesh ? 'TUG_MESH=true\n' : '');
369
- const remote = await loadTemplate(templatesDir, 'node-install.sh', {
370
- ENV: env,
371
- IMAGE_SH: sh(image),
372
- RUN_FLAGS: `${o.mesh ? MESH_RUN_FLAGS : HOST_NETWORK_FLAG} `,
373
- });
374
- runLocal('ssh', ['--', sshTarget, remote]);
375
- if (o.wait) {
376
- // A report from the new identity: wait for a report after the install began, not a replaced node's stale status.
377
- for (let i = 0; i < 30; i++) {
378
- const { nodes } = await request('GET', '/api/v1/nodes');
379
- const seen = nodes.find((n) => n.name === name)?.last_seen_at;
380
- if (seen && seen > started)
381
- return void console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
382
- await new Promise((r) => setTimeout(r, 2000));
383
- }
384
- console.error(`node ${name} has not reported yet; check \`docker logs tug-agent\` on the host`);
385
- }
386
- console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
387
- }
388
- async function runNodeIdentity(sshArgument, o, mode, repair = false) {
389
- const cfg = await loadCfg();
390
- const verb = repair
391
- ? 'node-repair-identity'
392
- : mode === 'create'
393
- ? 'node-install'
394
- : 'node-replace-identity';
395
- if (o.all) {
396
- const results = [];
397
- for (const [name, settings] of Object.entries(cfg.nodes)) {
398
- const target = settings.ssh;
399
- if (!target)
400
- continue;
401
- const merged = { ...o, ...nodeOverrides(settings), name };
402
- try {
403
- await installNodeIdentity(target, name, mode, merged, repair);
404
- results.push({ node: name, ok: true });
405
- }
406
- catch (e) {
407
- results.push({ node: name, ok: false, error: errMessage(e) });
408
- }
409
- }
410
- console.log(JSON.stringify({ results }, null, 2));
411
- if (results.some((r) => !r.ok))
412
- process.exitCode = 1;
413
- return;
414
- }
415
- if (!o.name)
416
- throw new Error(`${verb} needs --name`);
417
- const sshTarget = sshArgument ?? nodeSettings(cfg, o.name).ssh;
418
- if (!sshTarget)
419
- throw new Error(`${verb} needs an SSH target: pass <ssh-target> or set nodes.${o.name}.ssh in ${cfg.file}`);
420
- await installNodeIdentity(sshTarget, o.name, mode, o, repair);
421
- }
422
- const nodeOverrides = (settings) => ({
423
- ...(settings.address ? { address: settings.address } : {}),
424
- ...(settings.publicAddress ? { publicAddress: settings.publicAddress } : {}),
425
- ...(Array.isArray(settings.label) ? { label: settings.label } : {}),
426
- ...(settings.mesh ? { mesh: true } : {}),
427
- });
428
- for (const [verb, description, mode, repair] of [
429
- [
430
- 'node-install',
431
- 'create a node and install its agent over SSH (the SSH target defaults to nodes.<name>.ssh in the config file); safe to rerun after a partial failure',
432
- 'create',
433
- false,
434
- ],
435
- [
436
- 'node-replace-identity',
437
- 'install a new key on an existing node over SSH: for an expired, lost or suspected key, a disabled node, a CA loss or an older restore',
438
- 'replace',
439
- false,
440
- ],
441
- [
442
- 'node-repair-identity',
443
- 'move aside corrupt agent credential documents on the node (never service volumes) and replace its identity',
444
- 'replace',
445
- true,
446
- ],
447
- ]) {
448
- const command = program
449
- .command(`${verb} [sshTarget]`)
450
- .description(description)
451
- .option('--address <addr>')
452
- .option('--public-address <addr>')
453
- .option('--label <label>', 'node label (repeatable)', collect, []);
454
- addNodeIdentityOptions(command).action(async (sshArgument, o) => runNodeIdentity(sshArgument, o, mode, repair));
455
- }
456
- program
457
- .command('node-prepare-enrollment')
458
- .description('mint a single-use, name-bound join key so a host you cannot SSH to can self-join with one docker run; a saved cloud-init document contains a credential')
459
- .requiredOption('--name <name>', 'the node name the key is bound to; mint one key per instance')
460
- .option('--agent-control-url <url>', 'connect-only URL the agent dials; the certificate is still verified against the credential URL')
461
- .option('--ttl <seconds>', 'join key lifetime', '3600')
462
- .option('--image <tag>', "agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
463
- .option('--mesh', 'print a docker run that joins the WireGuard mesh (host networking, NET_ADMIN)')
464
- .option('--label <label>', 'node label (repeatable)', collect, [])
465
- .option('--install-docker', 'print cloud-init that installs Docker and starts the agent')
466
- .option('--json', 'print JSON including the command or cloud-init payload')
467
- .action(async (o) => {
468
- const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
469
- const minted = await request('POST', '/api/v1/nodes/enrollment-credential', {
470
- name: o.name,
471
- ttlSeconds: Number(o.ttl),
472
- labels: o.label.length ? o.label : undefined,
473
- });
474
- const keyPayload = {
475
- v: 2,
476
- credential: minted.credential,
477
- name: minted.name,
478
- ...(connectUrl ? { connectUrl } : {}),
479
- };
480
- const joinKey = Buffer.from(JSON.stringify(keyPayload)).toString('base64url');
481
- const command = dockerRunSnippet(resolveImage(o.image), joinKey, o.mesh);
482
- const cloudInit = o.installDocker
483
- ? enrollmentCloudInit(await dockerBootstrap(), command)
484
- : undefined;
485
- if (o.json) {
486
- console.log(JSON.stringify({
487
- name: minted.name,
488
- expiresAt: minted.expiresAt,
489
- joinKey,
490
- command,
491
- ...(cloudInit ? { cloudInit } : {}),
492
- }, null, 2));
493
- return;
494
- }
495
- if (cloudInit) {
496
- process.stdout.write(cloudInit);
497
- return;
498
- }
499
- console.log(`# Join key for ${minted.name} valid until ${minted.expiresAt}, single use. Run on the fresh host:\n`);
500
- console.log(command);
501
- });
502
- program
503
- .command('secret-set <name>')
504
- .option('--value <value>')
505
- .action(async (name, o) => {
506
- const value = o.value ??
507
- (await new Promise((resolve) => {
508
- process.stdout.write(`${name}: `);
509
- process.stdin.once('data', (d) => resolve(String(d).trim()));
510
- }));
511
- console.log(await request('POST', '/api/v1/secrets', {
512
- project: o.project || (await loadCfg()).project || 'site',
513
- name,
514
- value,
515
- }));
516
- });
517
- program
518
- .command('secret-list')
519
- .action(async (o) => console.log(JSON.stringify(await request('GET', `/api/v1/secrets?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`), null, 2)));
520
- program
521
- .command('secret-remove <name>')
522
- .action(async (name, o) => console.log(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`)));
523
- const formatBytes = (bytes) => bytes === null
524
- ? '-'
525
- : bytes < 1024 ** 2
526
- ? `${Math.ceil(bytes / 1024)} KiB`
527
- : `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
528
- const BACKUP_TERMINAL = ['succeeded', 'failed', 'missed', 'cancelled'];
529
- program
530
- .command('backup-run [targets...]')
531
- .description('back up the named services or volumes now, or every backup target of the project; waits and exits nonzero if any backup fails')
532
- .option('--no-wait', 'return once the backups are queued')
533
- .option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
534
- .action(async (targets, o) => {
535
- const project = (await loadCfg()).project || 'site';
536
- const queued = await request('POST', '/api/v1/backups/run', { project, targets: targets.length ? targets : undefined });
537
- for (const message of queued.errors)
538
- console.error(message);
539
- for (const run of queued.runs)
540
- console.log(`queued ${run.target} as ${run.runId}`);
541
- let failed = queued.errors.length > 0;
542
- if (o.wait) {
543
- const deadline = Date.now() + Number(o.timeout) * 1000;
544
- const pending = new Set(queued.runs.map((run) => run.runId));
545
- while (pending.size && Date.now() < deadline) {
546
- await new Promise((resolve) => setTimeout(resolve, 2000));
547
- const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=200`);
548
- for (const run of listing.runs)
549
- if (pending.has(run.id) && BACKUP_TERMINAL.includes(run.state)) {
550
- pending.delete(run.id);
551
- const ok = run.state === 'succeeded';
552
- failed ||= !ok;
553
- console.log(ok
554
- ? `${run.target}: snapshot ${run.snapshot_id?.slice(0, 8)}, ${formatBytes(run.added_bytes)} added`
555
- : `${run.target}: ${run.state}${run.error ? ` (${run.error})` : ''}`);
556
- }
557
- }
558
- if (pending.size) {
559
- console.error(`timed out waiting for ${pending.size} backup(s); they keep running on the node`);
560
- failed = true;
561
- }
562
- }
563
- if (failed)
564
- process.exitCode = 1;
565
- });
566
- program
567
- .command('backup-list')
568
- .description('show each backup target with its last success and next run, then recent runs')
569
- .option('--limit <n>', 'number of recent runs to show', '20')
570
- .option('--check', 'exit nonzero when a target is stale, or its last run failed or was missed, or there is no destination; for cron and monitors')
571
- .action(async (o) => {
572
- const project = (await loadCfg()).project || 'site';
573
- const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=${encodeURIComponent(o.limit)}`);
574
- if (!listing.destinationConfigured)
575
- console.error('no backup destination is configured; run tug backup-destination-set');
576
- if (!listing.targets.length)
577
- console.log('no backup targets; add x-tug.backup to a database service or volume');
578
- for (const t of listing.targets)
579
- console.log(`${t.target}\t${t.node ?? '-'}\t${t.schedule}\tlast success ${t.lastSuccessAt ?? 'never'}\tnext ${t.enabled ? t.nextRunAt : 'disabled'}`);
580
- for (const r of listing.runs)
581
- console.log(`${r.finished_at ?? r.queued_at}\t${r.target}\t${r.trigger}\t${r.state}\t${r.snapshot_id?.slice(0, 8) ?? '-'}\t${formatBytes(r.size_bytes)}${r.error ? `\t${r.error}` : ''}`);
582
- if (o.check) {
583
- const problems = backupCheckProblems(listing);
584
- for (const problem of problems)
585
- console.error(`PROBLEM ${problem}`);
586
- if (problems.length)
587
- process.exitCode = 1;
588
- }
589
- });
590
- program
591
- .command('restore <target>')
592
- .description('restore a service or volume from a backup. Without --confirm it only prints the plan. An in-place restore locks the project, stops the services that write to the target, takes a safety backup, restores, then starts them again')
593
- .option('--from <id>', 'backup run id, restic snapshot id, or latest', 'latest')
594
- .option('--scratch', 'restore a volume into a new volume next to the live one, touching nothing live')
595
- .option('--no-safety-backup', 'skip the backup of the current data taken before an in-place restore')
596
- .option('--writers <service>', 'service to stop during the restore, instead of discovering them (repeatable)', collect, [])
597
- .option('--no-writers', 'restore without stopping any service')
598
- .option('--confirm', 'perform the restore')
599
- .option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
600
- .action(async (target, o) => {
601
- const project = (await loadCfg()).project || 'site';
602
- const explicit = Array.isArray(o.writers) && o.writers.length > 0 ? o.writers : undefined;
603
- const result = await request('POST', '/api/v1/restores', {
604
- project,
605
- target,
606
- from: o.from,
607
- scratch: o.scratch === true,
608
- safetyBackup: o.safetyBackup,
609
- writers: explicit,
610
- noWriters: o.writers === false,
611
- confirm: o.confirm === true,
612
- });
613
- const plan = result.plan;
614
- console.log(`target: ${plan.target} on ${plan.node}`);
615
- console.log(`snapshot: ${plan.snapshotId}${plan.snapshotTime ? ` (${plan.snapshotTime})` : ''}`);
616
- console.log(`mode: ${plan.mode}${plan.scratchVolume ? ` into new volume ${plan.scratchVolume}` : ''}`);
617
- if (plan.mode === 'in-place') {
618
- console.log(`stops: ${plan.writers.length ? plan.writers.join(', ') : 'nothing'}`);
619
- console.log(`safety: ${plan.safetyBackup ? 'a backup of the current data is taken first' : 'none (--no-safety-backup)'}`);
620
- }
621
- if (!result.confirmed) {
622
- console.log('\nNothing was changed. Run again with --confirm to restore.');
623
- return;
624
- }
625
- const deadline = Date.now() + Number(o.timeout) * 1000;
626
- for (;;) {
627
- await new Promise((resolve) => setTimeout(resolve, 2000));
628
- const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
629
- const restore = restores.find((item) => item.id === result.restoreId);
630
- if (restore?.state === 'succeeded') {
631
- if (restore.error)
632
- console.error(`warning: ${restore.error}`);
633
- console.log(`restore ${restore.id} completed${restore.scratch_volume ? `; files are in volume ${restore.scratch_volume}` : ''}`);
634
- return;
635
- }
636
- if (restore && ['failed', 'interrupted', 'released'].includes(restore.state)) {
637
- console.error(`restore ${restore.id} ${restore.state}: ${restore.error ?? ''}`);
638
- if (restore.state === 'interrupted')
639
- console.error(`the services stay stopped; run tug restore-resume ${restore.id} to try again or tug restore-release ${restore.id} to start them (data may be partly restored)`);
640
- process.exitCode = 1;
641
- return;
642
- }
643
- if (Date.now() > deadline) {
644
- console.error(`timed out waiting; restore ${result.restoreId} continues on control (tug restore-list)`);
645
- process.exitCode = 1;
646
- return;
647
- }
648
- }
649
- });
650
- program
651
- .command('restore-list')
652
- .description('show recent restores and their state')
653
- .action(async () => {
654
- const project = (await loadCfg()).project || 'site';
655
- const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
656
- if (!restores.length)
657
- console.log('no restores');
658
- for (const r of restores)
659
- console.log(`${r.started_at}\t${r.id}\t${r.target}\t${r.mode}\t${r.state}\t${r.snapshot_id.slice(0, 8)}${r.error ? `\t${r.error}` : ''}`);
660
- });
661
- program
662
- .command('restore-resume <id>')
663
- .description('run an interrupted restore again; the stopped services stay stopped meanwhile')
664
- .action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/resume`))));
665
- program
666
- .command('restore-release <id>')
667
- .description('start the services an interrupted restore stopped and unlock the project; the data may be partly restored')
668
- .action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/release`))));
669
- program
670
- .command('backup-destination-set <url>')
671
- .description('set where backups go, such as s3:https://s3.example.com/bucket/tug-backups; credentials are stored encrypted on control')
672
- .option('--env <name>', 'credential variable for the backend, such as AWS_ACCESS_KEY_ID (repeatable); each value is read from the variable of that name in your environment, else from a line on stdin', collect, [])
673
- .action(async (url, o) => {
674
- const missing = o.env.filter((name) => process.env[name] === undefined);
675
- const lines = missing.length
676
- ? (await readStdin()).split(/\r?\n/).filter((line) => line.length > 0)
677
- : [];
678
- if (lines.length < missing.length)
679
- throw new Error(`expected a value on stdin for ${missing.slice(lines.length).join(', ')}`);
680
- const env = Object.fromEntries(o.env.map((name) => [name, process.env[name] ?? lines[missing.indexOf(name)]]));
681
- console.log(JSON.stringify(await request('POST', '/api/v1/backup/destination', { url, env })));
682
- });
683
- program
684
- .command('backup-destination-show')
685
- .description('show the backup destination and the names of its credential variables, never values')
686
- .action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/backup/destination'), null, 2)));
687
- program
688
- .command('backup-destination-remove')
689
- .description('remove the backup destination and its stored credentials; existing backups stay in the repository')
690
- .action(async () => console.log(JSON.stringify(await request('DELETE', '/api/v1/backup/destination'))));
691
- program
692
- .command('backup-repository-password')
693
- .description("print the project's repository and its password for offline escrow; anyone holding the password and destination credentials can read the project's backups")
694
- .action(async () => {
695
- const c = await loadCfg();
696
- const project = c.project || 'site';
697
- console.log(JSON.stringify(await request('GET', `/api/v1/backup/repository-password?project=${encodeURIComponent(project)}`), null, 2));
698
- });
699
- program
700
- .command('credential-create <name>')
701
- .description('create a named operator credential for CI or a teammate; it is printed once, control keeps no copy of its private key, and it expires after one year at most')
702
- .addOption(new Option('--role <role>', 'read: status and listings; deploy: read plus deploy, rollback, cancel, restart, logs; admin: everything')
703
- .choices(ROLES)
704
- .makeOptionMandatory())
705
- .addOption(new Option('--project <name>', 'limit a read or deploy credential to this project; repeat for several')
706
- .default([], 'all projects')
707
- .argParser((value, previous) => [...previous, value]))
708
- .option('--expires-in-days <n>', 'expire the credential after this many days (default and maximum: 365)')
709
- .action(async (name, o) => {
710
- const created = await request('POST', '/api/v1/credentials', {
711
- name,
712
- role: o.role,
713
- projects: o.project.length ? o.project : undefined,
714
- expiresInSeconds: o.expiresInDays ? Number(o.expiresInDays) * 86400 : undefined,
715
- });
716
- console.error('Store this credential now (for example as the TUG_CREDENTIAL secret in CI); it is not shown again. Replace one by creating a new name, verifying it, then revoking the old name.');
717
- console.log(JSON.stringify(created, null, 2));
718
- });
719
- program
720
- .command('credential-list')
721
- .description('list operator credentials with role, projects, expiry and last use; secrets are never shown')
722
- .action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/credentials'), null, 2)));
723
- program
724
- .command('credential-revoke <name>')
725
- .description('revoke an operator credential immediately; its name stays reserved for the audit trail')
726
- .action(async (name) => console.log(JSON.stringify(await request('DELETE', `/api/v1/credentials/${encodeURIComponent(name)}`), null, 2)));
727
- async function makeContext() {
728
- const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
729
- try {
730
- await createSourceArchive(process.cwd(), tmp, [
731
- resolvedConfig.file,
732
- resolvedConfig.localFile,
733
- ...(resolvedConfig.credentialFile ? [resolvedConfig.credentialFile] : []),
734
- ]);
735
- }
736
- catch (error) {
737
- await fs.rm(tmp, { force: true });
738
- throw error;
739
- }
740
- return tmp;
741
- }
742
- program
743
- .command('deploy')
744
- .option('-f, --file <file>', 'compose file', 'docker-compose.yml')
745
- .option('--version <version>', 'display version (default: git-<Compose hash>)')
746
- .option('--build-local', 'build application images locally and upload them without source')
747
- .option('--no-wait', 'return after desired state is created')
748
- .option('--timeout <seconds>', 'wait timeout in seconds', '300')
749
- .action(async (o) => {
750
- const cfg = await loadCfg();
751
- const project = o.project || cfg.project || 'site';
752
- // Control secrets are interpolated as sentinels; the agent substitutes the node's released values.
753
- const { secrets } = await request('GET', `/api/v1/secrets?project=${encodeURIComponent(project)}`);
754
- const compose = await normalizeCompose(o.file, secrets.map((secret) => secret.name), { project });
755
- const version = o.version || `git-${composeHash(compose)}`;
756
- let response;
757
- if (o.buildLocal) {
758
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-local-build-'));
759
- try {
760
- const form = new FormData();
761
- const localImages = await prepareLocalImages(compose, process.cwd(), directory);
762
- for (const { field } of localImages)
763
- form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
764
- form.append('payload', JSON.stringify({ project, version, compose, localImages }));
765
- response = await request('POST', '/api/v1/deployments', form, true);
766
- }
767
- finally {
768
- await fs.rm(directory, { recursive: true, force: true });
769
- }
770
- }
771
- else if (Object.values(compose.services).some((service) => service.build)) {
772
- const tarball = await makeContext();
773
- try {
774
- const form = new FormData();
775
- form.append('payload', JSON.stringify({ project, version, compose }));
776
- form.append('context', await openAsBlob(tarball), 'context.tgz');
777
- response = await request('POST', '/api/v1/deployments', form, true);
778
- }
779
- finally {
780
- await fs.rm(tarball, { force: true });
781
- }
782
- }
783
- else
784
- response = await request('POST', '/api/v1/deployments', {
785
- project,
786
- version,
787
- compose,
788
- });
789
- if (o.wait)
790
- response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
791
- console.log(JSON.stringify(response, null, 2));
792
- });
793
- program
794
- .command('status')
795
- .action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/status'), null, 2)));
796
- program
797
- .command('events')
798
- .description('list recent events with the credential that caused each control action; defaults to the resolved project')
799
- .option('--limit <n>', 'number of events', '100')
800
- .option('--all', 'list events across all projects (limited to your projects for scoped credentials)')
801
- .action(async (o) => {
802
- if (o.all && o.project)
803
- throw new Error('--all and --project cannot be combined');
804
- const cfg = await loadCfg();
805
- const project = o.all ? undefined : o.project || cfg.project;
806
- const query = `limit=${encodeURIComponent(o.limit)}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
807
- console.log(JSON.stringify(await request('GET', `/api/v1/events?${query}`), null, 2));
808
- });
809
- program
810
- .command('doctor')
811
- .description('check this machine and the control plane: certificate expiry, node connectivity and clock skew')
812
- .option('--nodes', 'also run host diagnostics on every node through its agent and wait')
813
- .option('--node <name>', 'run host diagnostics on one node through its agent and wait')
814
- .option('--timeout <seconds>', 'wait timeout for node diagnostics', '60')
815
- .action(async (o) => {
816
- const local = [];
817
- for (const [name, cmd, args] of [
818
- ['node', 'node', ['--version']],
819
- ['docker', 'docker', ['version', '--format', '{{.Server.Version}}']],
820
- ['docker_compose', 'docker', ['compose', 'version']],
821
- ]) {
822
- try {
823
- local.push({ name, ok: true, output: runCapture(cmd, args).trim() });
824
- }
825
- catch (e) {
826
- local.push({ name, ok: false, error: errMessage(e) });
827
- }
828
- }
829
- const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
830
- const warnings = (controlDiagnostics.checks ?? []).flatMap((check) => (check.warnings ?? []).map((warning) => `${check.name}: ${warning}`));
831
- const output = {
832
- ok: local.every((c) => c.ok) && controlDiagnostics.ok,
833
- ...(warnings.length && { warnings }),
834
- local,
835
- control: controlDiagnostics,
836
- };
837
- if (o.nodes || o.node) {
838
- const queued = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
839
- const commands = [];
840
- for (const command of queued.commands) {
841
- const finished = await waitForCommand(command.commandId, Number(o.timeout));
842
- commands.push({
843
- nodeId: command.nodeId,
844
- state: finished.state,
845
- result: JSON.parse(finished.result_json || '{}'),
846
- });
847
- }
848
- output.nodeDiagnostics = { commands };
849
- if (commands.some((command) => command.state !== 'succeeded'))
850
- output.ok = false;
851
- }
852
- console.log(JSON.stringify(output, null, 2));
853
- });
854
- program
855
- .command('rollback')
856
- .option('--force')
857
- .option('--no-wait', 'return immediately after activating rollback')
858
- .option('--timeout <seconds>', 'wait timeout', '300')
859
- .action(async (o) => {
860
- const cfg = await loadCfg();
861
- const response = await request('POST', '/api/v1/rollback', {
862
- project: o.project || cfg.project || 'site',
863
- force: Boolean(o.force),
864
- });
865
- if (o.wait)
866
- response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
867
- console.log(JSON.stringify(response, null, 2));
868
- });
869
- program
870
- .command('deploy-cancel <id>')
871
- .action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/deployments/${encodeURIComponent(id)}/cancel`, {}), null, 2)));
872
- program.command('volume-list').action(async (o) => {
873
- const cfg = await loadCfg();
874
- console.log(JSON.stringify(await request('GET', `/api/v1/volumes?project=${encodeURIComponent(o.project || cfg.project || 'site')}`), null, 2));
875
- });
876
- program
877
- .command('volume-remove <name>')
878
- .option('--delete-data', 'delete underlying Docker volume data')
879
- .action(async (name, o) => {
880
- const cfg = await loadCfg();
881
- const project = o.project || cfg.project || 'site';
882
- console.log(JSON.stringify(await request('DELETE', `/api/v1/volumes/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}&deleteData=${Boolean(o.deleteData)}`), null, 2));
883
- });
884
- program
885
- .command('control-backup')
886
- .description('create a control-plane backup (state, PKI, master key and retained image archives; not workload volumes)')
887
- .action(async () => console.log(JSON.stringify(await request('POST', '/api/v1/control/backups/run', {}), null, 2)));
888
- program
889
- .command('control-restore <artifactRef>')
890
- .description('restore a control-plane backup on the control-plane host; requires a control restart')
891
- .option('--confirm')
892
- .action(async (artifactRef, o) => console.log(JSON.stringify(await request('POST', '/api/v1/control/restore', {
893
- artifactRef,
894
- confirm: Boolean(o.confirm),
895
- }), null, 2)));
896
- async function waitForCommand(commandId, timeoutSeconds = 60) {
897
- const deadline = Date.now() + timeoutSeconds * 1000;
898
- while (Date.now() < deadline) {
899
- const cmd = await request('GET', `/api/v1/commands/${commandId}`);
900
- if (cmd?.state === 'succeeded' || cmd?.state === 'failed')
901
- return cmd;
902
- await new Promise((r) => setTimeout(r, 1000));
903
- }
904
- throw new Error(`timed out waiting for command ${commandId}`);
905
- }
906
- program
907
- .command('restart <service>')
908
- .option('--timeout <seconds>', 'wait timeout', '60')
909
- .action(async (svc, o) => {
910
- const cfg = await loadCfg();
911
- const project = o.project || cfg.project || 'site';
912
- const created = await request('POST', '/api/v1/restart', { project, service: svc });
913
- const cmd = await waitForCommand(created.commandId, Number(o.timeout));
914
- if (cmd.state === 'failed')
915
- throw new Error(JSON.parse(cmd.result_json || '{}').error || 'restart failed');
916
- console.log(JSON.stringify(cmd, null, 2));
917
- });
918
- async function runLive(operation, input) {
919
- const cfg = await loadCfg();
920
- process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.credential, cfg.controlUrl));
921
- }
922
- program
923
- .command('exec <service> [command...]')
924
- .description('execute a command on a service container (default: sh)')
925
- .option('-i, --interactive', 'forward stdin to the remote command')
926
- .option('-t, --tty', 'allocate a terminal (requires a local terminal and agent TTY support)')
927
- .option('--node <name>', 'select the node when a service has multiple placements')
928
- .action(async (service, command, o) => {
929
- await runLive('exec', { service, command, ...o });
930
- });
931
- program
932
- .command('run <service> [command...]')
933
- .description("run a one-off command in a new container from the service: same image, environment, secrets, network and mounts, removed afterwards (for example: tug run web -- wp core update-db). Exits with the command's exit code")
934
- .option('--node <name>', 'select the node when a service has multiple placements')
935
- .action(async (service, command, o) => {
936
- if (!command.length)
937
- throw new Error('run needs a command, such as: tug run web -- wp core update-db');
938
- await runLive('exec', { service, command, node: o.node, oneOff: true });
939
- });
940
- program
941
- .command('ps')
942
- .description('print per-container state as JSON')
943
- .option('--node <name>', 'select one node')
944
- .action(async (o) => {
945
- await runLive('ps', o);
946
- });
947
- program
948
- .command('inspect <service>')
949
- .description('print container details as JSON')
950
- .option('--node <name>', 'select one node')
951
- .action(async (service, o) => {
952
- await runLive('inspect', { service, ...o });
953
- });
954
- program
955
- .command('logs <service>')
956
- .description('fetch service container logs from the node that runs the service')
957
- .option('--tail <n>', 'number of lines', '200')
958
- .option('-f, --follow', 'follow live logs')
959
- .option('--node <name>', 'select a node for followed logs')
960
- .action(async (svc, o) => {
961
- if (o.follow) {
962
- await runLive('logs', { service: svc, node: o.node, tail: o.tail });
963
- return;
964
- }
965
- if (o.node)
966
- throw new Error('--node requires --follow for logs');
967
- const cfg = await loadCfg();
968
- const project = o.project || cfg.project || 'site';
969
- const created = await request('POST', '/api/v1/logs', {
970
- project,
971
- service: svc,
972
- tail: Number(o.tail),
973
- });
974
- const cmd = await waitForCommand(created.commandId, 60);
975
- const result = JSON.parse(cmd.result_json || '{}');
976
- if (result.stdout)
977
- process.stdout.write(result.stdout);
978
- if (result.stderr)
979
- process.stderr.write(result.stderr);
980
- if (result.error)
981
- throw new Error(result.error);
982
- });
30
+ registerInit(program);
31
+ registerControl(program);
32
+ registerNode(program);
33
+ registerDeploy(program);
34
+ registerObserve(program);
35
+ registerService(program);
36
+ registerSecret(program);
37
+ registerBackup(program);
38
+ registerRestore(program);
39
+ registerCredential(program);
40
+ registerVolume(program);
41
+ /** Commands that may create the config file, so a selected file need not exist yet. */
42
+ const CREATES_CONFIG = ['init', 'control setup'];
43
+ const NODE_NAME_FROM_ARGUMENT = ['node register', 'node replace', 'node repair'];
44
+ const NODE_NAME_FROM_OPTION = ['node add', 'node enroll'];
983
45
  program.hook('preAction', async (_root, command) => {
46
+ const path = commandPath(command);
984
47
  const options = command.opts();
985
48
  for (const [key, min, max] of [
986
49
  ['port', 1, 65535],
987
- ['wgPort', 1, 65535],
988
50
  ['timeout', 1, 86400],
989
51
  ['ttl', 1, 31536000],
990
52
  ['uses', 1, 1000000],
@@ -995,18 +57,18 @@ program.hook('preAction', async (_root, command) => {
995
57
  if (options[key] !== undefined)
996
58
  integerOption(String(options[key]), '--' + key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()), min, max);
997
59
  }
998
- if (command.name() === 'control-restore' && !options.confirm)
999
- throw new Error('control-restore requires --confirm');
1000
- const positional = command.name() === 'exec' ? command.args.slice(0, 1) : command.args;
60
+ if (path === 'control restore' && !options.confirm)
61
+ throw new Error('control restore requires --confirm');
62
+ const positional = path === 'exec' ? command.args.slice(0, 1) : command.args;
1001
63
  for (const value of [...positional, ...Object.values(options), ...Object.values(program.opts())])
1002
64
  if (typeof value === 'string' && /[\r\n\0]/.test(value))
1003
65
  throw new Error('arguments and option values must not contain line breaks or NUL');
1004
- if (command.name() === 'exec' && command.args.slice(1).some((value) => value.includes('\0')))
66
+ if (path === 'exec' && command.args.slice(1).some((value) => value.includes('\0')))
1005
67
  throw new Error('exec command arguments must not contain NUL');
1006
68
  if (options.name && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(options.name))
1007
69
  throw new Error('--name must contain only letters, numbers, dots, underscores or hyphens');
1008
70
  const connection = command.optsWithGlobals();
1009
- if (command.name() === 'credential-create') {
71
+ if (path === 'credential create') {
1010
72
  // --project is repeatable here and scopes the new credential, so it must not also select the connection project.
1011
73
  if (!CREDENTIAL_NAME_PATTERN.test(command.args[0]))
1012
74
  throw new Error('credential name must be 1-64 letters, numbers, dots, underscores or hyphens, starting with a letter or number');
@@ -1017,25 +79,31 @@ program.hook('preAction', async (_root, command) => {
1017
79
  throw new Error('admin credentials cannot be limited to projects; use --role read or --role deploy with --project');
1018
80
  connection.project = undefined;
1019
81
  }
1020
- resolvedConfig = await resolveCliConfig(connection, process.env, process.cwd(), command.name() === 'init');
82
+ const resolved = await resolveCliConfig(connection, process.env, process.cwd(), CREATES_CONFIG.includes(path));
83
+ setResolvedConfig(resolved);
1021
84
  // Config values are defaults for options not given on the command line; nodes are only read by the
1022
85
  // commands that create or register one, never by commands that talk to an existing node.
1023
- const nodeName = command.name() === 'node-register'
86
+ const nodeName = NODE_NAME_FROM_ARGUMENT.includes(path)
1024
87
  ? command.args[0]
1025
- : [
1026
- 'node-install',
1027
- 'node-replace-identity',
1028
- 'node-repair-identity',
1029
- 'node-prepare-enrollment',
1030
- ].includes(command.name())
88
+ : NODE_NAME_FROM_OPTION.includes(path)
1031
89
  ? options.name
1032
90
  : undefined;
1033
91
  applyConfigDefaults(command, options, {
1034
- ...resolvedConfig.commandDefaults,
1035
- ...(command.name().startsWith('node-') ? nodeSettings(resolvedConfig, nodeName) : {}),
92
+ ...resolved.commandDefaults,
93
+ ...(path.startsWith('node ') || path === 'control setup'
94
+ ? nodeSettings(resolved, nodeName)
95
+ : {}),
1036
96
  });
1037
97
  });
1038
- for (const command of program.commands) {
98
+ /** Every command that does work accepts the connection options, so they can follow the command. */
99
+ function addConnectionOptions(command) {
100
+ if (command.commands.length) {
101
+ for (const child of command.commands)
102
+ addConnectionOptions(child);
103
+ // `deploy` is both a command and the parent of `deploy cancel`.
104
+ if (!command.options.length)
105
+ return;
106
+ }
1039
107
  command
1040
108
  .option('--config <file>', CONFIG_HELP)
1041
109
  .option('--control-url <url>', 'connect target for the control API (the credential still verifies its own public URL)')
@@ -1043,8 +111,14 @@ for (const command of program.commands) {
1043
111
  if (!command.options.some((option) => option.long === '--project'))
1044
112
  command.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
1045
113
  }
1046
- for (const command of [program, ...program.commands])
114
+ function forbidExtraArguments(command) {
1047
115
  command.allowExcessArguments(false);
116
+ for (const child of command.commands)
117
+ forbidExtraArguments(child);
118
+ }
119
+ for (const command of program.commands)
120
+ addConnectionOptions(command);
121
+ forbidExtraArguments(program);
1048
122
  program.showHelpAfterError();
1049
123
  program.parseAsync().catch((e) => {
1050
124
  console.error(`${errMessage(e)}\nRun tug <command> --help for usage.`);