@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.
Files changed (80) hide show
  1. package/README.md +110 -68
  2. package/dist/agent.js +38 -2
  3. package/dist/backup-config.js +1 -1
  4. package/dist/backup-restore.d.ts +9 -2
  5. package/dist/backup-restore.js +75 -9
  6. package/dist/backup-scheduler.js +3 -1
  7. package/dist/cli-build.d.ts +1 -1
  8. package/dist/cli-build.js +4 -1
  9. package/dist/cli-config.d.ts +1 -1
  10. package/dist/cli-config.js +7 -2
  11. package/dist/cli-process.d.ts +13 -0
  12. package/dist/cli-process.js +46 -0
  13. package/dist/cli-progress.d.ts +11 -0
  14. package/dist/cli-progress.js +37 -0
  15. package/dist/cli-prompt.js +5 -4
  16. package/dist/cli-selection.d.ts +13 -0
  17. package/dist/cli-selection.js +39 -0
  18. package/dist/cli.js +4 -0
  19. package/dist/commands/backup.js +48 -31
  20. package/dist/commands/context.d.ts +6 -5
  21. package/dist/commands/context.js +65 -28
  22. package/dist/commands/control.js +56 -42
  23. package/dist/commands/credential.js +21 -2
  24. package/dist/commands/deploy.d.ts +4 -0
  25. package/dist/commands/deploy.js +116 -69
  26. package/dist/commands/hold.d.ts +2 -0
  27. package/dist/commands/hold.js +21 -0
  28. package/dist/commands/host.d.ts +8 -2
  29. package/dist/commands/host.js +36 -8
  30. package/dist/commands/image.d.ts +2 -0
  31. package/dist/commands/image.js +13 -0
  32. package/dist/commands/init.js +1 -1
  33. package/dist/commands/node.d.ts +1 -0
  34. package/dist/commands/node.js +139 -73
  35. package/dist/commands/observe.js +7 -2
  36. package/dist/commands/restore.js +71 -50
  37. package/dist/commands/secret.js +8 -3
  38. package/dist/commands/selection.d.ts +22 -0
  39. package/dist/commands/selection.js +90 -0
  40. package/dist/commands/service.js +17 -6
  41. package/dist/commands/volume.js +3 -2
  42. package/dist/control.js +234 -55
  43. package/dist/credentials.js +7 -0
  44. package/dist/db-backup-destination.d.ts +14 -0
  45. package/dist/db-backup-destination.js +28 -0
  46. package/dist/db-locks.d.ts +10 -0
  47. package/dist/db-locks.js +23 -0
  48. package/dist/db-transaction.d.ts +3 -0
  49. package/dist/db-transaction.js +24 -0
  50. package/dist/docker-api.d.ts +1 -0
  51. package/dist/image.d.ts +9 -0
  52. package/dist/image.js +19 -0
  53. package/dist/live.js +1 -0
  54. package/dist/util.d.ts +2 -0
  55. package/dist/util.js +6 -0
  56. package/docs/access-control.md +166 -0
  57. package/docs/architecture.md +254 -0
  58. package/docs/backups.md +160 -0
  59. package/docs/cli.md +182 -0
  60. package/docs/compose.md +245 -0
  61. package/docs/configuration.md +52 -0
  62. package/docs/control-plane.md +112 -0
  63. package/docs/development.md +140 -0
  64. package/docs/getting-started.md +75 -0
  65. package/docs/healthchecks.md +51 -0
  66. package/docs/migration.md +113 -0
  67. package/docs/networking.md +150 -0
  68. package/docs/nodes.md +143 -0
  69. package/docs/observability.md +213 -0
  70. package/docs/real-host-evaluation.md +224 -0
  71. package/docs/releases.md +39 -0
  72. package/docs/roadmap.md +234 -0
  73. package/docs/security.md +198 -0
  74. package/docs/service-placement.md +72 -0
  75. package/docs/standalone-binary.md +79 -0
  76. package/docs/swarm-comparison.md +197 -0
  77. package/docs/terminology.md +74 -0
  78. package/docs/testing.md +254 -0
  79. package/docs/troubleshooting.md +33 -0
  80. package/package.json +6 -3
@@ -1,21 +1,30 @@
1
+ import { selectService } from "./selection.js";
1
2
  import { projectOf, request, runLive, waitForCommand } from "./context.js";
2
3
  export function registerService(program) {
3
4
  program
4
- .command('exec <service> [command...]')
5
+ .command('exec [service] [command...]')
5
6
  .description('execute a command on a service container (default: sh)')
7
+ .option('--select', 'choose the service; all positional arguments are the remote command')
6
8
  .option('-i, --interactive', 'forward stdin to the remote command')
7
9
  .option('-t, --tty', 'allocate a terminal (requires a local terminal and agent TTY support)')
8
10
  .option('--node <name>', 'select the node when a service has multiple placements')
9
11
  .action(async (service, command, o) => {
12
+ if (o.select && service)
13
+ command.unshift(service);
14
+ service = await selectService(o.select ? undefined : service);
10
15
  await runLive('exec', { service, command, ...o });
11
16
  });
12
17
  program
13
- .command('run <service> [command...]')
18
+ .command('run [service] [command...]')
14
19
  .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
20
  .option('--node <name>', 'select the node when a service has multiple placements')
21
+ .option('--select', 'choose the service; all positional arguments are the remote command')
16
22
  .action(async (service, command, o) => {
23
+ if (o.select && service)
24
+ command.unshift(service);
17
25
  if (!command.length)
18
26
  throw new Error('run needs a command, such as: tug run web -- wp core update-db');
27
+ service = await selectService(o.select ? undefined : service);
19
28
  await runLive('exec', { service, command, node: o.node, oneOff: true });
20
29
  });
21
30
  program
@@ -26,25 +35,27 @@ export function registerService(program) {
26
35
  await runLive('ps', o);
27
36
  });
28
37
  program
29
- .command('inspect <service>')
38
+ .command('inspect [service]')
30
39
  .description('print container details as JSON')
31
40
  .option('--node <name>', 'select one node')
32
41
  .action(async (service, o) => {
42
+ service = await selectService(service);
33
43
  await runLive('inspect', { service, ...o });
34
44
  });
35
45
  program
36
- .command('logs <service>')
46
+ .command('logs [service]')
37
47
  .description('fetch service container logs from the node that runs the service')
38
48
  .option('--tail <n>', 'number of lines', '200')
39
49
  .option('-f, --follow', 'follow live logs')
40
50
  .option('--node <name>', 'select a node for followed logs')
41
51
  .action(async (svc, o) => {
52
+ if (o.node && !o.follow)
53
+ throw new Error('--node requires --follow for logs');
54
+ svc = await selectService(svc);
42
55
  if (o.follow) {
43
56
  await runLive('logs', { service: svc, node: o.node, tail: o.tail });
44
57
  return;
45
58
  }
46
- if (o.node)
47
- throw new Error('--node requires --follow for logs');
48
59
  const created = await request('POST', '/api/v1/logs', {
49
60
  project: await projectOf(o),
50
61
  service: svc,
@@ -1,3 +1,4 @@
1
+ import { selectResource } from "./selection.js";
1
2
  import { print, projectOf, request } from "./context.js";
2
3
  export function registerVolume(program) {
3
4
  const volume = program.command('volume').description("list and remove a project's volumes");
@@ -6,8 +7,8 @@ export function registerVolume(program) {
6
7
  .description('list the volumes of the project and where they live')
7
8
  .action(async (o) => print(await request('GET', `/api/v1/volumes?project=${encodeURIComponent(await projectOf(o))}`)));
8
9
  volume
9
- .command('remove <name>')
10
+ .command('remove [name]')
10
11
  .description('remove a volume from the project')
11
12
  .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
+ .action(async (name, o) => print(await request('DELETE', `/api/v1/volumes/${encodeURIComponent(await selectResource(name, 'volumes'))}?project=${encodeURIComponent(await projectOf(o))}&deleteData=${Boolean(o.deleteData)}`)));
13
14
  }
package/dist/control.js CHANGED
@@ -15,6 +15,9 @@ import { WebSocketServer, WebSocket } from 'ws';
15
15
  import * as tar from 'tar';
16
16
  import { openDb, openSnapshotDb } from "./db.js";
17
17
  import { isUniqueViolation } from "./db-driver.js";
18
+ import { lockQueries } from "./db-locks.js";
19
+ import { backupDestinationQueries } from "./db-backup-destination.js";
20
+ import { immediateTransaction } from "./db-transaction.js";
18
21
  import { encrypt, decrypt, loadOrCreateMasterKey } from "./secrets.js";
19
22
  import { buildDesiredState, ingressHostsOf, ingressHostsOverlap, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
20
23
  import { buildImage, unpackContext, run as runCommand } from "./build.js";
@@ -27,9 +30,9 @@ import { isNodeHost } from "./node-address.js";
27
30
  import { buildIngressWork, ingressConfigHash, resolveServiceEndpoints } from "./ingress.js";
28
31
  import { parseStatusReport } from "./status-report.js";
29
32
  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
- import { BACKUP_SECRET_PROJECT, isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
33
+ import { isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
31
34
  import { createBackupService } from "./backup-scheduler.js";
32
- import { createRestoreService } from "./backup-restore.js";
35
+ import { createRestoreService, discoverWriters } from "./backup-restore.js";
33
36
  import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createCredential, listCredentials, parseCredentialRequest, queryProject, recordEvent, revokeCredential, } from "./credentials.js";
34
37
  class WakeHub {
35
38
  cursors = new Map();
@@ -758,6 +761,7 @@ export async function createApp(options = {}) {
758
761
  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
762
  if (!previousDs)
760
763
  throw httpError(404, 'no previous deployment found');
764
+ assertArtifactsAvailable(JSON.parse(previousDs.state_json), `roll back to ${previousDs.version}`);
761
765
  const ts = now();
762
766
  const depId = id('dep');
763
767
  const previousState = JSON.parse(previousDs.state_json);
@@ -863,18 +867,24 @@ export async function createApp(options = {}) {
863
867
  api('get', '/api/v1/live', null, (_req, res) => {
864
868
  res.status(426).json({ error: 'WebSocket upgrade required' });
865
869
  });
870
+ const locks = lockQueries(db);
866
871
  function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
867
872
  const name = `project:${project}`;
868
873
  const ts = now();
869
- db.run('DELETE FROM locks WHERE expires_at < ?', ts);
874
+ locks.purgeExpired(ts);
870
875
  const owner = `${operation}:${id('lock')}`;
871
876
  try {
872
- db.run('INSERT INTO locks(name,owner,expires_at,created_at) VALUES(?,?,?,?)', name, owner, new Date(Date.now() + ttlMs).toISOString(), ts);
877
+ locks.insert({
878
+ name,
879
+ owner,
880
+ expires_at: new Date(Date.now() + ttlMs).toISOString(),
881
+ created_at: ts,
882
+ });
873
883
  }
874
884
  catch (e) {
875
885
  if (!isUniqueViolation(e))
876
886
  throw e;
877
- const existing = db.get('SELECT * FROM locks WHERE name=?', name);
887
+ const existing = locks.find(name);
878
888
  const err = new Error(`project ${project} is locked by ${existing?.owner} until ${existing?.expires_at}`);
879
889
  err.status = 409;
880
890
  err.lock = existing;
@@ -882,7 +892,7 @@ export async function createApp(options = {}) {
882
892
  }
883
893
  const heartbeat = setInterval(() => {
884
894
  try {
885
- db.run('UPDATE locks SET expires_at=? WHERE name=? AND owner=?', new Date(Date.now() + ttlMs).toISOString(), name, owner);
895
+ locks.renew(name, owner, new Date(Date.now() + ttlMs).toISOString());
886
896
  }
887
897
  catch {
888
898
  /* Best-effort bookkeeping. */
@@ -896,7 +906,7 @@ export async function createApp(options = {}) {
896
906
  clearInterval(lock.heartbeat);
897
907
  if (lock)
898
908
  try {
899
- db.run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner);
909
+ locks.release(lock.name, lock.owner);
900
910
  }
901
911
  catch {
902
912
  /* Best-effort bookkeeping. */
@@ -934,10 +944,17 @@ export async function createApp(options = {}) {
934
944
  const global = ext.global === true;
935
945
  const labels = Array.isArray(placement.labels) ? placement.labels : [];
936
946
  const matched = nodeInfos.filter((node) => labels.every((label) => node.labels.includes(label)));
937
- const pool = labels.length ? matched : nodeInfos;
947
+ // Explicit `placement.nodes` (without labels or global, which deploy resolves into nodes) bound the pool; scaling never re-homes a service off them.
948
+ const pinned = !labels.length && !global && Array.isArray(placement.nodes)
949
+ ? placement.nodes
950
+ : [];
951
+ const candidates = pinned.length
952
+ ? matched.filter((node) => pinned.includes(node.name))
953
+ : matched;
954
+ const pool = candidates;
938
955
  if (!pool.length)
939
- throw httpError(400, labels.length
940
- ? 'no healthy nodes match the service placement labels'
956
+ throw httpError(400, labels.length || pinned.length
957
+ ? 'no healthy nodes match the service placement'
941
958
  : 'no nodes available for scaling');
942
959
  const selected = global ? pool : pool.slice(0, Math.min(replicas, pool.length));
943
960
  state.services[service].placement = {
@@ -1277,16 +1294,17 @@ export async function createApp(options = {}) {
1277
1294
  });
1278
1295
  res.json({ ok: true, name: String(req.params.name) });
1279
1296
  });
1280
- const backupDestination = () => db.get("SELECT * FROM backup_destination WHERE id='default'");
1297
+ const destinationQueries = backupDestinationQueries(db);
1298
+ const backupDestination = () => destinationQueries.find();
1281
1299
  const backupEnv = () => {
1282
1300
  const row = backupDestination();
1283
1301
  if (!row)
1284
1302
  return {};
1285
1303
  const env = {};
1286
1304
  for (const name of JSON.parse(row.env_names_json)) {
1287
- const secret = db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', BACKUP_SECRET_PROJECT, name);
1305
+ const secret = destinationQueries.secret(name);
1288
1306
  if (secret)
1289
- env[name] = decrypt(secret.encrypted_value, masterKey);
1307
+ env[name] = decrypt(secret, masterKey);
1290
1308
  }
1291
1309
  return env;
1292
1310
  };
@@ -1363,7 +1381,9 @@ export async function createApp(options = {}) {
1363
1381
  return res.status(started.status).json({ error: started.error });
1364
1382
  res.json({ confirmed: true, plan, ...started });
1365
1383
  });
1366
- api('get', '/api/v1/restores', queryProject, async (req, res) => res.json({ restores: restores.list(String(req.query.project || 'site')) }));
1384
+ api('get', '/api/v1/restores', queryProject, async (req, res) => res.json({
1385
+ restores: restores.list(String(req.query.project || 'site'), 20, req.query.state === 'interrupted'),
1386
+ }));
1367
1387
  api('post', '/api/v1/restores/:id/release', null, async (req, res) => {
1368
1388
  const result = restores.releaseInterrupted(String(req.params.id));
1369
1389
  if ('error' in result)
@@ -1377,6 +1397,7 @@ export async function createApp(options = {}) {
1377
1397
  res.json(result);
1378
1398
  });
1379
1399
  api('get', '/api/v1/backups', queryProject, async (req, res) => {
1400
+ backups.sync();
1380
1401
  const project = String(req.query.project || 'site');
1381
1402
  const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
1382
1403
  res.json({
@@ -1407,19 +1428,11 @@ export async function createApp(options = {}) {
1407
1428
  return res.status(400).json({ error: errMessage(e) });
1408
1429
  }
1409
1430
  const ts = now();
1410
- db.exec('BEGIN IMMEDIATE');
1411
- try {
1412
- db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
1413
- for (const [name, value] of Object.entries(parsed.env))
1414
- db.run('INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)', id('sec'), BACKUP_SECRET_PROJECT, name, encrypt(value, masterKey), 1, ts, ts);
1415
- db.run(`INSERT INTO backup_destination(id,url,env_names_json,checked_at,check_result_json,updated_at) VALUES('default',?,?,NULL,NULL,?)
1416
- ON CONFLICT(id) DO UPDATE SET url=excluded.url,env_names_json=excluded.env_names_json,checked_at=NULL,check_result_json=NULL,updated_at=excluded.updated_at`, parsed.url, JSON.stringify(Object.keys(parsed.env).sort()), ts);
1417
- db.exec('COMMIT');
1418
- }
1419
- catch (e) {
1420
- db.exec('ROLLBACK');
1421
- throw e;
1422
- }
1431
+ destinationQueries.replace(parsed.url, Object.entries(parsed.env).map(([name, value]) => ({
1432
+ id: id('sec'),
1433
+ name,
1434
+ encryptedValue: encrypt(value, masterKey),
1435
+ })), ts);
1423
1436
  audit(req.actor, {
1424
1437
  type: 'backup.destination_set',
1425
1438
  message: `backup destination set to ${parsed.url} by ${req.actor.name}`,
@@ -1428,14 +1441,13 @@ export async function createApp(options = {}) {
1428
1441
  res.json({ ok: true, url: parsed.url, envNames: Object.keys(parsed.env).sort() });
1429
1442
  });
1430
1443
  api('delete', '/api/v1/backup/destination', null, async (req, res) => {
1431
- db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
1432
- const removed = db.run("DELETE FROM backup_destination WHERE id='default'");
1433
- if (removed.changes)
1444
+ const removed = destinationQueries.remove();
1445
+ if (removed)
1434
1446
  audit(req.actor, {
1435
1447
  type: 'backup.destination_removed',
1436
1448
  message: `backup destination removed by ${req.actor.name}`,
1437
1449
  });
1438
- res.json({ ok: true, removed: removed.changes > 0 });
1450
+ res.json({ ok: true, removed });
1439
1451
  });
1440
1452
  api('get', '/api/v1/backup/repository-password', null, async (req, res) => {
1441
1453
  const project = String(req.query.project || '');
@@ -1536,7 +1548,70 @@ export async function createApp(options = {}) {
1536
1548
  }
1537
1549
  // Previous-process builds cannot finish after a restart; clear their unpublished files.
1538
1550
  db.run("UPDATE deployments SET state='build_failed',error_message='control restarted during build' WHERE state IN ('uploading','building')");
1551
+ /** Archives deleted by `image prune`, or missing from a backup taken without images, can no longer be deployed. */
1552
+ async function markMissingArtifacts() {
1553
+ for (const row of db.all("SELECT * FROM builds WHERE state='complete' AND artifact_path IS NOT NULL"))
1554
+ if (!(await fs.stat(path.join(DATA_DIR, row.artifact_path)).catch(() => null)))
1555
+ db.run("UPDATE builds SET state='pruned',artifact_path=NULL,updated_at=? WHERE id=?", now(), row.id);
1556
+ }
1557
+ /** Throws a 409 when a desired state needs a build archive that was pruned. */
1558
+ function assertArtifactsAvailable(state, action) {
1559
+ for (const [name, service] of Object.entries(state.services)) {
1560
+ if (!service.artifact)
1561
+ continue;
1562
+ const build = db.get('SELECT * FROM builds WHERE id=?', service.artifact.buildId);
1563
+ if (build?.state === 'pruned')
1564
+ throw httpError(409, `cannot ${action}: the build archive of ${name} was pruned (tug image prune); deploy it again instead`);
1565
+ }
1566
+ }
1567
+ /**
1568
+ * Deletes build archives no longer needed: everything except the newest `keep` desired states of each project,
1569
+ * the current deployment and any version a node still reports. The desired states stay, so history lists them,
1570
+ * but rolling back to a pruned one is refused.
1571
+ */
1572
+ async function pruneArtifacts(keep, dryRun) {
1573
+ const kept = new Set();
1574
+ const reported = db
1575
+ .all('SELECT project_id,status_json FROM node_statuses')
1576
+ .map((row) => ({
1577
+ project: row.project_id,
1578
+ ...JSON.parse(row.status_json),
1579
+ }));
1580
+ const current = new Set(db
1581
+ .all('SELECT current_deployment_id FROM projects')
1582
+ .map((row) => row.current_deployment_id));
1583
+ const rows = db.all('SELECT * FROM desired_states ORDER BY created_at DESC');
1584
+ const seen = new Map();
1585
+ for (const row of rows) {
1586
+ const index = seen.get(row.project_id) ?? 0;
1587
+ seen.set(row.project_id, index + 1);
1588
+ const state = JSON.parse(row.state_json);
1589
+ const lagging = reported.some((r) => r.project === row.project_id &&
1590
+ r.observedVersion === state.version &&
1591
+ (!r.observedRevision || r.observedRevision === state.revision));
1592
+ if (index >= keep && !lagging && !current.has(row.deployment_id))
1593
+ continue;
1594
+ for (const service of Object.values(state.services))
1595
+ if (service.artifact)
1596
+ kept.add(service.artifact.buildId);
1597
+ }
1598
+ const pruned = [];
1599
+ for (const build of db.all("SELECT * FROM builds WHERE state='complete' AND artifact_path IS NOT NULL")) {
1600
+ if (kept.has(build.id))
1601
+ continue;
1602
+ pruned.push({ id: build.id, service: build.service, sizeBytes: build.size_bytes });
1603
+ if (dryRun)
1604
+ continue;
1605
+ await fs.rm(path.join(DATA_DIR, build.artifact_path), { force: true });
1606
+ db.run("UPDATE builds SET state='pruned',artifact_path=NULL,updated_at=? WHERE id=?", now(), build.id);
1607
+ const shared = db.get("SELECT 1 FROM builds WHERE image_ref=? AND state='complete'", build.image_ref);
1608
+ if (!shared)
1609
+ await images.run('docker', ['image', 'rm', build.image_ref]).catch(() => { });
1610
+ }
1611
+ return { pruned, freedBytes: pruned.reduce((sum, build) => sum + build.sizeBytes, 0) };
1612
+ }
1539
1613
  await collectArtifacts();
1614
+ await markMissingArtifacts();
1540
1615
  const receiveDeployment = upload.any();
1541
1616
  api('post', '/api/v1/deployments', null, (req, res) => {
1542
1617
  receiveDeployment(req, res, (error) => {
@@ -1547,6 +1622,25 @@ export async function createApp(options = {}) {
1547
1622
  });
1548
1623
  });
1549
1624
  });
1625
+ /**
1626
+ * Services that keep data on a node and are placed on different nodes by `compose` than by the current
1627
+ * deployment. Deploying such a change starts the service empty on the new node: the data does not move.
1628
+ */
1629
+ function movedServices(project, compose) {
1630
+ const current = currentDesired(project);
1631
+ if (!current)
1632
+ return [];
1633
+ const before = JSON.parse(current.state_json);
1634
+ return Object.entries(compose.services || {})
1635
+ .filter(([name, svc]) => {
1636
+ const old = before.services?.[name]?.placement?.nodes ?? [];
1637
+ const next = svc['x-tug']?.placement?.nodes ?? [];
1638
+ const keepsData = Boolean(svc['x-tug']?.backup) ||
1639
+ (svc.volumes ?? []).some((mount) => typeof mount === 'object' && mount.type === 'volume');
1640
+ return keepsData && old.length > 0 && next.length > 0 && old.some((n) => !next.includes(n));
1641
+ })
1642
+ .map(([name]) => name);
1643
+ }
1550
1644
  async function deployRequest(req, res, uploadError) {
1551
1645
  const files = (Array.isArray(req.files) ? req.files : []);
1552
1646
  let responseBody;
@@ -1620,6 +1714,10 @@ export async function createApp(options = {}) {
1620
1714
  throw httpError(400, errMessage(e));
1621
1715
  }
1622
1716
  assertIngressHostsFree(project, compose);
1717
+ const acknowledgedMoves = Array.isArray(body.moves) ? body.moves.map(String) : [];
1718
+ const unacknowledged = movedServices(project, compose).filter((name) => !acknowledgedMoves.includes(name));
1719
+ if (unacknowledged.length)
1720
+ 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
1721
  // The CLI interpolates control secrets as sentinels; a reference to a secret that is gone would deploy an empty value.
1624
1722
  const referenced = sentinelNames(compose);
1625
1723
  if (referenced.includes(REGISTRY_AUTH_SECRET))
@@ -1677,7 +1775,7 @@ export async function createApp(options = {}) {
1677
1775
  lock = acquireProjectLock(project, 'deploy');
1678
1776
  const active = db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
1679
1777
  if (active)
1680
- throw httpError(409, `deployment ${active.id} is already ${active.state}`);
1778
+ throw httpError(409, `deployment ${active.id} is already ${active.state}; wait for it, or cancel it with: tug deploy cancel ${active.id}`);
1681
1779
  depId = id('dep');
1682
1780
  const ts = now();
1683
1781
  db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
@@ -1759,17 +1857,20 @@ export async function createApp(options = {}) {
1759
1857
  const buildLock = lock;
1760
1858
  const publishingId = depId;
1761
1859
  await withPublication(() => {
1762
- const owned = db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now());
1860
+ const owned = locks.owns(buildLock.name, buildLock.owner, now());
1763
1861
  const pending = db.get('SELECT 1 FROM deployments WHERE id=? AND state=?', publishingId, 'building');
1764
1862
  if (!owned || !pending)
1765
1863
  throw httpError(409, 'deployment was cancelled or its build lease was lost');
1766
- db.exec('BEGIN IMMEDIATE');
1767
- try {
1864
+ immediateTransaction(db, () => {
1768
1865
  const published = db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), publishingId);
1769
- if (published.changes !== 1 ||
1770
- !db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now()))
1866
+ if (published.changes !== 1 || !locks.owns(buildLock.name, buildLock.owner, now()))
1771
1867
  throw httpError(409, 'deployment was cancelled or its build lease was lost');
1772
1868
  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);
1869
+ // The moved service starts empty: keep what writes to it stopped until its data is restored.
1870
+ for (const moved of acknowledgedMoves.filter((name) => compose.services?.[name]))
1871
+ for (const writer of discoverWriters(compose, `service:${moved}`))
1872
+ if (!acknowledgedMoves.includes(writer))
1873
+ db.run('INSERT OR REPLACE INTO service_holds(project_id,service,reason,restore_id,created_at) VALUES(?,?,?,?,?)', project, writer, `moved ${moved}`, null, ts);
1773
1874
  for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
1774
1875
  const lp = volumeSpec?.['x-tug'] || {};
1775
1876
  const nodeName = lp.placement?.nodes?.[0] || null;
@@ -1779,17 +1880,7 @@ export async function createApp(options = {}) {
1779
1880
  db.run(`INSERT INTO volumes(id,project_id,name,type,driver,node_id,config_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(project_id,name) DO UPDATE SET type=excluded.type,driver=excluded.driver,node_id=excluded.node_id,config_json=excluded.config_json,updated_at=excluded.updated_at`, id('vol'), project, volumeName, lp.type || 'local', lp.driver || null, node?.id || null, JSON.stringify(lp), ts, ts);
1780
1881
  }
1781
1882
  db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', publishingId, now(), project);
1782
- db.exec('COMMIT');
1783
- }
1784
- catch (e) {
1785
- try {
1786
- db.exec('ROLLBACK');
1787
- }
1788
- catch {
1789
- /* Preserve the publication error. */
1790
- }
1791
- throw e;
1792
- }
1883
+ });
1793
1884
  });
1794
1885
  audit(req.actor, {
1795
1886
  type: 'deploy',
@@ -1888,9 +1979,21 @@ export async function createApp(options = {}) {
1888
1979
  sendError(res, e);
1889
1980
  }
1890
1981
  }));
1891
- api('get', '/api/v1/volumes', queryProject, async (req, res) => res.json({
1892
- volumes: db.all('SELECT * FROM volumes WHERE project_id=? ORDER BY name', String(req.query.project || 'site')),
1893
- }));
1982
+ api('get', '/api/v1/volumes', queryProject, async (req, res) => {
1983
+ const project = String(req.query.project || 'site');
1984
+ const desired = currentDesired(project);
1985
+ const state = desired ? JSON.parse(desired.state_json) : undefined;
1986
+ const volumes = db.all('SELECT v.*,n.name AS node FROM volumes v LEFT JOIN nodes n ON n.id=v.node_id WHERE v.project_id=? ORDER BY v.name', project);
1987
+ res.json({
1988
+ volumes: volumes.map((volume) => ({
1989
+ ...volume,
1990
+ persistent: Boolean(JSON.parse(volume.config_json || '{}').persistent || volume.type !== 'ephemeral'),
1991
+ usedBy: Object.entries(state?.compose?.services || {})
1992
+ .filter(([, service]) => (service.volumes || []).some((mount) => (typeof mount === 'string' ? mount.split(':')[0] : mount.source) === volume.name))
1993
+ .map(([name]) => name),
1994
+ })),
1995
+ });
1996
+ });
1894
1997
  api('delete', '/api/v1/volumes/:name', queryProject, async (req, res) => {
1895
1998
  const project = String(req.query.project || 'site');
1896
1999
  const deleteData = String(req.query.deleteData || 'false') === 'true';
@@ -1933,6 +2036,31 @@ export async function createApp(options = {}) {
1933
2036
  releaseLock(lock);
1934
2037
  }
1935
2038
  });
2039
+ api('post', '/api/v1/holds/release', bodyProject, async (req, res) => withWriter(async () => {
2040
+ const project = String(req.body?.project || 'site');
2041
+ const services = Array.isArray(req.body?.services)
2042
+ ? req.body.services.map(String)
2043
+ : [];
2044
+ // Only holds a deploy placed for a move; a restore's holds are dropped by `restore release`.
2045
+ const rows = db.all('SELECT service FROM service_holds WHERE project_id=? AND restore_id IS NULL', project);
2046
+ const released = rows
2047
+ .map((row) => row.service)
2048
+ .filter((service) => !services.length || services.includes(service));
2049
+ for (const service of released)
2050
+ db.run('DELETE FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', project, service);
2051
+ wakeAll(project);
2052
+ res.json({ ok: true, project, released });
2053
+ }));
2054
+ api('post', '/api/v1/images/prune', null, async (req, res) => withWriter(async () => {
2055
+ const keep = req.body?.keep === undefined ? 5 : Number(req.body.keep);
2056
+ if (!Number.isInteger(keep) || keep < 1)
2057
+ return res.status(400).json({ error: 'keep must be a positive integer' });
2058
+ res.json({
2059
+ dryRun: Boolean(req.body?.dryRun),
2060
+ keep,
2061
+ ...(await pruneArtifacts(keep, Boolean(req.body?.dryRun))),
2062
+ });
2063
+ }));
1936
2064
  api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
1937
2065
  const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
1938
2066
  // The archive holds the master key and the CA key, so nothing here is ever readable beyond the control user.
@@ -1954,14 +2082,16 @@ export async function createApp(options = {}) {
1954
2082
  recursive: true,
1955
2083
  filter: (src) => path.basename(src) !== 'server.json',
1956
2084
  });
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 });
2085
+ const withImages = req.body?.images !== false;
2086
+ if (withImages)
2087
+ await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
2088
+ await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control', images: withImages }, null, 2), { mode: 0o600 });
1959
2089
  await tar.c({ gzip: true, file: artifactRef, cwd: staging, mode: 0o600 }, [
1960
2090
  'tug.db',
1961
2091
  'master.key',
1962
2092
  'metadata.json',
1963
2093
  'pki',
1964
- 'images',
2094
+ ...(withImages ? ['images'] : []),
1965
2095
  ]);
1966
2096
  }
1967
2097
  catch (error) {
@@ -2007,11 +2137,16 @@ export async function createApp(options = {}) {
2007
2137
  for (const secret of snapshot.all('SELECT * FROM secrets'))
2008
2138
  decrypt(secret.encrypted_value, key);
2009
2139
  const states = snapshot.all('SELECT * FROM desired_states');
2010
- for (const stateRow of states) {
2140
+ const meta = JSON.parse(await fs.readFile(path.join(restoreDir, 'metadata.json'), 'utf8').catch(() => '{}'));
2141
+ // A backup taken without images restores the archive-less builds as pruned on the next start.
2142
+ for (const stateRow of meta.images === false ? [] : states) {
2011
2143
  const state = JSON.parse(stateRow.state_json);
2012
2144
  for (const service of Object.values(state.services)) {
2013
2145
  if (!service.artifact)
2014
2146
  continue;
2147
+ // `image prune` already removed this one before the backup.
2148
+ if (snapshot.get("SELECT 1 FROM builds WHERE id=? AND state='pruned'", service.artifact.buildId))
2149
+ continue;
2015
2150
  const build = snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
2016
2151
  if (!build?.artifact_path ||
2017
2152
  !/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
@@ -2117,6 +2252,50 @@ export async function createApp(options = {}) {
2117
2252
  res.json({ commandId: cmdId, nodeId: node.id });
2118
2253
  });
2119
2254
  api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(db.get('SELECT * FROM commands WHERE id=?', String(req.params.id))));
2255
+ api('get', '/api/v1/projects', null, async (req, res) => {
2256
+ const scope = projectScope(req.actor, 'id');
2257
+ res.json({
2258
+ projects: db.all(`SELECT id AS name FROM projects WHERE ${scope.where} ORDER BY id`, ...scope.args),
2259
+ });
2260
+ });
2261
+ api('get', '/api/v1/services', queryProject, async (req, res) => {
2262
+ const project = String(req.query.project || 'site');
2263
+ const row = currentDesired(project);
2264
+ const desired = row ? JSON.parse(row.state_json) : undefined;
2265
+ const nodes = db.all('SELECT * FROM nodes ORDER BY name');
2266
+ res.json({
2267
+ services: Object.entries(desired?.services || {})
2268
+ .map(([name, service]) => ({
2269
+ name,
2270
+ nodes: nodes
2271
+ .filter((node) => !service.placement?.nodes?.length || service.placement.nodes.includes(node.name))
2272
+ .map((node) => ({
2273
+ name: node.name,
2274
+ status: node.disabled_at ? 'disabled' : isStale(node) ? 'stale' : node.status,
2275
+ })),
2276
+ }))
2277
+ .sort((a, b) => a.name.localeCompare(b.name)),
2278
+ });
2279
+ });
2280
+ api('get', '/api/v1/deployments', queryProject, async (req, res) => {
2281
+ res.json({
2282
+ deployments: db.all(`SELECT id,project_id,version,state,created_at FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) ORDER BY created_at DESC,id`, String(req.query.project || 'site')),
2283
+ });
2284
+ });
2285
+ api('get', '/api/v1/backup/snapshots', queryProject, async (req, res) => {
2286
+ const project = String(req.query.project || 'site');
2287
+ const target = String(req.query.target || '');
2288
+ if (!target)
2289
+ throw httpError(400, 'target is required');
2290
+ res.json({
2291
+ snapshots: db.all(`SELECT id,target,snapshot_id,finished_at,size_bytes,trigger FROM backup_runs WHERE project_id=? AND target=? AND state='succeeded' AND snapshot_id IS NOT NULL ORDER BY finished_at DESC,id`, project, target),
2292
+ });
2293
+ });
2294
+ api('get', '/api/v1/holds', queryProject, async (req, res) => {
2295
+ res.json({
2296
+ holds: db.all('SELECT service,reason,created_at FROM service_holds WHERE project_id=? AND restore_id IS NULL ORDER BY service', String(req.query.project || 'site')),
2297
+ });
2298
+ });
2120
2299
  api('get', '/api/v1/status', null, async (req, res) => {
2121
2300
  const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
2122
2301
  const cutoff = new Date(Date.now() - staleSeconds * 1000).toISOString();
@@ -18,6 +18,11 @@ const LAST_USED_INTERVAL_MS = 60_000;
18
18
  */
19
19
  export const ROUTE_ROLES = {
20
20
  'GET /api/v1/live': 'deploy',
21
+ 'GET /api/v1/services': 'read',
22
+ 'GET /api/v1/projects': 'read',
23
+ 'GET /api/v1/deployments': 'read',
24
+ 'GET /api/v1/backup/snapshots': 'read',
25
+ 'GET /api/v1/holds': 'admin',
21
26
  'GET /api/v1/status': 'read',
22
27
  'GET /api/v1/events': 'read',
23
28
  'GET /api/v1/projects/:project': 'read',
@@ -53,6 +58,8 @@ export const ROUTE_ROLES = {
53
58
  'DELETE /api/v1/backup/destination': 'admin',
54
59
  'GET /api/v1/backup/repository-password': 'admin',
55
60
  'POST /api/v1/control/backups/run': 'admin',
61
+ 'POST /api/v1/images/prune': 'admin',
62
+ 'POST /api/v1/holds/release': 'admin',
56
63
  'POST /api/v1/control/restore': 'admin',
57
64
  'GET /api/v1/diagnostics': 'admin',
58
65
  'POST /api/v1/diagnostics/nodes': 'admin',
@@ -0,0 +1,14 @@
1
+ import type { BackupDestinationRow, Db } from './db.ts';
2
+ interface DestinationSecret {
3
+ id: string;
4
+ name: string;
5
+ encryptedValue: string;
6
+ }
7
+ /** Accepts encrypted values only. Encryption and credential parsing belong to the caller. */
8
+ export declare function backupDestinationQueries(db: Db): {
9
+ find(): BackupDestinationRow | undefined;
10
+ secret(name: string): string | undefined;
11
+ replace(url: string, secrets: DestinationSecret[], at: string): void;
12
+ remove(): boolean;
13
+ };
14
+ export {};
@@ -0,0 +1,28 @@
1
+ import { BACKUP_SECRET_PROJECT } from "./backup-destination.js";
2
+ import { immediateTransaction } from "./db-transaction.js";
3
+ /** Accepts encrypted values only. Encryption and credential parsing belong to the caller. */
4
+ export function backupDestinationQueries(db) {
5
+ return {
6
+ find() {
7
+ return db.get("SELECT * FROM backup_destination WHERE id='default'");
8
+ },
9
+ secret(name) {
10
+ return db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', BACKUP_SECRET_PROJECT, name)?.encrypted_value;
11
+ },
12
+ replace(url, secrets, at) {
13
+ immediateTransaction(db, () => {
14
+ db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
15
+ for (const secret of secrets)
16
+ db.run('INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)', secret.id, BACKUP_SECRET_PROJECT, secret.name, secret.encryptedValue, 1, at, at);
17
+ db.run(`INSERT INTO backup_destination(id,url,env_names_json,checked_at,check_result_json,updated_at) VALUES('default',?,?,NULL,NULL,?)
18
+ ON CONFLICT(id) DO UPDATE SET url=excluded.url,env_names_json=excluded.env_names_json,checked_at=NULL,check_result_json=NULL,updated_at=excluded.updated_at`, url, JSON.stringify(secrets.map((secret) => secret.name).sort()), at);
19
+ });
20
+ },
21
+ remove() {
22
+ return immediateTransaction(db, () => {
23
+ db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
24
+ return db.run("DELETE FROM backup_destination WHERE id='default'").changes > 0;
25
+ });
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,10 @@
1
+ import type { Db, LockRow } from './db.ts';
2
+ /** SQL persistence only; callers own lease clocks, heartbeats and conflict responses. */
3
+ export declare function lockQueries(db: Db): {
4
+ purgeExpired(at: string): void;
5
+ insert(lock: LockRow): void;
6
+ find(name: string): LockRow | undefined;
7
+ owns(name: string, owner: string, at: string): boolean;
8
+ renew(name: string, owner: string, expiresAt: string): void;
9
+ release(name: string, owner: string): void;
10
+ };