@wpelevator/tug 0.12.0 → 0.13.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 +7 -3
- package/dist/backup-restore.d.ts +1 -1
- package/dist/backup-restore.js +3 -1
- package/dist/backup-scheduler.js +3 -1
- package/dist/cli-build.d.ts +1 -1
- package/dist/cli-build.js +4 -1
- package/dist/cli-process.d.ts +13 -0
- package/dist/cli-process.js +46 -0
- package/dist/cli-progress.d.ts +11 -0
- package/dist/cli-progress.js +37 -0
- package/dist/cli-prompt.js +5 -4
- package/dist/cli-selection.d.ts +13 -0
- package/dist/cli-selection.js +39 -0
- package/dist/commands/backup.js +48 -31
- package/dist/commands/context.d.ts +2 -5
- package/dist/commands/context.js +50 -28
- package/dist/commands/control.js +55 -42
- package/dist/commands/credential.js +21 -2
- package/dist/commands/deploy.js +97 -70
- package/dist/commands/hold.js +12 -4
- package/dist/commands/host.d.ts +2 -2
- package/dist/commands/host.js +13 -8
- package/dist/commands/node.d.ts +1 -0
- package/dist/commands/node.js +103 -71
- package/dist/commands/observe.js +7 -2
- package/dist/commands/restore.js +71 -50
- package/dist/commands/secret.js +8 -3
- package/dist/commands/selection.d.ts +22 -0
- package/dist/commands/selection.js +90 -0
- package/dist/commands/service.js +17 -6
- package/dist/commands/volume.js +3 -2
- package/dist/control.js +95 -47
- package/dist/credentials.js +5 -0
- package/dist/db-backup-destination.d.ts +14 -0
- package/dist/db-backup-destination.js +28 -0
- package/dist/db-locks.d.ts +10 -0
- package/dist/db-locks.js +23 -0
- package/dist/db-transaction.d.ts +3 -0
- package/dist/db-transaction.js +24 -0
- package/dist/image.d.ts +9 -0
- package/dist/image.js +19 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.js +6 -0
- package/docs/access-control.md +166 -0
- package/docs/architecture.md +254 -0
- package/docs/backups.md +160 -0
- package/docs/cli.md +182 -0
- package/docs/compose.md +245 -0
- package/docs/configuration.md +52 -0
- package/docs/control-plane.md +112 -0
- package/docs/development.md +140 -0
- package/docs/getting-started.md +75 -0
- package/docs/healthchecks.md +51 -0
- package/docs/migration.md +113 -0
- package/docs/networking.md +150 -0
- package/docs/nodes.md +143 -0
- package/docs/observability.md +213 -0
- package/docs/real-host-evaluation.md +224 -0
- package/docs/releases.md +39 -0
- package/docs/roadmap.md +234 -0
- package/docs/security.md +198 -0
- package/docs/service-placement.md +72 -0
- package/docs/standalone-binary.md +79 -0
- package/docs/swarm-comparison.md +197 -0
- package/docs/terminology.md +74 -0
- package/docs/testing.md +254 -0
- package/docs/troubleshooting.md +33 -0
- package/package.json +6 -3
package/dist/commands/service.js
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
|
+
import { selectService } from "./selection.js";
|
|
1
2
|
import { projectOf, request, runLive, waitForCommand } from "./context.js";
|
|
2
3
|
export function registerService(program) {
|
|
3
4
|
program
|
|
4
|
-
.command('exec
|
|
5
|
+
.command('exec [service] [command...]')
|
|
5
6
|
.description('execute a command on a service container (default: sh)')
|
|
7
|
+
.option('--select', 'choose the service; all positional arguments are the remote command')
|
|
6
8
|
.option('-i, --interactive', 'forward stdin to the remote command')
|
|
7
9
|
.option('-t, --tty', 'allocate a terminal (requires a local terminal and agent TTY support)')
|
|
8
10
|
.option('--node <name>', 'select the node when a service has multiple placements')
|
|
9
11
|
.action(async (service, command, o) => {
|
|
12
|
+
if (o.select && service)
|
|
13
|
+
command.unshift(service);
|
|
14
|
+
service = await selectService(o.select ? undefined : service);
|
|
10
15
|
await runLive('exec', { service, command, ...o });
|
|
11
16
|
});
|
|
12
17
|
program
|
|
13
|
-
.command('run
|
|
18
|
+
.command('run [service] [command...]')
|
|
14
19
|
.description("run a one-off command in a new container from the service: same image, environment, secrets, network and mounts, removed afterwards (for example: tug run web -- wp core update-db). Exits with the command's exit code")
|
|
15
20
|
.option('--node <name>', 'select the node when a service has multiple placements')
|
|
21
|
+
.option('--select', 'choose the service; all positional arguments are the remote command')
|
|
16
22
|
.action(async (service, command, o) => {
|
|
23
|
+
if (o.select && service)
|
|
24
|
+
command.unshift(service);
|
|
17
25
|
if (!command.length)
|
|
18
26
|
throw new Error('run needs a command, such as: tug run web -- wp core update-db');
|
|
27
|
+
service = await selectService(o.select ? undefined : service);
|
|
19
28
|
await runLive('exec', { service, command, node: o.node, oneOff: true });
|
|
20
29
|
});
|
|
21
30
|
program
|
|
@@ -26,25 +35,27 @@ export function registerService(program) {
|
|
|
26
35
|
await runLive('ps', o);
|
|
27
36
|
});
|
|
28
37
|
program
|
|
29
|
-
.command('inspect
|
|
38
|
+
.command('inspect [service]')
|
|
30
39
|
.description('print container details as JSON')
|
|
31
40
|
.option('--node <name>', 'select one node')
|
|
32
41
|
.action(async (service, o) => {
|
|
42
|
+
service = await selectService(service);
|
|
33
43
|
await runLive('inspect', { service, ...o });
|
|
34
44
|
});
|
|
35
45
|
program
|
|
36
|
-
.command('logs
|
|
46
|
+
.command('logs [service]')
|
|
37
47
|
.description('fetch service container logs from the node that runs the service')
|
|
38
48
|
.option('--tail <n>', 'number of lines', '200')
|
|
39
49
|
.option('-f, --follow', 'follow live logs')
|
|
40
50
|
.option('--node <name>', 'select a node for followed logs')
|
|
41
51
|
.action(async (svc, o) => {
|
|
52
|
+
if (o.node && !o.follow)
|
|
53
|
+
throw new Error('--node requires --follow for logs');
|
|
54
|
+
svc = await selectService(svc);
|
|
42
55
|
if (o.follow) {
|
|
43
56
|
await runLive('logs', { service: svc, node: o.node, tail: o.tail });
|
|
44
57
|
return;
|
|
45
58
|
}
|
|
46
|
-
if (o.node)
|
|
47
|
-
throw new Error('--node requires --follow for logs');
|
|
48
59
|
const created = await request('POST', '/api/v1/logs', {
|
|
49
60
|
project: await projectOf(o),
|
|
50
61
|
service: svc,
|
package/dist/commands/volume.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectResource } from "./selection.js";
|
|
1
2
|
import { print, projectOf, request } from "./context.js";
|
|
2
3
|
export function registerVolume(program) {
|
|
3
4
|
const volume = program.command('volume').description("list and remove a project's volumes");
|
|
@@ -6,8 +7,8 @@ export function registerVolume(program) {
|
|
|
6
7
|
.description('list the volumes of the project and where they live')
|
|
7
8
|
.action(async (o) => print(await request('GET', `/api/v1/volumes?project=${encodeURIComponent(await projectOf(o))}`)));
|
|
8
9
|
volume
|
|
9
|
-
.command('remove
|
|
10
|
+
.command('remove [name]')
|
|
10
11
|
.description('remove a volume from the project')
|
|
11
12
|
.option('--delete-data', 'delete underlying Docker volume data')
|
|
12
|
-
.action(async (name, o) => print(await request('DELETE', `/api/v1/volumes/${encodeURIComponent(name)}?project=${encodeURIComponent(await projectOf(o))}&deleteData=${Boolean(o.deleteData)}`)));
|
|
13
|
+
.action(async (name, o) => print(await request('DELETE', `/api/v1/volumes/${encodeURIComponent(await selectResource(name, 'volumes'))}?project=${encodeURIComponent(await projectOf(o))}&deleteData=${Boolean(o.deleteData)}`)));
|
|
13
14
|
}
|
package/dist/control.js
CHANGED
|
@@ -15,6 +15,9 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|
|
15
15
|
import * as tar from 'tar';
|
|
16
16
|
import { openDb, openSnapshotDb } from "./db.js";
|
|
17
17
|
import { isUniqueViolation } from "./db-driver.js";
|
|
18
|
+
import { lockQueries } from "./db-locks.js";
|
|
19
|
+
import { backupDestinationQueries } from "./db-backup-destination.js";
|
|
20
|
+
import { immediateTransaction } from "./db-transaction.js";
|
|
18
21
|
import { encrypt, decrypt, loadOrCreateMasterKey } from "./secrets.js";
|
|
19
22
|
import { buildDesiredState, ingressHostsOf, ingressHostsOverlap, loopbackIngressServices, serviceSecretNames, validateCompose, } from "./compose.js";
|
|
20
23
|
import { buildImage, unpackContext, run as runCommand } from "./build.js";
|
|
@@ -27,7 +30,7 @@ import { isNodeHost } from "./node-address.js";
|
|
|
27
30
|
import { buildIngressWork, ingressConfigHash, resolveServiceEndpoints } from "./ingress.js";
|
|
28
31
|
import { parseStatusReport } from "./status-report.js";
|
|
29
32
|
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";
|
|
30
|
-
import {
|
|
33
|
+
import { isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
|
|
31
34
|
import { createBackupService } from "./backup-scheduler.js";
|
|
32
35
|
import { createRestoreService, discoverWriters } from "./backup-restore.js";
|
|
33
36
|
import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createCredential, listCredentials, parseCredentialRequest, queryProject, recordEvent, revokeCredential, } from "./credentials.js";
|
|
@@ -864,18 +867,24 @@ export async function createApp(options = {}) {
|
|
|
864
867
|
api('get', '/api/v1/live', null, (_req, res) => {
|
|
865
868
|
res.status(426).json({ error: 'WebSocket upgrade required' });
|
|
866
869
|
});
|
|
870
|
+
const locks = lockQueries(db);
|
|
867
871
|
function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
|
|
868
872
|
const name = `project:${project}`;
|
|
869
873
|
const ts = now();
|
|
870
|
-
|
|
874
|
+
locks.purgeExpired(ts);
|
|
871
875
|
const owner = `${operation}:${id('lock')}`;
|
|
872
876
|
try {
|
|
873
|
-
|
|
877
|
+
locks.insert({
|
|
878
|
+
name,
|
|
879
|
+
owner,
|
|
880
|
+
expires_at: new Date(Date.now() + ttlMs).toISOString(),
|
|
881
|
+
created_at: ts,
|
|
882
|
+
});
|
|
874
883
|
}
|
|
875
884
|
catch (e) {
|
|
876
885
|
if (!isUniqueViolation(e))
|
|
877
886
|
throw e;
|
|
878
|
-
const existing =
|
|
887
|
+
const existing = locks.find(name);
|
|
879
888
|
const err = new Error(`project ${project} is locked by ${existing?.owner} until ${existing?.expires_at}`);
|
|
880
889
|
err.status = 409;
|
|
881
890
|
err.lock = existing;
|
|
@@ -883,7 +892,7 @@ export async function createApp(options = {}) {
|
|
|
883
892
|
}
|
|
884
893
|
const heartbeat = setInterval(() => {
|
|
885
894
|
try {
|
|
886
|
-
|
|
895
|
+
locks.renew(name, owner, new Date(Date.now() + ttlMs).toISOString());
|
|
887
896
|
}
|
|
888
897
|
catch {
|
|
889
898
|
/* Best-effort bookkeeping. */
|
|
@@ -897,7 +906,7 @@ export async function createApp(options = {}) {
|
|
|
897
906
|
clearInterval(lock.heartbeat);
|
|
898
907
|
if (lock)
|
|
899
908
|
try {
|
|
900
|
-
|
|
909
|
+
locks.release(lock.name, lock.owner);
|
|
901
910
|
}
|
|
902
911
|
catch {
|
|
903
912
|
/* Best-effort bookkeeping. */
|
|
@@ -1285,16 +1294,17 @@ export async function createApp(options = {}) {
|
|
|
1285
1294
|
});
|
|
1286
1295
|
res.json({ ok: true, name: String(req.params.name) });
|
|
1287
1296
|
});
|
|
1288
|
-
const
|
|
1297
|
+
const destinationQueries = backupDestinationQueries(db);
|
|
1298
|
+
const backupDestination = () => destinationQueries.find();
|
|
1289
1299
|
const backupEnv = () => {
|
|
1290
1300
|
const row = backupDestination();
|
|
1291
1301
|
if (!row)
|
|
1292
1302
|
return {};
|
|
1293
1303
|
const env = {};
|
|
1294
1304
|
for (const name of JSON.parse(row.env_names_json)) {
|
|
1295
|
-
const secret =
|
|
1305
|
+
const secret = destinationQueries.secret(name);
|
|
1296
1306
|
if (secret)
|
|
1297
|
-
env[name] = decrypt(secret
|
|
1307
|
+
env[name] = decrypt(secret, masterKey);
|
|
1298
1308
|
}
|
|
1299
1309
|
return env;
|
|
1300
1310
|
};
|
|
@@ -1371,7 +1381,9 @@ export async function createApp(options = {}) {
|
|
|
1371
1381
|
return res.status(started.status).json({ error: started.error });
|
|
1372
1382
|
res.json({ confirmed: true, plan, ...started });
|
|
1373
1383
|
});
|
|
1374
|
-
api('get', '/api/v1/restores', queryProject, async (req, res) => res.json({
|
|
1384
|
+
api('get', '/api/v1/restores', queryProject, async (req, res) => res.json({
|
|
1385
|
+
restores: restores.list(String(req.query.project || 'site'), 20, req.query.state === 'interrupted'),
|
|
1386
|
+
}));
|
|
1375
1387
|
api('post', '/api/v1/restores/:id/release', null, async (req, res) => {
|
|
1376
1388
|
const result = restores.releaseInterrupted(String(req.params.id));
|
|
1377
1389
|
if ('error' in result)
|
|
@@ -1385,6 +1397,7 @@ export async function createApp(options = {}) {
|
|
|
1385
1397
|
res.json(result);
|
|
1386
1398
|
});
|
|
1387
1399
|
api('get', '/api/v1/backups', queryProject, async (req, res) => {
|
|
1400
|
+
backups.sync();
|
|
1388
1401
|
const project = String(req.query.project || 'site');
|
|
1389
1402
|
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
|
|
1390
1403
|
res.json({
|
|
@@ -1415,19 +1428,11 @@ export async function createApp(options = {}) {
|
|
|
1415
1428
|
return res.status(400).json({ error: errMessage(e) });
|
|
1416
1429
|
}
|
|
1417
1430
|
const ts = now();
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
db.run(`INSERT INTO backup_destination(id,url,env_names_json,checked_at,check_result_json,updated_at) VALUES('default',?,?,NULL,NULL,?)
|
|
1424
|
-
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);
|
|
1425
|
-
db.exec('COMMIT');
|
|
1426
|
-
}
|
|
1427
|
-
catch (e) {
|
|
1428
|
-
db.exec('ROLLBACK');
|
|
1429
|
-
throw e;
|
|
1430
|
-
}
|
|
1431
|
+
destinationQueries.replace(parsed.url, Object.entries(parsed.env).map(([name, value]) => ({
|
|
1432
|
+
id: id('sec'),
|
|
1433
|
+
name,
|
|
1434
|
+
encryptedValue: encrypt(value, masterKey),
|
|
1435
|
+
})), ts);
|
|
1431
1436
|
audit(req.actor, {
|
|
1432
1437
|
type: 'backup.destination_set',
|
|
1433
1438
|
message: `backup destination set to ${parsed.url} by ${req.actor.name}`,
|
|
@@ -1436,14 +1441,13 @@ export async function createApp(options = {}) {
|
|
|
1436
1441
|
res.json({ ok: true, url: parsed.url, envNames: Object.keys(parsed.env).sort() });
|
|
1437
1442
|
});
|
|
1438
1443
|
api('delete', '/api/v1/backup/destination', null, async (req, res) => {
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
if (removed.changes)
|
|
1444
|
+
const removed = destinationQueries.remove();
|
|
1445
|
+
if (removed)
|
|
1442
1446
|
audit(req.actor, {
|
|
1443
1447
|
type: 'backup.destination_removed',
|
|
1444
1448
|
message: `backup destination removed by ${req.actor.name}`,
|
|
1445
1449
|
});
|
|
1446
|
-
res.json({ ok: true, removed
|
|
1450
|
+
res.json({ ok: true, removed });
|
|
1447
1451
|
});
|
|
1448
1452
|
api('get', '/api/v1/backup/repository-password', null, async (req, res) => {
|
|
1449
1453
|
const project = String(req.query.project || '');
|
|
@@ -1771,7 +1775,7 @@ export async function createApp(options = {}) {
|
|
|
1771
1775
|
lock = acquireProjectLock(project, 'deploy');
|
|
1772
1776
|
const active = db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
|
|
1773
1777
|
if (active)
|
|
1774
|
-
throw httpError(409, `deployment ${active.id} is already ${active.state}`);
|
|
1778
|
+
throw httpError(409, `deployment ${active.id} is already ${active.state}; wait for it, or cancel it with: tug deploy cancel ${active.id}`);
|
|
1775
1779
|
depId = id('dep');
|
|
1776
1780
|
const ts = now();
|
|
1777
1781
|
db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
|
|
@@ -1853,15 +1857,13 @@ export async function createApp(options = {}) {
|
|
|
1853
1857
|
const buildLock = lock;
|
|
1854
1858
|
const publishingId = depId;
|
|
1855
1859
|
await withPublication(() => {
|
|
1856
|
-
const owned =
|
|
1860
|
+
const owned = locks.owns(buildLock.name, buildLock.owner, now());
|
|
1857
1861
|
const pending = db.get('SELECT 1 FROM deployments WHERE id=? AND state=?', publishingId, 'building');
|
|
1858
1862
|
if (!owned || !pending)
|
|
1859
1863
|
throw httpError(409, 'deployment was cancelled or its build lease was lost');
|
|
1860
|
-
db
|
|
1861
|
-
try {
|
|
1864
|
+
immediateTransaction(db, () => {
|
|
1862
1865
|
const published = db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), publishingId);
|
|
1863
|
-
if (published.changes !== 1 ||
|
|
1864
|
-
!db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now()))
|
|
1866
|
+
if (published.changes !== 1 || !locks.owns(buildLock.name, buildLock.owner, now()))
|
|
1865
1867
|
throw httpError(409, 'deployment was cancelled or its build lease was lost');
|
|
1866
1868
|
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);
|
|
1867
1869
|
// The moved service starts empty: keep what writes to it stopped until its data is restored.
|
|
@@ -1878,17 +1880,7 @@ export async function createApp(options = {}) {
|
|
|
1878
1880
|
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);
|
|
1879
1881
|
}
|
|
1880
1882
|
db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', publishingId, now(), project);
|
|
1881
|
-
|
|
1882
|
-
}
|
|
1883
|
-
catch (e) {
|
|
1884
|
-
try {
|
|
1885
|
-
db.exec('ROLLBACK');
|
|
1886
|
-
}
|
|
1887
|
-
catch {
|
|
1888
|
-
/* Preserve the publication error. */
|
|
1889
|
-
}
|
|
1890
|
-
throw e;
|
|
1891
|
-
}
|
|
1883
|
+
});
|
|
1892
1884
|
});
|
|
1893
1885
|
audit(req.actor, {
|
|
1894
1886
|
type: 'deploy',
|
|
@@ -1987,9 +1979,21 @@ export async function createApp(options = {}) {
|
|
|
1987
1979
|
sendError(res, e);
|
|
1988
1980
|
}
|
|
1989
1981
|
}));
|
|
1990
|
-
api('get', '/api/v1/volumes', queryProject, async (req, res) =>
|
|
1991
|
-
|
|
1992
|
-
|
|
1982
|
+
api('get', '/api/v1/volumes', queryProject, async (req, res) => {
|
|
1983
|
+
const project = String(req.query.project || 'site');
|
|
1984
|
+
const desired = currentDesired(project);
|
|
1985
|
+
const state = desired ? JSON.parse(desired.state_json) : undefined;
|
|
1986
|
+
const volumes = db.all('SELECT v.*,n.name AS node FROM volumes v LEFT JOIN nodes n ON n.id=v.node_id WHERE v.project_id=? ORDER BY v.name', project);
|
|
1987
|
+
res.json({
|
|
1988
|
+
volumes: volumes.map((volume) => ({
|
|
1989
|
+
...volume,
|
|
1990
|
+
persistent: Boolean(JSON.parse(volume.config_json || '{}').persistent || volume.type !== 'ephemeral'),
|
|
1991
|
+
usedBy: Object.entries(state?.compose?.services || {})
|
|
1992
|
+
.filter(([, service]) => (service.volumes || []).some((mount) => (typeof mount === 'string' ? mount.split(':')[0] : mount.source) === volume.name))
|
|
1993
|
+
.map(([name]) => name),
|
|
1994
|
+
})),
|
|
1995
|
+
});
|
|
1996
|
+
});
|
|
1993
1997
|
api('delete', '/api/v1/volumes/:name', queryProject, async (req, res) => {
|
|
1994
1998
|
const project = String(req.query.project || 'site');
|
|
1995
1999
|
const deleteData = String(req.query.deleteData || 'false') === 'true';
|
|
@@ -2248,6 +2252,50 @@ export async function createApp(options = {}) {
|
|
|
2248
2252
|
res.json({ commandId: cmdId, nodeId: node.id });
|
|
2249
2253
|
});
|
|
2250
2254
|
api('get', '/api/v1/commands/:id', commandProject, async (req, res) => res.json(db.get('SELECT * FROM commands WHERE id=?', String(req.params.id))));
|
|
2255
|
+
api('get', '/api/v1/projects', null, async (req, res) => {
|
|
2256
|
+
const scope = projectScope(req.actor, 'id');
|
|
2257
|
+
res.json({
|
|
2258
|
+
projects: db.all(`SELECT id AS name FROM projects WHERE ${scope.where} ORDER BY id`, ...scope.args),
|
|
2259
|
+
});
|
|
2260
|
+
});
|
|
2261
|
+
api('get', '/api/v1/services', queryProject, async (req, res) => {
|
|
2262
|
+
const project = String(req.query.project || 'site');
|
|
2263
|
+
const row = currentDesired(project);
|
|
2264
|
+
const desired = row ? JSON.parse(row.state_json) : undefined;
|
|
2265
|
+
const nodes = db.all('SELECT * FROM nodes ORDER BY name');
|
|
2266
|
+
res.json({
|
|
2267
|
+
services: Object.entries(desired?.services || {})
|
|
2268
|
+
.map(([name, service]) => ({
|
|
2269
|
+
name,
|
|
2270
|
+
nodes: nodes
|
|
2271
|
+
.filter((node) => !service.placement?.nodes?.length || service.placement.nodes.includes(node.name))
|
|
2272
|
+
.map((node) => ({
|
|
2273
|
+
name: node.name,
|
|
2274
|
+
status: node.disabled_at ? 'disabled' : isStale(node) ? 'stale' : node.status,
|
|
2275
|
+
})),
|
|
2276
|
+
}))
|
|
2277
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
2278
|
+
});
|
|
2279
|
+
});
|
|
2280
|
+
api('get', '/api/v1/deployments', queryProject, async (req, res) => {
|
|
2281
|
+
res.json({
|
|
2282
|
+
deployments: db.all(`SELECT id,project_id,version,state,created_at FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) ORDER BY created_at DESC,id`, String(req.query.project || 'site')),
|
|
2283
|
+
});
|
|
2284
|
+
});
|
|
2285
|
+
api('get', '/api/v1/backup/snapshots', queryProject, async (req, res) => {
|
|
2286
|
+
const project = String(req.query.project || 'site');
|
|
2287
|
+
const target = String(req.query.target || '');
|
|
2288
|
+
if (!target)
|
|
2289
|
+
throw httpError(400, 'target is required');
|
|
2290
|
+
res.json({
|
|
2291
|
+
snapshots: db.all(`SELECT id,target,snapshot_id,finished_at,size_bytes,trigger FROM backup_runs WHERE project_id=? AND target=? AND state='succeeded' AND snapshot_id IS NOT NULL ORDER BY finished_at DESC,id`, project, target),
|
|
2292
|
+
});
|
|
2293
|
+
});
|
|
2294
|
+
api('get', '/api/v1/holds', queryProject, async (req, res) => {
|
|
2295
|
+
res.json({
|
|
2296
|
+
holds: db.all('SELECT service,reason,created_at FROM service_holds WHERE project_id=? AND restore_id IS NULL ORDER BY service', String(req.query.project || 'site')),
|
|
2297
|
+
});
|
|
2298
|
+
});
|
|
2251
2299
|
api('get', '/api/v1/status', null, async (req, res) => {
|
|
2252
2300
|
const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
|
|
2253
2301
|
const cutoff = new Date(Date.now() - staleSeconds * 1000).toISOString();
|
package/dist/credentials.js
CHANGED
|
@@ -18,6 +18,11 @@ const LAST_USED_INTERVAL_MS = 60_000;
|
|
|
18
18
|
*/
|
|
19
19
|
export const ROUTE_ROLES = {
|
|
20
20
|
'GET /api/v1/live': 'deploy',
|
|
21
|
+
'GET /api/v1/services': 'read',
|
|
22
|
+
'GET /api/v1/projects': 'read',
|
|
23
|
+
'GET /api/v1/deployments': 'read',
|
|
24
|
+
'GET /api/v1/backup/snapshots': 'read',
|
|
25
|
+
'GET /api/v1/holds': 'admin',
|
|
21
26
|
'GET /api/v1/status': 'read',
|
|
22
27
|
'GET /api/v1/events': 'read',
|
|
23
28
|
'GET /api/v1/projects/:project': 'read',
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BackupDestinationRow, Db } from './db.ts';
|
|
2
|
+
interface DestinationSecret {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
encryptedValue: string;
|
|
6
|
+
}
|
|
7
|
+
/** Accepts encrypted values only. Encryption and credential parsing belong to the caller. */
|
|
8
|
+
export declare function backupDestinationQueries(db: Db): {
|
|
9
|
+
find(): BackupDestinationRow | undefined;
|
|
10
|
+
secret(name: string): string | undefined;
|
|
11
|
+
replace(url: string, secrets: DestinationSecret[], at: string): void;
|
|
12
|
+
remove(): boolean;
|
|
13
|
+
};
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { BACKUP_SECRET_PROJECT } from "./backup-destination.js";
|
|
2
|
+
import { immediateTransaction } from "./db-transaction.js";
|
|
3
|
+
/** Accepts encrypted values only. Encryption and credential parsing belong to the caller. */
|
|
4
|
+
export function backupDestinationQueries(db) {
|
|
5
|
+
return {
|
|
6
|
+
find() {
|
|
7
|
+
return db.get("SELECT * FROM backup_destination WHERE id='default'");
|
|
8
|
+
},
|
|
9
|
+
secret(name) {
|
|
10
|
+
return db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', BACKUP_SECRET_PROJECT, name)?.encrypted_value;
|
|
11
|
+
},
|
|
12
|
+
replace(url, secrets, at) {
|
|
13
|
+
immediateTransaction(db, () => {
|
|
14
|
+
db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
|
|
15
|
+
for (const secret of secrets)
|
|
16
|
+
db.run('INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)', secret.id, BACKUP_SECRET_PROJECT, secret.name, secret.encryptedValue, 1, at, at);
|
|
17
|
+
db.run(`INSERT INTO backup_destination(id,url,env_names_json,checked_at,check_result_json,updated_at) VALUES('default',?,?,NULL,NULL,?)
|
|
18
|
+
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`, url, JSON.stringify(secrets.map((secret) => secret.name).sort()), at);
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
remove() {
|
|
22
|
+
return immediateTransaction(db, () => {
|
|
23
|
+
db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
|
|
24
|
+
return db.run("DELETE FROM backup_destination WHERE id='default'").changes > 0;
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Db, LockRow } from './db.ts';
|
|
2
|
+
/** SQL persistence only; callers own lease clocks, heartbeats and conflict responses. */
|
|
3
|
+
export declare function lockQueries(db: Db): {
|
|
4
|
+
purgeExpired(at: string): void;
|
|
5
|
+
insert(lock: LockRow): void;
|
|
6
|
+
find(name: string): LockRow | undefined;
|
|
7
|
+
owns(name: string, owner: string, at: string): boolean;
|
|
8
|
+
renew(name: string, owner: string, expiresAt: string): void;
|
|
9
|
+
release(name: string, owner: string): void;
|
|
10
|
+
};
|
package/dist/db-locks.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** SQL persistence only; callers own lease clocks, heartbeats and conflict responses. */
|
|
2
|
+
export function lockQueries(db) {
|
|
3
|
+
return {
|
|
4
|
+
purgeExpired(at) {
|
|
5
|
+
db.run('DELETE FROM locks WHERE expires_at < ?', at);
|
|
6
|
+
},
|
|
7
|
+
insert(lock) {
|
|
8
|
+
db.run('INSERT INTO locks(name,owner,expires_at,created_at) VALUES(?,?,?,?)', lock.name, lock.owner, lock.expires_at, lock.created_at);
|
|
9
|
+
},
|
|
10
|
+
find(name) {
|
|
11
|
+
return db.get('SELECT * FROM locks WHERE name=?', name);
|
|
12
|
+
},
|
|
13
|
+
owns(name, owner, at) {
|
|
14
|
+
return Boolean(db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', name, owner, at));
|
|
15
|
+
},
|
|
16
|
+
renew(name, owner, expiresAt) {
|
|
17
|
+
db.run('UPDATE locks SET expires_at=? WHERE name=? AND owner=?', expiresAt, name, owner);
|
|
18
|
+
},
|
|
19
|
+
release(name, owner) {
|
|
20
|
+
db.run('DELETE FROM locks WHERE name=? AND owner=?', name, owner);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { Db } from './db-driver.ts';
|
|
2
|
+
/** Run synchronous work in an immediate transaction. Nested transactions are rejected by SQLite. */
|
|
3
|
+
export declare function immediateTransaction<T>(db: Db, operation: () => T & (T extends PromiseLike<unknown> ? never : unknown)): T;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Run synchronous work in an immediate transaction. Nested transactions are rejected by SQLite. */
|
|
2
|
+
export function immediateTransaction(db, operation) {
|
|
3
|
+
// Keep BEGIN outside the catch: a failed BEGIN must not roll back somebody else's transaction.
|
|
4
|
+
db.exec('BEGIN IMMEDIATE');
|
|
5
|
+
try {
|
|
6
|
+
const result = operation();
|
|
7
|
+
if (result !== null &&
|
|
8
|
+
(typeof result === 'object' || typeof result === 'function') &&
|
|
9
|
+
'then' in result &&
|
|
10
|
+
typeof result.then === 'function')
|
|
11
|
+
throw new TypeError('Database transactions require synchronous callbacks');
|
|
12
|
+
db.exec('COMMIT');
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
try {
|
|
17
|
+
db.exec('ROLLBACK');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
/* Preserve the operation or commit failure. */
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
package/dist/image.d.ts
CHANGED
|
@@ -7,3 +7,12 @@ export declare function packageVersion(): string;
|
|
|
7
7
|
export declare function defaultImage(version?: string): string;
|
|
8
8
|
/** An explicit `--image` always wins; otherwise a locally built image is `tug:local` and anything else is the published image. */
|
|
9
9
|
export declare function resolveImage(image: string | undefined, buildLocalImage?: boolean, version?: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Fails before any host is touched when the published image for this CLI version does not exist yet. A release
|
|
12
|
+
* publishes the npm package a few minutes before its image, so a fresh CLI can briefly name a missing tag. Only a
|
|
13
|
+
* definitive "no such manifest" answer fails; no Docker, no network or a private registry carry on silently.
|
|
14
|
+
*/
|
|
15
|
+
export declare function assertImagePublished(image: string, inspect?: (image: string) => {
|
|
16
|
+
status: number | null;
|
|
17
|
+
stderr: string;
|
|
18
|
+
}): void;
|
package/dist/image.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -18,3 +19,21 @@ export function defaultImage(version = packageVersion()) {
|
|
|
18
19
|
export function resolveImage(image, buildLocalImage = false, version = packageVersion()) {
|
|
19
20
|
return image || (buildLocalImage ? LOCAL_IMAGE : defaultImage(version));
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Fails before any host is touched when the published image for this CLI version does not exist yet. A release
|
|
24
|
+
* publishes the npm package a few minutes before its image, so a fresh CLI can briefly name a missing tag. Only a
|
|
25
|
+
* definitive "no such manifest" answer fails; no Docker, no network or a private registry carry on silently.
|
|
26
|
+
*/
|
|
27
|
+
export function assertImagePublished(image, inspect = (ref) => {
|
|
28
|
+
const result = spawnSync('docker', ['manifest', 'inspect', ref], {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
timeout: 20_000,
|
|
31
|
+
});
|
|
32
|
+
return { status: result.status, stderr: String(result.stderr ?? '') };
|
|
33
|
+
}) {
|
|
34
|
+
if (!image.startsWith(`${IMAGE_REPOSITORY}:`))
|
|
35
|
+
return;
|
|
36
|
+
const result = inspect(image);
|
|
37
|
+
if (result.status !== 0 && /no such manifest|manifest unknown|not found/i.test(result.stderr))
|
|
38
|
+
throw new Error(`${image} is not published yet: a release builds its image a few minutes after the npm package. Retry shortly, or pass --image ${IMAGE_REPOSITORY}:latest to use the latest release.`);
|
|
39
|
+
}
|
package/dist/util.d.ts
CHANGED
|
@@ -20,3 +20,5 @@ export declare const isDuration: (value: unknown) => boolean;
|
|
|
20
20
|
* interface. Hostnames and other non-IP strings return false.
|
|
21
21
|
*/
|
|
22
22
|
export declare function isPublicAddress(address: string): boolean;
|
|
23
|
+
/** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
|
|
24
|
+
export declare function describeFetchError(error: unknown): string;
|
package/dist/util.js
CHANGED
|
@@ -90,3 +90,9 @@ export function isPublicAddress(address) {
|
|
|
90
90
|
}
|
|
91
91
|
return false;
|
|
92
92
|
}
|
|
93
|
+
/** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
|
|
94
|
+
export function describeFetchError(error) {
|
|
95
|
+
const e = error;
|
|
96
|
+
const cause = e?.cause?.code || e?.cause?.message;
|
|
97
|
+
return cause ? `${e?.message ?? 'error'} (${cause})` : errMessage(error);
|
|
98
|
+
}
|