@wpelevator/tug 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -11
- package/dist/agent-jobs.d.ts +41 -0
- package/dist/agent-jobs.js +161 -0
- package/dist/agent.d.ts +1 -3
- package/dist/agent.js +58 -35
- package/dist/backup-config.d.ts +47 -0
- package/dist/backup-config.js +256 -0
- package/dist/backup-destination.d.ts +22 -0
- package/dist/backup-destination.js +72 -0
- package/dist/backup-job.d.ts +32 -0
- package/dist/backup-job.js +352 -0
- package/dist/backup-restore.d.ts +67 -0
- package/dist/backup-restore.js +297 -0
- package/dist/backup-scheduler.d.ts +54 -0
- package/dist/backup-scheduler.js +403 -0
- package/dist/build.d.ts +2 -3
- package/dist/build.js +1 -3
- package/dist/cli-build.d.ts +3 -3
- package/dist/cli-build.js +18 -6
- package/dist/cli-config.d.ts +45 -5
- package/dist/cli-config.js +269 -19
- package/dist/cli-live-transport.d.ts +1 -1
- package/dist/cli-live-transport.js +2 -2
- package/dist/cli-live.d.ts +2 -0
- package/dist/cli-live.js +1 -0
- package/dist/cli.js +217 -49
- package/dist/compose.d.ts +1 -2
- package/dist/compose.js +7 -6
- package/dist/container-spec.d.ts +3 -2
- package/dist/container-spec.js +9 -34
- package/dist/control.d.ts +4 -2
- package/dist/control.js +591 -381
- package/dist/cron.d.ts +17 -0
- package/dist/cron.js +124 -0
- package/dist/db-driver.d.ts +20 -0
- package/dist/db-driver.js +11 -0
- package/dist/db-node.d.ts +3 -0
- package/dist/db-node.js +60 -0
- package/dist/db.d.ts +79 -6
- package/dist/db.js +47 -26
- package/dist/docker-api.d.ts +7 -0
- package/dist/docker-api.js +15 -3
- package/dist/live.d.ts +8 -0
- package/dist/live.js +84 -0
- package/dist/protocol.d.ts +121 -12
- package/dist/protocol.js +7 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +133 -0
- package/dist/templates/control-compose.yml +0 -4
- package/dist/templates/node-install.sh +0 -1
- package/dist/tokens.d.ts +7 -7
- package/dist/tokens.js +34 -21
- package/package.json +2 -3
package/dist/control.js
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import express from 'express';
|
|
3
|
+
import { resolveImage, parseRegistryCredentials, imageLocation, REGISTRY_AUTH_SECRET, } from "./registry.js";
|
|
3
4
|
import multer from 'multer';
|
|
4
5
|
import fs from 'node:fs/promises';
|
|
5
6
|
import { createReadStream } from 'node:fs';
|
|
6
7
|
import { pipeline } from 'node:stream/promises';
|
|
7
8
|
import { saveImageArchive, inspectImage, validateImageArchive, hashArchive } from "./artifacts.js";
|
|
8
|
-
import sqlite3 from 'sqlite3';
|
|
9
|
-
import { open } from 'sqlite';
|
|
10
9
|
import { scanArchive } from "./archive.js";
|
|
11
10
|
import https from 'node:https';
|
|
12
11
|
import http from 'node:http';
|
|
13
12
|
import path from 'node:path';
|
|
14
13
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
15
14
|
import * as tar from 'tar';
|
|
16
|
-
import { openDb } from "./db.js";
|
|
15
|
+
import { openDb, openSnapshotDb } from "./db.js";
|
|
16
|
+
import { isUniqueViolation } from "./db-driver.js";
|
|
17
17
|
import { encrypt, decrypt, loadOrCreateMasterKey } from "./secrets.js";
|
|
18
18
|
import { buildDesiredState, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
|
|
19
|
-
import {
|
|
19
|
+
import { buildImage, unpackContext, run as runCommand } from "./build.js";
|
|
20
20
|
import { sentinelNames } from "./normalize.js";
|
|
21
21
|
import { ensureDir, errMessage, hashToken, id, now, token } from "./util.js";
|
|
22
22
|
import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, } from "./mesh.js";
|
|
23
23
|
import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
|
|
24
|
-
import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, } from "./protocol.js";
|
|
24
|
+
import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, BACKUP_COMMAND_TYPES, } from "./protocol.js";
|
|
25
|
+
import { BACKUP_SECRET_PROJECT, isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
|
|
26
|
+
import { createBackupService } from "./backup-scheduler.js";
|
|
27
|
+
import { createRestoreService } from "./backup-restore.js";
|
|
25
28
|
import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createToken, listTokens, parseTokenRequest, queryProject, recordEvent, revokeToken, } from "./tokens.js";
|
|
26
29
|
class WakeHub {
|
|
27
30
|
cursors = new Map();
|
|
@@ -119,7 +122,18 @@ function parseLiveRequest(value) {
|
|
|
119
122
|
Number(value.tail) <= 10_000)
|
|
120
123
|
return value;
|
|
121
124
|
if (value.operation === 'exec' &&
|
|
122
|
-
frameKeys(value, [
|
|
125
|
+
frameKeys(value, [
|
|
126
|
+
...common,
|
|
127
|
+
'service',
|
|
128
|
+
'command',
|
|
129
|
+
'interactive',
|
|
130
|
+
'tty',
|
|
131
|
+
'columns',
|
|
132
|
+
'rows',
|
|
133
|
+
'oneOff',
|
|
134
|
+
]) &&
|
|
135
|
+
(value.oneOff === undefined || value.oneOff === true) &&
|
|
136
|
+
(value.oneOff !== true || (value.interactive === false && value.tty === false)) &&
|
|
123
137
|
Array.isArray(value.command) &&
|
|
124
138
|
value.command.length > 0 &&
|
|
125
139
|
value.command.length <= 256 &&
|
|
@@ -394,7 +408,8 @@ const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map((s) => `'${s}'`).join(','
|
|
|
394
408
|
const PORT = Number(process.env.PORT || 8080);
|
|
395
409
|
export async function createApp(options = {}) {
|
|
396
410
|
const images = {
|
|
397
|
-
|
|
411
|
+
resolve: resolveImage,
|
|
412
|
+
build: buildImage,
|
|
398
413
|
save: saveImageArchive,
|
|
399
414
|
inspect: inspectImage,
|
|
400
415
|
validate: validateImageArchive,
|
|
@@ -411,9 +426,6 @@ export async function createApp(options = {}) {
|
|
|
411
426
|
});
|
|
412
427
|
const dbFile = path.join(DATA_DIR, 'tug.db');
|
|
413
428
|
const masterKeyFile = path.join(DATA_DIR, 'master.key');
|
|
414
|
-
const REGISTRY = process.env.TUG_REGISTRY?.trim() || '';
|
|
415
|
-
const REGISTRY_AUTH_PATH = process.env.TUG_REGISTRY_AUTH_PATH || null;
|
|
416
|
-
const REGISTRY_CONTAINER = process.env.TUG_REGISTRY_CONTAINER || null;
|
|
417
429
|
// Networking layers are opt-in; with all of them off the work response is unchanged.
|
|
418
430
|
const MESH = process.env.TUG_MESH === 'true';
|
|
419
431
|
const MESH_CIDR = process.env.TUG_MESH_CIDR || DEFAULT_MESH_CIDR;
|
|
@@ -422,56 +434,6 @@ export async function createApp(options = {}) {
|
|
|
422
434
|
const ACME_EMAIL = process.env.TUG_ACME_EMAIL || undefined;
|
|
423
435
|
if (MESH)
|
|
424
436
|
parseCidr(MESH_CIDR);
|
|
425
|
-
let htpasswdQueue = Promise.resolve();
|
|
426
|
-
function withHtpasswdLock(fn) {
|
|
427
|
-
const next = htpasswdQueue.catch(() => { }).then(fn);
|
|
428
|
-
htpasswdQueue = next.then(() => { }, () => { });
|
|
429
|
-
return next;
|
|
430
|
-
}
|
|
431
|
-
async function reloadRegistry() {
|
|
432
|
-
if (!REGISTRY_CONTAINER)
|
|
433
|
-
return;
|
|
434
|
-
await runCommand('docker', ['restart', REGISTRY_CONTAINER]).catch((e) => console.error(`registry reload failed: ${e.message}`));
|
|
435
|
-
}
|
|
436
|
-
async function upsertRegistryCredential(name, password) {
|
|
437
|
-
if (!REGISTRY || !REGISTRY_AUTH_PATH)
|
|
438
|
-
return;
|
|
439
|
-
await withHtpasswdLock(async () => {
|
|
440
|
-
const { out } = await runCommand('htpasswd', ['-nbB', name, password]);
|
|
441
|
-
const line = out.trim();
|
|
442
|
-
let existing = '';
|
|
443
|
-
try {
|
|
444
|
-
existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
|
|
445
|
-
}
|
|
446
|
-
catch { }
|
|
447
|
-
const lines = existing
|
|
448
|
-
.split('\n')
|
|
449
|
-
.filter(Boolean)
|
|
450
|
-
.filter((l) => !l.startsWith(`${name}:`));
|
|
451
|
-
lines.push(line);
|
|
452
|
-
await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
|
|
453
|
-
await fs.writeFile(REGISTRY_AUTH_PATH, `${lines.join('\n')}\n`);
|
|
454
|
-
});
|
|
455
|
-
await reloadRegistry();
|
|
456
|
-
}
|
|
457
|
-
async function removeRegistryCredential(name) {
|
|
458
|
-
if (!REGISTRY_AUTH_PATH)
|
|
459
|
-
return;
|
|
460
|
-
await withHtpasswdLock(async () => {
|
|
461
|
-
let existing = '';
|
|
462
|
-
try {
|
|
463
|
-
existing = await fs.readFile(REGISTRY_AUTH_PATH, 'utf8');
|
|
464
|
-
}
|
|
465
|
-
catch { }
|
|
466
|
-
const lines = existing
|
|
467
|
-
.split('\n')
|
|
468
|
-
.filter(Boolean)
|
|
469
|
-
.filter((l) => !l.startsWith(`${name}:`));
|
|
470
|
-
await ensureDir(path.dirname(REGISTRY_AUTH_PATH));
|
|
471
|
-
await fs.writeFile(REGISTRY_AUTH_PATH, lines.length ? `${lines.join('\n')}\n` : '');
|
|
472
|
-
});
|
|
473
|
-
await reloadRegistry();
|
|
474
|
-
}
|
|
475
437
|
const db = await (options.openDatabase || openDb)(dbFile);
|
|
476
438
|
const wakeHub = new WakeHub();
|
|
477
439
|
// Events are an operator-facing log, not a record of truth, so keep them bounded. `0` turns a limit off; anything that is
|
|
@@ -490,32 +452,37 @@ export async function createApp(options = {}) {
|
|
|
490
452
|
const EVENT_MAX_ROWS = envCount('TUG_EVENT_MAX_ROWS', 100_000);
|
|
491
453
|
const eventPruneIntervalMs = options.eventPruneIntervalMs ?? 60 * 60 * 1000;
|
|
492
454
|
let lastEventPrune = Date.now();
|
|
493
|
-
|
|
455
|
+
function pruneEvents() {
|
|
494
456
|
lastEventPrune = Date.now();
|
|
495
457
|
let removed = 0;
|
|
496
458
|
if (EVENT_RETENTION_DAYS > 0)
|
|
497
459
|
removed +=
|
|
498
|
-
|
|
460
|
+
db.run('DELETE FROM events WHERE created_at < ?', new Date(Date.now() - EVENT_RETENTION_DAYS * 86_400_000).toISOString()).changes ?? 0;
|
|
499
461
|
if (EVENT_MAX_ROWS > 0)
|
|
500
462
|
removed +=
|
|
501
|
-
|
|
463
|
+
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;
|
|
502
464
|
if (removed)
|
|
503
465
|
console.log(`removed ${removed} old events (kept ${EVENT_RETENTION_DAYS || 'any'} days, at most ${EVENT_MAX_ROWS || 'unlimited'} rows)`);
|
|
504
466
|
return removed;
|
|
505
467
|
}
|
|
506
|
-
|
|
468
|
+
try {
|
|
469
|
+
pruneEvents();
|
|
470
|
+
}
|
|
471
|
+
catch (e) {
|
|
472
|
+
console.error(`event pruning failed: ${errMessage(e)}`);
|
|
473
|
+
}
|
|
507
474
|
const masterKey = await loadOrCreateMasterKey(masterKeyFile);
|
|
508
|
-
const operator =
|
|
475
|
+
const operator = db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
|
|
509
476
|
if (!operator) {
|
|
510
477
|
const t = process.env.TUG_OPERATOR_TOKEN || token('tug_op');
|
|
511
|
-
|
|
478
|
+
db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'operator_token_hash', hashToken(t));
|
|
512
479
|
await fs
|
|
513
480
|
.writeFile(path.join(DATA_DIR, 'operator.token'), `${t}\n`, { mode: 0o600 })
|
|
514
481
|
.catch(() => { });
|
|
515
482
|
console.log(`operator token: ${t}`);
|
|
516
483
|
}
|
|
517
484
|
const app = express();
|
|
518
|
-
const authenticateAgent =
|
|
485
|
+
const authenticateAgent = (raw) => raw
|
|
519
486
|
? db.get('SELECT * FROM nodes WHERE agent_token_hash=?', hashToken(raw))
|
|
520
487
|
: undefined;
|
|
521
488
|
let restartRequired = false;
|
|
@@ -579,13 +546,21 @@ export async function createApp(options = {}) {
|
|
|
579
546
|
apiRoutes.set(key, role);
|
|
580
547
|
route(method, url, [guard(role, resolve ?? undefined), ...handlers]);
|
|
581
548
|
};
|
|
582
|
-
const deploymentProject =
|
|
583
|
-
|
|
584
|
-
|
|
549
|
+
const deploymentProject = (req) => db.get('SELECT project_id FROM deployments WHERE id=?', req.params.id)
|
|
550
|
+
?.project_id;
|
|
551
|
+
const commandProject = (req) => {
|
|
552
|
+
const row = db.get('SELECT project_id FROM commands WHERE id=?', req.params.id);
|
|
585
553
|
return row ? row.project_id : undefined;
|
|
586
554
|
};
|
|
587
555
|
/** Audit trail entry for a control action; bookkeeping failures are logged, never returned to the caller. */
|
|
588
|
-
const audit = (actor, event) =>
|
|
556
|
+
const audit = (actor, event) => {
|
|
557
|
+
try {
|
|
558
|
+
recordEvent(db, { ...event, actor: actor.name });
|
|
559
|
+
}
|
|
560
|
+
catch (e) {
|
|
561
|
+
console.error(`audit event failed: ${errMessage(e)}`);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
589
564
|
const projectScope = (actor, column) => actor.projects
|
|
590
565
|
? {
|
|
591
566
|
where: `${column} IN (${actor.projects.map(() => '?').join(',') || "''"})`,
|
|
@@ -594,22 +569,22 @@ export async function createApp(options = {}) {
|
|
|
594
569
|
: { where: '1=1', args: [] };
|
|
595
570
|
const authAgent = async (req, res, next) => {
|
|
596
571
|
const raw = (req.headers.authorization || '').replace(/^Bearer /, '');
|
|
597
|
-
const node =
|
|
572
|
+
const node = authenticateAgent(raw);
|
|
598
573
|
if (!node)
|
|
599
574
|
return res.status(401).json({ error: 'unauthorized' });
|
|
600
575
|
req.node = node;
|
|
601
576
|
next();
|
|
602
577
|
};
|
|
603
|
-
const wakeAll =
|
|
604
|
-
for (const node of
|
|
578
|
+
const wakeAll = (project) => {
|
|
579
|
+
for (const node of db.all('SELECT id FROM nodes'))
|
|
605
580
|
wakeHub.wake(node.id, project);
|
|
606
581
|
};
|
|
607
582
|
let endpointSnapshot = '';
|
|
608
|
-
|
|
609
|
-
const project =
|
|
583
|
+
function updateDeploymentConvergence(projectId) {
|
|
584
|
+
const project = db.get('SELECT * FROM projects WHERE id=?', projectId);
|
|
610
585
|
if (!project?.current_deployment_id)
|
|
611
586
|
return;
|
|
612
|
-
const ds =
|
|
587
|
+
const ds = db.get('SELECT * FROM desired_states WHERE deployment_id=?', project.current_deployment_id);
|
|
613
588
|
if (!ds)
|
|
614
589
|
return;
|
|
615
590
|
const state = JSON.parse(ds.state_json);
|
|
@@ -619,7 +594,7 @@ export async function createApp(options = {}) {
|
|
|
619
594
|
required.add(node);
|
|
620
595
|
if (!required.size)
|
|
621
596
|
return;
|
|
622
|
-
const statuses =
|
|
597
|
+
const statuses = db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
|
|
623
598
|
const byNode = new Map(statuses.map((s) => [s.node_id, s]));
|
|
624
599
|
const reportsRevision = (node) => state.revision
|
|
625
600
|
? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision
|
|
@@ -628,7 +603,7 @@ export async function createApp(options = {}) {
|
|
|
628
603
|
.filter((node) => reportsRevision(node) && byNode.get(node)?.health === 'failed')
|
|
629
604
|
.map((node) => byNode.get(node));
|
|
630
605
|
if (failedNodes.length) {
|
|
631
|
-
const marked =
|
|
606
|
+
const marked = db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', deploymentFailureMessage(failedNodes), now(), project.current_deployment_id, 'rolling_out');
|
|
632
607
|
if (marked.changes === 1)
|
|
633
608
|
scheduleAutoRollback(projectId, project.current_deployment_id, state);
|
|
634
609
|
}
|
|
@@ -637,29 +612,29 @@ export async function createApp(options = {}) {
|
|
|
637
612
|
: byNode.get(node)?.observed_version === state.version) &&
|
|
638
613
|
['serving', 'skipped'].includes(byNode.get(node)?.health ?? ''));
|
|
639
614
|
if (ok)
|
|
640
|
-
|
|
615
|
+
db.run('UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state=?', 'succeeded', now(), project.current_deployment_id, 'rolling_out');
|
|
641
616
|
}
|
|
642
617
|
/** Events control writes when it rolls back on its own; they are attributed to a reserved system actor. */
|
|
643
|
-
|
|
644
|
-
|
|
618
|
+
function recordAutoRollbackEvent(projectId, deploymentId, type, message, metadata = {}) {
|
|
619
|
+
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);
|
|
645
620
|
}
|
|
646
621
|
/**
|
|
647
622
|
* Creates a rollback deployment that copies the most recent earlier deployment's desired state under a
|
|
648
623
|
* new revision. Shared by `POST /api/v1/rollback` and automatic rollback; the caller holds the writer queue.
|
|
649
624
|
* `auto` only considers `succeeded` deployments and requires `failedDeploymentId` to still be current.
|
|
650
625
|
*/
|
|
651
|
-
|
|
626
|
+
function createRollback(project, opts) {
|
|
652
627
|
let lock;
|
|
653
628
|
try {
|
|
654
|
-
lock =
|
|
655
|
-
const active =
|
|
629
|
+
lock = acquireProjectLock(project, opts.auto ? 'auto-rollback' : 'rollback');
|
|
630
|
+
const active = db.get(`SELECT id,state FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) ORDER BY created_at DESC LIMIT 1`, project);
|
|
656
631
|
if (active && !opts.force)
|
|
657
632
|
throw httpError(409, `deployment ${active.id} is already ${active.state}; retry with force to rollback anyway`);
|
|
658
|
-
const current =
|
|
633
|
+
const current = db.get('SELECT current_deployment_id FROM projects WHERE id=?', project);
|
|
659
634
|
if (opts.failedDeploymentId && current?.current_deployment_id !== opts.failedDeploymentId)
|
|
660
635
|
throw httpError(409, `deployment ${opts.failedDeploymentId} is no longer the current deployment`);
|
|
661
636
|
const states = opts.auto ? `'succeeded'` : `'succeeded','rolling_out'`;
|
|
662
|
-
const previousDs =
|
|
637
|
+
const previousDs = db.get(`SELECT ds.*, d.version AS deployment_version FROM desired_states ds JOIN deployments d ON d.id=ds.deployment_id
|
|
663
638
|
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 || '');
|
|
664
639
|
if (!previousDs)
|
|
665
640
|
throw httpError(404, 'no previous deployment found');
|
|
@@ -667,17 +642,17 @@ export async function createApp(options = {}) {
|
|
|
667
642
|
const depId = id('dep');
|
|
668
643
|
const previousState = JSON.parse(previousDs.state_json);
|
|
669
644
|
previousState.revision = depId;
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
645
|
+
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);
|
|
646
|
+
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);
|
|
647
|
+
db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', depId, now(), project);
|
|
648
|
+
wakeAll(project);
|
|
674
649
|
return {
|
|
675
|
-
deployment:
|
|
650
|
+
deployment: db.get('SELECT * FROM deployments WHERE id=?', depId),
|
|
676
651
|
desiredState: previousState,
|
|
677
652
|
};
|
|
678
653
|
}
|
|
679
654
|
finally {
|
|
680
|
-
|
|
655
|
+
releaseLock(lock);
|
|
681
656
|
}
|
|
682
657
|
}
|
|
683
658
|
/**
|
|
@@ -690,9 +665,9 @@ export async function createApp(options = {}) {
|
|
|
690
665
|
if (state.compose['x-tug']?.rollback !== 'auto')
|
|
691
666
|
return;
|
|
692
667
|
void (async () => {
|
|
693
|
-
const failed =
|
|
668
|
+
const failed = db.get('SELECT * FROM deployments WHERE id=?', deploymentId);
|
|
694
669
|
if (failed?.version.startsWith(ROLLBACK_VERSION_PREFIX)) {
|
|
695
|
-
|
|
670
|
+
recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `rollback deployment ${failed.version} failed; not rolling back a rollback`);
|
|
696
671
|
return;
|
|
697
672
|
}
|
|
698
673
|
try {
|
|
@@ -701,27 +676,32 @@ export async function createApp(options = {}) {
|
|
|
701
676
|
auto: true,
|
|
702
677
|
failedDeploymentId: deploymentId,
|
|
703
678
|
}));
|
|
704
|
-
|
|
679
|
+
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}`, {
|
|
705
680
|
failedDeploymentId: deploymentId,
|
|
706
681
|
rollbackDeploymentId: out.deployment.id,
|
|
707
682
|
version: out.desiredState.version,
|
|
708
683
|
});
|
|
709
684
|
}
|
|
710
685
|
catch (e) {
|
|
711
|
-
|
|
686
|
+
try {
|
|
687
|
+
recordAutoRollbackEvent(projectId, deploymentId, 'deployment.auto_rollback_skipped', `deployment ${deploymentId} failed but automatic rollback was not possible: ${errMessage(e)}`);
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
/* Best-effort audit event. */
|
|
691
|
+
}
|
|
712
692
|
}
|
|
713
693
|
})().catch((e) => console.error('auto rollback failed:', errMessage(e)));
|
|
714
694
|
}
|
|
715
|
-
|
|
695
|
+
function currentDesired(projectId) {
|
|
716
696
|
return db.get('SELECT ds.* FROM desired_states ds JOIN projects p ON p.current_deployment_id=ds.deployment_id WHERE p.id=?', projectId);
|
|
717
697
|
}
|
|
718
698
|
const live = new LiveHub(async (actor, request) => {
|
|
719
699
|
assertProject(actor, request.project);
|
|
720
|
-
const row =
|
|
700
|
+
const row = currentDesired(request.project);
|
|
721
701
|
if (!row)
|
|
722
702
|
throw httpError(404, missingProject(request.project));
|
|
723
703
|
const desired = JSON.parse(row.state_json);
|
|
724
|
-
const nodes =
|
|
704
|
+
const nodes = db.all('SELECT * FROM nodes');
|
|
725
705
|
const eligible = new Set();
|
|
726
706
|
const services = request.operation === 'ps'
|
|
727
707
|
? Object.values(desired.services)
|
|
@@ -739,6 +719,17 @@ export async function createApp(options = {}) {
|
|
|
739
719
|
const node = nodes.find((node) => node.name === (request.node || [...eligible][0]));
|
|
740
720
|
if (!node)
|
|
741
721
|
throw httpError(404, 'no eligible node for this operation');
|
|
722
|
+
if (request.operation === 'exec' && request.oneOff)
|
|
723
|
+
audit(actor, {
|
|
724
|
+
type: 'job.run',
|
|
725
|
+
project: request.project,
|
|
726
|
+
message: `${actor.name} ran a one-off ${request.service} container on ${node.name}: ${request.command.join(' ').slice(0, 200)}`,
|
|
727
|
+
metadata: {
|
|
728
|
+
service: request.service,
|
|
729
|
+
node: node.name,
|
|
730
|
+
command: request.command.slice(0, 64),
|
|
731
|
+
},
|
|
732
|
+
});
|
|
742
733
|
return node;
|
|
743
734
|
}, options.liveStartTimeoutMs);
|
|
744
735
|
streamRuntimes.set(app, {
|
|
@@ -750,38 +741,44 @@ export async function createApp(options = {}) {
|
|
|
750
741
|
api('get', '/api/v1/live', null, (_req, res) => {
|
|
751
742
|
res.status(426).json({ error: 'WebSocket upgrade required' });
|
|
752
743
|
});
|
|
753
|
-
|
|
744
|
+
function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
|
|
754
745
|
const name = `project:${project}`;
|
|
755
746
|
const ts = now();
|
|
756
|
-
|
|
747
|
+
db.run('DELETE FROM locks WHERE expires_at < ?', ts);
|
|
757
748
|
const owner = `${operation}:${id('lock')}`;
|
|
758
749
|
try {
|
|
759
|
-
|
|
750
|
+
db.run('INSERT INTO locks(name,owner,expires_at,created_at) VALUES(?,?,?,?)', name, owner, new Date(Date.now() + ttlMs).toISOString(), ts);
|
|
760
751
|
}
|
|
761
752
|
catch (e) {
|
|
762
|
-
if (
|
|
753
|
+
if (!isUniqueViolation(e))
|
|
763
754
|
throw e;
|
|
764
|
-
const existing =
|
|
755
|
+
const existing = db.get('SELECT * FROM locks WHERE name=?', name);
|
|
765
756
|
const err = new Error(`project ${project} is locked by ${existing?.owner} until ${existing?.expires_at}`);
|
|
766
757
|
err.status = 409;
|
|
767
758
|
err.lock = existing;
|
|
768
759
|
throw err;
|
|
769
760
|
}
|
|
770
761
|
const heartbeat = setInterval(() => {
|
|
771
|
-
|
|
772
|
-
.run('UPDATE locks SET expires_at=? WHERE name=? AND owner=?', new Date(Date.now() + ttlMs).toISOString(), name, owner)
|
|
773
|
-
|
|
762
|
+
try {
|
|
763
|
+
db.run('UPDATE locks SET expires_at=? WHERE name=? AND owner=?', new Date(Date.now() + ttlMs).toISOString(), name, owner);
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
/* Best-effort bookkeeping. */
|
|
767
|
+
}
|
|
774
768
|
}, Math.max(10, Math.floor(ttlMs / 3)));
|
|
775
769
|
heartbeat.unref();
|
|
776
770
|
return { name, owner, heartbeat };
|
|
777
771
|
}
|
|
778
|
-
|
|
772
|
+
function releaseLock(lock) {
|
|
779
773
|
if (lock?.heartbeat)
|
|
780
774
|
clearInterval(lock.heartbeat);
|
|
781
775
|
if (lock)
|
|
782
|
-
|
|
783
|
-
.run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner)
|
|
784
|
-
|
|
776
|
+
try {
|
|
777
|
+
db.run('DELETE FROM locks WHERE name=? AND owner=?', lock.name, lock.owner);
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
/* Best-effort bookkeeping. */
|
|
781
|
+
}
|
|
785
782
|
}
|
|
786
783
|
function sendError(res, e) {
|
|
787
784
|
const err = e;
|
|
@@ -796,13 +793,13 @@ export async function createApp(options = {}) {
|
|
|
796
793
|
if (!project || !service || !Number.isInteger(replicas) || replicas < 1)
|
|
797
794
|
throw httpError(400, 'project, service and a positive integer replicas are required');
|
|
798
795
|
assertProject(req.actor, project);
|
|
799
|
-
const dsRow =
|
|
796
|
+
const dsRow = currentDesired(project);
|
|
800
797
|
if (!dsRow)
|
|
801
798
|
throw httpError(404, `project ${project} has no desired state`);
|
|
802
799
|
const state = JSON.parse(dsRow.state_json);
|
|
803
800
|
if (!state.services?.[service])
|
|
804
801
|
throw httpError(404, `service ${service} not found in project ${project}`);
|
|
805
|
-
const nodeRows =
|
|
802
|
+
const nodeRows = db.all('SELECT name,labels_json,last_seen_at FROM nodes ORDER BY created_at,name');
|
|
806
803
|
const nodeInfos = nodeRows
|
|
807
804
|
.filter((row) => !isStale(row))
|
|
808
805
|
.map((row) => ({
|
|
@@ -827,8 +824,8 @@ export async function createApp(options = {}) {
|
|
|
827
824
|
};
|
|
828
825
|
state.services[service].replicas = selected.length;
|
|
829
826
|
state.revision = id('rev');
|
|
830
|
-
|
|
831
|
-
|
|
827
|
+
db.run('UPDATE desired_states SET state_json=? WHERE id=?', JSON.stringify(state), dsRow.id);
|
|
828
|
+
wakeAll(project);
|
|
832
829
|
res.json({
|
|
833
830
|
ok: true,
|
|
834
831
|
project,
|
|
@@ -841,59 +838,59 @@ export async function createApp(options = {}) {
|
|
|
841
838
|
api('post', '/api/v1/projects', (req) => String(req.body?.name || 'site'), async (req, res) => {
|
|
842
839
|
const name = req.body.name || 'site';
|
|
843
840
|
const ts = now();
|
|
844
|
-
|
|
845
|
-
res.json(
|
|
841
|
+
db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', name, name, ts, ts);
|
|
842
|
+
res.json(db.get('SELECT * FROM projects WHERE id=?', name));
|
|
846
843
|
});
|
|
847
|
-
api('get', '/api/v1/projects/:project', (req) => req.params.project, async (req, res) => res.json(
|
|
844
|
+
api('get', '/api/v1/projects/:project', (req) => req.params.project, async (req, res) => res.json(db.get('SELECT * FROM projects WHERE id=?', req.params.project)));
|
|
848
845
|
/** A node whose agent has been seen before but not within TUG_STALE_SECONDS; never-seen (just registered) nodes stay schedulable. */
|
|
849
846
|
const isStale = (row) => !!row.last_seen_at &&
|
|
850
847
|
Date.parse(row.last_seen_at) < Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000;
|
|
851
848
|
const NODE_INFO_SQL = 'SELECT id,name,address,public_address,overlay_ip,labels_json,status,created_at,updated_at FROM nodes WHERE id=?';
|
|
852
849
|
/** Allocates the node's stable overlay IP on first registration (retrying if a concurrent register took it). */
|
|
853
|
-
|
|
850
|
+
function ensureOverlayIp(nodeId) {
|
|
854
851
|
if (!MESH)
|
|
855
852
|
return;
|
|
856
853
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
857
|
-
const row =
|
|
854
|
+
const row = db.get('SELECT overlay_ip FROM nodes WHERE id=?', nodeId);
|
|
858
855
|
if (row?.overlay_ip)
|
|
859
856
|
return;
|
|
860
|
-
const used =
|
|
857
|
+
const used = db
|
|
858
|
+
.all('SELECT overlay_ip FROM nodes WHERE overlay_ip IS NOT NULL')
|
|
859
|
+
.map((r) => r.overlay_ip);
|
|
861
860
|
try {
|
|
862
|
-
|
|
861
|
+
db.run('UPDATE nodes SET overlay_ip=? WHERE id=? AND overlay_ip IS NULL', allocateOverlayIp(MESH_CIDR, used), nodeId);
|
|
863
862
|
return;
|
|
864
863
|
}
|
|
865
864
|
catch (e) {
|
|
866
|
-
if (
|
|
865
|
+
if (!isUniqueViolation(e))
|
|
867
866
|
throw e;
|
|
868
867
|
}
|
|
869
868
|
}
|
|
870
869
|
throw new Error('could not allocate an overlay address');
|
|
871
870
|
}
|
|
872
871
|
/** Stores (or refreshes) the node's WireGuard peer entry; the private key never reaches control. */
|
|
873
|
-
|
|
872
|
+
function syncWgPeer(nodeId, publicKey) {
|
|
874
873
|
if (!MESH)
|
|
875
874
|
return;
|
|
876
|
-
const node =
|
|
877
|
-
const existing =
|
|
875
|
+
const node = db.get('SELECT * FROM nodes WHERE id=?', nodeId);
|
|
876
|
+
const existing = db.get('SELECT * FROM wg_peers WHERE node_id=?', nodeId);
|
|
878
877
|
const key = publicKey ?? existing?.public_key;
|
|
879
878
|
if (!node?.overlay_ip || !key)
|
|
880
879
|
return;
|
|
881
|
-
|
|
880
|
+
db.run(`INSERT INTO wg_peers(node_id,public_key,overlay_ip,endpoint,allowed_ips,updated_at) VALUES(?,?,?,?,?,?)
|
|
882
881
|
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());
|
|
883
882
|
}
|
|
884
883
|
async function registerNode({ name, address, publicAddress, wgPublicKey, labels, }) {
|
|
885
884
|
if (wgPublicKey !== undefined && !isWgKey(wgPublicKey))
|
|
886
885
|
throw httpError(400, 'wgPublicKey must be a base64 WireGuard public key');
|
|
887
886
|
const agentToken = token('tug_agent');
|
|
888
|
-
const registryToken = token('tug_reg');
|
|
889
887
|
const ts = now();
|
|
890
|
-
|
|
891
|
-
VALUES(
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
const row = await db.get(NODE_INFO_SQL, name);
|
|
888
|
+
db.run(`INSERT INTO nodes(id,name,address,public_address,status,labels_json,agent_token_hash,created_at,updated_at)
|
|
889
|
+
VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,address=excluded.address,public_address=excluded.public_address,labels_json=excluded.labels_json,agent_token_hash=excluded.agent_token_hash,updated_at=excluded.updated_at`, name, name, address || null, publicAddress || null, 'registered', JSON.stringify(Array.isArray(labels) ? labels : []), hashToken(agentToken), ts, ts);
|
|
890
|
+
ensureOverlayIp(name);
|
|
891
|
+
syncWgPeer(name, wgPublicKey);
|
|
892
|
+
wakeAll();
|
|
893
|
+
const row = db.get(NODE_INFO_SQL, name);
|
|
897
894
|
if (!row)
|
|
898
895
|
throw httpError(500, 'node not found after registration');
|
|
899
896
|
const node = {
|
|
@@ -907,7 +904,7 @@ export async function createApp(options = {}) {
|
|
|
907
904
|
created_at: row.created_at,
|
|
908
905
|
updated_at: row.updated_at,
|
|
909
906
|
};
|
|
910
|
-
return { node, agentToken
|
|
907
|
+
return { node, agentToken };
|
|
911
908
|
}
|
|
912
909
|
api('post', '/api/v1/nodes/register', null, async (req, res) => {
|
|
913
910
|
const name = req.body.name;
|
|
@@ -934,18 +931,18 @@ export async function createApp(options = {}) {
|
|
|
934
931
|
const uses = Math.max(1, Number(req.body.uses ?? 1));
|
|
935
932
|
const enrollToken = token('tug_enroll');
|
|
936
933
|
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
937
|
-
|
|
938
|
-
res.json({ enrollToken, name, usesRemaining: uses, expiresAt
|
|
934
|
+
db.run('INSERT INTO enroll_tokens(token_hash,name,uses_remaining,expires_at,created_at) VALUES(?,?,?,?,?)', hashToken(enrollToken), name, uses, expiresAt, now());
|
|
935
|
+
res.json({ enrollToken, name, usesRemaining: uses, expiresAt });
|
|
939
936
|
});
|
|
940
937
|
post('/agent/v1/enroll', async (req, res) => {
|
|
941
938
|
const body = req.body;
|
|
942
939
|
const raw = String(body.enrollToken || (req.headers.authorization || '').replace(/^Bearer /, '')).trim();
|
|
943
940
|
if (!raw)
|
|
944
941
|
return res.status(400).json({ error: 'enrollToken required' });
|
|
945
|
-
const row =
|
|
942
|
+
const row = db.get('SELECT * FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
|
|
946
943
|
if (!row || row.uses_remaining < 1 || row.expires_at < now()) {
|
|
947
944
|
if (row)
|
|
948
|
-
|
|
945
|
+
db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
|
|
949
946
|
return res.status(401).json({ error: 'invalid or expired enroll token' });
|
|
950
947
|
}
|
|
951
948
|
const name = row.name || body.name;
|
|
@@ -968,14 +965,14 @@ export async function createApp(options = {}) {
|
|
|
968
965
|
}
|
|
969
966
|
const remaining = row.uses_remaining - 1;
|
|
970
967
|
if (remaining < 1)
|
|
971
|
-
|
|
968
|
+
db.run('DELETE FROM enroll_tokens WHERE token_hash=?', hashToken(raw));
|
|
972
969
|
else
|
|
973
|
-
|
|
974
|
-
const enrolled = { ...result
|
|
970
|
+
db.run('UPDATE enroll_tokens SET uses_remaining=? WHERE token_hash=?', remaining, hashToken(raw));
|
|
971
|
+
const enrolled = { ...result };
|
|
975
972
|
res.json(enrolled);
|
|
976
973
|
});
|
|
977
974
|
api('get', '/api/v1/nodes', null, async (req, res) => {
|
|
978
|
-
const rows =
|
|
975
|
+
const rows = db.all('SELECT id,name,address,public_address,overlay_ip,ingress,ingress_status_json,status,labels_json,last_seen_at,created_at,updated_at FROM nodes ORDER BY name');
|
|
979
976
|
res.json({
|
|
980
977
|
nodes: rows.map((row) => {
|
|
981
978
|
const { labels_json, ...rest } = row;
|
|
@@ -983,30 +980,13 @@ export async function createApp(options = {}) {
|
|
|
983
980
|
}),
|
|
984
981
|
});
|
|
985
982
|
});
|
|
986
|
-
|
|
987
|
-
const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
|
|
988
|
-
if (!node)
|
|
989
|
-
return res.status(404).json({ error: 'node not found' });
|
|
990
|
-
const registryToken = token('tug_reg');
|
|
991
|
-
await db.run('UPDATE nodes SET registry_token_hash=?,updated_at=? WHERE id=?', hashToken(registryToken), now(), node.id);
|
|
992
|
-
await upsertRegistryCredential(node.name, registryToken);
|
|
993
|
-
res.json({ ok: true, node: node.name, registryToken });
|
|
994
|
-
});
|
|
995
|
-
api('post', '/api/v1/nodes/:name/revoke-registry-token', null, async (req, res) => {
|
|
996
|
-
const node = await db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
|
|
997
|
-
if (!node)
|
|
998
|
-
return res.status(404).json({ error: 'node not found' });
|
|
999
|
-
await db.run('UPDATE nodes SET registry_token_hash=NULL,updated_at=? WHERE id=?', now(), node.id);
|
|
1000
|
-
await removeRegistryCredential(node.name);
|
|
1001
|
-
res.json({ ok: true, node: node.name, revoked: true });
|
|
1002
|
-
});
|
|
1003
|
-
async function findNode(nameOrId) {
|
|
983
|
+
function findNode(nameOrId) {
|
|
1004
984
|
return db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', nameOrId, nameOrId);
|
|
1005
985
|
}
|
|
1006
|
-
|
|
986
|
+
function queueCommand(nodeId, projectId, type, payload = {}, deadline = null) {
|
|
1007
987
|
const commandId = id('cmd');
|
|
1008
988
|
const ts = now();
|
|
1009
|
-
|
|
989
|
+
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, deadline, ts, ts);
|
|
1010
990
|
wakeHub.wake(nodeId, projectId ?? undefined);
|
|
1011
991
|
return commandId;
|
|
1012
992
|
}
|
|
@@ -1015,19 +995,19 @@ export async function createApp(options = {}) {
|
|
|
1015
995
|
api('post', '/api/v1/nodes/:name/rotate-wg-key', null, async (req, res) => {
|
|
1016
996
|
if (!MESH)
|
|
1017
997
|
return res.status(409).json({ error: 'the WireGuard mesh is not enabled (TUG_MESH=true)' });
|
|
1018
|
-
const node =
|
|
998
|
+
const node = findNode(req.params.name);
|
|
1019
999
|
if (!node)
|
|
1020
1000
|
return res.status(404).json({ error: 'node not found' });
|
|
1021
|
-
res.json({ commandId:
|
|
1001
|
+
res.json({ commandId: queueCommand(node.id, null, 'wg.rotate-key'), nodeId: node.id });
|
|
1022
1002
|
});
|
|
1023
1003
|
// Removes a retired node: its token, WireGuard peer, statuses and queued commands. Refused while a current deployment still places
|
|
1024
1004
|
// something on it (redeploy without it first) or while its agent is still reporting, unless force=true.
|
|
1025
1005
|
api('delete', '/api/v1/nodes/:name', null, async (req, res) => {
|
|
1026
|
-
const node =
|
|
1006
|
+
const node = db.get('SELECT id,name,last_seen_at FROM nodes WHERE name=? OR id=?', req.params.name, req.params.name);
|
|
1027
1007
|
if (!node)
|
|
1028
1008
|
return res.status(404).json({ error: 'node not found' });
|
|
1029
1009
|
const referenced = [];
|
|
1030
|
-
for (const state of
|
|
1010
|
+
for (const state of currentDesiredStates()) {
|
|
1031
1011
|
for (const [name, svc] of Object.entries(state.services || {}))
|
|
1032
1012
|
if (svc.placement?.nodes?.includes(node.name))
|
|
1033
1013
|
referenced.push(`${state.project}/${name}`);
|
|
@@ -1043,24 +1023,24 @@ export async function createApp(options = {}) {
|
|
|
1043
1023
|
return res.status(409).json({
|
|
1044
1024
|
error: `node ${node.name} is still reporting; stop its agent first or pass force=true`,
|
|
1045
1025
|
});
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1026
|
+
db.run('DELETE FROM commands WHERE node_id=?', node.id);
|
|
1027
|
+
db.run('DELETE FROM node_statuses WHERE node_id=?', node.id);
|
|
1028
|
+
db.run('DELETE FROM wg_peers WHERE node_id=?', node.id);
|
|
1029
|
+
db.run('DELETE FROM nodes WHERE id=?', node.id);
|
|
1030
|
+
audit(req.actor, {
|
|
1051
1031
|
type: 'node.removed',
|
|
1052
1032
|
message: `node ${node.name} removed`,
|
|
1053
1033
|
metadata: { node: node.name },
|
|
1054
1034
|
});
|
|
1055
|
-
|
|
1035
|
+
wakeAll();
|
|
1056
1036
|
res.json({ ok: true, node: node.name });
|
|
1057
1037
|
});
|
|
1058
1038
|
api('post', '/api/v1/nodes/:name/ingress', null, async (req, res) => {
|
|
1059
|
-
const node =
|
|
1039
|
+
const node = findNode(req.params.name);
|
|
1060
1040
|
if (!node)
|
|
1061
1041
|
return res.status(404).json({ error: 'node not found' });
|
|
1062
1042
|
const enabled = req.body.enabled !== false;
|
|
1063
|
-
|
|
1043
|
+
db.run('UPDATE nodes SET ingress=?,updated_at=? WHERE id=?', enabled ? 1 : 0, now(), node.id);
|
|
1064
1044
|
wakeHub.wake(node.id);
|
|
1065
1045
|
res.json({ ok: true, node: node.name, ingress: enabled });
|
|
1066
1046
|
});
|
|
@@ -1071,30 +1051,18 @@ export async function createApp(options = {}) {
|
|
|
1071
1051
|
if (!isWgKey(publicKey))
|
|
1072
1052
|
return res.status(400).json({ error: 'publicKey must be a base64 WireGuard public key' });
|
|
1073
1053
|
try {
|
|
1074
|
-
|
|
1075
|
-
|
|
1054
|
+
syncWgPeer(req.node.id, publicKey);
|
|
1055
|
+
wakeAll();
|
|
1076
1056
|
res.json({ ok: true });
|
|
1077
1057
|
}
|
|
1078
1058
|
catch (e) {
|
|
1079
|
-
sendError(res,
|
|
1080
|
-
? httpError(409, 'public key already registered to another node')
|
|
1081
|
-
: e);
|
|
1059
|
+
sendError(res, isUniqueViolation(e) ? httpError(409, 'public key already registered to another node') : e);
|
|
1082
1060
|
}
|
|
1083
1061
|
});
|
|
1084
|
-
post('/api/v1/registry/auth', async (req, res) => {
|
|
1085
|
-
const username = req.body.username;
|
|
1086
|
-
const password = req.body.password;
|
|
1087
|
-
const node = username && password
|
|
1088
|
-
? 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))
|
|
1089
|
-
: null;
|
|
1090
|
-
if (!node)
|
|
1091
|
-
return res.status(401).json({ ok: false, error: 'invalid registry credentials' });
|
|
1092
|
-
res.json({ ok: true, node: node.name });
|
|
1093
|
-
});
|
|
1094
1062
|
api('post', '/api/v1/tokens', null, async (req, res) => {
|
|
1095
1063
|
const request = parseTokenRequest(req.body);
|
|
1096
|
-
const created =
|
|
1097
|
-
|
|
1064
|
+
const created = createToken(db, request, req.actor.name);
|
|
1065
|
+
audit(req.actor, {
|
|
1098
1066
|
type: 'token.create',
|
|
1099
1067
|
message: `token ${created.name} (${created.role}${created.projects ? `, projects: ${created.projects.join(', ')}` : ''}) created by ${req.actor.name}`,
|
|
1100
1068
|
metadata: {
|
|
@@ -1106,30 +1074,210 @@ export async function createApp(options = {}) {
|
|
|
1106
1074
|
});
|
|
1107
1075
|
res.json(created);
|
|
1108
1076
|
});
|
|
1109
|
-
api('get', '/api/v1/tokens', null, async (_req, res) => res.json({ tokens:
|
|
1077
|
+
api('get', '/api/v1/tokens', null, async (_req, res) => res.json({ tokens: listTokens(db) }));
|
|
1110
1078
|
api('delete', '/api/v1/tokens/:name', null, async (req, res) => {
|
|
1111
|
-
if (!
|
|
1079
|
+
if (!revokeToken(db, req.params.name))
|
|
1112
1080
|
return res.status(404).json({ error: 'token not found' });
|
|
1113
|
-
|
|
1081
|
+
audit(req.actor, {
|
|
1114
1082
|
type: 'token.revoke',
|
|
1115
1083
|
message: `token ${req.params.name} revoked by ${req.actor.name}`,
|
|
1116
1084
|
metadata: { name: req.params.name },
|
|
1117
1085
|
});
|
|
1118
1086
|
res.json({ ok: true, name: req.params.name });
|
|
1119
1087
|
});
|
|
1088
|
+
const backupDestination = () => db.get("SELECT * FROM backup_destination WHERE id='default'");
|
|
1089
|
+
const backupEnv = () => {
|
|
1090
|
+
const row = backupDestination();
|
|
1091
|
+
if (!row)
|
|
1092
|
+
return {};
|
|
1093
|
+
const env = {};
|
|
1094
|
+
for (const name of JSON.parse(row.env_names_json)) {
|
|
1095
|
+
const secret = db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', BACKUP_SECRET_PROJECT, name);
|
|
1096
|
+
if (secret)
|
|
1097
|
+
env[name] = decrypt(secret.encrypted_value, masterKey);
|
|
1098
|
+
}
|
|
1099
|
+
return env;
|
|
1100
|
+
};
|
|
1101
|
+
const backups = createBackupService({
|
|
1102
|
+
db,
|
|
1103
|
+
queueCommand,
|
|
1104
|
+
resticImage: process.env.TUG_RESTIC_IMAGE,
|
|
1105
|
+
// Defensive: the agent already redacts, but a stored error must never carry a destination credential.
|
|
1106
|
+
redact: (text) => redactSecrets(text, Object.values(backupEnv())),
|
|
1107
|
+
});
|
|
1108
|
+
app.locals.backups = backups;
|
|
1109
|
+
const restores = createRestoreService({ db, backups, wake: (project) => wakeAll(project) });
|
|
1110
|
+
app.locals.restores = restores;
|
|
1111
|
+
const backupTickMs = Number(process.env.TUG_BACKUP_TICK_MS) || 30_000;
|
|
1112
|
+
const backupTimer = setInterval(() => {
|
|
1113
|
+
try {
|
|
1114
|
+
backups.tick();
|
|
1115
|
+
restores.advance();
|
|
1116
|
+
}
|
|
1117
|
+
catch (e) {
|
|
1118
|
+
console.error(`backup scheduler failed: ${errMessage(e)}`);
|
|
1119
|
+
}
|
|
1120
|
+
}, backupTickMs);
|
|
1121
|
+
backupTimer.unref();
|
|
1122
|
+
try {
|
|
1123
|
+
backups.tick(); // catch up on slots missed while control was down
|
|
1124
|
+
restores.advance(); // re-adopt unfinished restores and keep their projects locked
|
|
1125
|
+
}
|
|
1126
|
+
catch (e) {
|
|
1127
|
+
console.error(`backup scheduler failed: ${errMessage(e)}`);
|
|
1128
|
+
}
|
|
1129
|
+
api('post', '/api/v1/backups/run', bodyProject, async (req, res) => {
|
|
1130
|
+
const project = String(req.body.project || 'site');
|
|
1131
|
+
const targets = req.body.targets;
|
|
1132
|
+
if (targets !== undefined &&
|
|
1133
|
+
(!Array.isArray(targets) || targets.some((t) => typeof t !== 'string')))
|
|
1134
|
+
return res.status(400).json({ error: 'targets must be a list of service or volume names' });
|
|
1135
|
+
if (!db.get('SELECT 1 FROM projects WHERE id=?', project))
|
|
1136
|
+
return res.status(404).json({ error: `project ${project} not found` });
|
|
1137
|
+
const result = backups.runNow(project, targets, req.actor.name);
|
|
1138
|
+
if (!result.runs.length)
|
|
1139
|
+
return res.status(result.errors.length ? 409 : 404).json({
|
|
1140
|
+
error: result.errors.join('; ') || `project ${project} has no backup targets`,
|
|
1141
|
+
});
|
|
1142
|
+
res.json(result);
|
|
1143
|
+
});
|
|
1144
|
+
api('post', '/api/v1/restores', bodyProject, async (req, res) => {
|
|
1145
|
+
const body = req.body ?? {};
|
|
1146
|
+
const project = String(body.project || 'site');
|
|
1147
|
+
if (typeof body.target !== 'string' || !body.target)
|
|
1148
|
+
return res
|
|
1149
|
+
.status(400)
|
|
1150
|
+
.json({ error: 'target is required, such as service:db or volume:uploads' });
|
|
1151
|
+
const target = body.target.includes(':')
|
|
1152
|
+
? body.target
|
|
1153
|
+
: (db.get('SELECT target FROM backup_schedules WHERE project_id=? AND enabled=1 AND target LIKE ?', project, `%:${body.target}`)?.target ?? body.target);
|
|
1154
|
+
const request = {
|
|
1155
|
+
project,
|
|
1156
|
+
target,
|
|
1157
|
+
from: typeof body.from === 'string' ? body.from : undefined,
|
|
1158
|
+
scratch: body.scratch === true,
|
|
1159
|
+
safetyBackup: body.safetyBackup !== false,
|
|
1160
|
+
writers: Array.isArray(body.writers) ? body.writers.map(String) : undefined,
|
|
1161
|
+
noWriters: body.noWriters === true,
|
|
1162
|
+
actor: req.actor.name,
|
|
1163
|
+
};
|
|
1164
|
+
const plan = restores.plan(request);
|
|
1165
|
+
if ('error' in plan)
|
|
1166
|
+
return res.status(plan.status).json({ error: plan.error });
|
|
1167
|
+
if (body.confirm !== true)
|
|
1168
|
+
return res.json({ confirmed: false, plan });
|
|
1169
|
+
const started = restores.start(request, plan);
|
|
1170
|
+
if ('error' in started)
|
|
1171
|
+
return res.status(started.status).json({ error: started.error });
|
|
1172
|
+
res.json({ confirmed: true, plan, ...started });
|
|
1173
|
+
});
|
|
1174
|
+
api('get', '/api/v1/restores', queryProject, async (req, res) => res.json({ restores: restores.list(String(req.query.project || 'site')) }));
|
|
1175
|
+
api('post', '/api/v1/restores/:id/release', null, async (req, res) => {
|
|
1176
|
+
const result = restores.releaseInterrupted(req.params.id);
|
|
1177
|
+
if ('error' in result)
|
|
1178
|
+
return res.status(result.status).json({ error: result.error });
|
|
1179
|
+
res.json(result);
|
|
1180
|
+
});
|
|
1181
|
+
api('post', '/api/v1/restores/:id/resume', null, async (req, res) => {
|
|
1182
|
+
const result = restores.resume(req.params.id);
|
|
1183
|
+
if ('error' in result)
|
|
1184
|
+
return res.status(result.status).json({ error: result.error });
|
|
1185
|
+
res.json(result);
|
|
1186
|
+
});
|
|
1187
|
+
api('get', '/api/v1/backups', queryProject, async (req, res) => {
|
|
1188
|
+
const project = String(req.query.project || 'site');
|
|
1189
|
+
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
|
|
1190
|
+
res.json({
|
|
1191
|
+
project,
|
|
1192
|
+
destinationConfigured: Boolean(backupDestination()),
|
|
1193
|
+
...backups.list(project, limit),
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
api('get', '/api/v1/backup/destination', null, async (_req, res) => {
|
|
1197
|
+
const row = backupDestination();
|
|
1198
|
+
res.json(row
|
|
1199
|
+
? {
|
|
1200
|
+
configured: true,
|
|
1201
|
+
url: row.url,
|
|
1202
|
+
envNames: JSON.parse(row.env_names_json),
|
|
1203
|
+
checkedAt: row.checked_at,
|
|
1204
|
+
checkResult: row.check_result_json ? JSON.parse(row.check_result_json) : null,
|
|
1205
|
+
updatedAt: row.updated_at,
|
|
1206
|
+
}
|
|
1207
|
+
: { configured: false });
|
|
1208
|
+
});
|
|
1209
|
+
api('post', '/api/v1/backup/destination', null, async (req, res) => {
|
|
1210
|
+
let parsed;
|
|
1211
|
+
try {
|
|
1212
|
+
parsed = parseBackupDestination(req.body);
|
|
1213
|
+
}
|
|
1214
|
+
catch (e) {
|
|
1215
|
+
return res.status(400).json({ error: errMessage(e) });
|
|
1216
|
+
}
|
|
1217
|
+
const ts = now();
|
|
1218
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1219
|
+
try {
|
|
1220
|
+
db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
|
|
1221
|
+
for (const [name, value] of Object.entries(parsed.env))
|
|
1222
|
+
db.run('INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)', id('sec'), BACKUP_SECRET_PROJECT, name, encrypt(value, masterKey), 1, ts, ts);
|
|
1223
|
+
db.run(`INSERT INTO backup_destination(id,url,env_names_json,checked_at,check_result_json,updated_at) VALUES('default',?,?,NULL,NULL,?)
|
|
1224
|
+
ON CONFLICT(id) DO UPDATE SET url=excluded.url,env_names_json=excluded.env_names_json,checked_at=NULL,check_result_json=NULL,updated_at=excluded.updated_at`, parsed.url, JSON.stringify(Object.keys(parsed.env).sort()), ts);
|
|
1225
|
+
db.exec('COMMIT');
|
|
1226
|
+
}
|
|
1227
|
+
catch (e) {
|
|
1228
|
+
db.exec('ROLLBACK');
|
|
1229
|
+
throw e;
|
|
1230
|
+
}
|
|
1231
|
+
audit(req.actor, {
|
|
1232
|
+
type: 'backup.destination_set',
|
|
1233
|
+
message: `backup destination set to ${parsed.url} by ${req.actor.name}`,
|
|
1234
|
+
metadata: { url: parsed.url, envNames: Object.keys(parsed.env).sort() },
|
|
1235
|
+
});
|
|
1236
|
+
res.json({ ok: true, url: parsed.url, envNames: Object.keys(parsed.env).sort() });
|
|
1237
|
+
});
|
|
1238
|
+
api('delete', '/api/v1/backup/destination', null, async (req, res) => {
|
|
1239
|
+
db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
|
|
1240
|
+
const removed = db.run("DELETE FROM backup_destination WHERE id='default'");
|
|
1241
|
+
if (removed.changes)
|
|
1242
|
+
audit(req.actor, {
|
|
1243
|
+
type: 'backup.destination_removed',
|
|
1244
|
+
message: `backup destination removed by ${req.actor.name}`,
|
|
1245
|
+
});
|
|
1246
|
+
res.json({ ok: true, removed: removed.changes > 0 });
|
|
1247
|
+
});
|
|
1248
|
+
api('get', '/api/v1/backup/key', null, async (req, res) => {
|
|
1249
|
+
const project = String(req.query.project || '');
|
|
1250
|
+
if (!PROJECT_NAME_PATTERN.test(project) || isReservedProject(project))
|
|
1251
|
+
return res.status(400).json({ error: 'project is required' });
|
|
1252
|
+
if (!db.get('SELECT 1 FROM projects WHERE id=?', project))
|
|
1253
|
+
return res.status(404).json({ error: `project ${project} not found` });
|
|
1254
|
+
const destination = backupDestination();
|
|
1255
|
+
audit(req.actor, {
|
|
1256
|
+
type: 'backup.key_read',
|
|
1257
|
+
project,
|
|
1258
|
+
message: `repository password for ${project} read by ${req.actor.name}`,
|
|
1259
|
+
});
|
|
1260
|
+
res.json({
|
|
1261
|
+
project,
|
|
1262
|
+
repository: destination ? projectRepository(destination.url, project) : null,
|
|
1263
|
+
password: repositoryPassword(masterKey, project),
|
|
1264
|
+
});
|
|
1265
|
+
});
|
|
1120
1266
|
api('post', '/api/v1/secrets', bodyProject, async (req, res) => {
|
|
1121
1267
|
const { project = 'site', name, value } = req.body;
|
|
1268
|
+
if (isReservedProject(String(project)))
|
|
1269
|
+
return res.status(400).json({ error: `project names starting with _ are reserved` });
|
|
1122
1270
|
if (!name || value == null)
|
|
1123
1271
|
return res.status(400).json({ error: 'name and value required' });
|
|
1124
1272
|
let lock;
|
|
1125
1273
|
try {
|
|
1126
|
-
lock =
|
|
1127
|
-
const old =
|
|
1274
|
+
lock = acquireProjectLock(project, 'secret.set');
|
|
1275
|
+
const old = db.get('SELECT version FROM secrets WHERE project_id=? AND name=?', project, name);
|
|
1128
1276
|
const ts = now();
|
|
1129
|
-
|
|
1277
|
+
db.run(`INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)
|
|
1130
1278
|
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);
|
|
1131
|
-
|
|
1132
|
-
|
|
1279
|
+
wakeAll(project);
|
|
1280
|
+
audit(req.actor, {
|
|
1133
1281
|
type: 'secret.set',
|
|
1134
1282
|
project,
|
|
1135
1283
|
message: `secret ${name} set to version ${(old?.version || 0) + 1} by ${req.actor.name}`,
|
|
@@ -1141,18 +1289,22 @@ export async function createApp(options = {}) {
|
|
|
1141
1289
|
sendError(res, e);
|
|
1142
1290
|
}
|
|
1143
1291
|
finally {
|
|
1144
|
-
|
|
1292
|
+
releaseLock(lock);
|
|
1145
1293
|
}
|
|
1146
1294
|
});
|
|
1147
|
-
api('get', '/api/v1/secrets', queryProject, async (req, res) =>
|
|
1148
|
-
|
|
1149
|
-
|
|
1295
|
+
api('get', '/api/v1/secrets', queryProject, async (req, res) => isReservedProject(String(req.query.project || 'site'))
|
|
1296
|
+
? res.status(400).json({ error: 'project names starting with _ are reserved' })
|
|
1297
|
+
: res.json({
|
|
1298
|
+
secrets: db.all('SELECT project_id,name,version,updated_at FROM secrets WHERE project_id=? ORDER BY name', String(req.query.project || 'site')),
|
|
1299
|
+
}));
|
|
1150
1300
|
api('delete', '/api/v1/secrets/:name', queryProject, async (req, res) => {
|
|
1151
1301
|
const project = String(req.query.project || 'site');
|
|
1152
|
-
|
|
1302
|
+
if (isReservedProject(project))
|
|
1303
|
+
return res.status(400).json({ error: 'project names starting with _ are reserved' });
|
|
1304
|
+
const removed = db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, req.params.name);
|
|
1153
1305
|
if (removed.changes) {
|
|
1154
|
-
|
|
1155
|
-
|
|
1306
|
+
wakeAll(project);
|
|
1307
|
+
audit(req.actor, {
|
|
1156
1308
|
type: 'secret.delete',
|
|
1157
1309
|
project,
|
|
1158
1310
|
message: `secret ${req.params.name} deleted by ${req.actor.name}`,
|
|
@@ -1163,33 +1315,35 @@ export async function createApp(options = {}) {
|
|
|
1163
1315
|
});
|
|
1164
1316
|
const artifactDir = path.join(DATA_DIR, 'images');
|
|
1165
1317
|
await ensureDir(artifactDir);
|
|
1166
|
-
|
|
1318
|
+
function retainedStates() {
|
|
1167
1319
|
// All published history is retained. This conservative superset protects failed/lagging versions too.
|
|
1168
|
-
return
|
|
1320
|
+
return db
|
|
1321
|
+
.all('SELECT * FROM desired_states')
|
|
1322
|
+
.map((row) => JSON.parse(row.state_json));
|
|
1169
1323
|
}
|
|
1170
1324
|
async function collectArtifacts() {
|
|
1171
|
-
const states =
|
|
1325
|
+
const states = retainedStates();
|
|
1172
1326
|
const refs = new Set(states.flatMap((state) => Object.values(state.services).flatMap((service) => service.artifact ? [service.artifact.buildId] : [])));
|
|
1173
1327
|
const imageRefs = new Set(states.flatMap((state) => Object.values(state.services).map((service) => service.image)));
|
|
1174
|
-
const rows =
|
|
1328
|
+
const rows = db.all('SELECT * FROM builds');
|
|
1175
1329
|
for (const row of rows) {
|
|
1176
1330
|
if (refs.has(row.id) || imageRefs.has(row.image_ref))
|
|
1177
1331
|
continue;
|
|
1178
|
-
const active =
|
|
1332
|
+
const active = db.get("SELECT 1 FROM deployments WHERE id=? AND state IN ('uploading','building')", row.deployment_id);
|
|
1179
1333
|
if (active)
|
|
1180
1334
|
continue;
|
|
1181
1335
|
await fs.rm(path.join(DATA_DIR, row.artifact_path || path.join('images', `${row.id}.tar.gz`)), { force: true });
|
|
1182
1336
|
await images.run('docker', ['image', 'rm', row.image_ref]).catch(() => { });
|
|
1183
|
-
|
|
1337
|
+
db.run('DELETE FROM builds WHERE id=?', row.id);
|
|
1184
1338
|
}
|
|
1185
|
-
const remaining =
|
|
1339
|
+
const remaining = db.all('SELECT id FROM builds');
|
|
1186
1340
|
const owned = new Set(remaining.flatMap((row) => [`${row.id}.tar.gz`, `${row.id}.tar.gz.tmp`]));
|
|
1187
1341
|
for (const name of await fs.readdir(artifactDir))
|
|
1188
1342
|
if (!owned.has(name))
|
|
1189
1343
|
await fs.rm(path.join(artifactDir, name), { force: true });
|
|
1190
1344
|
}
|
|
1191
1345
|
// Previous-process builds cannot finish after a restart; clear their unpublished files.
|
|
1192
|
-
|
|
1346
|
+
db.run("UPDATE deployments SET state='build_failed',error_message='control restarted during build' WHERE state IN ('uploading','building')");
|
|
1193
1347
|
await collectArtifacts();
|
|
1194
1348
|
const receiveDeployment = upload.any();
|
|
1195
1349
|
api('post', '/api/v1/deployments', null, (req, res) => {
|
|
@@ -1230,7 +1384,7 @@ export async function createApp(options = {}) {
|
|
|
1230
1384
|
throw httpError(400, 'invalid project or version');
|
|
1231
1385
|
if (version.startsWith(ROLLBACK_VERSION_PREFIX))
|
|
1232
1386
|
throw httpError(400, `version cannot start with "${ROLLBACK_VERSION_PREFIX}" (reserved for rollback deployments)`);
|
|
1233
|
-
const nodeRows =
|
|
1387
|
+
const nodeRows = db.all('SELECT name,labels_json,last_seen_at,created_at FROM nodes ORDER BY created_at,name');
|
|
1234
1388
|
// Stale nodes (agent not seen recently) are not scheduled onto, so a dead host cannot hold a rollout open.
|
|
1235
1389
|
const nodeInfos = nodeRows
|
|
1236
1390
|
.filter((row) => !isStale(row))
|
|
@@ -1263,18 +1417,6 @@ export async function createApp(options = {}) {
|
|
|
1263
1417
|
}
|
|
1264
1418
|
}
|
|
1265
1419
|
const nodes = nodeRows.map((row) => ({ name: row.name }));
|
|
1266
|
-
for (const vol of Object.values(compose.volumes || {})) {
|
|
1267
|
-
const xe = vol?.['x-tug'];
|
|
1268
|
-
if (!xe ||
|
|
1269
|
-
(xe.type !== 'nfs' && xe.driver !== 'nfs') ||
|
|
1270
|
-
xe.address ||
|
|
1271
|
-
!xe.placement?.nodes?.[0])
|
|
1272
|
-
continue;
|
|
1273
|
-
const exporter = await db.get('SELECT address,overlay_ip FROM nodes WHERE name=?', xe.placement.nodes[0]);
|
|
1274
|
-
const addr = (MESH && exporter?.overlay_ip) || exporter?.address;
|
|
1275
|
-
if (addr)
|
|
1276
|
-
xe.address = addr;
|
|
1277
|
-
}
|
|
1278
1420
|
try {
|
|
1279
1421
|
validateCompose(compose, nodes.map((n) => n.name));
|
|
1280
1422
|
}
|
|
@@ -1283,8 +1425,13 @@ export async function createApp(options = {}) {
|
|
|
1283
1425
|
}
|
|
1284
1426
|
// The CLI interpolates control secrets as sentinels; a reference to a secret that is gone would deploy an empty value.
|
|
1285
1427
|
const referenced = sentinelNames(compose);
|
|
1428
|
+
if (referenced.includes(REGISTRY_AUTH_SECRET))
|
|
1429
|
+
throw httpError(400, REGISTRY_AUTH_SECRET +
|
|
1430
|
+
' is reserved for registry authentication and cannot be used in service definitions');
|
|
1286
1431
|
if (referenced.length) {
|
|
1287
|
-
const known = new Set(
|
|
1432
|
+
const known = new Set(db
|
|
1433
|
+
.all('SELECT name FROM secrets WHERE project_id=?', project)
|
|
1434
|
+
.map((row) => row.name));
|
|
1288
1435
|
const missing = referenced.filter((name) => !known.has(name));
|
|
1289
1436
|
if (missing.length)
|
|
1290
1437
|
throw httpError(400, `secrets not found in project ${project}: ${missing.join(', ')}; create them with tug secret-set`);
|
|
@@ -1320,30 +1467,29 @@ export async function createApp(options = {}) {
|
|
|
1320
1467
|
}
|
|
1321
1468
|
if (files.length !== (local ? local.length : context ? 1 : 0))
|
|
1322
1469
|
throw httpError(400, 'unexpected deployment upload');
|
|
1323
|
-
if (
|
|
1324
|
-
throw httpError(400, 'build services require a source context or localImages upload
|
|
1470
|
+
if (builtServices.length && !context && !local)
|
|
1471
|
+
throw httpError(400, 'build services require a source context or localImages upload');
|
|
1325
1472
|
for (const [svcName, svc] of Object.entries(compose.services)) {
|
|
1326
1473
|
const ing = svc['x-tug']?.ingress;
|
|
1327
1474
|
if (ing?.tls !== 'custom')
|
|
1328
1475
|
continue;
|
|
1329
1476
|
for (const secret of [ing.cert, ing.key])
|
|
1330
|
-
if (!
|
|
1477
|
+
if (!db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret ?? null))
|
|
1331
1478
|
throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with secret-set first`);
|
|
1332
1479
|
}
|
|
1333
|
-
lock =
|
|
1334
|
-
const active =
|
|
1480
|
+
lock = acquireProjectLock(project, 'deploy');
|
|
1481
|
+
const active = db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
|
|
1335
1482
|
if (active)
|
|
1336
1483
|
throw httpError(409, `deployment ${active.id} is already ${active.state}`);
|
|
1337
1484
|
depId = id('dep');
|
|
1338
1485
|
const ts = now();
|
|
1339
|
-
|
|
1340
|
-
|
|
1486
|
+
db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
|
|
1487
|
+
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);
|
|
1341
1488
|
const desired = buildDesiredState({
|
|
1342
1489
|
project,
|
|
1343
1490
|
version,
|
|
1344
1491
|
revision: depId,
|
|
1345
1492
|
compose,
|
|
1346
|
-
registry: REGISTRY,
|
|
1347
1493
|
defaultNode: nodes[0]?.name || null,
|
|
1348
1494
|
});
|
|
1349
1495
|
if (context) {
|
|
@@ -1351,14 +1497,10 @@ export async function createApp(options = {}) {
|
|
|
1351
1497
|
await unpackContext(context.path, contextDir);
|
|
1352
1498
|
}
|
|
1353
1499
|
for (const service of builtServices) {
|
|
1354
|
-
if (!contextDir && !local) {
|
|
1355
|
-
desired.services[service].image = `${REGISTRY}/${project}/${service}:${version}`;
|
|
1356
|
-
continue;
|
|
1357
|
-
} // Explicit registry/no-build-upload means an existing pushed image.
|
|
1358
1500
|
const spec = desired.services[service];
|
|
1359
1501
|
const buildId = id('build');
|
|
1360
1502
|
const imageRef = spec.image;
|
|
1361
|
-
|
|
1503
|
+
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);
|
|
1362
1504
|
let info;
|
|
1363
1505
|
let importedClientRef;
|
|
1364
1506
|
const relative = path.join('images', `${buildId}.tar.gz`);
|
|
@@ -1392,21 +1534,12 @@ export async function createApp(options = {}) {
|
|
|
1392
1534
|
service,
|
|
1393
1535
|
buildSpec: compose.services[service].build,
|
|
1394
1536
|
imageRef,
|
|
1395
|
-
push: false,
|
|
1396
1537
|
});
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
}
|
|
1403
|
-
else {
|
|
1404
|
-
info = await images.save(imageRef, temporary);
|
|
1405
|
-
await fs.rename(temporary, final);
|
|
1406
|
-
const artifact = { buildId, imageRef, ...info };
|
|
1407
|
-
spec.artifact = artifact;
|
|
1408
|
-
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);
|
|
1409
|
-
}
|
|
1538
|
+
info = await images.save(imageRef, temporary);
|
|
1539
|
+
await fs.rename(temporary, final);
|
|
1540
|
+
const artifact = { buildId, imageRef, ...info };
|
|
1541
|
+
spec.artifact = artifact;
|
|
1542
|
+
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);
|
|
1410
1543
|
}
|
|
1411
1544
|
finally {
|
|
1412
1545
|
if (importedClientRef)
|
|
@@ -1414,43 +1547,61 @@ export async function createApp(options = {}) {
|
|
|
1414
1547
|
await fs.rm(temporary, { force: true });
|
|
1415
1548
|
}
|
|
1416
1549
|
}
|
|
1550
|
+
const registrySecret = db.get('SELECT * FROM secrets WHERE project_id=? AND name=?', project, REGISTRY_AUTH_SECRET);
|
|
1551
|
+
const credentials = parseRegistryCredentials(registrySecret ? decrypt(registrySecret.encrypted_value, masterKey) : undefined);
|
|
1552
|
+
const resolved = new Map();
|
|
1553
|
+
for (const [name, spec] of Object.entries(desired.services)) {
|
|
1554
|
+
if (spec.artifact)
|
|
1555
|
+
continue;
|
|
1556
|
+
const image = spec.image;
|
|
1557
|
+
if (!resolved.has(image))
|
|
1558
|
+
resolved.set(image, await images.resolve(image, credentials));
|
|
1559
|
+
spec.image = resolved.get(image);
|
|
1560
|
+
desired.compose.services[name].image = spec.image;
|
|
1561
|
+
}
|
|
1417
1562
|
const buildLock = lock;
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
const
|
|
1563
|
+
const publishingId = depId;
|
|
1564
|
+
await withPublication(() => {
|
|
1565
|
+
const owned = db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now());
|
|
1566
|
+
const pending = db.get('SELECT 1 FROM deployments WHERE id=? AND state=?', publishingId, 'building');
|
|
1421
1567
|
if (!owned || !pending)
|
|
1422
1568
|
throw httpError(409, 'deployment was cancelled or its build lease was lost');
|
|
1423
|
-
|
|
1569
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1424
1570
|
try {
|
|
1425
|
-
const published =
|
|
1571
|
+
const published = db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), publishingId);
|
|
1426
1572
|
if (published.changes !== 1 ||
|
|
1427
|
-
!
|
|
1573
|
+
!db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now()))
|
|
1428
1574
|
throw httpError(409, 'deployment was cancelled or its build lease was lost');
|
|
1429
|
-
|
|
1575
|
+
db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, publishingId, version, JSON.stringify(desired), ts);
|
|
1430
1576
|
for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
|
|
1431
1577
|
const lp = volumeSpec?.['x-tug'] || {};
|
|
1432
1578
|
const nodeName = lp.placement?.nodes?.[0] || null;
|
|
1433
1579
|
const node = nodeName
|
|
1434
|
-
?
|
|
1580
|
+
? db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName)
|
|
1435
1581
|
: null;
|
|
1436
|
-
|
|
1582
|
+
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);
|
|
1437
1583
|
}
|
|
1438
|
-
|
|
1439
|
-
|
|
1584
|
+
db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', publishingId, now(), project);
|
|
1585
|
+
db.exec('COMMIT');
|
|
1440
1586
|
}
|
|
1441
1587
|
catch (e) {
|
|
1442
|
-
|
|
1588
|
+
try {
|
|
1589
|
+
db.exec('ROLLBACK');
|
|
1590
|
+
}
|
|
1591
|
+
catch {
|
|
1592
|
+
/* Preserve the publication error. */
|
|
1593
|
+
}
|
|
1443
1594
|
throw e;
|
|
1444
1595
|
}
|
|
1445
1596
|
});
|
|
1446
|
-
|
|
1597
|
+
audit(req.actor, {
|
|
1447
1598
|
type: 'deploy',
|
|
1448
1599
|
project,
|
|
1449
1600
|
deploymentId: depId,
|
|
1450
1601
|
message: `deployment ${depId} (${version}) started by ${req.actor.name}`,
|
|
1451
1602
|
metadata: { version },
|
|
1452
1603
|
});
|
|
1453
|
-
|
|
1604
|
+
wakeAll(project);
|
|
1454
1605
|
responseBody = {
|
|
1455
1606
|
deployment: { id: depId, project, version, state: 'rolling_out', actor: req.actor.name },
|
|
1456
1607
|
desiredState: desired,
|
|
@@ -1458,13 +1609,16 @@ export async function createApp(options = {}) {
|
|
|
1458
1609
|
}
|
|
1459
1610
|
catch (e) {
|
|
1460
1611
|
if (depId)
|
|
1461
|
-
|
|
1462
|
-
.run("UPDATE deployments SET state='build_failed',error_message=?,updated_at=? WHERE id=? AND state='building'", errMessage(e), now(), depId)
|
|
1463
|
-
|
|
1612
|
+
try {
|
|
1613
|
+
db.run("UPDATE deployments SET state='build_failed',error_message=?,updated_at=? WHERE id=? AND state='building'", errMessage(e), now(), depId);
|
|
1614
|
+
}
|
|
1615
|
+
catch {
|
|
1616
|
+
/* Best-effort bookkeeping. */
|
|
1617
|
+
}
|
|
1464
1618
|
responseError = e;
|
|
1465
1619
|
}
|
|
1466
1620
|
finally {
|
|
1467
|
-
|
|
1621
|
+
releaseLock(lock);
|
|
1468
1622
|
await Promise.all(files.map((file) => fs.rm(file.path, { force: true }).catch(() => { })));
|
|
1469
1623
|
if (contextDir)
|
|
1470
1624
|
await fs.rm(contextDir, { recursive: true, force: true });
|
|
@@ -1476,12 +1630,12 @@ export async function createApp(options = {}) {
|
|
|
1476
1630
|
res.json(responseBody);
|
|
1477
1631
|
}
|
|
1478
1632
|
get('/agent/v1/images/:buildId', authAgent, async (req, res) => {
|
|
1479
|
-
const states =
|
|
1633
|
+
const states = retainedStates();
|
|
1480
1634
|
const allowed = states.some((state) => Object.values(state.services).some((service) => service.artifact?.buildId === req.params.buildId &&
|
|
1481
1635
|
(!service.placement.nodes?.length || service.placement.nodes.includes(req.node.name))));
|
|
1482
1636
|
if (!allowed)
|
|
1483
1637
|
return res.status(404).json({ error: 'image not found' });
|
|
1484
|
-
const row =
|
|
1638
|
+
const row = db.get('SELECT * FROM builds WHERE id=? AND state=?', req.params.buildId, 'complete');
|
|
1485
1639
|
if (!row?.artifact_path)
|
|
1486
1640
|
return res.status(404).json({ error: 'image not found' });
|
|
1487
1641
|
try {
|
|
@@ -1499,17 +1653,17 @@ export async function createApp(options = {}) {
|
|
|
1499
1653
|
res.status(404).json({ error: 'image artifact unavailable' });
|
|
1500
1654
|
}
|
|
1501
1655
|
});
|
|
1502
|
-
api('get', '/api/v1/deployments/:id', deploymentProject, async (req, res) => res.json(
|
|
1503
|
-
api('post', '/api/v1/deployments/:id/cancel', deploymentProject, async (req, res) => withPublication(
|
|
1504
|
-
const row =
|
|
1656
|
+
api('get', '/api/v1/deployments/:id', deploymentProject, async (req, res) => res.json(db.get('SELECT * FROM deployments WHERE id=?', req.params.id)));
|
|
1657
|
+
api('post', '/api/v1/deployments/:id/cancel', deploymentProject, async (req, res) => withPublication(() => {
|
|
1658
|
+
const row = db.get('SELECT * FROM deployments WHERE id=?', req.params.id);
|
|
1505
1659
|
if (!row)
|
|
1506
1660
|
return res.status(404).json({ error: 'deployment not found' });
|
|
1507
1661
|
if (!ACTIVE_DEPLOYMENT_STATES.includes(row.state))
|
|
1508
1662
|
return res.status(409).json({ error: `deployment is ${row.state}` });
|
|
1509
|
-
const result =
|
|
1663
|
+
const result = db.run(`UPDATE deployments SET state=?,updated_at=? WHERE id=? AND state IN (${ACTIVE_STATES_SQL})`, 'cancelled', now(), req.params.id);
|
|
1510
1664
|
if (result.changes !== 1)
|
|
1511
1665
|
return res.status(409).json({ error: 'deployment is no longer active' });
|
|
1512
|
-
|
|
1666
|
+
audit(req.actor, {
|
|
1513
1667
|
type: 'cancel',
|
|
1514
1668
|
project: row.project_id,
|
|
1515
1669
|
deploymentId: row.id,
|
|
@@ -1520,11 +1674,11 @@ export async function createApp(options = {}) {
|
|
|
1520
1674
|
api('post', '/api/v1/rollback', bodyProject, async (req, res) => withWriter(async () => {
|
|
1521
1675
|
const project = req.body.project || 'site';
|
|
1522
1676
|
try {
|
|
1523
|
-
const out =
|
|
1677
|
+
const out = createRollback(project, {
|
|
1524
1678
|
actor: req.actor.name,
|
|
1525
1679
|
force: Boolean(req.body.force),
|
|
1526
1680
|
});
|
|
1527
|
-
|
|
1681
|
+
audit(req.actor, {
|
|
1528
1682
|
type: 'rollback',
|
|
1529
1683
|
project,
|
|
1530
1684
|
deploymentId: out.deployment.id,
|
|
@@ -1538,15 +1692,15 @@ export async function createApp(options = {}) {
|
|
|
1538
1692
|
}
|
|
1539
1693
|
}));
|
|
1540
1694
|
api('get', '/api/v1/volumes', queryProject, async (req, res) => res.json({
|
|
1541
|
-
volumes:
|
|
1695
|
+
volumes: db.all('SELECT * FROM volumes WHERE project_id=? ORDER BY name', String(req.query.project || 'site')),
|
|
1542
1696
|
}));
|
|
1543
1697
|
api('delete', '/api/v1/volumes/:name', queryProject, async (req, res) => {
|
|
1544
1698
|
const project = String(req.query.project || 'site');
|
|
1545
1699
|
const deleteData = String(req.query.deleteData || 'false') === 'true';
|
|
1546
1700
|
let lock;
|
|
1547
1701
|
try {
|
|
1548
|
-
lock = deleteData ?
|
|
1549
|
-
const volume =
|
|
1702
|
+
lock = deleteData ? acquireProjectLock(project, 'volume.delete') : null;
|
|
1703
|
+
const volume = db.get('SELECT * FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
|
|
1550
1704
|
if (!volume)
|
|
1551
1705
|
return res.status(404).json({ error: 'volume not found' });
|
|
1552
1706
|
const config = JSON.parse(volume.config_json || '{}');
|
|
@@ -1554,7 +1708,7 @@ export async function createApp(options = {}) {
|
|
|
1554
1708
|
return res
|
|
1555
1709
|
.status(409)
|
|
1556
1710
|
.json({ error: 'refusing to delete persistent volume without deleteData=true' });
|
|
1557
|
-
const ds =
|
|
1711
|
+
const ds = currentDesired(project);
|
|
1558
1712
|
if (ds) {
|
|
1559
1713
|
const state = JSON.parse(ds.state_json);
|
|
1560
1714
|
const inUse = Object.entries(state.compose?.services || {})
|
|
@@ -1567,19 +1721,19 @@ export async function createApp(options = {}) {
|
|
|
1567
1721
|
}
|
|
1568
1722
|
let commandId = null;
|
|
1569
1723
|
if (deleteData && volume.node_id) {
|
|
1570
|
-
commandId =
|
|
1724
|
+
commandId = queueCommand(volume.node_id, project, 'volume.delete', {
|
|
1571
1725
|
project,
|
|
1572
1726
|
volume: req.params.name,
|
|
1573
1727
|
});
|
|
1574
1728
|
}
|
|
1575
|
-
|
|
1729
|
+
db.run('DELETE FROM volumes WHERE project_id=? AND name=?', project, req.params.name);
|
|
1576
1730
|
res.json({ ok: true, commandId, deletedMetadata: true, deleteData });
|
|
1577
1731
|
}
|
|
1578
1732
|
catch (e) {
|
|
1579
1733
|
sendError(res, e);
|
|
1580
1734
|
}
|
|
1581
1735
|
finally {
|
|
1582
|
-
|
|
1736
|
+
releaseLock(lock);
|
|
1583
1737
|
}
|
|
1584
1738
|
});
|
|
1585
1739
|
api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
|
|
@@ -1590,8 +1744,8 @@ export async function createApp(options = {}) {
|
|
|
1590
1744
|
const staging = path.join(backupDir, `${backupId}-staging`);
|
|
1591
1745
|
await fs.rm(staging, { recursive: true, force: true });
|
|
1592
1746
|
await ensureDir(staging);
|
|
1593
|
-
const snapshotPath = path.join(staging, 'tug.db')
|
|
1594
|
-
|
|
1747
|
+
const snapshotPath = path.join(staging, 'tug.db');
|
|
1748
|
+
db.run('VACUUM INTO ?', snapshotPath);
|
|
1595
1749
|
await fs.copyFile(masterKeyFile, path.join(staging, 'master.key'));
|
|
1596
1750
|
await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
|
|
1597
1751
|
await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control' }, null, 2));
|
|
@@ -1603,7 +1757,7 @@ export async function createApp(options = {}) {
|
|
|
1603
1757
|
'images',
|
|
1604
1758
|
]);
|
|
1605
1759
|
await fs.rm(staging, { recursive: true, force: true });
|
|
1606
|
-
|
|
1760
|
+
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());
|
|
1607
1761
|
res.json({ backupId, artifactRef, state: 'completed' });
|
|
1608
1762
|
}));
|
|
1609
1763
|
api('post', '/api/v1/control/restore', null, async (req, res) => withWriter(async () => {
|
|
@@ -1629,25 +1783,21 @@ export async function createApp(options = {}) {
|
|
|
1629
1783
|
const key = (await fs.readFile(path.join(restoreDir, 'master.key'), 'utf8')).trim();
|
|
1630
1784
|
if (!/^[A-Za-z0-9+/]{43}=$/.test(key))
|
|
1631
1785
|
throw new Error('invalid backup master key');
|
|
1632
|
-
snapshot =
|
|
1633
|
-
|
|
1634
|
-
driver: sqlite3.Database,
|
|
1635
|
-
mode: sqlite3.OPEN_READONLY,
|
|
1636
|
-
});
|
|
1637
|
-
const integrity = await snapshot.get('PRAGMA integrity_check');
|
|
1786
|
+
snapshot = openSnapshotDb(path.join(restoreDir, 'tug.db'));
|
|
1787
|
+
const integrity = snapshot.get('PRAGMA integrity_check');
|
|
1638
1788
|
if (integrity?.integrity_check !== 'ok')
|
|
1639
1789
|
throw new Error('backup database failed integrity check');
|
|
1640
|
-
if (!
|
|
1790
|
+
if (!snapshot.get("SELECT 1 FROM meta WHERE key='operator_token_hash'"))
|
|
1641
1791
|
throw new Error('backup database has no operator identity');
|
|
1642
|
-
for (const secret of
|
|
1792
|
+
for (const secret of snapshot.all('SELECT * FROM secrets'))
|
|
1643
1793
|
decrypt(secret.encrypted_value, key);
|
|
1644
|
-
const states =
|
|
1794
|
+
const states = snapshot.all('SELECT * FROM desired_states');
|
|
1645
1795
|
for (const stateRow of states) {
|
|
1646
1796
|
const state = JSON.parse(stateRow.state_json);
|
|
1647
1797
|
for (const service of Object.values(state.services)) {
|
|
1648
1798
|
if (!service.artifact)
|
|
1649
1799
|
continue;
|
|
1650
|
-
const build =
|
|
1800
|
+
const build = snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
|
|
1651
1801
|
if (!build?.artifact_path ||
|
|
1652
1802
|
!/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
|
|
1653
1803
|
throw new Error('backup contains an unsafe or missing image reference');
|
|
@@ -1659,12 +1809,12 @@ export async function createApp(options = {}) {
|
|
|
1659
1809
|
throw new Error('backup image archive checksum mismatch');
|
|
1660
1810
|
}
|
|
1661
1811
|
}
|
|
1662
|
-
|
|
1812
|
+
snapshot.close();
|
|
1663
1813
|
snapshot = undefined;
|
|
1664
1814
|
await ensureDir(path.join(restoreDir, 'images'));
|
|
1665
1815
|
// Validation completes before changing any live file. Stop DB-dependent work until restart.
|
|
1666
1816
|
restartRequired = true;
|
|
1667
|
-
|
|
1817
|
+
db.close();
|
|
1668
1818
|
await fs.rm(`${dbFile}-wal`, { force: true });
|
|
1669
1819
|
await fs.rm(`${dbFile}-shm`, { force: true });
|
|
1670
1820
|
await fs.cp(path.join(restoreDir, 'images'), artifactDir, { recursive: true });
|
|
@@ -1673,15 +1823,19 @@ export async function createApp(options = {}) {
|
|
|
1673
1823
|
res.json({ ok: true, restored: true, restartRequired: true });
|
|
1674
1824
|
}
|
|
1675
1825
|
catch (e) {
|
|
1676
|
-
|
|
1677
|
-
|
|
1826
|
+
try {
|
|
1827
|
+
snapshot?.close();
|
|
1828
|
+
}
|
|
1829
|
+
catch {
|
|
1830
|
+
/* Preserve the restore error. */
|
|
1831
|
+
}
|
|
1678
1832
|
res.status(restartRequired ? 500 : 400).json({ error: errMessage(e), restartRequired });
|
|
1679
1833
|
}
|
|
1680
1834
|
}));
|
|
1681
|
-
|
|
1835
|
+
function commandForService({ project, service, type, payload, }) {
|
|
1682
1836
|
if (!service)
|
|
1683
1837
|
throw httpError(400, 'service required');
|
|
1684
|
-
const ds =
|
|
1838
|
+
const ds = currentDesired(project);
|
|
1685
1839
|
if (!ds)
|
|
1686
1840
|
throw httpError(404, missingProject(project));
|
|
1687
1841
|
const state = JSON.parse(ds.state_json);
|
|
@@ -1691,22 +1845,22 @@ export async function createApp(options = {}) {
|
|
|
1691
1845
|
const nodeName = svc.placement?.nodes?.[0];
|
|
1692
1846
|
if (!nodeName)
|
|
1693
1847
|
throw httpError(400, 'service has no explicit node placement');
|
|
1694
|
-
const node =
|
|
1848
|
+
const node = db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
|
|
1695
1849
|
if (!node)
|
|
1696
1850
|
throw httpError(404, 'node not found');
|
|
1697
|
-
const cmdId =
|
|
1851
|
+
const cmdId = queueCommand(node.id, project, type, { project, service, ...payload });
|
|
1698
1852
|
return { commandId: cmdId, nodeId: node.id };
|
|
1699
1853
|
}
|
|
1700
1854
|
api('post', '/api/v1/restart', bodyProject, async (req, res) => {
|
|
1701
1855
|
try {
|
|
1702
1856
|
const project = req.body.project || 'site';
|
|
1703
|
-
const queued =
|
|
1857
|
+
const queued = commandForService({
|
|
1704
1858
|
project,
|
|
1705
1859
|
service: req.body.service,
|
|
1706
1860
|
type: 'service.restart',
|
|
1707
1861
|
payload: {},
|
|
1708
1862
|
});
|
|
1709
|
-
|
|
1863
|
+
audit(req.actor, {
|
|
1710
1864
|
type: 'restart',
|
|
1711
1865
|
project,
|
|
1712
1866
|
message: `service ${req.body.service} restart requested by ${req.actor.name}`,
|
|
@@ -1724,7 +1878,7 @@ export async function createApp(options = {}) {
|
|
|
1724
1878
|
const tail = Number(req.body.tail || 200);
|
|
1725
1879
|
if (!service)
|
|
1726
1880
|
return res.status(400).json({ error: 'service required' });
|
|
1727
|
-
const ds =
|
|
1881
|
+
const ds = currentDesired(project);
|
|
1728
1882
|
if (!ds)
|
|
1729
1883
|
return res.status(404).json({ error: missingProject(project) });
|
|
1730
1884
|
const state = JSON.parse(ds.state_json);
|
|
@@ -1734,25 +1888,25 @@ export async function createApp(options = {}) {
|
|
|
1734
1888
|
const nodeName = svc.placement?.nodes?.[0];
|
|
1735
1889
|
if (!nodeName)
|
|
1736
1890
|
return res.status(400).json({ error: 'service has no explicit node placement' });
|
|
1737
|
-
const node =
|
|
1891
|
+
const node = db.get('SELECT id FROM nodes WHERE name=? OR id=?', nodeName, nodeName);
|
|
1738
1892
|
if (!node)
|
|
1739
1893
|
return res.status(404).json({ error: 'node not found' });
|
|
1740
|
-
const cmdId =
|
|
1894
|
+
const cmdId = queueCommand(node.id, project, 'logs.fetch', { project, service, tail });
|
|
1741
1895
|
res.json({ commandId: cmdId, nodeId: node.id });
|
|
1742
1896
|
});
|
|
1743
|
-
api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(
|
|
1897
|
+
api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(db.get('SELECT * FROM commands WHERE id=?', req.params.id)));
|
|
1744
1898
|
api('get', '/api/v1/status', null, async (req, res) => {
|
|
1745
1899
|
const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
|
|
1746
1900
|
const cutoff = new Date(Date.now() - staleSeconds * 1000).toISOString();
|
|
1747
|
-
|
|
1901
|
+
db.run("UPDATE nodes SET status='stale',updated_at=? WHERE last_seen_at IS NOT NULL AND last_seen_at < ?", now(), cutoff);
|
|
1748
1902
|
const projects = projectScope(req.actor, 'id');
|
|
1749
1903
|
const deployments = projectScope(req.actor, 'project_id');
|
|
1750
1904
|
const statuses = projectScope(req.actor, 'project_id');
|
|
1751
1905
|
res.json({
|
|
1752
|
-
projects:
|
|
1753
|
-
deployments:
|
|
1754
|
-
nodes:
|
|
1755
|
-
statuses:
|
|
1906
|
+
projects: db.all(`SELECT * FROM projects WHERE ${projects.where}`, ...projects.args),
|
|
1907
|
+
deployments: db.all(`SELECT * FROM deployments WHERE ${deployments.where} ORDER BY created_at DESC LIMIT 20`, ...deployments.args),
|
|
1908
|
+
nodes: db.all('SELECT id,name,status,last_seen_at FROM nodes'),
|
|
1909
|
+
statuses: db.all(`SELECT * FROM node_statuses WHERE ${statuses.where}`, ...statuses.args),
|
|
1756
1910
|
});
|
|
1757
1911
|
});
|
|
1758
1912
|
api('get', '/api/v1/events', null, async (req, res) => {
|
|
@@ -1762,8 +1916,8 @@ export async function createApp(options = {}) {
|
|
|
1762
1916
|
const limit = Math.min(Number(req.query.limit || 100), 500);
|
|
1763
1917
|
const scope = projectScope(req.actor, 'project_id'); // scoped tokens never see node-level or other projects' events
|
|
1764
1918
|
const events = project
|
|
1765
|
-
?
|
|
1766
|
-
:
|
|
1919
|
+
? db.all('SELECT * FROM events WHERE project_id=? ORDER BY created_at DESC LIMIT ?', project, limit)
|
|
1920
|
+
: db.all(`SELECT * FROM events WHERE ${scope.where} ORDER BY created_at DESC LIMIT ?`, ...scope.args, limit);
|
|
1767
1921
|
res.json({ events });
|
|
1768
1922
|
});
|
|
1769
1923
|
api('get', '/api/v1/diagnostics', null, async (req, res) => {
|
|
@@ -1777,10 +1931,10 @@ export async function createApp(options = {}) {
|
|
|
1777
1931
|
}
|
|
1778
1932
|
}
|
|
1779
1933
|
await check('sqlite', async () => ({
|
|
1780
|
-
value:
|
|
1934
|
+
value: db.get('SELECT COUNT(*) AS count FROM projects').count,
|
|
1781
1935
|
}));
|
|
1782
1936
|
await check('operator_token', async () => ({
|
|
1783
|
-
configured: Boolean(
|
|
1937
|
+
configured: Boolean(db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash')),
|
|
1784
1938
|
}));
|
|
1785
1939
|
await check('master_key', async () => ({ configured: Boolean(masterKey) }));
|
|
1786
1940
|
await check('docker', async () => ({
|
|
@@ -1791,14 +1945,14 @@ export async function createApp(options = {}) {
|
|
|
1791
1945
|
}));
|
|
1792
1946
|
if (MESH) {
|
|
1793
1947
|
await check('mesh', async () => {
|
|
1794
|
-
const nodes =
|
|
1948
|
+
const nodes = 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');
|
|
1795
1949
|
const missing = nodes.filter((n) => !n.has_key).map((n) => n.name);
|
|
1796
1950
|
if (missing.length)
|
|
1797
1951
|
throw new Error(`nodes without a reported WireGuard key (is TUG_MESH=true on their agents?): ${missing.join(', ')}`);
|
|
1798
1952
|
return { cidr: MESH_CIDR, port: WG_PORT, nodes: nodes.length };
|
|
1799
1953
|
});
|
|
1800
1954
|
}
|
|
1801
|
-
const ingressNodes =
|
|
1955
|
+
const ingressNodes = db.all('SELECT name,ingress_status_json FROM nodes WHERE ingress=1');
|
|
1802
1956
|
if (ingressNodes.length) {
|
|
1803
1957
|
await check('ingress', async () => {
|
|
1804
1958
|
const bad = ingressNodes
|
|
@@ -1810,45 +1964,47 @@ export async function createApp(options = {}) {
|
|
|
1810
1964
|
return { nodes: ingressNodes.map((n) => n.name) };
|
|
1811
1965
|
});
|
|
1812
1966
|
}
|
|
1813
|
-
res.json({ ok: checks.every((c) => c.ok),
|
|
1967
|
+
res.json({ ok: checks.every((c) => c.ok), checks });
|
|
1814
1968
|
});
|
|
1815
1969
|
api('post', '/api/v1/diagnostics/nodes', null, async (req, res) => {
|
|
1816
1970
|
const nodes = req.body.node
|
|
1817
|
-
?
|
|
1818
|
-
:
|
|
1971
|
+
? db.all('SELECT id FROM nodes WHERE id=? OR name=?', req.body.node, req.body.node)
|
|
1972
|
+
: db.all('SELECT id FROM nodes');
|
|
1819
1973
|
if (req.body.node && !nodes.length)
|
|
1820
1974
|
return res.status(404).json({ error: `node ${req.body.node} not found` });
|
|
1821
1975
|
const commands = [];
|
|
1822
1976
|
for (const node of nodes) {
|
|
1823
|
-
const cmdId =
|
|
1977
|
+
const cmdId = queueCommand(node.id, null, 'diagnostics.run');
|
|
1824
1978
|
commands.push({ commandId: cmdId, nodeId: node.id });
|
|
1825
1979
|
}
|
|
1826
1980
|
res.json({ commands });
|
|
1827
1981
|
});
|
|
1828
|
-
|
|
1829
|
-
const rows =
|
|
1982
|
+
function currentDesiredStates() {
|
|
1983
|
+
const rows = 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`);
|
|
1830
1984
|
return rows.map((s) => JSON.parse(s.state_json));
|
|
1831
1985
|
}
|
|
1832
|
-
|
|
1986
|
+
function endpointsFor(states) {
|
|
1833
1987
|
const staleBefore = new Date(Date.now() - Number(process.env.TUG_STALE_SECONDS || 60) * 1000).toISOString();
|
|
1834
|
-
const nodes =
|
|
1988
|
+
const nodes = db.all('SELECT n.*, EXISTS(SELECT 1 FROM wg_peers w WHERE w.node_id=n.id) AS has_key FROM nodes n');
|
|
1835
1989
|
const views = nodes.map((n) => ({
|
|
1836
1990
|
name: n.name,
|
|
1837
1991
|
overlayIp: MESH && n.has_key ? n.overlay_ip : null,
|
|
1838
1992
|
address: n.address,
|
|
1839
1993
|
lastSeenAt: n.last_seen_at,
|
|
1840
1994
|
}));
|
|
1841
|
-
const statuses =
|
|
1995
|
+
const statuses = db
|
|
1996
|
+
.all('SELECT node_id,project_id,status_json FROM node_statuses')
|
|
1997
|
+
.map((r) => ({
|
|
1842
1998
|
node: nodes.find((n) => n.id === r.node_id)?.name ?? r.node_id,
|
|
1843
1999
|
project: r.project_id,
|
|
1844
2000
|
status: JSON.parse(r.status_json),
|
|
1845
2001
|
}));
|
|
1846
2002
|
return resolveServiceEndpoints(states, views, statuses, staleBefore);
|
|
1847
2003
|
}
|
|
1848
|
-
|
|
2004
|
+
function networkFor(node) {
|
|
1849
2005
|
if (!MESH || !node.overlay_ip)
|
|
1850
2006
|
return undefined;
|
|
1851
|
-
const peers =
|
|
2007
|
+
const peers = 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);
|
|
1852
2008
|
return {
|
|
1853
2009
|
interface: WG_INTERFACE,
|
|
1854
2010
|
overlayIp: node.overlay_ip,
|
|
@@ -1864,17 +2020,17 @@ export async function createApp(options = {}) {
|
|
|
1864
2020
|
};
|
|
1865
2021
|
}
|
|
1866
2022
|
/** Names of all nodes and of the ingress nodes, for port binding and upstream decisions. */
|
|
1867
|
-
|
|
1868
|
-
const rows =
|
|
2023
|
+
function nodeTopology() {
|
|
2024
|
+
const rows = db.all('SELECT name,ingress FROM nodes ORDER BY name');
|
|
1869
2025
|
return {
|
|
1870
2026
|
nodes: rows.map((r) => r.name),
|
|
1871
2027
|
ingressNodes: rows.filter((r) => r.ingress).map((r) => r.name),
|
|
1872
2028
|
};
|
|
1873
2029
|
}
|
|
1874
2030
|
/** Where this node's published ports listen: the address other nodes and the ingress reach it on, or loopback. */
|
|
1875
|
-
|
|
1876
|
-
const hasKey = MESH && node.overlay_ip &&
|
|
1877
|
-
const { nodes, ingressNodes } =
|
|
2031
|
+
function portBindingFor(node, states) {
|
|
2032
|
+
const hasKey = MESH && node.overlay_ip && db.get('SELECT 1 FROM wg_peers WHERE node_id=?', node.id);
|
|
2033
|
+
const { nodes, ingressNodes } = nodeTopology();
|
|
1878
2034
|
const loopback = {};
|
|
1879
2035
|
for (const state of states) {
|
|
1880
2036
|
const services = loopbackIngressServices(state, node.name, ingressNodes, nodes);
|
|
@@ -1883,7 +2039,7 @@ export async function createApp(options = {}) {
|
|
|
1883
2039
|
}
|
|
1884
2040
|
return { address: (hasKey ? node.overlay_ip : node.address) || null, loopback };
|
|
1885
2041
|
}
|
|
1886
|
-
|
|
2042
|
+
function ingressFor(node, states) {
|
|
1887
2043
|
const secrets = new Map();
|
|
1888
2044
|
for (const state of states) {
|
|
1889
2045
|
for (const svc of Object.values(state.compose.services || {})) {
|
|
@@ -1891,7 +2047,7 @@ export async function createApp(options = {}) {
|
|
|
1891
2047
|
if (ing?.tls !== 'custom')
|
|
1892
2048
|
continue;
|
|
1893
2049
|
for (const name of [ing.cert, ing.key]) {
|
|
1894
|
-
const row =
|
|
2050
|
+
const row = db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', state.project, name ?? null);
|
|
1895
2051
|
if (row)
|
|
1896
2052
|
secrets.set(`${state.project}/${name}`, decrypt(row.encrypted_value, masterKey));
|
|
1897
2053
|
}
|
|
@@ -1899,9 +2055,9 @@ export async function createApp(options = {}) {
|
|
|
1899
2055
|
}
|
|
1900
2056
|
return buildIngressWork({
|
|
1901
2057
|
states,
|
|
1902
|
-
endpoints:
|
|
2058
|
+
endpoints: endpointsFor(states),
|
|
1903
2059
|
node: node.name,
|
|
1904
|
-
...
|
|
2060
|
+
...nodeTopology(),
|
|
1905
2061
|
secrets,
|
|
1906
2062
|
acmeEmail: ACME_EMAIL,
|
|
1907
2063
|
});
|
|
@@ -1909,19 +2065,21 @@ export async function createApp(options = {}) {
|
|
|
1909
2065
|
get('/agent/v1/wake', authAgent, async (req, res) => {
|
|
1910
2066
|
const after = Math.max(0, Number.parseInt(String(req.query.after ?? '0'), 10) || 0);
|
|
1911
2067
|
const timeoutMs = Math.min(30_000, Math.max(0, Number.parseInt(String(req.query.timeout ?? '25000'), 10) || 0));
|
|
1912
|
-
|
|
2068
|
+
db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
|
|
1913
2069
|
res.json(await wakeHub.wait(req.node.id, after, timeoutMs));
|
|
1914
2070
|
});
|
|
1915
2071
|
get('/agent/v1/work', authAgent, async (req, res) => {
|
|
1916
|
-
|
|
1917
|
-
const node =
|
|
1918
|
-
const allStates =
|
|
2072
|
+
db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', now(), 'online', req.node.id);
|
|
2073
|
+
const node = db.get('SELECT * FROM nodes WHERE id=?', req.node.id);
|
|
2074
|
+
const allStates = currentDesiredStates();
|
|
1919
2075
|
const desiredStates = allStates.filter((st) => Object.values(st.services).some((svc) => (svc.placement?.nodes || []).includes(node.name) || !svc.placement?.nodes?.length));
|
|
1920
|
-
|
|
2076
|
+
// A command past its deadline is never delivered late; the backup scheduler turns it into a missed run.
|
|
2077
|
+
db.run("UPDATE commands SET state='expired',updated_at=? WHERE node_id=? AND state='pending' AND deadline IS NOT NULL AND deadline<?", now(), node.id, now());
|
|
2078
|
+
const commands = db.all('SELECT * FROM commands WHERE node_id=? AND state=?', node.id, 'pending');
|
|
1921
2079
|
if (commands.length)
|
|
1922
|
-
|
|
2080
|
+
db.run(`UPDATE commands SET state='running',attempts=attempts+1,updated_at=? WHERE node_id=? AND state='pending'`, now(), node.id);
|
|
1923
2081
|
const retainedImages = [
|
|
1924
|
-
...new Set(
|
|
2082
|
+
...new Set(retainedStates().flatMap((state) => Object.values(state.services)
|
|
1925
2083
|
.filter((service) => service.artifact &&
|
|
1926
2084
|
(!service.placement.nodes?.length || service.placement.nodes.includes(node.name)))
|
|
1927
2085
|
.map((service) => service.image))),
|
|
@@ -1933,64 +2091,116 @@ export async function createApp(options = {}) {
|
|
|
1933
2091
|
commands,
|
|
1934
2092
|
retainedImages,
|
|
1935
2093
|
};
|
|
1936
|
-
const network =
|
|
2094
|
+
const network = networkFor(node);
|
|
1937
2095
|
if (network)
|
|
1938
2096
|
work.network = network;
|
|
1939
|
-
work.ports =
|
|
2097
|
+
work.ports = portBindingFor(node, desiredStates);
|
|
2098
|
+
const holds = restores.holds();
|
|
2099
|
+
if (Object.keys(holds).length)
|
|
2100
|
+
work.holds = holds;
|
|
1940
2101
|
if (SERVICE_DNS) {
|
|
1941
|
-
const all =
|
|
2102
|
+
const all = endpointsFor(allStates);
|
|
1942
2103
|
work.serviceEndpoints = Object.fromEntries(desiredStates.map((st) => [st.project, all[st.project] || {}]));
|
|
1943
2104
|
}
|
|
1944
2105
|
if (node.ingress)
|
|
1945
|
-
work.ingress =
|
|
2106
|
+
work.ingress = ingressFor(node, allStates);
|
|
1946
2107
|
res.json(work);
|
|
1947
2108
|
});
|
|
1948
2109
|
post('/agent/v1/status', authAgent, async (req, res) => {
|
|
1949
2110
|
const ts = now();
|
|
1950
2111
|
const report = req.body;
|
|
1951
2112
|
for (const st of report.projects || []) {
|
|
1952
|
-
|
|
1953
|
-
|
|
2113
|
+
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);
|
|
2114
|
+
updateDeploymentConvergence(st.project);
|
|
1954
2115
|
}
|
|
2116
|
+
if (report.jobs?.length)
|
|
2117
|
+
backups.onHeartbeat(report.jobs);
|
|
1955
2118
|
for (const cr of (report.commandResults || [])) {
|
|
1956
2119
|
const result = cr.result || {};
|
|
1957
|
-
|
|
2120
|
+
if (cr.commandId && cr.state)
|
|
2121
|
+
backups.onResult(cr.commandId, cr.state, result);
|
|
2122
|
+
db.run('UPDATE commands SET state=?,result_json=?,updated_at=? WHERE id=? AND node_id=?', cr.state || 'succeeded', JSON.stringify(result), ts, cr.commandId ?? null, req.node.id);
|
|
1958
2123
|
}
|
|
1959
2124
|
if (report.ingress)
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
2125
|
+
db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
|
|
2126
|
+
db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
|
|
2127
|
+
try {
|
|
2128
|
+
restores.advance();
|
|
2129
|
+
}
|
|
2130
|
+
catch (e) {
|
|
2131
|
+
console.error(`restore failed to advance: ${errMessage(e)}`);
|
|
2132
|
+
}
|
|
2133
|
+
const nextEndpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
|
|
1963
2134
|
if (nextEndpointSnapshot !== endpointSnapshot) {
|
|
1964
2135
|
endpointSnapshot = nextEndpointSnapshot;
|
|
1965
|
-
|
|
2136
|
+
wakeAll();
|
|
1966
2137
|
}
|
|
1967
2138
|
res.json({ ok: true });
|
|
1968
2139
|
});
|
|
1969
2140
|
post('/agent/v1/events', authAgent, async (req, res) => {
|
|
1970
2141
|
const evs = req.body.events || [req.body];
|
|
1971
2142
|
for (const ev of evs)
|
|
1972
|
-
|
|
2143
|
+
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());
|
|
1973
2144
|
if (Date.now() - lastEventPrune >= eventPruneIntervalMs)
|
|
1974
|
-
|
|
2145
|
+
try {
|
|
2146
|
+
pruneEvents();
|
|
2147
|
+
}
|
|
2148
|
+
catch (e) {
|
|
2149
|
+
console.error(`event pruning failed: ${errMessage(e)}`);
|
|
2150
|
+
}
|
|
1975
2151
|
res.json({ ok: true });
|
|
1976
2152
|
});
|
|
2153
|
+
// Credentials for a backup helper are released only while its command is running on the calling node, and
|
|
2154
|
+
// never ride in the command payload, events or results.
|
|
2155
|
+
get('/agent/v1/backup/credentials', authAgent, async (req, res) => {
|
|
2156
|
+
const command = db.get('SELECT node_id,project_id,type,state FROM commands WHERE id=?', String(req.query.command || ''));
|
|
2157
|
+
if (!command)
|
|
2158
|
+
return res.status(404).json({ error: 'command not found' });
|
|
2159
|
+
if (command.node_id !== req.node.id)
|
|
2160
|
+
return res.status(403).json({ error: 'command belongs to another node' });
|
|
2161
|
+
if (!BACKUP_COMMAND_TYPES.includes(command.type))
|
|
2162
|
+
return res.status(403).json({ error: 'command does not use backup credentials' });
|
|
2163
|
+
if (command.state !== 'running')
|
|
2164
|
+
return res.status(409).json({ error: `command is ${command.state}, not running` });
|
|
2165
|
+
if (!command.project_id || !backupDestination())
|
|
2166
|
+
return res.status(409).json({ error: 'no backup destination is configured' });
|
|
2167
|
+
const response = {
|
|
2168
|
+
env: {
|
|
2169
|
+
...backupEnv(),
|
|
2170
|
+
RESTIC_PASSWORD: repositoryPassword(masterKey, command.project_id),
|
|
2171
|
+
},
|
|
2172
|
+
};
|
|
2173
|
+
res.json(response);
|
|
2174
|
+
});
|
|
1977
2175
|
get('/agent/v1/secrets', authAgent, async (req, res) => {
|
|
1978
2176
|
const project = String(req.query.project || 'site');
|
|
1979
|
-
let rows =
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2177
|
+
let rows = db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
|
|
2178
|
+
const ds = currentDesired(project);
|
|
2179
|
+
const desired = ds ? JSON.parse(ds.state_json) : undefined;
|
|
2180
|
+
const requested = req.query.services
|
|
2181
|
+
? String(req.query.services)
|
|
1983
2182
|
.split(',')
|
|
1984
2183
|
.map((s) => s.trim())
|
|
1985
|
-
.filter(Boolean)
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2184
|
+
.filter(Boolean)
|
|
2185
|
+
: Object.keys(desired?.services || {});
|
|
2186
|
+
const names = requested.filter((name) => desired?.services[name]?.placement.nodes?.includes(req.node.name));
|
|
2187
|
+
const allowed = serviceSecretNames(desired?.compose, names);
|
|
2188
|
+
const registrySecret = rows.find((row) => row.name === REGISTRY_AUTH_SECRET);
|
|
2189
|
+
const registryAuth = {};
|
|
2190
|
+
const credentials = parseRegistryCredentials(registrySecret ? decrypt(registrySecret.encrypted_value, masterKey) : undefined);
|
|
2191
|
+
for (const name of names) {
|
|
2192
|
+
const spec = desired.services[name];
|
|
2193
|
+
if (!spec.artifact && spec.image) {
|
|
2194
|
+
const host = imageLocation(spec.image).host;
|
|
2195
|
+
if (credentials[host])
|
|
2196
|
+
registryAuth[host] = credentials[host];
|
|
2197
|
+
}
|
|
1990
2198
|
}
|
|
2199
|
+
rows = rows.filter((row) => allowed.has(row.name) && row.name !== REGISTRY_AUTH_SECRET);
|
|
1991
2200
|
const secrets = {
|
|
1992
2201
|
secrets: Object.fromEntries(rows.map((r) => [r.name, decrypt(r.encrypted_value, masterKey)])),
|
|
1993
2202
|
versions: Object.fromEntries(rows.map((r) => [r.name, r.version])),
|
|
2203
|
+
...(Object.keys(registryAuth).length ? { registryAuth } : {}),
|
|
1994
2204
|
};
|
|
1995
2205
|
res.json(secrets);
|
|
1996
2206
|
});
|
|
@@ -2019,7 +2229,7 @@ function attachAgentStream(app, server) {
|
|
|
2019
2229
|
const raw = String(request.headers.authorization || '').replace(/^Bearer /, '');
|
|
2020
2230
|
void (async () => {
|
|
2021
2231
|
if (pathname === '/api/v1/live') {
|
|
2022
|
-
const actor =
|
|
2232
|
+
const actor = runtime.authenticateOperator(raw);
|
|
2023
2233
|
if (!actor) {
|
|
2024
2234
|
reject(401, 'Unauthorized');
|
|
2025
2235
|
return;
|
|
@@ -2031,7 +2241,7 @@ function attachAgentStream(app, server) {
|
|
|
2031
2241
|
wss.handleUpgrade(request, socket, head, (ws) => runtime.live.operator(actor, ws));
|
|
2032
2242
|
}
|
|
2033
2243
|
else {
|
|
2034
|
-
const node =
|
|
2244
|
+
const node = runtime.authenticate(raw);
|
|
2035
2245
|
if (!node) {
|
|
2036
2246
|
reject(401, 'Unauthorized');
|
|
2037
2247
|
return;
|