@wpelevator/tug 0.1.0 → 0.2.1

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,49 @@ 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
+ const missingProject = (project) => `project ${project} has no desired state`;
25
+ const missingService = (project, service) => `service ${service} not found in project ${project}`;
26
+ /** Collapse a node report into the deployment error `tug deploy` prints. Compose failures are multi-line; keep the reason, not the progress log. */
27
+ function deploymentFailureMessage(rows) {
28
+ const limit = 500;
29
+ const oneLine = (value) => {
30
+ const flat = value.replace(/\s+/g, ' ').trim();
31
+ return flat.length > limit ? `${flat.slice(0, limit)}...` : flat;
32
+ };
33
+ const details = rows.map(row => {
34
+ let status = {};
35
+ try {
36
+ status = JSON.parse(row.status_json);
37
+ }
38
+ catch { /* a bad report still names the node */ }
39
+ const parts = [];
40
+ if (typeof status.error === 'string' && status.error.trim())
41
+ parts.push(oneLine(status.error));
42
+ for (const [service, state] of Object.entries(status.serviceStates || {})) {
43
+ if (typeof state?.rolloutError === 'string' && state.rolloutError.trim())
44
+ parts.push(`${service}: ${oneLine(state.rolloutError)}`);
45
+ }
46
+ const detail = parts.join('; ');
47
+ return detail ? `${row.node_id}: ${detail}` : row.node_id;
48
+ });
49
+ return `one or more assigned nodes reported failed health: ${details.join('; ')}`;
50
+ }
51
+ /** Versions of rollback deployments start with this; `tug deploy` may not use it, which is how automatic rollback tells them apart. */
52
+ const ROLLBACK_VERSION_PREFIX = 'rollback-';
17
53
  const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map(s => `'${s}'`).join(',');
18
54
  const PORT = Number(process.env.PORT || 8080);
19
- export async function createApp() {
55
+ export async function createApp(options = {}) {
56
+ const images = { build: buildAndPush, save: saveImageArchive, inspect: inspectImage, validate: validateImageArchive, run: runCommand, ...options.images };
20
57
  const DATA_DIR = process.env.TUG_DATA_DIR || './data';
21
- const upload = multer({ dest: process.env.TUG_UPLOAD_DIR || path.join(DATA_DIR, 'uploads') });
58
+ const MAX_IMAGE_BYTES = Number(process.env.TUG_MAX_IMAGE_BYTES || 5 * 1024 ** 3);
59
+ if (!Number.isSafeInteger(MAX_IMAGE_BYTES) || MAX_IMAGE_BYTES < 1)
60
+ throw new Error('TUG_MAX_IMAGE_BYTES must be a positive safe integer');
61
+ 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
62
  const dbFile = path.join(DATA_DIR, 'tug.db');
23
63
  const masterKeyFile = path.join(DATA_DIR, 'master.key');
24
- const REGISTRY = process.env.TUG_REGISTRY || 'localhost:5000';
64
+ const REGISTRY = process.env.TUG_REGISTRY?.trim() || '';
25
65
  const REGISTRY_AUTH_PATH = process.env.TUG_REGISTRY_AUTH_PATH || null;
26
66
  const REGISTRY_CONTAINER = process.env.TUG_REGISTRY_CONTAINER || null;
27
67
  // Networking layers are opt-in; with all of them off the work response is unchanged.
@@ -44,7 +84,7 @@ export async function createApp() {
44
84
  await runCommand('docker', ['restart', REGISTRY_CONTAINER]).catch(e => console.error(`registry reload failed: ${e.message}`));
45
85
  }
46
86
  async function upsertRegistryCredential(name, password) {
47
- if (!REGISTRY_AUTH_PATH)
87
+ if (!REGISTRY || !REGISTRY_AUTH_PATH)
48
88
  return;
49
89
  await withHtpasswdLock(async () => {
50
90
  const { out } = await runCommand('htpasswd', ['-nbB', name, password]);
@@ -76,7 +116,35 @@ export async function createApp() {
76
116
  });
77
117
  await reloadRegistry();
78
118
  }
79
- const db = await openDb(dbFile);
119
+ const db = await (options.openDatabase || openDb)(dbFile);
120
+ // Events are an operator-facing log, not a record of truth, so keep them bounded. `0` turns a limit off; anything that is
121
+ // not a whole number falls back to the default.
122
+ const envCount = (name, fallback) => {
123
+ const raw = process.env[name];
124
+ if (raw === undefined || raw === '')
125
+ return fallback;
126
+ const value = Number(raw);
127
+ if (Number.isInteger(value) && value >= 0)
128
+ return value;
129
+ console.warn(`${name}=${JSON.stringify(raw)} is not a whole number; using ${fallback}`);
130
+ return fallback;
131
+ };
132
+ const EVENT_RETENTION_DAYS = envCount('TUG_EVENT_RETENTION_DAYS', 30);
133
+ const EVENT_MAX_ROWS = envCount('TUG_EVENT_MAX_ROWS', 100_000);
134
+ const eventPruneIntervalMs = options.eventPruneIntervalMs ?? 60 * 60 * 1000;
135
+ let lastEventPrune = Date.now();
136
+ async function pruneEvents() {
137
+ lastEventPrune = Date.now();
138
+ let removed = 0;
139
+ if (EVENT_RETENTION_DAYS > 0)
140
+ removed += (await db.run('DELETE FROM events WHERE created_at < ?', new Date(Date.now() - EVENT_RETENTION_DAYS * 86_400_000).toISOString())).changes ?? 0;
141
+ if (EVENT_MAX_ROWS > 0)
142
+ removed += (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;
143
+ if (removed)
144
+ console.log(`removed ${removed} old events (kept ${EVENT_RETENTION_DAYS || 'any'} days, at most ${EVENT_MAX_ROWS || 'unlimited'} rows)`);
145
+ return removed;
146
+ }
147
+ await pruneEvents().catch(e => console.error(`event pruning failed: ${errMessage(e)}`));
80
148
  const masterKey = await loadOrCreateMasterKey(masterKeyFile);
81
149
  const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
82
150
  if (!operator) {
@@ -86,14 +154,66 @@ export async function createApp() {
86
154
  console.log(`operator token: ${t}`);
87
155
  }
88
156
  const app = express();
157
+ let restartRequired = false;
158
+ app.use((_req, res, next) => restartRequired ? res.status(503).json({ error: 'control restored; restart required', restartRequired: true }) : next());
89
159
  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();
160
+ // Hold the writer for the operation lifetime, including after an HTTP disconnect.
161
+ let writer = Promise.resolve();
162
+ async function withWriter(operation) {
163
+ const previous = writer;
164
+ let release;
165
+ writer = new Promise(resolve => { release = resolve; });
166
+ await previous;
167
+ try {
168
+ if (restartRequired)
169
+ throw httpError(503, 'control restored; restart required');
170
+ return await operation();
171
+ }
172
+ finally {
173
+ release();
174
+ }
175
+ }
176
+ let publication = Promise.resolve();
177
+ async function withPublication(operation) {
178
+ const previous = publication;
179
+ let release;
180
+ publication = new Promise(resolve => { release = resolve; });
181
+ await previous;
182
+ try {
183
+ return await operation();
184
+ }
185
+ finally {
186
+ release();
187
+ }
188
+ }
189
+ const safe = (handler) => (req, res, next) => {
190
+ try {
191
+ void Promise.resolve(handler(req, res, next)).catch(next);
192
+ }
193
+ catch (error) {
194
+ next(error);
195
+ }
196
+ };
197
+ const route = (method, url, handlers) => app[method](url, ...handlers.map(safe));
198
+ const get = (url, ...handlers) => route('get', url, handlers);
199
+ const post = (url, ...handlers) => route('post', url, handlers);
200
+ // Operator-facing routes go through api(): the role table in tokens.ts is the only place a route's access is declared.
201
+ const guard = createGuard(db);
202
+ const apiRoutes = new Map();
203
+ app.locals.apiRoutes = apiRoutes;
204
+ const api = (method, url, resolve, ...handlers) => {
205
+ const key = `${method.toUpperCase()} ${url}`;
206
+ const role = ROUTE_ROLES[key];
207
+ if (!role || role === 'public')
208
+ throw new Error(`API route ${key} has no declared role in ROUTE_ROLES`);
209
+ apiRoutes.set(key, role);
210
+ route(method, url, [guard(role, resolve ?? undefined), ...handlers]);
96
211
  };
212
+ const deploymentProject = async (req) => (await db.get('SELECT project_id FROM deployments WHERE id=?', req.params.id))?.project_id;
213
+ 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; };
214
+ /** Audit trail entry for a control action; bookkeeping failures are logged, never returned to the caller. */
215
+ const audit = (actor, event) => recordEvent(db, { ...event, actor: actor.name }).catch(e => console.error(`audit event failed: ${errMessage(e)}`));
216
+ const projectScope = (actor, column) => actor.projects ? { where: `${column} IN (${actor.projects.map(() => '?').join(',') || "''"})`, args: actor.projects } : { where: '1=1', args: [] };
97
217
  const authAgent = async (req, res, next) => {
98
218
  const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
99
219
  const node = raw && await db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw));
@@ -116,15 +236,80 @@ export async function createApp() {
116
236
  required.add(node);
117
237
  if (!required.size)
118
238
  return;
119
- const statuses = await db.all('SELECT node_id,observed_version,health FROM node_statuses WHERE project_id=?', projectId);
239
+ const statuses = await db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
120
240
  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 ?? ''));
241
+ const reportsRevision = (node) => state.revision ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision : byNode.get(node)?.observed_version === state.version;
242
+ const failedNodes = [...required].filter(node => reportsRevision(node) && byNode.get(node)?.health === 'failed').map(node => byNode.get(node));
243
+ if (failedNodes.length) {
244
+ 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');
245
+ if (marked.changes === 1)
246
+ scheduleAutoRollback(projectId, project.current_deployment_id, state);
247
+ }
248
+ 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
249
  if (ok)
126
250
  await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
127
251
  }
252
+ /** Events control writes when it rolls back on its own; they are attributed to a reserved system actor. */
253
+ async function recordAutoRollbackEvent(projectId, deploymentId, type, message, metadata = {}) {
254
+ 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);
255
+ }
256
+ /**
257
+ * Creates a rollback deployment that copies the most recent earlier deployment's desired state under a
258
+ * new revision. Shared by `POST /api/v1/rollback` and automatic rollback; the caller holds the writer queue.
259
+ * `auto` only considers `succeeded` deployments and requires `failedDeploymentId` to still be current.
260
+ */
261
+ async function createRollback(project, opts) {
262
+ let lock;
263
+ try {
264
+ lock = await acquireProjectLock(project, opts.auto ? 'auto-rollback' : 'rollback');
265
+ 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);
266
+ if (active && !opts.force)
267
+ throw httpError(409, `deployment ${active.id} is already ${active.state}; retry with force to rollback anyway`);
268
+ const current = await db.get('SELECT current_deployment_id FROM projects WHERE id=?', project);
269
+ if (opts.failedDeploymentId && current?.current_deployment_id !== opts.failedDeploymentId)
270
+ throw httpError(409, `deployment ${opts.failedDeploymentId} is no longer the current deployment`);
271
+ const states = opts.auto ? `'succeeded'` : `'succeeded','rolling_out'`;
272
+ 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
273
+ 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 || '');
274
+ if (!previousDs)
275
+ throw httpError(404, 'no previous deployment found');
276
+ const ts = now();
277
+ const depId = id('dep');
278
+ const previousState = JSON.parse(previousDs.state_json);
279
+ previousState.revision = depId;
280
+ 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);
281
+ 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);
282
+ await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
283
+ return { deployment: await db.get('SELECT * FROM deployments WHERE id=?', depId), desiredState: previousState };
284
+ }
285
+ finally {
286
+ await releaseLock(lock);
287
+ }
288
+ }
289
+ /**
290
+ * With `x-tug.rollback: auto`, a deployment that just turned `failed` is rolled back to the previous
291
+ * `succeeded` one. Rollback deployments are never rolled back (their versions carry a reserved prefix
292
+ * that `tug deploy` cannot use), so a bad rollback target cannot loop. Runs in the background so a
293
+ * busy writer queue never blocks agent status reports; the outcome is recorded as an event either way.
294
+ */
295
+ function scheduleAutoRollback(projectId, deploymentId, state) {
296
+ if (state.compose['x-tug']?.rollback !== 'auto')
297
+ return;
298
+ void (async () => {
299
+ const failed = await db.get('SELECT * FROM deployments WHERE id=?', deploymentId);
300
+ if (failed?.version.startsWith(ROLLBACK_VERSION_PREFIX)) {
301
+ await recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `rollback deployment ${failed.version} failed; not rolling back a rollback`);
302
+ return;
303
+ }
304
+ try {
305
+ const out = await withWriter(() => createRollback(projectId, { actor: AUTO_ROLLBACK_ACTOR, auto: true, failedDeploymentId: deploymentId }));
306
+ 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 });
307
+ }
308
+ catch (e) {
309
+ await recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `deployment ${deploymentId} failed but automatic rollback was not possible: ${errMessage(e)}`).catch(() => { });
310
+ }
311
+ })().catch(e => console.error('auto rollback failed:', errMessage(e)));
312
+ }
128
313
  async function currentDesired(projectId) {
129
314
  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
315
  }
@@ -145,21 +330,25 @@ export async function createApp() {
145
330
  err.lock = existing;
146
331
  throw err;
147
332
  }
148
- return { name, owner };
333
+ 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)));
334
+ heartbeat.unref();
335
+ return { name, owner, heartbeat };
149
336
  }
150
337
  async function releaseLock(lock) {
338
+ if (lock?.heartbeat)
339
+ clearInterval(lock.heartbeat);
151
340
  if (lock)
152
341
  await db.run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner).catch(() => { });
153
342
  }
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) => {
343
+ function sendError(res, e) { const err = e; res.status(restartRequired ? 503 : err.status || 500).json({ error: err.message, lock: err.lock || undefined }); }
344
+ get('/healthz', (req, res) => res.json({ ok: true }));
345
+ api('post', '/api/v1/projects', req => String(req.body?.name || 'site'), async (req, res) => {
157
346
  const name = req.body.name || 'site';
158
347
  const ts = now();
159
348
  await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
160
349
  res.json(await db.get('SELECT * FROM projects WHERE id=?', name));
161
350
  });
162
- app.get('/api/v1/projects/:project', authOperator, async (req, res) => res.json(await db.get('SELECT * FROM projects WHERE id=?', req.params.project)));
351
+ 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
352
  const NODE_INFO_SQL = 'SELECT id,name,address,public_address,overlay_ip,status,created_at,updated_at FROM nodes WHERE id=?';
164
353
  /** Allocates the node's stable overlay IP on first registration (retrying if a concurrent register took it). */
165
354
  async function ensureOverlayIp(nodeId) {
@@ -207,7 +396,7 @@ export async function createApp() {
207
396
  const node = (await db.get(NODE_INFO_SQL, name));
208
397
  return { node, agentToken, registryToken };
209
398
  }
210
- app.post('/api/v1/nodes/register', authOperator, async (req, res) => {
399
+ api('post', '/api/v1/nodes/register', null, async (req, res) => {
211
400
  const name = req.body.name;
212
401
  if (!name)
213
402
  return res.status(400).json({ error: 'name required' });
@@ -220,7 +409,7 @@ export async function createApp() {
220
409
  });
221
410
  // Self-service enrollment: operator mints a short-lived, limited-use key; the agent
222
411
  // exchanges it for durable credentials on first boot (Portainer Edge-style join).
223
- app.post('/api/v1/nodes/enroll-token', authOperator, async (req, res) => {
412
+ api('post', '/api/v1/nodes/enroll-token', null, async (req, res) => {
224
413
  const name = req.body.name || null;
225
414
  const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? 3600));
226
415
  const uses = Math.max(1, Number(req.body.uses ?? 1));
@@ -229,7 +418,7 @@ export async function createApp() {
229
418
  await db.run('INSERT INTO enroll_tokens(token_hash,name,uses_remaining,expires_at,created_at) VALUES(?,?,?,?,?)', hashToken(enrollToken), name, uses, expiresAt, now());
230
419
  res.json({ enrollToken, name, usesRemaining: uses, expiresAt, registry: REGISTRY });
231
420
  });
232
- app.post('/agent/v1/enroll', async (req, res) => {
421
+ post('/agent/v1/enroll', async (req, res) => {
233
422
  const body = req.body;
234
423
  const raw = String(body.enrollToken || (req.headers.authorization || '').replace(/^Bearer /, '')).trim();
235
424
  if (!raw)
@@ -258,8 +447,8 @@ export async function createApp() {
258
447
  const enrolled = { ...result, registry: REGISTRY };
259
448
  res.json(enrolled);
260
449
  });
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) => {
450
+ 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') }));
451
+ api('post', '/api/v1/nodes/:name/rotate-registry-token', null, async (req, res) => {
263
452
  const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
264
453
  if (!node)
265
454
  return res.status(404).json({ error: 'node not found' });
@@ -268,7 +457,7 @@ export async function createApp() {
268
457
  await upsertRegistryCredential(node.name, registryToken);
269
458
  res.json({ ok: true, node: node.name, registryToken });
270
459
  });
271
- app.post('/api/v1/nodes/:name/revoke-registry-token', authOperator, async (req, res) => {
460
+ api('post', '/api/v1/nodes/:name/revoke-registry-token', null, async (req, res) => {
272
461
  const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
273
462
  if (!node)
274
463
  return res.status(404).json({ error: 'node not found' });
@@ -284,7 +473,7 @@ export async function createApp() {
284
473
  }
285
474
  // The private key lives only on the node, so rotation is a command the agent executes: it
286
475
  // 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) => {
476
+ api('post', '/api/v1/nodes/:name/rotate-wg-key', null, async (req, res) => {
288
477
  if (!MESH)
289
478
  return res.status(409).json({ error: 'the WireGuard mesh is not enabled (TUG_MESH=true)' });
290
479
  const node = await findNode(req.params.name);
@@ -292,7 +481,7 @@ export async function createApp() {
292
481
  return res.status(404).json({ error: 'node not found' });
293
482
  res.json({ commandId: await queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
294
483
  });
295
- app.post('/api/v1/nodes/:name/ingress', authOperator, async (req, res) => {
484
+ api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
296
485
  const node = await findNode(req.params.name);
297
486
  if (!node)
298
487
  return res.status(404).json({ error: 'node not found' });
@@ -300,7 +489,7 @@ export async function createApp() {
300
489
  await db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
301
490
  res.json({ ok: true, node: node.name, ingress: enabled });
302
491
  });
303
- app.post('/agent/v1/wireguard/key', authAgent, async (req, res) => {
492
+ post('/agent/v1/wireguard/key', authAgent, async (req, res) => {
304
493
  if (!MESH)
305
494
  return res.status(409).json({ error: 'the WireGuard mesh is not enabled' });
306
495
  const { publicKey } = req.body;
@@ -314,7 +503,7 @@ export async function createApp() {
314
503
  sendError(res, /UNIQUE constraint/.test(errMessage(e)) ? httpError(409, 'public key already registered to another node') : e);
315
504
  }
316
505
  });
317
- app.post('/api/v1/registry/auth', async (req, res) => {
506
+ post('/api/v1/registry/auth', async (req, res) => {
318
507
  const username = req.body.username;
319
508
  const password = req.body.password;
320
509
  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 +511,20 @@ export async function createApp() {
322
511
  return res.status(401).json({ ok: false, error: 'invalid registry credentials' });
323
512
  res.json({ ok: true, node: node.name });
324
513
  });
325
- app.post('/api/v1/secrets', authOperator, async (req, res) => {
514
+ api('post', '/api/v1/tokens', null, async (req, res) => {
515
+ const request = parseTokenRequest(req.body);
516
+ const created = await createToken(db, request, req.actor.name);
517
+ 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 } });
518
+ res.json(created);
519
+ });
520
+ api('get', '/api/v1/tokens', null, async (_req, res) => res.json({ tokens: await listTokens(db) }));
521
+ api('delete', '/api/v1/tokens/:name', null, async (req, res) => {
522
+ if (!await revokeToken(db, req.params.name))
523
+ return res.status(404).json({ error: 'token not found' });
524
+ await audit(req.actor, { type: 'token.revoke', message: `token ${req.params.name} revoked by ${req.actor.name}`, metadata: { name: req.params.name } });
525
+ res.json({ ok: true, name: req.params.name });
526
+ });
527
+ api('post', '/api/v1/secrets', bodyProject, async (req, res) => {
326
528
  const { project = 'site', name, value } = req.body;
327
529
  if (!name || value == null)
328
530
  return res.status(400).json({ error: 'name and value required' });
@@ -333,6 +535,7 @@ export async function createApp() {
333
535
  const ts = now();
334
536
  await db.run(`INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)
335
537
  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);
538
+ 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
539
  res.json({ ok: true, name, version: (old?.version || 0) + 1 });
337
540
  }
338
541
  catch (e) {
@@ -342,109 +545,275 @@ export async function createApp() {
342
545
  await releaseLock(lock);
343
546
  }
344
547
  });
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')
548
+ 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') }));
549
+ api('delete', '/api/v1/secrets/:name', queryProject, async (req, res) => {
550
+ const project = String(req.query.project || 'site');
551
+ const removed = await db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, req.params.name);
552
+ if (removed.changes)
553
+ await audit(req.actor, { type: 'secret.delete', project, message: `secret ${req.params.name} deleted by ${req.actor.name}`, metadata: { name: req.params.name } });
554
+ res.json({ ok: true });
555
+ });
556
+ const artifactDir = path.join(DATA_DIR, 'images');
557
+ await ensureDir(artifactDir);
558
+ async function retainedStates() {
559
+ // All published history is retained. This conservative superset protects failed/lagging versions too.
560
+ return (await db.all('SELECT * FROM desired_states')).map(row => JSON.parse(row.state_json));
561
+ }
562
+ async function collectArtifacts() {
563
+ const states = await retainedStates();
564
+ const refs = new Set(states.flatMap(state => Object.values(state.services).flatMap(service => service.artifact ? [service.artifact.buildId] : [])));
565
+ const imageRefs = new Set(states.flatMap(state => Object.values(state.services).map(service => service.image)));
566
+ const rows = await db.all('SELECT * FROM builds');
567
+ for (const row of rows) {
568
+ if (refs.has(row.id) || imageRefs.has(row.image_ref))
362
569
  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
- }
570
+ const active = await db.get("SELECT 1 FROM deployments WHERE id=? AND state IN ('uploading','building')", row.deployment_id);
571
+ if (active)
572
+ continue;
573
+ await fs.rm(path.join(DATA_DIR, row.artifact_path || path.join('images', `${row.id}.tar.gz`)), { force: true });
574
+ await images.run('docker', ['image', 'rm', row.image_ref]).catch(() => { });
575
+ await db.run('DELETE FROM builds WHERE id=?', row.id);
367
576
  }
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();
577
+ const remaining = await db.all('SELECT id FROM builds');
578
+ const owned = new Set(remaining.flatMap(row => [`${row.id}.tar.gz`, `${row.id}.tar.gz.tmp`]));
579
+ for (const name of await fs.readdir(artifactDir))
580
+ if (!owned.has(name))
581
+ await fs.rm(path.join(artifactDir, name), { force: true });
582
+ }
583
+ // Previous-process builds cannot finish after a restart; clear their unpublished files.
584
+ await db.run("UPDATE deployments SET state='build_failed',error_message='control restarted during build' WHERE state IN ('uploading','building')");
585
+ await collectArtifacts();
586
+ const receiveDeployment = upload.any();
587
+ api('post', '/api/v1/deployments', null, (req, res) => {
588
+ receiveDeployment(req, res, error => {
589
+ void withWriter(() => deployRequest(req, res, error)).catch(async (failure) => {
590
+ const files = Array.isArray(req.files) ? req.files : [];
591
+ await Promise.all(files.map(file => fs.rm(file.path, { force: true }).catch(() => { })));
592
+ sendError(res, failure);
593
+ });
594
+ });
595
+ });
596
+ async function deployRequest(req, res, uploadError) {
597
+ const files = (Array.isArray(req.files) ? req.files : []);
598
+ let responseBody;
599
+ let responseError;
373
600
  let lock;
601
+ let depId;
602
+ let contextDir;
374
603
  try {
604
+ if (uploadError)
605
+ throw httpError(400, errMessage(uploadError));
606
+ let body;
607
+ try {
608
+ body = req.is('multipart/form-data') ? JSON.parse(req.body.payload) : req.body;
609
+ }
610
+ catch {
611
+ throw httpError(400, 'invalid deployment payload JSON');
612
+ }
613
+ if (!body || typeof body !== 'object')
614
+ throw httpError(400, 'deployment payload is required');
615
+ const project = body.project || 'site';
616
+ const compose = body.compose;
617
+ const version = body.version || `git-${Date.now()}`;
618
+ assertProject(req.actor, project);
619
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(project) || typeof version !== 'string' || !version.length)
620
+ throw httpError(400, 'invalid project or version');
621
+ if (version.startsWith(ROLLBACK_VERSION_PREFIX))
622
+ throw httpError(400, `version cannot start with "${ROLLBACK_VERSION_PREFIX}" (reserved for rollback deployments)`);
623
+ const nodes = await db.all('SELECT name FROM nodes ORDER BY created_at,name');
624
+ try {
625
+ validateCompose(compose, nodes.map(n => n.name));
626
+ }
627
+ catch (e) {
628
+ throw httpError(400, errMessage(e));
629
+ }
630
+ const builtServices = Object.entries(compose.services).filter(([, service]) => service.build).map(([name]) => name);
631
+ const local = body.localImages;
632
+ const context = files.find(file => file.fieldname === 'context');
633
+ if (local !== undefined && (!Array.isArray(local) || context))
634
+ throw httpError(400, 'localImages must be an array and cannot accompany a source context');
635
+ if (files.filter(file => file.fieldname === 'context').length > 1)
636
+ throw httpError(400, 'duplicate source context');
637
+ if (local) {
638
+ if (local.some(item => !item || typeof item !== 'object'))
639
+ throw httpError(400, 'invalid local image manifest');
640
+ 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)
641
+ throw httpError(400, 'localImages must contain exactly one image for each build service');
642
+ for (const item of local) {
643
+ 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)
644
+ throw httpError(400, 'invalid local image manifest or file');
645
+ }
646
+ }
647
+ if (files.length !== (local ? local.length : context ? 1 : 0))
648
+ throw httpError(400, 'unexpected deployment upload');
649
+ if (!REGISTRY && builtServices.length && !context && !local)
650
+ throw httpError(400, 'build services require a source context or localImages upload without a registry');
651
+ for (const [svcName, svc] of Object.entries(compose.services)) {
652
+ const ing = svc['x-tug']?.ingress;
653
+ if (ing?.tls !== 'custom')
654
+ continue;
655
+ for (const secret of [ing.cert, ing.key])
656
+ if (!await db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret))
657
+ throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with secret-set first`);
658
+ }
375
659
  lock = await acquireProjectLock(project, 'deploy');
660
+ const active = await db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
661
+ if (active)
662
+ throw httpError(409, `deployment ${active.id} is already ${active.state}`);
663
+ depId = id('dep');
664
+ const ts = now();
376
665
  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' });
666
+ 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);
667
+ const desired = buildDesiredState({ project, version, revision: depId, compose, registry: REGISTRY, defaultNode: nodes[0]?.name || null });
668
+ if (context) {
669
+ contextDir = path.resolve(DATA_DIR, 'build-contexts', depId);
670
+ await unpackContext(context.path, contextDir);
385
671
  }
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);
672
+ for (const service of builtServices) {
673
+ if (!contextDir && !local) {
674
+ desired.services[service].image = `${REGISTRY}/${project}/${service}:${version}`;
675
+ continue;
676
+ } // Explicit registry/no-build-upload means an existing pushed image.
677
+ const spec = desired.services[service];
678
+ const buildId = id('build');
679
+ const imageRef = spec.image;
680
+ 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);
681
+ let info;
682
+ let importedClientRef;
683
+ const relative = path.join('images', `${buildId}.tar.gz`);
684
+ const final = path.join(DATA_DIR, relative);
685
+ const temporary = `${final}.tmp`;
686
+ try {
687
+ if (local) {
688
+ const item = local.find(item => item.service === service);
689
+ const file = files.find(file => file.fieldname === item.field);
690
+ try {
691
+ info = await images.validate(file.path, item.imageRef);
692
+ }
693
+ catch (error) {
694
+ throw httpError(400, `${service}: invalid image archive: ${errMessage(error)}`);
695
+ }
696
+ if (info.sha256 !== item.sha256 || info.sizeBytes !== item.sizeBytes || info.imageId !== item.imageId || info.platform !== item.platform)
697
+ throw httpError(400, `${service}: local image metadata/checksum mismatch`);
698
+ importedClientRef = item.imageRef;
699
+ await images.run('docker', ['load', '-i', file.path]);
700
+ const imported = await images.inspect(item.imageRef);
701
+ if (imported.platform !== info.platform)
702
+ throw httpError(400, 'imported image identity mismatch');
703
+ await images.run('docker', ['tag', item.imageRef, imageRef]);
704
+ }
705
+ else
706
+ await images.build({ contextDir: contextDir, service, buildSpec: compose.services[service].build, imageRef, push: false });
707
+ if (REGISTRY) {
708
+ if (process.env.TUG_PUSH !== 'false')
709
+ await images.run('docker', ['push', imageRef]);
710
+ const inspected = await images.inspect(imageRef);
711
+ await db.run('UPDATE builds SET state=?,image_id=?,platform=?,updated_at=? WHERE id=?', 'complete', inspected.imageId, inspected.platform, now(), buildId);
712
+ }
713
+ else {
714
+ info = await images.save(imageRef, temporary);
715
+ await fs.rename(temporary, final);
716
+ const artifact = { buildId, imageRef, ...info };
717
+ spec.artifact = artifact;
718
+ 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);
719
+ }
720
+ }
721
+ finally {
722
+ if (importedClientRef)
723
+ await images.run('docker', ['image', 'rm', importedClientRef]).catch(() => { });
724
+ await fs.rm(temporary, { force: true });
725
+ }
393
726
  }
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 });
727
+ const buildLock = lock;
728
+ await withPublication(async () => {
729
+ const owned = await db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now());
730
+ const pending = await db.get('SELECT 1 FROM deployments WHERE id=? AND state=?', depId, 'building');
731
+ if (!owned || !pending)
732
+ throw httpError(409, 'deployment was cancelled or its build lease was lost');
733
+ await db.exec('BEGIN IMMEDIATE');
734
+ try {
735
+ const published = await db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), depId);
736
+ if (published.changes !== 1 || !await db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now()))
737
+ throw httpError(409, 'deployment was cancelled or its build lease was lost');
738
+ 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);
739
+ for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
740
+ const lp = volumeSpec?.['x-tug'] || {};
741
+ const nodeName = lp.placement?.nodes?.[0] || null;
742
+ const node = nodeName ? await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName) : null;
743
+ 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);
744
+ }
745
+ await db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
746
+ await db.exec('COMMIT');
747
+ }
748
+ catch (e) {
749
+ await db.exec('ROLLBACK');
750
+ throw e;
751
+ }
752
+ });
753
+ await audit(req.actor, { type: 'deploy', project, deploymentId: depId, message: `deployment ${depId} (${version}) started by ${req.actor.name}`, metadata: { version } });
754
+ responseBody = { deployment: { id: depId, project, version, state: 'rolling_out', actor: req.actor.name }, desiredState: desired };
397
755
  }
398
756
  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);
757
+ if (depId)
758
+ await db.run("UPDATE deployments SET state='build_failed',error_message=?,updated_at=? WHERE id=? AND state='building'", errMessage(e), now(), depId).catch(() => { });
759
+ responseError = e;
401
760
  }
402
761
  finally {
403
762
  await releaseLock(lock);
404
- if (req.file)
405
- await fs.rm(req.file.path, { force: true }).catch(() => { });
763
+ await Promise.all(files.map(file => fs.rm(file.path, { force: true }).catch(() => { })));
764
+ if (contextDir)
765
+ await fs.rm(contextDir, { recursive: true, force: true });
766
+ await collectArtifacts().catch(e => console.error('image cleanup failed:', errMessage(e)));
767
+ }
768
+ if (responseError)
769
+ sendError(res, responseError);
770
+ else
771
+ res.json(responseBody);
772
+ }
773
+ get('/agent/v1/images/:buildId', authAgent, async (req, res) => {
774
+ const states = await retainedStates();
775
+ 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))));
776
+ if (!allowed)
777
+ return res.status(404).json({ error: 'image not found' });
778
+ const row = await db.get('SELECT * FROM builds WHERE id=? AND state=?', req.params.buildId, 'complete');
779
+ if (!row?.artifact_path)
780
+ return res.status(404).json({ error: 'image not found' });
781
+ try {
782
+ await fs.access(path.join(DATA_DIR, row.artifact_path));
783
+ res.set({ 'Content-Type': 'application/gzip', 'Content-Length': String(row.size_bytes), 'X-Tug-Sha256': row.sha256, 'Cache-Control': 'private, no-store' });
784
+ await pipeline(createReadStream(path.join(DATA_DIR, row.artifact_path)), res);
785
+ }
786
+ catch {
787
+ if (!res.headersSent)
788
+ res.status(404).json({ error: 'image artifact unavailable' });
406
789
  }
407
790
  });
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) => {
791
+ api('get', '/api/v1/deployments/:id', deploymentProject, async (req, res) => res.json(await db.get('SELECT * FROM deployments WHERE id=?', req.params.id)));
792
+ api('post', '/api/v1/deployments/:id/cancel', deploymentProject, async (req, res) => withPublication(async () => {
410
793
  const row = await db.get('SELECT * FROM deployments WHERE id=?', req.params.id);
411
794
  if (!row)
412
795
  return res.status(404).json({ error: 'deployment not found' });
413
796
  if (!ACTIVE_DEPLOYMENT_STATES.includes(row.state))
414
797
  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);
798
+ const result = await db.run(`UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state IN (${ACTIVE_STATES_SQL})`, 'cancelled', now(), req.params.id);
799
+ if (result.changes !== 1)
800
+ return res.status(409).json({ error: 'deployment is no longer active' });
801
+ await audit(req.actor, { type: 'cancel', project: row.project_id, deploymentId: row.id, message: `deployment ${row.id} cancelled by ${req.actor.name}` });
416
802
  res.json({ ok: true });
417
- });
418
- app.post('/api/v1/rollback', authOperator, async (req, res) => {
803
+ }));
804
+ api('post', '/api/v1/rollback', bodyProject, async (req, res) => withWriter(async () => {
419
805
  const project = req.body.project || 'site';
420
- let lock;
421
806
  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 });
807
+ const out = await createRollback(project, { actor: req.actor.name, force: Boolean(req.body.force) });
808
+ 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) } });
809
+ res.json(out);
438
810
  }
439
811
  catch (e) {
440
812
  sendError(res, e);
441
813
  }
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) => {
814
+ }));
815
+ 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') }));
816
+ api('delete', '/api/v1/volumes/:name', queryProject, async (req, res) => {
448
817
  const project = String(req.query.project || 'site');
449
818
  const deleteData = String(req.query.deleteData || 'false') === 'true';
450
819
  let lock;
@@ -479,7 +848,7 @@ export async function createApp() {
479
848
  await releaseLock(lock);
480
849
  }
481
850
  });
482
- app.post('/api/v1/control/backups/run', authOperator, async (req, res) => {
851
+ api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
483
852
  const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
484
853
  await ensureDir(backupDir);
485
854
  const ts = now();
@@ -487,40 +856,87 @@ export async function createApp() {
487
856
  const staging = path.join(backupDir, `${backupId}-staging`);
488
857
  await fs.rm(staging, { recursive: true, force: true });
489
858
  await ensureDir(staging);
490
- await db.exec('PRAGMA wal_checkpoint(FULL);');
491
- await fs.copyFile(dbFile, path.join(staging, 'tug.db'));
859
+ const snapshotPath = path.join(staging, 'tug.db').replaceAll("'", "''");
860
+ await db.exec(`VACUUM INTO '${snapshotPath}'`);
492
861
  await fs.copyFile(masterKeyFile, path.join(staging, 'master.key'));
862
+ await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
493
863
  await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2));
494
864
  const artifactRef = path.resolve(backupDir, `${backupId}.tar.gz`);
495
- await tar.c({ gzip: true, file: artifactRef, cwd: staging }, ['tug.db', 'master.key', 'metadata.json']);
865
+ await tar.c({ gzip: true, file: artifactRef, cwd: staging }, ['tug.db', 'master.key', 'metadata.json', 'images']);
496
866
  await fs.rm(staging, { recursive: true, force: true });
497
867
  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
868
  res.json({ backupId, artifactRef, state: 'completed' });
499
- });
500
- app.post('/api/v1/control/restore', authOperator, async (req, res) => {
869
+ }));
870
+ api('post', '/api/v1/control/restore', null, async (req, res) => withWriter(async () => {
501
871
  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' });
872
+ return res.status(400).json({ error: 'confirm=true is required; restore overwrites control data then requires restart' });
503
873
  const artifactRef = req.body.artifactRef;
504
- if (!artifactRef)
874
+ if (typeof artifactRef !== 'string' || !artifactRef)
505
875
  return res.status(400).json({ error: 'artifactRef required' });
506
876
  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
- });
877
+ let snapshot;
878
+ try {
879
+ await fs.rm(restoreDir, { recursive: true, force: true });
880
+ await ensureDir(restoreDir);
881
+ await scanArchive(artifactRef, entry => {
882
+ if (path.isAbsolute(entry.path) || entry.path.split('/').includes('..') || !['File', 'Directory'].includes(entry.type))
883
+ throw new Error('unsafe control backup entry');
884
+ });
885
+ await tar.x({ file: artifactRef, cwd: restoreDir, strict: true });
886
+ const key = (await fs.readFile(path.join(restoreDir, 'master.key'), 'utf8')).trim();
887
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(key))
888
+ throw new Error('invalid backup master key');
889
+ snapshot = await open({ filename: path.join(restoreDir, 'tug.db'), driver: sqlite3.Database, mode: sqlite3.OPEN_READONLY });
890
+ const integrity = await snapshot.get('PRAGMA integrity_check');
891
+ if (integrity?.integrity_check !== 'ok')
892
+ throw new Error('backup database failed integrity check');
893
+ if (!await snapshot.get("SELECT 1 FROM meta WHERE key='operator_token_hash'"))
894
+ throw new Error('backup database has no operator identity');
895
+ for (const secret of await snapshot.all('SELECT * FROM secrets'))
896
+ decrypt(secret.encrypted_value, key);
897
+ const states = await snapshot.all('SELECT * FROM desired_states');
898
+ for (const stateRow of states) {
899
+ const state = JSON.parse(stateRow.state_json);
900
+ for (const service of Object.values(state.services)) {
901
+ if (!service.artifact)
902
+ continue;
903
+ const build = await snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
904
+ if (!build?.artifact_path || !/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
905
+ throw new Error('backup contains an unsafe or missing image reference');
906
+ const actual = await hashArchive(path.join(restoreDir, build.artifact_path));
907
+ if (actual.sha256 !== build.sha256 || actual.sizeBytes !== build.size_bytes || actual.sha256 !== service.artifact.sha256 || actual.sizeBytes !== service.artifact.sizeBytes)
908
+ throw new Error('backup image archive checksum mismatch');
909
+ }
910
+ }
911
+ await snapshot.close();
912
+ snapshot = undefined;
913
+ await ensureDir(path.join(restoreDir, 'images'));
914
+ // Validation completes before changing any live file. Stop DB-dependent work until restart.
915
+ restartRequired = true;
916
+ await db.close();
917
+ await fs.rm(`${dbFile}-wal`, { force: true });
918
+ await fs.rm(`${dbFile}-shm`, { force: true });
919
+ await fs.cp(path.join(restoreDir, 'images'), artifactDir, { recursive: true });
920
+ await fs.copyFile(path.join(restoreDir, 'tug.db'), dbFile);
921
+ await fs.copyFile(path.join(restoreDir, 'master.key'), masterKeyFile);
922
+ res.json({ ok: true, restored: true, restartRequired: true });
923
+ }
924
+ catch (e) {
925
+ if (snapshot)
926
+ await snapshot.close().catch(() => { });
927
+ res.status(restartRequired ? 500 : 400).json({ error: errMessage(e), restartRequired });
928
+ }
929
+ }));
514
930
  async function commandForService({ project, service, type, payload }) {
515
931
  if (!service)
516
932
  throw httpError(400, 'service required');
517
933
  const ds = await currentDesired(project);
518
934
  if (!ds)
519
- throw httpError(404, 'project has no desired state');
935
+ throw httpError(404, missingProject(project));
520
936
  const state = JSON.parse(ds.state_json);
521
937
  const svc = state.services?.[service];
522
938
  if (!svc)
523
- throw httpError(404, 'service not found');
939
+ throw httpError(404, missingService(project, service));
524
940
  const nodeName = svc.placement?.nodes?.[0];
525
941
  if (!nodeName)
526
942
  throw httpError(400, 'service has no explicit node placement');
@@ -532,15 +948,18 @@ export async function createApp() {
532
948
  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
949
  return { commandId: cmdId, nodeId: node.id };
534
950
  }
535
- app.post('/api/v1/restart', authOperator, async (req, res) => {
951
+ api('post', '/api/v1/restart', bodyProject, async (req, res) => {
536
952
  try {
537
- res.json(await commandForService({ project: req.body.project || 'site', service: req.body.service, type: 'service.restart', payload: {} }));
953
+ const project = req.body.project || 'site';
954
+ const queued = await commandForService({ project, service: req.body.service, type: 'service.restart', payload: {} });
955
+ 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 } });
956
+ res.json(queued);
538
957
  }
539
958
  catch (e) {
540
959
  res.status(e.status || 500).json({ error: errMessage(e) });
541
960
  }
542
961
  });
543
- app.post('/api/v1/logs', authOperator, async (req, res) => {
962
+ api('post', '/api/v1/logs', bodyProject, async (req, res) => {
544
963
  const project = req.body.project || 'site';
545
964
  const service = req.body.service;
546
965
  const tail = Number(req.body.tail || 200);
@@ -548,11 +967,11 @@ export async function createApp() {
548
967
  return res.status(400).json({ error: 'service required' });
549
968
  const ds = await currentDesired(project);
550
969
  if (!ds)
551
- return res.status(404).json({ error: 'project has no desired state' });
970
+ return res.status(404).json({ error: missingProject(project) });
552
971
  const state = JSON.parse(ds.state_json);
553
972
  const svc = state.services?.[service];
554
973
  if (!svc)
555
- return res.status(404).json({ error: 'service not found' });
974
+ return res.status(404).json({ error: missingService(project, service) });
556
975
  const nodeName = svc.placement?.nodes?.[0];
557
976
  if (!nodeName)
558
977
  return res.status(400).json({ error: 'service has no explicit node placement' });
@@ -564,22 +983,28 @@ export async function createApp() {
564
983
  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
984
  res.json({ commandId: cmdId, nodeId: node.id });
566
985
  });
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) => {
986
+ api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(await db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
987
+ api('get', '/api/v1/status', null, async (req, res) => {
569
988
  const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
570
989
  const cutoff = new Date(Date.now() - staleSeconds * 1000).toISOString();
571
990
  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') });
991
+ const projects = projectScope(req.actor, 'id');
992
+ const deployments = projectScope(req.actor, 'project_id');
993
+ const statuses = projectScope(req.actor, 'project_id');
994
+ 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
995
  });
574
- app.get('/api/v1/events', authOperator, async (req, res) => {
575
- const project = req.query.project || null;
996
+ api('get', '/api/v1/events', null, async (req, res) => {
997
+ const project = req.query.project ? String(req.query.project) : null;
998
+ if (project)
999
+ assertProject(req.actor, project);
576
1000
  const limit = Math.min(Number(req.query.limit || 100), 500);
1001
+ const scope = projectScope(req.actor, 'project_id'); // scoped tokens never see node-level or other projects' events
577
1002
  const events = project
578
1003
  ? 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);
1004
+ : await db.all(`SELECT * FROM events WHERE ${scope.where} ORDER BY created_at DESC LIMIT ?`, ...scope.args, limit);
580
1005
  res.json({ events });
581
1006
  });
582
- app.get('/api/v1/diagnostics', authOperator, async (req, res) => {
1007
+ api('get', '/api/v1/diagnostics', null, async (req, res) => {
583
1008
  const checks = [];
584
1009
  async function check(name, fn) {
585
1010
  try {
@@ -614,8 +1039,10 @@ export async function createApp() {
614
1039
  }
615
1040
  res.json({ ok: checks.every(c => c.ok), registry: REGISTRY, checks });
616
1041
  });
617
- app.post('/api/v1/diagnostics/nodes', authOperator, async (req, res) => {
1042
+ api('post', '/api/v1/diagnostics/nodes', null, async (req, res) => {
618
1043
  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');
1044
+ if (req.body.node && !nodes.length)
1045
+ return res.status(404).json({ error: `node ${req.body.node} not found` });
619
1046
  const ts = now();
620
1047
  const commands = [];
621
1048
  for (const node of nodes) {
@@ -662,7 +1089,7 @@ export async function createApp() {
662
1089
  }
663
1090
  return buildIngressWork({ states, endpoints: await endpointsFor(states), node: node.name, secrets, acmeEmail: ACME_EMAIL });
664
1091
  }
665
- app.get('/agent/v1/work', authAgent, async (req, res) => {
1092
+ get('/agent/v1/work', authAgent, async (req, res) => {
666
1093
  await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
667
1094
  const node = (await db.get('SELECT * FROM nodes WHERE id=?', req.node.id));
668
1095
  const allStates = await currentDesiredStates();
@@ -670,7 +1097,8 @@ export async function createApp() {
670
1097
  const commands = await db.all('SELECT * FROM commands WHERE node_id=? AND state=?', node.id, 'pending');
671
1098
  if (commands.length)
672
1099
  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 };
1100
+ 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)))];
1101
+ const work = { serverTime: now(), nodeId: node.id, desiredStates, commands, retainedImages };
674
1102
  const network = await networkFor(node);
675
1103
  if (network)
676
1104
  work.network = network;
@@ -682,7 +1110,7 @@ export async function createApp() {
682
1110
  work.ingress = await ingressFor(node, allStates);
683
1111
  res.json(work);
684
1112
  });
685
- app.post('/agent/v1/status', authAgent, async (req, res) => {
1113
+ post('/agent/v1/status', authAgent, async (req, res) => {
686
1114
  const ts = now();
687
1115
  const report = req.body;
688
1116
  for (const st of report.projects || []) {
@@ -698,9 +1126,15 @@ export async function createApp() {
698
1126
  await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
699
1127
  res.json({ ok: true });
700
1128
  });
701
- app.post('/agent/v1/events', authAgent, async (req, res) => { const evs = req.body.events || [req.body]; for (const ev of evs)
702
- 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) => {
1129
+ post('/agent/v1/events', authAgent, async (req, res) => {
1130
+ const evs = req.body.events || [req.body];
1131
+ for (const ev of evs)
1132
+ 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());
1133
+ if (Date.now() - lastEventPrune >= eventPruneIntervalMs)
1134
+ await pruneEvents().catch(e => console.error(`event pruning failed: ${errMessage(e)}`));
1135
+ res.json({ ok: true });
1136
+ });
1137
+ get('/agent/v1/secrets', authAgent, async (req, res) => {
704
1138
  const project = String(req.query.project || 'site');
705
1139
  let rows = await db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
706
1140
  if (req.query.services) {
@@ -712,6 +1146,11 @@ export async function createApp() {
712
1146
  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
1147
  res.json(secrets);
714
1148
  });
1149
+ app.use((error, _req, res, next) => {
1150
+ if (res.headersSent)
1151
+ return next(error);
1152
+ sendError(res, error);
1153
+ });
715
1154
  return app;
716
1155
  }
717
1156
  export async function listen(app, port = PORT) {