@wpelevator/tug 0.10.0 → 0.12.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 +172 -75
- package/dist/agent-credential.js +3 -3
- package/dist/agent.js +39 -3
- package/dist/backup-check.d.ts +1 -1
- package/dist/backup-config.js +33 -25
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/backup-restore.d.ts +8 -1
- package/dist/backup-restore.js +135 -71
- package/dist/backup-scheduler.js +1 -1
- package/dist/cli-build.js +1 -1
- package/dist/cli-config.d.ts +7 -2
- package/dist/cli-config.js +25 -46
- 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 +66 -981
- package/dist/commands/backup.d.ts +2 -0
- package/dist/commands/backup.js +109 -0
- package/dist/commands/context.d.ts +58 -0
- package/dist/commands/context.js +111 -0
- package/dist/commands/control.d.ts +2 -0
- package/dist/commands/control.js +111 -0
- package/dist/commands/credential.d.ts +2 -0
- package/dist/commands/credential.js +36 -0
- package/dist/commands/deploy.d.ts +6 -0
- package/dist/commands/deploy.js +159 -0
- package/dist/commands/hold.d.ts +2 -0
- package/dist/commands/hold.js +13 -0
- package/dist/commands/host.d.ts +24 -0
- package/dist/commands/host.js +122 -0
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +13 -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 +354 -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 +145 -14
- package/dist/credentials.js +2 -0
- package/dist/diagnostics.js +1 -1
- package/dist/docker-api.d.ts +1 -0
- package/dist/live.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AddressCandidate } from '../cli-onboarding.ts';
|
|
2
|
+
export declare function preflightRemote(target: string, { controlUrl, ports, minDiskMb, compose, }?: {
|
|
3
|
+
controlUrl?: string;
|
|
4
|
+
ports?: number[];
|
|
5
|
+
minDiskMb?: number;
|
|
6
|
+
/** Requires the Compose plugin; only the control host runs Compose, nodes need the Docker Engine alone. */
|
|
7
|
+
compose?: boolean;
|
|
8
|
+
}): void;
|
|
9
|
+
export declare function transferTugImage(target: string, imageTag: string): Promise<void>;
|
|
10
|
+
export declare function dockerBootstrap(): Promise<string>;
|
|
11
|
+
/** Installs Docker over SSH; `compose` also installs the Compose plugin, which only the control host needs. */
|
|
12
|
+
export declare function bootstrapDocker(target: string, compose?: boolean): Promise<void>;
|
|
13
|
+
/** Whether the host already has the Docker Engine (and the Compose plugin when `compose`). */
|
|
14
|
+
export declare function hostHasDocker(target: string, compose?: boolean): boolean;
|
|
15
|
+
/** The host's short hostname, or undefined when it cannot be read. */
|
|
16
|
+
export declare function remoteHostname(target: string): string | undefined;
|
|
17
|
+
/** The host's global IPv4 addresses, private ones first; empty when the host cannot say. */
|
|
18
|
+
export declare function discoverAddresses(target: string): AddressCandidate[];
|
|
19
|
+
/**
|
|
20
|
+
* The script that retires a node host: stops the agent and ingress, removes the project containers, networks and
|
|
21
|
+
* agent state. Project volumes (your data) and the ingress certificates go only with `deleteData`. A host that runs
|
|
22
|
+
* the control plane is refused, because removing it would destroy the master key and the private CA.
|
|
23
|
+
*/
|
|
24
|
+
export declare function teardownScript(deleteData: boolean): string;
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The script that retires a node host: stops the agent and ingress, removes the project containers, networks and
|
|
102
|
+
* agent state. Project volumes (your data) and the ingress certificates go only with `deleteData`. A host that runs
|
|
103
|
+
* the control plane is refused, because removing it would destroy the master key and the private CA.
|
|
104
|
+
*/
|
|
105
|
+
export function teardownScript(deleteData) {
|
|
106
|
+
return [
|
|
107
|
+
'set -eu',
|
|
108
|
+
"if docker ps -a --format '{{.Names}}' | grep -q '^tug-control'; then echo 'this host runs the control plane; retire it by hand (see docs/migration.md)' >&2; exit 3; fi",
|
|
109
|
+
'docker rm -f tug-agent tug-caddy >/dev/null 2>&1 || true',
|
|
110
|
+
'ids=$(docker ps -aq --filter label=tug.project --filter label=tug.job; docker ps -aq --filter label=tug.project)',
|
|
111
|
+
'[ -z "$ids" ] || docker rm -fv $ids >/dev/null',
|
|
112
|
+
"docker network ls -q --filter 'name=^tug_' | xargs -r docker network rm >/dev/null 2>&1 || true",
|
|
113
|
+
'rm -rf /etc/tug /var/lib/tug',
|
|
114
|
+
...(deleteData
|
|
115
|
+
? [
|
|
116
|
+
'docker volume ls -q --filter label=tug.project | xargs -r docker volume rm >/dev/null',
|
|
117
|
+
'docker volume rm tug-caddy-data tug-caddy-config tug-caddy-etc tug_restic_cache >/dev/null 2>&1 || true',
|
|
118
|
+
]
|
|
119
|
+
: []),
|
|
120
|
+
'echo "node host cleaned"',
|
|
121
|
+
].join('\n');
|
|
122
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { print, request } from "./context.js";
|
|
2
|
+
export function registerImage(program) {
|
|
3
|
+
const image = program.command('image').description('manage the build archives kept on control');
|
|
4
|
+
image
|
|
5
|
+
.command('prune')
|
|
6
|
+
.description('delete build archives that rollback no longer needs: all but the newest N deployments of each project, the current one and any version a node still runs')
|
|
7
|
+
.option('--keep <n>', 'deployments per project whose archives are kept', '5')
|
|
8
|
+
.option('--dry-run', 'list what would be deleted without deleting it')
|
|
9
|
+
.action(async (o) => print(await request('POST', '/api/v1/images/prune', {
|
|
10
|
+
keep: Number(o.keep),
|
|
11
|
+
dryRun: Boolean(o.dryRun),
|
|
12
|
+
})));
|
|
13
|
+
}
|
|
@@ -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, credential.url))
|
|
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,354 @@
|
|
|
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, teardownScript, 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> [sshTarget]')
|
|
310
|
+
.description('remove a retired node from the control plane (its identity, statuses and queued commands); with --teardown also stop its agent and delete its containers and agent state over SSH')
|
|
311
|
+
.option('--force', 'remove even if the node is still reporting')
|
|
312
|
+
.option('--teardown', 'also clean the host over SSH (the SSH target defaults to nodes.<name>.ssh in the config file)')
|
|
313
|
+
.option('--delete-data', 'with --teardown, also delete project volumes and ingress certificates')
|
|
314
|
+
.option('-y, --yes', 'with --teardown, do not ask for confirmation')
|
|
315
|
+
.action(async (name, ssh, o) => {
|
|
316
|
+
if (!o.teardown) {
|
|
317
|
+
if (ssh || o.deleteData || o.yes)
|
|
318
|
+
throw new Error('an SSH target, --delete-data and --yes need --teardown');
|
|
319
|
+
return print(await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}${o.force ? '?force=true' : ''}`));
|
|
320
|
+
}
|
|
321
|
+
const cfg = await loadCfg();
|
|
322
|
+
const sshTarget = ssh ?? nodeSettings(cfg, name).ssh;
|
|
323
|
+
if (!sshTarget)
|
|
324
|
+
throw new Error(`--teardown needs an SSH target: pass <ssh-target> or set nodes.${name}.ssh in ${cfg.file}`);
|
|
325
|
+
if (!o.yes) {
|
|
326
|
+
if (!canPrompt())
|
|
327
|
+
throw new Error('--teardown deletes data on the host; pass -y to confirm without a terminal');
|
|
328
|
+
console.error(`This will stop the Tug agent on ${sshTarget}, remove its Tug containers and networks and delete /etc/tug and /var/lib/tug${o.deleteData
|
|
329
|
+
? ', and DELETE every project volume and the ingress certificates'
|
|
330
|
+
: ' (project volumes are kept)'}.`);
|
|
331
|
+
if (!(await confirm('Continue?', false)))
|
|
332
|
+
throw new Error('cancelled');
|
|
333
|
+
}
|
|
334
|
+
// The control record goes first: it refuses a node that is still placed in a current deployment before any
|
|
335
|
+
// host is touched. A node control no longer knows is cleaned anyway, so a failed cleanup can be retried.
|
|
336
|
+
let removed = { ok: true, node: name, alreadyRemoved: true };
|
|
337
|
+
try {
|
|
338
|
+
removed = await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}?force=true`);
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (!/node not found/.test(errMessage(error)))
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
runLocal('ssh', ['--', sshTarget, teardownScript(Boolean(o.deleteData))]);
|
|
345
|
+
print({ ...removed, teardown: sshTarget, deleteData: Boolean(o.deleteData) });
|
|
346
|
+
});
|
|
347
|
+
node
|
|
348
|
+
.command('ingress <name>')
|
|
349
|
+
.description('mark a node as an ingress node (managed Caddy on ports 80/443), or remove the role with --disable')
|
|
350
|
+
.option('--disable', 'stop acting as an ingress node')
|
|
351
|
+
.action(async (name, o) => print(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, {
|
|
352
|
+
enabled: !o.disable,
|
|
353
|
+
})));
|
|
354
|
+
}
|