@wpelevator/tug 0.1.0 → 0.2.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.
package/dist/control.js CHANGED
@@ -2,6 +2,12 @@
2
2
  import express from 'express';
3
3
  import multer from 'multer';
4
4
  import fs from 'node:fs/promises';
5
+ import { createReadStream } from 'node:fs';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { saveImageArchive, inspectImage, validateImageArchive, hashArchive } from "./artifacts.js";
8
+ import sqlite3 from 'sqlite3';
9
+ import { open } from 'sqlite';
10
+ import { scanArchive } from "./archive.js";
5
11
  import https from 'node:https';
6
12
  import path from 'node:path';
7
13
  import * as tar from 'tar';
@@ -13,15 +19,22 @@ import { ensureDir, errMessage, hashToken, id, now, token } from "./util.js";
13
19
  import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint } from "./mesh.js";
14
20
  import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
15
21
  import { ACTIVE_DEPLOYMENT_STATES } from "./protocol.js";
22
+ import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, assertProject, bodyProject, createGuard, createToken, listTokens, parseTokenRequest, queryProject, recordEvent, revokeToken } from "./tokens.js";
16
23
  const httpError = (status, message) => Object.assign(new Error(message), { status });
24
+ /** Versions of rollback deployments start with this; `tug deploy` may not use it, which is how automatic rollback tells them apart. */
25
+ const ROLLBACK_VERSION_PREFIX = 'rollback-';
17
26
  const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map(s => `'${s}'`).join(',');
18
27
  const PORT = Number(process.env.PORT || 8080);
19
- export async function createApp() {
28
+ export async function createApp(options = {}) {
29
+ const images = { build: buildAndPush, save: saveImageArchive, inspect: inspectImage, validate: validateImageArchive, run: runCommand, ...options.images };
20
30
  const DATA_DIR = process.env.TUG_DATA_DIR || './data';
21
- const upload = multer({ dest: process.env.TUG_UPLOAD_DIR || path.join(DATA_DIR, 'uploads') });
31
+ const MAX_IMAGE_BYTES = Number(process.env.TUG_MAX_IMAGE_BYTES || 5 * 1024 ** 3);
32
+ if (!Number.isSafeInteger(MAX_IMAGE_BYTES) || MAX_IMAGE_BYTES < 1)
33
+ throw new Error('TUG_MAX_IMAGE_BYTES must be a positive safe integer');
34
+ const upload = multer({ dest: process.env.TUG_UPLOAD_DIR || path.join(DATA_DIR, 'uploads'), limits: { fileSize: MAX_IMAGE_BYTES, files: 64, fields: 1, fieldSize: 1024 ** 2 } });
22
35
  const dbFile = path.join(DATA_DIR, 'tug.db');
23
36
  const masterKeyFile = path.join(DATA_DIR, 'master.key');
24
- const REGISTRY = process.env.TUG_REGISTRY || 'localhost:5000';
37
+ const REGISTRY = process.env.TUG_REGISTRY?.trim() || '';
25
38
  const REGISTRY_AUTH_PATH = process.env.TUG_REGISTRY_AUTH_PATH || null;
26
39
  const REGISTRY_CONTAINER = process.env.TUG_REGISTRY_CONTAINER || null;
27
40
  // Networking layers are opt-in; with all of them off the work response is unchanged.
@@ -44,7 +57,7 @@ export async function createApp() {
44
57
  await runCommand('docker', ['restart', REGISTRY_CONTAINER]).catch(e => console.error(`registry reload failed: ${e.message}`));
45
58
  }
46
59
  async function upsertRegistryCredential(name, password) {
47
- if (!REGISTRY_AUTH_PATH)
60
+ if (!REGISTRY || !REGISTRY_AUTH_PATH)
48
61
  return;
49
62
  await withHtpasswdLock(async () => {
50
63
  const { out } = await runCommand('htpasswd', ['-nbB', name, password]);
@@ -76,7 +89,7 @@ export async function createApp() {
76
89
  });
77
90
  await reloadRegistry();
78
91
  }
79
- const db = await openDb(dbFile);
92
+ const db = await (options.openDatabase || openDb)(dbFile);
80
93
  const masterKey = await loadOrCreateMasterKey(masterKeyFile);
81
94
  const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
82
95
  if (!operator) {
@@ -86,14 +99,66 @@ export async function createApp() {
86
99
  console.log(`operator token: ${t}`);
87
100
  }
88
101
  const app = express();
102
+ let restartRequired = false;
103
+ app.use((_req, res, next) => restartRequired ? res.status(503).json({ error: 'control restored; restart required', restartRequired: true }) : next());
89
104
  app.use(express.json({ limit: '20mb' }));
90
- const authOperator = async (req, res, next) => {
91
- const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
92
- const row = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
93
- if (!raw || hashToken(raw) !== row?.value)
94
- return res.status(401).json({ error: 'unauthorized' });
95
- next();
105
+ // Hold the writer for the operation lifetime, including after an HTTP disconnect.
106
+ let writer = Promise.resolve();
107
+ async function withWriter(operation) {
108
+ const previous = writer;
109
+ let release;
110
+ writer = new Promise(resolve => { release = resolve; });
111
+ await previous;
112
+ try {
113
+ if (restartRequired)
114
+ throw httpError(503, 'control restored; restart required');
115
+ return await operation();
116
+ }
117
+ finally {
118
+ release();
119
+ }
120
+ }
121
+ let publication = Promise.resolve();
122
+ async function withPublication(operation) {
123
+ const previous = publication;
124
+ let release;
125
+ publication = new Promise(resolve => { release = resolve; });
126
+ await previous;
127
+ try {
128
+ return await operation();
129
+ }
130
+ finally {
131
+ release();
132
+ }
133
+ }
134
+ const safe = (handler) => (req, res, next) => {
135
+ try {
136
+ void Promise.resolve(handler(req, res, next)).catch(next);
137
+ }
138
+ catch (error) {
139
+ next(error);
140
+ }
96
141
  };
142
+ const route = (method, url, handlers) => app[method](url, ...handlers.map(safe));
143
+ const get = (url, ...handlers) => route('get', url, handlers);
144
+ const post = (url, ...handlers) => route('post', url, handlers);
145
+ // Operator-facing routes go through api(): the role table in tokens.ts is the only place a route's access is declared.
146
+ const guard = createGuard(db);
147
+ const apiRoutes = new Map();
148
+ app.locals.apiRoutes = apiRoutes;
149
+ const api = (method, url, resolve, ...handlers) => {
150
+ const key = `${method.toUpperCase()} ${url}`;
151
+ const role = ROUTE_ROLES[key];
152
+ if (!role || role === 'public')
153
+ throw new Error(`API route ${key} has no declared role in ROUTE_ROLES`);
154
+ apiRoutes.set(key, role);
155
+ route(method, url, [guard(role, resolve ?? undefined), ...handlers]);
156
+ };
157
+ const deploymentProject = async (req) => (await db.get('SELECT project_id FROM deployments WHERE id=?', req.params.id))?.project_id;
158
+ const commandProject = async (req) => { const row = await db.get('SELECT project_id FROM commands WHERE id=?', req.params.id); return row ? row.project_id : undefined; };
159
+ /** Audit trail entry for a control action; bookkeeping failures are logged, never returned to the caller. */
160
+ const audit = (actor, event) => recordEvent(db, { ...event, actor: actor.name }).catch(e => console.error(`audit event failed: ${errMessage(e)}`));
161
+ const projectScope = (actor, column) => actor.projects ? { where: `${column} IN (${actor.projects.map(() => '?').join(',') || "''"})`, args: actor.projects } : { where: '1=1', args: [] };
97
162
  const authAgent = async (req, res, next) => {
98
163
  const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
99
164
  const node = raw && await db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw));
@@ -116,15 +181,80 @@ export async function createApp() {
116
181
  required.add(node);
117
182
  if (!required.size)
118
183
  return;
119
- const statuses = await db.all('SELECT node_id,observed_version,health FROM node_statuses WHERE project_id=?', projectId);
184
+ const statuses = await db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
120
185
  const byNode = new Map(statuses.map(s => [s.node_id, s]));
121
- const failed = [...required].some(node => byNode.get(node)?.observed_version === state.version && byNode.get(node)?.health === 'failed');
122
- if (failed)
123
- await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', 'one or more assigned nodes reported failed health', now(), project.current_deployment_id, 'rolling_out');
124
- const ok = [...required].every(node => byNode.get(node)?.observed_version === state.version && ['serving', 'skipped'].includes(byNode.get(node)?.health ?? ''));
186
+ const reportsRevision = (node) => state.revision ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision : byNode.get(node)?.observed_version === state.version;
187
+ const failed = [...required].some(node => reportsRevision(node) && byNode.get(node)?.health === 'failed');
188
+ if (failed) {
189
+ const marked = await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', 'one or more assigned nodes reported failed health', now(), project.current_deployment_id, 'rolling_out');
190
+ if (marked.changes === 1)
191
+ scheduleAutoRollback(projectId, project.current_deployment_id, state);
192
+ }
193
+ const ok = [...required].every(node => (state.revision ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision : byNode.get(node)?.observed_version === state.version) && ['serving', 'skipped'].includes(byNode.get(node)?.health ?? ''));
125
194
  if (ok)
126
195
  await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
127
196
  }
197
+ /** Events control writes when it rolls back on its own; they are attributed to a reserved system actor. */
198
+ async function recordAutoRollbackEvent(projectId, deploymentId, type, message, metadata = {}) {
199
+ 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);
200
+ }
201
+ /**
202
+ * Creates a rollback deployment that copies the most recent earlier deployment's desired state under a
203
+ * new revision. Shared by `POST /api/v1/rollback` and automatic rollback; the caller holds the writer queue.
204
+ * `auto` only considers `succeeded` deployments and requires `failedDeploymentId` to still be current.
205
+ */
206
+ async function createRollback(project, opts) {
207
+ let lock;
208
+ try {
209
+ lock = await acquireProjectLock(project, opts.auto ? 'auto-rollback' : 'rollback');
210
+ 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);
211
+ if (active && !opts.force)
212
+ throw httpError(409, `deployment ${active.id} is already ${active.state}; retry with force to rollback anyway`);
213
+ const current = await db.get('SELECT current_deployment_id FROM projects WHERE id=?', project);
214
+ if (opts.failedDeploymentId && current?.current_deployment_id !== opts.failedDeploymentId)
215
+ throw httpError(409, `deployment ${opts.failedDeploymentId} is no longer the current deployment`);
216
+ const states = opts.auto ? `'succeeded'` : `'succeeded','rolling_out'`;
217
+ 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
218
+ 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 || '');
219
+ if (!previousDs)
220
+ throw httpError(404, 'no previous deployment found');
221
+ const ts = now();
222
+ const depId = id('dep');
223
+ const previousState = JSON.parse(previousDs.state_json);
224
+ previousState.revision = depId;
225
+ 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);
226
+ 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);
227
+ await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
228
+ return { deployment: await db.get('SELECT * FROM deployments WHERE id=?', depId), desiredState: previousState };
229
+ }
230
+ finally {
231
+ await releaseLock(lock);
232
+ }
233
+ }
234
+ /**
235
+ * With `x-tug.rollback: auto`, a deployment that just turned `failed` is rolled back to the previous
236
+ * `succeeded` one. Rollback deployments are never rolled back (their versions carry a reserved prefix
237
+ * that `tug deploy` cannot use), so a bad rollback target cannot loop. Runs in the background so a
238
+ * busy writer queue never blocks agent status reports; the outcome is recorded as an event either way.
239
+ */
240
+ function scheduleAutoRollback(projectId, deploymentId, state) {
241
+ if (state.compose['x-tug']?.rollback !== 'auto')
242
+ return;
243
+ void (async () => {
244
+ const failed = await db.get('SELECT * FROM deployments WHERE id=?', deploymentId);
245
+ if (failed?.version.startsWith(ROLLBACK_VERSION_PREFIX)) {
246
+ await recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `rollback deployment ${failed.version} failed; not rolling back a rollback`);
247
+ return;
248
+ }
249
+ try {
250
+ const out = await withWriter(() => createRollback(projectId, { actor: AUTO_ROLLBACK_ACTOR, auto: true, failedDeploymentId: deploymentId }));
251
+ 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}`, { failedDeploymentId: deploymentId, rollbackDeploymentId: out.deployment.id, version: out.desiredState.version });
252
+ }
253
+ catch (e) {
254
+ await recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `deployment ${deploymentId} failed but automatic rollback was not possible: ${errMessage(e)}`).catch(() => { });
255
+ }
256
+ })().catch(e => console.error('auto rollback failed:', errMessage(e)));
257
+ }
128
258
  async function currentDesired(projectId) {
129
259
  return db.get('SELECT ds.* FROM desired_states ds JOIN projects p ON p.current_deployment_id=ds.deployment_id WHERE p.id=?', projectId);
130
260
  }
@@ -145,21 +275,25 @@ export async function createApp() {
145
275
  err.lock = existing;
146
276
  throw err;
147
277
  }
148
- return { name, owner };
278
+ const heartbeat = setInterval(() => { void db.run('UPDATE locks SET expires_at=? WHERE name=? AND owner=?', new Date(Date.now() + ttlMs).toISOString(), name, owner).catch(() => { }); }, Math.max(10, Math.floor(ttlMs / 3)));
279
+ heartbeat.unref();
280
+ return { name, owner, heartbeat };
149
281
  }
150
282
  async function releaseLock(lock) {
283
+ if (lock?.heartbeat)
284
+ clearInterval(lock.heartbeat);
151
285
  if (lock)
152
286
  await db.run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner).catch(() => { });
153
287
  }
154
- function sendError(res, e) { const err = e; res.status(err.status || 500).json({ error: err.message, lock: err.lock || undefined }); }
155
- app.get('/healthz', (req, res) => res.json({ ok: true }));
156
- app.post('/api/v1/projects', authOperator, async (req, res) => {
288
+ function sendError(res, e) { const err = e; res.status(restartRequired ? 503 : err.status || 500).json({ error: err.message, lock: err.lock || undefined }); }
289
+ get('/healthz', (req, res) => res.json({ ok: true }));
290
+ api('post', '/api/v1/projects', req => String(req.body?.name || 'site'), async (req, res) => {
157
291
  const name = req.body.name || 'site';
158
292
  const ts = now();
159
293
  await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
160
294
  res.json(await db.get('SELECT * FROM projects WHERE id=?', name));
161
295
  });
162
- app.get('/api/v1/projects/:project', authOperator, async (req, res) => res.json(await db.get('SELECT * FROM projects WHERE id=?', req.params.project)));
296
+ 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)));
163
297
  const NODE_INFO_SQL = 'SELECT id,name,address,public_address,overlay_ip,status,created_at,updated_at FROM nodes WHERE id=?';
164
298
  /** Allocates the node's stable overlay IP on first registration (retrying if a concurrent register took it). */
165
299
  async function ensureOverlayIp(nodeId) {
@@ -207,7 +341,7 @@ export async function createApp() {
207
341
  const node = (await db.get(NODE_INFO_SQL, name));
208
342
  return { node, agentToken, registryToken };
209
343
  }
210
- app.post('/api/v1/nodes/register', authOperator, async (req, res) => {
344
+ api('post', '/api/v1/nodes/register', null, async (req, res) => {
211
345
  const name = req.body.name;
212
346
  if (!name)
213
347
  return res.status(400).json({ error: 'name required' });
@@ -220,7 +354,7 @@ export async function createApp() {
220
354
  });
221
355
  // Self-service enrollment: operator mints a short-lived, limited-use key; the agent
222
356
  // exchanges it for durable credentials on first boot (Portainer Edge-style join).
223
- app.post('/api/v1/nodes/enroll-token', authOperator, async (req, res) => {
357
+ api('post', '/api/v1/nodes/enroll-token', null, async (req, res) => {
224
358
  const name = req.body.name || null;
225
359
  const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? 3600));
226
360
  const uses = Math.max(1, Number(req.body.uses ?? 1));
@@ -229,7 +363,7 @@ export async function createApp() {
229
363
  await db.run('INSERT INTO enroll_tokens(token_hash,name,uses_remaining,expires_at,created_at) VALUES(?,?,?,?,?)', hashToken(enrollToken), name, uses, expiresAt, now());
230
364
  res.json({ enrollToken, name, usesRemaining: uses, expiresAt, registry: REGISTRY });
231
365
  });
232
- app.post('/agent/v1/enroll', async (req, res) => {
366
+ post('/agent/v1/enroll', async (req, res) => {
233
367
  const body = req.body;
234
368
  const raw = String(body.enrollToken || (req.headers.authorization || '').replace(/^Bearer /, '')).trim();
235
369
  if (!raw)
@@ -258,8 +392,8 @@ export async function createApp() {
258
392
  const enrolled = { ...result, registry: REGISTRY };
259
393
  res.json(enrolled);
260
394
  });
261
- app.get('/api/v1/nodes', authOperator, async (req, res) => res.json({ nodes: await db.all('SELECT id,name,address,public_address,overlay_ip,ingress,ingress_status_json,status,last_seen_at,created_at,updated_at FROM nodes ORDER BY name') }));
262
- app.post('/api/v1/nodes/:name/rotate-registry-token', authOperator, async (req, res) => {
395
+ api('get', '/api/v1/nodes', null, async (req, res) => res.json({ nodes: await db.all('SELECT id,name,address,public_address,overlay_ip,ingress,ingress_status_json,status,last_seen_at,created_at,updated_at FROM nodes ORDER BY name') }));
396
+ api('post', '/api/v1/nodes/:name/rotate-registry-token', null, async (req, res) => {
263
397
  const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
264
398
  if (!node)
265
399
  return res.status(404).json({ error: 'node not found' });
@@ -268,7 +402,7 @@ export async function createApp() {
268
402
  await upsertRegistryCredential(node.name, registryToken);
269
403
  res.json({ ok: true, node: node.name, registryToken });
270
404
  });
271
- app.post('/api/v1/nodes/:name/revoke-registry-token', authOperator, async (req, res) => {
405
+ api('post', '/api/v1/nodes/:name/revoke-registry-token', null, async (req, res) => {
272
406
  const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
273
407
  if (!node)
274
408
  return res.status(404).json({ error: 'node not found' });
@@ -284,7 +418,7 @@ export async function createApp() {
284
418
  }
285
419
  // The private key lives only on the node, so rotation is a command the agent executes: it
286
420
  // generates a new keypair, reports the public key and peers pick it up from their next work response.
287
- app.post('/api/v1/nodes/:name/rotate-wg-key', authOperator, async (req, res) => {
421
+ api('post', '/api/v1/nodes/:name/rotate-wg-key', null, async (req, res) => {
288
422
  if (!MESH)
289
423
  return res.status(409).json({ error: 'the WireGuard mesh is not enabled (TUG_MESH=true)' });
290
424
  const node = await findNode(req.params.name);
@@ -292,7 +426,7 @@ export async function createApp() {
292
426
  return res.status(404).json({ error: 'node not found' });
293
427
  res.json({ commandId: await queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
294
428
  });
295
- app.post('/api/v1/nodes/:name/ingress', authOperator, async (req, res) => {
429
+ api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
296
430
  const node = await findNode(req.params.name);
297
431
  if (!node)
298
432
  return res.status(404).json({ error: 'node not found' });
@@ -300,7 +434,7 @@ export async function createApp() {
300
434
  await db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
301
435
  res.json({ ok: true, node: node.name, ingress: enabled });
302
436
  });
303
- app.post('/agent/v1/wireguard/key', authAgent, async (req, res) => {
437
+ post('/agent/v1/wireguard/key', authAgent, async (req, res) => {
304
438
  if (!MESH)
305
439
  return res.status(409).json({ error: 'the WireGuard mesh is not enabled' });
306
440
  const { publicKey } = req.body;
@@ -314,7 +448,7 @@ export async function createApp() {
314
448
  sendError(res, /UNIQUE constraint/.test(errMessage(e)) ? httpError(409, 'public key already registered to another node') : e);
315
449
  }
316
450
  });
317
- app.post('/api/v1/registry/auth', async (req, res) => {
451
+ post('/api/v1/registry/auth', async (req, res) => {
318
452
  const username = req.body.username;
319
453
  const password = req.body.password;
320
454
  const node = username && password ? 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)) : null;
@@ -322,7 +456,20 @@ export async function createApp() {
322
456
  return res.status(401).json({ ok: false, error: 'invalid registry credentials' });
323
457
  res.json({ ok: true, node: node.name });
324
458
  });
325
- app.post('/api/v1/secrets', authOperator, async (req, res) => {
459
+ api('post', '/api/v1/tokens', null, async (req, res) => {
460
+ const request = parseTokenRequest(req.body);
461
+ const created = await createToken(db, request, req.actor.name);
462
+ await audit(req.actor, { type: 'token.create', message: `token ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`, metadata: { name: created.name, role: created.role, projects: created.projects, expiresAt: created.expiresAt } });
463
+ res.json(created);
464
+ });
465
+ api('get', '/api/v1/tokens', null, async (_req, res) => res.json({ tokens: await listTokens(db) }));
466
+ api('delete', '/api/v1/tokens/:name', null, async (req, res) => {
467
+ if (!await revokeToken(db, req.params.name))
468
+ return res.status(404).json({ error: 'token not found' });
469
+ await audit(req.actor, { type: 'token.revoke', message: `token ${req.params.name} revoked by ${req.actor.name}`, metadata: { name: req.params.name } });
470
+ res.json({ ok: true, name: req.params.name });
471
+ });
472
+ api('post', '/api/v1/secrets', bodyProject, async (req, res) => {
326
473
  const { project = 'site', name, value } = req.body;
327
474
  if (!name || value == null)
328
475
  return res.status(400).json({ error: 'name and value required' });
@@ -333,6 +480,7 @@ export async function createApp() {
333
480
  const ts = now();
334
481
  await db.run(`INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)
335
482
  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);
483
+ await audit(req.actor, { type: 'secret.set', project, message: `secret ${name} set to version ${(old?.version || 0) + 1} by ${req.actor.name}`, metadata: { name, version: (old?.version || 0) + 1 } });
336
484
  res.json({ ok: true, name, version: (old?.version || 0) + 1 });
337
485
  }
338
486
  catch (e) {
@@ -342,109 +490,275 @@ export async function createApp() {
342
490
  await releaseLock(lock);
343
491
  }
344
492
  });
345
- app.get('/api/v1/secrets', authOperator, async (req, res) => res.json({ secrets: await db.all('SELECT project_id,name,version,updated_at FROM secrets WHERE project_id=? ORDER BY name', req.query.project || 'site') }));
346
- app.delete('/api/v1/secrets/:name', authOperator, async (req, res) => { await db.run('DELETE FROM secrets WHERE project_id=? AND name=?', req.query.project || 'site', req.params.name); res.json({ ok: true }); });
347
- app.post('/api/v1/deployments', authOperator, upload.single('context'), async (req, res) => {
348
- const body = req.file ? JSON.parse(req.body.payload) : req.body;
349
- const project = body.project || 'site';
350
- const compose = body.compose;
351
- const version = body.version || `git-${Date.now()}`;
352
- const nodes = await db.all('SELECT name FROM nodes');
353
- try {
354
- validateCompose(compose, nodes.map(n => n.name));
355
- }
356
- catch (e) {
357
- return res.status(400).json({ error: errMessage(e) });
358
- }
359
- for (const [svcName, svc] of Object.entries(compose.services)) {
360
- const ing = svc['x-tug']?.ingress;
361
- if (ing?.tls !== 'custom')
493
+ api('get', '/api/v1/secrets', queryProject, async (req, res) => res.json({ secrets: await db.all('SELECT project_id,name,version,updated_at FROM secrets WHERE project_id=? ORDER BY name', req.query.project || 'site') }));
494
+ api('delete', '/api/v1/secrets/:name', queryProject, async (req, res) => {
495
+ const project = String(req.query.project || 'site');
496
+ const removed = await db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, req.params.name);
497
+ if (removed.changes)
498
+ await audit(req.actor, { type: 'secret.delete', project, message: `secret ${req.params.name} deleted by ${req.actor.name}`, metadata: { name: req.params.name } });
499
+ res.json({ ok: true });
500
+ });
501
+ const artifactDir = path.join(DATA_DIR, 'images');
502
+ await ensureDir(artifactDir);
503
+ async function retainedStates() {
504
+ // All published history is retained. This conservative superset protects failed/lagging versions too.
505
+ return (await db.all('SELECT * FROM desired_states')).map(row => JSON.parse(row.state_json));
506
+ }
507
+ async function collectArtifacts() {
508
+ const states = await retainedStates();
509
+ const refs = new Set(states.flatMap(state => Object.values(state.services).flatMap(service => service.artifact ? [service.artifact.buildId] : [])));
510
+ const imageRefs = new Set(states.flatMap(state => Object.values(state.services).map(service => service.image)));
511
+ const rows = await db.all('SELECT * FROM builds');
512
+ for (const row of rows) {
513
+ if (refs.has(row.id) || imageRefs.has(row.image_ref))
362
514
  continue;
363
- for (const secret of [ing.cert, ing.key]) {
364
- if (!await db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret))
365
- return res.status(400).json({ error: `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with secret:set first` });
366
- }
515
+ const active = await db.get("SELECT 1 FROM deployments WHERE id=? AND state IN ('uploading','building')", row.deployment_id);
516
+ if (active)
517
+ continue;
518
+ await fs.rm(path.join(DATA_DIR, row.artifact_path || path.join('images', `${row.id}.tar.gz`)), { force: true });
519
+ await images.run('docker', ['image', 'rm', row.image_ref]).catch(() => { });
520
+ await db.run('DELETE FROM builds WHERE id=?', row.id);
367
521
  }
368
- 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);
369
- if (active)
370
- return res.status(409).json({ error: `deployment ${active.id} is already ${active.state}` });
371
- const depId = id('dep');
372
- const ts = now();
522
+ const remaining = await db.all('SELECT id FROM builds');
523
+ const owned = new Set(remaining.flatMap(row => [`${row.id}.tar.gz`, `${row.id}.tar.gz.tmp`]));
524
+ for (const name of await fs.readdir(artifactDir))
525
+ if (!owned.has(name))
526
+ await fs.rm(path.join(artifactDir, name), { force: true });
527
+ }
528
+ // Previous-process builds cannot finish after a restart; clear their unpublished files.
529
+ await db.run("UPDATE deployments SET state='build_failed',error_message='control restarted during build' WHERE state IN ('uploading','building')");
530
+ await collectArtifacts();
531
+ const receiveDeployment = upload.any();
532
+ api('post', '/api/v1/deployments', null, (req, res) => {
533
+ receiveDeployment(req, res, error => {
534
+ void withWriter(() => deployRequest(req, res, error)).catch(async (failure) => {
535
+ const files = Array.isArray(req.files) ? req.files : [];
536
+ await Promise.all(files.map(file => fs.rm(file.path, { force: true }).catch(() => { })));
537
+ sendError(res, failure);
538
+ });
539
+ });
540
+ });
541
+ async function deployRequest(req, res, uploadError) {
542
+ const files = (Array.isArray(req.files) ? req.files : []);
543
+ let responseBody;
544
+ let responseError;
373
545
  let lock;
546
+ let depId;
547
+ let contextDir;
374
548
  try {
549
+ if (uploadError)
550
+ throw httpError(400, errMessage(uploadError));
551
+ let body;
552
+ try {
553
+ body = req.is('multipart/form-data') ? JSON.parse(req.body.payload) : req.body;
554
+ }
555
+ catch {
556
+ throw httpError(400, 'invalid deployment payload JSON');
557
+ }
558
+ if (!body || typeof body !== 'object')
559
+ throw httpError(400, 'deployment payload is required');
560
+ const project = body.project || 'site';
561
+ const compose = body.compose;
562
+ const version = body.version || `git-${Date.now()}`;
563
+ assertProject(req.actor, project);
564
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(project) || typeof version !== 'string' || !version.length)
565
+ throw httpError(400, 'invalid project or version');
566
+ if (version.startsWith(ROLLBACK_VERSION_PREFIX))
567
+ throw httpError(400, `version cannot start with "${ROLLBACK_VERSION_PREFIX}" (reserved for rollback deployments)`);
568
+ const nodes = await db.all('SELECT name FROM nodes ORDER BY created_at,name');
569
+ try {
570
+ validateCompose(compose, nodes.map(n => n.name));
571
+ }
572
+ catch (e) {
573
+ throw httpError(400, errMessage(e));
574
+ }
575
+ const builtServices = Object.entries(compose.services).filter(([, service]) => service.build).map(([name]) => name);
576
+ const local = body.localImages;
577
+ const context = files.find(file => file.fieldname === 'context');
578
+ if (local !== undefined && (!Array.isArray(local) || context))
579
+ throw httpError(400, 'localImages must be an array and cannot accompany a source context');
580
+ if (files.filter(file => file.fieldname === 'context').length > 1)
581
+ throw httpError(400, 'duplicate source context');
582
+ if (local) {
583
+ if (local.some(item => !item || typeof item !== 'object'))
584
+ throw httpError(400, 'invalid local image manifest');
585
+ if (local.length !== builtServices.length || new Set(local.map(item => item.service)).size !== local.length || new Set(local.map(item => item.field)).size !== local.length)
586
+ throw httpError(400, 'localImages must contain exactly one image for each build service');
587
+ for (const item of local) {
588
+ if (!/^tug-build\/[a-f0-9-]{36}\/image-\d+:local$/.test(item.imageRef) || item.imageRef.split('/').at(-1) !== `${item.field}:local` || !builtServices.includes(item.service) || !/^image-\d+$/.test(item.field) || files.filter(file => file.fieldname === item.field).length !== 1 || !/^[a-f0-9]{64}$/.test(item.sha256) || !Number.isSafeInteger(item.sizeBytes) || item.sizeBytes <= 0 || item.sizeBytes > MAX_IMAGE_BYTES)
589
+ throw httpError(400, 'invalid local image manifest or file');
590
+ }
591
+ }
592
+ if (files.length !== (local ? local.length : context ? 1 : 0))
593
+ throw httpError(400, 'unexpected deployment upload');
594
+ if (!REGISTRY && builtServices.length && !context && !local)
595
+ throw httpError(400, 'build services require a source context or localImages upload without a registry');
596
+ for (const [svcName, svc] of Object.entries(compose.services)) {
597
+ const ing = svc['x-tug']?.ingress;
598
+ if (ing?.tls !== 'custom')
599
+ continue;
600
+ for (const secret of [ing.cert, ing.key])
601
+ if (!await db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret))
602
+ throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with secret-set first`);
603
+ }
375
604
  lock = await acquireProjectLock(project, 'deploy');
605
+ const active = await db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
606
+ if (active)
607
+ throw httpError(409, `deployment ${active.id} is already ${active.state}`);
608
+ depId = id('dep');
609
+ const ts = now();
376
610
  await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
377
- await db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at) VALUES(?,?,?,?,?,?)', depId, project, version, 'building', ts, ts);
378
- const desired = buildDesiredState({ project, version, compose, registry: REGISTRY, defaultNode: nodes[0]?.name || null });
379
- if (req.file) {
380
- const contextDir = path.resolve(DATA_DIR, 'build-contexts', depId);
381
- await unpackContext(req.file.path, contextDir);
382
- for (const [svc, spec] of Object.entries(compose.services))
383
- if (spec.build)
384
- await buildAndPush({ contextDir, service: svc, buildSpec: spec.build, imageRef: desired.services[svc].image, push: process.env.TUG_PUSH !== 'false' });
611
+ 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);
612
+ const desired = buildDesiredState({ project, version, revision: depId, compose, registry: REGISTRY, defaultNode: nodes[0]?.name || null });
613
+ if (context) {
614
+ contextDir = path.resolve(DATA_DIR, 'build-contexts', depId);
615
+ await unpackContext(context.path, contextDir);
385
616
  }
386
- 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);
387
- for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
388
- const lp = volumeSpec?.['x-tug'] || {};
389
- const nodeName = lp.placement?.nodes?.[0] || null;
390
- const node = nodeName ? await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName) : null;
391
- await db.run(`INSERT INTO volumes(id,project_id,name,type,driver,node_id,config_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)
392
- 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);
617
+ for (const service of builtServices) {
618
+ if (!contextDir && !local) {
619
+ desired.services[service].image = `${REGISTRY}/${project}/${service}:${version}`;
620
+ continue;
621
+ } // Explicit registry/no-build-upload means an existing pushed image.
622
+ const spec = desired.services[service];
623
+ const buildId = id('build');
624
+ const imageRef = spec.image;
625
+ 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);
626
+ let info;
627
+ let importedClientRef;
628
+ const relative = path.join('images', `${buildId}.tar.gz`);
629
+ const final = path.join(DATA_DIR, relative);
630
+ const temporary = `${final}.tmp`;
631
+ try {
632
+ if (local) {
633
+ const item = local.find(item => item.service === service);
634
+ const file = files.find(file => file.fieldname === item.field);
635
+ try {
636
+ info = await images.validate(file.path, item.imageRef);
637
+ }
638
+ catch (error) {
639
+ throw httpError(400, `${service}: invalid image archive: ${errMessage(error)}`);
640
+ }
641
+ if (info.sha256 !== item.sha256 || info.sizeBytes !== item.sizeBytes || info.imageId !== item.imageId || info.platform !== item.platform)
642
+ throw httpError(400, `${service}: local image metadata/checksum mismatch`);
643
+ importedClientRef = item.imageRef;
644
+ await images.run('docker', ['load', '-i', file.path]);
645
+ const imported = await images.inspect(item.imageRef);
646
+ if (imported.platform !== info.platform)
647
+ throw httpError(400, 'imported image identity mismatch');
648
+ await images.run('docker', ['tag', item.imageRef, imageRef]);
649
+ }
650
+ else
651
+ await images.build({ contextDir: contextDir, service, buildSpec: compose.services[service].build, imageRef, push: false });
652
+ if (REGISTRY) {
653
+ if (process.env.TUG_PUSH !== 'false')
654
+ await images.run('docker', ['push', imageRef]);
655
+ const inspected = await images.inspect(imageRef);
656
+ await db.run('UPDATE builds SET state=?,image_id=?,platform=?,updated_at=? WHERE id=?', 'complete', inspected.imageId, inspected.platform, now(), buildId);
657
+ }
658
+ else {
659
+ info = await images.save(imageRef, temporary);
660
+ await fs.rename(temporary, final);
661
+ const artifact = { buildId, imageRef, ...info };
662
+ spec.artifact = artifact;
663
+ 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);
664
+ }
665
+ }
666
+ finally {
667
+ if (importedClientRef)
668
+ await images.run('docker', ['image', 'rm', importedClientRef]).catch(() => { });
669
+ await fs.rm(temporary, { force: true });
670
+ }
393
671
  }
394
- await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
395
- await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=?', 'rolling_out', now(), depId);
396
- res.json({ deployment: { id: depId, project, version, state: 'rolling_out' }, desiredState: desired });
672
+ const buildLock = lock;
673
+ await withPublication(async () => {
674
+ const owned = await db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now());
675
+ const pending = await db.get('SELECT 1 FROM deployments WHERE id=? AND state=?', depId, 'building');
676
+ if (!owned || !pending)
677
+ throw httpError(409, 'deployment was cancelled or its build lease was lost');
678
+ await db.exec('BEGIN IMMEDIATE');
679
+ try {
680
+ const published = await db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), depId);
681
+ if (published.changes !== 1 || !await db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now()))
682
+ throw httpError(409, 'deployment was cancelled or its build lease was lost');
683
+ 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);
684
+ for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
685
+ const lp = volumeSpec?.['x-tug'] || {};
686
+ const nodeName = lp.placement?.nodes?.[0] || null;
687
+ const node = nodeName ? await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName) : null;
688
+ 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);
689
+ }
690
+ await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
691
+ await db.exec('COMMIT');
692
+ }
693
+ catch (e) {
694
+ await db.exec('ROLLBACK');
695
+ throw e;
696
+ }
697
+ });
698
+ await audit(req.actor, { type: 'deploy', project, deploymentId: depId, message: `deployment ${depId} (${version}) started by ${req.actor.name}`, metadata: { version } });
699
+ responseBody = { deployment: { id: depId, project, version, state: 'rolling_out', actor: req.actor.name }, desiredState: desired };
397
700
  }
398
701
  catch (e) {
399
- await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=?', 'build_failed', errMessage(e), now(), depId).catch(() => { });
400
- sendError(res, e);
702
+ if (depId)
703
+ await db.run("UPDATE deployments SET state='build_failed',error_message=?,updated_at=? WHERE id=? AND state='building'", errMessage(e), now(), depId).catch(() => { });
704
+ responseError = e;
401
705
  }
402
706
  finally {
403
707
  await releaseLock(lock);
404
- if (req.file)
405
- await fs.rm(req.file.path, { force: true }).catch(() => { });
708
+ await Promise.all(files.map(file => fs.rm(file.path, { force: true }).catch(() => { })));
709
+ if (contextDir)
710
+ await fs.rm(contextDir, { recursive: true, force: true });
711
+ await collectArtifacts().catch(e => console.error('image cleanup failed:', errMessage(e)));
712
+ }
713
+ if (responseError)
714
+ sendError(res, responseError);
715
+ else
716
+ res.json(responseBody);
717
+ }
718
+ get('/agent/v1/images/:buildId', authAgent, async (req, res) => {
719
+ const states = await retainedStates();
720
+ const allowed = states.some(state => Object.values(state.services).some(service => service.artifact?.buildId === req.params.buildId && (!service.placement.nodes?.length || service.placement.nodes.includes(req.node.name))));
721
+ if (!allowed)
722
+ return res.status(404).json({ error: 'image not found' });
723
+ const row = await db.get('SELECT * FROM builds WHERE id=? AND state=?', req.params.buildId, 'complete');
724
+ if (!row?.artifact_path)
725
+ return res.status(404).json({ error: 'image not found' });
726
+ try {
727
+ await fs.access(path.join(DATA_DIR, row.artifact_path));
728
+ res.set({ 'Content-Type': 'application/gzip', 'Content-Length': String(row.size_bytes), 'X-Tug-Sha256': row.sha256, 'Cache-Control': 'private, no-store' });
729
+ await pipeline(createReadStream(path.join(DATA_DIR, row.artifact_path)), res);
730
+ }
731
+ catch {
732
+ if (!res.headersSent)
733
+ res.status(404).json({ error: 'image artifact unavailable' });
406
734
  }
407
735
  });
408
- app.get('/api/v1/deployments/:id', authOperator, async (req, res) => res.json(await db.get('SELECT * FROM deployments WHERE id=?', req.params.id)));
409
- app.post('/api/v1/deployments/:id/cancel', authOperator, async (req, res) => {
736
+ api('get', '/api/v1/deployments/:id', deploymentProject, async (req, res) => res.json(await db.get('SELECT * FROM deployments WHERE id=?', req.params.id)));
737
+ api('post', '/api/v1/deployments/:id/cancel', deploymentProject, async (req, res) => withPublication(async () => {
410
738
  const row = await db.get('SELECT * FROM deployments WHERE id=?', req.params.id);
411
739
  if (!row)
412
740
  return res.status(404).json({ error: 'deployment not found' });
413
741
  if (!ACTIVE_DEPLOYMENT_STATES.includes(row.state))
414
742
  return res.status(409).json({ error: `deployment is ${row.state}` });
415
- await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=?', 'cancelled', now(), req.params.id);
743
+ const result = await db.run(`UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state IN (${ACTIVE_STATES_SQL})`, 'cancelled', now(), req.params.id);
744
+ if (result.changes !== 1)
745
+ return res.status(409).json({ error: 'deployment is no longer active' });
746
+ await audit(req.actor, { type: 'cancel', project: row.project_id, deploymentId: row.id, message: `deployment ${row.id} cancelled by ${req.actor.name}` });
416
747
  res.json({ ok: true });
417
- });
418
- app.post('/api/v1/rollback', authOperator, async (req, res) => {
748
+ }));
749
+ api('post', '/api/v1/rollback', bodyProject, async (req, res) => withWriter(async () => {
419
750
  const project = req.body.project || 'site';
420
- let lock;
421
751
  try {
422
- lock = await acquireProjectLock(project, 'rollback');
423
- 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);
424
- if (active && !req.body.force)
425
- return res.status(409).json({ error: `deployment ${active.id} is already ${active.state}; retry with force to rollback anyway` });
426
- const current = await db.get('SELECT current_deployment_id FROM projects WHERE id=?', project);
427
- 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
428
- WHERE ds.project_id=? AND ds.deployment_id != ? AND d.state IN ('succeeded','rolling_out') ORDER BY ds.created_at DESC LIMIT 1`, project, current?.current_deployment_id || '');
429
- if (!previousDs)
430
- return res.status(404).json({ error: 'no previous deployment found' });
431
- const ts = now();
432
- const depId = id('dep');
433
- const previousState = JSON.parse(previousDs.state_json);
434
- await db.run('INSERT INTO deployments(id,project_id,version,state,created_at,updated_at) VALUES(?,?,?,?,?,?)', depId, project, `rollback-${previousState.version}-${Date.now()}`, 'rolling_out', ts, ts);
435
- 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);
436
- await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
437
- res.json({ deployment: await db.get('SELECT * FROM deployments WHERE id=?', depId), desiredState: previousState });
752
+ const out = await createRollback(project, { actor: req.actor.name, force: Boolean(req.body.force) });
753
+ await audit(req.actor, { type: 'rollback', project, deploymentId: out.deployment.id, message: `rollback to ${out.desiredState.version} (deployment ${out.deployment.id}) started by ${req.actor.name}`, metadata: { version: out.desiredState.version, force: Boolean(req.body.force) } });
754
+ res.json(out);
438
755
  }
439
756
  catch (e) {
440
757
  sendError(res, e);
441
758
  }
442
- finally {
443
- await releaseLock(lock);
444
- }
445
- });
446
- app.get('/api/v1/volumes', authOperator, async (req, res) => res.json({ volumes: await db.all('SELECT * FROM volumes WHERE project_id=? ORDER BY name', req.query.project || 'site') }));
447
- app.delete('/api/v1/volumes/:name', authOperator, async (req, res) => {
759
+ }));
760
+ api('get', '/api/v1/volumes', queryProject, async (req, res) => res.json({ volumes: await db.all('SELECT * FROM volumes WHERE project_id=? ORDER BY name', req.query.project || 'site') }));
761
+ api('delete', '/api/v1/volumes/:name', queryProject, async (req, res) => {
448
762
  const project = String(req.query.project || 'site');
449
763
  const deleteData = String(req.query.deleteData || 'false') === 'true';
450
764
  let lock;
@@ -479,7 +793,7 @@ export async function createApp() {
479
793
  await releaseLock(lock);
480
794
  }
481
795
  });
482
- app.post('/api/v1/control/backups/run', authOperator, async (req, res) => {
796
+ api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
483
797
  const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
484
798
  await ensureDir(backupDir);
485
799
  const ts = now();
@@ -487,30 +801,77 @@ export async function createApp() {
487
801
  const staging = path.join(backupDir, `${backupId}-staging`);
488
802
  await fs.rm(staging, { recursive: true, force: true });
489
803
  await ensureDir(staging);
490
- await db.exec('PRAGMA wal_checkpoint(FULL);');
491
- await fs.copyFile(dbFile, path.join(staging, 'tug.db'));
804
+ const snapshotPath = path.join(staging, 'tug.db').replaceAll("'", "''");
805
+ await db.exec(`VACUUM INTO '${snapshotPath}'`);
492
806
  await fs.copyFile(masterKeyFile, path.join(staging, 'master.key'));
807
+ await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
493
808
  await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2));
494
809
  const artifactRef = path.resolve(backupDir, `${backupId}.tar.gz`);
495
- await tar.c({ gzip: true, file: artifactRef, cwd: staging }, ['tug.db', 'master.key', 'metadata.json']);
810
+ await tar.c({ gzip: true, file: artifactRef, cwd: staging }, ['tug.db', 'master.key', 'metadata.json', 'images']);
496
811
  await fs.rm(staging, { recursive: true, force: true });
497
812
  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());
498
813
  res.json({ backupId, artifactRef, state: 'completed' });
499
- });
500
- app.post('/api/v1/control/restore', authOperator, async (req, res) => {
814
+ }));
815
+ api('post', '/api/v1/control/restore', null, async (req, res) => withWriter(async () => {
501
816
  if (!req.body.confirm)
502
- return res.status(400).json({ error: 'confirm=true is required; restore overwrites control database and master key, then requires process restart' });
817
+ return res.status(400).json({ error: 'confirm=true is required; restore overwrites control data then requires restart' });
503
818
  const artifactRef = req.body.artifactRef;
504
- if (!artifactRef)
819
+ if (typeof artifactRef !== 'string' || !artifactRef)
505
820
  return res.status(400).json({ error: 'artifactRef required' });
506
821
  const restoreDir = process.env.TUG_CONTROL_RESTORE_DIR || path.join(DATA_DIR, 'control-restore');
507
- await fs.rm(restoreDir, { recursive: true, force: true });
508
- await ensureDir(restoreDir);
509
- await tar.x({ file: artifactRef, cwd: restoreDir });
510
- await fs.copyFile(path.join(restoreDir, 'tug.db'), dbFile);
511
- await fs.copyFile(path.join(restoreDir, 'master.key'), masterKeyFile);
512
- res.json({ ok: true, restored: true, restartRequired: true });
513
- });
822
+ let snapshot;
823
+ try {
824
+ await fs.rm(restoreDir, { recursive: true, force: true });
825
+ await ensureDir(restoreDir);
826
+ await scanArchive(artifactRef, entry => {
827
+ if (path.isAbsolute(entry.path) || entry.path.split('/').includes('..') || !['File', 'Directory'].includes(entry.type))
828
+ throw new Error('unsafe control backup entry');
829
+ });
830
+ await tar.x({ file: artifactRef, cwd: restoreDir, strict: true });
831
+ const key = (await fs.readFile(path.join(restoreDir, 'master.key'), 'utf8')).trim();
832
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(key))
833
+ throw new Error('invalid backup master key');
834
+ snapshot = await open({ filename: path.join(restoreDir, 'tug.db'), driver: sqlite3.Database, mode: sqlite3.OPEN_READONLY });
835
+ const integrity = await snapshot.get('PRAGMA integrity_check');
836
+ if (integrity?.integrity_check !== 'ok')
837
+ throw new Error('backup database failed integrity check');
838
+ if (!await snapshot.get("SELECT 1 FROM meta WHERE key='operator_token_hash'"))
839
+ throw new Error('backup database has no operator identity');
840
+ for (const secret of await snapshot.all('SELECT * FROM secrets'))
841
+ decrypt(secret.encrypted_value, key);
842
+ const states = await snapshot.all('SELECT * FROM desired_states');
843
+ for (const stateRow of states) {
844
+ const state = JSON.parse(stateRow.state_json);
845
+ for (const service of Object.values(state.services)) {
846
+ if (!service.artifact)
847
+ continue;
848
+ const build = await snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
849
+ if (!build?.artifact_path || !/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
850
+ throw new Error('backup contains an unsafe or missing image reference');
851
+ const actual = await hashArchive(path.join(restoreDir, build.artifact_path));
852
+ if (actual.sha256 !== build.sha256 || actual.sizeBytes !== build.size_bytes || actual.sha256 !== service.artifact.sha256 || actual.sizeBytes !== service.artifact.sizeBytes)
853
+ throw new Error('backup image archive checksum mismatch');
854
+ }
855
+ }
856
+ await snapshot.close();
857
+ snapshot = undefined;
858
+ await ensureDir(path.join(restoreDir, 'images'));
859
+ // Validation completes before changing any live file. Stop DB-dependent work until restart.
860
+ restartRequired = true;
861
+ await db.close();
862
+ await fs.rm(`${dbFile}-wal`, { force: true });
863
+ await fs.rm(`${dbFile}-shm`, { force: true });
864
+ await fs.cp(path.join(restoreDir, 'images'), artifactDir, { recursive: true });
865
+ await fs.copyFile(path.join(restoreDir, 'tug.db'), dbFile);
866
+ await fs.copyFile(path.join(restoreDir, 'master.key'), masterKeyFile);
867
+ res.json({ ok: true, restored: true, restartRequired: true });
868
+ }
869
+ catch (e) {
870
+ if (snapshot)
871
+ await snapshot.close().catch(() => { });
872
+ res.status(restartRequired ? 500 : 400).json({ error: errMessage(e), restartRequired });
873
+ }
874
+ }));
514
875
  async function commandForService({ project, service, type, payload }) {
515
876
  if (!service)
516
877
  throw httpError(400, 'service required');
@@ -532,15 +893,18 @@ export async function createApp() {
532
893
  await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', cmdId, node.id, project, type, 'pending', JSON.stringify({ project, service, ...payload }), 0, ts, null, ts, ts);
533
894
  return { commandId: cmdId, nodeId: node.id };
534
895
  }
535
- app.post('/api/v1/restart', authOperator, async (req, res) => {
896
+ api('post', '/api/v1/restart', bodyProject, async (req, res) => {
536
897
  try {
537
- res.json(await commandForService({ project: req.body.project || 'site', service: req.body.service, type: 'service.restart', payload: {} }));
898
+ const project = req.body.project || 'site';
899
+ const queued = await commandForService({ project, service: req.body.service, type: 'service.restart', payload: {} });
900
+ await audit(req.actor, { type: 'restart', project, message: `service ${req.body.service} restart requested by ${req.actor.name}`, metadata: { service: req.body.service, commandId: queued.commandId } });
901
+ res.json(queued);
538
902
  }
539
903
  catch (e) {
540
904
  res.status(e.status || 500).json({ error: errMessage(e) });
541
905
  }
542
906
  });
543
- app.post('/api/v1/logs', authOperator, async (req, res) => {
907
+ api('post', '/api/v1/logs', bodyProject, async (req, res) => {
544
908
  const project = req.body.project || 'site';
545
909
  const service = req.body.service;
546
910
  const tail = Number(req.body.tail || 200);
@@ -564,22 +928,28 @@ export async function createApp() {
564
928
  await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', cmdId, node.id, project, 'logs.fetch', 'pending', JSON.stringify({ project, service, tail }), 0, ts, null, ts, ts);
565
929
  res.json({ commandId: cmdId, nodeId: node.id });
566
930
  });
567
- app.get('/api/v1/commands/:id', authOperator, async (req, res) => res.json(await db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
568
- app.get('/api/v1/status', authOperator, async (req, res) => {
931
+ api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(await db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
932
+ api('get', '/api/v1/status', null, async (req, res) => {
569
933
  const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
570
934
  const cutoff = new Date(Date.now() - staleSeconds * 1000).toISOString();
571
935
  await db.run("UPDATE nodes SET status='stale',updated_at=? WHERE last_seen_at IS NOT NULL AND last_seen_at < ?", now(), cutoff);
572
- res.json({ projects: await db.all('SELECT * FROM projects'), deployments: await db.all('SELECT * FROM deployments ORDER BY created_at DESC LIMIT 20'), nodes: await db.all('SELECT id,name,status,last_seen_at FROM nodes'), statuses: await db.all('SELECT * FROM node_statuses') });
936
+ const projects = projectScope(req.actor, 'id');
937
+ const deployments = projectScope(req.actor, 'project_id');
938
+ const statuses = projectScope(req.actor, 'project_id');
939
+ res.json({ projects: await db.all(`SELECT * FROM projects WHERE ${projects.where}`, ...projects.args), deployments: await db.all(`SELECT * FROM deployments WHERE ${deployments.where} ORDER BY created_at DESC LIMIT 20`, ...deployments.args), nodes: await db.all('SELECT id,name,status,last_seen_at FROM nodes'), statuses: await db.all(`SELECT * FROM node_statuses WHERE ${statuses.where}`, ...statuses.args) });
573
940
  });
574
- app.get('/api/v1/events', authOperator, async (req, res) => {
575
- const project = req.query.project || null;
941
+ api('get', '/api/v1/events', null, async (req, res) => {
942
+ const project = req.query.project ? String(req.query.project) : null;
943
+ if (project)
944
+ assertProject(req.actor, project);
576
945
  const limit = Math.min(Number(req.query.limit || 100), 500);
946
+ const scope = projectScope(req.actor, 'project_id'); // scoped tokens never see node-level or other projects' events
577
947
  const events = project
578
948
  ? await db.all('SELECT * FROM events WHERE project_id=? ORDER BY created_at DESC LIMIT ?', project, limit)
579
- : await db.all('SELECT * FROM events ORDER BY created_at DESC LIMIT ?', limit);
949
+ : await db.all(`SELECT * FROM events WHERE ${scope.where} ORDER BY created_at DESC LIMIT ?`, ...scope.args, limit);
580
950
  res.json({ events });
581
951
  });
582
- app.get('/api/v1/diagnostics', authOperator, async (req, res) => {
952
+ api('get', '/api/v1/diagnostics', null, async (req, res) => {
583
953
  const checks = [];
584
954
  async function check(name, fn) {
585
955
  try {
@@ -614,7 +984,7 @@ export async function createApp() {
614
984
  }
615
985
  res.json({ ok: checks.every(c => c.ok), registry: REGISTRY, checks });
616
986
  });
617
- app.post('/api/v1/diagnostics/nodes', authOperator, async (req, res) => {
987
+ api('post', '/api/v1/diagnostics/nodes', null, async (req, res) => {
618
988
  const nodes = req.body.node ? await db.all('SELECT id FROM nodes WHERE id=? OR name=?', req.body.node, req.body.node) : await db.all('SELECT id FROM nodes');
619
989
  const ts = now();
620
990
  const commands = [];
@@ -662,7 +1032,7 @@ export async function createApp() {
662
1032
  }
663
1033
  return buildIngressWork({ states, endpoints: await endpointsFor(states), node: node.name, secrets, acmeEmail: ACME_EMAIL });
664
1034
  }
665
- app.get('/agent/v1/work', authAgent, async (req, res) => {
1035
+ get('/agent/v1/work', authAgent, async (req, res) => {
666
1036
  await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
667
1037
  const node = (await db.get('SELECT * FROM nodes WHERE id=?', req.node.id));
668
1038
  const allStates = await currentDesiredStates();
@@ -670,7 +1040,8 @@ export async function createApp() {
670
1040
  const commands = await db.all('SELECT * FROM commands WHERE node_id=? AND state=?', node.id, 'pending');
671
1041
  if (commands.length)
672
1042
  await db.run(`UPDATE commands SET state='running',attempts=attempts+1,updated_at=? WHERE node_id=? AND state='pending'`, now(), node.id);
673
- const work = { serverTime: now(), nodeId: node.id, desiredStates, commands };
1043
+ const retainedImages = [...new Set((await retainedStates()).flatMap(state => Object.values(state.services).filter(service => service.artifact && (!service.placement.nodes?.length || service.placement.nodes.includes(node.name))).map(service => service.image)))];
1044
+ const work = { serverTime: now(), nodeId: node.id, desiredStates, commands, retainedImages };
674
1045
  const network = await networkFor(node);
675
1046
  if (network)
676
1047
  work.network = network;
@@ -682,7 +1053,7 @@ export async function createApp() {
682
1053
  work.ingress = await ingressFor(node, allStates);
683
1054
  res.json(work);
684
1055
  });
685
- app.post('/agent/v1/status', authAgent, async (req, res) => {
1056
+ post('/agent/v1/status', authAgent, async (req, res) => {
686
1057
  const ts = now();
687
1058
  const report = req.body;
688
1059
  for (const st of report.projects || []) {
@@ -698,9 +1069,9 @@ export async function createApp() {
698
1069
  await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
699
1070
  res.json({ ok: true });
700
1071
  });
701
- app.post('/agent/v1/events', authAgent, async (req, res) => { const evs = req.body.events || [req.body]; for (const ev of evs)
1072
+ post('/agent/v1/events', authAgent, async (req, res) => { const evs = req.body.events || [req.body]; for (const ev of evs)
702
1073
  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()); res.json({ ok: true }); });
703
- app.get('/agent/v1/secrets', authAgent, async (req, res) => {
1074
+ get('/agent/v1/secrets', authAgent, async (req, res) => {
704
1075
  const project = String(req.query.project || 'site');
705
1076
  let rows = await db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
706
1077
  if (req.query.services) {
@@ -712,6 +1083,11 @@ export async function createApp() {
712
1083
  const secrets = { secrets: Object.fromEntries(rows.map(r => [r.name, decrypt(r.encrypted_value, masterKey)])), versions: Object.fromEntries(rows.map(r => [r.name, r.version])) };
713
1084
  res.json(secrets);
714
1085
  });
1086
+ app.use((error, _req, res, next) => {
1087
+ if (res.headersSent)
1088
+ return next(error);
1089
+ sendError(res, error);
1090
+ });
715
1091
  return app;
716
1092
  }
717
1093
  export async function listen(app, port = PORT) {