@wpelevator/tug 0.0.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 +63 -0
- package/dist/agent.d.ts +40 -0
- package/dist/agent.js +547 -0
- 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.d.ts +22 -0
- package/dist/build.js +44 -0
- 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.d.ts +2 -0
- package/dist/cli.js +422 -0
- package/dist/compose.d.ts +33 -0
- package/dist/compose.js +260 -0
- package/dist/control.d.ts +24 -0
- package/dist/control.js +1105 -0
- package/dist/db.d.ts +117 -0
- package/dist/db.js +51 -0
- package/dist/image.d.ts +9 -0
- package/dist/image.js +20 -0
- package/dist/ingress.d.ts +50 -0
- package/dist/ingress.js +180 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +22 -0
- package/dist/mesh.d.ts +70 -0
- package/dist/mesh.js +183 -0
- package/dist/protocol.d.ts +326 -0
- package/dist/protocol.js +8 -0
- package/dist/rollout.d.ts +72 -0
- package/dist/rollout.js +319 -0
- package/dist/runners.d.ts +12 -0
- package/dist/runners.js +10 -0
- package/dist/secrets.d.ts +3 -0
- package/dist/secrets.js +29 -0
- package/dist/templates/agent.env +6 -0
- package/dist/templates/control-compose.yml +18 -0
- package/dist/templates/control-install.sh +7 -0
- package/dist/templates/install-docker.sh +85 -0
- package/dist/templates/node-install.sh +8 -0
- package/dist/tokens.d.ts +91 -0
- package/dist/tokens.js +165 -0
- package/dist/util.d.ts +15 -0
- package/dist/util.js +35 -0
- package/package.json +62 -3
package/dist/control.js
ADDED
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import multer from 'multer';
|
|
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";
|
|
11
|
+
import https from 'node:https';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import * as tar from 'tar';
|
|
14
|
+
import { openDb } from "./db.js";
|
|
15
|
+
import { encrypt, decrypt, loadOrCreateMasterKey } from "./secrets.js";
|
|
16
|
+
import { buildDesiredState, serviceSecretNames, validateCompose } from "./compose.js";
|
|
17
|
+
import { buildAndPush, unpackContext, run as runCommand } from "./build.js";
|
|
18
|
+
import { ensureDir, errMessage, hashToken, id, now, token } from "./util.js";
|
|
19
|
+
import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint } from "./mesh.js";
|
|
20
|
+
import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
|
|
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";
|
|
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-';
|
|
26
|
+
const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map(s => `'${s}'`).join(',');
|
|
27
|
+
const PORT = Number(process.env.PORT || 8080);
|
|
28
|
+
export async function createApp(options = {}) {
|
|
29
|
+
const images = { build: buildAndPush, save: saveImageArchive, inspect: inspectImage, validate: validateImageArchive, run: runCommand, ...options.images };
|
|
30
|
+
const DATA_DIR = process.env.TUG_DATA_DIR || './data';
|
|
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 } });
|
|
35
|
+
const dbFile = path.join(DATA_DIR, 'tug.db');
|
|
36
|
+
const masterKeyFile = path.join(DATA_DIR, 'master.key');
|
|
37
|
+
const REGISTRY = process.env.TUG_REGISTRY?.trim() || '';
|
|
38
|
+
const REGISTRY_AUTH_PATH = process.env.TUG_REGISTRY_AUTH_PATH || null;
|
|
39
|
+
const REGISTRY_CONTAINER = process.env.TUG_REGISTRY_CONTAINER || null;
|
|
40
|
+
// Networking layers are opt-in; with all of them off the work response is unchanged.
|
|
41
|
+
const MESH = process.env.TUG_MESH === 'true';
|
|
42
|
+
const MESH_CIDR = process.env.TUG_MESH_CIDR || DEFAULT_MESH_CIDR;
|
|
43
|
+
const WG_PORT = Number(process.env.TUG_WG_PORT || DEFAULT_WG_PORT);
|
|
44
|
+
const SERVICE_DNS = (process.env.TUG_SERVICE_DNS ?? String(MESH)) === 'true';
|
|
45
|
+
const ACME_EMAIL = process.env.TUG_ACME_EMAIL || undefined;
|
|
46
|
+
if (MESH)
|
|
47
|
+
parseCidr(MESH_CIDR);
|
|
48
|
+
let htpasswdQueue = Promise.resolve();
|
|
49
|
+
function withHtpasswdLock(fn) {
|
|
50
|
+
const next = htpasswdQueue.catch(() => { }).then(fn);
|
|
51
|
+
htpasswdQueue = next.then(() => { }, () => { });
|
|
52
|
+
return next;
|
|
53
|
+
}
|
|
54
|
+
async function reloadRegistry() {
|
|
55
|
+
if (!REGISTRY_CONTAINER)
|
|
56
|
+
return;
|
|
57
|
+
await runCommand('docker', ['restart', REGISTRY_CONTAINER]).catch(e => console.error(`registry reload failed: ${e.message}`));
|
|
58
|
+
}
|
|
59
|
+
async function upsertRegistryCredential(name, password) {
|
|
60
|
+
if (!REGISTRY || !REGISTRY_AUTH_PATH)
|
|
61
|
+
return;
|
|
62
|
+
await withHtpasswdLock(async () => {
|
|
63
|
+
const { out } = await runCommand('htpasswd', ['-nbB', name, password]);
|
|
64
|
+
const line = out.trim();
|
|
65
|
+
let existing = '';
|
|
66
|
+
try {
|
|
67
|
+
existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
const lines = existing.split('\n').filter(Boolean).filter(l => !l.startsWith(`${name}:`));
|
|
71
|
+
lines.push(line);
|
|
72
|
+
await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
|
|
73
|
+
await fs.writeFile(REGISTRY_AUTH_PATH, `${lines.join('\n')}\n`);
|
|
74
|
+
});
|
|
75
|
+
await reloadRegistry();
|
|
76
|
+
}
|
|
77
|
+
async function removeRegistryCredential(name) {
|
|
78
|
+
if (!REGISTRY_AUTH_PATH)
|
|
79
|
+
return;
|
|
80
|
+
await withHtpasswdLock(async () => {
|
|
81
|
+
let existing = '';
|
|
82
|
+
try {
|
|
83
|
+
existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
const lines = existing.split('\n').filter(Boolean).filter(l => !l.startsWith(`${name}:`));
|
|
87
|
+
await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
|
|
88
|
+
await fs.writeFile(REGISTRY_AUTH_PATH, lines.length ? `${lines.join('\n')}\n` : '');
|
|
89
|
+
});
|
|
90
|
+
await reloadRegistry();
|
|
91
|
+
}
|
|
92
|
+
const db = await (options.openDatabase || openDb)(dbFile);
|
|
93
|
+
const masterKey = await loadOrCreateMasterKey(masterKeyFile);
|
|
94
|
+
const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
|
|
95
|
+
if (!operator) {
|
|
96
|
+
const t = process.env.TUG_OPERATOR_TOKEN || token('tug_op');
|
|
97
|
+
await db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'operator_token_hash', hashToken(t));
|
|
98
|
+
await fs.writeFile(path.join(DATA_DIR, 'operator.token'), `${t}\n`, { mode: 0o600 }).catch(() => { });
|
|
99
|
+
console.log(`operator token: ${t}`);
|
|
100
|
+
}
|
|
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());
|
|
104
|
+
app.use(express.json({ limit: '20mb' }));
|
|
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
|
+
}
|
|
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: [] };
|
|
162
|
+
const authAgent = async (req, res, next) => {
|
|
163
|
+
const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
|
|
164
|
+
const node = raw && await db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw));
|
|
165
|
+
if (!node)
|
|
166
|
+
return res.status(401).json({ error: 'unauthorized' });
|
|
167
|
+
req.node = node;
|
|
168
|
+
next();
|
|
169
|
+
};
|
|
170
|
+
async function updateDeploymentConvergence(projectId) {
|
|
171
|
+
const project = await db.get('SELECT * FROM projects WHERE id=?', projectId);
|
|
172
|
+
if (!project?.current_deployment_id)
|
|
173
|
+
return;
|
|
174
|
+
const ds = await db.get('SELECT * FROM desired_states WHERE deployment_id=?', project.current_deployment_id);
|
|
175
|
+
if (!ds)
|
|
176
|
+
return;
|
|
177
|
+
const state = JSON.parse(ds.state_json);
|
|
178
|
+
const required = new Set();
|
|
179
|
+
for (const svc of Object.values(state.services || {}))
|
|
180
|
+
for (const node of svc.placement?.nodes || [])
|
|
181
|
+
required.add(node);
|
|
182
|
+
if (!required.size)
|
|
183
|
+
return;
|
|
184
|
+
const statuses = await db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
|
|
185
|
+
const byNode = new Map(statuses.map(s => [s.node_id, s]));
|
|
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 ?? ''));
|
|
194
|
+
if (ok)
|
|
195
|
+
await db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
|
|
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
|
+
}
|
|
258
|
+
async function currentDesired(projectId) {
|
|
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);
|
|
260
|
+
}
|
|
261
|
+
async function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
|
|
262
|
+
const name = `project:${project}`;
|
|
263
|
+
const ts = now();
|
|
264
|
+
await db.run('DELETE FROM locks WHERE expires_at < ?', ts);
|
|
265
|
+
const owner = `${operation}:${id('lock')}`;
|
|
266
|
+
try {
|
|
267
|
+
await db.run('INSERT INTO locks(name,owner,expires_at,created_at) VALUES(?,?,?,?)', name, owner, new Date(Date.now() + ttlMs).toISOString(), ts);
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
if (!/UNIQUE constraint/.test(errMessage(e)))
|
|
271
|
+
throw e;
|
|
272
|
+
const existing = await db.get('SELECT * FROM locks WHERE name=?', name);
|
|
273
|
+
const err = new Error(`project ${project} is locked by ${existing?.owner} until ${existing?.expires_at}`);
|
|
274
|
+
err.status = 409;
|
|
275
|
+
err.lock = existing;
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
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 };
|
|
281
|
+
}
|
|
282
|
+
async function releaseLock(lock) {
|
|
283
|
+
if (lock?.heartbeat)
|
|
284
|
+
clearInterval(lock.heartbeat);
|
|
285
|
+
if (lock)
|
|
286
|
+
await db.run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner).catch(() => { });
|
|
287
|
+
}
|
|
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) => {
|
|
291
|
+
const name = req.body.name || 'site';
|
|
292
|
+
const ts = now();
|
|
293
|
+
await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
|
|
294
|
+
res.json(await db.get('SELECT * FROM projects WHERE id=?', name));
|
|
295
|
+
});
|
|
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)));
|
|
297
|
+
const NODE_INFO_SQL = 'SELECT id,name,address,public_address,overlay_ip,status,created_at,updated_at FROM nodes WHERE id=?';
|
|
298
|
+
/** Allocates the node's stable overlay IP on first registration (retrying if a concurrent register took it). */
|
|
299
|
+
async function ensureOverlayIp(nodeId) {
|
|
300
|
+
if (!MESH)
|
|
301
|
+
return;
|
|
302
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
303
|
+
const row = await db.get('SELECT overlay_ip FROM nodes WHERE id=?', nodeId);
|
|
304
|
+
if (row?.overlay_ip)
|
|
305
|
+
return;
|
|
306
|
+
const used = (await db.all('SELECT overlay_ip FROM nodes WHERE overlay_ip IS NOT NULL')).map(r => r.overlay_ip);
|
|
307
|
+
try {
|
|
308
|
+
await db.run('UPDATE nodes SET overlay_ip=? WHERE id=? AND overlay_ip IS NULL', allocateOverlayIp(MESH_CIDR, used), nodeId);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
if (!/UNIQUE constraint/.test(errMessage(e)))
|
|
313
|
+
throw e;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
throw new Error('could not allocate an overlay address');
|
|
317
|
+
}
|
|
318
|
+
/** Stores (or refreshes) the node's WireGuard peer entry; the private key never reaches control. */
|
|
319
|
+
async function syncWgPeer(nodeId, publicKey) {
|
|
320
|
+
if (!MESH)
|
|
321
|
+
return;
|
|
322
|
+
const node = await db.get('SELECT * FROM nodes WHERE id=?', nodeId);
|
|
323
|
+
const existing = await db.get('SELECT * FROM wg_peers WHERE node_id=?', nodeId);
|
|
324
|
+
const key = publicKey ?? existing?.public_key;
|
|
325
|
+
if (!node?.overlay_ip || !key)
|
|
326
|
+
return;
|
|
327
|
+
await db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
|
|
328
|
+
ON CONFLICT(node_id) DO UPDATE SET public_key=excluded.public_key,overlay_ip=excluded.overlay_ip,endpoint=excluded.endpoint,allowed_ips=excluded.allowed_ips,updated_at=excluded.updated_at`, nodeId, key, node.overlay_ip, wgEndpoint(node.public_address, WG_PORT) ?? null, `${node.overlay_ip}/32`, now());
|
|
329
|
+
}
|
|
330
|
+
async function registerNode({ name, address, publicAddress, wgPublicKey }) {
|
|
331
|
+
if (wgPublicKey !== undefined && !isWgKey(wgPublicKey))
|
|
332
|
+
throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
|
|
333
|
+
const agentToken = token('tug_agent');
|
|
334
|
+
const registryToken = token('tug_reg');
|
|
335
|
+
const ts = now();
|
|
336
|
+
await db.run(`INSERT INTO nodes(id,name,address,public_address,status,agent_token_hash,registry_token_hash,created_at,updated_at)
|
|
337
|
+
VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,address=excluded.address,public_address=excluded.public_address,agent_token_hash=excluded.agent_token_hash,registry_token_hash=excluded.registry_token_hash,updated_at=excluded.updated_at`, name, name, address || null, publicAddress || null, 'registered', hashToken(agentToken), hashToken(registryToken), ts, ts);
|
|
338
|
+
await ensureOverlayIp(name);
|
|
339
|
+
await syncWgPeer(name, wgPublicKey);
|
|
340
|
+
await upsertRegistryCredential(name, registryToken);
|
|
341
|
+
const node = (await db.get(NODE_INFO_SQL, name));
|
|
342
|
+
return { node, agentToken, registryToken };
|
|
343
|
+
}
|
|
344
|
+
api('post', '/api/v1/nodes/register', null, async (req, res) => {
|
|
345
|
+
const name = req.body.name;
|
|
346
|
+
if (!name)
|
|
347
|
+
return res.status(400).json({ error: 'name required' });
|
|
348
|
+
try {
|
|
349
|
+
res.json(await registerNode({ name, address: req.body.address, publicAddress: req.body.publicAddress, wgPublicKey: req.body.wgPublicKey }));
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
sendError(res, e);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
// Self-service enrollment: operator mints a short-lived, limited-use key; the agent
|
|
356
|
+
// exchanges it for durable credentials on first boot (Portainer Edge-style join).
|
|
357
|
+
api('post', '/api/v1/nodes/enroll-token', null, async (req, res) => {
|
|
358
|
+
const name = req.body.name || null;
|
|
359
|
+
const ttlSeconds = Math.max(1, Number(req.body.ttlSeconds ?? 3600));
|
|
360
|
+
const uses = Math.max(1, Number(req.body.uses ?? 1));
|
|
361
|
+
const enrollToken = token('tug_enroll');
|
|
362
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
363
|
+
await db.run('INSERT INTO enroll_tokens(token_hash,name,uses_remaining,expires_at,created_at) VALUES(?,?,?,?,?)', hashToken(enrollToken), name, uses, expiresAt, now());
|
|
364
|
+
res.json({ enrollToken, name, usesRemaining: uses, expiresAt, registry: REGISTRY });
|
|
365
|
+
});
|
|
366
|
+
post('/agent/v1/enroll', async (req, res) => {
|
|
367
|
+
const body = req.body;
|
|
368
|
+
const raw = String(body.enrollToken || (req.headers.authorization || '').replace(/^Bearer /, '')).trim();
|
|
369
|
+
if (!raw)
|
|
370
|
+
return res.status(400).json({ error: 'enrollToken required' });
|
|
371
|
+
const row = await db.get('SELECT * FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
|
|
372
|
+
if (!row || row.uses_remaining < 1 || row.expires_at < now()) {
|
|
373
|
+
if (row)
|
|
374
|
+
await db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
|
|
375
|
+
return res.status(401).json({ error: 'invalid or expired enroll token' });
|
|
376
|
+
}
|
|
377
|
+
const name = row.name || body.name;
|
|
378
|
+
if (!name)
|
|
379
|
+
return res.status(400).json({ error: 'node name required (bind one to the enroll token or send name)' });
|
|
380
|
+
let result;
|
|
381
|
+
try {
|
|
382
|
+
result = await registerNode({ name, address: body.address, publicAddress: body.publicAddress, wgPublicKey: body.wgPublicKey });
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
return sendError(res, e);
|
|
386
|
+
}
|
|
387
|
+
const remaining = row.uses_remaining - 1;
|
|
388
|
+
if (remaining < 1)
|
|
389
|
+
await db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
|
|
390
|
+
else
|
|
391
|
+
await db.run('UPDATE enroll_tokens SET uses_remaining=? WHERE token_hash=?', remaining, hashToken(raw));
|
|
392
|
+
const enrolled = { ...result, registry: REGISTRY };
|
|
393
|
+
res.json(enrolled);
|
|
394
|
+
});
|
|
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) => {
|
|
397
|
+
const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
|
|
398
|
+
if (!node)
|
|
399
|
+
return res.status(404).json({ error: 'node not found' });
|
|
400
|
+
const registryToken = token('tug_reg');
|
|
401
|
+
await db.run('UPDATE nodes SET registry_token_hash=?,updated_at=? WHERE id=?', hashToken(registryToken), now(), node.id);
|
|
402
|
+
await upsertRegistryCredential(node.name, registryToken);
|
|
403
|
+
res.json({ ok: true, node: node.name, registryToken });
|
|
404
|
+
});
|
|
405
|
+
api('post', '/api/v1/nodes/:name/revoke-registry-token', null, async (req, res) => {
|
|
406
|
+
const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
|
|
407
|
+
if (!node)
|
|
408
|
+
return res.status(404).json({ error: 'node not found' });
|
|
409
|
+
await db.run('UPDATE nodes SET registry_token_hash=NULL,updated_at=? WHERE id=?', now(), node.id);
|
|
410
|
+
await removeRegistryCredential(node.name);
|
|
411
|
+
res.json({ ok: true, node: node.name, revoked: true });
|
|
412
|
+
});
|
|
413
|
+
async function findNode(nameOrId) { return db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', nameOrId, nameOrId); }
|
|
414
|
+
function queueCommand(nodeId, projectId, type, payload = {}) {
|
|
415
|
+
const commandId = id('cmd');
|
|
416
|
+
const ts = now();
|
|
417
|
+
return db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, nodeId, projectId, type, 'pending', JSON.stringify(payload), 0, ts, null, ts, ts).then(() => commandId);
|
|
418
|
+
}
|
|
419
|
+
// The private key lives only on the node, so rotation is a command the agent executes: it
|
|
420
|
+
// generates a new keypair, reports the public key and peers pick it up from their next work response.
|
|
421
|
+
api('post', '/api/v1/nodes/:name/rotate-wg-key', null, async (req, res) => {
|
|
422
|
+
if (!MESH)
|
|
423
|
+
return res.status(409).json({ error: 'the WireGuard mesh is not enabled (TUG_MESH=true)' });
|
|
424
|
+
const node = await findNode(req.params.name);
|
|
425
|
+
if (!node)
|
|
426
|
+
return res.status(404).json({ error: 'node not found' });
|
|
427
|
+
res.json({ commandId: await queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
|
|
428
|
+
});
|
|
429
|
+
api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
|
|
430
|
+
const node = await findNode(req.params.name);
|
|
431
|
+
if (!node)
|
|
432
|
+
return res.status(404).json({ error: 'node not found' });
|
|
433
|
+
const enabled = req.body.enabled !== false;
|
|
434
|
+
await db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
|
|
435
|
+
res.json({ ok: true, node: node.name, ingress: enabled });
|
|
436
|
+
});
|
|
437
|
+
post('/agent/v1/wireguard/key', authAgent, async (req, res) => {
|
|
438
|
+
if (!MESH)
|
|
439
|
+
return res.status(409).json({ error: 'the WireGuard mesh is not enabled' });
|
|
440
|
+
const { publicKey } = req.body;
|
|
441
|
+
if (!isWgKey(publicKey))
|
|
442
|
+
return res.status(400).json({ error: 'publicKey must be a base64 WireGuard public key' });
|
|
443
|
+
try {
|
|
444
|
+
await syncWgPeer(req.node.id, publicKey);
|
|
445
|
+
res.json({ ok: true });
|
|
446
|
+
}
|
|
447
|
+
catch (e) {
|
|
448
|
+
sendError(res, /UNIQUE constraint/.test(errMessage(e)) ? httpError(409, 'public key already registered to another node') : e);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
post('/api/v1/registry/auth', async (req, res) => {
|
|
452
|
+
const username = req.body.username;
|
|
453
|
+
const password = req.body.password;
|
|
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;
|
|
455
|
+
if (!node)
|
|
456
|
+
return res.status(401).json({ ok: false, error: 'invalid registry credentials' });
|
|
457
|
+
res.json({ ok: true, node: node.name });
|
|
458
|
+
});
|
|
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) => {
|
|
473
|
+
const { project = 'site', name, value } = req.body;
|
|
474
|
+
if (!name || value == null)
|
|
475
|
+
return res.status(400).json({ error: 'name and value required' });
|
|
476
|
+
let lock;
|
|
477
|
+
try {
|
|
478
|
+
lock = await acquireProjectLock(project, 'secret.set');
|
|
479
|
+
const old = await db.get('SELECT version FROM secrets WHERE project_id=? AND name=?', project, name);
|
|
480
|
+
const ts = now();
|
|
481
|
+
await db.run(`INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)
|
|
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 } });
|
|
484
|
+
res.json({ ok: true, name, version: (old?.version || 0) + 1 });
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
sendError(res, e);
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
await releaseLock(lock);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
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))
|
|
514
|
+
continue;
|
|
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);
|
|
521
|
+
}
|
|
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;
|
|
545
|
+
let lock;
|
|
546
|
+
let depId;
|
|
547
|
+
let contextDir;
|
|
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
|
+
}
|
|
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();
|
|
610
|
+
await db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
|
|
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);
|
|
616
|
+
}
|
|
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
|
+
}
|
|
671
|
+
}
|
|
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 };
|
|
700
|
+
}
|
|
701
|
+
catch (e) {
|
|
702
|
+
if (depId)
|
|
703
|
+
await db.run("UPDATE deployments SET state='build_failed',error_message=?,updated_at=? WHERE id=? AND state='building'", errMessage(e), now(), depId).catch(() => { });
|
|
704
|
+
responseError = e;
|
|
705
|
+
}
|
|
706
|
+
finally {
|
|
707
|
+
await releaseLock(lock);
|
|
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' });
|
|
734
|
+
}
|
|
735
|
+
});
|
|
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 () => {
|
|
738
|
+
const row = await db.get('SELECT * FROM deployments WHERE id=?', req.params.id);
|
|
739
|
+
if (!row)
|
|
740
|
+
return res.status(404).json({ error: 'deployment not found' });
|
|
741
|
+
if (!ACTIVE_DEPLOYMENT_STATES.includes(row.state))
|
|
742
|
+
return res.status(409).json({ error: `deployment is ${row.state}` });
|
|
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}` });
|
|
747
|
+
res.json({ ok: true });
|
|
748
|
+
}));
|
|
749
|
+
api('post', '/api/v1/rollback', bodyProject, async (req, res) => withWriter(async () => {
|
|
750
|
+
const project = req.body.project || 'site';
|
|
751
|
+
try {
|
|
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);
|
|
755
|
+
}
|
|
756
|
+
catch (e) {
|
|
757
|
+
sendError(res, e);
|
|
758
|
+
}
|
|
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) => {
|
|
762
|
+
const project = String(req.query.project || 'site');
|
|
763
|
+
const deleteData = String(req.query.deleteData || 'false') === 'true';
|
|
764
|
+
let lock;
|
|
765
|
+
try {
|
|
766
|
+
lock = deleteData ? await acquireProjectLock(project, 'volume.delete') : null;
|
|
767
|
+
const volume = await db.get('SELECT * FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
|
|
768
|
+
if (!volume)
|
|
769
|
+
return res.status(404).json({ error: 'volume not found' });
|
|
770
|
+
const config = JSON.parse(volume.config_json || '{}');
|
|
771
|
+
if ((config.persistent || volume.type !== 'ephemeral') && !deleteData)
|
|
772
|
+
return res.status(409).json({ error: 'refusing to delete persistent volume without deleteData=true' });
|
|
773
|
+
const ds = await currentDesired(project);
|
|
774
|
+
if (ds) {
|
|
775
|
+
const state = JSON.parse(ds.state_json);
|
|
776
|
+
const inUse = Object.entries(state.compose?.services || {}).filter(([, svc]) => (svc.volumes || []).some(v => (typeof v === 'string' ? v.split(':')[0] : v.source) === req.params.name)).map(([name]) => name);
|
|
777
|
+
if (inUse.length)
|
|
778
|
+
return res.status(409).json({ error: `volume is still referenced by services: ${inUse.join(', ')}` });
|
|
779
|
+
}
|
|
780
|
+
let commandId = null;
|
|
781
|
+
if (deleteData && volume.node_id) {
|
|
782
|
+
commandId = id('cmd');
|
|
783
|
+
const ts = now();
|
|
784
|
+
await db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, volume.node_id, project, 'volume.delete', 'pending', JSON.stringify({ project, volume: req.params.name }), 0, ts, null, ts, ts);
|
|
785
|
+
}
|
|
786
|
+
await db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
|
|
787
|
+
res.json({ ok: true, commandId, deletedMetadata: true, deleteData });
|
|
788
|
+
}
|
|
789
|
+
catch (e) {
|
|
790
|
+
sendError(res, e);
|
|
791
|
+
}
|
|
792
|
+
finally {
|
|
793
|
+
await releaseLock(lock);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
|
|
797
|
+
const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
|
|
798
|
+
await ensureDir(backupDir);
|
|
799
|
+
const ts = now();
|
|
800
|
+
const backupId = id('cbak');
|
|
801
|
+
const staging = path.join(backupDir, `${backupId}-staging`);
|
|
802
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
803
|
+
await ensureDir(staging);
|
|
804
|
+
const snapshotPath = path.join(staging, 'tug.db').replaceAll("'", "''");
|
|
805
|
+
await db.exec(`VACUUM INTO '${snapshotPath}'`);
|
|
806
|
+
await fs.copyFile(masterKeyFile, path.join(staging, 'master.key'));
|
|
807
|
+
await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
|
|
808
|
+
await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2));
|
|
809
|
+
const artifactRef = path.resolve(backupDir, `${backupId}.tar.gz`);
|
|
810
|
+
await tar.c({ gzip: true, file: artifactRef, cwd: staging }, ['tug.db', 'master.key', 'metadata.json', 'images']);
|
|
811
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
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());
|
|
813
|
+
res.json({ backupId, artifactRef, state: 'completed' });
|
|
814
|
+
}));
|
|
815
|
+
api('post', '/api/v1/control/restore', null, async (req, res) => withWriter(async () => {
|
|
816
|
+
if (!req.body.confirm)
|
|
817
|
+
return res.status(400).json({ error: 'confirm=true is required; restore overwrites control data then requires restart' });
|
|
818
|
+
const artifactRef = req.body.artifactRef;
|
|
819
|
+
if (typeof artifactRef !== 'string' || !artifactRef)
|
|
820
|
+
return res.status(400).json({ error: 'artifactRef required' });
|
|
821
|
+
const restoreDir = process.env.TUG_CONTROL_RESTORE_DIR || path.join(DATA_DIR, 'control-restore');
|
|
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
|
+
}));
|
|
875
|
+
async function commandForService({ project, service, type, payload }) {
|
|
876
|
+
if (!service)
|
|
877
|
+
throw httpError(400, 'service required');
|
|
878
|
+
const ds = await currentDesired(project);
|
|
879
|
+
if (!ds)
|
|
880
|
+
throw httpError(404, 'project has no desired state');
|
|
881
|
+
const state = JSON.parse(ds.state_json);
|
|
882
|
+
const svc = state.services?.[service];
|
|
883
|
+
if (!svc)
|
|
884
|
+
throw httpError(404, 'service not found');
|
|
885
|
+
const nodeName = svc.placement?.nodes?.[0];
|
|
886
|
+
if (!nodeName)
|
|
887
|
+
throw httpError(400, 'service has no explicit node placement');
|
|
888
|
+
const node = await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
|
|
889
|
+
if (!node)
|
|
890
|
+
throw httpError(404, 'node not found');
|
|
891
|
+
const cmdId = id('cmd');
|
|
892
|
+
const ts = now();
|
|
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);
|
|
894
|
+
return { commandId: cmdId, nodeId: node.id };
|
|
895
|
+
}
|
|
896
|
+
api('post', '/api/v1/restart', bodyProject, async (req, res) => {
|
|
897
|
+
try {
|
|
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);
|
|
902
|
+
}
|
|
903
|
+
catch (e) {
|
|
904
|
+
res.status(e.status || 500).json({ error: errMessage(e) });
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
api('post', '/api/v1/logs', bodyProject, async (req, res) => {
|
|
908
|
+
const project = req.body.project || 'site';
|
|
909
|
+
const service = req.body.service;
|
|
910
|
+
const tail = Number(req.body.tail || 200);
|
|
911
|
+
if (!service)
|
|
912
|
+
return res.status(400).json({ error: 'service required' });
|
|
913
|
+
const ds = await currentDesired(project);
|
|
914
|
+
if (!ds)
|
|
915
|
+
return res.status(404).json({ error: 'project has no desired state' });
|
|
916
|
+
const state = JSON.parse(ds.state_json);
|
|
917
|
+
const svc = state.services?.[service];
|
|
918
|
+
if (!svc)
|
|
919
|
+
return res.status(404).json({ error: 'service not found' });
|
|
920
|
+
const nodeName = svc.placement?.nodes?.[0];
|
|
921
|
+
if (!nodeName)
|
|
922
|
+
return res.status(400).json({ error: 'service has no explicit node placement' });
|
|
923
|
+
const node = await db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
|
|
924
|
+
if (!node)
|
|
925
|
+
return res.status(404).json({ error: 'node not found' });
|
|
926
|
+
const cmdId = id('cmd');
|
|
927
|
+
const ts = now();
|
|
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);
|
|
929
|
+
res.json({ commandId: cmdId, nodeId: node.id });
|
|
930
|
+
});
|
|
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) => {
|
|
933
|
+
const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
|
|
934
|
+
const cutoff = new Date(Date.now() - staleSeconds * 1000).toISOString();
|
|
935
|
+
await db.run("UPDATE nodes SET status='stale',updated_at=? WHERE last_seen_at IS NOT NULL AND last_seen_at < ?", now(), cutoff);
|
|
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) });
|
|
940
|
+
});
|
|
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);
|
|
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
|
|
947
|
+
const events = project
|
|
948
|
+
? await db.all('SELECT * FROM events WHERE project_id=? ORDER BY created_at DESC LIMIT ?', project, limit)
|
|
949
|
+
: await db.all(`SELECT * FROM events WHERE ${scope.where} ORDER BY created_at DESC LIMIT ?`, ...scope.args, limit);
|
|
950
|
+
res.json({ events });
|
|
951
|
+
});
|
|
952
|
+
api('get', '/api/v1/diagnostics', null, async (req, res) => {
|
|
953
|
+
const checks = [];
|
|
954
|
+
async function check(name, fn) {
|
|
955
|
+
try {
|
|
956
|
+
checks.push({ name, ok: true, ...(await fn()) });
|
|
957
|
+
}
|
|
958
|
+
catch (e) {
|
|
959
|
+
checks.push({ name, ok: false, error: errMessage(e) });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
await check('sqlite', async () => ({ value: (await db.get('SELECT COUNT(*) AS count FROM projects')).count }));
|
|
963
|
+
await check('operator_token', async () => ({ configured: Boolean(await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash')) }));
|
|
964
|
+
await check('master_key', async () => ({ configured: Boolean(masterKey) }));
|
|
965
|
+
await check('docker', async () => ({ output: (await runCommand('docker', ['version', '--format', '{{.Server.Version}}'])).out.trim() }));
|
|
966
|
+
await check('docker_compose', async () => ({ output: (await runCommand('docker', ['compose', 'version'])).out.trim() }));
|
|
967
|
+
if (MESH) {
|
|
968
|
+
await check('mesh', async () => {
|
|
969
|
+
const nodes = await db.all('SELECT n.name, n.overlay_ip, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
|
|
970
|
+
const missing = nodes.filter(n => !n.has_key).map(n => n.name);
|
|
971
|
+
if (missing.length)
|
|
972
|
+
throw new Error(`nodes without a reported WireGuard key (is TUG_MESH=true on their agents?): ${missing.join(', ')}`);
|
|
973
|
+
return { cidr: MESH_CIDR, port: WG_PORT, nodes: nodes.length };
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
const ingressNodes = await db.all('SELECT name,ingress_status_json FROM nodes WHERE ingress=1');
|
|
977
|
+
if (ingressNodes.length) {
|
|
978
|
+
await check('ingress', async () => {
|
|
979
|
+
const bad = ingressNodes.filter(n => (n.ingress_status_json ? JSON.parse(n.ingress_status_json).state : 'unreported') !== 'serving').map(n => n.name);
|
|
980
|
+
if (bad.length)
|
|
981
|
+
throw new Error(`ingress nodes not serving: ${bad.join(', ')}`);
|
|
982
|
+
return { nodes: ingressNodes.map(n => n.name) };
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
res.json({ ok: checks.every(c => c.ok), registry: REGISTRY, checks });
|
|
986
|
+
});
|
|
987
|
+
api('post', '/api/v1/diagnostics/nodes', null, async (req, res) => {
|
|
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');
|
|
989
|
+
const ts = now();
|
|
990
|
+
const commands = [];
|
|
991
|
+
for (const node of nodes) {
|
|
992
|
+
const cmdId = id('cmd');
|
|
993
|
+
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, null, 'diagnostics.run', 'pending', '{}', 0, ts, null, ts, ts);
|
|
994
|
+
commands.push({ commandId: cmdId, nodeId: node.id });
|
|
995
|
+
}
|
|
996
|
+
res.json({ commands });
|
|
997
|
+
});
|
|
998
|
+
async function currentDesiredStates() {
|
|
999
|
+
const rows = await db.all(`SELECT ds.* FROM desired_states ds JOIN projects p ON p.current_deployment_id=ds.deployment_id ORDER BY ds.created_at DESC`);
|
|
1000
|
+
return rows.map((s) => JSON.parse(s.state_json));
|
|
1001
|
+
}
|
|
1002
|
+
async function endpointsFor(states) {
|
|
1003
|
+
const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
|
|
1004
|
+
const nodes = await db.all('SELECT n.*, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
|
|
1005
|
+
const views = nodes.map(n => ({ name: n.name, overlayIp: MESH && n.has_key ? n.overlay_ip : null, address: n.address, lastSeenAt: n.last_seen_at }));
|
|
1006
|
+
const statuses = (await db.all('SELECT node_id,project_id,status_json FROM node_statuses'))
|
|
1007
|
+
.map(r => ({ node: nodes.find(n => n.id === r.node_id)?.name ?? r.node_id, project: r.project_id, status: JSON.parse(r.status_json) }));
|
|
1008
|
+
return resolveServiceEndpoints(states, views, statuses, staleBefore);
|
|
1009
|
+
}
|
|
1010
|
+
async function networkFor(node) {
|
|
1011
|
+
if (!MESH || !node.overlay_ip)
|
|
1012
|
+
return undefined;
|
|
1013
|
+
const peers = await db.all('SELECT w.*, n.name FROM wg_peers w JOIN nodes n ON n.id=w.node_id WHERE w.node_id != ? ORDER BY n.name', node.id);
|
|
1014
|
+
return {
|
|
1015
|
+
interface: WG_INTERFACE, overlayIp: node.overlay_ip, cidr: MESH_CIDR, listenPort: WG_PORT,
|
|
1016
|
+
peers: peers.map(p => ({ node: p.name, publicKey: p.public_key, endpoint: p.endpoint ?? undefined, allowedIps: p.allowed_ips.split(','), persistentKeepalive: WG_KEEPALIVE }))
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
async function ingressFor(node, states) {
|
|
1020
|
+
const secrets = new Map();
|
|
1021
|
+
for (const state of states) {
|
|
1022
|
+
for (const svc of Object.values(state.compose.services || {})) {
|
|
1023
|
+
const ing = svc['x-tug']?.ingress;
|
|
1024
|
+
if (ing?.tls !== 'custom')
|
|
1025
|
+
continue;
|
|
1026
|
+
for (const name of [ing.cert, ing.key]) {
|
|
1027
|
+
const row = await db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', state.project, name);
|
|
1028
|
+
if (row)
|
|
1029
|
+
secrets.set(`${state.project}/${name}`, decrypt(row.encrypted_value, masterKey));
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return buildIngressWork({ states, endpoints: await endpointsFor(states), node: node.name, secrets, acmeEmail: ACME_EMAIL });
|
|
1034
|
+
}
|
|
1035
|
+
get('/agent/v1/work', authAgent, async (req, res) => {
|
|
1036
|
+
await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
|
|
1037
|
+
const node = (await db.get('SELECT * FROM nodes WHERE id=?', req.node.id));
|
|
1038
|
+
const allStates = await currentDesiredStates();
|
|
1039
|
+
const desiredStates = allStates.filter(st => Object.values(st.services).some(svc => (svc.placement?.nodes || []).includes(node.name) || !(svc.placement?.nodes?.length)));
|
|
1040
|
+
const commands = await db.all('SELECT * FROM commands WHERE node_id=? AND state=?', node.id, 'pending');
|
|
1041
|
+
if (commands.length)
|
|
1042
|
+
await db.run(`UPDATE commands SET state='running',attempts=attempts+1,updated_at=? WHERE node_id=? AND state='pending'`, now(), node.id);
|
|
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 };
|
|
1045
|
+
const network = await networkFor(node);
|
|
1046
|
+
if (network)
|
|
1047
|
+
work.network = network;
|
|
1048
|
+
if (SERVICE_DNS) {
|
|
1049
|
+
const all = await endpointsFor(allStates);
|
|
1050
|
+
work.serviceEndpoints = Object.fromEntries(desiredStates.map(st => [st.project, all[st.project] || {}]));
|
|
1051
|
+
}
|
|
1052
|
+
if (node.ingress)
|
|
1053
|
+
work.ingress = await ingressFor(node, allStates);
|
|
1054
|
+
res.json(work);
|
|
1055
|
+
});
|
|
1056
|
+
post('/agent/v1/status', authAgent, async (req, res) => {
|
|
1057
|
+
const ts = now();
|
|
1058
|
+
const report = req.body;
|
|
1059
|
+
for (const st of report.projects || []) {
|
|
1060
|
+
await db.run(`INSERT INTO node_statuses(id,node_id,project_id,observed_version,status_json,health,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(node_id,project_id) DO UPDATE SET observed_version=excluded.observed_version,status_json=excluded.status_json,health=excluded.health,updated_at=excluded.updated_at`, id('ns'), req.node.id, st.project, st.observedVersion, JSON.stringify(st), st.health || 'unknown', ts);
|
|
1061
|
+
await updateDeploymentConvergence(st.project);
|
|
1062
|
+
}
|
|
1063
|
+
for (const cr of (report.commandResults || [])) {
|
|
1064
|
+
const result = cr.result || {};
|
|
1065
|
+
await db.run('UPDATE commands SET state=?,result_json=?,updated_at=? WHERE id=? AND node_id=?', cr.state || 'succeeded', JSON.stringify(result), ts, cr.commandId, req.node.id);
|
|
1066
|
+
}
|
|
1067
|
+
if (report.ingress)
|
|
1068
|
+
await db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
|
|
1069
|
+
await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
|
|
1070
|
+
res.json({ ok: true });
|
|
1071
|
+
});
|
|
1072
|
+
post('/agent/v1/events', authAgent, async (req, res) => { const evs = req.body.events || [req.body]; for (const ev of evs)
|
|
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 }); });
|
|
1074
|
+
get('/agent/v1/secrets', authAgent, async (req, res) => {
|
|
1075
|
+
const project = String(req.query.project || 'site');
|
|
1076
|
+
let rows = await db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
|
|
1077
|
+
if (req.query.services) {
|
|
1078
|
+
const ds = await currentDesired(project);
|
|
1079
|
+
const names = String(req.query.services).split(',').map(s => s.trim()).filter(Boolean);
|
|
1080
|
+
const allowed = ds ? serviceSecretNames(JSON.parse(ds.state_json).compose, names) : new Set();
|
|
1081
|
+
rows = rows.filter(r => allowed.has(r.name));
|
|
1082
|
+
}
|
|
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])) };
|
|
1084
|
+
res.json(secrets);
|
|
1085
|
+
});
|
|
1086
|
+
app.use((error, _req, res, next) => {
|
|
1087
|
+
if (res.headersSent)
|
|
1088
|
+
return next(error);
|
|
1089
|
+
sendError(res, error);
|
|
1090
|
+
});
|
|
1091
|
+
return app;
|
|
1092
|
+
}
|
|
1093
|
+
export async function listen(app, port = PORT) {
|
|
1094
|
+
const certFile = process.env.TUG_TLS_CERT_FILE;
|
|
1095
|
+
const keyFile = process.env.TUG_TLS_KEY_FILE;
|
|
1096
|
+
if (certFile && keyFile) {
|
|
1097
|
+
const [cert, key] = await Promise.all([fs.readFile(certFile), fs.readFile(keyFile)]);
|
|
1098
|
+
const server = https.createServer({ cert, key }, app);
|
|
1099
|
+
return new Promise(resolve => server.listen(port, () => { console.log(`tug-control listening on ${port} (tls)`); resolve(server); }));
|
|
1100
|
+
}
|
|
1101
|
+
console.warn('TUG_TLS_CERT_FILE/TUG_TLS_KEY_FILE not set; serving plain HTTP');
|
|
1102
|
+
return new Promise(resolve => { const server = app.listen(port, () => { console.log(`tug-control listening on ${port}`); resolve(server); }); });
|
|
1103
|
+
}
|
|
1104
|
+
if (import.meta.url === `file://${process.argv[1]}`)
|
|
1105
|
+
createApp().then(app => listen(app));
|