@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.
Files changed (53) hide show
  1. package/README.md +93 -34
  2. package/dist/agent-credential.js +3 -3
  3. package/dist/agent.js +1 -1
  4. package/dist/backup-check.d.ts +1 -1
  5. package/dist/backup-config.js +32 -24
  6. package/dist/backup-destination.d.ts +1 -1
  7. package/dist/backup-destination.js +1 -1
  8. package/dist/backup-restore.js +65 -65
  9. package/dist/backup-scheduler.js +1 -1
  10. package/dist/cli-build.js +1 -1
  11. package/dist/cli-config.d.ts +6 -1
  12. package/dist/cli-config.js +18 -44
  13. package/dist/cli-install.d.ts +4 -2
  14. package/dist/cli-install.js +12 -7
  15. package/dist/cli-live.js +4 -6
  16. package/dist/cli-onboarding.d.ts +31 -0
  17. package/dist/cli-onboarding.js +66 -0
  18. package/dist/cli-prompt.d.ts +9 -0
  19. package/dist/cli-prompt.js +60 -0
  20. package/dist/cli-secrets.d.ts +12 -0
  21. package/dist/cli-secrets.js +71 -0
  22. package/dist/cli-status.d.ts +11 -0
  23. package/dist/cli-status.js +37 -7
  24. package/dist/cli.js +62 -981
  25. package/dist/commands/backup.d.ts +2 -0
  26. package/dist/commands/backup.js +109 -0
  27. package/dist/commands/context.d.ts +54 -0
  28. package/dist/commands/context.js +96 -0
  29. package/dist/commands/control.d.ts +2 -0
  30. package/dist/commands/control.js +110 -0
  31. package/dist/commands/credential.d.ts +2 -0
  32. package/dist/commands/credential.js +36 -0
  33. package/dist/commands/deploy.d.ts +2 -0
  34. package/dist/commands/deploy.js +139 -0
  35. package/dist/commands/host.d.ts +18 -0
  36. package/dist/commands/host.js +99 -0
  37. package/dist/commands/init.d.ts +11 -0
  38. package/dist/commands/init.js +40 -0
  39. package/dist/commands/node.d.ts +28 -0
  40. package/dist/commands/node.js +320 -0
  41. package/dist/commands/observe.d.ts +2 -0
  42. package/dist/commands/observe.js +71 -0
  43. package/dist/commands/restore.d.ts +2 -0
  44. package/dist/commands/restore.js +85 -0
  45. package/dist/commands/secret.d.ts +2 -0
  46. package/dist/commands/secret.js +26 -0
  47. package/dist/commands/service.d.ts +2 -0
  48. package/dist/commands/service.js +62 -0
  49. package/dist/commands/volume.d.ts +2 -0
  50. package/dist/commands/volume.js +13 -0
  51. package/dist/control.js +6 -6
  52. package/dist/diagnostics.js +1 -1
  53. package/package.json +1 -1
@@ -0,0 +1,99 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { saveImageArchive } from "../artifacts.js";
6
+ import { controlPortCheck, localImageBuildArgs, shellQuote as sh } from "../cli-install.js";
7
+ import { LIST_ADDRESSES_COMMAND, parseAddressCandidates, sortCandidates, } from "../cli-onboarding.js";
8
+ import { runCapture, runLocal, templatesDir } from "./context.js";
9
+ export function preflightRemote(target, { controlUrl, ports = [], minDiskMb = 1024, compose = false, } = {}) {
10
+ const checks = [
11
+ [
12
+ 'docker.version',
13
+ 'docker version >/dev/null',
14
+ 'Install Docker Engine and ensure the SSH user can access it.',
15
+ ],
16
+ ...(compose
17
+ ? [
18
+ [
19
+ 'docker.compose.version',
20
+ 'docker compose version >/dev/null',
21
+ 'Install Docker Compose v2.',
22
+ ],
23
+ ]
24
+ : []),
25
+ [
26
+ 'paths',
27
+ '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',
28
+ 'Ensure /opt, /var/lib, and /etc/tug are writable by the installer.',
29
+ ],
30
+ [
31
+ 'disk.space',
32
+ `[ "$(df -Pm /var/lib | tail -1 | awk '{print $4}')" -ge ${Number(minDiskMb)} ]`,
33
+ `Free at least ${minDiskMb}MB on the filesystem backing /var/lib.`,
34
+ ],
35
+ ];
36
+ for (const port of ports) {
37
+ checks.push([
38
+ `port.${port}.free`,
39
+ controlPortCheck(port),
40
+ `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.`,
41
+ ]);
42
+ }
43
+ if (controlUrl)
44
+ checks.push([
45
+ 'control.reachable',
46
+ // TCP reachability only: no credential is copied to the host, and real TLS authorization happens when the agent connects.
47
+ `timeout 5 bash -c ${sh(`exec 3<>/dev/tcp/${new URL(controlUrl).hostname.replace(/^\[|\]$/g, '')}/${new URL(controlUrl).port || 443}`)}`,
48
+ 'Verify DNS and the firewall from the node to the control port (control speaks mTLS only, so only TCP reachability is checked here).',
49
+ ]);
50
+ const script = `set -e
51
+ ${checks.map(([name, cmd, fix]) => `echo "preflight: ${name}"; (${cmd}) || { echo "FAILED ${name}: ${fix}" >&2; exit 20; }`).join('\n')}
52
+ `;
53
+ runLocal('ssh', ['--', target, script]);
54
+ }
55
+ export async function transferTugImage(target, imageTag) {
56
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-runtime-'));
57
+ const archive = path.join(directory, 'image.tar.gz');
58
+ const remote = `/tmp/${path.basename(directory)}.tar.gz`;
59
+ try {
60
+ const uname = runCapture('ssh', ['--', target, 'uname -m']);
61
+ runLocal('docker', localImageBuildArgs(imageTag, uname));
62
+ await saveImageArchive(imageTag, archive);
63
+ runLocal('scp', ['--', archive, `${target}:${remote}`]);
64
+ runLocal('ssh', [
65
+ '--',
66
+ target,
67
+ `set -eu\ntrap ${sh(`rm -f ${sh(remote)}`)} EXIT\ndocker load --input ${sh(remote)}`,
68
+ ]);
69
+ }
70
+ finally {
71
+ await fs.rm(directory, { recursive: true, force: true });
72
+ }
73
+ }
74
+ export async function dockerBootstrap() {
75
+ return fs.readFile(path.join(templatesDir, 'install-docker.sh'), 'utf8');
76
+ }
77
+ /** Installs Docker over SSH; `compose` also installs the Compose plugin, which only the control host needs. */
78
+ export async function bootstrapDocker(target, compose = false) {
79
+ runLocal('ssh', [
80
+ '--',
81
+ target,
82
+ `${compose ? 'TUG_INSTALL_COMPOSE=1 ' : ''}sh -eu -c ${sh(await dockerBootstrap())}`,
83
+ ]);
84
+ }
85
+ /** Whether the host already has the Docker Engine (and the Compose plugin when `compose`). */
86
+ export function hostHasDocker(target, compose = false) {
87
+ const check = `docker version >/dev/null 2>&1${compose ? ' && docker compose version >/dev/null 2>&1' : ''}`;
88
+ return spawnSync('ssh', ['--', target, check], { stdio: 'ignore' }).status === 0;
89
+ }
90
+ /** The host's short hostname, or undefined when it cannot be read. */
91
+ export function remoteHostname(target) {
92
+ const result = spawnSync('ssh', ['--', target, 'hostname -s'], { encoding: 'utf8' });
93
+ return result.status === 0 ? String(result.stdout).trim() || undefined : undefined;
94
+ }
95
+ /** The host's global IPv4 addresses, private ones first; empty when the host cannot say. */
96
+ export function discoverAddresses(target) {
97
+ const result = spawnSync('ssh', ['--', target, LIST_ADDRESSES_COMMAND], { encoding: 'utf8' });
98
+ return result.status === 0 ? sortCandidates(parseAddressCandidates(String(result.stdout))) : [];
99
+ }
@@ -0,0 +1,11 @@
1
+ import type { Command } from 'commander';
2
+ import type { ResolvedCliConfig } from '../cli-config.ts';
3
+ /**
4
+ * Store a credential document in a 0600 file under the user's config directory and point the git-ignored overlay
5
+ * next to the tracked config at it, creating the tracked config when it is missing. Prints what it wrote.
6
+ */
7
+ export declare function importCredential(cfg: ResolvedCliConfig, text: string, connection: {
8
+ controlUrl?: string;
9
+ project?: string;
10
+ }): Promise<void>;
11
+ export declare function registerInit(program: Command): void;
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { writeInitConfig } from "../cli-config.js";
5
+ import { decodeCredential, writeFileDurable } from "../pki.js";
6
+ import { loadCfg, readStdin } from "./context.js";
7
+ /**
8
+ * Store a credential document in a 0600 file under the user's config directory and point the git-ignored overlay
9
+ * next to the tracked config at it, creating the tracked config when it is missing. Prints what it wrote.
10
+ */
11
+ export async function importCredential(cfg, text, connection) {
12
+ const credential = decodeCredential(text);
13
+ // TUG_CREDENTIALS_DIR keeps scripts and tests out of the real home directory.
14
+ const dir = process.env.TUG_CREDENTIALS_DIR ||
15
+ path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'tug', 'credentials');
16
+ const source = path.join(dir, `${new URL(credential.url).host.replace(/[^A-Za-z0-9.-]/g, '_')}.json`);
17
+ await writeFileDurable(source, `${JSON.stringify(credential, null, 2)}\n`, 0o600);
18
+ for (const file of await writeInitConfig(cfg, source, connection))
19
+ console.log(`wrote ${file}`);
20
+ console.log(`imported credential to ${source}`);
21
+ }
22
+ export function registerInit(program) {
23
+ program
24
+ .command('init [credentialFile]')
25
+ .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')
26
+ .action(async (credentialFile, _o, command) => {
27
+ const cfg = await loadCfg();
28
+ const { controlUrl, project } = command.optsWithGlobals();
29
+ let text;
30
+ if (credentialFile === '-')
31
+ text = await readStdin();
32
+ else if (credentialFile)
33
+ text = await fs.readFile(credentialFile, 'utf8');
34
+ else if (cfg.credential)
35
+ text = JSON.stringify(cfg.credential);
36
+ if (!text)
37
+ throw new Error('tug init needs a credential file (or - for stdin)');
38
+ await importCredential(cfg, text, { controlUrl, project });
39
+ });
40
+ }
@@ -0,0 +1,28 @@
1
+ import type { Command } from 'commander';
2
+ import type { AddressCandidate } from '../cli-onboarding.ts';
3
+ import type { Prompter } from '../cli-prompt.ts';
4
+ import type { InstallOpts } from './context.ts';
5
+ /** Everything the SSH-assisted node commands share. */
6
+ export interface NodeIdentityOpts extends InstallOpts {
7
+ name?: string;
8
+ address?: string;
9
+ label: string[];
10
+ agentControlUrl?: string;
11
+ all?: boolean;
12
+ wait: boolean;
13
+ ingress?: boolean;
14
+ yes?: boolean;
15
+ }
16
+ /** The questions and probes `planNodeAdd` makes, injectable so the prompt flow is testable without a terminal or SSH. */
17
+ export interface PlanDeps {
18
+ interactive: boolean;
19
+ hasDocker: (target: string) => boolean;
20
+ discover: (target: string) => AddressCandidate[];
21
+ prompt: Prompter;
22
+ }
23
+ /**
24
+ * What `node add` is about to do, with the choices a terminal can still make: Docker, the inter-node address and the
25
+ * ingress role are asked about, never assumed. Without a terminal nothing is probed or asked and only the plan is printed.
26
+ */
27
+ export declare function planNodeAdd(sshTarget: string, name: string, o: NodeIdentityOpts, deps?: PlanDeps): Promise<void>;
28
+ export declare function registerNode(program: Command): void;
@@ -0,0 +1,320 @@
1
+ import { applyConfigDefaults, normalizeControlUrl, nodeSettings } from "../cli-config.js";
2
+ import { formatNodes } from "../cli-status.js";
3
+ import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, } from "../cli-install.js";
4
+ import { formatNodePlan, inferNodeName, isValidNodeName } from "../cli-onboarding.js";
5
+ import { ask, canPrompt, choose, confirm } from "../cli-prompt.js";
6
+ import { isNodeHost } from "../node-address.js";
7
+ import { resolveImage } from "../image.js";
8
+ import { errMessage, loadTemplate } from "../util.js";
9
+ import { collect, loadCfg, print, readStdin, request, runCapture, runLocal, templatesDir, transportFor, } from "./context.js";
10
+ import { bootstrapDocker, discoverAddresses, remoteHostname, dockerBootstrap, hostHasDocker, preflightRemote, transferTugImage, } from "./host.js";
11
+ import fs from 'node:fs/promises';
12
+ const AGENT_STATE = '/var/lib/tug';
13
+ function addNodeIdentityOptions(command) {
14
+ return command
15
+ .option('--address <addr>', 'private inter-node address for this node')
16
+ .option('--label <label>', 'node label (repeatable)', collect, [])
17
+ .option('--all', 'walk every node with an ssh target in the config file, continuing after failures')
18
+ .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')
19
+ .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
20
+ .option('--install-docker', 'install missing Docker Engine on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
21
+ .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')
22
+ .option('--no-wait', 'do not wait for the node to report after the install');
23
+ }
24
+ /**
25
+ * The SSH-assisted flow: the node makes its own key and CSR, an admin credential authorizes the identity at this moment and
26
+ * the certificate is installed back over SSH, so no bootstrap secret travels and the private key never leaves the node.
27
+ */
28
+ async function installNodeIdentity(sshTarget, name, mode, o, repair = false,
29
+ /** False in batch mode, where only the final per-node results are printed. */
30
+ printResult = true) {
31
+ const cfg = await loadCfg();
32
+ const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
33
+ await transportFor(cfg).then((t) => t.close()); // fail before touching the host when there is no credential
34
+ if (o.installDocker)
35
+ await bootstrapDocker(sshTarget);
36
+ preflightRemote(sshTarget, {
37
+ controlUrl: connectUrl ?? cfg.credential?.url,
38
+ });
39
+ const image = resolveImage(o.image, o.buildLocalImage);
40
+ if (o.buildLocalImage)
41
+ await transferTugImage(sshTarget, image);
42
+ const started = new Date().toISOString();
43
+ if (repair)
44
+ runLocal('ssh', [
45
+ '--',
46
+ sshTarget,
47
+ `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`,
48
+ ]);
49
+ const agent = (command, interactive = false) => `mkdir -p ${AGENT_STATE} && docker run --rm ${interactive ? '-i ' : ''}-v ${AGENT_STATE}:${AGENT_STATE} ${sh(image)} agent ${command}`;
50
+ const csr = runCapture('ssh', ['--', sshTarget, agent('identity')]);
51
+ const response = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode, address: o.address, labels: o.label });
52
+ runCapture('ssh', ['--', sshTarget, agent('install-cert', true)], {
53
+ input: JSON.stringify(response),
54
+ });
55
+ const env = await loadTemplate(templatesDir, 'agent.env', {
56
+ CONTROL_URL_LINE: connectUrl ? `TUG_CONTROL_URL=${connectUrl}\n` : '',
57
+ NODE_NAME: name,
58
+ });
59
+ const remote = await loadTemplate(templatesDir, 'node-install.sh', {
60
+ ENV: env,
61
+ IMAGE_SH: sh(image),
62
+ RUN_FLAGS: `${HOST_NETWORK_FLAG} `,
63
+ });
64
+ runLocal('ssh', ['--', sshTarget, remote]);
65
+ if (o.ingress)
66
+ await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, { enabled: true });
67
+ const result = { ok: true, node: response.node, ...(o.ingress ? { ingress: true } : {}) };
68
+ let reporting = !o.wait;
69
+ if (o.wait) {
70
+ // A report from the new identity: wait for a report after the install began, not a replaced node's stale status.
71
+ for (let i = 0; i < 30; i++) {
72
+ const { nodes } = await request('GET', '/api/v1/nodes');
73
+ const seen = nodes.find((n) => n.name === name)?.last_seen_at;
74
+ if (seen && seen > started) {
75
+ reporting = true;
76
+ break;
77
+ }
78
+ await new Promise((r) => setTimeout(r, 2000));
79
+ }
80
+ if (!reporting)
81
+ console.error(`node ${name} has not reported yet; check \`docker logs tug-agent\` on the host`);
82
+ }
83
+ if (printResult)
84
+ print(o.wait ? { ...result, reporting } : result);
85
+ return reporting;
86
+ }
87
+ const realPlanDeps = () => ({
88
+ interactive: canPrompt(),
89
+ hasDocker: (target) => hostHasDocker(target),
90
+ discover: discoverAddresses,
91
+ prompt: ask,
92
+ });
93
+ /**
94
+ * What `node add` is about to do, with the choices a terminal can still make: Docker, the inter-node address and the
95
+ * ingress role are asked about, never assumed. Without a terminal nothing is probed or asked and only the plan is printed.
96
+ */
97
+ export async function planNodeAdd(sshTarget, name, o, deps = realPlanDeps()) {
98
+ const { interactive, prompt } = deps;
99
+ if (interactive && !o.installDocker && !deps.hasDocker(sshTarget))
100
+ o.installDocker = await confirm(`Docker is not installed on ${sshTarget}. Install it now (needs root)?`, true, prompt);
101
+ if (interactive && !o.address) {
102
+ const candidates = deps.discover(sshTarget);
103
+ if (candidates.length) {
104
+ const none = 'none: leave the address unset';
105
+ const options = [
106
+ ...candidates.map((c) => `${c.address} (${c.interface}, ${c.private ? 'private' : 'public'})`),
107
+ none,
108
+ ];
109
+ // A public address would carry cross-node traffic in the clear, so it is never the default.
110
+ const index = await choose(`Which address should other nodes use to reach ${name}?`, options, candidates[0].private ? 0 : options.length - 1, prompt);
111
+ if (index < candidates.length)
112
+ o.address = candidates[index].address;
113
+ }
114
+ }
115
+ if (interactive && o.ingress === undefined)
116
+ o.ingress = await confirm(`Should ${name} handle ingress (managed Caddy on ports 80 and 443)?`, false, prompt);
117
+ console.error(formatNodePlan({
118
+ name,
119
+ ssh: sshTarget,
120
+ installDocker: o.installDocker,
121
+ probed: interactive,
122
+ address: o.address,
123
+ ingress: o.ingress,
124
+ image: resolveImage(o.image, o.buildLocalImage),
125
+ }));
126
+ if (interactive && !o.yes && !(await confirm(`Add ${name}?`, true, prompt)))
127
+ throw new Error('cancelled; nothing was changed');
128
+ }
129
+ /**
130
+ * A name for a node when the SSH host does not suggest one (an IP address): in a terminal ask for it, offering the
131
+ * host's own hostname as the default. Without a terminal there is nobody to ask.
132
+ */
133
+ async function promptNodeName(sshTarget) {
134
+ if (!canPrompt())
135
+ return undefined;
136
+ const suggestion = sshTarget ? remoteHostname(sshTarget) : undefined;
137
+ const fallback = suggestion && isValidNodeName(suggestion) ? suggestion : undefined;
138
+ for (;;) {
139
+ const answer = (await ask(`Node name${fallback ? ` (${fallback})` : ''}: `)).trim() || fallback;
140
+ if (!answer)
141
+ continue;
142
+ if (isValidNodeName(answer))
143
+ return answer;
144
+ console.error('Use letters, numbers, dots, underscores or hyphens, starting with a letter or number.');
145
+ }
146
+ }
147
+ async function runNodeIdentity(positional, o, mode, command, repair = false) {
148
+ const cfg = await loadCfg();
149
+ const verb = repair ? 'node repair' : mode === 'create' ? 'node add' : 'node replace';
150
+ if (o.all) {
151
+ if (positional.name || positional.ssh)
152
+ throw new Error(`${verb} --all walks every configured node; do not name one`);
153
+ const results = [];
154
+ for (const [name, settings] of Object.entries(cfg.nodes)) {
155
+ const target = settings.ssh;
156
+ if (!target)
157
+ continue;
158
+ const merged = { ...o, name };
159
+ applyConfigDefaults(command, merged, nodeSettings(cfg, name));
160
+ try {
161
+ const reporting = await installNodeIdentity(target, name, mode, merged, repair, false);
162
+ results.push({ node: name, ok: true, ...(merged.wait ? { reporting } : {}) });
163
+ }
164
+ catch (e) {
165
+ results.push({ node: name, ok: false, error: errMessage(e) });
166
+ }
167
+ }
168
+ print({ results });
169
+ if (results.some((r) => !r.ok || r.reporting === false))
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+ let name = positional.name ?? (positional.ssh ? inferNodeName(positional.ssh) : undefined);
174
+ if (!name && mode === 'create')
175
+ name = await promptNodeName(positional.ssh);
176
+ if (!name)
177
+ throw new Error(mode === 'create'
178
+ ? `${verb} needs --name${positional.ssh ? ' (an IP address does not suggest one; in a terminal it asks)' : ''}`
179
+ : `${verb} needs the node name`);
180
+ if (mode === 'create') {
181
+ // The name may have come from the SSH host, after the config defaults were applied without one.
182
+ o.name = name;
183
+ applyConfigDefaults(command, o, nodeSettings(cfg, name));
184
+ }
185
+ const sshTarget = positional.ssh ?? nodeSettings(cfg, name).ssh;
186
+ if (!sshTarget)
187
+ throw new Error(`${verb} needs an SSH target: pass <ssh-target> or set nodes.${name}.ssh in ${cfg.file}`);
188
+ if (mode === 'create') {
189
+ if (o.agentControlUrl)
190
+ normalizeControlUrl(o.agentControlUrl);
191
+ await transportFor(cfg).then((t) => t.close()); // no questions about a host this CLI cannot enroll
192
+ await planNodeAdd(sshTarget, name, o);
193
+ }
194
+ if (!(await installNodeIdentity(sshTarget, name, mode, o, repair)))
195
+ process.exitCode = 1;
196
+ }
197
+ export function registerNode(program) {
198
+ const node = program.command('node').description('add, inspect, repair and remove nodes');
199
+ addNodeIdentityOptions(node
200
+ .command('add [sshTarget]')
201
+ .description('create a node and install its agent over SSH; the name defaults to the first label of the SSH host and the SSH target to nodes.<name>.ssh in the config file. In a terminal it offers to install Docker, choose the inter-node address and enable ingress, then shows a plan to confirm. Safe to rerun after a partial failure')
202
+ .option('--name <name>', 'node name (default: the first DNS label of the SSH host)')
203
+ .option('--ingress', 'also make the node an ingress node (managed Caddy on ports 80/443)')
204
+ .option('-y, --yes', 'do not ask for confirmation in a terminal')).action(async (ssh, o, command) => runNodeIdentity({ name: o.name, ssh }, o, 'create', command));
205
+ for (const [verb, description, repair] of [
206
+ [
207
+ 'replace',
208
+ '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',
209
+ false,
210
+ ],
211
+ [
212
+ 'repair',
213
+ 'move aside corrupt agent credential documents on the node (never service volumes) and replace its identity',
214
+ true,
215
+ ],
216
+ ]) {
217
+ addNodeIdentityOptions(node
218
+ .command(`${verb} [name] [sshTarget]`)
219
+ .description(`${description} (the SSH target defaults to nodes.<name>.ssh in the config file; --all walks every configured node)`)).action(async (name, ssh, o, command) => runNodeIdentity({ name, ssh }, o, 'replace', command, repair));
220
+ }
221
+ node
222
+ .command('list')
223
+ .alias('ls')
224
+ .description('list the registered nodes with their state, address, ingress role and last contact')
225
+ .option('--json', 'print the raw API result as JSON')
226
+ .action(async (o) => {
227
+ const result = await request('GET', '/api/v1/nodes');
228
+ if (o.json)
229
+ print(result);
230
+ else
231
+ console.log(formatNodes(result.nodes));
232
+ });
233
+ node
234
+ .command('enroll')
235
+ .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')
236
+ .option('--name <name>', 'the node name the key is bound to; mint one key per instance (asked for in a terminal when omitted)')
237
+ .option('--address <addr>', 'private inter-node address for this node')
238
+ .option('--agent-control-url <url>', 'connect-only URL the agent dials; the certificate is still verified against the credential URL')
239
+ .option('--ttl <seconds>', 'join key lifetime', '3600')
240
+ .option('--image <tag>', "agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
241
+ .option('--label <label>', 'node label (repeatable)', collect, [])
242
+ .option('--install-docker', 'print cloud-init that installs Docker and starts the agent')
243
+ .option('--json', 'print JSON including the command or cloud-init payload')
244
+ .action(async (o, cmd) => {
245
+ if (!o.name) {
246
+ o.name = await promptNodeName();
247
+ if (!o.name)
248
+ throw new Error('node enroll needs --name');
249
+ applyConfigDefaults(cmd, o, nodeSettings(await loadCfg(), o.name));
250
+ }
251
+ if (o.address !== undefined && !isNodeHost(o.address))
252
+ throw new Error('address must be an IP address or hostname without a port');
253
+ const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
254
+ const minted = await request('POST', '/api/v1/nodes/enrollment-credential', {
255
+ name: o.name,
256
+ ttlSeconds: Number(o.ttl),
257
+ labels: o.label.length ? o.label : undefined,
258
+ });
259
+ const keyPayload = {
260
+ v: 2,
261
+ credential: minted.credential,
262
+ name: minted.name,
263
+ ...(connectUrl ? { connectUrl } : {}),
264
+ };
265
+ const joinKey = Buffer.from(JSON.stringify(keyPayload)).toString('base64url');
266
+ const command = dockerRunSnippet(resolveImage(o.image), joinKey, o.address);
267
+ const cloudInit = o.installDocker
268
+ ? enrollmentCloudInit(await dockerBootstrap(), command)
269
+ : undefined;
270
+ if (o.json) {
271
+ print({
272
+ name: minted.name,
273
+ expiresAt: minted.expiresAt,
274
+ joinKey,
275
+ command,
276
+ ...(cloudInit ? { cloudInit } : {}),
277
+ });
278
+ return;
279
+ }
280
+ if (cloudInit) {
281
+ process.stdout.write(cloudInit);
282
+ return;
283
+ }
284
+ console.log(`# Join key for ${minted.name} valid until ${minted.expiresAt}, single use. Run on the fresh host:\n`);
285
+ console.log(command);
286
+ });
287
+ node
288
+ .command('register <name>')
289
+ .description('sign a node-generated CSR and print the identity response (the primitive behind node add; the private key stays with whoever made the CSR)')
290
+ .requiredOption('--csr <file>', 'PEM certificate request generated on the node (- for stdin)')
291
+ .option('--mode <mode>', 'create a new node or replace an existing node key', 'create')
292
+ .option('--address <addr>')
293
+ .option('--label <label>', 'node label (repeatable)', collect, [])
294
+ .action(async (name, o) => {
295
+ if (o.mode !== 'create' && o.mode !== 'replace')
296
+ throw new Error("--mode must be 'create' or 'replace'");
297
+ const csr = o.csr === '-' ? await readStdin() : await fs.readFile(o.csr, 'utf8');
298
+ print(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode: o.mode, address: o.address, labels: o.label }));
299
+ });
300
+ node
301
+ .command('address <name> <address>')
302
+ .description("update a node's inter-node address")
303
+ .action(async (name, address) => print(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/address`, { address })));
304
+ node
305
+ .command('disable <name>')
306
+ .description("block a node's key immediately without deleting its metadata or placement (no SSH needed); bring it back with node replace")
307
+ .action(async (name) => print(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/disable`, {})));
308
+ node
309
+ .command('remove <name>')
310
+ .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')
311
+ .option('--force', 'remove even if the node is still reporting')
312
+ .action(async (name, o) => print(await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}${o.force ? '?force=true' : ''}`)));
313
+ node
314
+ .command('ingress <name>')
315
+ .description('mark a node as an ingress node (managed Caddy on ports 80/443), or remove the role with --disable')
316
+ .option('--disable', 'stop acting as an ingress node')
317
+ .action(async (name, o) => print(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, {
318
+ enabled: !o.disable,
319
+ })));
320
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerObserve(program: Command): void;
@@ -0,0 +1,71 @@
1
+ import { errMessage } from "../util.js";
2
+ import { formatStatus } from "../cli-status.js";
3
+ import { loadCfg, print, request, runCapture, waitForCommand } from "./context.js";
4
+ export function registerObserve(program) {
5
+ program
6
+ .command('status')
7
+ .description('summarize projects, deployments and nodes')
8
+ .option('--json', 'print the raw status as JSON for scripts')
9
+ .action(async (o) => {
10
+ const status = await request('GET', '/api/v1/status');
11
+ console.log(o.json ? JSON.stringify(status, null, 2) : formatStatus(status));
12
+ });
13
+ program
14
+ .command('events')
15
+ .description('list recent events with the credential that caused each control action; defaults to the resolved project')
16
+ .option('--limit <n>', 'number of events', '100')
17
+ .option('--all', 'list events across all projects (limited to your projects for scoped credentials)')
18
+ .action(async (o) => {
19
+ if (o.all && o.project)
20
+ throw new Error('--all and --project cannot be combined');
21
+ const cfg = await loadCfg();
22
+ const project = o.all ? undefined : o.project || cfg.project;
23
+ const query = `limit=${encodeURIComponent(o.limit)}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
24
+ print(await request('GET', `/api/v1/events?${query}`));
25
+ });
26
+ program
27
+ .command('doctor')
28
+ .description('check this machine and the control plane: certificate expiry, node connectivity and clock skew')
29
+ .option('--nodes', 'also run host diagnostics on every node through its agent and wait')
30
+ .option('--node <name>', 'run host diagnostics on one node through its agent and wait')
31
+ .option('--timeout <seconds>', 'wait timeout for node diagnostics', '60')
32
+ .action(async (o) => {
33
+ const local = [];
34
+ for (const [name, cmd, args] of [
35
+ ['node', 'node', ['--version']],
36
+ ['docker', 'docker', ['version', '--format', '{{.Server.Version}}']],
37
+ ['docker_compose', 'docker', ['compose', 'version']],
38
+ ]) {
39
+ try {
40
+ local.push({ name, ok: true, output: runCapture(cmd, args).trim() });
41
+ }
42
+ catch (e) {
43
+ local.push({ name, ok: false, error: errMessage(e) });
44
+ }
45
+ }
46
+ const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
47
+ const warnings = (controlDiagnostics.checks ?? []).flatMap((check) => (check.warnings ?? []).map((warning) => `${check.name}: ${warning}`));
48
+ const output = {
49
+ ok: local.every((c) => c.ok) && controlDiagnostics.ok,
50
+ ...(warnings.length && { warnings }),
51
+ local,
52
+ control: controlDiagnostics,
53
+ };
54
+ if (o.nodes || o.node) {
55
+ const queued = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
56
+ const commands = [];
57
+ for (const command of queued.commands) {
58
+ const finished = await waitForCommand(command.commandId, Number(o.timeout));
59
+ commands.push({
60
+ nodeId: command.nodeId,
61
+ state: finished.state,
62
+ result: JSON.parse(finished.result_json || '{}'),
63
+ });
64
+ }
65
+ output.nodeDiagnostics = { commands };
66
+ if (commands.some((command) => command.state !== 'succeeded'))
67
+ output.ok = false;
68
+ }
69
+ print(output);
70
+ });
71
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerRestore(program: Command): void;