@wpelevator/tug 0.10.0 → 0.12.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 (61) hide show
  1. package/README.md +172 -75
  2. package/dist/agent-credential.js +3 -3
  3. package/dist/agent.js +39 -3
  4. package/dist/backup-check.d.ts +1 -1
  5. package/dist/backup-config.js +33 -25
  6. package/dist/backup-destination.d.ts +1 -1
  7. package/dist/backup-destination.js +1 -1
  8. package/dist/backup-restore.d.ts +8 -1
  9. package/dist/backup-restore.js +135 -71
  10. package/dist/backup-scheduler.js +1 -1
  11. package/dist/cli-build.js +1 -1
  12. package/dist/cli-config.d.ts +7 -2
  13. package/dist/cli-config.js +25 -46
  14. package/dist/cli-install.d.ts +4 -2
  15. package/dist/cli-install.js +12 -7
  16. package/dist/cli-live.js +4 -6
  17. package/dist/cli-onboarding.d.ts +31 -0
  18. package/dist/cli-onboarding.js +66 -0
  19. package/dist/cli-prompt.d.ts +9 -0
  20. package/dist/cli-prompt.js +60 -0
  21. package/dist/cli-secrets.d.ts +12 -0
  22. package/dist/cli-secrets.js +71 -0
  23. package/dist/cli-status.d.ts +11 -0
  24. package/dist/cli-status.js +37 -7
  25. package/dist/cli.js +66 -981
  26. package/dist/commands/backup.d.ts +2 -0
  27. package/dist/commands/backup.js +109 -0
  28. package/dist/commands/context.d.ts +58 -0
  29. package/dist/commands/context.js +111 -0
  30. package/dist/commands/control.d.ts +2 -0
  31. package/dist/commands/control.js +111 -0
  32. package/dist/commands/credential.d.ts +2 -0
  33. package/dist/commands/credential.js +36 -0
  34. package/dist/commands/deploy.d.ts +6 -0
  35. package/dist/commands/deploy.js +159 -0
  36. package/dist/commands/hold.d.ts +2 -0
  37. package/dist/commands/hold.js +13 -0
  38. package/dist/commands/host.d.ts +24 -0
  39. package/dist/commands/host.js +122 -0
  40. package/dist/commands/image.d.ts +2 -0
  41. package/dist/commands/image.js +13 -0
  42. package/dist/commands/init.d.ts +11 -0
  43. package/dist/commands/init.js +40 -0
  44. package/dist/commands/node.d.ts +28 -0
  45. package/dist/commands/node.js +354 -0
  46. package/dist/commands/observe.d.ts +2 -0
  47. package/dist/commands/observe.js +71 -0
  48. package/dist/commands/restore.d.ts +2 -0
  49. package/dist/commands/restore.js +85 -0
  50. package/dist/commands/secret.d.ts +2 -0
  51. package/dist/commands/secret.js +26 -0
  52. package/dist/commands/service.d.ts +2 -0
  53. package/dist/commands/service.js +62 -0
  54. package/dist/commands/volume.d.ts +2 -0
  55. package/dist/commands/volume.js +13 -0
  56. package/dist/control.js +145 -14
  57. package/dist/credentials.js +2 -0
  58. package/dist/diagnostics.js +1 -1
  59. package/dist/docker-api.d.ts +1 -0
  60. package/dist/live.js +1 -0
  61. package/package.json +1 -1
@@ -0,0 +1,71 @@
1
+ import { errMessage } from "../util.js";
2
+ import { formatStatus } from "../cli-status.js";
3
+ import { loadCfg, print, request, runCapture, waitForCommand } from "./context.js";
4
+ export function registerObserve(program) {
5
+ program
6
+ .command('status')
7
+ .description('summarize projects, deployments and nodes')
8
+ .option('--json', 'print the raw status as JSON for scripts')
9
+ .action(async (o) => {
10
+ const status = await request('GET', '/api/v1/status');
11
+ console.log(o.json ? JSON.stringify(status, null, 2) : formatStatus(status));
12
+ });
13
+ program
14
+ .command('events')
15
+ .description('list recent events with the credential that caused each control action; defaults to the resolved project')
16
+ .option('--limit <n>', 'number of events', '100')
17
+ .option('--all', 'list events across all projects (limited to your projects for scoped credentials)')
18
+ .action(async (o) => {
19
+ if (o.all && o.project)
20
+ throw new Error('--all and --project cannot be combined');
21
+ const cfg = await loadCfg();
22
+ const project = o.all ? undefined : o.project || cfg.project;
23
+ const query = `limit=${encodeURIComponent(o.limit)}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
24
+ print(await request('GET', `/api/v1/events?${query}`));
25
+ });
26
+ program
27
+ .command('doctor')
28
+ .description('check this machine and the control plane: certificate expiry, node connectivity and clock skew')
29
+ .option('--nodes', 'also run host diagnostics on every node through its agent and wait')
30
+ .option('--node <name>', 'run host diagnostics on one node through its agent and wait')
31
+ .option('--timeout <seconds>', 'wait timeout for node diagnostics', '60')
32
+ .action(async (o) => {
33
+ const local = [];
34
+ for (const [name, cmd, args] of [
35
+ ['node', 'node', ['--version']],
36
+ ['docker', 'docker', ['version', '--format', '{{.Server.Version}}']],
37
+ ['docker_compose', 'docker', ['compose', 'version']],
38
+ ]) {
39
+ try {
40
+ local.push({ name, ok: true, output: runCapture(cmd, args).trim() });
41
+ }
42
+ catch (e) {
43
+ local.push({ name, ok: false, error: errMessage(e) });
44
+ }
45
+ }
46
+ const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
47
+ const warnings = (controlDiagnostics.checks ?? []).flatMap((check) => (check.warnings ?? []).map((warning) => `${check.name}: ${warning}`));
48
+ const output = {
49
+ ok: local.every((c) => c.ok) && controlDiagnostics.ok,
50
+ ...(warnings.length && { warnings }),
51
+ local,
52
+ control: controlDiagnostics,
53
+ };
54
+ if (o.nodes || o.node) {
55
+ const queued = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
56
+ const commands = [];
57
+ for (const command of queued.commands) {
58
+ const finished = await waitForCommand(command.commandId, Number(o.timeout));
59
+ commands.push({
60
+ nodeId: command.nodeId,
61
+ state: finished.state,
62
+ result: JSON.parse(finished.result_json || '{}'),
63
+ });
64
+ }
65
+ output.nodeDiagnostics = { commands };
66
+ if (commands.some((command) => command.state !== 'succeeded'))
67
+ output.ok = false;
68
+ }
69
+ print(output);
70
+ });
71
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerRestore(program: Command): void;
@@ -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
@@ -29,7 +29,7 @@ import { parseStatusReport } from "./status-report.js";
29
29
  import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, BACKUP_COMMAND_TYPES, } from "./protocol.js";
30
30
  import { BACKUP_SECRET_PROJECT, isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
31
31
  import { createBackupService } from "./backup-scheduler.js";
32
- import { createRestoreService } from "./backup-restore.js";
32
+ import { createRestoreService, discoverWriters } from "./backup-restore.js";
33
33
  import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createCredential, listCredentials, parseCredentialRequest, queryProject, recordEvent, revokeCredential, } from "./credentials.js";
34
34
  class WakeHub {
35
35
  cursors = new Map();
@@ -758,6 +758,7 @@ export async function createApp(options = {}) {
758
758
  WHERE ds.project_id=? AND ds.deployment_id != ? AND d.state IN (${states}) ORDER BY ds.created_at DESC LIMIT 1`, project, current?.current_deployment_id || '');
759
759
  if (!previousDs)
760
760
  throw httpError(404, 'no previous deployment found');
761
+ assertArtifactsAvailable(JSON.parse(previousDs.state_json), `roll back to ${previousDs.version}`);
761
762
  const ts = now();
762
763
  const depId = id('dep');
763
764
  const previousState = JSON.parse(previousDs.state_json);
@@ -934,10 +935,17 @@ export async function createApp(options = {}) {
934
935
  const global = ext.global === true;
935
936
  const labels = Array.isArray(placement.labels) ? placement.labels : [];
936
937
  const matched = nodeInfos.filter((node) => labels.every((label) => node.labels.includes(label)));
937
- const pool = labels.length ? matched : nodeInfos;
938
+ // Explicit `placement.nodes` (without labels or global, which deploy resolves into nodes) bound the pool; scaling never re-homes a service off them.
939
+ const pinned = !labels.length && !global && Array.isArray(placement.nodes)
940
+ ? placement.nodes
941
+ : [];
942
+ const candidates = pinned.length
943
+ ? matched.filter((node) => pinned.includes(node.name))
944
+ : matched;
945
+ const pool = candidates;
938
946
  if (!pool.length)
939
- throw httpError(400, labels.length
940
- ? 'no healthy nodes match the service placement labels'
947
+ throw httpError(400, labels.length || pinned.length
948
+ ? 'no healthy nodes match the service placement'
941
949
  : 'no nodes available for scaling');
942
950
  const selected = global ? pool : pool.slice(0, Math.min(replicas, pool.length));
943
951
  state.services[service].placement = {
@@ -1008,7 +1016,7 @@ export async function createApp(options = {}) {
1008
1016
  return res.status(400).json({ error: "mode must be 'create' or 'replace'" });
1009
1017
  try {
1010
1018
  // 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.
1019
+ // `tug node remove` therefore never inherits the old ID, and certificates for the removed node cannot match the new one.
1012
1020
  let nodeId = db.get('SELECT * FROM nodes WHERE name=?', name)?.id ?? id('node');
1013
1021
  let signed = await pki.signClientCsr(body.csr, identityUri('node', nodeId), NODE_LIFETIME_MS);
1014
1022
  // Signing is asynchronous: re-read, and re-sign once if a concurrent request created the node meanwhile.
@@ -1024,7 +1032,7 @@ export async function createApp(options = {}) {
1024
1032
  if (existing) {
1025
1033
  if (existing.key_hash !== signed.keyHash)
1026
1034
  return res.status(409).json({
1027
- error: `node ${name} already exists; use node-replace-identity to install a new key`,
1035
+ error: `node ${name} already exists; use tug node replace to install a new key`,
1028
1036
  });
1029
1037
  }
1030
1038
  else {
@@ -1137,7 +1145,7 @@ export async function createApp(options = {}) {
1137
1145
  const signed = await pki.signClientCsr(req.body?.csr, identityUri('node', node.id), NODE_LIFETIME_MS);
1138
1146
  if (signed.keyHash !== node.key_hash)
1139
1147
  return res.status(403).json({
1140
- error: 'renewal requires a CSR for the node key; use node-replace-identity to change keys',
1148
+ error: 'renewal requires a CSR for the node key; use tug node replace to change keys',
1141
1149
  });
1142
1150
  const last = node.last_renewed_at ? Date.parse(node.last_renewed_at) : 0;
1143
1151
  if (Date.now() - last < NODE_RENEW_MIN_INTERVAL_MS())
@@ -1150,7 +1158,7 @@ export async function createApp(options = {}) {
1150
1158
  sendError(res, e);
1151
1159
  }
1152
1160
  });
1153
- // Blocks a node's key without deleting its metadata or changing placement. It returns only through node-replace-identity.
1161
+ // Blocks a node's key without deleting its metadata or changing placement. It returns only through `tug node replace`.
1154
1162
  api('post', '/api/v1/nodes/:name/disable', null, async (req, res) => {
1155
1163
  const node = findNode(String(req.params.name));
1156
1164
  if (!node)
@@ -1536,7 +1544,70 @@ export async function createApp(options = {}) {
1536
1544
  }
1537
1545
  // Previous-process builds cannot finish after a restart; clear their unpublished files.
1538
1546
  db.run("UPDATE deployments SET state='build_failed',error_message='control restarted during build' WHERE state IN ('uploading','building')");
1547
+ /** Archives deleted by `image prune`, or missing from a backup taken without images, can no longer be deployed. */
1548
+ async function markMissingArtifacts() {
1549
+ for (const row of db.all("SELECT * FROM builds WHERE state='complete' AND artifact_path IS NOT NULL"))
1550
+ if (!(await fs.stat(path.join(DATA_DIR, row.artifact_path)).catch(() => null)))
1551
+ db.run("UPDATE builds SET state='pruned',artifact_path=NULL,updated_at=? WHERE id=?", now(), row.id);
1552
+ }
1553
+ /** Throws a 409 when a desired state needs a build archive that was pruned. */
1554
+ function assertArtifactsAvailable(state, action) {
1555
+ for (const [name, service] of Object.entries(state.services)) {
1556
+ if (!service.artifact)
1557
+ continue;
1558
+ const build = db.get('SELECT * FROM builds WHERE id=?', service.artifact.buildId);
1559
+ if (build?.state === 'pruned')
1560
+ throw httpError(409, `cannot ${action}: the build archive of ${name} was pruned (tug image prune); deploy it again instead`);
1561
+ }
1562
+ }
1563
+ /**
1564
+ * Deletes build archives no longer needed: everything except the newest `keep` desired states of each project,
1565
+ * the current deployment and any version a node still reports. The desired states stay, so history lists them,
1566
+ * but rolling back to a pruned one is refused.
1567
+ */
1568
+ async function pruneArtifacts(keep, dryRun) {
1569
+ const kept = new Set();
1570
+ const reported = db
1571
+ .all('SELECT project_id,status_json FROM node_statuses')
1572
+ .map((row) => ({
1573
+ project: row.project_id,
1574
+ ...JSON.parse(row.status_json),
1575
+ }));
1576
+ const current = new Set(db
1577
+ .all('SELECT current_deployment_id FROM projects')
1578
+ .map((row) => row.current_deployment_id));
1579
+ const rows = db.all('SELECT * FROM desired_states ORDER BY created_at DESC');
1580
+ const seen = new Map();
1581
+ for (const row of rows) {
1582
+ const index = seen.get(row.project_id) ?? 0;
1583
+ seen.set(row.project_id, index + 1);
1584
+ const state = JSON.parse(row.state_json);
1585
+ const lagging = reported.some((r) => r.project === row.project_id &&
1586
+ r.observedVersion === state.version &&
1587
+ (!r.observedRevision || r.observedRevision === state.revision));
1588
+ if (index >= keep && !lagging && !current.has(row.deployment_id))
1589
+ continue;
1590
+ for (const service of Object.values(state.services))
1591
+ if (service.artifact)
1592
+ kept.add(service.artifact.buildId);
1593
+ }
1594
+ const pruned = [];
1595
+ for (const build of db.all("SELECT * FROM builds WHERE state='complete' AND artifact_path IS NOT NULL")) {
1596
+ if (kept.has(build.id))
1597
+ continue;
1598
+ pruned.push({ id: build.id, service: build.service, sizeBytes: build.size_bytes });
1599
+ if (dryRun)
1600
+ continue;
1601
+ await fs.rm(path.join(DATA_DIR, build.artifact_path), { force: true });
1602
+ db.run("UPDATE builds SET state='pruned',artifact_path=NULL,updated_at=? WHERE id=?", now(), build.id);
1603
+ const shared = db.get("SELECT 1 FROM builds WHERE image_ref=? AND state='complete'", build.image_ref);
1604
+ if (!shared)
1605
+ await images.run('docker', ['image', 'rm', build.image_ref]).catch(() => { });
1606
+ }
1607
+ return { pruned, freedBytes: pruned.reduce((sum, build) => sum + build.sizeBytes, 0) };
1608
+ }
1539
1609
  await collectArtifacts();
1610
+ await markMissingArtifacts();
1540
1611
  const receiveDeployment = upload.any();
1541
1612
  api('post', '/api/v1/deployments', null, (req, res) => {
1542
1613
  receiveDeployment(req, res, (error) => {
@@ -1547,6 +1618,25 @@ export async function createApp(options = {}) {
1547
1618
  });
1548
1619
  });
1549
1620
  });
1621
+ /**
1622
+ * Services that keep data on a node and are placed on different nodes by `compose` than by the current
1623
+ * deployment. Deploying such a change starts the service empty on the new node: the data does not move.
1624
+ */
1625
+ function movedServices(project, compose) {
1626
+ const current = currentDesired(project);
1627
+ if (!current)
1628
+ return [];
1629
+ const before = JSON.parse(current.state_json);
1630
+ return Object.entries(compose.services || {})
1631
+ .filter(([name, svc]) => {
1632
+ const old = before.services?.[name]?.placement?.nodes ?? [];
1633
+ const next = svc['x-tug']?.placement?.nodes ?? [];
1634
+ const keepsData = Boolean(svc['x-tug']?.backup) ||
1635
+ (svc.volumes ?? []).some((mount) => typeof mount === 'object' && mount.type === 'volume');
1636
+ return keepsData && old.length > 0 && next.length > 0 && old.some((n) => !next.includes(n));
1637
+ })
1638
+ .map(([name]) => name);
1639
+ }
1550
1640
  async function deployRequest(req, res, uploadError) {
1551
1641
  const files = (Array.isArray(req.files) ? req.files : []);
1552
1642
  let responseBody;
@@ -1620,6 +1710,10 @@ export async function createApp(options = {}) {
1620
1710
  throw httpError(400, errMessage(e));
1621
1711
  }
1622
1712
  assertIngressHostsFree(project, compose);
1713
+ const acknowledgedMoves = Array.isArray(body.moves) ? body.moves.map(String) : [];
1714
+ const unacknowledged = movedServices(project, compose).filter((name) => !acknowledgedMoves.includes(name));
1715
+ if (unacknowledged.length)
1716
+ throw httpError(409, `${unacknowledged.join(', ')} would start empty on a different node: its data does not move. Back it up, then deploy with --move ${unacknowledged.join(' --move ')} to hold the services that write to it, and run tug restore run <target> --confirm`);
1623
1717
  // The CLI interpolates control secrets as sentinels; a reference to a secret that is gone would deploy an empty value.
1624
1718
  const referenced = sentinelNames(compose);
1625
1719
  if (referenced.includes(REGISTRY_AUTH_SECRET))
@@ -1631,7 +1725,7 @@ export async function createApp(options = {}) {
1631
1725
  .map((row) => row.name));
1632
1726
  const missing = referenced.filter((name) => !known.has(name));
1633
1727
  if (missing.length)
1634
- throw httpError(400, `secrets not found in project ${project}: ${missing.join(', ')}; create them with tug secret-set`);
1728
+ throw httpError(400, `secrets not found in project ${project}: ${missing.join(', ')}; create them with tug secret set`);
1635
1729
  }
1636
1730
  const builtServices = Object.entries(compose.services)
1637
1731
  .filter(([, service]) => service.build)
@@ -1672,7 +1766,7 @@ export async function createApp(options = {}) {
1672
1766
  continue;
1673
1767
  for (const secret of [ing.cert, ing.key])
1674
1768
  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`);
1769
+ throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with tug secret set first`);
1676
1770
  }
1677
1771
  lock = acquireProjectLock(project, 'deploy');
1678
1772
  const active = db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
@@ -1770,6 +1864,11 @@ export async function createApp(options = {}) {
1770
1864
  !db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now()))
1771
1865
  throw httpError(409, 'deployment was cancelled or its build lease was lost');
1772
1866
  db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, publishingId, version, JSON.stringify(desired), ts);
1867
+ // The moved service starts empty: keep what writes to it stopped until its data is restored.
1868
+ for (const moved of acknowledgedMoves.filter((name) => compose.services?.[name]))
1869
+ for (const writer of discoverWriters(compose, `service:${moved}`))
1870
+ if (!acknowledgedMoves.includes(writer))
1871
+ db.run('INSERT OR REPLACE INTO service_holds(project_id,service,reason,restore_id,created_at) VALUES(?,?,?,?,?)', project, writer, `moved ${moved}`, null, ts);
1773
1872
  for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
1774
1873
  const lp = volumeSpec?.['x-tug'] || {};
1775
1874
  const nodeName = lp.placement?.nodes?.[0] || null;
@@ -1933,6 +2032,31 @@ export async function createApp(options = {}) {
1933
2032
  releaseLock(lock);
1934
2033
  }
1935
2034
  });
2035
+ api('post', '/api/v1/holds/release', bodyProject, async (req, res) => withWriter(async () => {
2036
+ const project = String(req.body?.project || 'site');
2037
+ const services = Array.isArray(req.body?.services)
2038
+ ? req.body.services.map(String)
2039
+ : [];
2040
+ // Only holds a deploy placed for a move; a restore's holds are dropped by `restore release`.
2041
+ const rows = db.all('SELECT service FROM service_holds WHERE project_id=? AND restore_id IS NULL', project);
2042
+ const released = rows
2043
+ .map((row) => row.service)
2044
+ .filter((service) => !services.length || services.includes(service));
2045
+ for (const service of released)
2046
+ db.run('DELETE FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', project, service);
2047
+ wakeAll(project);
2048
+ res.json({ ok: true, project, released });
2049
+ }));
2050
+ api('post', '/api/v1/images/prune', null, async (req, res) => withWriter(async () => {
2051
+ const keep = req.body?.keep === undefined ? 5 : Number(req.body.keep);
2052
+ if (!Number.isInteger(keep) || keep < 1)
2053
+ return res.status(400).json({ error: 'keep must be a positive integer' });
2054
+ res.json({
2055
+ dryRun: Boolean(req.body?.dryRun),
2056
+ keep,
2057
+ ...(await pruneArtifacts(keep, Boolean(req.body?.dryRun))),
2058
+ });
2059
+ }));
1936
2060
  api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
1937
2061
  const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
1938
2062
  // The archive holds the master key and the CA key, so nothing here is ever readable beyond the control user.
@@ -1954,14 +2078,16 @@ export async function createApp(options = {}) {
1954
2078
  recursive: true,
1955
2079
  filter: (src) => path.basename(src) !== 'server.json',
1956
2080
  });
1957
- await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
1958
- await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2), { mode: 0o600 });
2081
+ const withImages = req.body?.images !== false;
2082
+ if (withImages)
2083
+ await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
2084
+ await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control', images: withImages }, null, 2), { mode: 0o600 });
1959
2085
  await tar.c({ gzip: true, file: artifactRef, cwd: staging, mode: 0o600 }, [
1960
2086
  'tug.db',
1961
2087
  'master.key',
1962
2088
  'metadata.json',
1963
2089
  'pki',
1964
- 'images',
2090
+ ...(withImages ? ['images'] : []),
1965
2091
  ]);
1966
2092
  }
1967
2093
  catch (error) {
@@ -2007,11 +2133,16 @@ export async function createApp(options = {}) {
2007
2133
  for (const secret of snapshot.all('SELECT * FROM secrets'))
2008
2134
  decrypt(secret.encrypted_value, key);
2009
2135
  const states = snapshot.all('SELECT * FROM desired_states');
2010
- for (const stateRow of states) {
2136
+ const meta = JSON.parse(await fs.readFile(path.join(restoreDir, 'metadata.json'), 'utf8').catch(() => '{}'));
2137
+ // A backup taken without images restores the archive-less builds as pruned on the next start.
2138
+ for (const stateRow of meta.images === false ? [] : states) {
2011
2139
  const state = JSON.parse(stateRow.state_json);
2012
2140
  for (const service of Object.values(state.services)) {
2013
2141
  if (!service.artifact)
2014
2142
  continue;
2143
+ // `image prune` already removed this one before the backup.
2144
+ if (snapshot.get("SELECT 1 FROM builds WHERE id=? AND state='pruned'", service.artifact.buildId))
2145
+ continue;
2015
2146
  const build = snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
2016
2147
  if (!build?.artifact_path ||
2017
2148
  !/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
@@ -53,6 +53,8 @@ export const ROUTE_ROLES = {
53
53
  'DELETE /api/v1/backup/destination': 'admin',
54
54
  'GET /api/v1/backup/repository-password': 'admin',
55
55
  'POST /api/v1/control/backups/run': 'admin',
56
+ 'POST /api/v1/images/prune': 'admin',
57
+ 'POST /api/v1/holds/release': 'admin',
56
58
  'POST /api/v1/control/restore': 'admin',
57
59
  'GET /api/v1/diagnostics': 'admin',
58
60
  'POST /api/v1/diagnostics/nodes': 'admin',
@@ -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') {
@@ -40,6 +40,7 @@ export interface ContainerInspect {
40
40
  NetworkMode?: string;
41
41
  Binds?: string[];
42
42
  Mounts?: unknown[];
43
+ ExtraHosts?: string[];
43
44
  };
44
45
  State?: {
45
46
  Status?: string;
package/dist/live.js CHANGED
@@ -312,6 +312,7 @@ export async function startOneOff(docker, template, command) {
312
312
  NetworkMode: host.NetworkMode,
313
313
  ...(host.Binds?.length ? { Binds: host.Binds } : {}),
314
314
  ...(host.Mounts?.length ? { Mounts: host.Mounts } : {}),
315
+ ...(host.ExtraHosts?.length ? { ExtraHosts: host.ExtraHosts } : {}),
315
316
  },
316
317
  });
317
318
  const remove = () => docker.removeContainer(id).catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",