@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.
Files changed (53) hide show
  1. package/README.md +93 -34
  2. package/dist/agent-credential.js +3 -3
  3. package/dist/agent.js +1 -1
  4. package/dist/backup-check.d.ts +1 -1
  5. package/dist/backup-config.js +32 -24
  6. package/dist/backup-destination.d.ts +1 -1
  7. package/dist/backup-destination.js +1 -1
  8. package/dist/backup-restore.js +65 -65
  9. package/dist/backup-scheduler.js +1 -1
  10. package/dist/cli-build.js +1 -1
  11. package/dist/cli-config.d.ts +6 -1
  12. package/dist/cli-config.js +18 -44
  13. package/dist/cli-install.d.ts +4 -2
  14. package/dist/cli-install.js +12 -7
  15. package/dist/cli-live.js +4 -6
  16. package/dist/cli-onboarding.d.ts +31 -0
  17. package/dist/cli-onboarding.js +66 -0
  18. package/dist/cli-prompt.d.ts +9 -0
  19. package/dist/cli-prompt.js +60 -0
  20. package/dist/cli-secrets.d.ts +12 -0
  21. package/dist/cli-secrets.js +71 -0
  22. package/dist/cli-status.d.ts +11 -0
  23. package/dist/cli-status.js +37 -7
  24. package/dist/cli.js +62 -981
  25. package/dist/commands/backup.d.ts +2 -0
  26. package/dist/commands/backup.js +109 -0
  27. package/dist/commands/context.d.ts +54 -0
  28. package/dist/commands/context.js +96 -0
  29. package/dist/commands/control.d.ts +2 -0
  30. package/dist/commands/control.js +110 -0
  31. package/dist/commands/credential.d.ts +2 -0
  32. package/dist/commands/credential.js +36 -0
  33. package/dist/commands/deploy.d.ts +2 -0
  34. package/dist/commands/deploy.js +139 -0
  35. package/dist/commands/host.d.ts +18 -0
  36. package/dist/commands/host.js +99 -0
  37. package/dist/commands/init.d.ts +11 -0
  38. package/dist/commands/init.js +40 -0
  39. package/dist/commands/node.d.ts +28 -0
  40. package/dist/commands/node.js +320 -0
  41. package/dist/commands/observe.d.ts +2 -0
  42. package/dist/commands/observe.js +71 -0
  43. package/dist/commands/restore.d.ts +2 -0
  44. package/dist/commands/restore.js +85 -0
  45. package/dist/commands/secret.d.ts +2 -0
  46. package/dist/commands/secret.js +26 -0
  47. package/dist/commands/service.d.ts +2 -0
  48. package/dist/commands/service.js +62 -0
  49. package/dist/commands/volume.d.ts +2 -0
  50. package/dist/commands/volume.js +13 -0
  51. package/dist/control.js +6 -6
  52. package/dist/diagnostics.js +1 -1
  53. 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,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerSecret(program: Command): void;
@@ -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,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerService(program: Command): void;
@@ -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,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerVolume(program: Command): void;
@@ -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-remove therefore never inherits the old ID, and certificates for the removed node cannot match the new one.
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-replace-identity to install a new key`,
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-replace-identity to change keys',
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-replace-identity.
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-set`);
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-set first`);
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);
@@ -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-replace-identity ${node.name}`);
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') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",