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