@wpelevator/tug 0.8.0 → 0.10.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 +21 -15
- package/dist/agent.d.ts +5 -7
- package/dist/agent.js +44 -66
- package/dist/backup-job.js +17 -5
- package/dist/backup-restore.js +4 -4
- package/dist/backup-scheduler.js +2 -0
- package/dist/build.d.ts +6 -0
- package/dist/build.js +27 -7
- package/dist/cli-build.d.ts +2 -0
- package/dist/cli-build.js +22 -2
- package/dist/cli-config.d.ts +4 -1
- package/dist/cli-config.js +28 -23
- package/dist/cli-install.d.ts +7 -4
- package/dist/cli-install.js +21 -23
- package/dist/cli-status.d.ts +29 -0
- package/dist/cli-status.js +61 -0
- package/dist/cli.js +60 -66
- package/dist/compose.d.ts +2 -0
- package/dist/compose.js +9 -0
- package/dist/container-spec.d.ts +5 -0
- package/dist/container-spec.js +11 -2
- package/dist/control-credential.d.ts +2 -0
- package/dist/control-credential.js +4 -4
- package/dist/control.d.ts +1 -1
- package/dist/control.js +109 -141
- package/dist/credentials.js +1 -1
- package/dist/db.d.ts +0 -10
- package/dist/db.js +1 -2
- package/dist/ingress.d.ts +1 -2
- package/dist/ingress.js +1 -1
- package/dist/live.d.ts +2 -0
- package/dist/live.js +9 -1
- package/dist/main.js +1 -1
- package/dist/node-address.d.ts +2 -0
- package/dist/node-address.js +6 -0
- package/dist/pki.d.ts +14 -3
- package/dist/pki.js +24 -7
- package/dist/protocol.d.ts +2 -34
- package/dist/reconciler.js +40 -7
- package/dist/runners.d.ts +0 -6
- package/dist/runners.js +1 -6
- package/dist/templates/control-compose.yml +1 -2
- package/package.json +1 -1
- package/dist/mesh.d.ts +0 -70
- package/dist/mesh.js +0 -202
package/dist/cli-config.js
CHANGED
|
@@ -8,7 +8,18 @@ import { errMessage } from "./util.js";
|
|
|
8
8
|
export const COMMAND_SETTINGS = {
|
|
9
9
|
file: { type: 'path', flag: '--file', commands: ['deploy'] },
|
|
10
10
|
buildLocal: { type: 'boolean', flag: '--build-local', commands: ['deploy'] },
|
|
11
|
-
wait: {
|
|
11
|
+
wait: {
|
|
12
|
+
type: 'boolean',
|
|
13
|
+
flag: '--no-wait',
|
|
14
|
+
commands: [
|
|
15
|
+
'deploy',
|
|
16
|
+
'rollback',
|
|
17
|
+
'backup-run',
|
|
18
|
+
'node-install',
|
|
19
|
+
'node-replace-identity',
|
|
20
|
+
'node-repair-identity',
|
|
21
|
+
],
|
|
22
|
+
},
|
|
12
23
|
};
|
|
13
24
|
/** Per-node connection data (`nodes.<name>`); `nodeDefaults` takes the subset that is not node-specific. */
|
|
14
25
|
export const NODE_SETTINGS = {
|
|
@@ -17,32 +28,28 @@ export const NODE_SETTINGS = {
|
|
|
17
28
|
flag: '<ssh-target>',
|
|
18
29
|
commands: ['node-install', 'node-replace-identity', 'node-repair-identity'],
|
|
19
30
|
},
|
|
20
|
-
address: {
|
|
21
|
-
publicAddress: {
|
|
31
|
+
address: {
|
|
22
32
|
type: 'string',
|
|
23
|
-
flag: '--
|
|
24
|
-
commands: [
|
|
33
|
+
flag: '--address',
|
|
34
|
+
commands: [
|
|
35
|
+
'node-install',
|
|
36
|
+
'node-replace-identity',
|
|
37
|
+
'node-repair-identity',
|
|
38
|
+
'node-register',
|
|
39
|
+
'node-prepare-enrollment',
|
|
40
|
+
],
|
|
25
41
|
},
|
|
26
42
|
label: {
|
|
27
43
|
type: 'list',
|
|
28
44
|
flag: '--label',
|
|
29
|
-
commands: ['node-install', 'node-register', 'node-prepare-enrollment'],
|
|
30
|
-
},
|
|
31
|
-
mesh: {
|
|
32
|
-
type: 'boolean',
|
|
33
|
-
flag: '--mesh',
|
|
34
45
|
commands: [
|
|
35
46
|
'node-install',
|
|
36
47
|
'node-replace-identity',
|
|
37
48
|
'node-repair-identity',
|
|
49
|
+
'node-register',
|
|
38
50
|
'node-prepare-enrollment',
|
|
39
51
|
],
|
|
40
52
|
},
|
|
41
|
-
wgPort: {
|
|
42
|
-
type: 'port',
|
|
43
|
-
flag: '--wg-port',
|
|
44
|
-
commands: ['node-install', 'node-replace-identity', 'node-repair-identity'],
|
|
45
|
-
},
|
|
46
53
|
installDocker: {
|
|
47
54
|
type: 'boolean',
|
|
48
55
|
flag: '--install-docker',
|
|
@@ -51,6 +58,7 @@ export const NODE_SETTINGS = {
|
|
|
51
58
|
'node-replace-identity',
|
|
52
59
|
'node-repair-identity',
|
|
53
60
|
'node-prepare-enrollment',
|
|
61
|
+
'control-install',
|
|
54
62
|
],
|
|
55
63
|
},
|
|
56
64
|
image: {
|
|
@@ -61,6 +69,7 @@ export const NODE_SETTINGS = {
|
|
|
61
69
|
'node-replace-identity',
|
|
62
70
|
'node-repair-identity',
|
|
63
71
|
'node-prepare-enrollment',
|
|
72
|
+
'control-install',
|
|
64
73
|
],
|
|
65
74
|
},
|
|
66
75
|
agentControlUrl: {
|
|
@@ -74,7 +83,7 @@ export const NODE_SETTINGS = {
|
|
|
74
83
|
],
|
|
75
84
|
},
|
|
76
85
|
};
|
|
77
|
-
const NODE_SPECIFIC_KEYS = ['ssh', 'address'
|
|
86
|
+
const NODE_SPECIFIC_KEYS = ['ssh', 'address'];
|
|
78
87
|
const SETTINGS = { ...COMMAND_SETTINGS, ...NODE_SETTINGS };
|
|
79
88
|
/** Default file names, in lookup order; more than one present is an error. */
|
|
80
89
|
export const CONFIG_NAMES = ['tug.yml', 'tug.yaml', 'tug.json', '.tug.json'];
|
|
@@ -118,16 +127,14 @@ export async function resolveCliConfig(options, env, cwd, creating = false) {
|
|
|
118
127
|
if (!creating)
|
|
119
128
|
throw new Error(`cannot use credential ${credentialFile ?? 'from TUG_CREDENTIAL'}: ${errMessage(e)}`, { cause: e });
|
|
120
129
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
config.controlUrl ||
|
|
124
|
-
credential?.url ||
|
|
125
|
-
'https://localhost:8443';
|
|
130
|
+
const requestedControlUrl = options.controlUrl || env.TUG_CONTROL_URL || config.controlUrl;
|
|
131
|
+
const controlUrl = requestedControlUrl || credential?.url || 'https://localhost:8443';
|
|
126
132
|
return {
|
|
127
133
|
file,
|
|
128
134
|
fileExists: Boolean(base),
|
|
129
135
|
localFile,
|
|
130
136
|
controlUrl: normalizeControlUrl(controlUrl),
|
|
137
|
+
requestedControlUrl: requestedControlUrl ? normalizeControlUrl(requestedControlUrl) : undefined,
|
|
131
138
|
credential,
|
|
132
139
|
credentialFile,
|
|
133
140
|
project: options.project || env.TUG_PROJECT || config.project || 'site',
|
|
@@ -214,8 +221,6 @@ function settingValue(setting, value, where, dir) {
|
|
|
214
221
|
if (typeof value !== 'boolean')
|
|
215
222
|
throw new Error(`${where} must be true or false`);
|
|
216
223
|
return value;
|
|
217
|
-
case 'port':
|
|
218
|
-
return String(integerOption(String(value), where, 1, 65535));
|
|
219
224
|
case 'list':
|
|
220
225
|
return (Array.isArray(value) ? value : [value]).map((item, index) => text(item, Array.isArray(value) ? `${where}[${index}]` : where));
|
|
221
226
|
}
|
package/dist/cli-install.d.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/** The agent dials container IPs for health checks; from the default bridge they are unreachable on current Docker (29.x), so it always shares the host network namespace. */
|
|
2
2
|
export declare const HOST_NETWORK_FLAG = "--network host";
|
|
3
|
-
export declare const MESH_RUN_FLAGS = "--network host --cap-add NET_ADMIN";
|
|
4
3
|
/** Docker platform for the architecture a host reports with `uname -m`. */
|
|
5
4
|
export declare function dockerPlatform(uname: string): string;
|
|
6
5
|
/** Build for the host the image will run on, not the machine that builds it (an arm64 laptop and an amd64 VPS differ). */
|
|
7
6
|
export declare function localImageBuildArgs(imageTag: string, uname: string): string[];
|
|
8
7
|
export declare function shellQuote(value: unknown): string;
|
|
9
|
-
export declare function dockerRunSnippet(image: string, joinKey: string,
|
|
8
|
+
export declare function dockerRunSnippet(image: string, joinKey: string, address?: string): string;
|
|
10
9
|
/** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
|
|
11
10
|
export declare function enrollmentCloudInit(bootstrap: string, command: string): string;
|
|
12
11
|
/** Name of the container `control-install` creates (Compose project `tug-control`, service `control`). */
|
|
13
12
|
export declare const CONTROL_CONTAINER = "tug-control-control-1";
|
|
14
13
|
/** A TCP port is free, or is held by the control container this install would replace, so installing again upgrades in place. */
|
|
15
14
|
export declare function controlPortCheck(port: number): string;
|
|
16
|
-
/**
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The default control URL for `control-install`: the SSH target's host and the published port. Accepts
|
|
17
|
+
* `host`, `user@host`, `ssh://user@host:2222` and bracketed IPv6. An SSH alias from ~/.ssh/config is used as written,
|
|
18
|
+
* so pass `--control-url` when the alias is not a name or address clients can reach.
|
|
19
|
+
*/
|
|
20
|
+
export declare function defaultControlUrl(sshTarget: string, port: number | string): string;
|
package/dist/cli-install.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as yaml from 'js-yaml';
|
|
2
2
|
/** The agent dials container IPs for health checks; from the default bridge they are unreachable on current Docker (29.x), so it always shares the host network namespace. */
|
|
3
3
|
export const HOST_NETWORK_FLAG = '--network host';
|
|
4
|
-
export const MESH_RUN_FLAGS = `${HOST_NETWORK_FLAG} --cap-add NET_ADMIN`;
|
|
5
4
|
/** Docker platform for the architecture a host reports with `uname -m`. */
|
|
6
5
|
export function dockerPlatform(uname) {
|
|
7
6
|
const arch = uname.trim();
|
|
@@ -18,8 +17,8 @@ export function localImageBuildArgs(imageTag, uname) {
|
|
|
18
17
|
export function shellQuote(value) {
|
|
19
18
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
20
19
|
}
|
|
21
|
-
export function dockerRunSnippet(image, joinKey,
|
|
22
|
-
return `docker run -d --name tug-agent --restart unless-stopped ${
|
|
20
|
+
export function dockerRunSnippet(image, joinKey, address) {
|
|
21
|
+
return `docker run -d --name tug-agent --restart unless-stopped ${HOST_NETWORK_FLAG} -v /var/run/docker.sock:/var/run/docker.sock -v tug-agent:/var/lib/tug -e TUG_JOIN_KEY=${shellQuote(joinKey)} ${address ? `-e TUG_NODE_ADDRESS=${shellQuote(address)} ` : ''}${shellQuote(image)} agent`;
|
|
23
22
|
}
|
|
24
23
|
/** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
|
|
25
24
|
export function enrollmentCloudInit(bootstrap, command) {
|
|
@@ -42,24 +41,23 @@ ss -ltn | awk '{print $4}' | grep -qE '[.:]${port}$' || exit 0
|
|
|
42
41
|
[ -n "$(docker ps -q --filter 'name=^/${CONTROL_CONTAINER}$' --filter 'publish=${port}' 2>/dev/null)" ]
|
|
43
42
|
`;
|
|
44
43
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
`;
|
|
44
|
+
/**
|
|
45
|
+
* The default control URL for `control-install`: the SSH target's host and the published port. Accepts
|
|
46
|
+
* `host`, `user@host`, `ssh://user@host:2222` and bracketed IPv6. An SSH alias from ~/.ssh/config is used as written,
|
|
47
|
+
* so pass `--control-url` when the alias is not a name or address clients can reach.
|
|
48
|
+
*/
|
|
49
|
+
export function defaultControlUrl(sshTarget, port) {
|
|
50
|
+
let host = sshTarget
|
|
51
|
+
.trim()
|
|
52
|
+
.replace(/^ssh:\/\//, '')
|
|
53
|
+
.replace(/\/.*$/, '');
|
|
54
|
+
host = host.replace(/^[^@]*@/, '');
|
|
55
|
+
const bracketed = /^\[([^\]]+)\]/.exec(host);
|
|
56
|
+
if (bracketed)
|
|
57
|
+
host = bracketed[1];
|
|
58
|
+
else if (host.split(':').length === 2)
|
|
59
|
+
host = host.split(':')[0];
|
|
60
|
+
if (!host || /[\s'"]/.test(host))
|
|
61
|
+
throw new Error(`cannot derive a host from SSH target ${sshTarget}`);
|
|
62
|
+
return `https://${host.includes(':') ? `[${host}]` : host}:${port}`;
|
|
65
63
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The control status endpoint returns database rows and a JSON-encoded node report. */
|
|
2
|
+
export interface StatusResponse {
|
|
3
|
+
projects: {
|
|
4
|
+
id: string;
|
|
5
|
+
current_deployment_id: string | null;
|
|
6
|
+
}[];
|
|
7
|
+
deployments: {
|
|
8
|
+
id: string;
|
|
9
|
+
version: string;
|
|
10
|
+
state: string;
|
|
11
|
+
error_message: string | null;
|
|
12
|
+
}[];
|
|
13
|
+
nodes: {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
status: string;
|
|
17
|
+
last_seen_at: string | null;
|
|
18
|
+
}[];
|
|
19
|
+
statuses: {
|
|
20
|
+
project_id: string;
|
|
21
|
+
node_id: string;
|
|
22
|
+
node: string | null;
|
|
23
|
+
health: string;
|
|
24
|
+
observed_version: string | null;
|
|
25
|
+
status_json: string;
|
|
26
|
+
}[];
|
|
27
|
+
}
|
|
28
|
+
/** Render observations without treating a stale node's last report as current health. */
|
|
29
|
+
export declare function formatStatus(status: StatusResponse): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Render observations without treating a stale node's last report as current health. */
|
|
2
|
+
export function formatStatus(status) {
|
|
3
|
+
const lines = [];
|
|
4
|
+
const nodes = new Map(status.nodes.map((node) => [node.id, node]));
|
|
5
|
+
for (const project of [...status.projects].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
6
|
+
const deployment = status.deployments.find((item) => item.id === project.current_deployment_id);
|
|
7
|
+
lines.push(`Project ${project.id}`);
|
|
8
|
+
if (deployment) {
|
|
9
|
+
lines.push(` Deployment: ${deployment.version} (${deployment.state})`);
|
|
10
|
+
if (deployment.error_message)
|
|
11
|
+
lines.push(` Error: ${deployment.error_message}`);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
lines.push(project.current_deployment_id
|
|
15
|
+
? ' Deployment: current deployment not in recent results'
|
|
16
|
+
: ' Deployment: none');
|
|
17
|
+
}
|
|
18
|
+
const reports = status.statuses
|
|
19
|
+
.filter((item) => item.project_id === project.id)
|
|
20
|
+
.sort((a, b) => (a.node || a.node_id).localeCompare(b.node || b.node_id));
|
|
21
|
+
if (!reports.length)
|
|
22
|
+
lines.push(' No node reports');
|
|
23
|
+
for (const report of reports) {
|
|
24
|
+
const node = nodes.get(report.node_id);
|
|
25
|
+
lines.push(` Node ${report.node || report.node_id}: ${node?.status || 'unknown'}; last reported ${report.health}${report.observed_version ? ` (${report.observed_version})` : ''}`);
|
|
26
|
+
try {
|
|
27
|
+
const detail = JSON.parse(report.status_json);
|
|
28
|
+
if (detail &&
|
|
29
|
+
typeof detail === 'object' &&
|
|
30
|
+
'error' in detail &&
|
|
31
|
+
typeof detail.error === 'string' &&
|
|
32
|
+
detail.error)
|
|
33
|
+
lines.push(` Error: ${detail.error}`);
|
|
34
|
+
if (detail &&
|
|
35
|
+
typeof detail === 'object' &&
|
|
36
|
+
'serviceStates' in detail &&
|
|
37
|
+
detail.serviceStates &&
|
|
38
|
+
typeof detail.serviceStates === 'object') {
|
|
39
|
+
for (const [name, state] of Object.entries(detail.serviceStates).sort(([a], [b]) => a.localeCompare(b))) {
|
|
40
|
+
if (!state || typeof state !== 'object')
|
|
41
|
+
continue;
|
|
42
|
+
const item = state;
|
|
43
|
+
if (typeof item.rolloutState === 'string')
|
|
44
|
+
lines.push(` ${name}: ${item.rolloutState}${typeof item.rolloutError === 'string' && item.rolloutError ? ` — ${item.rolloutError}` : ''}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
lines.push(' Report details unavailable');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!status.projects.length)
|
|
54
|
+
lines.push('No projects');
|
|
55
|
+
lines.push('', 'Nodes');
|
|
56
|
+
if (!status.nodes.length)
|
|
57
|
+
lines.push(' No nodes');
|
|
58
|
+
for (const node of [...status.nodes].sort((a, b) => a.name.localeCompare(b.name)))
|
|
59
|
+
lines.push(` ${node.name}: ${node.status}; last seen ${node.last_seen_at || 'never'}`);
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { isNodeHost } from "./node-address.js";
|
|
2
3
|
import { Command, Option } from 'commander';
|
|
3
4
|
import { openAsBlob } from 'node:fs';
|
|
4
5
|
import { applyConfigDefaults, integerOption, normalizeControlUrl, nodeSettings, resolveCliConfig, writeInitConfig, } from "./cli-config.js";
|
|
5
|
-
import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG,
|
|
6
|
-
import { createSourceArchive, prepareLocalImages } from "./cli-build.js";
|
|
6
|
+
import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, CONTROL_CONTAINER, defaultControlUrl, controlPortCheck, localImageBuildArgs, } from "./cli-install.js";
|
|
7
|
+
import { createLocalBuildSnapshot, createSourceArchive, prepareLocalImages } from "./cli-build.js";
|
|
7
8
|
import { saveImageArchive } from "./artifacts.js";
|
|
8
9
|
import { spawnSync } from 'node:child_process';
|
|
9
10
|
import fs from 'node:fs/promises';
|
|
@@ -16,6 +17,7 @@ import { errMessage, loadTemplate } from "./util.js";
|
|
|
16
17
|
import { createTransport } from "./agent-credential.js";
|
|
17
18
|
import { decodeCredential, writeFileDurable } from "./pki.js";
|
|
18
19
|
import { backupCheckProblems } from "./backup-check.js";
|
|
20
|
+
import { formatStatus } from "./cli-status.js";
|
|
19
21
|
import { PROJECT_NAME_PATTERN, ROLES, CREDENTIAL_NAME_PATTERN } from "./credentials.js";
|
|
20
22
|
import { packageVersion, resolveImage } from "./image.js";
|
|
21
23
|
import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
|
|
@@ -85,7 +87,7 @@ function runCapture(cmd, args, opts = {}) {
|
|
|
85
87
|
throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
|
|
86
88
|
return String(result.stdout);
|
|
87
89
|
}
|
|
88
|
-
function preflightRemote(target, { controlUrl, ports = [],
|
|
90
|
+
function preflightRemote(target, { controlUrl, ports = [], minDiskMb = 1024, compose = false, } = {}) {
|
|
89
91
|
const checks = [
|
|
90
92
|
[
|
|
91
93
|
'docker.version',
|
|
@@ -119,20 +121,6 @@ function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDis
|
|
|
119
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.`,
|
|
120
122
|
]);
|
|
121
123
|
}
|
|
122
|
-
if (mesh) {
|
|
123
|
-
checks.push([
|
|
124
|
-
'wireguard.kernel',
|
|
125
|
-
'ip link add tugwgtest type wireguard && ip link del tugwgtest || modprobe -n wireguard 2>/dev/null',
|
|
126
|
-
'Install WireGuard (kernel 5.6+ has it built in; older kernels need the wireguard-dkms package) and ensure iproute2 is present.',
|
|
127
|
-
]);
|
|
128
|
-
}
|
|
129
|
-
for (const port of udpPorts) {
|
|
130
|
-
checks.push([
|
|
131
|
-
`udp.${port}.free`,
|
|
132
|
-
meshPortCheck(port),
|
|
133
|
-
`UDP port ${port} is in use by something other than this node's running Tug mesh agent (or the ss tool is missing). Stop the conflicting service, or choose another port with --wg-port and set TUG_WG_PORT on control.`,
|
|
134
|
-
]);
|
|
135
|
-
}
|
|
136
124
|
if (controlUrl)
|
|
137
125
|
checks.push([
|
|
138
126
|
'control.reachable',
|
|
@@ -219,33 +207,27 @@ program
|
|
|
219
207
|
.requiredOption('--csr <file>', 'PEM certificate request generated on the node (- for stdin)')
|
|
220
208
|
.option('--mode <mode>', 'create a new node or replace an existing node key', 'create')
|
|
221
209
|
.option('--address <addr>')
|
|
222
|
-
.option('--public-address <addr>')
|
|
223
210
|
.option('--label <label>', 'node label (repeatable)', collect, [])
|
|
224
211
|
.action(async (name, o) => {
|
|
225
212
|
if (o.mode !== 'create' && o.mode !== 'replace')
|
|
226
213
|
throw new Error("--mode must be 'create' or 'replace'");
|
|
227
214
|
const csr = o.csr === '-' ? await readStdin() : await fs.readFile(o.csr, 'utf8');
|
|
228
|
-
const r = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode: o.mode, address: o.address,
|
|
215
|
+
const r = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode: o.mode, address: o.address, labels: o.label });
|
|
229
216
|
console.log(JSON.stringify(r, null, 2));
|
|
230
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
|
+
});
|
|
231
224
|
program
|
|
232
225
|
.command('node-disable <name>')
|
|
233
226
|
.description("block a node's key immediately without deleting its metadata or placement (no SSH needed); bring it back with node-replace-identity")
|
|
234
227
|
.action(async (name) => console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/disable`, {}), null, 2)));
|
|
235
|
-
program
|
|
236
|
-
.command('node-rotate-wg-key <name>')
|
|
237
|
-
.description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll')
|
|
238
|
-
.option('--timeout <seconds>', 'wait timeout', '60')
|
|
239
|
-
.action(async (name, o) => {
|
|
240
|
-
const created = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-wg-key`, {});
|
|
241
|
-
const cmd = await waitForCommand(created.commandId, Number(o.timeout));
|
|
242
|
-
if (cmd.state === 'failed')
|
|
243
|
-
throw new Error(JSON.parse(cmd.result_json || '{}').error || 'key rotation failed');
|
|
244
|
-
console.log(JSON.stringify({ ok: true, node: name, ...JSON.parse(cmd.result_json || '{}') }, null, 2));
|
|
245
|
-
});
|
|
246
228
|
program
|
|
247
229
|
.command('node-remove <name>')
|
|
248
|
-
.description('remove a retired node from the control plane (its identity,
|
|
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')
|
|
249
231
|
.option('--force', 'remove even if the node is still reporting')
|
|
250
232
|
.action(async (name, o) => {
|
|
251
233
|
console.log(JSON.stringify(await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}${o.force ? '?force=true' : ''}`), null, 2));
|
|
@@ -272,7 +254,6 @@ program
|
|
|
272
254
|
program
|
|
273
255
|
.command('control-install <sshTarget>')
|
|
274
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')
|
|
275
|
-
.requiredOption('--public-url <url>', 'credential-free https origin clients use, including the published port')
|
|
276
257
|
.option('--extra-san <name>', 'additional DNS name or IP for the server certificate (repeatable)', collect, [])
|
|
277
258
|
.option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
|
|
278
259
|
.option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
|
|
@@ -280,9 +261,12 @@ program
|
|
|
280
261
|
.option('--port <port>', 'published mTLS port', '8443')
|
|
281
262
|
.option('--credential-out <file>', 'where to write the root credential', 'tug-root.credential.json')
|
|
282
263
|
.option('--recover-root', "replace root's key from the control host without the API (lost credential); other admins are unaffected")
|
|
283
|
-
.option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
|
|
284
264
|
.action(async (sshTarget, o) => {
|
|
285
|
-
const
|
|
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`);
|
|
286
270
|
if (o.installDocker)
|
|
287
271
|
await bootstrapDocker(sshTarget, true);
|
|
288
272
|
preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
|
|
@@ -292,10 +276,9 @@ program
|
|
|
292
276
|
const yamlValue = (value) => JSON.stringify(value);
|
|
293
277
|
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
294
278
|
IMAGE: yamlValue(image),
|
|
295
|
-
|
|
279
|
+
CONTROL_URL: yamlValue(new URL(controlUrl).origin),
|
|
296
280
|
EXTRA_SANS: yamlValue(o.extraSan.join(',')),
|
|
297
281
|
PORT: o.port,
|
|
298
|
-
MESH: yamlValue(o.mesh ? 'true' : 'false'),
|
|
299
282
|
});
|
|
300
283
|
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
301
284
|
COMPOSE: compose,
|
|
@@ -316,7 +299,7 @@ program
|
|
|
316
299
|
await writeFileDurable(o.credentialOut, `${String(out.stdout).trim()}\n`, 0o600);
|
|
317
300
|
console.error(`root credential written to ${o.credentialOut}; import it with: tug init ${o.credentialOut}`);
|
|
318
301
|
}
|
|
319
|
-
console.log(JSON.stringify({ ok: true, sshTarget, url:
|
|
302
|
+
console.log(JSON.stringify({ ok: true, sshTarget, url: controlUrl }, null, 2));
|
|
320
303
|
});
|
|
321
304
|
const AGENT_STATE = '/var/lib/tug';
|
|
322
305
|
function addNodeIdentityOptions(command) {
|
|
@@ -324,8 +307,6 @@ function addNodeIdentityOptions(command) {
|
|
|
324
307
|
.option('--name <name>', 'node name')
|
|
325
308
|
.option('--all', 'walk every node with an ssh target in the config file, continuing after failures')
|
|
326
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')
|
|
327
|
-
.option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
|
|
328
|
-
.option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
|
|
329
310
|
.option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
|
|
330
311
|
.option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
|
|
331
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')
|
|
@@ -335,7 +316,9 @@ function addNodeIdentityOptions(command) {
|
|
|
335
316
|
* The SSH-assisted flow: the node makes its own key and CSR, an admin credential authorizes the identity at this moment and
|
|
336
317
|
* the certificate is installed back over SSH, so no bootstrap secret travels and the private key never leaves the node.
|
|
337
318
|
*/
|
|
338
|
-
async function installNodeIdentity(sshTarget, name, mode, o, repair = false
|
|
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) {
|
|
339
322
|
const cfg = await loadCfg();
|
|
340
323
|
const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
|
|
341
324
|
await transportFor(cfg).then((t) => t.close()); // fail before touching the host when there is no credential
|
|
@@ -343,8 +326,6 @@ async function installNodeIdentity(sshTarget, name, mode, o, repair = false) {
|
|
|
343
326
|
await bootstrapDocker(sshTarget);
|
|
344
327
|
preflightRemote(sshTarget, {
|
|
345
328
|
controlUrl: connectUrl ?? cfg.credential?.url,
|
|
346
|
-
mesh: o.mesh,
|
|
347
|
-
udpPorts: o.mesh ? [Number(o.wgPort)] : [],
|
|
348
329
|
});
|
|
349
330
|
const image = resolveImage(o.image, o.buildLocalImage);
|
|
350
331
|
if (o.buildLocalImage)
|
|
@@ -358,18 +339,18 @@ async function installNodeIdentity(sshTarget, name, mode, o, repair = false) {
|
|
|
358
339
|
]);
|
|
359
340
|
const agent = (command, interactive = false) => `mkdir -p ${AGENT_STATE} && docker run --rm ${interactive ? '-i ' : ''}-v ${AGENT_STATE}:${AGENT_STATE} ${sh(image)} agent ${command}`;
|
|
360
341
|
const csr = runCapture('ssh', ['--', sshTarget, agent('identity')]);
|
|
361
|
-
const response = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode, address: o.address,
|
|
342
|
+
const response = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode, address: o.address, labels: o.label });
|
|
362
343
|
runCapture('ssh', ['--', sshTarget, agent('install-cert', true)], {
|
|
363
344
|
input: JSON.stringify(response),
|
|
364
345
|
});
|
|
365
|
-
const env =
|
|
346
|
+
const env = await loadTemplate(templatesDir, 'agent.env', {
|
|
366
347
|
CONTROL_URL_LINE: connectUrl ? `TUG_CONTROL_URL=${connectUrl}\n` : '',
|
|
367
348
|
NODE_NAME: name,
|
|
368
|
-
})
|
|
349
|
+
});
|
|
369
350
|
const remote = await loadTemplate(templatesDir, 'node-install.sh', {
|
|
370
351
|
ENV: env,
|
|
371
352
|
IMAGE_SH: sh(image),
|
|
372
|
-
RUN_FLAGS: `${
|
|
353
|
+
RUN_FLAGS: `${HOST_NETWORK_FLAG} `,
|
|
373
354
|
});
|
|
374
355
|
runLocal('ssh', ['--', sshTarget, remote]);
|
|
375
356
|
if (o.wait) {
|
|
@@ -377,15 +358,19 @@ async function installNodeIdentity(sshTarget, name, mode, o, repair = false) {
|
|
|
377
358
|
for (let i = 0; i < 30; i++) {
|
|
378
359
|
const { nodes } = await request('GET', '/api/v1/nodes');
|
|
379
360
|
const seen = nodes.find((n) => n.name === name)?.last_seen_at;
|
|
380
|
-
if (seen && seen > started)
|
|
381
|
-
|
|
361
|
+
if (seen && seen > started) {
|
|
362
|
+
if (print)
|
|
363
|
+
console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
382
366
|
await new Promise((r) => setTimeout(r, 2000));
|
|
383
367
|
}
|
|
384
368
|
console.error(`node ${name} has not reported yet; check \`docker logs tug-agent\` on the host`);
|
|
385
369
|
}
|
|
386
|
-
|
|
370
|
+
if (print)
|
|
371
|
+
console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
|
|
387
372
|
}
|
|
388
|
-
async function runNodeIdentity(sshArgument, o, mode, repair = false) {
|
|
373
|
+
async function runNodeIdentity(sshArgument, o, mode, command, repair = false) {
|
|
389
374
|
const cfg = await loadCfg();
|
|
390
375
|
const verb = repair
|
|
391
376
|
? 'node-repair-identity'
|
|
@@ -398,9 +383,10 @@ async function runNodeIdentity(sshArgument, o, mode, repair = false) {
|
|
|
398
383
|
const target = settings.ssh;
|
|
399
384
|
if (!target)
|
|
400
385
|
continue;
|
|
401
|
-
const merged = { ...o,
|
|
386
|
+
const merged = { ...o, name };
|
|
387
|
+
applyConfigDefaults(command, merged, nodeSettings(cfg, name));
|
|
402
388
|
try {
|
|
403
|
-
await installNodeIdentity(target, name, mode, merged, repair);
|
|
389
|
+
await installNodeIdentity(target, name, mode, merged, repair, false);
|
|
404
390
|
results.push({ node: name, ok: true });
|
|
405
391
|
}
|
|
406
392
|
catch (e) {
|
|
@@ -419,12 +405,6 @@ async function runNodeIdentity(sshArgument, o, mode, repair = false) {
|
|
|
419
405
|
throw new Error(`${verb} needs an SSH target: pass <ssh-target> or set nodes.${o.name}.ssh in ${cfg.file}`);
|
|
420
406
|
await installNodeIdentity(sshTarget, o.name, mode, o, repair);
|
|
421
407
|
}
|
|
422
|
-
const nodeOverrides = (settings) => ({
|
|
423
|
-
...(settings.address ? { address: settings.address } : {}),
|
|
424
|
-
...(settings.publicAddress ? { publicAddress: settings.publicAddress } : {}),
|
|
425
|
-
...(Array.isArray(settings.label) ? { label: settings.label } : {}),
|
|
426
|
-
...(settings.mesh ? { mesh: true } : {}),
|
|
427
|
-
});
|
|
428
408
|
for (const [verb, description, mode, repair] of [
|
|
429
409
|
[
|
|
430
410
|
'node-install',
|
|
@@ -449,22 +429,23 @@ for (const [verb, description, mode, repair] of [
|
|
|
449
429
|
.command(`${verb} [sshTarget]`)
|
|
450
430
|
.description(description)
|
|
451
431
|
.option('--address <addr>')
|
|
452
|
-
.option('--public-address <addr>')
|
|
453
432
|
.option('--label <label>', 'node label (repeatable)', collect, []);
|
|
454
|
-
addNodeIdentityOptions(command).action(async (sshArgument, o) => runNodeIdentity(sshArgument, o, mode, repair));
|
|
433
|
+
addNodeIdentityOptions(command).action(async (sshArgument, o, command) => runNodeIdentity(sshArgument, o, mode, command, repair));
|
|
455
434
|
}
|
|
456
435
|
program
|
|
457
436
|
.command('node-prepare-enrollment')
|
|
458
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')
|
|
459
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')
|
|
460
440
|
.option('--agent-control-url <url>', 'connect-only URL the agent dials; the certificate is still verified against the credential URL')
|
|
461
441
|
.option('--ttl <seconds>', 'join key lifetime', '3600')
|
|
462
442
|
.option('--image <tag>', "agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
|
|
463
|
-
.option('--mesh', 'print a docker run that joins the WireGuard mesh (host networking, NET_ADMIN)')
|
|
464
443
|
.option('--label <label>', 'node label (repeatable)', collect, [])
|
|
465
444
|
.option('--install-docker', 'print cloud-init that installs Docker and starts the agent')
|
|
466
445
|
.option('--json', 'print JSON including the command or cloud-init payload')
|
|
467
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');
|
|
468
449
|
const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
|
|
469
450
|
const minted = await request('POST', '/api/v1/nodes/enrollment-credential', {
|
|
470
451
|
name: o.name,
|
|
@@ -478,7 +459,7 @@ program
|
|
|
478
459
|
...(connectUrl ? { connectUrl } : {}),
|
|
479
460
|
};
|
|
480
461
|
const joinKey = Buffer.from(JSON.stringify(keyPayload)).toString('base64url');
|
|
481
|
-
const command = dockerRunSnippet(resolveImage(o.image), joinKey, o.
|
|
462
|
+
const command = dockerRunSnippet(resolveImage(o.image), joinKey, o.address);
|
|
482
463
|
const cloudInit = o.installDocker
|
|
483
464
|
? enrollmentCloudInit(await dockerBootstrap(), command)
|
|
484
465
|
: undefined;
|
|
@@ -730,6 +711,7 @@ async function makeContext() {
|
|
|
730
711
|
await createSourceArchive(process.cwd(), tmp, [
|
|
731
712
|
resolvedConfig.file,
|
|
732
713
|
resolvedConfig.localFile,
|
|
714
|
+
...(resolvedConfig.credentialFile ? [resolvedConfig.credentialFile] : []),
|
|
733
715
|
]);
|
|
734
716
|
}
|
|
735
717
|
catch (error) {
|
|
@@ -757,7 +739,14 @@ program
|
|
|
757
739
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-local-build-'));
|
|
758
740
|
try {
|
|
759
741
|
const form = new FormData();
|
|
760
|
-
const
|
|
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);
|
|
761
750
|
for (const { field } of localImages)
|
|
762
751
|
form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
|
|
763
752
|
form.append('payload', JSON.stringify({ project, version, compose, localImages }));
|
|
@@ -791,7 +780,11 @@ program
|
|
|
791
780
|
});
|
|
792
781
|
program
|
|
793
782
|
.command('status')
|
|
794
|
-
.
|
|
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
|
+
});
|
|
795
788
|
program
|
|
796
789
|
.command('events')
|
|
797
790
|
.description('list recent events with the credential that caused each control action; defaults to the resolved project')
|
|
@@ -983,7 +976,6 @@ program.hook('preAction', async (_root, command) => {
|
|
|
983
976
|
const options = command.opts();
|
|
984
977
|
for (const [key, min, max] of [
|
|
985
978
|
['port', 1, 65535],
|
|
986
|
-
['wgPort', 1, 65535],
|
|
987
979
|
['timeout', 1, 86400],
|
|
988
980
|
['ttl', 1, 31536000],
|
|
989
981
|
['uses', 1, 1000000],
|
|
@@ -1031,7 +1023,9 @@ program.hook('preAction', async (_root, command) => {
|
|
|
1031
1023
|
: undefined;
|
|
1032
1024
|
applyConfigDefaults(command, options, {
|
|
1033
1025
|
...resolvedConfig.commandDefaults,
|
|
1034
|
-
...(command.name().startsWith('node-')
|
|
1026
|
+
...(command.name().startsWith('node-') || command.name() === 'control-install'
|
|
1027
|
+
? nodeSettings(resolvedConfig, nodeName)
|
|
1028
|
+
: {}),
|
|
1035
1029
|
});
|
|
1036
1030
|
});
|
|
1037
1031
|
for (const command of program.commands) {
|
package/dist/compose.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export declare function isRolling(svc: ComposeService | undefined): boolean;
|
|
|
36
36
|
export declare const SUPPORTED_SERVICE_KEYS: readonly string[];
|
|
37
37
|
/** Lower-cased ingress hostnames a Compose definition claims, by `service` name. */
|
|
38
38
|
export declare function ingressHostsOf(compose: ComposeFile): Map<string, string>;
|
|
39
|
+
/** True when two lowercase ingress hosts could route the same request: equal, or one is a `*.` wildcard covering the other. */
|
|
40
|
+
export declare function ingressHostsOverlap(a: string, b: string): boolean;
|
|
39
41
|
export declare function validateCompose(compose: ComposeFile | undefined, nodes?: string[]): asserts compose is ComposeFile;
|
|
40
42
|
export interface BuildDesiredStateOptions {
|
|
41
43
|
project: string;
|