@wpelevator/tug 0.11.0 → 0.13.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 +110 -68
- package/dist/agent.js +38 -2
- package/dist/backup-config.js +1 -1
- package/dist/backup-restore.d.ts +9 -2
- package/dist/backup-restore.js +75 -9
- package/dist/backup-scheduler.js +3 -1
- package/dist/cli-build.d.ts +1 -1
- package/dist/cli-build.js +4 -1
- package/dist/cli-config.d.ts +1 -1
- package/dist/cli-config.js +7 -2
- package/dist/cli-process.d.ts +13 -0
- package/dist/cli-process.js +46 -0
- package/dist/cli-progress.d.ts +11 -0
- package/dist/cli-progress.js +37 -0
- package/dist/cli-prompt.js +5 -4
- package/dist/cli-selection.d.ts +13 -0
- package/dist/cli-selection.js +39 -0
- package/dist/cli.js +4 -0
- package/dist/commands/backup.js +48 -31
- package/dist/commands/context.d.ts +6 -5
- package/dist/commands/context.js +65 -28
- package/dist/commands/control.js +56 -42
- package/dist/commands/credential.js +21 -2
- package/dist/commands/deploy.d.ts +4 -0
- package/dist/commands/deploy.js +116 -69
- package/dist/commands/hold.d.ts +2 -0
- package/dist/commands/hold.js +21 -0
- package/dist/commands/host.d.ts +8 -2
- package/dist/commands/host.js +36 -8
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +13 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/node.d.ts +1 -0
- package/dist/commands/node.js +139 -73
- package/dist/commands/observe.js +7 -2
- package/dist/commands/restore.js +71 -50
- package/dist/commands/secret.js +8 -3
- package/dist/commands/selection.d.ts +22 -0
- package/dist/commands/selection.js +90 -0
- package/dist/commands/service.js +17 -6
- package/dist/commands/volume.js +3 -2
- package/dist/control.js +234 -55
- package/dist/credentials.js +7 -0
- package/dist/db-backup-destination.d.ts +14 -0
- package/dist/db-backup-destination.js +28 -0
- package/dist/db-locks.d.ts +10 -0
- package/dist/db-locks.js +23 -0
- package/dist/db-transaction.d.ts +3 -0
- package/dist/db-transaction.js +24 -0
- package/dist/docker-api.d.ts +1 -0
- package/dist/image.d.ts +9 -0
- package/dist/image.js +19 -0
- package/dist/live.js +1 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.js +6 -0
- package/docs/access-control.md +166 -0
- package/docs/architecture.md +254 -0
- package/docs/backups.md +160 -0
- package/docs/cli.md +182 -0
- package/docs/compose.md +245 -0
- package/docs/configuration.md +52 -0
- package/docs/control-plane.md +112 -0
- package/docs/development.md +140 -0
- package/docs/getting-started.md +75 -0
- package/docs/healthchecks.md +51 -0
- package/docs/migration.md +113 -0
- package/docs/networking.md +150 -0
- package/docs/nodes.md +143 -0
- package/docs/observability.md +213 -0
- package/docs/real-host-evaluation.md +224 -0
- package/docs/releases.md +39 -0
- package/docs/roadmap.md +234 -0
- package/docs/security.md +198 -0
- package/docs/service-placement.md +72 -0
- package/docs/standalone-binary.md +79 -0
- package/docs/swarm-comparison.md +197 -0
- package/docs/terminology.md +74 -0
- package/docs/testing.md +254 -0
- package/docs/troubleshooting.md +33 -0
- package/package.json +6 -3
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { RUN_OUTPUT_LIMIT } from "./build.js";
|
|
3
|
+
/** Keep the event loop free for progress timers during SSH, Docker and file transfers. */
|
|
4
|
+
export function runProcess(cmd, args, { capture = false, input } = {}) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const child = spawn(cmd, args, {
|
|
7
|
+
stdio: [
|
|
8
|
+
input === undefined ? 'inherit' : 'pipe',
|
|
9
|
+
capture ? 'pipe' : 'inherit',
|
|
10
|
+
capture ? 'pipe' : 'inherit',
|
|
11
|
+
],
|
|
12
|
+
});
|
|
13
|
+
let stdout = '';
|
|
14
|
+
let stderr = '';
|
|
15
|
+
child.stdout?.on('data', (chunk) => {
|
|
16
|
+
stdout = (stdout + String(chunk)).slice(-RUN_OUTPUT_LIMIT);
|
|
17
|
+
});
|
|
18
|
+
child.stderr?.on('data', (chunk) => {
|
|
19
|
+
stderr = (stderr + String(chunk)).slice(-RUN_OUTPUT_LIMIT);
|
|
20
|
+
});
|
|
21
|
+
child.on('error', reject);
|
|
22
|
+
child.stdin?.on('error', (error) => {
|
|
23
|
+
if (error.code !== 'EPIPE')
|
|
24
|
+
reject(error);
|
|
25
|
+
});
|
|
26
|
+
if (input !== undefined)
|
|
27
|
+
child.stdin?.end(input);
|
|
28
|
+
child.on('close', (status, signal) => {
|
|
29
|
+
if (signal)
|
|
30
|
+
reject(new Error(`${cmd} terminated by ${signal}`));
|
|
31
|
+
else
|
|
32
|
+
resolve({ status, stdout, stderr });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export async function runLocal(cmd, args) {
|
|
37
|
+
const result = await runProcess(cmd, args);
|
|
38
|
+
if (result.status !== 0)
|
|
39
|
+
throw new Error(`${cmd} failed (exit ${result.status})`);
|
|
40
|
+
}
|
|
41
|
+
export async function runCapture(cmd, args, opts = {}) {
|
|
42
|
+
const result = await runProcess(cmd, args, { ...opts, capture: true });
|
|
43
|
+
if (result.status !== 0)
|
|
44
|
+
throw new Error(`${cmd} failed (exit ${result.status})\n${result.stderr}`);
|
|
45
|
+
return result.stdout;
|
|
46
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Line-based stderr progress works in terminals and CI without changing stdout results. */
|
|
2
|
+
export declare function createProgress({ write, now, intervalMs, }?: {
|
|
3
|
+
write?: ((line: string) => void) | undefined;
|
|
4
|
+
now?: (() => number) | undefined;
|
|
5
|
+
intervalMs?: number | undefined;
|
|
6
|
+
}): {
|
|
7
|
+
stage(next: string): void;
|
|
8
|
+
stop(): void;
|
|
9
|
+
};
|
|
10
|
+
/** Own the timer for one operation, including failures and early returns. */
|
|
11
|
+
export declare function withProgress<T>(label: string, operation: (progress: ReturnType<typeof createProgress>) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Line-based stderr progress works in terminals and CI without changing stdout results. */
|
|
2
|
+
export function createProgress({ write = (line) => {
|
|
3
|
+
process.stderr.write(`${line}\n`);
|
|
4
|
+
}, now = Date.now, intervalMs = 10_000, } = {}) {
|
|
5
|
+
let label = '';
|
|
6
|
+
let started = now();
|
|
7
|
+
let timer;
|
|
8
|
+
const report = () => write(`[${Math.floor((now() - started) / 1000)}s] ${label}`);
|
|
9
|
+
return {
|
|
10
|
+
stage(next) {
|
|
11
|
+
if (next === label)
|
|
12
|
+
return;
|
|
13
|
+
label = next;
|
|
14
|
+
started = now();
|
|
15
|
+
report();
|
|
16
|
+
if (!timer) {
|
|
17
|
+
timer = setInterval(report, intervalMs);
|
|
18
|
+
timer.unref();
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
stop() {
|
|
22
|
+
clearInterval(timer);
|
|
23
|
+
timer = undefined;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Own the timer for one operation, including failures and early returns. */
|
|
28
|
+
export async function withProgress(label, operation) {
|
|
29
|
+
const progress = createProgress();
|
|
30
|
+
try {
|
|
31
|
+
progress.stage(label);
|
|
32
|
+
return await operation(progress);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
progress.stop();
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/cli-prompt.js
CHANGED
|
@@ -10,24 +10,25 @@ export const ask = async (question, hidden = false) => {
|
|
|
10
10
|
const output = new Writable({
|
|
11
11
|
write(chunk, _encoding, callback) {
|
|
12
12
|
if (!muted.on)
|
|
13
|
-
process.
|
|
13
|
+
process.stderr.write(chunk);
|
|
14
14
|
callback();
|
|
15
15
|
},
|
|
16
16
|
});
|
|
17
17
|
const rl = readline.createInterface({ input: process.stdin, output, terminal: true });
|
|
18
18
|
try {
|
|
19
19
|
return await new Promise((resolve, reject) => {
|
|
20
|
-
|
|
20
|
+
const onClose = () => reject(new Error('input closed before an answer was given'));
|
|
21
|
+
rl.once('close', onClose);
|
|
21
22
|
rl.question(question, (answer) => {
|
|
23
|
+
rl.off('close', onClose);
|
|
22
24
|
if (hidden)
|
|
23
|
-
process.
|
|
25
|
+
process.stderr.write('\n');
|
|
24
26
|
resolve(answer);
|
|
25
27
|
});
|
|
26
28
|
muted.on = hidden;
|
|
27
29
|
});
|
|
28
30
|
}
|
|
29
31
|
finally {
|
|
30
|
-
rl.removeAllListeners('close');
|
|
31
32
|
rl.close();
|
|
32
33
|
}
|
|
33
34
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Prompter } from './cli-prompt.ts';
|
|
2
|
+
export interface Choice {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SelectionIO {
|
|
7
|
+
interactive: boolean;
|
|
8
|
+
prompt: Prompter;
|
|
9
|
+
write: (message: string) => void;
|
|
10
|
+
}
|
|
11
|
+
/** Resolve lazily so explicit arguments never need a discovery request. */
|
|
12
|
+
export declare function selectOne(value: string | undefined, question: string, load: () => Promise<Choice[]>, io?: SelectionIO): Promise<string>;
|
|
13
|
+
export declare function selectMany(question: string, load: () => Promise<Choice[]>, io?: SelectionIO): Promise<string[]>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ask, canPrompt } from "./cli-prompt.js";
|
|
2
|
+
const terminal = () => ({
|
|
3
|
+
interactive: canPrompt(),
|
|
4
|
+
prompt: ask,
|
|
5
|
+
write: console.error,
|
|
6
|
+
});
|
|
7
|
+
/** Resolve lazily so explicit arguments never need a discovery request. */
|
|
8
|
+
export async function selectOne(value, question, load, io = terminal()) {
|
|
9
|
+
if (value)
|
|
10
|
+
return value;
|
|
11
|
+
const selected = await selectChoices(question, load, false, io);
|
|
12
|
+
return selected[0];
|
|
13
|
+
}
|
|
14
|
+
export async function selectMany(question, load, io = terminal()) {
|
|
15
|
+
return selectChoices(question, load, true, io);
|
|
16
|
+
}
|
|
17
|
+
async function selectChoices(question, load, multiple, io) {
|
|
18
|
+
if (!io.interactive)
|
|
19
|
+
throw new Error(`${question}: pass an explicit target; selection requires a terminal outside CI`);
|
|
20
|
+
const options = await load();
|
|
21
|
+
if (!options.length)
|
|
22
|
+
throw new Error(`${question}: no eligible options`);
|
|
23
|
+
io.write(question);
|
|
24
|
+
options.forEach((option, index) => io.write(` ${index + 1}) ${option.label}`));
|
|
25
|
+
for (;;) {
|
|
26
|
+
const answer = (await io.prompt(multiple
|
|
27
|
+
? 'Choose numbers separated by commas (or q to cancel): '
|
|
28
|
+
: 'Choose a number (or q to cancel): ')).trim();
|
|
29
|
+
if (/^(q|quit|cancel)$/i.test(answer))
|
|
30
|
+
throw new Error('cancelled; nothing was changed');
|
|
31
|
+
const parts = answer.split(',').map((part) => part.trim());
|
|
32
|
+
if ((!multiple && parts.length !== 1) || parts.some((part) => !/^[1-9]\d*$/.test(part)))
|
|
33
|
+
continue;
|
|
34
|
+
const indexes = parts.map((part) => Number(part) - 1);
|
|
35
|
+
if (indexes.some((index) => index >= options.length))
|
|
36
|
+
continue;
|
|
37
|
+
return [...new Set(indexes)].map((index) => options[index].value);
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,8 @@ import { registerInit } from "./commands/init.js";
|
|
|
14
14
|
import { registerNode } from "./commands/node.js";
|
|
15
15
|
import { registerObserve } from "./commands/observe.js";
|
|
16
16
|
import { registerRestore } from "./commands/restore.js";
|
|
17
|
+
import { registerHold } from "./commands/hold.js";
|
|
18
|
+
import { registerImage } from "./commands/image.js";
|
|
17
19
|
import { registerSecret } from "./commands/secret.js";
|
|
18
20
|
import { registerService } from "./commands/service.js";
|
|
19
21
|
import { registerVolume } from "./commands/volume.js";
|
|
@@ -33,6 +35,8 @@ registerNode(program);
|
|
|
33
35
|
registerDeploy(program);
|
|
34
36
|
registerObserve(program);
|
|
35
37
|
registerService(program);
|
|
38
|
+
registerHold(program);
|
|
39
|
+
registerImage(program);
|
|
36
40
|
registerSecret(program);
|
|
37
41
|
registerBackup(program);
|
|
38
42
|
registerRestore(program);
|
package/dist/commands/backup.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { selectBackups } from "./selection.js";
|
|
2
|
+
import { withProgress } from "../cli-progress.js";
|
|
1
3
|
import { backupCheckProblems } from "../backup-check.js";
|
|
2
4
|
import { collect, loadCfg, print, readStdin, request } from "./context.js";
|
|
3
5
|
const formatBytes = (bytes) => bytes === null
|
|
@@ -13,42 +15,57 @@ export function registerBackup(program) {
|
|
|
13
15
|
backup
|
|
14
16
|
.command('run [targets...]')
|
|
15
17
|
.description('back up the named services or volumes now, or every backup target of the project; waits and exits nonzero if any backup fails')
|
|
18
|
+
.option('--select', 'choose one or more backup targets instead of backing up all')
|
|
16
19
|
.option('--no-wait', 'return once the backups are queued')
|
|
17
20
|
.option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
|
|
18
21
|
.action(async (targets, o) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
targets
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
22
|
+
if (o.select && targets.length)
|
|
23
|
+
throw new Error('--select cannot be combined with explicit targets');
|
|
24
|
+
if (o.select)
|
|
25
|
+
targets = await selectBackups();
|
|
26
|
+
return withProgress('Queueing backups', async (progress) => {
|
|
27
|
+
const project = (await loadCfg()).project || 'site';
|
|
28
|
+
const queued = await request('POST', '/api/v1/backups/run', {
|
|
29
|
+
project,
|
|
30
|
+
targets: targets.length ? targets : undefined,
|
|
31
|
+
});
|
|
32
|
+
for (const message of queued.errors)
|
|
33
|
+
console.error(message);
|
|
34
|
+
for (const run of queued.runs)
|
|
35
|
+
console.log(`queued ${run.target} as ${run.runId}`);
|
|
36
|
+
let failed = queued.errors.length > 0;
|
|
37
|
+
if (o.wait) {
|
|
38
|
+
const deadline = Date.now() + Number(o.timeout) * 1000;
|
|
39
|
+
const pending = new Set(queued.runs.map((run) => run.runId));
|
|
40
|
+
if (pending.size)
|
|
41
|
+
progress.stage(`Waiting for backups: ${queued.runs.map((run) => run.target).join(', ')}`);
|
|
42
|
+
const states = new Map();
|
|
43
|
+
while (pending.size && Date.now() < deadline) {
|
|
44
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
45
|
+
const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=200`);
|
|
46
|
+
for (const run of listing.runs) {
|
|
47
|
+
if (pending.has(run.id))
|
|
48
|
+
states.set(run.id, `${run.target}: ${run.state}`);
|
|
49
|
+
if (pending.has(run.id) && BACKUP_TERMINAL.includes(run.state)) {
|
|
50
|
+
pending.delete(run.id);
|
|
51
|
+
const ok = run.state === 'succeeded';
|
|
52
|
+
failed ||= !ok;
|
|
53
|
+
console.log(ok
|
|
54
|
+
? `${run.target}: snapshot ${run.snapshot_id?.slice(0, 8)}, ${formatBytes(run.added_bytes)} added`
|
|
55
|
+
: `${run.target}: ${run.state}${run.error ? ` (${run.error})` : ''}`);
|
|
56
|
+
}
|
|
43
57
|
}
|
|
58
|
+
if (pending.size)
|
|
59
|
+
progress.stage(`Backups: ${[...pending].map((id) => states.get(id) ?? id).join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
if (pending.size) {
|
|
62
|
+
console.error(`timed out waiting for ${pending.size} backup(s); they keep running on the node`);
|
|
63
|
+
failed = true;
|
|
64
|
+
}
|
|
44
65
|
}
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
if (failed)
|
|
51
|
-
process.exitCode = 1;
|
|
66
|
+
if (failed)
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
});
|
|
52
69
|
});
|
|
53
70
|
backup
|
|
54
71
|
.command('list')
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
export { runCapture, runLocal } from '../cli-process.ts';
|
|
2
2
|
import type { ResolvedCliConfig } from '../cli-config.ts';
|
|
3
3
|
import type { LiveInput, LiveRequest } from '../cli-live.ts';
|
|
4
4
|
import type { DeploymentState } from '../protocol.ts';
|
|
@@ -16,6 +16,10 @@ export interface DeployResponse {
|
|
|
16
16
|
deployment: DeploymentRecord;
|
|
17
17
|
desiredState?: unknown;
|
|
18
18
|
}
|
|
19
|
+
/** One line for a person: which project and version reached which state, and how many services it runs. */
|
|
20
|
+
export declare function formatDeployment(response: DeployResponse): string;
|
|
21
|
+
/** Prints the full API result with `--json`, otherwise the human summary. */
|
|
22
|
+
export declare function printResult<T>(json: boolean | undefined, value: T, summary: (value: T) => string): void;
|
|
19
23
|
export interface CommonOpts {
|
|
20
24
|
project?: string;
|
|
21
25
|
}
|
|
@@ -46,9 +50,6 @@ export declare function request<T = any>(method: string, route: string, body?: u
|
|
|
46
50
|
/** The project a project-scoped command works on: `--project`, then the resolved config, then `site`. */
|
|
47
51
|
export declare function projectOf(o: CommonOpts): Promise<string>;
|
|
48
52
|
export declare const print: (value: unknown) => void;
|
|
49
|
-
export declare function
|
|
50
|
-
export declare function runCapture(cmd: string, args: string[], opts?: SpawnSyncOptions): string;
|
|
51
|
-
export declare function waitForDeployment(deploymentId: string, timeoutSeconds?: number): Promise<DeploymentRecord>;
|
|
53
|
+
export declare function waitForDeployment(deploymentId: string, timeoutSeconds?: number, onProgress?: (deployment: DeploymentRecord) => void): Promise<DeploymentRecord>;
|
|
52
54
|
export declare function waitForCommand(commandId: string, timeoutSeconds?: number): Promise<CommandRecord>;
|
|
53
55
|
export declare function runLive(operation: LiveRequest['operation'], input: Omit<LiveInput, 'project'>): Promise<void>;
|
|
54
|
-
export {};
|
package/dist/commands/context.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { canPrompt } from "../cli-prompt.js";
|
|
2
|
+
import { selectOne } from "../cli-selection.js";
|
|
3
|
+
import { withProgress } from "../cli-progress.js";
|
|
4
|
+
export { runCapture, runLocal } from "../cli-process.js";
|
|
2
5
|
import path from 'node:path';
|
|
3
6
|
import { fileURLToPath } from 'node:url';
|
|
4
7
|
import { createTransport } from "../agent-credential.js";
|
|
5
8
|
import { createCliLiveTransport } from "../cli-live-transport.js";
|
|
6
9
|
import { parseLiveRequest, runCliLive } from "../cli-live.js";
|
|
7
10
|
import { TERMINAL_DEPLOYMENT_STATES } from "../protocol.js";
|
|
11
|
+
import { describeFetchError, errMessage } from "../util.js";
|
|
8
12
|
// Templates live next to the commands' parent: src/templates in dev (`node src/cli.ts`), dist/templates in the
|
|
9
13
|
// built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
|
|
10
14
|
export const templatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
15
|
+
/** One line for a person: which project and version reached which state, and how many services it runs. */
|
|
16
|
+
export function formatDeployment(response) {
|
|
17
|
+
const { deployment } = response;
|
|
18
|
+
const state = response.desiredState;
|
|
19
|
+
const services = Object.keys(state?.services ?? {}).length;
|
|
20
|
+
const label = [state?.project, state?.version].filter(Boolean).join(' ');
|
|
21
|
+
return `${label ? `${label}: ` : ''}deployment ${deployment.id} ${deployment.state}${services ? `, ${services} service${services === 1 ? '' : 's'}` : ''}${deployment.error_message ? ` (${deployment.error_message})` : ''}`;
|
|
22
|
+
}
|
|
23
|
+
/** Prints the full API result with `--json`, otherwise the human summary. */
|
|
24
|
+
export function printResult(json, value, summary) {
|
|
25
|
+
if (json)
|
|
26
|
+
print(value);
|
|
27
|
+
else
|
|
28
|
+
console.log(summary(value));
|
|
29
|
+
}
|
|
11
30
|
export const CONFIG_HELP = 'CLI config file, YAML or JSON (default: TUG_CONFIG, else tug.yml, tug.yaml, tug.json or .tug.json)';
|
|
12
31
|
let resolvedConfig;
|
|
13
32
|
/** Set by the root `preAction` hook before any command runs. */
|
|
@@ -38,11 +57,17 @@ export async function request(method, route, body, multipart = false) {
|
|
|
38
57
|
if (!multipart)
|
|
39
58
|
headers['content-type'] = 'application/json';
|
|
40
59
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
res = await transport.http(`${transport.base}${route}`, {
|
|
63
|
+
method,
|
|
64
|
+
headers,
|
|
65
|
+
body: multipart ? body : body ? JSON.stringify(body) : undefined,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw new Error(`${method} ${route}: could not reach control at ${transport.base}: ${describeFetchError(error)}`, { cause: error });
|
|
70
|
+
}
|
|
46
71
|
if (!res.ok)
|
|
47
72
|
throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
|
|
48
73
|
return (await res.json());
|
|
@@ -56,21 +81,17 @@ export async function projectOf(o) {
|
|
|
56
81
|
return o.project || (await loadCfg()).project || 'site';
|
|
57
82
|
}
|
|
58
83
|
export const print = (value) => console.log(JSON.stringify(value, null, 2));
|
|
59
|
-
export function
|
|
60
|
-
const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
|
|
61
|
-
if (result.status !== 0)
|
|
62
|
-
throw new Error(`${cmd} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}`);
|
|
63
|
-
}
|
|
64
|
-
export function runCapture(cmd, args, opts = {}) {
|
|
65
|
-
const result = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
|
|
66
|
-
if (result.status !== 0)
|
|
67
|
-
throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
|
|
68
|
-
return String(result.stdout);
|
|
69
|
-
}
|
|
70
|
-
export async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
84
|
+
export async function waitForDeployment(deploymentId, timeoutSeconds = 300, onProgress) {
|
|
71
85
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
72
86
|
while (Date.now() < deadline) {
|
|
73
|
-
|
|
87
|
+
let deployment;
|
|
88
|
+
try {
|
|
89
|
+
deployment = await request('GET', `/api/v1/deployments/${encodeURIComponent(deploymentId)}`);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
throw new Error(`lost contact with control while waiting for deployment ${deploymentId} (${errMessage(error)}); it may still be rolling out: check tug status, or stop it with: tug deploy cancel ${deploymentId}`, { cause: error });
|
|
93
|
+
}
|
|
94
|
+
onProgress?.(deployment);
|
|
74
95
|
if (TERMINAL_DEPLOYMENT_STATES.includes(deployment.state)) {
|
|
75
96
|
if (deployment.state !== 'succeeded')
|
|
76
97
|
throw new Error(`deployment ${deploymentId} ${deployment.state}${deployment.error_message ? `: ${deployment.error_message}` : ''}`);
|
|
@@ -78,19 +99,35 @@ export async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
|
78
99
|
}
|
|
79
100
|
await new Promise((r) => setTimeout(r, 2000));
|
|
80
101
|
}
|
|
81
|
-
throw new Error(`timed out waiting for deployment ${deploymentId}`);
|
|
102
|
+
throw new Error(`timed out waiting for deployment ${deploymentId}; it is still rolling out: check tug status, or stop it with: tug deploy cancel ${deploymentId}`);
|
|
82
103
|
}
|
|
83
104
|
export async function waitForCommand(commandId, timeoutSeconds = 60) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
return withProgress(`Waiting for command ${commandId}`, async (progress) => {
|
|
106
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
const cmd = await request('GET', `/api/v1/commands/${commandId}`);
|
|
109
|
+
if (cmd)
|
|
110
|
+
progress.stage(`Command ${commandId}: ${cmd.state}`);
|
|
111
|
+
if (cmd?.state === 'succeeded' || cmd?.state === 'failed')
|
|
112
|
+
return cmd;
|
|
113
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`timed out waiting for command ${commandId}`);
|
|
116
|
+
});
|
|
92
117
|
}
|
|
93
118
|
export async function runLive(operation, input) {
|
|
94
119
|
const cfg = await loadCfg();
|
|
120
|
+
if (!input.node && canPrompt()) {
|
|
121
|
+
const { services: listing } = await request('GET', `/api/v1/services?project=${encodeURIComponent(cfg.project || 'site')}`);
|
|
122
|
+
const candidates = new Map(listing
|
|
123
|
+
.filter((service) => operation === 'ps' || service.name === input.service)
|
|
124
|
+
.flatMap((service) => service.nodes)
|
|
125
|
+
.map((node) => [node.name, node]));
|
|
126
|
+
if (candidates.size > 1)
|
|
127
|
+
input.node = await selectOne(undefined, 'Select a node for this operation', async () => [...candidates.values()].map((node) => ({
|
|
128
|
+
value: node.name,
|
|
129
|
+
label: `${node.name} (${node.status})`,
|
|
130
|
+
})));
|
|
131
|
+
}
|
|
95
132
|
process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.credential, cfg.controlUrl));
|
|
96
133
|
}
|
package/dist/commands/control.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { withProgress } from "../cli-progress.js";
|
|
2
|
+
import { runProcess } from "../cli-process.js";
|
|
2
3
|
import { canPrompt, confirm } from "../cli-prompt.js";
|
|
3
4
|
import { CONTROL_CONTAINER, defaultControlUrl, shellQuote as sh } from "../cli-install.js";
|
|
4
|
-
import { resolveImage } from "../image.js";
|
|
5
|
+
import { assertImagePublished, resolveImage } from "../image.js";
|
|
5
6
|
import { decodeCredential, writeFileDurable } from "../pki.js";
|
|
6
7
|
import { loadTemplate } from "../util.js";
|
|
7
8
|
import { collect, loadCfg, print, request, runLocal, templatesDir } from "./context.js";
|
|
@@ -36,6 +37,7 @@ function addSetup(parent, description) {
|
|
|
36
37
|
o.installDocker = await confirm(`Docker with the Compose plugin is not installed on ${sshTarget}. Install it now (needs root)?`);
|
|
37
38
|
}
|
|
38
39
|
const image = resolveImage(o.image, o.buildLocalImage);
|
|
40
|
+
assertImagePublished(image);
|
|
39
41
|
console.error([
|
|
40
42
|
'Control plan',
|
|
41
43
|
` host: ${sshTarget}`,
|
|
@@ -47,45 +49,56 @@ function addSetup(parent, description) {
|
|
|
47
49
|
].join('\n'));
|
|
48
50
|
if (interactive && !o.yes && !(await confirm('Install the control plane?')))
|
|
49
51
|
throw new Error('cancelled; nothing was changed');
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
await transferTugImage(sshTarget, image);
|
|
55
|
-
const yamlValue = (value) => JSON.stringify(value);
|
|
56
|
-
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
57
|
-
IMAGE: yamlValue(image),
|
|
58
|
-
CONTROL_URL: yamlValue(new URL(controlUrl).origin),
|
|
59
|
-
EXTRA_SANS: yamlValue(o.extraSan.join(',')),
|
|
60
|
-
PORT: o.port,
|
|
61
|
-
});
|
|
62
|
-
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
63
|
-
COMPOSE: compose,
|
|
64
|
-
CONTAINER_SH: sh(CONTROL_CONTAINER),
|
|
65
|
-
});
|
|
66
|
-
runLocal('ssh', ['--', sshTarget, remote]);
|
|
67
|
-
// Upgrades never reissue root: fresh installs create it, `--recover-root` replaces its key. Output goes straight to files.
|
|
68
|
-
const issue = `docker exec ${sh(CONTROL_CONTAINER)} node dist/main.js control issue-credential --name root --role admin${o.recoverRoot ? ' --replace' : ''}`;
|
|
69
|
-
const out = spawnSync('ssh', ['--', sshTarget, issue], { encoding: 'utf8' });
|
|
70
|
-
if (out.status !== 0) {
|
|
71
|
-
if (!o.recoverRoot && /already used/.test(String(out.stderr)))
|
|
72
|
-
console.error('control already has a root credential; upgrade complete (pass --recover-root to replace it)');
|
|
73
|
-
else
|
|
74
|
-
throw new Error(`issuing the root credential failed: ${String(out.stderr).trim()}`);
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
const text = `${String(out.stdout).trim()}\n`;
|
|
78
|
-
decodeCredential(text);
|
|
79
|
-
if (o.credentialOut) {
|
|
80
|
-
await writeFileDurable(o.credentialOut, text, 0o600);
|
|
81
|
-
console.error(`root credential written to ${o.credentialOut}`);
|
|
52
|
+
return withProgress(`Preparing control on ${sshTarget}`, async (progress) => {
|
|
53
|
+
if (o.installDocker) {
|
|
54
|
+
progress.stage(`Installing Docker on ${sshTarget}`);
|
|
55
|
+
await bootstrapDocker(sshTarget, true);
|
|
82
56
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
57
|
+
progress.stage(`Checking control host ${sshTarget}`);
|
|
58
|
+
await preflightRemote(sshTarget, {
|
|
59
|
+
ports: [Number(o.port)],
|
|
60
|
+
minDiskMb: 2048,
|
|
61
|
+
compose: true,
|
|
62
|
+
});
|
|
63
|
+
if (o.buildLocalImage)
|
|
64
|
+
await transferTugImage(sshTarget, image, progress.stage);
|
|
65
|
+
const yamlValue = (value) => JSON.stringify(value);
|
|
66
|
+
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
67
|
+
IMAGE: yamlValue(image),
|
|
68
|
+
CONTROL_URL: yamlValue(new URL(controlUrl).origin),
|
|
69
|
+
EXTRA_SANS: yamlValue(o.extraSan.join(',')),
|
|
70
|
+
PORT: o.port,
|
|
71
|
+
});
|
|
72
|
+
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
73
|
+
COMPOSE: compose,
|
|
74
|
+
CONTAINER_SH: sh(CONTROL_CONTAINER),
|
|
75
|
+
});
|
|
76
|
+
progress.stage(`Installing control on ${sshTarget}`);
|
|
77
|
+
await runLocal('ssh', ['--', sshTarget, remote]);
|
|
78
|
+
// Upgrades never reissue root: fresh installs create it, `--recover-root` replaces its key. Output goes straight to files.
|
|
79
|
+
const issue = `docker exec ${sh(CONTROL_CONTAINER)} node dist/main.js control issue-credential --name root --role admin${o.recoverRoot ? ' --replace' : ''}`;
|
|
80
|
+
progress.stage(`Issuing root credential on ${sshTarget}`);
|
|
81
|
+
const out = await runProcess('ssh', ['--', sshTarget, issue], { capture: true });
|
|
82
|
+
if (out.status !== 0) {
|
|
83
|
+
if (!o.recoverRoot && /already used/.test(String(out.stderr)))
|
|
84
|
+
console.error('control already has a root credential; upgrade complete (pass --recover-root to replace it)');
|
|
85
|
+
else
|
|
86
|
+
throw new Error(`issuing the root credential failed: ${String(out.stderr).trim()}`);
|
|
86
87
|
}
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
else {
|
|
89
|
+
const text = `${String(out.stdout).trim()}\n`;
|
|
90
|
+
decodeCredential(text);
|
|
91
|
+
if (o.credentialOut) {
|
|
92
|
+
await writeFileDurable(o.credentialOut, text, 0o600);
|
|
93
|
+
console.error(`root credential written to ${o.credentialOut}`);
|
|
94
|
+
}
|
|
95
|
+
if (o.import) {
|
|
96
|
+
const { controlUrl: connectUrl, project } = command.optsWithGlobals();
|
|
97
|
+
await importCredential(cfg, text, { controlUrl: connectUrl, project });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
print({ ok: true, sshTarget, url: controlUrl });
|
|
101
|
+
});
|
|
89
102
|
});
|
|
90
103
|
}
|
|
91
104
|
export function registerControl(program) {
|
|
@@ -98,13 +111,14 @@ export function registerControl(program) {
|
|
|
98
111
|
control
|
|
99
112
|
.command('backup')
|
|
100
113
|
.description('create a control-plane backup (state, PKI, master key and retained image archives; not workload volumes)')
|
|
101
|
-
.
|
|
114
|
+
.option('--no-images', 'leave the retained build archives out (deploy again to rebuild them)')
|
|
115
|
+
.action(async (o) => withProgress('Creating control-plane backup', async () => print(await request('POST', '/api/v1/control/backups/run', { images: o.images }))));
|
|
102
116
|
control
|
|
103
117
|
.command('restore <artifactRef>')
|
|
104
118
|
.description('restore a control-plane backup on the control-plane host; requires a control restart')
|
|
105
119
|
.option('--confirm')
|
|
106
|
-
.action(async (artifactRef, o) => print(await request('POST', '/api/v1/control/restore', {
|
|
120
|
+
.action(async (artifactRef, o) => withProgress('Restoring control-plane backup', async () => print(await request('POST', '/api/v1/control/restore', {
|
|
107
121
|
artifactRef,
|
|
108
122
|
confirm: Boolean(o.confirm),
|
|
109
|
-
})));
|
|
123
|
+
}))));
|
|
110
124
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { selectMany } from "../cli-selection.js";
|
|
2
|
+
import { selectResource } from "./selection.js";
|
|
1
3
|
import { Option } from 'commander';
|
|
2
4
|
import { ROLES } from "../credentials.js";
|
|
3
5
|
import { print, request } from "./context.js";
|
|
@@ -14,8 +16,25 @@ export function registerCredential(program) {
|
|
|
14
16
|
.addOption(new Option('--project <name>', 'limit a read or deploy credential to this project; repeat for several')
|
|
15
17
|
.default([], 'all projects')
|
|
16
18
|
.argParser((value, previous) => [...previous, value]))
|
|
19
|
+
.option('--select-projects', 'choose existing projects for the credential scope')
|
|
17
20
|
.option('--expires-in-days <n>', 'expire the credential after this many days (default and maximum: 365)')
|
|
18
21
|
.action(async (name, o) => {
|
|
22
|
+
if (o.selectProjects) {
|
|
23
|
+
if (o.role === 'admin' || o.project.length)
|
|
24
|
+
throw new Error('--select-projects requires read/deploy role and no --project');
|
|
25
|
+
o.project = await selectMany('Select projects (choose all projects alone for unrestricted access)', async () => {
|
|
26
|
+
const { projects } = await request('GET', '/api/v1/projects');
|
|
27
|
+
return [
|
|
28
|
+
{ value: '*', label: 'All projects, including future projects' },
|
|
29
|
+
...projects.map((project) => ({ value: project.name, label: project.name })),
|
|
30
|
+
];
|
|
31
|
+
});
|
|
32
|
+
if (o.project.includes('*')) {
|
|
33
|
+
if (o.project.length !== 1)
|
|
34
|
+
throw new Error('all projects must be selected alone');
|
|
35
|
+
o.project = [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
19
38
|
const created = await request('POST', '/api/v1/credentials', {
|
|
20
39
|
name,
|
|
21
40
|
role: o.role,
|
|
@@ -30,7 +49,7 @@ export function registerCredential(program) {
|
|
|
30
49
|
.description('list operator credentials with role, projects, expiry and last use; secrets are never shown')
|
|
31
50
|
.action(async () => print(await request('GET', '/api/v1/credentials')));
|
|
32
51
|
credential
|
|
33
|
-
.command('revoke
|
|
52
|
+
.command('revoke [name]')
|
|
34
53
|
.description('revoke an operator credential immediately; its name stays reserved for the audit trail')
|
|
35
|
-
.action(async (name) => print(await request('DELETE', `/api/v1/credentials/${encodeURIComponent(name)}`)));
|
|
54
|
+
.action(async (name) => print(await request('DELETE', `/api/v1/credentials/${encodeURIComponent(await selectResource(name, 'credentials'))}`)));
|
|
36
55
|
}
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
+
/** The first conventional Compose file name in the working directory; `docker-compose.yml` when none exists. */
|
|
3
|
+
export declare function defaultComposeFile(cwd?: string): string;
|
|
4
|
+
/** The top-level `name:` of a Compose file, which Compose would use as the project name. */
|
|
5
|
+
export declare function composeName(file: string): Promise<string | undefined>;
|
|
2
6
|
export declare function registerDeploy(program: Command): void;
|