@wpelevator/tug 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +10 -11
  2. package/dist/agent-jobs.d.ts +41 -0
  3. package/dist/agent-jobs.js +161 -0
  4. package/dist/agent.d.ts +1 -3
  5. package/dist/agent.js +58 -35
  6. package/dist/backup-config.d.ts +47 -0
  7. package/dist/backup-config.js +256 -0
  8. package/dist/backup-destination.d.ts +22 -0
  9. package/dist/backup-destination.js +72 -0
  10. package/dist/backup-job.d.ts +32 -0
  11. package/dist/backup-job.js +352 -0
  12. package/dist/backup-restore.d.ts +67 -0
  13. package/dist/backup-restore.js +297 -0
  14. package/dist/backup-scheduler.d.ts +54 -0
  15. package/dist/backup-scheduler.js +403 -0
  16. package/dist/build.d.ts +2 -3
  17. package/dist/build.js +1 -3
  18. package/dist/cli-build.d.ts +3 -3
  19. package/dist/cli-build.js +18 -6
  20. package/dist/cli-config.d.ts +45 -5
  21. package/dist/cli-config.js +269 -19
  22. package/dist/cli-live-transport.d.ts +1 -1
  23. package/dist/cli-live-transport.js +2 -2
  24. package/dist/cli-live.d.ts +2 -0
  25. package/dist/cli-live.js +1 -0
  26. package/dist/cli.js +217 -49
  27. package/dist/compose.d.ts +1 -2
  28. package/dist/compose.js +7 -6
  29. package/dist/container-spec.d.ts +3 -2
  30. package/dist/container-spec.js +9 -34
  31. package/dist/control.d.ts +4 -2
  32. package/dist/control.js +591 -381
  33. package/dist/cron.d.ts +17 -0
  34. package/dist/cron.js +124 -0
  35. package/dist/db-driver.d.ts +20 -0
  36. package/dist/db-driver.js +11 -0
  37. package/dist/db-node.d.ts +3 -0
  38. package/dist/db-node.js +60 -0
  39. package/dist/db.d.ts +79 -6
  40. package/dist/db.js +47 -26
  41. package/dist/docker-api.d.ts +7 -0
  42. package/dist/docker-api.js +15 -3
  43. package/dist/live.d.ts +8 -0
  44. package/dist/live.js +84 -0
  45. package/dist/protocol.d.ts +121 -12
  46. package/dist/protocol.js +7 -0
  47. package/dist/registry.d.ts +17 -0
  48. package/dist/registry.js +133 -0
  49. package/dist/templates/control-compose.yml +0 -4
  50. package/dist/templates/node-install.sh +0 -1
  51. package/dist/tokens.d.ts +7 -7
  52. package/dist/tokens.js +34 -21
  53. package/package.json +2 -3
package/dist/control.js CHANGED
@@ -1,27 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
  import express from 'express';
3
+ import { resolveImage, parseRegistryCredentials, imageLocation, REGISTRY_AUTH_SECRET, } from "./registry.js";
3
4
  import multer from 'multer';
4
5
  import fs from 'node:fs/promises';
5
6
  import { createReadStream } from 'node:fs';
6
7
  import { pipeline } from 'node:stream/promises';
7
8
  import { saveImageArchive, inspectImage, validateImageArchive, hashArchive } from "./artifacts.js";
8
- import sqlite3 from 'sqlite3';
9
- import { open } from 'sqlite';
10
9
  import { scanArchive } from "./archive.js";
11
10
  import https from 'node:https';
12
11
  import http from 'node:http';
13
12
  import path from 'node:path';
14
13
  import { WebSocketServer, WebSocket } from 'ws';
15
14
  import * as tar from 'tar';
16
- import { openDb } from "./db.js";
15
+ import { openDb, openSnapshotDb } from "./db.js";
16
+ import { isUniqueViolation } from "./db-driver.js";
17
17
  import { encrypt, decrypt, loadOrCreateMasterKey } from "./secrets.js";
18
18
  import { buildDesiredState, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
19
- import { buildAndPush, unpackContext, run as runCommand } from "./build.js";
19
+ import { buildImage, unpackContext, run as runCommand } from "./build.js";
20
20
  import { sentinelNames } from "./normalize.js";
21
21
  import { ensureDir, errMessage, hashToken, id, now, token } from "./util.js";
22
22
  import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, } from "./mesh.js";
23
23
  import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
24
- import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, } from "./protocol.js";
24
+ 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";
25
+ import { BACKUP_SECRET_PROJECT, isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
26
+ import { createBackupService } from "./backup-scheduler.js";
27
+ import { createRestoreService } from "./backup-restore.js";
25
28
  import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createToken, listTokens, parseTokenRequest, queryProject, recordEvent, revokeToken, } from "./tokens.js";
26
29
  class WakeHub {
27
30
  cursors = new Map();
@@ -119,7 +122,18 @@ function parseLiveRequest(value) {
119
122
  Number(value.tail) <= 10_000)
120
123
  return value;
121
124
  if (value.operation === 'exec' &&
122
- frameKeys(value, [...common, 'service', 'command', 'interactive', 'tty', 'columns', 'rows']) &&
125
+ frameKeys(value, [
126
+ ...common,
127
+ 'service',
128
+ 'command',
129
+ 'interactive',
130
+ 'tty',
131
+ 'columns',
132
+ 'rows',
133
+ 'oneOff',
134
+ ]) &&
135
+ (value.oneOff === undefined || value.oneOff === true) &&
136
+ (value.oneOff !== true || (value.interactive === false && value.tty === false)) &&
123
137
  Array.isArray(value.command) &&
124
138
  value.command.length > 0 &&
125
139
  value.command.length <= 256 &&
@@ -394,7 +408,8 @@ const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map((s) => `'${s}'`).join(','
394
408
  const PORT = Number(process.env.PORT || 8080);
395
409
  export async function createApp(options = {}) {
396
410
  const images = {
397
- build: buildAndPush,
411
+ resolve: resolveImage,
412
+ build: buildImage,
398
413
  save: saveImageArchive,
399
414
  inspect: inspectImage,
400
415
  validate: validateImageArchive,
@@ -411,9 +426,6 @@ export async function createApp(options = {}) {
411
426
  });
412
427
  const dbFile = path.join(DATA_DIR, 'tug.db');
413
428
  const masterKeyFile = path.join(DATA_DIR, 'master.key');
414
- const REGISTRY = process.env.TUG_REGISTRY?.trim() || '';
415
- const REGISTRY_AUTH_PATH = process.env.TUG_REGISTRY_AUTH_PATH || null;
416
- const REGISTRY_CONTAINER = process.env.TUG_REGISTRY_CONTAINER || null;
417
429
  // Networking layers are opt-in; with all of them off the work response is unchanged.
418
430
  const MESH = process.env.TUG_MESH === 'true';
419
431
  const MESH_CIDR = process.env.TUG_MESH_CIDR || DEFAULT_MESH_CIDR;
@@ -422,56 +434,6 @@ export async function createApp(options = {}) {
422
434
  const ACME_EMAIL = process.env.TUG_ACME_EMAIL || undefined;
423
435
  if (MESH)
424
436
  parseCidr(MESH_CIDR);
425
- let htpasswdQueue = Promise.resolve();
426
- function withHtpasswdLock(fn) {
427
- const next = htpasswdQueue.catch(() => { }).then(fn);
428
- htpasswdQueue = next.then(() => { }, () => { });
429
- return next;
430
- }
431
- async function reloadRegistry() {
432
- if (!REGISTRY_CONTAINER)
433
- return;
434
- await runCommand('docker', ['restart', REGISTRY_CONTAINER]).catch((e) => console.error(`registry reload failed: ${e.message}`));
435
- }
436
- async function upsertRegistryCredential(name, password) {
437
- if (!REGISTRY || !REGISTRY_AUTH_PATH)
438
- return;
439
- await withHtpasswdLock(async () => {
440
- const { out } = await runCommand('htpasswd', ['-nbB', name, password]);
441
- const line = out.trim();
442
- let existing = '';
443
- try {
444
- existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
445
- }
446
- catch { }
447
- const lines = existing
448
- .split('\n')
449
- .filter(Boolean)
450
- .filter((l) => !l.startsWith(`${name}:`));
451
- lines.push(line);
452
- await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
453
- await fs.writeFile(REGISTRY_AUTH_PATH, `${lines.join('\n')}\n`);
454
- });
455
- await reloadRegistry();
456
- }
457
- async function removeRegistryCredential(name) {
458
- if (!REGISTRY_AUTH_PATH)
459
- return;
460
- await withHtpasswdLock(async () => {
461
- let existing = '';
462
- try {
463
- existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
464
- }
465
- catch { }
466
- const lines = existing
467
- .split('\n')
468
- .filter(Boolean)
469
- .filter((l) => !l.startsWith(`${name}:`));
470
- await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
471
- await fs.writeFile(REGISTRY_AUTH_PATH, lines.length ? `${lines.join('\n')}\n` : '');
472
- });
473
- await reloadRegistry();
474
- }
475
437
  const db = await (options.openDatabase || openDb)(dbFile);
476
438
  const wakeHub = new WakeHub();
477
439
  // Events are an operator-facing log, not a record of truth, so keep them bounded. `0` turns a limit off; anything that is
@@ -490,32 +452,37 @@ export async function createApp(options = {}) {
490
452
  const EVENT_MAX_ROWS = envCount('TUG_EVENT_MAX_ROWS', 100_000);
491
453
  const eventPruneIntervalMs = options.eventPruneIntervalMs ?? 60 * 60 * 1000;
492
454
  let lastEventPrune = Date.now();
493
- async function pruneEvents() {
455
+ function pruneEvents() {
494
456
  lastEventPrune = Date.now();
495
457
  let removed = 0;
496
458
  if (EVENT_RETENTION_DAYS > 0)
497
459
  removed +=
498
- (await db.run('DELETE FROM events WHERE created_at < ?', new Date(Date.now() - EVENT_RETENTION_DAYS * 86_400_000).toISOString())).changes ?? 0;
460
+ db.run('DELETE FROM events WHERE created_at < ?', new Date(Date.now() - EVENT_RETENTION_DAYS * 86_400_000).toISOString()).changes ?? 0;
499
461
  if (EVENT_MAX_ROWS > 0)
500
462
  removed +=
501
- (await db.run('DELETE FROM events WHERE id IN (SELECT id FROM events ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)', EVENT_MAX_ROWS)).changes ?? 0;
463
+ db.run('DELETE FROM events WHERE id IN (SELECT id FROM events ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)', EVENT_MAX_ROWS).changes ?? 0;
502
464
  if (removed)
503
465
  console.log(`removed ${removed} old events (kept ${EVENT_RETENTION_DAYS || 'any'} days, at most ${EVENT_MAX_ROWS || 'unlimited'} rows)`);
504
466
  return removed;
505
467
  }
506
- await pruneEvents().catch((e) => console.error(`event pruning failed: ${errMessage(e)}`));
468
+ try {
469
+ pruneEvents();
470
+ }
471
+ catch (e) {
472
+ console.error(`event pruning failed: ${errMessage(e)}`);
473
+ }
507
474
  const masterKey = await loadOrCreateMasterKey(masterKeyFile);
508
- const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
475
+ const operator = db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
509
476
  if (!operator) {
510
477
  const t = process.env.TUG_OPERATOR_TOKEN || token('tug_op');
511
- await db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'operator_token_hash', hashToken(t));
478
+ db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'operator_token_hash', hashToken(t));
512
479
  await fs
513
480
  .writeFile(path.join(DATA_DIR, 'operator.token'), `${t}\n`, { mode: 0o600 })
514
481
  .catch(() => { });
515
482
  console.log(`operator token: ${t}`);
516
483
  }
517
484
  const app = express();
518
- const authenticateAgent = async (raw) => raw
485
+ const authenticateAgent = (raw) => raw
519
486
  ? db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw))
520
487
  : undefined;
521
488
  let restartRequired = false;
@@ -579,13 +546,21 @@ export async function createApp(options = {}) {
579
546
  apiRoutes.set(key, role);
580
547
  route(method, url, [guard(role, resolve ?? undefined), ...handlers]);
581
548
  };
582
- const deploymentProject = async (req) => (await db.get('SELECT project_id FROM deployments WHERE id=?', req.params.id))?.project_id;
583
- const commandProject = async (req) => {
584
- const row = await db.get('SELECT project_id FROM commands WHERE id=?', req.params.id);
549
+ const deploymentProject = (req) => db.get('SELECT project_id FROM deployments WHERE id=?', req.params.id)
550
+ ?.project_id;
551
+ const commandProject = (req) => {
552
+ const row = db.get('SELECT project_id FROM commands WHERE id=?', req.params.id);
585
553
  return row ? row.project_id : undefined;
586
554
  };
587
555
  /** Audit trail entry for a control action; bookkeeping failures are logged, never returned to the caller. */
588
- const audit = (actor, event) => recordEvent(db, { ...event, actor: actor.name }).catch((e) => console.error(`audit event failed: ${errMessage(e)}`));
556
+ const audit = (actor, event) => {
557
+ try {
558
+ recordEvent(db, { ...event, actor: actor.name });
559
+ }
560
+ catch (e) {
561
+ console.error(`audit event failed: ${errMessage(e)}`);
562
+ }
563
+ };
589
564
  const projectScope = (actor, column) => actor.projects
590
565
  ? {
591
566
  where: `${column} IN (${actor.projects.map(() => '?').join(',') || "''"})`,
@@ -594,22 +569,22 @@ export async function createApp(options = {}) {
594
569
  : { where: '1=1', args: [] };
595
570
  const authAgent = async (req, res, next) => {
596
571
  const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
597
- const node = await authenticateAgent(raw);
572
+ const node = authenticateAgent(raw);
598
573
  if (!node)
599
574
  return res.status(401).json({ error: 'unauthorized' });
600
575
  req.node = node;
601
576
  next();
602
577
  };
603
- const wakeAll = async (project) => {
604
- for (const node of await db.all('SELECT id FROM nodes'))
578
+ const wakeAll = (project) => {
579
+ for (const node of db.all('SELECT id FROM nodes'))
605
580
  wakeHub.wake(node.id, project);
606
581
  };
607
582
  let endpointSnapshot = '';
608
- async function updateDeploymentConvergence(projectId) {
609
- const project = await db.get('SELECT * FROM projects WHERE id=?', projectId);
583
+ function updateDeploymentConvergence(projectId) {
584
+ const project = db.get('SELECT * FROM projects WHERE id=?', projectId);
610
585
  if (!project?.current_deployment_id)
611
586
  return;
612
- const ds = await db.get('SELECT * FROM desired_states WHERE deployment_id=?', project.current_deployment_id);
587
+ const ds = db.get('SELECT * FROM desired_states WHERE deployment_id=?', project.current_deployment_id);
613
588
  if (!ds)
614
589
  return;
615
590
  const state = JSON.parse(ds.state_json);
@@ -619,7 +594,7 @@ export async function createApp(options = {}) {
619
594
  required.add(node);
620
595
  if (!required.size)
621
596
  return;
622
- const statuses = await db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
597
+ const statuses = db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
623
598
  const byNode = new Map(statuses.map((s) => [s.node_id, s]));
624
599
  const reportsRevision = (node) => state.revision
625
600
  ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision
@@ -628,7 +603,7 @@ export async function createApp(options = {}) {
628
603
  .filter((node) => reportsRevision(node) && byNode.get(node)?.health === 'failed')
629
604
  .map((node) => byNode.get(node));
630
605
  if (failedNodes.length) {
631
- const marked = await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', deploymentFailureMessage(failedNodes), now(), project.current_deployment_id, 'rolling_out');
606
+ const marked = db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', deploymentFailureMessage(failedNodes), now(), project.current_deployment_id, 'rolling_out');
632
607
  if (marked.changes === 1)
633
608
  scheduleAutoRollback(projectId, project.current_deployment_id, state);
634
609
  }
@@ -637,29 +612,29 @@ export async function createApp(options = {}) {
637
612
  : byNode.get(node)?.observed_version === state.version) &&
638
613
  ['serving', 'skipped'].includes(byNode.get(node)?.health ?? ''));
639
614
  if (ok)
640
- await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
615
+ db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
641
616
  }
642
617
  /** Events control writes when it rolls back on its own; they are attributed to a reserved system actor. */
643
- async function recordAutoRollbackEvent(projectId, deploymentId, type, message, metadata = {}) {
644
- await db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?,?,?,?)', id('evt'), projectId, deploymentId, null, 'warn', type, message, JSON.stringify(metadata), now(), AUTO_ROLLBACK_ACTOR);
618
+ function recordAutoRollbackEvent(projectId, deploymentId, type, message, metadata = {}) {
619
+ db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?,?,?,?)', id('evt'), projectId, deploymentId, null, 'warn', type, message, JSON.stringify(metadata), now(), AUTO_ROLLBACK_ACTOR);
645
620
  }
646
621
  /**
647
622
  * Creates a rollback deployment that copies the most recent earlier deployment's desired state under a
648
623
  * new revision. Shared by `POST /api/v1/rollback` and automatic rollback; the caller holds the writer queue.
649
624
  * `auto` only considers `succeeded` deployments and requires `failedDeploymentId` to still be current.
650
625
  */
651
- async function createRollback(project, opts) {
626
+ function createRollback(project, opts) {
652
627
  let lock;
653
628
  try {
654
- lock = await acquireProjectLock(project, opts.auto ? 'auto-rollback' : 'rollback');
655
- const active = await db.get(`SELECT id,state FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) ORDER BY created_at DESC LIMIT 1`, project);
629
+ lock = acquireProjectLock(project, opts.auto ? 'auto-rollback' : 'rollback');
630
+ const active = db.get(`SELECT id,state FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) ORDER BY created_at DESC LIMIT 1`, project);
656
631
  if (active && !opts.force)
657
632
  throw httpError(409, `deployment ${active.id} is already ${active.state}; retry with force to rollback anyway`);
658
- const current = await db.get('SELECT current_deployment_id FROM projects WHERE id=?', project);
633
+ const current = db.get('SELECT current_deployment_id FROM projects WHERE id=?', project);
659
634
  if (opts.failedDeploymentId && current?.current_deployment_id !== opts.failedDeploymentId)
660
635
  throw httpError(409, `deployment ${opts.failedDeploymentId} is no longer the current deployment`);
661
636
  const states = opts.auto ? `'succeeded'` : `'succeeded','rolling_out'`;
662
- const previousDs = await db.get(`SELECT ds.*, d.version AS deployment_version FROM desired_states ds JOIN deployments d ON d.id=ds.deployment_id
637
+ const previousDs = db.get(`SELECT ds.*, d.version AS deployment_version FROM desired_states ds JOIN deployments d ON d.id=ds.deployment_id
663
638
  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 || '');
664
639
  if (!previousDs)
665
640
  throw httpError(404, 'no previous deployment found');
@@ -667,17 +642,17 @@ export async function createApp(options = {}) {
667
642
  const depId = id('dep');
668
643
  const previousState = JSON.parse(previousDs.state_json);
669
644
  previousState.revision = depId;
670
- await db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at,actor) VALUES(?,?,?,?,?,?,?)', depId, project, `${ROLLBACK_VERSION_PREFIX}${previousState.version}-${Date.now()}`, 'rolling_out', ts, ts, opts.actor);
671
- await db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, depId, previousState.version, JSON.stringify(previousState), ts);
672
- await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
673
- await wakeAll(project);
645
+ db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at,actor) VALUES(?,?,?,?,?,?,?)', depId, project, `${ROLLBACK_VERSION_PREFIX}${previousState.version}-${Date.now()}`, 'rolling_out', ts, ts, opts.actor);
646
+ db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, depId, previousState.version, JSON.stringify(previousState), ts);
647
+ db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
648
+ wakeAll(project);
674
649
  return {
675
- deployment: await db.get('SELECT * FROM deployments WHERE id=?', depId),
650
+ deployment: db.get('SELECT * FROM deployments WHERE id=?', depId),
676
651
  desiredState: previousState,
677
652
  };
678
653
  }
679
654
  finally {
680
- await releaseLock(lock);
655
+ releaseLock(lock);
681
656
  }
682
657
  }
683
658
  /**
@@ -690,9 +665,9 @@ export async function createApp(options = {}) {
690
665
  if (state.compose['x-tug']?.rollback !== 'auto')
691
666
  return;
692
667
  void (async () => {
693
- const failed = await db.get('SELECT * FROM deployments WHERE id=?', deploymentId);
668
+ const failed = db.get('SELECT * FROM deployments WHERE id=?', deploymentId);
694
669
  if (failed?.version.startsWith(ROLLBACK_VERSION_PREFIX)) {
695
- await recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `rollback deployment ${failed.version} failed; not rolling back a rollback`);
670
+ recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `rollback deployment ${failed.version} failed; not rolling back a rollback`);
696
671
  return;
697
672
  }
698
673
  try {
@@ -701,27 +676,32 @@ export async function createApp(options = {}) {
701
676
  auto: true,
702
677
  failedDeploymentId: deploymentId,
703
678
  }));
704
- await recordAutoRollbackEvent(projectId, out.deployment.id, 'deployment.auto_rollback', `deployment ${deploymentId} (${failed?.version}) reported failed health; rolled back to ${out.desiredState.version} as deployment ${out.deployment.id}`, {
679
+ recordAutoRollbackEvent(projectId, out.deployment.id, 'deployment.auto_rollback', `deployment ${deploymentId} (${failed?.version}) reported failed health; rolled back to ${out.desiredState.version} as deployment ${out.deployment.id}`, {
705
680
  failedDeploymentId: deploymentId,
706
681
  rollbackDeploymentId: out.deployment.id,
707
682
  version: out.desiredState.version,
708
683
  });
709
684
  }
710
685
  catch (e) {
711
- await recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `deployment ${deploymentId} failed but automatic rollback was not possible: ${errMessage(e)}`).catch(() => { });
686
+ try {
687
+ recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `deployment ${deploymentId} failed but automatic rollback was not possible: ${errMessage(e)}`);
688
+ }
689
+ catch {
690
+ /* Best-effort audit event. */
691
+ }
712
692
  }
713
693
  })().catch((e) => console.error('auto rollback failed:', errMessage(e)));
714
694
  }
715
- async function currentDesired(projectId) {
695
+ function currentDesired(projectId) {
716
696
  return db.get('SELECT ds.* FROM desired_states ds JOIN projects p ON p.current_deployment_id=ds.deployment_id WHERE p.id=?', projectId);
717
697
  }
718
698
  const live = new LiveHub(async (actor, request) => {
719
699
  assertProject(actor, request.project);
720
- const row = await currentDesired(request.project);
700
+ const row = currentDesired(request.project);
721
701
  if (!row)
722
702
  throw httpError(404, missingProject(request.project));
723
703
  const desired = JSON.parse(row.state_json);
724
- const nodes = await db.all('SELECT * FROM nodes');
704
+ const nodes = db.all('SELECT * FROM nodes');
725
705
  const eligible = new Set();
726
706
  const services = request.operation === 'ps'
727
707
  ? Object.values(desired.services)
@@ -739,6 +719,17 @@ export async function createApp(options = {}) {
739
719
  const node = nodes.find((node) => node.name === (request.node || [...eligible][0]));
740
720
  if (!node)
741
721
  throw httpError(404, 'no eligible node for this operation');
722
+ if (request.operation === 'exec' && request.oneOff)
723
+ audit(actor, {
724
+ type: 'job.run',
725
+ project: request.project,
726
+ message: `${actor.name} ran a one-off ${request.service} container on ${node.name}: ${request.command.join(' ').slice(0, 200)}`,
727
+ metadata: {
728
+ service: request.service,
729
+ node: node.name,
730
+ command: request.command.slice(0, 64),
731
+ },
732
+ });
742
733
  return node;
743
734
  }, options.liveStartTimeoutMs);
744
735
  streamRuntimes.set(app, {
@@ -750,38 +741,44 @@ export async function createApp(options = {}) {
750
741
  api('get', '/api/v1/live', null, (_req, res) => {
751
742
  res.status(426).json({ error: 'WebSocket upgrade required' });
752
743
  });
753
- async function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
744
+ function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
754
745
  const name = `project:${project}`;
755
746
  const ts = now();
756
- await db.run('DELETE FROM locks WHERE expires_at < ?', ts);
747
+ db.run('DELETE FROM locks WHERE expires_at < ?', ts);
757
748
  const owner = `${operation}:${id('lock')}`;
758
749
  try {
759
- await db.run('INSERT INTO locks(name,owner,expires_at,created_at) VALUES(?,?,?,?)', name, owner, new Date(Date.now() + ttlMs).toISOString(), ts);
750
+ db.run('INSERT INTO locks(name,owner,expires_at,created_at) VALUES(?,?,?,?)', name, owner, new Date(Date.now() + ttlMs).toISOString(), ts);
760
751
  }
761
752
  catch (e) {
762
- if (!/UNIQUE constraint/.test(errMessage(e)))
753
+ if (!isUniqueViolation(e))
763
754
  throw e;
764
- const existing = await db.get('SELECT * FROM locks WHERE name=?', name);
755
+ const existing = db.get('SELECT * FROM locks WHERE name=?', name);
765
756
  const err = new Error(`project ${project} is locked by ${existing?.owner} until ${existing?.expires_at}`);
766
757
  err.status = 409;
767
758
  err.lock = existing;
768
759
  throw err;
769
760
  }
770
761
  const heartbeat = setInterval(() => {
771
- void db
772
- .run('UPDATE locks SET expires_at=? WHERE name=? AND owner=?', new Date(Date.now() + ttlMs).toISOString(), name, owner)
773
- .catch(() => { });
762
+ try {
763
+ db.run('UPDATE locks SET expires_at=? WHERE name=? AND owner=?', new Date(Date.now() + ttlMs).toISOString(), name, owner);
764
+ }
765
+ catch {
766
+ /* Best-effort bookkeeping. */
767
+ }
774
768
  }, Math.max(10, Math.floor(ttlMs / 3)));
775
769
  heartbeat.unref();
776
770
  return { name, owner, heartbeat };
777
771
  }
778
- async function releaseLock(lock) {
772
+ function releaseLock(lock) {
779
773
  if (lock?.heartbeat)
780
774
  clearInterval(lock.heartbeat);
781
775
  if (lock)
782
- await db
783
- .run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner)
784
- .catch(() => { });
776
+ try {
777
+ db.run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner);
778
+ }
779
+ catch {
780
+ /* Best-effort bookkeeping. */
781
+ }
785
782
  }
786
783
  function sendError(res, e) {
787
784
  const err = e;
@@ -796,13 +793,13 @@ export async function createApp(options = {}) {
796
793
  if (!project || !service || !Number.isInteger(replicas) || replicas < 1)
797
794
  throw httpError(400, 'project, service and a positive integer replicas are required');
798
795
  assertProject(req.actor, project);
799
- const dsRow = await currentDesired(project);
796
+ const dsRow = currentDesired(project);
800
797
  if (!dsRow)
801
798
  throw httpError(404, `project ${project} has no desired state`);
802
799
  const state = JSON.parse(dsRow.state_json);
803
800
  if (!state.services?.[service])
804
801
  throw httpError(404, `service ${service} not found in project ${project}`);
805
- const nodeRows = await db.all('SELECT name,labels_json,last_seen_at FROM nodes ORDER BY created_at,name');
802
+ const nodeRows = db.all('SELECT name,labels_json,last_seen_at FROM nodes ORDER BY created_at,name');
806
803
  const nodeInfos = nodeRows
807
804
  .filter((row) => !isStale(row))
808
805
  .map((row) => ({
@@ -827,8 +824,8 @@ export async function createApp(options = {}) {
827
824
  };
828
825
  state.services[service].replicas = selected.length;
829
826
  state.revision = id('rev');
830
- await db.run('UPDATE desired_states SET state_json=? WHERE id=?', JSON.stringify(state), dsRow.id);
831
- await wakeAll(project);
827
+ db.run('UPDATE desired_states SET state_json=? WHERE id=?', JSON.stringify(state), dsRow.id);
828
+ wakeAll(project);
832
829
  res.json({
833
830
  ok: true,
834
831
  project,
@@ -841,59 +838,59 @@ export async function createApp(options = {}) {
841
838
  api('post', '/api/v1/projects', (req) => String(req.body?.name || 'site'), async (req, res) => {
842
839
  const name = req.body.name || 'site';
843
840
  const ts = now();
844
- await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
845
- res.json(await db.get('SELECT * FROM projects WHERE id=?', name));
841
+ db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
842
+ res.json(db.get('SELECT * FROM projects WHERE id=?', name));
846
843
  });
847
- api('get', '/api/v1/projects/:project', (req) => req.params.project, async (req, res) => res.json(await db.get('SELECT * FROM projects WHERE id=?', req.params.project)));
844
+ api('get', '/api/v1/projects/:project', (req) => req.params.project, async (req, res) => res.json(db.get('SELECT * FROM projects WHERE id=?', req.params.project)));
848
845
  /** A node whose agent has been seen before but not within TUG_STALE_SECONDS; never-seen (just registered) nodes stay schedulable. */
849
846
  const isStale = (row) => !!row.last_seen_at &&
850
847
  Date.parse(row.last_seen_at) < Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000;
851
848
  const NODE_INFO_SQL = 'SELECT id,name,address,public_address,overlay_ip,labels_json,status,created_at,updated_at FROM nodes WHERE id=?';
852
849
  /** Allocates the node's stable overlay IP on first registration (retrying if a concurrent register took it). */
853
- async function ensureOverlayIp(nodeId) {
850
+ function ensureOverlayIp(nodeId) {
854
851
  if (!MESH)
855
852
  return;
856
853
  for (let attempt = 0; attempt < 5; attempt++) {
857
- const row = await db.get('SELECT overlay_ip FROM nodes WHERE id=?', nodeId);
854
+ const row = db.get('SELECT overlay_ip FROM nodes WHERE id=?', nodeId);
858
855
  if (row?.overlay_ip)
859
856
  return;
860
- const used = (await db.all('SELECT overlay_ip FROM nodes WHERE overlay_ip IS NOT NULL')).map((r) => r.overlay_ip);
857
+ const used = db
858
+ .all('SELECT overlay_ip FROM nodes WHERE overlay_ip IS NOT NULL')
859
+ .map((r) => r.overlay_ip);
861
860
  try {
862
- await db.run('UPDATE nodes SET overlay_ip=? WHERE id=? AND overlay_ip IS NULL', allocateOverlayIp(MESH_CIDR, used), nodeId);
861
+ db.run('UPDATE nodes SET overlay_ip=? WHERE id=? AND overlay_ip IS NULL', allocateOverlayIp(MESH_CIDR, used), nodeId);
863
862
  return;
864
863
  }
865
864
  catch (e) {
866
- if (!/UNIQUE constraint/.test(errMessage(e)))
865
+ if (!isUniqueViolation(e))
867
866
  throw e;
868
867
  }
869
868
  }
870
869
  throw new Error('could not allocate an overlay address');
871
870
  }
872
871
  /** Stores (or refreshes) the node's WireGuard peer entry; the private key never reaches control. */
873
- async function syncWgPeer(nodeId, publicKey) {
872
+ function syncWgPeer(nodeId, publicKey) {
874
873
  if (!MESH)
875
874
  return;
876
- const node = await db.get('SELECT * FROM nodes WHERE id=?', nodeId);
877
- const existing = await db.get('SELECT * FROM wg_peers WHERE node_id=?', nodeId);
875
+ const node = db.get('SELECT * FROM nodes WHERE id=?', nodeId);
876
+ const existing = db.get('SELECT * FROM wg_peers WHERE node_id=?', nodeId);
878
877
  const key = publicKey ?? existing?.public_key;
879
878
  if (!node?.overlay_ip || !key)
880
879
  return;
881
- await db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
880
+ db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
882
881
  ON CONFLICT(node_id) DO UPDATE SET public_key=excluded.public_key,overlay_ip=excluded.overlay_ip,endpoint=excluded.endpoint,allowed_ips=excluded.allowed_ips,updated_at=excluded.updated_at`, nodeId, key, node.overlay_ip, wgEndpoint(node.public_address, WG_PORT) ?? null, `${node.overlay_ip}/32`, now());
883
882
  }
884
883
  async function registerNode({ name, address, publicAddress, wgPublicKey, labels, }) {
885
884
  if (wgPublicKey !== undefined && !isWgKey(wgPublicKey))
886
885
  throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
887
886
  const agentToken = token('tug_agent');
888
- const registryToken = token('tug_reg');
889
887
  const ts = now();
890
- await db.run(`INSERT INTO nodes(id,name,address,public_address,status,labels_json,agent_token_hash,registry_token_hash,created_at,updated_at)
891
- VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,address=excluded.address,public_address=excluded.public_address,labels_json=excluded.labels_json,agent_token_hash=excluded.agent_token_hash,registry_token_hash=excluded.registry_token_hash,updated_at=excluded.updated_at`, name, name, address || null, publicAddress || null, 'registered', JSON.stringify(Array.isArray(labels) ? labels : []), hashToken(agentToken), hashToken(registryToken), ts, ts);
892
- await ensureOverlayIp(name);
893
- await syncWgPeer(name, wgPublicKey);
894
- await upsertRegistryCredential(name, registryToken);
895
- await wakeAll();
896
- const row = await db.get(NODE_INFO_SQL, name);
888
+ db.run(`INSERT INTO nodes(id,name,address,public_address,status,labels_json,agent_token_hash,created_at,updated_at)
889
+ VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,address=excluded.address,public_address=excluded.public_address,labels_json=excluded.labels_json,agent_token_hash=excluded.agent_token_hash,updated_at=excluded.updated_at`, name, name, address || null, publicAddress || null, 'registered', JSON.stringify(Array.isArray(labels) ? labels : []), hashToken(agentToken), ts, ts);
890
+ ensureOverlayIp(name);
891
+ syncWgPeer(name, wgPublicKey);
892
+ wakeAll();
893
+ const row = db.get(NODE_INFO_SQL, name);
897
894
  if (!row)
898
895
  throw httpError(500, 'node not found after registration');
899
896
  const node = {
@@ -907,7 +904,7 @@ export async function createApp(options = {}) {
907
904
  created_at: row.created_at,
908
905
  updated_at: row.updated_at,
909
906
  };
910
- return { node, agentToken, registryToken };
907
+ return { node, agentToken };
911
908
  }
912
909
  api('post', '/api/v1/nodes/register', null, async (req, res) => {
913
910
  const name = req.body.name;
@@ -934,18 +931,18 @@ export async function createApp(options = {}) {
934
931
  const uses = Math.max(1, Number(req.body.uses ?? 1));
935
932
  const enrollToken = token('tug_enroll');
936
933
  const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
937
- await db.run('INSERT INTO enroll_tokens(token_hash,name,uses_remaining,expires_at,created_at) VALUES(?,?,?,?,?)', hashToken(enrollToken), name, uses, expiresAt, now());
938
- res.json({ enrollToken, name, usesRemaining: uses, expiresAt, registry: REGISTRY });
934
+ db.run('INSERT INTO enroll_tokens(token_hash,name,uses_remaining,expires_at,created_at) VALUES(?,?,?,?,?)', hashToken(enrollToken), name, uses, expiresAt, now());
935
+ res.json({ enrollToken, name, usesRemaining: uses, expiresAt });
939
936
  });
940
937
  post('/agent/v1/enroll', async (req, res) => {
941
938
  const body = req.body;
942
939
  const raw = String(body.enrollToken || (req.headers.authorization || '').replace(/^Bearer /, '')).trim();
943
940
  if (!raw)
944
941
  return res.status(400).json({ error: 'enrollToken required' });
945
- const row = await db.get('SELECT * FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
942
+ const row = db.get('SELECT * FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
946
943
  if (!row || row.uses_remaining < 1 || row.expires_at < now()) {
947
944
  if (row)
948
- await db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
945
+ db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
949
946
  return res.status(401).json({ error: 'invalid or expired enroll token' });
950
947
  }
951
948
  const name = row.name || body.name;
@@ -968,14 +965,14 @@ export async function createApp(options = {}) {
968
965
  }
969
966
  const remaining = row.uses_remaining - 1;
970
967
  if (remaining < 1)
971
- await db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
968
+ db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
972
969
  else
973
- await db.run('UPDATE enroll_tokens SET uses_remaining=? WHERE token_hash=?', remaining, hashToken(raw));
974
- const enrolled = { ...result, registry: REGISTRY };
970
+ db.run('UPDATE enroll_tokens SET uses_remaining=? WHERE token_hash=?', remaining, hashToken(raw));
971
+ const enrolled = { ...result };
975
972
  res.json(enrolled);
976
973
  });
977
974
  api('get', '/api/v1/nodes', null, async (req, res) => {
978
- const rows = await db.all('SELECT id,name,address,public_address,overlay_ip,ingress,ingress_status_json,status,labels_json,last_seen_at,created_at,updated_at FROM nodes ORDER BY name');
975
+ const rows = db.all('SELECT id,name,address,public_address,overlay_ip,ingress,ingress_status_json,status,labels_json,last_seen_at,created_at,updated_at FROM nodes ORDER BY name');
979
976
  res.json({
980
977
  nodes: rows.map((row) => {
981
978
  const { labels_json, ...rest } = row;
@@ -983,30 +980,13 @@ export async function createApp(options = {}) {
983
980
  }),
984
981
  });
985
982
  });
986
- api('post', '/api/v1/nodes/:name/rotate-registry-token', null, async (req, res) => {
987
- const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
988
- if (!node)
989
- return res.status(404).json({ error: 'node not found' });
990
- const registryToken = token('tug_reg');
991
- await db.run('UPDATE nodes SET registry_token_hash=?,updated_at=? WHERE id=?', hashToken(registryToken), now(), node.id);
992
- await upsertRegistryCredential(node.name, registryToken);
993
- res.json({ ok: true, node: node.name, registryToken });
994
- });
995
- api('post', '/api/v1/nodes/:name/revoke-registry-token', null, async (req, res) => {
996
- const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
997
- if (!node)
998
- return res.status(404).json({ error: 'node not found' });
999
- await db.run('UPDATE nodes SET registry_token_hash=NULL,updated_at=? WHERE id=?', now(), node.id);
1000
- await removeRegistryCredential(node.name);
1001
- res.json({ ok: true, node: node.name, revoked: true });
1002
- });
1003
- async function findNode(nameOrId) {
983
+ function findNode(nameOrId) {
1004
984
  return db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', nameOrId, nameOrId);
1005
985
  }
1006
- async function queueCommand(nodeId, projectId, type, payload = {}) {
986
+ function queueCommand(nodeId, projectId, type, payload = {}, deadline = null) {
1007
987
  const commandId = id('cmd');
1008
988
  const ts = now();
1009
- await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, nodeId, projectId, type, 'pending', JSON.stringify(payload), 0, ts, null, ts, ts);
989
+ db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, nodeId, projectId, type, 'pending', JSON.stringify(payload), 0, ts, deadline, ts, ts);
1010
990
  wakeHub.wake(nodeId, projectId ?? undefined);
1011
991
  return commandId;
1012
992
  }
@@ -1015,19 +995,19 @@ export async function createApp(options = {}) {
1015
995
  api('post', '/api/v1/nodes/:name/rotate-wg-key', null, async (req, res) => {
1016
996
  if (!MESH)
1017
997
  return res.status(409).json({ error: 'the WireGuard mesh is not enabled (TUG_MESH=true)' });
1018
- const node = await findNode(req.params.name);
998
+ const node = findNode(req.params.name);
1019
999
  if (!node)
1020
1000
  return res.status(404).json({ error: 'node not found' });
1021
- res.json({ commandId: await queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
1001
+ res.json({ commandId: queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
1022
1002
  });
1023
1003
  // Removes a retired node: its token, WireGuard peer, statuses and queued commands. Refused while a current deployment still places
1024
1004
  // something on it (redeploy without it first) or while its agent is still reporting, unless force=true.
1025
1005
  api('delete', '/api/v1/nodes/:name', null, async (req, res) => {
1026
- const node = await db.get('SELECT id,name,last_seen_at FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
1006
+ const node = db.get('SELECT id,name,last_seen_at FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
1027
1007
  if (!node)
1028
1008
  return res.status(404).json({ error: 'node not found' });
1029
1009
  const referenced = [];
1030
- for (const state of await currentDesiredStates()) {
1010
+ for (const state of currentDesiredStates()) {
1031
1011
  for (const [name, svc] of Object.entries(state.services || {}))
1032
1012
  if (svc.placement?.nodes?.includes(node.name))
1033
1013
  referenced.push(`${state.project}/${name}`);
@@ -1043,24 +1023,24 @@ export async function createApp(options = {}) {
1043
1023
  return res.status(409).json({
1044
1024
  error: `node ${node.name} is still reporting; stop its agent first or pass force=true`,
1045
1025
  });
1046
- await db.run('DELETE FROM commands WHERE node_id=?', node.id);
1047
- await db.run('DELETE FROM node_statuses WHERE node_id=?', node.id);
1048
- await db.run('DELETE FROM wg_peers WHERE node_id=?', node.id);
1049
- await db.run('DELETE FROM nodes WHERE id=?', node.id);
1050
- await audit(req.actor, {
1026
+ db.run('DELETE FROM commands WHERE node_id=?', node.id);
1027
+ db.run('DELETE FROM node_statuses WHERE node_id=?', node.id);
1028
+ db.run('DELETE FROM wg_peers WHERE node_id=?', node.id);
1029
+ db.run('DELETE FROM nodes WHERE id=?', node.id);
1030
+ audit(req.actor, {
1051
1031
  type: 'node.removed',
1052
1032
  message: `node ${node.name} removed`,
1053
1033
  metadata: { node: node.name },
1054
1034
  });
1055
- await wakeAll();
1035
+ wakeAll();
1056
1036
  res.json({ ok: true, node: node.name });
1057
1037
  });
1058
1038
  api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
1059
- const node = await findNode(req.params.name);
1039
+ const node = findNode(req.params.name);
1060
1040
  if (!node)
1061
1041
  return res.status(404).json({ error: 'node not found' });
1062
1042
  const enabled = req.body.enabled !== false;
1063
- await db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
1043
+ db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
1064
1044
  wakeHub.wake(node.id);
1065
1045
  res.json({ ok: true, node: node.name, ingress: enabled });
1066
1046
  });
@@ -1071,30 +1051,18 @@ export async function createApp(options = {}) {
1071
1051
  if (!isWgKey(publicKey))
1072
1052
  return res.status(400).json({ error: 'publicKey must be a base64 WireGuard public key' });
1073
1053
  try {
1074
- await syncWgPeer(req.node.id, publicKey);
1075
- await wakeAll();
1054
+ syncWgPeer(req.node.id, publicKey);
1055
+ wakeAll();
1076
1056
  res.json({ ok: true });
1077
1057
  }
1078
1058
  catch (e) {
1079
- sendError(res, /UNIQUE constraint/.test(errMessage(e))
1080
- ? httpError(409, 'public key already registered to another node')
1081
- : e);
1059
+ sendError(res, isUniqueViolation(e) ? httpError(409, 'public key already registered to another node') : e);
1082
1060
  }
1083
1061
  });
1084
- post('/api/v1/registry/auth', async (req, res) => {
1085
- const username = req.body.username;
1086
- const password = req.body.password;
1087
- const node = username && password
1088
- ? await db.get('SELECT id,name FROM nodes WHERE (name=? OR id=?) AND registry_token_hash IS NOT NULL AND registry_token_hash=?', username, username, hashToken(password))
1089
- : null;
1090
- if (!node)
1091
- return res.status(401).json({ ok: false, error: 'invalid registry credentials' });
1092
- res.json({ ok: true, node: node.name });
1093
- });
1094
1062
  api('post', '/api/v1/tokens', null, async (req, res) => {
1095
1063
  const request = parseTokenRequest(req.body);
1096
- const created = await createToken(db, request, req.actor.name);
1097
- await audit(req.actor, {
1064
+ const created = createToken(db, request, req.actor.name);
1065
+ audit(req.actor, {
1098
1066
  type: 'token.create',
1099
1067
  message: `token ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`,
1100
1068
  metadata: {
@@ -1106,30 +1074,210 @@ export async function createApp(options = {}) {
1106
1074
  });
1107
1075
  res.json(created);
1108
1076
  });
1109
- api('get', '/api/v1/tokens', null, async (_req, res) => res.json({ tokens: await listTokens(db) }));
1077
+ api('get', '/api/v1/tokens', null, async (_req, res) => res.json({ tokens: listTokens(db) }));
1110
1078
  api('delete', '/api/v1/tokens/:name', null, async (req, res) => {
1111
- if (!(await revokeToken(db, req.params.name)))
1079
+ if (!revokeToken(db, req.params.name))
1112
1080
  return res.status(404).json({ error: 'token not found' });
1113
- await audit(req.actor, {
1081
+ audit(req.actor, {
1114
1082
  type: 'token.revoke',
1115
1083
  message: `token ${req.params.name} revoked by ${req.actor.name}`,
1116
1084
  metadata: { name: req.params.name },
1117
1085
  });
1118
1086
  res.json({ ok: true, name: req.params.name });
1119
1087
  });
1088
+ const backupDestination = () => db.get("SELECT * FROM backup_destination WHERE id='default'");
1089
+ const backupEnv = () => {
1090
+ const row = backupDestination();
1091
+ if (!row)
1092
+ return {};
1093
+ const env = {};
1094
+ for (const name of JSON.parse(row.env_names_json)) {
1095
+ const secret = db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', BACKUP_SECRET_PROJECT, name);
1096
+ if (secret)
1097
+ env[name] = decrypt(secret.encrypted_value, masterKey);
1098
+ }
1099
+ return env;
1100
+ };
1101
+ const backups = createBackupService({
1102
+ db,
1103
+ queueCommand,
1104
+ resticImage: process.env.TUG_RESTIC_IMAGE,
1105
+ // Defensive: the agent already redacts, but a stored error must never carry a destination credential.
1106
+ redact: (text) => redactSecrets(text, Object.values(backupEnv())),
1107
+ });
1108
+ app.locals.backups = backups;
1109
+ const restores = createRestoreService({ db, backups, wake: (project) => wakeAll(project) });
1110
+ app.locals.restores = restores;
1111
+ const backupTickMs = Number(process.env.TUG_BACKUP_TICK_MS) || 30_000;
1112
+ const backupTimer = setInterval(() => {
1113
+ try {
1114
+ backups.tick();
1115
+ restores.advance();
1116
+ }
1117
+ catch (e) {
1118
+ console.error(`backup scheduler failed: ${errMessage(e)}`);
1119
+ }
1120
+ }, backupTickMs);
1121
+ backupTimer.unref();
1122
+ try {
1123
+ backups.tick(); // catch up on slots missed while control was down
1124
+ restores.advance(); // re-adopt unfinished restores and keep their projects locked
1125
+ }
1126
+ catch (e) {
1127
+ console.error(`backup scheduler failed: ${errMessage(e)}`);
1128
+ }
1129
+ api('post', '/api/v1/backups/run', bodyProject, async (req, res) => {
1130
+ const project = String(req.body.project || 'site');
1131
+ const targets = req.body.targets;
1132
+ if (targets !== undefined &&
1133
+ (!Array.isArray(targets) || targets.some((t) => typeof t !== 'string')))
1134
+ return res.status(400).json({ error: 'targets must be a list of service or volume names' });
1135
+ if (!db.get('SELECT 1 FROM projects WHERE id=?', project))
1136
+ return res.status(404).json({ error: `project ${project} not found` });
1137
+ const result = backups.runNow(project, targets, req.actor.name);
1138
+ if (!result.runs.length)
1139
+ return res.status(result.errors.length ? 409 : 404).json({
1140
+ error: result.errors.join('; ') || `project ${project} has no backup targets`,
1141
+ });
1142
+ res.json(result);
1143
+ });
1144
+ api('post', '/api/v1/restores', bodyProject, async (req, res) => {
1145
+ const body = req.body ?? {};
1146
+ const project = String(body.project || 'site');
1147
+ if (typeof body.target !== 'string' || !body.target)
1148
+ return res
1149
+ .status(400)
1150
+ .json({ error: 'target is required, such as service:db or volume:uploads' });
1151
+ const target = body.target.includes(':')
1152
+ ? body.target
1153
+ : (db.get('SELECT target FROM backup_schedules WHERE project_id=? AND enabled=1 AND target LIKE ?', project, `%:${body.target}`)?.target ?? body.target);
1154
+ const request = {
1155
+ project,
1156
+ target,
1157
+ from: typeof body.from === 'string' ? body.from : undefined,
1158
+ scratch: body.scratch === true,
1159
+ safetyBackup: body.safetyBackup !== false,
1160
+ writers: Array.isArray(body.writers) ? body.writers.map(String) : undefined,
1161
+ noWriters: body.noWriters === true,
1162
+ actor: req.actor.name,
1163
+ };
1164
+ const plan = restores.plan(request);
1165
+ if ('error' in plan)
1166
+ return res.status(plan.status).json({ error: plan.error });
1167
+ if (body.confirm !== true)
1168
+ return res.json({ confirmed: false, plan });
1169
+ const started = restores.start(request, plan);
1170
+ if ('error' in started)
1171
+ return res.status(started.status).json({ error: started.error });
1172
+ res.json({ confirmed: true, plan, ...started });
1173
+ });
1174
+ api('get', '/api/v1/restores', queryProject, async (req, res) => res.json({ restores: restores.list(String(req.query.project || 'site')) }));
1175
+ api('post', '/api/v1/restores/:id/release', null, async (req, res) => {
1176
+ const result = restores.releaseInterrupted(req.params.id);
1177
+ if ('error' in result)
1178
+ return res.status(result.status).json({ error: result.error });
1179
+ res.json(result);
1180
+ });
1181
+ api('post', '/api/v1/restores/:id/resume', null, async (req, res) => {
1182
+ const result = restores.resume(req.params.id);
1183
+ if ('error' in result)
1184
+ return res.status(result.status).json({ error: result.error });
1185
+ res.json(result);
1186
+ });
1187
+ api('get', '/api/v1/backups', queryProject, async (req, res) => {
1188
+ const project = String(req.query.project || 'site');
1189
+ const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
1190
+ res.json({
1191
+ project,
1192
+ destinationConfigured: Boolean(backupDestination()),
1193
+ ...backups.list(project, limit),
1194
+ });
1195
+ });
1196
+ api('get', '/api/v1/backup/destination', null, async (_req, res) => {
1197
+ const row = backupDestination();
1198
+ res.json(row
1199
+ ? {
1200
+ configured: true,
1201
+ url: row.url,
1202
+ envNames: JSON.parse(row.env_names_json),
1203
+ checkedAt: row.checked_at,
1204
+ checkResult: row.check_result_json ? JSON.parse(row.check_result_json) : null,
1205
+ updatedAt: row.updated_at,
1206
+ }
1207
+ : { configured: false });
1208
+ });
1209
+ api('post', '/api/v1/backup/destination', null, async (req, res) => {
1210
+ let parsed;
1211
+ try {
1212
+ parsed = parseBackupDestination(req.body);
1213
+ }
1214
+ catch (e) {
1215
+ return res.status(400).json({ error: errMessage(e) });
1216
+ }
1217
+ const ts = now();
1218
+ db.exec('BEGIN IMMEDIATE');
1219
+ try {
1220
+ db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
1221
+ for (const [name, value] of Object.entries(parsed.env))
1222
+ 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);
1223
+ db.run(`INSERT INTO backup_destination(id,url,env_names_json,checked_at,check_result_json,updated_at) VALUES('default',?,?,NULL,NULL,?)
1224
+ 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);
1225
+ db.exec('COMMIT');
1226
+ }
1227
+ catch (e) {
1228
+ db.exec('ROLLBACK');
1229
+ throw e;
1230
+ }
1231
+ audit(req.actor, {
1232
+ type: 'backup.destination_set',
1233
+ message: `backup destination set to ${parsed.url} by ${req.actor.name}`,
1234
+ metadata: { url: parsed.url, envNames: Object.keys(parsed.env).sort() },
1235
+ });
1236
+ res.json({ ok: true, url: parsed.url, envNames: Object.keys(parsed.env).sort() });
1237
+ });
1238
+ api('delete', '/api/v1/backup/destination', null, async (req, res) => {
1239
+ db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
1240
+ const removed = db.run("DELETE FROM backup_destination WHERE id='default'");
1241
+ if (removed.changes)
1242
+ audit(req.actor, {
1243
+ type: 'backup.destination_removed',
1244
+ message: `backup destination removed by ${req.actor.name}`,
1245
+ });
1246
+ res.json({ ok: true, removed: removed.changes > 0 });
1247
+ });
1248
+ api('get', '/api/v1/backup/key', null, async (req, res) => {
1249
+ const project = String(req.query.project || '');
1250
+ if (!PROJECT_NAME_PATTERN.test(project) || isReservedProject(project))
1251
+ return res.status(400).json({ error: 'project is required' });
1252
+ if (!db.get('SELECT 1 FROM projects WHERE id=?', project))
1253
+ return res.status(404).json({ error: `project ${project} not found` });
1254
+ const destination = backupDestination();
1255
+ audit(req.actor, {
1256
+ type: 'backup.key_read',
1257
+ project,
1258
+ message: `repository password for ${project} read by ${req.actor.name}`,
1259
+ });
1260
+ res.json({
1261
+ project,
1262
+ repository: destination ? projectRepository(destination.url, project) : null,
1263
+ password: repositoryPassword(masterKey, project),
1264
+ });
1265
+ });
1120
1266
  api('post', '/api/v1/secrets', bodyProject, async (req, res) => {
1121
1267
  const { project = 'site', name, value } = req.body;
1268
+ if (isReservedProject(String(project)))
1269
+ return res.status(400).json({ error: `project names starting with _ are reserved` });
1122
1270
  if (!name || value == null)
1123
1271
  return res.status(400).json({ error: 'name and value required' });
1124
1272
  let lock;
1125
1273
  try {
1126
- lock = await acquireProjectLock(project, 'secret.set');
1127
- const old = await db.get('SELECT version FROM secrets WHERE project_id=? AND name=?', project, name);
1274
+ lock = acquireProjectLock(project, 'secret.set');
1275
+ const old = db.get('SELECT version FROM secrets WHERE project_id=? AND name=?', project, name);
1128
1276
  const ts = now();
1129
- await db.run(`INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)
1277
+ db.run(`INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)
1130
1278
  ON CONFLICT(project_id,name) DO UPDATE SET encrypted_value=excluded.encrypted_value,version=excluded.version,updated_at=excluded.updated_at`, id('sec'), project, name, encrypt(value, masterKey), (old?.version || 0) + 1, ts, ts);
1131
- await wakeAll(project);
1132
- await audit(req.actor, {
1279
+ wakeAll(project);
1280
+ audit(req.actor, {
1133
1281
  type: 'secret.set',
1134
1282
  project,
1135
1283
  message: `secret ${name} set to version ${(old?.version || 0) + 1} by ${req.actor.name}`,
@@ -1141,18 +1289,22 @@ export async function createApp(options = {}) {
1141
1289
  sendError(res, e);
1142
1290
  }
1143
1291
  finally {
1144
- await releaseLock(lock);
1292
+ releaseLock(lock);
1145
1293
  }
1146
1294
  });
1147
- api('get', '/api/v1/secrets', queryProject, async (req, res) => res.json({
1148
- secrets: await db.all('SELECT project_id,name,version,updated_at FROM secrets WHERE project_id=? ORDER BY name', req.query.project || 'site'),
1149
- }));
1295
+ api('get', '/api/v1/secrets', queryProject, async (req, res) => isReservedProject(String(req.query.project || 'site'))
1296
+ ? res.status(400).json({ error: 'project names starting with _ are reserved' })
1297
+ : res.json({
1298
+ secrets: db.all('SELECT project_id,name,version,updated_at FROM secrets WHERE project_id=? ORDER BY name', String(req.query.project || 'site')),
1299
+ }));
1150
1300
  api('delete', '/api/v1/secrets/:name', queryProject, async (req, res) => {
1151
1301
  const project = String(req.query.project || 'site');
1152
- const removed = await db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, req.params.name);
1302
+ if (isReservedProject(project))
1303
+ return res.status(400).json({ error: 'project names starting with _ are reserved' });
1304
+ const removed = db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, req.params.name);
1153
1305
  if (removed.changes) {
1154
- await wakeAll(project);
1155
- await audit(req.actor, {
1306
+ wakeAll(project);
1307
+ audit(req.actor, {
1156
1308
  type: 'secret.delete',
1157
1309
  project,
1158
1310
  message: `secret ${req.params.name} deleted by ${req.actor.name}`,
@@ -1163,33 +1315,35 @@ export async function createApp(options = {}) {
1163
1315
  });
1164
1316
  const artifactDir = path.join(DATA_DIR, 'images');
1165
1317
  await ensureDir(artifactDir);
1166
- async function retainedStates() {
1318
+ function retainedStates() {
1167
1319
  // All published history is retained. This conservative superset protects failed/lagging versions too.
1168
- return (await db.all('SELECT * FROM desired_states')).map((row) => JSON.parse(row.state_json));
1320
+ return db
1321
+ .all('SELECT * FROM desired_states')
1322
+ .map((row) => JSON.parse(row.state_json));
1169
1323
  }
1170
1324
  async function collectArtifacts() {
1171
- const states = await retainedStates();
1325
+ const states = retainedStates();
1172
1326
  const refs = new Set(states.flatMap((state) => Object.values(state.services).flatMap((service) => service.artifact ? [service.artifact.buildId] : [])));
1173
1327
  const imageRefs = new Set(states.flatMap((state) => Object.values(state.services).map((service) => service.image)));
1174
- const rows = await db.all('SELECT * FROM builds');
1328
+ const rows = db.all('SELECT * FROM builds');
1175
1329
  for (const row of rows) {
1176
1330
  if (refs.has(row.id) || imageRefs.has(row.image_ref))
1177
1331
  continue;
1178
- const active = await db.get("SELECT 1 FROM deployments WHERE id=? AND state IN ('uploading','building')", row.deployment_id);
1332
+ const active = db.get("SELECT 1 FROM deployments WHERE id=? AND state IN ('uploading','building')", row.deployment_id);
1179
1333
  if (active)
1180
1334
  continue;
1181
1335
  await fs.rm(path.join(DATA_DIR, row.artifact_path || path.join('images', `${row.id}.tar.gz`)), { force: true });
1182
1336
  await images.run('docker', ['image', 'rm', row.image_ref]).catch(() => { });
1183
- await db.run('DELETE FROM builds WHERE id=?', row.id);
1337
+ db.run('DELETE FROM builds WHERE id=?', row.id);
1184
1338
  }
1185
- const remaining = await db.all('SELECT id FROM builds');
1339
+ const remaining = db.all('SELECT id FROM builds');
1186
1340
  const owned = new Set(remaining.flatMap((row) => [`${row.id}.tar.gz`, `${row.id}.tar.gz.tmp`]));
1187
1341
  for (const name of await fs.readdir(artifactDir))
1188
1342
  if (!owned.has(name))
1189
1343
  await fs.rm(path.join(artifactDir, name), { force: true });
1190
1344
  }
1191
1345
  // Previous-process builds cannot finish after a restart; clear their unpublished files.
1192
- await db.run("UPDATE deployments SET state='build_failed',error_message='control restarted during build' WHERE state IN ('uploading','building')");
1346
+ db.run("UPDATE deployments SET state='build_failed',error_message='control restarted during build' WHERE state IN ('uploading','building')");
1193
1347
  await collectArtifacts();
1194
1348
  const receiveDeployment = upload.any();
1195
1349
  api('post', '/api/v1/deployments', null, (req, res) => {
@@ -1230,7 +1384,7 @@ export async function createApp(options = {}) {
1230
1384
  throw httpError(400, 'invalid project or version');
1231
1385
  if (version.startsWith(ROLLBACK_VERSION_PREFIX))
1232
1386
  throw httpError(400, `version cannot start with "${ROLLBACK_VERSION_PREFIX}" (reserved for rollback deployments)`);
1233
- const nodeRows = await db.all('SELECT name,labels_json,last_seen_at,created_at FROM nodes ORDER BY created_at,name');
1387
+ const nodeRows = db.all('SELECT name,labels_json,last_seen_at,created_at FROM nodes ORDER BY created_at,name');
1234
1388
  // Stale nodes (agent not seen recently) are not scheduled onto, so a dead host cannot hold a rollout open.
1235
1389
  const nodeInfos = nodeRows
1236
1390
  .filter((row) => !isStale(row))
@@ -1263,18 +1417,6 @@ export async function createApp(options = {}) {
1263
1417
  }
1264
1418
  }
1265
1419
  const nodes = nodeRows.map((row) => ({ name: row.name }));
1266
- for (const vol of Object.values(compose.volumes || {})) {
1267
- const xe = vol?.['x-tug'];
1268
- if (!xe ||
1269
- (xe.type !== 'nfs' && xe.driver !== 'nfs') ||
1270
- xe.address ||
1271
- !xe.placement?.nodes?.[0])
1272
- continue;
1273
- const exporter = await db.get('SELECT address,overlay_ip FROM nodes WHERE name=?', xe.placement.nodes[0]);
1274
- const addr = (MESH && exporter?.overlay_ip) || exporter?.address;
1275
- if (addr)
1276
- xe.address = addr;
1277
- }
1278
1420
  try {
1279
1421
  validateCompose(compose, nodes.map((n) => n.name));
1280
1422
  }
@@ -1283,8 +1425,13 @@ export async function createApp(options = {}) {
1283
1425
  }
1284
1426
  // The CLI interpolates control secrets as sentinels; a reference to a secret that is gone would deploy an empty value.
1285
1427
  const referenced = sentinelNames(compose);
1428
+ if (referenced.includes(REGISTRY_AUTH_SECRET))
1429
+ throw httpError(400, REGISTRY_AUTH_SECRET +
1430
+ ' is reserved for registry authentication and cannot be used in service definitions');
1286
1431
  if (referenced.length) {
1287
- const known = new Set((await db.all('SELECT name FROM secrets WHERE project_id=?', project)).map((row) => row.name));
1432
+ const known = new Set(db
1433
+ .all('SELECT name FROM secrets WHERE project_id=?', project)
1434
+ .map((row) => row.name));
1288
1435
  const missing = referenced.filter((name) => !known.has(name));
1289
1436
  if (missing.length)
1290
1437
  throw httpError(400, `secrets not found in project ${project}: ${missing.join(', ')}; create them with tug secret-set`);
@@ -1320,30 +1467,29 @@ export async function createApp(options = {}) {
1320
1467
  }
1321
1468
  if (files.length !== (local ? local.length : context ? 1 : 0))
1322
1469
  throw httpError(400, 'unexpected deployment upload');
1323
- if (!REGISTRY && builtServices.length && !context && !local)
1324
- throw httpError(400, 'build services require a source context or localImages upload without a registry');
1470
+ if (builtServices.length && !context && !local)
1471
+ throw httpError(400, 'build services require a source context or localImages upload');
1325
1472
  for (const [svcName, svc] of Object.entries(compose.services)) {
1326
1473
  const ing = svc['x-tug']?.ingress;
1327
1474
  if (ing?.tls !== 'custom')
1328
1475
  continue;
1329
1476
  for (const secret of [ing.cert, ing.key])
1330
- if (!(await db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret)))
1477
+ if (!db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret ?? null))
1331
1478
  throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with secret-set first`);
1332
1479
  }
1333
- lock = await acquireProjectLock(project, 'deploy');
1334
- const active = await db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
1480
+ lock = acquireProjectLock(project, 'deploy');
1481
+ const active = db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
1335
1482
  if (active)
1336
1483
  throw httpError(409, `deployment ${active.id} is already ${active.state}`);
1337
1484
  depId = id('dep');
1338
1485
  const ts = now();
1339
- await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
1340
- await db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at,actor) VALUES(?,?,?,?,?,?,?)', depId, project, version, 'building', ts, ts, req.actor.name);
1486
+ db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
1487
+ db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at,actor) VALUES(?,?,?,?,?,?,?)', depId, project, version, 'building', ts, ts, req.actor.name);
1341
1488
  const desired = buildDesiredState({
1342
1489
  project,
1343
1490
  version,
1344
1491
  revision: depId,
1345
1492
  compose,
1346
- registry: REGISTRY,
1347
1493
  defaultNode: nodes[0]?.name || null,
1348
1494
  });
1349
1495
  if (context) {
@@ -1351,14 +1497,10 @@ export async function createApp(options = {}) {
1351
1497
  await unpackContext(context.path, contextDir);
1352
1498
  }
1353
1499
  for (const service of builtServices) {
1354
- if (!contextDir && !local) {
1355
- desired.services[service].image = `${REGISTRY}/${project}/${service}:${version}`;
1356
- continue;
1357
- } // Explicit registry/no-build-upload means an existing pushed image.
1358
1500
  const spec = desired.services[service];
1359
1501
  const buildId = id('build');
1360
1502
  const imageRef = spec.image;
1361
- await db.run('INSERT INTO builds(id,project_id,deployment_id,service,image_ref,state,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)', buildId, project, depId, service, imageRef, 'building', ts, ts);
1503
+ db.run('INSERT INTO builds(id,project_id,deployment_id,service,image_ref,state,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)', buildId, project, depId, service, imageRef, 'building', ts, ts);
1362
1504
  let info;
1363
1505
  let importedClientRef;
1364
1506
  const relative = path.join('images', `${buildId}.tar.gz`);
@@ -1392,21 +1534,12 @@ export async function createApp(options = {}) {
1392
1534
  service,
1393
1535
  buildSpec: compose.services[service].build,
1394
1536
  imageRef,
1395
- push: false,
1396
1537
  });
1397
- if (REGISTRY) {
1398
- if (process.env.TUG_PUSH !== 'false')
1399
- await images.run('docker', ['push', imageRef]);
1400
- const inspected = await images.inspect(imageRef);
1401
- await db.run('UPDATE builds SET state=?,image_id=?,platform=?,updated_at=? WHERE id=?', 'complete', inspected.imageId, inspected.platform, now(), buildId);
1402
- }
1403
- else {
1404
- info = await images.save(imageRef, temporary);
1405
- await fs.rename(temporary, final);
1406
- const artifact = { buildId, imageRef, ...info };
1407
- spec.artifact = artifact;
1408
- await db.run('UPDATE builds SET state=?,image_id=?,platform=?,sha256=?,size_bytes=?,artifact_path=?,updated_at=? WHERE id=?', 'complete', info.imageId, info.platform, info.sha256, info.sizeBytes, relative, now(), buildId);
1409
- }
1538
+ info = await images.save(imageRef, temporary);
1539
+ await fs.rename(temporary, final);
1540
+ const artifact = { buildId, imageRef, ...info };
1541
+ spec.artifact = artifact;
1542
+ db.run('UPDATE builds SET state=?,image_id=?,platform=?,sha256=?,size_bytes=?,artifact_path=?,updated_at=? WHERE id=?', 'complete', info.imageId, info.platform, info.sha256, info.sizeBytes, relative, now(), buildId);
1410
1543
  }
1411
1544
  finally {
1412
1545
  if (importedClientRef)
@@ -1414,43 +1547,61 @@ export async function createApp(options = {}) {
1414
1547
  await fs.rm(temporary, { force: true });
1415
1548
  }
1416
1549
  }
1550
+ const registrySecret = db.get('SELECT * FROM secrets WHERE project_id=? AND name=?', project, REGISTRY_AUTH_SECRET);
1551
+ const credentials = parseRegistryCredentials(registrySecret ? decrypt(registrySecret.encrypted_value, masterKey) : undefined);
1552
+ const resolved = new Map();
1553
+ for (const [name, spec] of Object.entries(desired.services)) {
1554
+ if (spec.artifact)
1555
+ continue;
1556
+ const image = spec.image;
1557
+ if (!resolved.has(image))
1558
+ resolved.set(image, await images.resolve(image, credentials));
1559
+ spec.image = resolved.get(image);
1560
+ desired.compose.services[name].image = spec.image;
1561
+ }
1417
1562
  const buildLock = lock;
1418
- await withPublication(async () => {
1419
- const owned = await db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now());
1420
- const pending = await db.get('SELECT 1 FROM deployments WHERE id=? AND state=?', depId, 'building');
1563
+ const publishingId = depId;
1564
+ await withPublication(() => {
1565
+ const owned = db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now());
1566
+ const pending = db.get('SELECT 1 FROM deployments WHERE id=? AND state=?', publishingId, 'building');
1421
1567
  if (!owned || !pending)
1422
1568
  throw httpError(409, 'deployment was cancelled or its build lease was lost');
1423
- await db.exec('BEGIN IMMEDIATE');
1569
+ db.exec('BEGIN IMMEDIATE');
1424
1570
  try {
1425
- const published = await db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), depId);
1571
+ const published = db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), publishingId);
1426
1572
  if (published.changes !== 1 ||
1427
- !(await db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now())))
1573
+ !db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now()))
1428
1574
  throw httpError(409, 'deployment was cancelled or its build lease was lost');
1429
- await db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, depId, version, JSON.stringify(desired), ts);
1575
+ 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);
1430
1576
  for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
1431
1577
  const lp = volumeSpec?.['x-tug'] || {};
1432
1578
  const nodeName = lp.placement?.nodes?.[0] || null;
1433
1579
  const node = nodeName
1434
- ? await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName)
1580
+ ? db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName)
1435
1581
  : null;
1436
- await 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);
1582
+ 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);
1437
1583
  }
1438
- await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
1439
- await db.exec('COMMIT');
1584
+ db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', publishingId, now(), project);
1585
+ db.exec('COMMIT');
1440
1586
  }
1441
1587
  catch (e) {
1442
- await db.exec('ROLLBACK');
1588
+ try {
1589
+ db.exec('ROLLBACK');
1590
+ }
1591
+ catch {
1592
+ /* Preserve the publication error. */
1593
+ }
1443
1594
  throw e;
1444
1595
  }
1445
1596
  });
1446
- await audit(req.actor, {
1597
+ audit(req.actor, {
1447
1598
  type: 'deploy',
1448
1599
  project,
1449
1600
  deploymentId: depId,
1450
1601
  message: `deployment ${depId} (${version}) started by ${req.actor.name}`,
1451
1602
  metadata: { version },
1452
1603
  });
1453
- await wakeAll(project);
1604
+ wakeAll(project);
1454
1605
  responseBody = {
1455
1606
  deployment: { id: depId, project, version, state: 'rolling_out', actor: req.actor.name },
1456
1607
  desiredState: desired,
@@ -1458,13 +1609,16 @@ export async function createApp(options = {}) {
1458
1609
  }
1459
1610
  catch (e) {
1460
1611
  if (depId)
1461
- await db
1462
- .run("UPDATE deployments SET state='build_failed',error_message=?,updated_at=? WHERE id=? AND state='building'", errMessage(e), now(), depId)
1463
- .catch(() => { });
1612
+ try {
1613
+ db.run("UPDATE deployments SET state='build_failed',error_message=?,updated_at=? WHERE id=? AND state='building'", errMessage(e), now(), depId);
1614
+ }
1615
+ catch {
1616
+ /* Best-effort bookkeeping. */
1617
+ }
1464
1618
  responseError = e;
1465
1619
  }
1466
1620
  finally {
1467
- await releaseLock(lock);
1621
+ releaseLock(lock);
1468
1622
  await Promise.all(files.map((file) => fs.rm(file.path, { force: true }).catch(() => { })));
1469
1623
  if (contextDir)
1470
1624
  await fs.rm(contextDir, { recursive: true, force: true });
@@ -1476,12 +1630,12 @@ export async function createApp(options = {}) {
1476
1630
  res.json(responseBody);
1477
1631
  }
1478
1632
  get('/agent/v1/images/:buildId', authAgent, async (req, res) => {
1479
- const states = await retainedStates();
1633
+ const states = retainedStates();
1480
1634
  const allowed = states.some((state) => Object.values(state.services).some((service) => service.artifact?.buildId === req.params.buildId &&
1481
1635
  (!service.placement.nodes?.length || service.placement.nodes.includes(req.node.name))));
1482
1636
  if (!allowed)
1483
1637
  return res.status(404).json({ error: 'image not found' });
1484
- const row = await db.get('SELECT * FROM builds WHERE id=? AND state=?', req.params.buildId, 'complete');
1638
+ const row = db.get('SELECT * FROM builds WHERE id=? AND state=?', req.params.buildId, 'complete');
1485
1639
  if (!row?.artifact_path)
1486
1640
  return res.status(404).json({ error: 'image not found' });
1487
1641
  try {
@@ -1499,17 +1653,17 @@ export async function createApp(options = {}) {
1499
1653
  res.status(404).json({ error: 'image artifact unavailable' });
1500
1654
  }
1501
1655
  });
1502
- api('get', '/api/v1/deployments/:id', deploymentProject, async (req, res) => res.json(await db.get('SELECT * FROM deployments WHERE id=?', req.params.id)));
1503
- api('post', '/api/v1/deployments/:id/cancel', deploymentProject, async (req, res) => withPublication(async () => {
1504
- const row = await db.get('SELECT * FROM deployments WHERE id=?', req.params.id);
1656
+ api('get', '/api/v1/deployments/:id', deploymentProject, async (req, res) => res.json(db.get('SELECT * FROM deployments WHERE id=?', req.params.id)));
1657
+ api('post', '/api/v1/deployments/:id/cancel', deploymentProject, async (req, res) => withPublication(() => {
1658
+ const row = db.get('SELECT * FROM deployments WHERE id=?', req.params.id);
1505
1659
  if (!row)
1506
1660
  return res.status(404).json({ error: 'deployment not found' });
1507
1661
  if (!ACTIVE_DEPLOYMENT_STATES.includes(row.state))
1508
1662
  return res.status(409).json({ error: `deployment is ${row.state}` });
1509
- const result = await db.run(`UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state IN (${ACTIVE_STATES_SQL})`, 'cancelled', now(), req.params.id);
1663
+ const result = db.run(`UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state IN (${ACTIVE_STATES_SQL})`, 'cancelled', now(), req.params.id);
1510
1664
  if (result.changes !== 1)
1511
1665
  return res.status(409).json({ error: 'deployment is no longer active' });
1512
- await audit(req.actor, {
1666
+ audit(req.actor, {
1513
1667
  type: 'cancel',
1514
1668
  project: row.project_id,
1515
1669
  deploymentId: row.id,
@@ -1520,11 +1674,11 @@ export async function createApp(options = {}) {
1520
1674
  api('post', '/api/v1/rollback', bodyProject, async (req, res) => withWriter(async () => {
1521
1675
  const project = req.body.project || 'site';
1522
1676
  try {
1523
- const out = await createRollback(project, {
1677
+ const out = createRollback(project, {
1524
1678
  actor: req.actor.name,
1525
1679
  force: Boolean(req.body.force),
1526
1680
  });
1527
- await audit(req.actor, {
1681
+ audit(req.actor, {
1528
1682
  type: 'rollback',
1529
1683
  project,
1530
1684
  deploymentId: out.deployment.id,
@@ -1538,15 +1692,15 @@ export async function createApp(options = {}) {
1538
1692
  }
1539
1693
  }));
1540
1694
  api('get', '/api/v1/volumes', queryProject, async (req, res) => res.json({
1541
- volumes: await db.all('SELECT * FROM volumes WHERE project_id=? ORDER BY name', req.query.project || 'site'),
1695
+ volumes: db.all('SELECT * FROM volumes WHERE project_id=? ORDER BY name', String(req.query.project || 'site')),
1542
1696
  }));
1543
1697
  api('delete', '/api/v1/volumes/:name', queryProject, async (req, res) => {
1544
1698
  const project = String(req.query.project || 'site');
1545
1699
  const deleteData = String(req.query.deleteData || 'false') === 'true';
1546
1700
  let lock;
1547
1701
  try {
1548
- lock = deleteData ? await acquireProjectLock(project, 'volume.delete') : null;
1549
- const volume = await db.get('SELECT * FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
1702
+ lock = deleteData ? acquireProjectLock(project, 'volume.delete') : null;
1703
+ const volume = db.get('SELECT * FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
1550
1704
  if (!volume)
1551
1705
  return res.status(404).json({ error: 'volume not found' });
1552
1706
  const config = JSON.parse(volume.config_json || '{}');
@@ -1554,7 +1708,7 @@ export async function createApp(options = {}) {
1554
1708
  return res
1555
1709
  .status(409)
1556
1710
  .json({ error: 'refusing to delete persistent volume without deleteData=true' });
1557
- const ds = await currentDesired(project);
1711
+ const ds = currentDesired(project);
1558
1712
  if (ds) {
1559
1713
  const state = JSON.parse(ds.state_json);
1560
1714
  const inUse = Object.entries(state.compose?.services || {})
@@ -1567,19 +1721,19 @@ export async function createApp(options = {}) {
1567
1721
  }
1568
1722
  let commandId = null;
1569
1723
  if (deleteData && volume.node_id) {
1570
- commandId = await queueCommand(volume.node_id, project, 'volume.delete', {
1724
+ commandId = queueCommand(volume.node_id, project, 'volume.delete', {
1571
1725
  project,
1572
1726
  volume: req.params.name,
1573
1727
  });
1574
1728
  }
1575
- await db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
1729
+ db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
1576
1730
  res.json({ ok: true, commandId, deletedMetadata: true, deleteData });
1577
1731
  }
1578
1732
  catch (e) {
1579
1733
  sendError(res, e);
1580
1734
  }
1581
1735
  finally {
1582
- await releaseLock(lock);
1736
+ releaseLock(lock);
1583
1737
  }
1584
1738
  });
1585
1739
  api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
@@ -1590,8 +1744,8 @@ export async function createApp(options = {}) {
1590
1744
  const staging = path.join(backupDir, `${backupId}-staging`);
1591
1745
  await fs.rm(staging, { recursive: true, force: true });
1592
1746
  await ensureDir(staging);
1593
- const snapshotPath = path.join(staging, 'tug.db').replaceAll("'", "''");
1594
- await db.exec(`VACUUM INTO '${snapshotPath}'`);
1747
+ const snapshotPath = path.join(staging, 'tug.db');
1748
+ db.run('VACUUM INTO ?', snapshotPath);
1595
1749
  await fs.copyFile(masterKeyFile, path.join(staging, 'master.key'));
1596
1750
  await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
1597
1751
  await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2));
@@ -1603,7 +1757,7 @@ export async function createApp(options = {}) {
1603
1757
  'images',
1604
1758
  ]);
1605
1759
  await fs.rm(staging, { recursive: true, force: true });
1606
- await db.run('INSERT INTO backups(id,project_id,target,node_id,state,artifact_ref,metadata_json,created_at,completed_at) VALUES(?,?,?,?,?,?,?,?,?)', backupId, '_control', 'control-plane', null, 'completed', artifactRef, JSON.stringify({ type: 'control' }), ts, now());
1760
+ db.run('INSERT INTO backups(id,project_id,target,node_id,state,artifact_ref,metadata_json,created_at,completed_at) VALUES(?,?,?,?,?,?,?,?,?)', backupId, '_control', 'control-plane', null, 'completed', artifactRef, JSON.stringify({ type: 'control' }), ts, now());
1607
1761
  res.json({ backupId, artifactRef, state: 'completed' });
1608
1762
  }));
1609
1763
  api('post', '/api/v1/control/restore', null, async (req, res) => withWriter(async () => {
@@ -1629,25 +1783,21 @@ export async function createApp(options = {}) {
1629
1783
  const key = (await fs.readFile(path.join(restoreDir, 'master.key'), 'utf8')).trim();
1630
1784
  if (!/^[A-Za-z0-9+/]{43}=$/.test(key))
1631
1785
  throw new Error('invalid backup master key');
1632
- snapshot = await open({
1633
- filename: path.join(restoreDir, 'tug.db'),
1634
- driver: sqlite3.Database,
1635
- mode: sqlite3.OPEN_READONLY,
1636
- });
1637
- const integrity = await snapshot.get('PRAGMA integrity_check');
1786
+ snapshot = openSnapshotDb(path.join(restoreDir, 'tug.db'));
1787
+ const integrity = snapshot.get('PRAGMA integrity_check');
1638
1788
  if (integrity?.integrity_check !== 'ok')
1639
1789
  throw new Error('backup database failed integrity check');
1640
- if (!(await snapshot.get("SELECT 1 FROM meta WHERE key='operator_token_hash'")))
1790
+ if (!snapshot.get("SELECT 1 FROM meta WHERE key='operator_token_hash'"))
1641
1791
  throw new Error('backup database has no operator identity');
1642
- for (const secret of await snapshot.all('SELECT * FROM secrets'))
1792
+ for (const secret of snapshot.all('SELECT * FROM secrets'))
1643
1793
  decrypt(secret.encrypted_value, key);
1644
- const states = await snapshot.all('SELECT * FROM desired_states');
1794
+ const states = snapshot.all('SELECT * FROM desired_states');
1645
1795
  for (const stateRow of states) {
1646
1796
  const state = JSON.parse(stateRow.state_json);
1647
1797
  for (const service of Object.values(state.services)) {
1648
1798
  if (!service.artifact)
1649
1799
  continue;
1650
- const build = await snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
1800
+ const build = snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
1651
1801
  if (!build?.artifact_path ||
1652
1802
  !/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
1653
1803
  throw new Error('backup contains an unsafe or missing image reference');
@@ -1659,12 +1809,12 @@ export async function createApp(options = {}) {
1659
1809
  throw new Error('backup image archive checksum mismatch');
1660
1810
  }
1661
1811
  }
1662
- await snapshot.close();
1812
+ snapshot.close();
1663
1813
  snapshot = undefined;
1664
1814
  await ensureDir(path.join(restoreDir, 'images'));
1665
1815
  // Validation completes before changing any live file. Stop DB-dependent work until restart.
1666
1816
  restartRequired = true;
1667
- await db.close();
1817
+ db.close();
1668
1818
  await fs.rm(`${dbFile}-wal`, { force: true });
1669
1819
  await fs.rm(`${dbFile}-shm`, { force: true });
1670
1820
  await fs.cp(path.join(restoreDir, 'images'), artifactDir, { recursive: true });
@@ -1673,15 +1823,19 @@ export async function createApp(options = {}) {
1673
1823
  res.json({ ok: true, restored: true, restartRequired: true });
1674
1824
  }
1675
1825
  catch (e) {
1676
- if (snapshot)
1677
- await snapshot.close().catch(() => { });
1826
+ try {
1827
+ snapshot?.close();
1828
+ }
1829
+ catch {
1830
+ /* Preserve the restore error. */
1831
+ }
1678
1832
  res.status(restartRequired ? 500 : 400).json({ error: errMessage(e), restartRequired });
1679
1833
  }
1680
1834
  }));
1681
- async function commandForService({ project, service, type, payload, }) {
1835
+ function commandForService({ project, service, type, payload, }) {
1682
1836
  if (!service)
1683
1837
  throw httpError(400, 'service required');
1684
- const ds = await currentDesired(project);
1838
+ const ds = currentDesired(project);
1685
1839
  if (!ds)
1686
1840
  throw httpError(404, missingProject(project));
1687
1841
  const state = JSON.parse(ds.state_json);
@@ -1691,22 +1845,22 @@ export async function createApp(options = {}) {
1691
1845
  const nodeName = svc.placement?.nodes?.[0];
1692
1846
  if (!nodeName)
1693
1847
  throw httpError(400, 'service has no explicit node placement');
1694
- const node = await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
1848
+ const node = db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
1695
1849
  if (!node)
1696
1850
  throw httpError(404, 'node not found');
1697
- const cmdId = await queueCommand(node.id, project, type, { project, service, ...payload });
1851
+ const cmdId = queueCommand(node.id, project, type, { project, service, ...payload });
1698
1852
  return { commandId: cmdId, nodeId: node.id };
1699
1853
  }
1700
1854
  api('post', '/api/v1/restart', bodyProject, async (req, res) => {
1701
1855
  try {
1702
1856
  const project = req.body.project || 'site';
1703
- const queued = await commandForService({
1857
+ const queued = commandForService({
1704
1858
  project,
1705
1859
  service: req.body.service,
1706
1860
  type: 'service.restart',
1707
1861
  payload: {},
1708
1862
  });
1709
- await audit(req.actor, {
1863
+ audit(req.actor, {
1710
1864
  type: 'restart',
1711
1865
  project,
1712
1866
  message: `service ${req.body.service} restart requested by ${req.actor.name}`,
@@ -1724,7 +1878,7 @@ export async function createApp(options = {}) {
1724
1878
  const tail = Number(req.body.tail || 200);
1725
1879
  if (!service)
1726
1880
  return res.status(400).json({ error: 'service required' });
1727
- const ds = await currentDesired(project);
1881
+ const ds = currentDesired(project);
1728
1882
  if (!ds)
1729
1883
  return res.status(404).json({ error: missingProject(project) });
1730
1884
  const state = JSON.parse(ds.state_json);
@@ -1734,25 +1888,25 @@ export async function createApp(options = {}) {
1734
1888
  const nodeName = svc.placement?.nodes?.[0];
1735
1889
  if (!nodeName)
1736
1890
  return res.status(400).json({ error: 'service has no explicit node placement' });
1737
- const node = await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
1891
+ const node = db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
1738
1892
  if (!node)
1739
1893
  return res.status(404).json({ error: 'node not found' });
1740
- const cmdId = await queueCommand(node.id, project, 'logs.fetch', { project, service, tail });
1894
+ const cmdId = queueCommand(node.id, project, 'logs.fetch', { project, service, tail });
1741
1895
  res.json({ commandId: cmdId, nodeId: node.id });
1742
1896
  });
1743
- api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(await db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
1897
+ api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
1744
1898
  api('get', '/api/v1/status', null, async (req, res) => {
1745
1899
  const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
1746
1900
  const cutoff = new Date(Date.now() - staleSeconds * 1000).toISOString();
1747
- await db.run("UPDATE nodes SET status='stale',updated_at=? WHERE last_seen_at IS NOT NULL AND last_seen_at < ?", now(), cutoff);
1901
+ db.run("UPDATE nodes SET status='stale',updated_at=? WHERE last_seen_at IS NOT NULL AND last_seen_at < ?", now(), cutoff);
1748
1902
  const projects = projectScope(req.actor, 'id');
1749
1903
  const deployments = projectScope(req.actor, 'project_id');
1750
1904
  const statuses = projectScope(req.actor, 'project_id');
1751
1905
  res.json({
1752
- projects: await db.all(`SELECT * FROM projects WHERE ${projects.where}`, ...projects.args),
1753
- deployments: await db.all(`SELECT * FROM deployments WHERE ${deployments.where} ORDER BY created_at DESC LIMIT 20`, ...deployments.args),
1754
- nodes: await db.all('SELECT id,name,status,last_seen_at FROM nodes'),
1755
- statuses: await db.all(`SELECT * FROM node_statuses WHERE ${statuses.where}`, ...statuses.args),
1906
+ projects: db.all(`SELECT * FROM projects WHERE ${projects.where}`, ...projects.args),
1907
+ deployments: db.all(`SELECT * FROM deployments WHERE ${deployments.where} ORDER BY created_at DESC LIMIT 20`, ...deployments.args),
1908
+ nodes: db.all('SELECT id,name,status,last_seen_at FROM nodes'),
1909
+ statuses: db.all(`SELECT * FROM node_statuses WHERE ${statuses.where}`, ...statuses.args),
1756
1910
  });
1757
1911
  });
1758
1912
  api('get', '/api/v1/events', null, async (req, res) => {
@@ -1762,8 +1916,8 @@ export async function createApp(options = {}) {
1762
1916
  const limit = Math.min(Number(req.query.limit || 100), 500);
1763
1917
  const scope = projectScope(req.actor, 'project_id'); // scoped tokens never see node-level or other projects' events
1764
1918
  const events = project
1765
- ? await db.all('SELECT * FROM events WHERE project_id=? ORDER BY created_at DESC LIMIT ?', project, limit)
1766
- : await db.all(`SELECT * FROM events WHERE ${scope.where} ORDER BY created_at DESC LIMIT ?`, ...scope.args, limit);
1919
+ ? db.all('SELECT * FROM events WHERE project_id=? ORDER BY created_at DESC LIMIT ?', project, limit)
1920
+ : db.all(`SELECT * FROM events WHERE ${scope.where} ORDER BY created_at DESC LIMIT ?`, ...scope.args, limit);
1767
1921
  res.json({ events });
1768
1922
  });
1769
1923
  api('get', '/api/v1/diagnostics', null, async (req, res) => {
@@ -1777,10 +1931,10 @@ export async function createApp(options = {}) {
1777
1931
  }
1778
1932
  }
1779
1933
  await check('sqlite', async () => ({
1780
- value: (await db.get('SELECT COUNT(*) AS count FROM projects')).count,
1934
+ value: db.get('SELECT COUNT(*) AS count FROM projects').count,
1781
1935
  }));
1782
1936
  await check('operator_token', async () => ({
1783
- configured: Boolean(await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash')),
1937
+ configured: Boolean(db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash')),
1784
1938
  }));
1785
1939
  await check('master_key', async () => ({ configured: Boolean(masterKey) }));
1786
1940
  await check('docker', async () => ({
@@ -1791,14 +1945,14 @@ export async function createApp(options = {}) {
1791
1945
  }));
1792
1946
  if (MESH) {
1793
1947
  await check('mesh', async () => {
1794
- const nodes = await db.all('SELECT n.name, n.overlay_ip, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
1948
+ const nodes = db.all('SELECT n.name, n.overlay_ip, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
1795
1949
  const missing = nodes.filter((n) => !n.has_key).map((n) => n.name);
1796
1950
  if (missing.length)
1797
1951
  throw new Error(`nodes without a reported WireGuard key (is TUG_MESH=true on their agents?): ${missing.join(', ')}`);
1798
1952
  return { cidr: MESH_CIDR, port: WG_PORT, nodes: nodes.length };
1799
1953
  });
1800
1954
  }
1801
- const ingressNodes = await db.all('SELECT name,ingress_status_json FROM nodes WHERE ingress=1');
1955
+ const ingressNodes = db.all('SELECT name,ingress_status_json FROM nodes WHERE ingress=1');
1802
1956
  if (ingressNodes.length) {
1803
1957
  await check('ingress', async () => {
1804
1958
  const bad = ingressNodes
@@ -1810,45 +1964,47 @@ export async function createApp(options = {}) {
1810
1964
  return { nodes: ingressNodes.map((n) => n.name) };
1811
1965
  });
1812
1966
  }
1813
- res.json({ ok: checks.every((c) => c.ok), registry: REGISTRY, checks });
1967
+ res.json({ ok: checks.every((c) => c.ok), checks });
1814
1968
  });
1815
1969
  api('post', '/api/v1/diagnostics/nodes', null, async (req, res) => {
1816
1970
  const nodes = req.body.node
1817
- ? await db.all('SELECT id FROM nodes WHERE id=? OR name=?', req.body.node, req.body.node)
1818
- : await db.all('SELECT id FROM nodes');
1971
+ ? db.all('SELECT id FROM nodes WHERE id=? OR name=?', req.body.node, req.body.node)
1972
+ : db.all('SELECT id FROM nodes');
1819
1973
  if (req.body.node && !nodes.length)
1820
1974
  return res.status(404).json({ error: `node ${req.body.node} not found` });
1821
1975
  const commands = [];
1822
1976
  for (const node of nodes) {
1823
- const cmdId = await queueCommand(node.id, null, 'diagnostics.run');
1977
+ const cmdId = queueCommand(node.id, null, 'diagnostics.run');
1824
1978
  commands.push({ commandId: cmdId, nodeId: node.id });
1825
1979
  }
1826
1980
  res.json({ commands });
1827
1981
  });
1828
- async function currentDesiredStates() {
1829
- const rows = await db.all(`SELECT ds.* FROM desired_states ds JOIN projects p ON p.current_deployment_id=ds.deployment_id ORDER BY ds.created_at DESC`);
1982
+ function currentDesiredStates() {
1983
+ const rows = db.all(`SELECT ds.* FROM desired_states ds JOIN projects p ON p.current_deployment_id=ds.deployment_id ORDER BY ds.created_at DESC`);
1830
1984
  return rows.map((s) => JSON.parse(s.state_json));
1831
1985
  }
1832
- async function endpointsFor(states) {
1986
+ function endpointsFor(states) {
1833
1987
  const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
1834
- const nodes = await db.all('SELECT n.*, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
1988
+ const nodes = db.all('SELECT n.*, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
1835
1989
  const views = nodes.map((n) => ({
1836
1990
  name: n.name,
1837
1991
  overlayIp: MESH && n.has_key ? n.overlay_ip : null,
1838
1992
  address: n.address,
1839
1993
  lastSeenAt: n.last_seen_at,
1840
1994
  }));
1841
- const statuses = (await db.all('SELECT node_id,project_id,status_json FROM node_statuses')).map((r) => ({
1995
+ const statuses = db
1996
+ .all('SELECT node_id,project_id,status_json FROM node_statuses')
1997
+ .map((r) => ({
1842
1998
  node: nodes.find((n) => n.id === r.node_id)?.name ?? r.node_id,
1843
1999
  project: r.project_id,
1844
2000
  status: JSON.parse(r.status_json),
1845
2001
  }));
1846
2002
  return resolveServiceEndpoints(states, views, statuses, staleBefore);
1847
2003
  }
1848
- async function networkFor(node) {
2004
+ function networkFor(node) {
1849
2005
  if (!MESH || !node.overlay_ip)
1850
2006
  return undefined;
1851
- const peers = await db.all('SELECT w.*, n.name FROM wg_peers w JOIN nodes n ON n.id=w.node_id WHERE w.node_id != ? ORDER BY n.name', node.id);
2007
+ const peers = db.all('SELECT w.*, n.name FROM wg_peers w JOIN nodes n ON n.id=w.node_id WHERE w.node_id != ? ORDER BY n.name', node.id);
1852
2008
  return {
1853
2009
  interface: WG_INTERFACE,
1854
2010
  overlayIp: node.overlay_ip,
@@ -1864,17 +2020,17 @@ export async function createApp(options = {}) {
1864
2020
  };
1865
2021
  }
1866
2022
  /** Names of all nodes and of the ingress nodes, for port binding and upstream decisions. */
1867
- async function nodeTopology() {
1868
- const rows = await db.all('SELECT name,ingress FROM nodes ORDER BY name');
2023
+ function nodeTopology() {
2024
+ const rows = db.all('SELECT name,ingress FROM nodes ORDER BY name');
1869
2025
  return {
1870
2026
  nodes: rows.map((r) => r.name),
1871
2027
  ingressNodes: rows.filter((r) => r.ingress).map((r) => r.name),
1872
2028
  };
1873
2029
  }
1874
2030
  /** Where this node's published ports listen: the address other nodes and the ingress reach it on, or loopback. */
1875
- async function portBindingFor(node, states) {
1876
- const hasKey = MESH && node.overlay_ip && (await db.get('SELECT 1 FROM wg_peers WHERE node_id=?', node.id));
1877
- const { nodes, ingressNodes } = await nodeTopology();
2031
+ function portBindingFor(node, states) {
2032
+ const hasKey = MESH && node.overlay_ip && db.get('SELECT 1 FROM wg_peers WHERE node_id=?', node.id);
2033
+ const { nodes, ingressNodes } = nodeTopology();
1878
2034
  const loopback = {};
1879
2035
  for (const state of states) {
1880
2036
  const services = loopbackIngressServices(state, node.name, ingressNodes, nodes);
@@ -1883,7 +2039,7 @@ export async function createApp(options = {}) {
1883
2039
  }
1884
2040
  return { address: (hasKey ? node.overlay_ip : node.address) || null, loopback };
1885
2041
  }
1886
- async function ingressFor(node, states) {
2042
+ function ingressFor(node, states) {
1887
2043
  const secrets = new Map();
1888
2044
  for (const state of states) {
1889
2045
  for (const svc of Object.values(state.compose.services || {})) {
@@ -1891,7 +2047,7 @@ export async function createApp(options = {}) {
1891
2047
  if (ing?.tls !== 'custom')
1892
2048
  continue;
1893
2049
  for (const name of [ing.cert, ing.key]) {
1894
- const row = await db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', state.project, name);
2050
+ const row = db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', state.project, name ?? null);
1895
2051
  if (row)
1896
2052
  secrets.set(`${state.project}/${name}`, decrypt(row.encrypted_value, masterKey));
1897
2053
  }
@@ -1899,9 +2055,9 @@ export async function createApp(options = {}) {
1899
2055
  }
1900
2056
  return buildIngressWork({
1901
2057
  states,
1902
- endpoints: await endpointsFor(states),
2058
+ endpoints: endpointsFor(states),
1903
2059
  node: node.name,
1904
- ...(await nodeTopology()),
2060
+ ...nodeTopology(),
1905
2061
  secrets,
1906
2062
  acmeEmail: ACME_EMAIL,
1907
2063
  });
@@ -1909,19 +2065,21 @@ export async function createApp(options = {}) {
1909
2065
  get('/agent/v1/wake', authAgent, async (req, res) => {
1910
2066
  const after = Math.max(0, Number.parseInt(String(req.query.after ?? '0'), 10) || 0);
1911
2067
  const timeoutMs = Math.min(30_000, Math.max(0, Number.parseInt(String(req.query.timeout ?? '25000'), 10) || 0));
1912
- await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
2068
+ db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
1913
2069
  res.json(await wakeHub.wait(req.node.id, after, timeoutMs));
1914
2070
  });
1915
2071
  get('/agent/v1/work', authAgent, async (req, res) => {
1916
- await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
1917
- const node = (await db.get('SELECT * FROM nodes WHERE id=?', req.node.id));
1918
- const allStates = await currentDesiredStates();
2072
+ db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
2073
+ const node = db.get('SELECT * FROM nodes WHERE id=?', req.node.id);
2074
+ const allStates = currentDesiredStates();
1919
2075
  const desiredStates = allStates.filter((st) => Object.values(st.services).some((svc) => (svc.placement?.nodes || []).includes(node.name) || !svc.placement?.nodes?.length));
1920
- const commands = await db.all('SELECT * FROM commands WHERE node_id=? AND state=?', node.id, 'pending');
2076
+ // A command past its deadline is never delivered late; the backup scheduler turns it into a missed run.
2077
+ db.run("UPDATE commands SET state='expired',updated_at=? WHERE node_id=? AND state='pending' AND deadline IS NOT NULL AND deadline<?", now(), node.id, now());
2078
+ const commands = db.all('SELECT * FROM commands WHERE node_id=? AND state=?', node.id, 'pending');
1921
2079
  if (commands.length)
1922
- await db.run(`UPDATE commands SET state='running',attempts=attempts+1,updated_at=? WHERE node_id=? AND state='pending'`, now(), node.id);
2080
+ db.run(`UPDATE commands SET state='running',attempts=attempts+1,updated_at=? WHERE node_id=? AND state='pending'`, now(), node.id);
1923
2081
  const retainedImages = [
1924
- ...new Set((await retainedStates()).flatMap((state) => Object.values(state.services)
2082
+ ...new Set(retainedStates().flatMap((state) => Object.values(state.services)
1925
2083
  .filter((service) => service.artifact &&
1926
2084
  (!service.placement.nodes?.length || service.placement.nodes.includes(node.name)))
1927
2085
  .map((service) => service.image))),
@@ -1933,64 +2091,116 @@ export async function createApp(options = {}) {
1933
2091
  commands,
1934
2092
  retainedImages,
1935
2093
  };
1936
- const network = await networkFor(node);
2094
+ const network = networkFor(node);
1937
2095
  if (network)
1938
2096
  work.network = network;
1939
- work.ports = await portBindingFor(node, desiredStates);
2097
+ work.ports = portBindingFor(node, desiredStates);
2098
+ const holds = restores.holds();
2099
+ if (Object.keys(holds).length)
2100
+ work.holds = holds;
1940
2101
  if (SERVICE_DNS) {
1941
- const all = await endpointsFor(allStates);
2102
+ const all = endpointsFor(allStates);
1942
2103
  work.serviceEndpoints = Object.fromEntries(desiredStates.map((st) => [st.project, all[st.project] || {}]));
1943
2104
  }
1944
2105
  if (node.ingress)
1945
- work.ingress = await ingressFor(node, allStates);
2106
+ work.ingress = ingressFor(node, allStates);
1946
2107
  res.json(work);
1947
2108
  });
1948
2109
  post('/agent/v1/status', authAgent, async (req, res) => {
1949
2110
  const ts = now();
1950
2111
  const report = req.body;
1951
2112
  for (const st of report.projects || []) {
1952
- await db.run(`INSERT INTO node_statuses(id,node_id,project_id,observed_version,status_json,health,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(node_id,project_id) DO UPDATE SET observed_version=excluded.observed_version,status_json=excluded.status_json,health=excluded.health,updated_at=excluded.updated_at`, id('ns'), req.node.id, st.project, st.observedVersion, JSON.stringify(st), st.health || 'unknown', ts);
1953
- await updateDeploymentConvergence(st.project);
2113
+ db.run(`INSERT INTO node_statuses(id,node_id,project_id,observed_version,status_json,health,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(node_id,project_id) DO UPDATE SET observed_version=excluded.observed_version,status_json=excluded.status_json,health=excluded.health,updated_at=excluded.updated_at`, id('ns'), req.node.id, st.project, st.observedVersion, JSON.stringify(st), st.health || 'unknown', ts);
2114
+ updateDeploymentConvergence(st.project);
1954
2115
  }
2116
+ if (report.jobs?.length)
2117
+ backups.onHeartbeat(report.jobs);
1955
2118
  for (const cr of (report.commandResults || [])) {
1956
2119
  const result = cr.result || {};
1957
- await db.run('UPDATE commands SET state=?,result_json=?,updated_at=? WHERE id=? AND node_id=?', cr.state || 'succeeded', JSON.stringify(result), ts, cr.commandId, req.node.id);
2120
+ if (cr.commandId && cr.state)
2121
+ backups.onResult(cr.commandId, cr.state, result);
2122
+ db.run('UPDATE commands SET state=?,result_json=?,updated_at=? WHERE id=? AND node_id=?', cr.state || 'succeeded', JSON.stringify(result), ts, cr.commandId ?? null, req.node.id);
1958
2123
  }
1959
2124
  if (report.ingress)
1960
- await db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
1961
- await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
1962
- const nextEndpointSnapshot = JSON.stringify(await endpointsFor(await currentDesiredStates()));
2125
+ db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
2126
+ db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
2127
+ try {
2128
+ restores.advance();
2129
+ }
2130
+ catch (e) {
2131
+ console.error(`restore failed to advance: ${errMessage(e)}`);
2132
+ }
2133
+ const nextEndpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
1963
2134
  if (nextEndpointSnapshot !== endpointSnapshot) {
1964
2135
  endpointSnapshot = nextEndpointSnapshot;
1965
- await wakeAll();
2136
+ wakeAll();
1966
2137
  }
1967
2138
  res.json({ ok: true });
1968
2139
  });
1969
2140
  post('/agent/v1/events', authAgent, async (req, res) => {
1970
2141
  const evs = req.body.events || [req.body];
1971
2142
  for (const ev of evs)
1972
- await db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at) VALUES(?,?,?,?,?,?,?,?,?)', id('evt'), ev.projectId || null, ev.deploymentId || null, req.node.id, ev.level || 'info', ev.type || 'event', ev.message || '', JSON.stringify(ev.metadata || {}), now());
2143
+ db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at) VALUES(?,?,?,?,?,?,?,?,?)', id('evt'), ev.projectId || null, ev.deploymentId || null, req.node.id, ev.level || 'info', ev.type || 'event', ev.message || '', JSON.stringify(ev.metadata || {}), now());
1973
2144
  if (Date.now() - lastEventPrune >= eventPruneIntervalMs)
1974
- await pruneEvents().catch((e) => console.error(`event pruning failed: ${errMessage(e)}`));
2145
+ try {
2146
+ pruneEvents();
2147
+ }
2148
+ catch (e) {
2149
+ console.error(`event pruning failed: ${errMessage(e)}`);
2150
+ }
1975
2151
  res.json({ ok: true });
1976
2152
  });
2153
+ // Credentials for a backup helper are released only while its command is running on the calling node, and
2154
+ // never ride in the command payload, events or results.
2155
+ get('/agent/v1/backup/credentials', authAgent, async (req, res) => {
2156
+ const command = db.get('SELECT node_id,project_id,type,state FROM commands WHERE id=?', String(req.query.command || ''));
2157
+ if (!command)
2158
+ return res.status(404).json({ error: 'command not found' });
2159
+ if (command.node_id !== req.node.id)
2160
+ return res.status(403).json({ error: 'command belongs to another node' });
2161
+ if (!BACKUP_COMMAND_TYPES.includes(command.type))
2162
+ return res.status(403).json({ error: 'command does not use backup credentials' });
2163
+ if (command.state !== 'running')
2164
+ return res.status(409).json({ error: `command is ${command.state}, not running` });
2165
+ if (!command.project_id || !backupDestination())
2166
+ return res.status(409).json({ error: 'no backup destination is configured' });
2167
+ const response = {
2168
+ env: {
2169
+ ...backupEnv(),
2170
+ RESTIC_PASSWORD: repositoryPassword(masterKey, command.project_id),
2171
+ },
2172
+ };
2173
+ res.json(response);
2174
+ });
1977
2175
  get('/agent/v1/secrets', authAgent, async (req, res) => {
1978
2176
  const project = String(req.query.project || 'site');
1979
- let rows = await db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
1980
- if (req.query.services) {
1981
- const ds = await currentDesired(project);
1982
- const names = String(req.query.services)
2177
+ let rows = db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
2178
+ const ds = currentDesired(project);
2179
+ const desired = ds ? JSON.parse(ds.state_json) : undefined;
2180
+ const requested = req.query.services
2181
+ ? String(req.query.services)
1983
2182
  .split(',')
1984
2183
  .map((s) => s.trim())
1985
- .filter(Boolean);
1986
- const allowed = ds
1987
- ? serviceSecretNames(JSON.parse(ds.state_json).compose, names)
1988
- : new Set();
1989
- rows = rows.filter((r) => allowed.has(r.name));
2184
+ .filter(Boolean)
2185
+ : Object.keys(desired?.services || {});
2186
+ const names = requested.filter((name) => desired?.services[name]?.placement.nodes?.includes(req.node.name));
2187
+ const allowed = serviceSecretNames(desired?.compose, names);
2188
+ const registrySecret = rows.find((row) => row.name === REGISTRY_AUTH_SECRET);
2189
+ const registryAuth = {};
2190
+ const credentials = parseRegistryCredentials(registrySecret ? decrypt(registrySecret.encrypted_value, masterKey) : undefined);
2191
+ for (const name of names) {
2192
+ const spec = desired.services[name];
2193
+ if (!spec.artifact && spec.image) {
2194
+ const host = imageLocation(spec.image).host;
2195
+ if (credentials[host])
2196
+ registryAuth[host] = credentials[host];
2197
+ }
1990
2198
  }
2199
+ rows = rows.filter((row) => allowed.has(row.name) && row.name !== REGISTRY_AUTH_SECRET);
1991
2200
  const secrets = {
1992
2201
  secrets: Object.fromEntries(rows.map((r) => [r.name, decrypt(r.encrypted_value, masterKey)])),
1993
2202
  versions: Object.fromEntries(rows.map((r) => [r.name, r.version])),
2203
+ ...(Object.keys(registryAuth).length ? { registryAuth } : {}),
1994
2204
  };
1995
2205
  res.json(secrets);
1996
2206
  });
@@ -2019,7 +2229,7 @@ function attachAgentStream(app, server) {
2019
2229
  const raw = String(request.headers.authorization || '').replace(/^Bearer /, '');
2020
2230
  void (async () => {
2021
2231
  if (pathname === '/api/v1/live') {
2022
- const actor = await runtime.authenticateOperator(raw);
2232
+ const actor = runtime.authenticateOperator(raw);
2023
2233
  if (!actor) {
2024
2234
  reject(401, 'Unauthorized');
2025
2235
  return;
@@ -2031,7 +2241,7 @@ function attachAgentStream(app, server) {
2031
2241
  wss.handleUpgrade(request, socket, head, (ws) => runtime.live.operator(actor, ws));
2032
2242
  }
2033
2243
  else {
2034
- const node = await runtime.authenticate(raw);
2244
+ const node = runtime.authenticate(raw);
2035
2245
  if (!node) {
2036
2246
  reject(401, 'Unauthorized');
2037
2247
  return;