@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/README.md +11 -6
- package/dist/agent.d.ts +9 -5
- package/dist/agent.js +215 -52
- 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 +191 -93
- package/dist/compose.d.ts +12 -2
- package/dist/compose.js +85 -7
- package/dist/control.d.ts +15 -1
- package/dist/control.js +583 -144
- package/dist/db.d.ts +8 -0
- package/dist/db.js +12 -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,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
|
|
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 || '
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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(
|
|
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' });
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
await
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
|
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
|
-
|
|
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) => {
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
951
|
+
api('post', '/api/v1/restart', bodyProject, async (req, res) => {
|
|
536
952
|
try {
|
|
537
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
const project = req.query.project
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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) {
|