@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
package/dist/commands/node.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { selectNodes, selectNode } from "./selection.js";
|
|
2
|
+
import { withProgress } from "../cli-progress.js";
|
|
1
3
|
import { applyConfigDefaults, normalizeControlUrl, nodeSettings } from "../cli-config.js";
|
|
2
4
|
import { formatNodes } from "../cli-status.js";
|
|
3
5
|
import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, } from "../cli-install.js";
|
|
4
6
|
import { formatNodePlan, inferNodeName, isValidNodeName } from "../cli-onboarding.js";
|
|
5
7
|
import { ask, canPrompt, choose, confirm } from "../cli-prompt.js";
|
|
6
8
|
import { isNodeHost } from "../node-address.js";
|
|
7
|
-
import { resolveImage } from "../image.js";
|
|
9
|
+
import { assertImagePublished, resolveImage } from "../image.js";
|
|
8
10
|
import { errMessage, loadTemplate } from "../util.js";
|
|
9
11
|
import { collect, loadCfg, print, readStdin, request, runCapture, runLocal, templatesDir, transportFor, } from "./context.js";
|
|
10
|
-
import { bootstrapDocker, discoverAddresses, remoteHostname, dockerBootstrap, hostHasDocker, preflightRemote, transferTugImage, } from "./host.js";
|
|
12
|
+
import { bootstrapDocker, discoverAddresses, remoteHostname, dockerBootstrap, hostHasDocker, preflightRemote, teardownScript, transferTugImage, } from "./host.js";
|
|
11
13
|
import fs from 'node:fs/promises';
|
|
12
14
|
const AGENT_STATE = '/var/lib/tug';
|
|
13
15
|
function addNodeIdentityOptions(command) {
|
|
@@ -28,61 +30,70 @@ function addNodeIdentityOptions(command) {
|
|
|
28
30
|
async function installNodeIdentity(sshTarget, name, mode, o, repair = false,
|
|
29
31
|
/** False in batch mode, where only the final per-node results are printed. */
|
|
30
32
|
printResult = true) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
await
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
sshTarget,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
await
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
33
|
+
return withProgress(`Preparing node ${name}`, async (progress) => {
|
|
34
|
+
const cfg = await loadCfg();
|
|
35
|
+
const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
|
|
36
|
+
await transportFor(cfg).then((t) => t.close()); // fail before touching the host when there is no credential
|
|
37
|
+
assertImagePublished(resolveImage(o.image, o.buildLocalImage));
|
|
38
|
+
if (o.installDocker) {
|
|
39
|
+
progress.stage(`Installing Docker on node ${name}`);
|
|
40
|
+
await bootstrapDocker(sshTarget);
|
|
41
|
+
}
|
|
42
|
+
progress.stage(`Checking host ${name}`);
|
|
43
|
+
await preflightRemote(sshTarget, {
|
|
44
|
+
controlUrl: connectUrl ?? cfg.credential?.url,
|
|
45
|
+
});
|
|
46
|
+
const image = resolveImage(o.image, o.buildLocalImage);
|
|
47
|
+
if (o.buildLocalImage)
|
|
48
|
+
await transferTugImage(sshTarget, image, progress.stage);
|
|
49
|
+
const started = new Date().toISOString();
|
|
50
|
+
if (repair)
|
|
51
|
+
await runLocal('ssh', [
|
|
52
|
+
'--',
|
|
53
|
+
sshTarget,
|
|
54
|
+
`mkdir -p ${AGENT_STATE} && cd ${AGENT_STATE} && for f in agent.json agent-pending.json; do [ -e "$f" ] && mv "$f" "$f.corrupt-$(date +%s)"; done; true`,
|
|
55
|
+
]);
|
|
56
|
+
const agent = (command, interactive = false) => `mkdir -p ${AGENT_STATE} && docker run --rm ${interactive ? '-i ' : ''}-v ${AGENT_STATE}:${AGENT_STATE} ${sh(image)} agent ${command}`;
|
|
57
|
+
progress.stage(`Creating identity for node ${name}`);
|
|
58
|
+
const csr = await runCapture('ssh', ['--', sshTarget, agent('identity')]);
|
|
59
|
+
const response = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode, address: o.address, labels: o.label });
|
|
60
|
+
await runCapture('ssh', ['--', sshTarget, agent('install-cert', true)], {
|
|
61
|
+
input: JSON.stringify(response),
|
|
62
|
+
});
|
|
63
|
+
const env = await loadTemplate(templatesDir, 'agent.env', {
|
|
64
|
+
CONTROL_URL_LINE: connectUrl ? `TUG_CONTROL_URL=${connectUrl}\n` : '',
|
|
65
|
+
NODE_NAME: name,
|
|
66
|
+
});
|
|
67
|
+
progress.stage(`Installing agent on node ${name}`);
|
|
68
|
+
const remote = await loadTemplate(templatesDir, 'node-install.sh', {
|
|
69
|
+
ENV: env,
|
|
70
|
+
IMAGE_SH: sh(image),
|
|
71
|
+
RUN_FLAGS: `${HOST_NETWORK_FLAG} `,
|
|
72
|
+
});
|
|
73
|
+
await runLocal('ssh', ['--', sshTarget, remote]);
|
|
74
|
+
if (o.ingress)
|
|
75
|
+
await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, { enabled: true });
|
|
76
|
+
const result = { ok: true, node: response.node, ...(o.ingress ? { ingress: true } : {}) };
|
|
77
|
+
let reporting = !o.wait;
|
|
78
|
+
if (o.wait) {
|
|
79
|
+
progress.stage(`Waiting for node ${name} to report`);
|
|
80
|
+
// A report from the new identity: wait for a report after the install began, not a replaced node's stale status.
|
|
81
|
+
for (let i = 0; i < 30; i++) {
|
|
82
|
+
const { nodes } = await request('GET', '/api/v1/nodes');
|
|
83
|
+
const seen = nodes.find((n) => n.name === name)?.last_seen_at;
|
|
84
|
+
if (seen && seen > started) {
|
|
85
|
+
reporting = true;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
77
89
|
}
|
|
78
|
-
|
|
90
|
+
if (!reporting)
|
|
91
|
+
console.error(`node ${name} has not reported yet; check \`docker logs tug-agent\` on the host`);
|
|
79
92
|
}
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
print(o.wait ? { ...result, reporting } : result);
|
|
85
|
-
return reporting;
|
|
93
|
+
if (printResult)
|
|
94
|
+
print(o.wait ? { ...result, reporting } : result);
|
|
95
|
+
return reporting;
|
|
96
|
+
});
|
|
86
97
|
}
|
|
87
98
|
const realPlanDeps = () => ({
|
|
88
99
|
interactive: canPrompt(),
|
|
@@ -147,11 +158,17 @@ async function promptNodeName(sshTarget) {
|
|
|
147
158
|
async function runNodeIdentity(positional, o, mode, command, repair = false) {
|
|
148
159
|
const cfg = await loadCfg();
|
|
149
160
|
const verb = repair ? 'node repair' : mode === 'create' ? 'node add' : 'node replace';
|
|
150
|
-
if (o.all)
|
|
161
|
+
if (o.select && o.all)
|
|
162
|
+
throw new Error('--select and --all cannot be combined');
|
|
163
|
+
if (o.all || o.select) {
|
|
151
164
|
if (positional.name || positional.ssh)
|
|
152
|
-
throw new Error(`${verb} --all
|
|
165
|
+
throw new Error(`${verb} --all/--select cannot be combined with a node or SSH target`);
|
|
153
166
|
const results = [];
|
|
154
|
-
|
|
167
|
+
const names = o.select
|
|
168
|
+
? await selectNodes(Object.keys(cfg.nodes).filter((name) => cfg.nodes[name].ssh))
|
|
169
|
+
: Object.keys(cfg.nodes);
|
|
170
|
+
for (const name of names) {
|
|
171
|
+
const settings = cfg.nodes[name];
|
|
155
172
|
const target = settings.ssh;
|
|
156
173
|
if (!target)
|
|
157
174
|
continue;
|
|
@@ -171,17 +188,17 @@ async function runNodeIdentity(positional, o, mode, command, repair = false) {
|
|
|
171
188
|
return;
|
|
172
189
|
}
|
|
173
190
|
let name = positional.name ?? (positional.ssh ? inferNodeName(positional.ssh) : undefined);
|
|
191
|
+
if (!name && mode === 'replace')
|
|
192
|
+
name = await selectNode();
|
|
174
193
|
if (!name && mode === 'create')
|
|
175
194
|
name = await promptNodeName(positional.ssh);
|
|
176
195
|
if (!name)
|
|
177
196
|
throw new Error(mode === 'create'
|
|
178
197
|
? `${verb} needs --name${positional.ssh ? ' (an IP address does not suggest one; in a terminal it asks)' : ''}`
|
|
179
198
|
: `${verb} needs the node name`);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
applyConfigDefaults(command, o, nodeSettings(cfg, name));
|
|
184
|
-
}
|
|
199
|
+
// Selection or SSH inference may supply the name after the root hook applied defaults.
|
|
200
|
+
o.name = name;
|
|
201
|
+
applyConfigDefaults(command, o, nodeSettings(cfg, name));
|
|
185
202
|
const sshTarget = positional.ssh ?? nodeSettings(cfg, name).ssh;
|
|
186
203
|
if (!sshTarget)
|
|
187
204
|
throw new Error(`${verb} needs an SSH target: pass <ssh-target> or set nodes.${name}.ssh in ${cfg.file}`);
|
|
@@ -216,7 +233,9 @@ export function registerNode(program) {
|
|
|
216
233
|
]) {
|
|
217
234
|
addNodeIdentityOptions(node
|
|
218
235
|
.command(`${verb} [name] [sshTarget]`)
|
|
219
|
-
.description(`${description} (the SSH target defaults to nodes.<name>.ssh in the config file; --all walks every configured node)`))
|
|
236
|
+
.description(`${description} (the SSH target defaults to nodes.<name>.ssh in the config file; --all walks every configured node)`))
|
|
237
|
+
.option('--select', 'choose registered nodes with configured SSH targets')
|
|
238
|
+
.action(async (name, ssh, o, command) => runNodeIdentity({ name, ssh }, o, 'replace', command, repair));
|
|
220
239
|
}
|
|
221
240
|
node
|
|
222
241
|
.command('list')
|
|
@@ -298,23 +317,70 @@ export function registerNode(program) {
|
|
|
298
317
|
print(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode: o.mode, address: o.address, labels: o.label }));
|
|
299
318
|
});
|
|
300
319
|
node
|
|
301
|
-
.command('address
|
|
320
|
+
.command('address [name] [address]')
|
|
321
|
+
.option('--to <address>', 'new address when choosing a node interactively')
|
|
302
322
|
.description("update a node's inter-node address")
|
|
303
|
-
.action(async (name, address) =>
|
|
323
|
+
.action(async (name, address, o) => {
|
|
324
|
+
if (address && o.to)
|
|
325
|
+
throw new Error('pass the address positionally or with --to, not both');
|
|
326
|
+
address = address || o.to || '';
|
|
327
|
+
if (!address)
|
|
328
|
+
throw new Error('node address needs an address; use --to when selecting a node');
|
|
329
|
+
name = await selectNode(name);
|
|
330
|
+
return print(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/address`, { address }));
|
|
331
|
+
});
|
|
304
332
|
node
|
|
305
|
-
.command('disable
|
|
333
|
+
.command('disable [name]')
|
|
306
334
|
.description("block a node's key immediately without deleting its metadata or placement (no SSH needed); bring it back with node replace")
|
|
307
|
-
.action(async (name) => print(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/disable`, {})));
|
|
335
|
+
.action(async (name) => print(await request('POST', `/api/v1/nodes/${encodeURIComponent(await selectNode(name))}/disable`, {})));
|
|
308
336
|
node
|
|
309
|
-
.command('remove
|
|
310
|
-
.description('remove a retired node from the control plane (its identity, statuses and queued commands); stop its agent and
|
|
337
|
+
.command('remove [name] [sshTarget]')
|
|
338
|
+
.description('remove a retired node from the control plane (its identity, statuses and queued commands); with --teardown also stop its agent and delete its containers and agent state over SSH')
|
|
311
339
|
.option('--force', 'remove even if the node is still reporting')
|
|
312
|
-
.
|
|
340
|
+
.option('--teardown', 'also clean the host over SSH (the SSH target defaults to nodes.<name>.ssh in the config file)')
|
|
341
|
+
.option('--delete-data', 'with --teardown, also delete project volumes and ingress certificates')
|
|
342
|
+
.option('-y, --yes', 'with --teardown, do not ask for confirmation')
|
|
343
|
+
.action(async (name, ssh, o) => {
|
|
344
|
+
if (!o.teardown) {
|
|
345
|
+
if (ssh || o.deleteData || o.yes)
|
|
346
|
+
throw new Error('an SSH target, --delete-data and --yes need --teardown');
|
|
347
|
+
name = await selectNode(name);
|
|
348
|
+
return print(await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}${o.force ? '?force=true' : ''}`));
|
|
349
|
+
}
|
|
350
|
+
name = await selectNode(name);
|
|
351
|
+
const cfg = await loadCfg();
|
|
352
|
+
const sshTarget = ssh ?? nodeSettings(cfg, name).ssh;
|
|
353
|
+
if (!sshTarget)
|
|
354
|
+
throw new Error(`--teardown needs an SSH target: pass <ssh-target> or set nodes.${name}.ssh in ${cfg.file}`);
|
|
355
|
+
if (!o.yes) {
|
|
356
|
+
if (!canPrompt())
|
|
357
|
+
throw new Error('--teardown deletes data on the host; pass -y to confirm without a terminal');
|
|
358
|
+
console.error(`This will stop the Tug agent on ${sshTarget}, remove its Tug containers and networks and delete /etc/tug and /var/lib/tug${o.deleteData
|
|
359
|
+
? ', and DELETE every project volume and the ingress certificates'
|
|
360
|
+
: ' (project volumes are kept)'}.`);
|
|
361
|
+
if (!(await confirm('Continue?', false)))
|
|
362
|
+
throw new Error('cancelled');
|
|
363
|
+
}
|
|
364
|
+
// The control record goes first: it refuses a node that is still placed in a current deployment before any
|
|
365
|
+
// host is touched. A node control no longer knows is cleaned anyway, so a failed cleanup can be retried.
|
|
366
|
+
let removed = { ok: true, node: name, alreadyRemoved: true };
|
|
367
|
+
try {
|
|
368
|
+
removed = await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}?force=true`);
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
if (!/node not found/.test(errMessage(error)))
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
await withProgress(`Cleaning node host ${sshTarget}`, async () => {
|
|
375
|
+
await runLocal('ssh', ['--', sshTarget, teardownScript(Boolean(o.deleteData))]);
|
|
376
|
+
});
|
|
377
|
+
print({ ...removed, teardown: sshTarget, deleteData: Boolean(o.deleteData) });
|
|
378
|
+
});
|
|
313
379
|
node
|
|
314
|
-
.command('ingress
|
|
380
|
+
.command('ingress [name]')
|
|
315
381
|
.description('mark a node as an ingress node (managed Caddy on ports 80/443), or remove the role with --disable')
|
|
316
382
|
.option('--disable', 'stop acting as an ingress node')
|
|
317
|
-
.action(async (name, o) => print(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, {
|
|
383
|
+
.action(async (name, o) => print(await request('POST', `/api/v1/nodes/${encodeURIComponent(await selectNode(name))}/ingress`, {
|
|
318
384
|
enabled: !o.disable,
|
|
319
385
|
})));
|
|
320
386
|
}
|
package/dist/commands/observe.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectNode } from "./selection.js";
|
|
1
2
|
import { errMessage } from "../util.js";
|
|
2
3
|
import { formatStatus } from "../cli-status.js";
|
|
3
4
|
import { loadCfg, print, request, runCapture, waitForCommand } from "./context.js";
|
|
@@ -27,9 +28,13 @@ export function registerObserve(program) {
|
|
|
27
28
|
.command('doctor')
|
|
28
29
|
.description('check this machine and the control plane: certificate expiry, node connectivity and clock skew')
|
|
29
30
|
.option('--nodes', 'also run host diagnostics on every node through its agent and wait')
|
|
30
|
-
.option('--node
|
|
31
|
+
.option('--node [name]', 'run host diagnostics on one node through its agent and wait')
|
|
31
32
|
.option('--timeout <seconds>', 'wait timeout for node diagnostics', '60')
|
|
32
33
|
.action(async (o) => {
|
|
34
|
+
if (o.nodes && o.node)
|
|
35
|
+
throw new Error('--nodes and --node cannot be combined');
|
|
36
|
+
if (o.node === true)
|
|
37
|
+
o.node = await selectNode();
|
|
33
38
|
const local = [];
|
|
34
39
|
for (const [name, cmd, args] of [
|
|
35
40
|
['node', 'node', ['--version']],
|
|
@@ -37,7 +42,7 @@ export function registerObserve(program) {
|
|
|
37
42
|
['docker_compose', 'docker', ['compose', 'version']],
|
|
38
43
|
]) {
|
|
39
44
|
try {
|
|
40
|
-
local.push({ name, ok: true, output: runCapture(cmd, args).trim() });
|
|
45
|
+
local.push({ name, ok: true, output: (await runCapture(cmd, args)).trim() });
|
|
41
46
|
}
|
|
42
47
|
catch (e) {
|
|
43
48
|
local.push({ name, ok: false, error: errMessage(e) });
|
package/dist/commands/restore.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import { selectMany } from "../cli-selection.js";
|
|
2
|
+
import { selectBackup, selectRestore, selectSnapshot, serviceChoices } from "./selection.js";
|
|
3
|
+
import { withProgress } from "../cli-progress.js";
|
|
1
4
|
import { collect, loadCfg, request } from "./context.js";
|
|
2
5
|
export function registerRestore(program) {
|
|
3
6
|
const restore = program
|
|
4
7
|
.command('restore')
|
|
5
8
|
.description('restore services and volumes from backups');
|
|
6
9
|
restore
|
|
7
|
-
.command('run
|
|
10
|
+
.command('run [target]')
|
|
8
11
|
.description('restore a service or volume from a backup. Without --confirm it only prints the plan. An in-place restore locks the project, stops the services that write to the target, takes a safety backup, restores, then starts them again')
|
|
12
|
+
.option('--select-snapshot', 'choose a successful backup from the catalog')
|
|
13
|
+
.option('--select-writers', 'choose services to stop instead of discovering writers')
|
|
9
14
|
.option('--from <id>', 'backup run id, restic snapshot id, or latest', 'latest')
|
|
10
15
|
.option('--scratch', 'restore a volume into a new volume next to the live one, touching nothing live')
|
|
11
16
|
.option('--no-safety-backup', 'skip the backup of the current data taken before an in-place restore')
|
|
@@ -13,55 +18,71 @@ export function registerRestore(program) {
|
|
|
13
18
|
.option('--no-writers', 'restore without stopping any service')
|
|
14
19
|
.option('--confirm', 'perform the restore')
|
|
15
20
|
.option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
|
|
16
|
-
.action(async (target, o) => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
console.error(`warning: ${record.error}`);
|
|
49
|
-
console.log(`restore ${record.id} completed${record.scratch_volume ? `; files are in volume ${record.scratch_volume}` : ''}`);
|
|
50
|
-
return;
|
|
21
|
+
.action(async (target, o, command) => {
|
|
22
|
+
if (o.selectSnapshot && command.getOptionValueSource('from') === 'cli')
|
|
23
|
+
throw new Error('--select-snapshot cannot be combined with --from');
|
|
24
|
+
if (o.selectWriters && (o.writers === false || o.writers.length))
|
|
25
|
+
throw new Error('--select-writers cannot be combined with --writers or --no-writers');
|
|
26
|
+
if (o.scratch && o.selectWriters)
|
|
27
|
+
throw new Error('--select-writers does not apply to scratch restores');
|
|
28
|
+
target = await selectBackup(target);
|
|
29
|
+
if (o.selectSnapshot)
|
|
30
|
+
o.from = await selectSnapshot(target);
|
|
31
|
+
if (o.selectWriters)
|
|
32
|
+
o.writers = await selectMany('Select services to stop for the restore', serviceChoices);
|
|
33
|
+
return withProgress(`Preparing restore for ${target}`, async (progress) => {
|
|
34
|
+
const project = (await loadCfg()).project || 'site';
|
|
35
|
+
const explicit = Array.isArray(o.writers) && o.writers.length > 0 ? o.writers : undefined;
|
|
36
|
+
const result = await request('POST', '/api/v1/restores', {
|
|
37
|
+
project,
|
|
38
|
+
target,
|
|
39
|
+
from: o.from,
|
|
40
|
+
scratch: o.scratch === true,
|
|
41
|
+
safetyBackup: o.safetyBackup,
|
|
42
|
+
writers: explicit,
|
|
43
|
+
noWriters: o.writers === false,
|
|
44
|
+
confirm: o.confirm === true,
|
|
45
|
+
});
|
|
46
|
+
const plan = result.plan;
|
|
47
|
+
console.log(`target: ${plan.target} on ${plan.node}`);
|
|
48
|
+
console.log(`snapshot: ${plan.snapshotId}${plan.snapshotTime ? ` (${plan.snapshotTime})` : ''}`);
|
|
49
|
+
console.log(`mode: ${plan.mode}${plan.scratchVolume ? ` into new volume ${plan.scratchVolume}` : ''}`);
|
|
50
|
+
if (plan.mode === 'in-place') {
|
|
51
|
+
console.log(`stops: ${plan.writers.length ? plan.writers.join(', ') : 'nothing'}`);
|
|
52
|
+
console.log(`safety: ${plan.safetyBackup ? 'a backup of the current data is taken first' : 'none (--no-safety-backup)'}`);
|
|
51
53
|
}
|
|
52
|
-
if (
|
|
53
|
-
console.
|
|
54
|
-
if (record.state === 'interrupted')
|
|
55
|
-
console.error(`the services stay stopped; run tug restore resume ${record.id} to try again or tug restore release ${record.id} to start them (data may be partly restored)`);
|
|
56
|
-
process.exitCode = 1;
|
|
54
|
+
if (!result.confirmed) {
|
|
55
|
+
console.log('\nNothing was changed. Run again with --confirm to restore.');
|
|
57
56
|
return;
|
|
58
57
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
progress.stage(`Restore ${result.restoreId}: queued`);
|
|
59
|
+
const deadline = Date.now() + Number(o.timeout) * 1000;
|
|
60
|
+
for (;;) {
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
62
|
+
const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
|
|
63
|
+
const record = restores.find((item) => item.id === result.restoreId);
|
|
64
|
+
if (record)
|
|
65
|
+
progress.stage(`Restore ${record.id}: ${record.state}`);
|
|
66
|
+
if (record?.state === 'succeeded') {
|
|
67
|
+
if (record.error)
|
|
68
|
+
console.error(`warning: ${record.error}`);
|
|
69
|
+
console.log(`restore ${record.id} completed${record.scratch_volume ? `; files are in volume ${record.scratch_volume}` : ''}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (record && ['failed', 'interrupted', 'released'].includes(record.state)) {
|
|
73
|
+
console.error(`restore ${record.id} ${record.state}: ${record.error ?? ''}`);
|
|
74
|
+
if (record.state === 'interrupted')
|
|
75
|
+
console.error(`the services stay stopped; run tug restore resume ${record.id} to try again or tug restore release ${record.id} to start them (data may be partly restored)`);
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (Date.now() > deadline) {
|
|
80
|
+
console.error(`timed out waiting; restore ${result.restoreId} continues on control (tug restore list)`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
63
84
|
}
|
|
64
|
-
}
|
|
85
|
+
});
|
|
65
86
|
});
|
|
66
87
|
restore
|
|
67
88
|
.command('list')
|
|
@@ -75,11 +96,11 @@ export function registerRestore(program) {
|
|
|
75
96
|
console.log(`${r.started_at}\t${r.id}\t${r.target}\t${r.mode}\t${r.state}\t${r.snapshot_id.slice(0, 8)}${r.error ? `\t${r.error}` : ''}`);
|
|
76
97
|
});
|
|
77
98
|
restore
|
|
78
|
-
.command('resume
|
|
99
|
+
.command('resume [id]')
|
|
79
100
|
.description('run an interrupted restore again; the stopped services stay stopped meanwhile')
|
|
80
|
-
.action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/resume`))));
|
|
101
|
+
.action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(await selectRestore(id))}/resume`))));
|
|
81
102
|
restore
|
|
82
|
-
.command('release
|
|
103
|
+
.command('release [id]')
|
|
83
104
|
.description('start the services an interrupted restore stopped and unlock the project; the data may be partly restored')
|
|
84
|
-
.action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/release`))));
|
|
105
|
+
.action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(await selectRestore(id))}/release`))));
|
|
85
106
|
}
|
package/dist/commands/secret.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectResource } from "./selection.js";
|
|
1
2
|
import { print, projectOf, request } from "./context.js";
|
|
2
3
|
export function registerSecret(program) {
|
|
3
4
|
const secret = program
|
|
@@ -11,7 +12,11 @@ export function registerSecret(program) {
|
|
|
11
12
|
const value = o.value ??
|
|
12
13
|
(await new Promise((resolve) => {
|
|
13
14
|
process.stdout.write(`${name}: `);
|
|
14
|
-
process.stdin.once('data', (d) =>
|
|
15
|
+
process.stdin.once('data', (d) => {
|
|
16
|
+
// This command needs no more input; an open stdin can otherwise keep Node alive.
|
|
17
|
+
process.stdin.destroy();
|
|
18
|
+
resolve(String(d).trim());
|
|
19
|
+
});
|
|
15
20
|
}));
|
|
16
21
|
print(await request('POST', '/api/v1/secrets', { project: await projectOf(o), name, value }));
|
|
17
22
|
});
|
|
@@ -20,7 +25,7 @@ export function registerSecret(program) {
|
|
|
20
25
|
.description('list secret names and versions, never values')
|
|
21
26
|
.action(async (o) => print(await request('GET', `/api/v1/secrets?project=${encodeURIComponent(await projectOf(o))}`)));
|
|
22
27
|
secret
|
|
23
|
-
.command('remove
|
|
28
|
+
.command('remove [name]')
|
|
24
29
|
.description('delete a secret')
|
|
25
|
-
.action(async (name, o) => print(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(await projectOf(o))}`)));
|
|
30
|
+
.action(async (name, o) => print(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(await selectResource(name, 'secrets'))}?project=${encodeURIComponent(await projectOf(o))}`)));
|
|
26
31
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Choice } from '../cli-selection.ts';
|
|
2
|
+
export declare const projectQuery: () => Promise<string>;
|
|
3
|
+
export interface ServiceChoice {
|
|
4
|
+
name: string;
|
|
5
|
+
nodes: {
|
|
6
|
+
name: string;
|
|
7
|
+
status: string;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
export declare function services(): Promise<ServiceChoice[]>;
|
|
11
|
+
export declare const serviceChoices: () => Promise<Choice[]>;
|
|
12
|
+
export declare const selectService: (name?: string) => Promise<string>;
|
|
13
|
+
export declare const selectNode: (name?: string) => Promise<string>;
|
|
14
|
+
export declare const selectResource: (name: string | undefined, resource: "secrets" | "volumes" | "credentials") => Promise<string>;
|
|
15
|
+
export declare const selectDeployment: (id?: string) => Promise<string>;
|
|
16
|
+
export declare const selectRestore: (id?: string) => Promise<string>;
|
|
17
|
+
export declare const backupChoices: () => Promise<Choice[]>;
|
|
18
|
+
export declare const selectBackup: (target?: string) => Promise<string>;
|
|
19
|
+
export declare const selectBackups: () => Promise<string[]>;
|
|
20
|
+
export declare const selectSnapshot: (target: string) => Promise<string>;
|
|
21
|
+
export declare const selectHolds: () => Promise<string[]>;
|
|
22
|
+
export declare const selectNodes: (configured: string[]) => Promise<string[]>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { selectMany, selectOne } from "../cli-selection.js";
|
|
2
|
+
import { projectOf, request } from "./context.js";
|
|
3
|
+
export const projectQuery = async () => `project=${encodeURIComponent(await projectOf({}))}`;
|
|
4
|
+
export async function services() {
|
|
5
|
+
return (await request('GET', `/api/v1/services?${await projectQuery()}`)).services;
|
|
6
|
+
}
|
|
7
|
+
export const serviceChoices = async () => (await services()).map((service) => ({
|
|
8
|
+
value: service.name,
|
|
9
|
+
label: `${service.name} (${service.nodes.map((node) => `${node.name}: ${node.status}`).join(', ') || 'no nodes'})`,
|
|
10
|
+
}));
|
|
11
|
+
export const selectService = (name) => selectOne(name, 'Select a service', serviceChoices);
|
|
12
|
+
export const selectNode = (name) => selectOne(name, 'Select a node', async () => {
|
|
13
|
+
const { nodes } = await request('GET', '/api/v1/nodes');
|
|
14
|
+
return nodes.map((node) => ({
|
|
15
|
+
value: node.name,
|
|
16
|
+
label: `${node.name} (${node.disabled_at ? 'disabled' : node.status}, ${node.address || 'no address'}${node.ingress ? ', ingress' : ''}${node.labels.length ? `, ${node.labels.join(', ')}` : ''})`,
|
|
17
|
+
}));
|
|
18
|
+
});
|
|
19
|
+
export const selectResource = (name, resource) => selectOne(name, `Select a ${resource.slice(0, -1)}`, async () => {
|
|
20
|
+
const listing = await request('GET', `/api/v1/${resource}${resource === 'credentials' ? '' : `?${await projectQuery()}`}`);
|
|
21
|
+
return listing[resource]
|
|
22
|
+
.filter((row) => !row.revokedAt && !row.usedBy?.length)
|
|
23
|
+
.map((row) => {
|
|
24
|
+
const details = [
|
|
25
|
+
row.version !== undefined ? `version ${row.version}` : '',
|
|
26
|
+
row.node || row.node_id,
|
|
27
|
+
row.type,
|
|
28
|
+
row.persistent ? 'persistent; needs --delete-data' : '',
|
|
29
|
+
row.role,
|
|
30
|
+
row.lastUsedAt ? `last used ${row.lastUsedAt}` : '',
|
|
31
|
+
row.projects === null ? 'all projects' : row.projects?.join(', '),
|
|
32
|
+
row.expiresAt ? `expires ${row.expiresAt}` : '',
|
|
33
|
+
]
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join(', ');
|
|
36
|
+
return { value: row.name, label: details ? `${row.name} (${details})` : row.name };
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
export const selectDeployment = (id) => selectOne(id, 'Select an active deployment to cancel', async () => {
|
|
40
|
+
const { deployments } = await request('GET', `/api/v1/deployments?${await projectQuery()}`);
|
|
41
|
+
return deployments.map((row) => ({
|
|
42
|
+
value: row.id,
|
|
43
|
+
label: `${row.version} (${row.state}, ${row.created_at}, ${row.id})`,
|
|
44
|
+
}));
|
|
45
|
+
});
|
|
46
|
+
export const selectRestore = (id) => selectOne(id, 'Select an interrupted restore', async () => {
|
|
47
|
+
const { restores } = await request('GET', `/api/v1/restores?${await projectQuery()}&state=interrupted`);
|
|
48
|
+
return restores
|
|
49
|
+
.filter((row) => row.state === 'interrupted')
|
|
50
|
+
.map((row) => ({
|
|
51
|
+
value: row.id,
|
|
52
|
+
label: `${row.target} (${row.started_at}, snapshot ${row.snapshot_id}, ${row.id}${row.error ? `, ${row.error}` : ''})`,
|
|
53
|
+
}));
|
|
54
|
+
});
|
|
55
|
+
export const backupChoices = async () => {
|
|
56
|
+
const { targets } = await request('GET', `/api/v1/backups?${await projectQuery()}`);
|
|
57
|
+
return targets
|
|
58
|
+
.filter((row) => row.enabled)
|
|
59
|
+
.map((row) => ({
|
|
60
|
+
value: row.target,
|
|
61
|
+
label: `${row.target} (${row.node || 'no node'}, last success ${row.lastSuccessAt || 'never'})`,
|
|
62
|
+
}));
|
|
63
|
+
};
|
|
64
|
+
export const selectBackup = (target) => selectOne(target, 'Select a backup target', backupChoices);
|
|
65
|
+
export const selectBackups = () => selectMany('Select backup targets', backupChoices);
|
|
66
|
+
export const selectSnapshot = (target) => selectOne(undefined, 'Select a backup snapshot', async () => {
|
|
67
|
+
// Resolve short target names the same way as the restore endpoint, but reject ambiguity.
|
|
68
|
+
const targets = await backupChoices();
|
|
69
|
+
const matching = targets.filter((row) => row.value === target || row.value.split(':').slice(1).join(':') === target);
|
|
70
|
+
if (matching.length !== 1)
|
|
71
|
+
throw new Error(`use a full backup target such as service:${target} or volume:${target}`);
|
|
72
|
+
const { snapshots } = await request('GET', `/api/v1/backup/snapshots?${await projectQuery()}&target=${encodeURIComponent(matching[0].value)}`);
|
|
73
|
+
return snapshots.map((row) => ({
|
|
74
|
+
value: row.id,
|
|
75
|
+
label: `${row.finished_at} (${row.snapshot_id}, ${row.size_bytes ?? '?'} bytes, ${row.trigger}, ${row.id})`,
|
|
76
|
+
}));
|
|
77
|
+
});
|
|
78
|
+
export const selectHolds = () => selectMany('Select move holds to release', async () => {
|
|
79
|
+
const { holds } = await request('GET', `/api/v1/holds?${await projectQuery()}`);
|
|
80
|
+
return holds.map((row) => ({
|
|
81
|
+
value: row.service,
|
|
82
|
+
label: `${row.service} (${row.reason}, ${row.created_at})`,
|
|
83
|
+
}));
|
|
84
|
+
});
|
|
85
|
+
export const selectNodes = (configured) => selectMany('Select nodes to replace or repair', async () => {
|
|
86
|
+
const { nodes } = await request('GET', '/api/v1/nodes');
|
|
87
|
+
return nodes
|
|
88
|
+
.filter((node) => configured.includes(node.name))
|
|
89
|
+
.map((node) => ({ value: node.name, label: `${node.name} (${node.status})` }));
|
|
90
|
+
});
|