@wpelevator/tug 0.9.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 +16 -10
- 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/cli-build.d.ts +2 -0
- package/dist/cli-build.js +14 -1
- 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 +59 -66
- 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 +90 -137
- 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/main.js +1 -1
- package/dist/node-address.d.ts +2 -0
- package/dist/node-address.js +6 -0
- package/dist/pki.d.ts +1 -1
- package/dist/pki.js +2 -2
- 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 -74
- package/dist/mesh.js +0 -219
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('--advertise-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;
|
|
@@ -758,7 +739,14 @@ program
|
|
|
758
739
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-local-build-'));
|
|
759
740
|
try {
|
|
760
741
|
const form = new FormData();
|
|
761
|
-
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);
|
|
762
750
|
for (const { field } of localImages)
|
|
763
751
|
form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
|
|
764
752
|
form.append('payload', JSON.stringify({ project, version, compose, localImages }));
|
|
@@ -792,7 +780,11 @@ program
|
|
|
792
780
|
});
|
|
793
781
|
program
|
|
794
782
|
.command('status')
|
|
795
|
-
.
|
|
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
|
+
});
|
|
796
788
|
program
|
|
797
789
|
.command('events')
|
|
798
790
|
.description('list recent events with the credential that caused each control action; defaults to the resolved project')
|
|
@@ -984,7 +976,6 @@ program.hook('preAction', async (_root, command) => {
|
|
|
984
976
|
const options = command.opts();
|
|
985
977
|
for (const [key, min, max] of [
|
|
986
978
|
['port', 1, 65535],
|
|
987
|
-
['wgPort', 1, 65535],
|
|
988
979
|
['timeout', 1, 86400],
|
|
989
980
|
['ttl', 1, 31536000],
|
|
990
981
|
['uses', 1, 1000000],
|
|
@@ -1032,7 +1023,9 @@ program.hook('preAction', async (_root, command) => {
|
|
|
1032
1023
|
: undefined;
|
|
1033
1024
|
applyConfigDefaults(command, options, {
|
|
1034
1025
|
...resolvedConfig.commandDefaults,
|
|
1035
|
-
...(command.name().startsWith('node-')
|
|
1026
|
+
...(command.name().startsWith('node-') || command.name() === 'control-install'
|
|
1027
|
+
? nodeSettings(resolvedConfig, nodeName)
|
|
1028
|
+
: {}),
|
|
1036
1029
|
});
|
|
1037
1030
|
});
|
|
1038
1031
|
for (const command of program.commands) {
|
package/dist/container-spec.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CreateContainerBody } from './docker-api.ts';
|
|
2
|
+
import type { VolumeInspect } from './docker-api.ts';
|
|
2
3
|
import type { DesiredState, ServiceEndpoint } from './protocol.ts';
|
|
3
4
|
import type { PortBind } from './compose.ts';
|
|
4
5
|
export declare const LABEL_PROJECT = "tug.project";
|
|
@@ -7,10 +8,14 @@ export declare const LABEL_JOB = "tug.job";
|
|
|
7
8
|
export declare const LABEL_SERVICE = "tug.service";
|
|
8
9
|
export declare const LABEL_REVISION = "tug.revision";
|
|
9
10
|
export declare const LABEL_HASH = "tug.config-hash";
|
|
11
|
+
/** Never treat a Docker name alone as proof of ownership; validate its labels before reuse. */
|
|
12
|
+
export declare function assertVolumeOwnership(volume: VolumeInspect, project: string, name: string): void;
|
|
10
13
|
/** The project network every container of the project joins; service names are its aliases. */
|
|
11
14
|
export declare const networkName: (project: string) => string;
|
|
12
15
|
/** Docker volume name for a Compose volume; `external` volumes keep their own name. */
|
|
13
16
|
export declare const volumeName: (project: string, volume: string) => string;
|
|
17
|
+
/** Scratch volumes occupy a separate namespace from project-managed volumes. */
|
|
18
|
+
export declare const scratchVolumeName: (project: string, restore: string) => string;
|
|
14
19
|
export declare const containerName: (project: string, service: string, hash: string) => string;
|
|
15
20
|
export interface VolumeSpec {
|
|
16
21
|
/** Docker volume name. */
|
package/dist/container-spec.js
CHANGED
|
@@ -9,10 +9,17 @@ export const LABEL_JOB = 'tug.job';
|
|
|
9
9
|
export const LABEL_SERVICE = 'tug.service';
|
|
10
10
|
export const LABEL_REVISION = 'tug.revision';
|
|
11
11
|
export const LABEL_HASH = 'tug.config-hash';
|
|
12
|
+
/** Never treat a Docker name alone as proof of ownership; validate its labels before reuse. */
|
|
13
|
+
export function assertVolumeOwnership(volume, project, name) {
|
|
14
|
+
if (volume.Labels?.[LABEL_PROJECT] !== project || volume.Labels?.['tug.volume'] !== name)
|
|
15
|
+
throw new Error(`volume ${volume.Name} is not owned by project ${project} as volume ${name}`);
|
|
16
|
+
}
|
|
12
17
|
/** The project network every container of the project joins; service names are its aliases. */
|
|
13
18
|
export const networkName = (project) => `tug_${project}`;
|
|
14
19
|
/** Docker volume name for a Compose volume; `external` volumes keep their own name. */
|
|
15
|
-
export const volumeName = (project, volume) => `
|
|
20
|
+
export const volumeName = (project, volume) => `tug_v${project.length}_${project}_${volume}`;
|
|
21
|
+
/** Scratch volumes occupy a separate namespace from project-managed volumes. */
|
|
22
|
+
export const scratchVolumeName = (project, restore) => `tug_r${project.length}_${project}_${restore}`;
|
|
16
23
|
export const containerName = (project, service, hash) => `tug-${project}-${service}-${hash.slice(0, 8)}`;
|
|
17
24
|
const SECOND_NS = 1_000_000_000;
|
|
18
25
|
const UNITS = {
|
|
@@ -113,10 +120,12 @@ function volumeSpecs(desired, node, used) {
|
|
|
113
120
|
for (const key of [...used].sort()) {
|
|
114
121
|
const def = desired.compose.volumes?.[key] || {};
|
|
115
122
|
const xe = def['x-tug'] || {};
|
|
123
|
+
const customLabels = { ...(def.labels || {}) };
|
|
124
|
+
delete customLabels['tug.restore'];
|
|
116
125
|
const labels = {
|
|
126
|
+
...customLabels,
|
|
117
127
|
[LABEL_PROJECT]: desired.project,
|
|
118
128
|
'tug.volume': key,
|
|
119
|
-
...(def.labels || {}),
|
|
120
129
|
};
|
|
121
130
|
if (def.external) {
|
|
122
131
|
out.push({
|
|
@@ -2,6 +2,8 @@ export interface IssueArgs {
|
|
|
2
2
|
name: string;
|
|
3
3
|
role: string;
|
|
4
4
|
replace: boolean;
|
|
5
|
+
/** The control URL to write into the credential; defaults to `TUG_CONTROL_URL`. */
|
|
6
|
+
controlUrl?: string;
|
|
5
7
|
}
|
|
6
8
|
export declare function parseIssueArgs(argv: string[]): IssueArgs;
|
|
7
9
|
/**
|
|
@@ -25,15 +25,15 @@ export function parseIssueArgs(argv) {
|
|
|
25
25
|
*/
|
|
26
26
|
export async function issueHostCredential(args) {
|
|
27
27
|
const dataDir = process.env.TUG_DATA_DIR || './data';
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
30
|
-
throw new Error('
|
|
28
|
+
const controlUrl = args.controlUrl ?? process.env.TUG_CONTROL_URL;
|
|
29
|
+
if (!controlUrl)
|
|
30
|
+
throw new Error('TUG_CONTROL_URL is required');
|
|
31
31
|
const db = await openDb(path.join(dataDir, 'tug.db'));
|
|
32
32
|
try {
|
|
33
33
|
const fresh = !db.get('SELECT 1 FROM operator_credentials') && !db.get('SELECT 1 FROM nodes');
|
|
34
34
|
const pki = await Pki.load(path.join(dataDir, 'pki'), fresh);
|
|
35
35
|
const request = parseCredentialRequest({ name: args.name, role: args.role });
|
|
36
|
-
const created = await createCredential(db, pki, new URL(
|
|
36
|
+
const created = await createCredential(db, pki, new URL(controlUrl).origin, request, 'host', {
|
|
37
37
|
replace: args.replace,
|
|
38
38
|
});
|
|
39
39
|
if (args.replace)
|
package/dist/control.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export declare function createApp(options?: {
|
|
|
27
27
|
openDatabase?: typeof openDb;
|
|
28
28
|
/** Minimum time between event prunes triggered by agent reports; default one hour. */ eventPruneIntervalMs?: number;
|
|
29
29
|
/** Override the live open/agent startup timeout in milliseconds. */ liveStartTimeoutMs?: number;
|
|
30
|
-
/** Credential-free HTTPS origin clients
|
|
30
|
+
/** Credential-free HTTPS origin clients connect to by default; defaults to `TUG_CONTROL_URL`. */ controlUrl?: string;
|
|
31
31
|
/**
|
|
32
32
|
* Replaces certificate-based identity resolution. Only for in-process tests that skip TLS; production never sets it.
|
|
33
33
|
* It returns the principal URI and key hash a connection would have presented.
|