@wpelevator/tug 0.10.0 → 0.11.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 +93 -34
- package/dist/agent-credential.js +3 -3
- package/dist/agent.js +1 -1
- package/dist/backup-check.d.ts +1 -1
- package/dist/backup-config.js +32 -24
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/backup-restore.js +65 -65
- package/dist/backup-scheduler.js +1 -1
- package/dist/cli-build.js +1 -1
- package/dist/cli-config.d.ts +6 -1
- package/dist/cli-config.js +18 -44
- package/dist/cli-install.d.ts +4 -2
- package/dist/cli-install.js +12 -7
- package/dist/cli-live.js +4 -6
- package/dist/cli-onboarding.d.ts +31 -0
- package/dist/cli-onboarding.js +66 -0
- package/dist/cli-prompt.d.ts +9 -0
- package/dist/cli-prompt.js +60 -0
- package/dist/cli-secrets.d.ts +12 -0
- package/dist/cli-secrets.js +71 -0
- package/dist/cli-status.d.ts +11 -0
- package/dist/cli-status.js +37 -7
- package/dist/cli.js +62 -981
- package/dist/commands/backup.d.ts +2 -0
- package/dist/commands/backup.js +109 -0
- package/dist/commands/context.d.ts +54 -0
- package/dist/commands/context.js +96 -0
- package/dist/commands/control.d.ts +2 -0
- package/dist/commands/control.js +110 -0
- package/dist/commands/credential.d.ts +2 -0
- package/dist/commands/credential.js +36 -0
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.js +139 -0
- package/dist/commands/host.d.ts +18 -0
- package/dist/commands/host.js +99 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +40 -0
- package/dist/commands/node.d.ts +28 -0
- package/dist/commands/node.js +320 -0
- package/dist/commands/observe.d.ts +2 -0
- package/dist/commands/observe.js +71 -0
- package/dist/commands/restore.d.ts +2 -0
- package/dist/commands/restore.js +85 -0
- package/dist/commands/secret.d.ts +2 -0
- package/dist/commands/secret.js +26 -0
- package/dist/commands/service.d.ts +2 -0
- package/dist/commands/service.js +62 -0
- package/dist/commands/volume.d.ts +2 -0
- package/dist/commands/volume.js +13 -0
- package/dist/control.js +6 -6
- package/dist/diagnostics.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { collect, loadCfg, request } from "./context.js";
|
|
2
|
+
export function registerRestore(program) {
|
|
3
|
+
const restore = program
|
|
4
|
+
.command('restore')
|
|
5
|
+
.description('restore services and volumes from backups');
|
|
6
|
+
restore
|
|
7
|
+
.command('run <target>')
|
|
8
|
+
.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')
|
|
9
|
+
.option('--from <id>', 'backup run id, restic snapshot id, or latest', 'latest')
|
|
10
|
+
.option('--scratch', 'restore a volume into a new volume next to the live one, touching nothing live')
|
|
11
|
+
.option('--no-safety-backup', 'skip the backup of the current data taken before an in-place restore')
|
|
12
|
+
.option('--writers <service>', 'service to stop during the restore, instead of discovering them (repeatable)', collect, [])
|
|
13
|
+
.option('--no-writers', 'restore without stopping any service')
|
|
14
|
+
.option('--confirm', 'perform the restore')
|
|
15
|
+
.option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
|
|
16
|
+
.action(async (target, o) => {
|
|
17
|
+
const project = (await loadCfg()).project || 'site';
|
|
18
|
+
const explicit = Array.isArray(o.writers) && o.writers.length > 0 ? o.writers : undefined;
|
|
19
|
+
const result = await request('POST', '/api/v1/restores', {
|
|
20
|
+
project,
|
|
21
|
+
target,
|
|
22
|
+
from: o.from,
|
|
23
|
+
scratch: o.scratch === true,
|
|
24
|
+
safetyBackup: o.safetyBackup,
|
|
25
|
+
writers: explicit,
|
|
26
|
+
noWriters: o.writers === false,
|
|
27
|
+
confirm: o.confirm === true,
|
|
28
|
+
});
|
|
29
|
+
const plan = result.plan;
|
|
30
|
+
console.log(`target: ${plan.target} on ${plan.node}`);
|
|
31
|
+
console.log(`snapshot: ${plan.snapshotId}${plan.snapshotTime ? ` (${plan.snapshotTime})` : ''}`);
|
|
32
|
+
console.log(`mode: ${plan.mode}${plan.scratchVolume ? ` into new volume ${plan.scratchVolume}` : ''}`);
|
|
33
|
+
if (plan.mode === 'in-place') {
|
|
34
|
+
console.log(`stops: ${plan.writers.length ? plan.writers.join(', ') : 'nothing'}`);
|
|
35
|
+
console.log(`safety: ${plan.safetyBackup ? 'a backup of the current data is taken first' : 'none (--no-safety-backup)'}`);
|
|
36
|
+
}
|
|
37
|
+
if (!result.confirmed) {
|
|
38
|
+
console.log('\nNothing was changed. Run again with --confirm to restore.');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const deadline = Date.now() + Number(o.timeout) * 1000;
|
|
42
|
+
for (;;) {
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
44
|
+
const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
|
|
45
|
+
const record = restores.find((item) => item.id === result.restoreId);
|
|
46
|
+
if (record?.state === 'succeeded') {
|
|
47
|
+
if (record.error)
|
|
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;
|
|
51
|
+
}
|
|
52
|
+
if (record && ['failed', 'interrupted', 'released'].includes(record.state)) {
|
|
53
|
+
console.error(`restore ${record.id} ${record.state}: ${record.error ?? ''}`);
|
|
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;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (Date.now() > deadline) {
|
|
60
|
+
console.error(`timed out waiting; restore ${result.restoreId} continues on control (tug restore list)`);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
restore
|
|
67
|
+
.command('list')
|
|
68
|
+
.description('show recent restores and their state')
|
|
69
|
+
.action(async () => {
|
|
70
|
+
const project = (await loadCfg()).project || 'site';
|
|
71
|
+
const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
|
|
72
|
+
if (!restores.length)
|
|
73
|
+
console.log('no restores');
|
|
74
|
+
for (const r of restores)
|
|
75
|
+
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
|
+
});
|
|
77
|
+
restore
|
|
78
|
+
.command('resume <id>')
|
|
79
|
+
.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`))));
|
|
81
|
+
restore
|
|
82
|
+
.command('release <id>')
|
|
83
|
+
.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`))));
|
|
85
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { print, projectOf, request } from "./context.js";
|
|
2
|
+
export function registerSecret(program) {
|
|
3
|
+
const secret = program
|
|
4
|
+
.command('secret')
|
|
5
|
+
.description('manage the project secrets that Compose files reference');
|
|
6
|
+
secret
|
|
7
|
+
.command('set <name>')
|
|
8
|
+
.description('set a secret, reading the value from stdin unless --value is given')
|
|
9
|
+
.option('--value <value>')
|
|
10
|
+
.action(async (name, o) => {
|
|
11
|
+
const value = o.value ??
|
|
12
|
+
(await new Promise((resolve) => {
|
|
13
|
+
process.stdout.write(`${name}: `);
|
|
14
|
+
process.stdin.once('data', (d) => resolve(String(d).trim()));
|
|
15
|
+
}));
|
|
16
|
+
print(await request('POST', '/api/v1/secrets', { project: await projectOf(o), name, value }));
|
|
17
|
+
});
|
|
18
|
+
secret
|
|
19
|
+
.command('list')
|
|
20
|
+
.description('list secret names and versions, never values')
|
|
21
|
+
.action(async (o) => print(await request('GET', `/api/v1/secrets?project=${encodeURIComponent(await projectOf(o))}`)));
|
|
22
|
+
secret
|
|
23
|
+
.command('remove <name>')
|
|
24
|
+
.description('delete a secret')
|
|
25
|
+
.action(async (name, o) => print(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(await projectOf(o))}`)));
|
|
26
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { projectOf, request, runLive, waitForCommand } from "./context.js";
|
|
2
|
+
export function registerService(program) {
|
|
3
|
+
program
|
|
4
|
+
.command('exec <service> [command...]')
|
|
5
|
+
.description('execute a command on a service container (default: sh)')
|
|
6
|
+
.option('-i, --interactive', 'forward stdin to the remote command')
|
|
7
|
+
.option('-t, --tty', 'allocate a terminal (requires a local terminal and agent TTY support)')
|
|
8
|
+
.option('--node <name>', 'select the node when a service has multiple placements')
|
|
9
|
+
.action(async (service, command, o) => {
|
|
10
|
+
await runLive('exec', { service, command, ...o });
|
|
11
|
+
});
|
|
12
|
+
program
|
|
13
|
+
.command('run <service> [command...]')
|
|
14
|
+
.description("run a one-off command in a new container from the service: same image, environment, secrets, network and mounts, removed afterwards (for example: tug run web -- wp core update-db). Exits with the command's exit code")
|
|
15
|
+
.option('--node <name>', 'select the node when a service has multiple placements')
|
|
16
|
+
.action(async (service, command, o) => {
|
|
17
|
+
if (!command.length)
|
|
18
|
+
throw new Error('run needs a command, such as: tug run web -- wp core update-db');
|
|
19
|
+
await runLive('exec', { service, command, node: o.node, oneOff: true });
|
|
20
|
+
});
|
|
21
|
+
program
|
|
22
|
+
.command('ps')
|
|
23
|
+
.description('print per-container state as JSON')
|
|
24
|
+
.option('--node <name>', 'select one node')
|
|
25
|
+
.action(async (o) => {
|
|
26
|
+
await runLive('ps', o);
|
|
27
|
+
});
|
|
28
|
+
program
|
|
29
|
+
.command('inspect <service>')
|
|
30
|
+
.description('print container details as JSON')
|
|
31
|
+
.option('--node <name>', 'select one node')
|
|
32
|
+
.action(async (service, o) => {
|
|
33
|
+
await runLive('inspect', { service, ...o });
|
|
34
|
+
});
|
|
35
|
+
program
|
|
36
|
+
.command('logs <service>')
|
|
37
|
+
.description('fetch service container logs from the node that runs the service')
|
|
38
|
+
.option('--tail <n>', 'number of lines', '200')
|
|
39
|
+
.option('-f, --follow', 'follow live logs')
|
|
40
|
+
.option('--node <name>', 'select a node for followed logs')
|
|
41
|
+
.action(async (svc, o) => {
|
|
42
|
+
if (o.follow) {
|
|
43
|
+
await runLive('logs', { service: svc, node: o.node, tail: o.tail });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (o.node)
|
|
47
|
+
throw new Error('--node requires --follow for logs');
|
|
48
|
+
const created = await request('POST', '/api/v1/logs', {
|
|
49
|
+
project: await projectOf(o),
|
|
50
|
+
service: svc,
|
|
51
|
+
tail: Number(o.tail),
|
|
52
|
+
});
|
|
53
|
+
const cmd = await waitForCommand(created.commandId, 60);
|
|
54
|
+
const result = JSON.parse(cmd.result_json || '{}');
|
|
55
|
+
if (result.stdout)
|
|
56
|
+
process.stdout.write(result.stdout);
|
|
57
|
+
if (result.stderr)
|
|
58
|
+
process.stderr.write(result.stderr);
|
|
59
|
+
if (result.error)
|
|
60
|
+
throw new Error(result.error);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { print, projectOf, request } from "./context.js";
|
|
2
|
+
export function registerVolume(program) {
|
|
3
|
+
const volume = program.command('volume').description("list and remove a project's volumes");
|
|
4
|
+
volume
|
|
5
|
+
.command('list')
|
|
6
|
+
.description('list the volumes of the project and where they live')
|
|
7
|
+
.action(async (o) => print(await request('GET', `/api/v1/volumes?project=${encodeURIComponent(await projectOf(o))}`)));
|
|
8
|
+
volume
|
|
9
|
+
.command('remove <name>')
|
|
10
|
+
.description('remove a volume from the project')
|
|
11
|
+
.option('--delete-data', 'delete underlying Docker volume data')
|
|
12
|
+
.action(async (name, o) => print(await request('DELETE', `/api/v1/volumes/${encodeURIComponent(name)}?project=${encodeURIComponent(await projectOf(o))}&deleteData=${Boolean(o.deleteData)}`)));
|
|
13
|
+
}
|
package/dist/control.js
CHANGED
|
@@ -1008,7 +1008,7 @@ export async function createApp(options = {}) {
|
|
|
1008
1008
|
return res.status(400).json({ error: "mode must be 'create' or 'replace'" });
|
|
1009
1009
|
try {
|
|
1010
1010
|
// The certificate names the immutable node ID: the existing node's, or a fresh one for a new name. A name freed by
|
|
1011
|
-
// node
|
|
1011
|
+
// `tug node remove` therefore never inherits the old ID, and certificates for the removed node cannot match the new one.
|
|
1012
1012
|
let nodeId = db.get('SELECT * FROM nodes WHERE name=?', name)?.id ?? id('node');
|
|
1013
1013
|
let signed = await pki.signClientCsr(body.csr, identityUri('node', nodeId), NODE_LIFETIME_MS);
|
|
1014
1014
|
// Signing is asynchronous: re-read, and re-sign once if a concurrent request created the node meanwhile.
|
|
@@ -1024,7 +1024,7 @@ export async function createApp(options = {}) {
|
|
|
1024
1024
|
if (existing) {
|
|
1025
1025
|
if (existing.key_hash !== signed.keyHash)
|
|
1026
1026
|
return res.status(409).json({
|
|
1027
|
-
error: `node ${name} already exists; use node
|
|
1027
|
+
error: `node ${name} already exists; use tug node replace to install a new key`,
|
|
1028
1028
|
});
|
|
1029
1029
|
}
|
|
1030
1030
|
else {
|
|
@@ -1137,7 +1137,7 @@ export async function createApp(options = {}) {
|
|
|
1137
1137
|
const signed = await pki.signClientCsr(req.body?.csr, identityUri('node', node.id), NODE_LIFETIME_MS);
|
|
1138
1138
|
if (signed.keyHash !== node.key_hash)
|
|
1139
1139
|
return res.status(403).json({
|
|
1140
|
-
error: 'renewal requires a CSR for the node key; use node
|
|
1140
|
+
error: 'renewal requires a CSR for the node key; use tug node replace to change keys',
|
|
1141
1141
|
});
|
|
1142
1142
|
const last = node.last_renewed_at ? Date.parse(node.last_renewed_at) : 0;
|
|
1143
1143
|
if (Date.now() - last < NODE_RENEW_MIN_INTERVAL_MS())
|
|
@@ -1150,7 +1150,7 @@ export async function createApp(options = {}) {
|
|
|
1150
1150
|
sendError(res, e);
|
|
1151
1151
|
}
|
|
1152
1152
|
});
|
|
1153
|
-
// Blocks a node's key without deleting its metadata or changing placement. It returns only through node
|
|
1153
|
+
// Blocks a node's key without deleting its metadata or changing placement. It returns only through `tug node replace`.
|
|
1154
1154
|
api('post', '/api/v1/nodes/:name/disable', null, async (req, res) => {
|
|
1155
1155
|
const node = findNode(String(req.params.name));
|
|
1156
1156
|
if (!node)
|
|
@@ -1631,7 +1631,7 @@ export async function createApp(options = {}) {
|
|
|
1631
1631
|
.map((row) => row.name));
|
|
1632
1632
|
const missing = referenced.filter((name) => !known.has(name));
|
|
1633
1633
|
if (missing.length)
|
|
1634
|
-
throw httpError(400, `secrets not found in project ${project}: ${missing.join(', ')}; create them with tug secret
|
|
1634
|
+
throw httpError(400, `secrets not found in project ${project}: ${missing.join(', ')}; create them with tug secret set`);
|
|
1635
1635
|
}
|
|
1636
1636
|
const builtServices = Object.entries(compose.services)
|
|
1637
1637
|
.filter(([, service]) => service.build)
|
|
@@ -1672,7 +1672,7 @@ export async function createApp(options = {}) {
|
|
|
1672
1672
|
continue;
|
|
1673
1673
|
for (const secret of [ing.cert, ing.key])
|
|
1674
1674
|
if (!db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret ?? null))
|
|
1675
|
-
throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with secret
|
|
1675
|
+
throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with tug secret set first`);
|
|
1676
1676
|
}
|
|
1677
1677
|
lock = acquireProjectLock(project, 'deploy');
|
|
1678
1678
|
const active = db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
|
package/dist/diagnostics.js
CHANGED
|
@@ -50,7 +50,7 @@ export function diagnoseNode(node, nowMs, staleSeconds) {
|
|
|
50
50
|
// A disabled node's certificate is irrelevant until it is replaced, so it never fails the diagnostics.
|
|
51
51
|
const report = connectivity === 'disabled' ? warnings : problems;
|
|
52
52
|
if (certificate?.state === 'expired')
|
|
53
|
-
report.push(`certificate expired ${certificate.notAfter}; run node
|
|
53
|
+
report.push(`certificate expired ${certificate.notAfter}; run tug node replace ${node.name}`);
|
|
54
54
|
else if (certificate?.state === 'expiring')
|
|
55
55
|
warnings.push(`certificate expires in ${certificate.daysLeft} days and has not renewed; check the agent logs on node ${node.name}`);
|
|
56
56
|
if (clockSkew.state === 'observed') {
|