@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/README.md +10 -6
- package/dist/agent.d.ts +9 -5
- package/dist/agent.js +192 -51
- package/dist/archive.d.ts +2 -0
- package/dist/archive.js +20 -0
- package/dist/artifacts.d.ts +23 -0
- package/dist/artifacts.js +238 -0
- package/dist/build.js +17 -2
- package/dist/cli-build.d.ts +15 -0
- package/dist/cli-build.js +50 -0
- package/dist/cli-config.d.ts +20 -0
- package/dist/cli-config.js +47 -0
- package/dist/cli-install.d.ts +7 -0
- package/dist/cli-install.js +31 -0
- package/dist/cli.js +179 -90
- package/dist/compose.d.ts +12 -2
- package/dist/compose.js +85 -7
- package/dist/control.d.ts +14 -1
- package/dist/control.js +515 -139
- package/dist/db.d.ts +2 -0
- package/dist/db.js +6 -3
- package/dist/ingress.js +5 -3
- package/dist/protocol.d.ts +44 -0
- package/dist/rollout.d.ts +72 -0
- package/dist/rollout.js +319 -0
- package/dist/templates/control-install.sh +1 -1
- package/dist/templates/install-docker.sh +85 -0
- package/dist/templates/node-install.sh +1 -1
- package/dist/tokens.d.ts +91 -0
- package/dist/tokens.js +165 -0
- package/dist/util.d.ts +3 -0
- package/dist/util.js +11 -0
- package/package.json +5 -2
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
|
|
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 || '
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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(
|
|
378
|
-
const desired = buildDesiredState({ project, version, compose, registry: REGISTRY, defaultNode: nodes[0]?.name || null });
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
await unpackContext(
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
await
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
896
|
+
api('post', '/api/v1/restart', bodyProject, async (req, res) => {
|
|
536
897
|
try {
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
const project = req.query.project
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|