@wpelevator/tug 0.12.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 (68) hide show
  1. package/README.md +7 -3
  2. package/dist/backup-restore.d.ts +1 -1
  3. package/dist/backup-restore.js +3 -1
  4. package/dist/backup-scheduler.js +3 -1
  5. package/dist/cli-build.d.ts +1 -1
  6. package/dist/cli-build.js +4 -1
  7. package/dist/cli-process.d.ts +13 -0
  8. package/dist/cli-process.js +46 -0
  9. package/dist/cli-progress.d.ts +11 -0
  10. package/dist/cli-progress.js +37 -0
  11. package/dist/cli-prompt.js +5 -4
  12. package/dist/cli-selection.d.ts +13 -0
  13. package/dist/cli-selection.js +39 -0
  14. package/dist/commands/backup.js +48 -31
  15. package/dist/commands/context.d.ts +2 -5
  16. package/dist/commands/context.js +50 -28
  17. package/dist/commands/control.js +55 -42
  18. package/dist/commands/credential.js +21 -2
  19. package/dist/commands/deploy.js +97 -70
  20. package/dist/commands/hold.js +12 -4
  21. package/dist/commands/host.d.ts +2 -2
  22. package/dist/commands/host.js +13 -8
  23. package/dist/commands/node.d.ts +1 -0
  24. package/dist/commands/node.js +103 -71
  25. package/dist/commands/observe.js +7 -2
  26. package/dist/commands/restore.js +71 -50
  27. package/dist/commands/secret.js +8 -3
  28. package/dist/commands/selection.d.ts +22 -0
  29. package/dist/commands/selection.js +90 -0
  30. package/dist/commands/service.js +17 -6
  31. package/dist/commands/volume.js +3 -2
  32. package/dist/control.js +95 -47
  33. package/dist/credentials.js +5 -0
  34. package/dist/db-backup-destination.d.ts +14 -0
  35. package/dist/db-backup-destination.js +28 -0
  36. package/dist/db-locks.d.ts +10 -0
  37. package/dist/db-locks.js +23 -0
  38. package/dist/db-transaction.d.ts +3 -0
  39. package/dist/db-transaction.js +24 -0
  40. package/dist/image.d.ts +9 -0
  41. package/dist/image.js +19 -0
  42. package/dist/util.d.ts +2 -0
  43. package/dist/util.js +6 -0
  44. package/docs/access-control.md +166 -0
  45. package/docs/architecture.md +254 -0
  46. package/docs/backups.md +160 -0
  47. package/docs/cli.md +182 -0
  48. package/docs/compose.md +245 -0
  49. package/docs/configuration.md +52 -0
  50. package/docs/control-plane.md +112 -0
  51. package/docs/development.md +140 -0
  52. package/docs/getting-started.md +75 -0
  53. package/docs/healthchecks.md +51 -0
  54. package/docs/migration.md +113 -0
  55. package/docs/networking.md +150 -0
  56. package/docs/nodes.md +143 -0
  57. package/docs/observability.md +213 -0
  58. package/docs/real-host-evaluation.md +224 -0
  59. package/docs/releases.md +39 -0
  60. package/docs/roadmap.md +234 -0
  61. package/docs/security.md +198 -0
  62. package/docs/service-placement.md +72 -0
  63. package/docs/standalone-binary.md +79 -0
  64. package/docs/swarm-comparison.md +197 -0
  65. package/docs/terminology.md +74 -0
  66. package/docs/testing.md +254 -0
  67. package/docs/troubleshooting.md +33 -0
  68. 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,7 +30,7 @@ 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
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";
@@ -864,18 +867,24 @@ export async function createApp(options = {}) {
864
867
  api('get', '/api/v1/live', null, (_req, res) => {
865
868
  res.status(426).json({ error: 'WebSocket upgrade required' });
866
869
  });
870
+ const locks = lockQueries(db);
867
871
  function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
868
872
  const name = `project:${project}`;
869
873
  const ts = now();
870
- db.run('DELETE FROM locks WHERE expires_at < ?', ts);
874
+ locks.purgeExpired(ts);
871
875
  const owner = `${operation}:${id('lock')}`;
872
876
  try {
873
- 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
+ });
874
883
  }
875
884
  catch (e) {
876
885
  if (!isUniqueViolation(e))
877
886
  throw e;
878
- const existing = db.get('SELECT * FROM locks WHERE name=?', name);
887
+ const existing = locks.find(name);
879
888
  const err = new Error(`project ${project} is locked by ${existing?.owner} until ${existing?.expires_at}`);
880
889
  err.status = 409;
881
890
  err.lock = existing;
@@ -883,7 +892,7 @@ export async function createApp(options = {}) {
883
892
  }
884
893
  const heartbeat = setInterval(() => {
885
894
  try {
886
- 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());
887
896
  }
888
897
  catch {
889
898
  /* Best-effort bookkeeping. */
@@ -897,7 +906,7 @@ export async function createApp(options = {}) {
897
906
  clearInterval(lock.heartbeat);
898
907
  if (lock)
899
908
  try {
900
- db.run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner);
909
+ locks.release(lock.name, lock.owner);
901
910
  }
902
911
  catch {
903
912
  /* Best-effort bookkeeping. */
@@ -1285,16 +1294,17 @@ export async function createApp(options = {}) {
1285
1294
  });
1286
1295
  res.json({ ok: true, name: String(req.params.name) });
1287
1296
  });
1288
- const backupDestination = () => db.get("SELECT * FROM backup_destination WHERE id='default'");
1297
+ const destinationQueries = backupDestinationQueries(db);
1298
+ const backupDestination = () => destinationQueries.find();
1289
1299
  const backupEnv = () => {
1290
1300
  const row = backupDestination();
1291
1301
  if (!row)
1292
1302
  return {};
1293
1303
  const env = {};
1294
1304
  for (const name of JSON.parse(row.env_names_json)) {
1295
- const secret = db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', BACKUP_SECRET_PROJECT, name);
1305
+ const secret = destinationQueries.secret(name);
1296
1306
  if (secret)
1297
- env[name] = decrypt(secret.encrypted_value, masterKey);
1307
+ env[name] = decrypt(secret, masterKey);
1298
1308
  }
1299
1309
  return env;
1300
1310
  };
@@ -1371,7 +1381,9 @@ export async function createApp(options = {}) {
1371
1381
  return res.status(started.status).json({ error: started.error });
1372
1382
  res.json({ confirmed: true, plan, ...started });
1373
1383
  });
1374
- 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
+ }));
1375
1387
  api('post', '/api/v1/restores/:id/release', null, async (req, res) => {
1376
1388
  const result = restores.releaseInterrupted(String(req.params.id));
1377
1389
  if ('error' in result)
@@ -1385,6 +1397,7 @@ export async function createApp(options = {}) {
1385
1397
  res.json(result);
1386
1398
  });
1387
1399
  api('get', '/api/v1/backups', queryProject, async (req, res) => {
1400
+ backups.sync();
1388
1401
  const project = String(req.query.project || 'site');
1389
1402
  const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
1390
1403
  res.json({
@@ -1415,19 +1428,11 @@ export async function createApp(options = {}) {
1415
1428
  return res.status(400).json({ error: errMessage(e) });
1416
1429
  }
1417
1430
  const ts = now();
1418
- db.exec('BEGIN IMMEDIATE');
1419
- try {
1420
- db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
1421
- for (const [name, value] of Object.entries(parsed.env))
1422
- 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);
1423
- db.run(`INSERT INTO backup_destination(id,url,env_names_json,checked_at,check_result_json,updated_at) VALUES('default',?,?,NULL,NULL,?)
1424
- 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);
1425
- db.exec('COMMIT');
1426
- }
1427
- catch (e) {
1428
- db.exec('ROLLBACK');
1429
- throw e;
1430
- }
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);
1431
1436
  audit(req.actor, {
1432
1437
  type: 'backup.destination_set',
1433
1438
  message: `backup destination set to ${parsed.url} by ${req.actor.name}`,
@@ -1436,14 +1441,13 @@ export async function createApp(options = {}) {
1436
1441
  res.json({ ok: true, url: parsed.url, envNames: Object.keys(parsed.env).sort() });
1437
1442
  });
1438
1443
  api('delete', '/api/v1/backup/destination', null, async (req, res) => {
1439
- db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
1440
- const removed = db.run("DELETE FROM backup_destination WHERE id='default'");
1441
- if (removed.changes)
1444
+ const removed = destinationQueries.remove();
1445
+ if (removed)
1442
1446
  audit(req.actor, {
1443
1447
  type: 'backup.destination_removed',
1444
1448
  message: `backup destination removed by ${req.actor.name}`,
1445
1449
  });
1446
- res.json({ ok: true, removed: removed.changes > 0 });
1450
+ res.json({ ok: true, removed });
1447
1451
  });
1448
1452
  api('get', '/api/v1/backup/repository-password', null, async (req, res) => {
1449
1453
  const project = String(req.query.project || '');
@@ -1771,7 +1775,7 @@ export async function createApp(options = {}) {
1771
1775
  lock = acquireProjectLock(project, 'deploy');
1772
1776
  const active = db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
1773
1777
  if (active)
1774
- 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}`);
1775
1779
  depId = id('dep');
1776
1780
  const ts = now();
1777
1781
  db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
@@ -1853,15 +1857,13 @@ export async function createApp(options = {}) {
1853
1857
  const buildLock = lock;
1854
1858
  const publishingId = depId;
1855
1859
  await withPublication(() => {
1856
- 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());
1857
1861
  const pending = db.get('SELECT 1 FROM deployments WHERE id=? AND state=?', publishingId, 'building');
1858
1862
  if (!owned || !pending)
1859
1863
  throw httpError(409, 'deployment was cancelled or its build lease was lost');
1860
- db.exec('BEGIN IMMEDIATE');
1861
- try {
1864
+ immediateTransaction(db, () => {
1862
1865
  const published = db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), publishingId);
1863
- if (published.changes !== 1 ||
1864
- !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()))
1865
1867
  throw httpError(409, 'deployment was cancelled or its build lease was lost');
1866
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);
1867
1869
  // The moved service starts empty: keep what writes to it stopped until its data is restored.
@@ -1878,17 +1880,7 @@ export async function createApp(options = {}) {
1878
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);
1879
1881
  }
1880
1882
  db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', publishingId, now(), project);
1881
- db.exec('COMMIT');
1882
- }
1883
- catch (e) {
1884
- try {
1885
- db.exec('ROLLBACK');
1886
- }
1887
- catch {
1888
- /* Preserve the publication error. */
1889
- }
1890
- throw e;
1891
- }
1883
+ });
1892
1884
  });
1893
1885
  audit(req.actor, {
1894
1886
  type: 'deploy',
@@ -1987,9 +1979,21 @@ export async function createApp(options = {}) {
1987
1979
  sendError(res, e);
1988
1980
  }
1989
1981
  }));
1990
- api('get', '/api/v1/volumes', queryProject, async (req, res) => res.json({
1991
- volumes: db.all('SELECT * FROM volumes WHERE project_id=? ORDER BY name', String(req.query.project || 'site')),
1992
- }));
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
+ });
1993
1997
  api('delete', '/api/v1/volumes/:name', queryProject, async (req, res) => {
1994
1998
  const project = String(req.query.project || 'site');
1995
1999
  const deleteData = String(req.query.deleteData || 'false') === 'true';
@@ -2248,6 +2252,50 @@ export async function createApp(options = {}) {
2248
2252
  res.json({ commandId: cmdId, nodeId: node.id });
2249
2253
  });
2250
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
+ });
2251
2299
  api('get', '/api/v1/status', null, async (req, res) => {
2252
2300
  const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
2253
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',
@@ -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
+ };
@@ -0,0 +1,23 @@
1
+ /** SQL persistence only; callers own lease clocks, heartbeats and conflict responses. */
2
+ export function lockQueries(db) {
3
+ return {
4
+ purgeExpired(at) {
5
+ db.run('DELETE FROM locks WHERE expires_at < ?', at);
6
+ },
7
+ insert(lock) {
8
+ db.run('INSERT INTO locks(name,owner,expires_at,created_at) VALUES(?,?,?,?)', lock.name, lock.owner, lock.expires_at, lock.created_at);
9
+ },
10
+ find(name) {
11
+ return db.get('SELECT * FROM locks WHERE name=?', name);
12
+ },
13
+ owns(name, owner, at) {
14
+ return Boolean(db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', name, owner, at));
15
+ },
16
+ renew(name, owner, expiresAt) {
17
+ db.run('UPDATE locks SET expires_at=? WHERE name=? AND owner=?', expiresAt, name, owner);
18
+ },
19
+ release(name, owner) {
20
+ db.run('DELETE FROM locks WHERE name=? AND owner=?', name, owner);
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,3 @@
1
+ import type { Db } from './db-driver.ts';
2
+ /** Run synchronous work in an immediate transaction. Nested transactions are rejected by SQLite. */
3
+ export declare function immediateTransaction<T>(db: Db, operation: () => T & (T extends PromiseLike<unknown> ? never : unknown)): T;
@@ -0,0 +1,24 @@
1
+ /** Run synchronous work in an immediate transaction. Nested transactions are rejected by SQLite. */
2
+ export function immediateTransaction(db, operation) {
3
+ // Keep BEGIN outside the catch: a failed BEGIN must not roll back somebody else's transaction.
4
+ db.exec('BEGIN IMMEDIATE');
5
+ try {
6
+ const result = operation();
7
+ if (result !== null &&
8
+ (typeof result === 'object' || typeof result === 'function') &&
9
+ 'then' in result &&
10
+ typeof result.then === 'function')
11
+ throw new TypeError('Database transactions require synchronous callbacks');
12
+ db.exec('COMMIT');
13
+ return result;
14
+ }
15
+ catch (error) {
16
+ try {
17
+ db.exec('ROLLBACK');
18
+ }
19
+ catch {
20
+ /* Preserve the operation or commit failure. */
21
+ }
22
+ throw error;
23
+ }
24
+ }
package/dist/image.d.ts CHANGED
@@ -7,3 +7,12 @@ export declare function packageVersion(): string;
7
7
  export declare function defaultImage(version?: string): string;
8
8
  /** An explicit `--image` always wins; otherwise a locally built image is `tug:local` and anything else is the published image. */
9
9
  export declare function resolveImage(image: string | undefined, buildLocalImage?: boolean, version?: string): string;
10
+ /**
11
+ * Fails before any host is touched when the published image for this CLI version does not exist yet. A release
12
+ * publishes the npm package a few minutes before its image, so a fresh CLI can briefly name a missing tag. Only a
13
+ * definitive "no such manifest" answer fails; no Docker, no network or a private registry carry on silently.
14
+ */
15
+ export declare function assertImagePublished(image: string, inspect?: (image: string) => {
16
+ status: number | null;
17
+ stderr: string;
18
+ }): void;
package/dist/image.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -18,3 +19,21 @@ export function defaultImage(version = packageVersion()) {
18
19
  export function resolveImage(image, buildLocalImage = false, version = packageVersion()) {
19
20
  return image || (buildLocalImage ? LOCAL_IMAGE : defaultImage(version));
20
21
  }
22
+ /**
23
+ * Fails before any host is touched when the published image for this CLI version does not exist yet. A release
24
+ * publishes the npm package a few minutes before its image, so a fresh CLI can briefly name a missing tag. Only a
25
+ * definitive "no such manifest" answer fails; no Docker, no network or a private registry carry on silently.
26
+ */
27
+ export function assertImagePublished(image, inspect = (ref) => {
28
+ const result = spawnSync('docker', ['manifest', 'inspect', ref], {
29
+ encoding: 'utf8',
30
+ timeout: 20_000,
31
+ });
32
+ return { status: result.status, stderr: String(result.stderr ?? '') };
33
+ }) {
34
+ if (!image.startsWith(`${IMAGE_REPOSITORY}:`))
35
+ return;
36
+ const result = inspect(image);
37
+ if (result.status !== 0 && /no such manifest|manifest unknown|not found/i.test(result.stderr))
38
+ throw new Error(`${image} is not published yet: a release builds its image a few minutes after the npm package. Retry shortly, or pass --image ${IMAGE_REPOSITORY}:latest to use the latest release.`);
39
+ }
package/dist/util.d.ts CHANGED
@@ -20,3 +20,5 @@ export declare const isDuration: (value: unknown) => boolean;
20
20
  * interface. Hostnames and other non-IP strings return false.
21
21
  */
22
22
  export declare function isPublicAddress(address: string): boolean;
23
+ /** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
24
+ export declare function describeFetchError(error: unknown): string;
package/dist/util.js CHANGED
@@ -90,3 +90,9 @@ export function isPublicAddress(address) {
90
90
  }
91
91
  return false;
92
92
  }
93
+ /** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
94
+ export function describeFetchError(error) {
95
+ const e = error;
96
+ const cause = e?.cause?.code || e?.cause?.message;
97
+ return cause ? `${e?.message ?? 'error'} (${cause})` : errMessage(error);
98
+ }