@wpelevator/tug 0.11.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 +110 -68
- package/dist/agent.js +38 -2
- package/dist/backup-config.js +1 -1
- package/dist/backup-restore.d.ts +9 -2
- package/dist/backup-restore.js +75 -9
- 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-config.d.ts +1 -1
- package/dist/cli-config.js +7 -2
- 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/cli.js +4 -0
- package/dist/commands/backup.js +48 -31
- package/dist/commands/context.d.ts +6 -5
- package/dist/commands/context.js +65 -28
- package/dist/commands/control.js +56 -42
- package/dist/commands/credential.js +21 -2
- package/dist/commands/deploy.d.ts +4 -0
- package/dist/commands/deploy.js +116 -69
- package/dist/commands/hold.d.ts +2 -0
- package/dist/commands/hold.js +21 -0
- package/dist/commands/host.d.ts +8 -2
- package/dist/commands/host.js +36 -8
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +13 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/node.d.ts +1 -0
- package/dist/commands/node.js +139 -73
- 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 +234 -55
- package/dist/credentials.js +7 -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/docker-api.d.ts +1 -0
- package/dist/image.d.ts +9 -0
- package/dist/image.js +19 -0
- package/dist/live.js +1 -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,9 +30,9 @@ 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
|
-
import { createRestoreService } from "./backup-restore.js";
|
|
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";
|
|
34
37
|
class WakeHub {
|
|
35
38
|
cursors = new Map();
|
|
@@ -758,6 +761,7 @@ export async function createApp(options = {}) {
|
|
|
758
761
|
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 || '');
|
|
759
762
|
if (!previousDs)
|
|
760
763
|
throw httpError(404, 'no previous deployment found');
|
|
764
|
+
assertArtifactsAvailable(JSON.parse(previousDs.state_json), `roll back to ${previousDs.version}`);
|
|
761
765
|
const ts = now();
|
|
762
766
|
const depId = id('dep');
|
|
763
767
|
const previousState = JSON.parse(previousDs.state_json);
|
|
@@ -863,18 +867,24 @@ export async function createApp(options = {}) {
|
|
|
863
867
|
api('get', '/api/v1/live', null, (_req, res) => {
|
|
864
868
|
res.status(426).json({ error: 'WebSocket upgrade required' });
|
|
865
869
|
});
|
|
870
|
+
const locks = lockQueries(db);
|
|
866
871
|
function acquireProjectLock(project, operation, ttlMs = Number(process.env.TUG_LOCK_TTL_MS || 10 * 60 * 1000)) {
|
|
867
872
|
const name = `project:${project}`;
|
|
868
873
|
const ts = now();
|
|
869
|
-
|
|
874
|
+
locks.purgeExpired(ts);
|
|
870
875
|
const owner = `${operation}:${id('lock')}`;
|
|
871
876
|
try {
|
|
872
|
-
|
|
877
|
+
locks.insert({
|
|
878
|
+
name,
|
|
879
|
+
owner,
|
|
880
|
+
expires_at: new Date(Date.now() + ttlMs).toISOString(),
|
|
881
|
+
created_at: ts,
|
|
882
|
+
});
|
|
873
883
|
}
|
|
874
884
|
catch (e) {
|
|
875
885
|
if (!isUniqueViolation(e))
|
|
876
886
|
throw e;
|
|
877
|
-
const existing =
|
|
887
|
+
const existing = locks.find(name);
|
|
878
888
|
const err = new Error(`project ${project} is locked by ${existing?.owner} until ${existing?.expires_at}`);
|
|
879
889
|
err.status = 409;
|
|
880
890
|
err.lock = existing;
|
|
@@ -882,7 +892,7 @@ export async function createApp(options = {}) {
|
|
|
882
892
|
}
|
|
883
893
|
const heartbeat = setInterval(() => {
|
|
884
894
|
try {
|
|
885
|
-
|
|
895
|
+
locks.renew(name, owner, new Date(Date.now() + ttlMs).toISOString());
|
|
886
896
|
}
|
|
887
897
|
catch {
|
|
888
898
|
/* Best-effort bookkeeping. */
|
|
@@ -896,7 +906,7 @@ export async function createApp(options = {}) {
|
|
|
896
906
|
clearInterval(lock.heartbeat);
|
|
897
907
|
if (lock)
|
|
898
908
|
try {
|
|
899
|
-
|
|
909
|
+
locks.release(lock.name, lock.owner);
|
|
900
910
|
}
|
|
901
911
|
catch {
|
|
902
912
|
/* Best-effort bookkeeping. */
|
|
@@ -934,10 +944,17 @@ export async function createApp(options = {}) {
|
|
|
934
944
|
const global = ext.global === true;
|
|
935
945
|
const labels = Array.isArray(placement.labels) ? placement.labels : [];
|
|
936
946
|
const matched = nodeInfos.filter((node) => labels.every((label) => node.labels.includes(label)));
|
|
937
|
-
|
|
947
|
+
// Explicit `placement.nodes` (without labels or global, which deploy resolves into nodes) bound the pool; scaling never re-homes a service off them.
|
|
948
|
+
const pinned = !labels.length && !global && Array.isArray(placement.nodes)
|
|
949
|
+
? placement.nodes
|
|
950
|
+
: [];
|
|
951
|
+
const candidates = pinned.length
|
|
952
|
+
? matched.filter((node) => pinned.includes(node.name))
|
|
953
|
+
: matched;
|
|
954
|
+
const pool = candidates;
|
|
938
955
|
if (!pool.length)
|
|
939
|
-
throw httpError(400, labels.length
|
|
940
|
-
? 'no healthy nodes match the service placement
|
|
956
|
+
throw httpError(400, labels.length || pinned.length
|
|
957
|
+
? 'no healthy nodes match the service placement'
|
|
941
958
|
: 'no nodes available for scaling');
|
|
942
959
|
const selected = global ? pool : pool.slice(0, Math.min(replicas, pool.length));
|
|
943
960
|
state.services[service].placement = {
|
|
@@ -1277,16 +1294,17 @@ export async function createApp(options = {}) {
|
|
|
1277
1294
|
});
|
|
1278
1295
|
res.json({ ok: true, name: String(req.params.name) });
|
|
1279
1296
|
});
|
|
1280
|
-
const
|
|
1297
|
+
const destinationQueries = backupDestinationQueries(db);
|
|
1298
|
+
const backupDestination = () => destinationQueries.find();
|
|
1281
1299
|
const backupEnv = () => {
|
|
1282
1300
|
const row = backupDestination();
|
|
1283
1301
|
if (!row)
|
|
1284
1302
|
return {};
|
|
1285
1303
|
const env = {};
|
|
1286
1304
|
for (const name of JSON.parse(row.env_names_json)) {
|
|
1287
|
-
const secret =
|
|
1305
|
+
const secret = destinationQueries.secret(name);
|
|
1288
1306
|
if (secret)
|
|
1289
|
-
env[name] = decrypt(secret
|
|
1307
|
+
env[name] = decrypt(secret, masterKey);
|
|
1290
1308
|
}
|
|
1291
1309
|
return env;
|
|
1292
1310
|
};
|
|
@@ -1363,7 +1381,9 @@ export async function createApp(options = {}) {
|
|
|
1363
1381
|
return res.status(started.status).json({ error: started.error });
|
|
1364
1382
|
res.json({ confirmed: true, plan, ...started });
|
|
1365
1383
|
});
|
|
1366
|
-
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
|
+
}));
|
|
1367
1387
|
api('post', '/api/v1/restores/:id/release', null, async (req, res) => {
|
|
1368
1388
|
const result = restores.releaseInterrupted(String(req.params.id));
|
|
1369
1389
|
if ('error' in result)
|
|
@@ -1377,6 +1397,7 @@ export async function createApp(options = {}) {
|
|
|
1377
1397
|
res.json(result);
|
|
1378
1398
|
});
|
|
1379
1399
|
api('get', '/api/v1/backups', queryProject, async (req, res) => {
|
|
1400
|
+
backups.sync();
|
|
1380
1401
|
const project = String(req.query.project || 'site');
|
|
1381
1402
|
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
|
|
1382
1403
|
res.json({
|
|
@@ -1407,19 +1428,11 @@ export async function createApp(options = {}) {
|
|
|
1407
1428
|
return res.status(400).json({ error: errMessage(e) });
|
|
1408
1429
|
}
|
|
1409
1430
|
const ts = now();
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
db.run(`INSERT INTO backup_destination(id,url,env_names_json,checked_at,check_result_json,updated_at) VALUES('default',?,?,NULL,NULL,?)
|
|
1416
|
-
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);
|
|
1417
|
-
db.exec('COMMIT');
|
|
1418
|
-
}
|
|
1419
|
-
catch (e) {
|
|
1420
|
-
db.exec('ROLLBACK');
|
|
1421
|
-
throw e;
|
|
1422
|
-
}
|
|
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);
|
|
1423
1436
|
audit(req.actor, {
|
|
1424
1437
|
type: 'backup.destination_set',
|
|
1425
1438
|
message: `backup destination set to ${parsed.url} by ${req.actor.name}`,
|
|
@@ -1428,14 +1441,13 @@ export async function createApp(options = {}) {
|
|
|
1428
1441
|
res.json({ ok: true, url: parsed.url, envNames: Object.keys(parsed.env).sort() });
|
|
1429
1442
|
});
|
|
1430
1443
|
api('delete', '/api/v1/backup/destination', null, async (req, res) => {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
if (removed.changes)
|
|
1444
|
+
const removed = destinationQueries.remove();
|
|
1445
|
+
if (removed)
|
|
1434
1446
|
audit(req.actor, {
|
|
1435
1447
|
type: 'backup.destination_removed',
|
|
1436
1448
|
message: `backup destination removed by ${req.actor.name}`,
|
|
1437
1449
|
});
|
|
1438
|
-
res.json({ ok: true, removed
|
|
1450
|
+
res.json({ ok: true, removed });
|
|
1439
1451
|
});
|
|
1440
1452
|
api('get', '/api/v1/backup/repository-password', null, async (req, res) => {
|
|
1441
1453
|
const project = String(req.query.project || '');
|
|
@@ -1536,7 +1548,70 @@ export async function createApp(options = {}) {
|
|
|
1536
1548
|
}
|
|
1537
1549
|
// Previous-process builds cannot finish after a restart; clear their unpublished files.
|
|
1538
1550
|
db.run("UPDATE deployments SET state='build_failed',error_message='control restarted during build' WHERE state IN ('uploading','building')");
|
|
1551
|
+
/** Archives deleted by `image prune`, or missing from a backup taken without images, can no longer be deployed. */
|
|
1552
|
+
async function markMissingArtifacts() {
|
|
1553
|
+
for (const row of db.all("SELECT * FROM builds WHERE state='complete' AND artifact_path IS NOT NULL"))
|
|
1554
|
+
if (!(await fs.stat(path.join(DATA_DIR, row.artifact_path)).catch(() => null)))
|
|
1555
|
+
db.run("UPDATE builds SET state='pruned',artifact_path=NULL,updated_at=? WHERE id=?", now(), row.id);
|
|
1556
|
+
}
|
|
1557
|
+
/** Throws a 409 when a desired state needs a build archive that was pruned. */
|
|
1558
|
+
function assertArtifactsAvailable(state, action) {
|
|
1559
|
+
for (const [name, service] of Object.entries(state.services)) {
|
|
1560
|
+
if (!service.artifact)
|
|
1561
|
+
continue;
|
|
1562
|
+
const build = db.get('SELECT * FROM builds WHERE id=?', service.artifact.buildId);
|
|
1563
|
+
if (build?.state === 'pruned')
|
|
1564
|
+
throw httpError(409, `cannot ${action}: the build archive of ${name} was pruned (tug image prune); deploy it again instead`);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Deletes build archives no longer needed: everything except the newest `keep` desired states of each project,
|
|
1569
|
+
* the current deployment and any version a node still reports. The desired states stay, so history lists them,
|
|
1570
|
+
* but rolling back to a pruned one is refused.
|
|
1571
|
+
*/
|
|
1572
|
+
async function pruneArtifacts(keep, dryRun) {
|
|
1573
|
+
const kept = new Set();
|
|
1574
|
+
const reported = db
|
|
1575
|
+
.all('SELECT project_id,status_json FROM node_statuses')
|
|
1576
|
+
.map((row) => ({
|
|
1577
|
+
project: row.project_id,
|
|
1578
|
+
...JSON.parse(row.status_json),
|
|
1579
|
+
}));
|
|
1580
|
+
const current = new Set(db
|
|
1581
|
+
.all('SELECT current_deployment_id FROM projects')
|
|
1582
|
+
.map((row) => row.current_deployment_id));
|
|
1583
|
+
const rows = db.all('SELECT * FROM desired_states ORDER BY created_at DESC');
|
|
1584
|
+
const seen = new Map();
|
|
1585
|
+
for (const row of rows) {
|
|
1586
|
+
const index = seen.get(row.project_id) ?? 0;
|
|
1587
|
+
seen.set(row.project_id, index + 1);
|
|
1588
|
+
const state = JSON.parse(row.state_json);
|
|
1589
|
+
const lagging = reported.some((r) => r.project === row.project_id &&
|
|
1590
|
+
r.observedVersion === state.version &&
|
|
1591
|
+
(!r.observedRevision || r.observedRevision === state.revision));
|
|
1592
|
+
if (index >= keep && !lagging && !current.has(row.deployment_id))
|
|
1593
|
+
continue;
|
|
1594
|
+
for (const service of Object.values(state.services))
|
|
1595
|
+
if (service.artifact)
|
|
1596
|
+
kept.add(service.artifact.buildId);
|
|
1597
|
+
}
|
|
1598
|
+
const pruned = [];
|
|
1599
|
+
for (const build of db.all("SELECT * FROM builds WHERE state='complete' AND artifact_path IS NOT NULL")) {
|
|
1600
|
+
if (kept.has(build.id))
|
|
1601
|
+
continue;
|
|
1602
|
+
pruned.push({ id: build.id, service: build.service, sizeBytes: build.size_bytes });
|
|
1603
|
+
if (dryRun)
|
|
1604
|
+
continue;
|
|
1605
|
+
await fs.rm(path.join(DATA_DIR, build.artifact_path), { force: true });
|
|
1606
|
+
db.run("UPDATE builds SET state='pruned',artifact_path=NULL,updated_at=? WHERE id=?", now(), build.id);
|
|
1607
|
+
const shared = db.get("SELECT 1 FROM builds WHERE image_ref=? AND state='complete'", build.image_ref);
|
|
1608
|
+
if (!shared)
|
|
1609
|
+
await images.run('docker', ['image', 'rm', build.image_ref]).catch(() => { });
|
|
1610
|
+
}
|
|
1611
|
+
return { pruned, freedBytes: pruned.reduce((sum, build) => sum + build.sizeBytes, 0) };
|
|
1612
|
+
}
|
|
1539
1613
|
await collectArtifacts();
|
|
1614
|
+
await markMissingArtifacts();
|
|
1540
1615
|
const receiveDeployment = upload.any();
|
|
1541
1616
|
api('post', '/api/v1/deployments', null, (req, res) => {
|
|
1542
1617
|
receiveDeployment(req, res, (error) => {
|
|
@@ -1547,6 +1622,25 @@ export async function createApp(options = {}) {
|
|
|
1547
1622
|
});
|
|
1548
1623
|
});
|
|
1549
1624
|
});
|
|
1625
|
+
/**
|
|
1626
|
+
* Services that keep data on a node and are placed on different nodes by `compose` than by the current
|
|
1627
|
+
* deployment. Deploying such a change starts the service empty on the new node: the data does not move.
|
|
1628
|
+
*/
|
|
1629
|
+
function movedServices(project, compose) {
|
|
1630
|
+
const current = currentDesired(project);
|
|
1631
|
+
if (!current)
|
|
1632
|
+
return [];
|
|
1633
|
+
const before = JSON.parse(current.state_json);
|
|
1634
|
+
return Object.entries(compose.services || {})
|
|
1635
|
+
.filter(([name, svc]) => {
|
|
1636
|
+
const old = before.services?.[name]?.placement?.nodes ?? [];
|
|
1637
|
+
const next = svc['x-tug']?.placement?.nodes ?? [];
|
|
1638
|
+
const keepsData = Boolean(svc['x-tug']?.backup) ||
|
|
1639
|
+
(svc.volumes ?? []).some((mount) => typeof mount === 'object' && mount.type === 'volume');
|
|
1640
|
+
return keepsData && old.length > 0 && next.length > 0 && old.some((n) => !next.includes(n));
|
|
1641
|
+
})
|
|
1642
|
+
.map(([name]) => name);
|
|
1643
|
+
}
|
|
1550
1644
|
async function deployRequest(req, res, uploadError) {
|
|
1551
1645
|
const files = (Array.isArray(req.files) ? req.files : []);
|
|
1552
1646
|
let responseBody;
|
|
@@ -1620,6 +1714,10 @@ export async function createApp(options = {}) {
|
|
|
1620
1714
|
throw httpError(400, errMessage(e));
|
|
1621
1715
|
}
|
|
1622
1716
|
assertIngressHostsFree(project, compose);
|
|
1717
|
+
const acknowledgedMoves = Array.isArray(body.moves) ? body.moves.map(String) : [];
|
|
1718
|
+
const unacknowledged = movedServices(project, compose).filter((name) => !acknowledgedMoves.includes(name));
|
|
1719
|
+
if (unacknowledged.length)
|
|
1720
|
+
throw httpError(409, `${unacknowledged.join(', ')} would start empty on a different node: its data does not move. Back it up, then deploy with --move ${unacknowledged.join(' --move ')} to hold the services that write to it, and run tug restore run <target> --confirm`);
|
|
1623
1721
|
// The CLI interpolates control secrets as sentinels; a reference to a secret that is gone would deploy an empty value.
|
|
1624
1722
|
const referenced = sentinelNames(compose);
|
|
1625
1723
|
if (referenced.includes(REGISTRY_AUTH_SECRET))
|
|
@@ -1677,7 +1775,7 @@ export async function createApp(options = {}) {
|
|
|
1677
1775
|
lock = acquireProjectLock(project, 'deploy');
|
|
1678
1776
|
const active = db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
|
|
1679
1777
|
if (active)
|
|
1680
|
-
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}`);
|
|
1681
1779
|
depId = id('dep');
|
|
1682
1780
|
const ts = now();
|
|
1683
1781
|
db.run('INSERT OR IGNORE INTO projects(id,name,created_at,updated_at) VALUES(?,?,?,?)', project, project, ts, ts);
|
|
@@ -1759,17 +1857,20 @@ export async function createApp(options = {}) {
|
|
|
1759
1857
|
const buildLock = lock;
|
|
1760
1858
|
const publishingId = depId;
|
|
1761
1859
|
await withPublication(() => {
|
|
1762
|
-
const owned =
|
|
1860
|
+
const owned = locks.owns(buildLock.name, buildLock.owner, now());
|
|
1763
1861
|
const pending = db.get('SELECT 1 FROM deployments WHERE id=? AND state=?', publishingId, 'building');
|
|
1764
1862
|
if (!owned || !pending)
|
|
1765
1863
|
throw httpError(409, 'deployment was cancelled or its build lease was lost');
|
|
1766
|
-
db
|
|
1767
|
-
try {
|
|
1864
|
+
immediateTransaction(db, () => {
|
|
1768
1865
|
const published = db.run("UPDATE deployments SET state='rolling_out',updated_at=? WHERE id=? AND state='building'", now(), publishingId);
|
|
1769
|
-
if (published.changes !== 1 ||
|
|
1770
|
-
!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()))
|
|
1771
1867
|
throw httpError(409, 'deployment was cancelled or its build lease was lost');
|
|
1772
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);
|
|
1869
|
+
// The moved service starts empty: keep what writes to it stopped until its data is restored.
|
|
1870
|
+
for (const moved of acknowledgedMoves.filter((name) => compose.services?.[name]))
|
|
1871
|
+
for (const writer of discoverWriters(compose, `service:${moved}`))
|
|
1872
|
+
if (!acknowledgedMoves.includes(writer))
|
|
1873
|
+
db.run('INSERT OR REPLACE INTO service_holds(project_id,service,reason,restore_id,created_at) VALUES(?,?,?,?,?)', project, writer, `moved ${moved}`, null, ts);
|
|
1773
1874
|
for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
|
|
1774
1875
|
const lp = volumeSpec?.['x-tug'] || {};
|
|
1775
1876
|
const nodeName = lp.placement?.nodes?.[0] || null;
|
|
@@ -1779,17 +1880,7 @@ export async function createApp(options = {}) {
|
|
|
1779
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);
|
|
1780
1881
|
}
|
|
1781
1882
|
db.run('UPDATE projects SET current_deployment_id=?,updated_at=? WHERE id=?', publishingId, now(), project);
|
|
1782
|
-
|
|
1783
|
-
}
|
|
1784
|
-
catch (e) {
|
|
1785
|
-
try {
|
|
1786
|
-
db.exec('ROLLBACK');
|
|
1787
|
-
}
|
|
1788
|
-
catch {
|
|
1789
|
-
/* Preserve the publication error. */
|
|
1790
|
-
}
|
|
1791
|
-
throw e;
|
|
1792
|
-
}
|
|
1883
|
+
});
|
|
1793
1884
|
});
|
|
1794
1885
|
audit(req.actor, {
|
|
1795
1886
|
type: 'deploy',
|
|
@@ -1888,9 +1979,21 @@ export async function createApp(options = {}) {
|
|
|
1888
1979
|
sendError(res, e);
|
|
1889
1980
|
}
|
|
1890
1981
|
}));
|
|
1891
|
-
api('get', '/api/v1/volumes', queryProject, async (req, res) =>
|
|
1892
|
-
|
|
1893
|
-
|
|
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
|
+
});
|
|
1894
1997
|
api('delete', '/api/v1/volumes/:name', queryProject, async (req, res) => {
|
|
1895
1998
|
const project = String(req.query.project || 'site');
|
|
1896
1999
|
const deleteData = String(req.query.deleteData || 'false') === 'true';
|
|
@@ -1933,6 +2036,31 @@ export async function createApp(options = {}) {
|
|
|
1933
2036
|
releaseLock(lock);
|
|
1934
2037
|
}
|
|
1935
2038
|
});
|
|
2039
|
+
api('post', '/api/v1/holds/release', bodyProject, async (req, res) => withWriter(async () => {
|
|
2040
|
+
const project = String(req.body?.project || 'site');
|
|
2041
|
+
const services = Array.isArray(req.body?.services)
|
|
2042
|
+
? req.body.services.map(String)
|
|
2043
|
+
: [];
|
|
2044
|
+
// Only holds a deploy placed for a move; a restore's holds are dropped by `restore release`.
|
|
2045
|
+
const rows = db.all('SELECT service FROM service_holds WHERE project_id=? AND restore_id IS NULL', project);
|
|
2046
|
+
const released = rows
|
|
2047
|
+
.map((row) => row.service)
|
|
2048
|
+
.filter((service) => !services.length || services.includes(service));
|
|
2049
|
+
for (const service of released)
|
|
2050
|
+
db.run('DELETE FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', project, service);
|
|
2051
|
+
wakeAll(project);
|
|
2052
|
+
res.json({ ok: true, project, released });
|
|
2053
|
+
}));
|
|
2054
|
+
api('post', '/api/v1/images/prune', null, async (req, res) => withWriter(async () => {
|
|
2055
|
+
const keep = req.body?.keep === undefined ? 5 : Number(req.body.keep);
|
|
2056
|
+
if (!Number.isInteger(keep) || keep < 1)
|
|
2057
|
+
return res.status(400).json({ error: 'keep must be a positive integer' });
|
|
2058
|
+
res.json({
|
|
2059
|
+
dryRun: Boolean(req.body?.dryRun),
|
|
2060
|
+
keep,
|
|
2061
|
+
...(await pruneArtifacts(keep, Boolean(req.body?.dryRun))),
|
|
2062
|
+
});
|
|
2063
|
+
}));
|
|
1936
2064
|
api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
|
|
1937
2065
|
const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
|
|
1938
2066
|
// The archive holds the master key and the CA key, so nothing here is ever readable beyond the control user.
|
|
@@ -1954,14 +2082,16 @@ export async function createApp(options = {}) {
|
|
|
1954
2082
|
recursive: true,
|
|
1955
2083
|
filter: (src) => path.basename(src) !== 'server.json',
|
|
1956
2084
|
});
|
|
1957
|
-
|
|
1958
|
-
|
|
2085
|
+
const withImages = req.body?.images !== false;
|
|
2086
|
+
if (withImages)
|
|
2087
|
+
await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
|
|
2088
|
+
await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control', images: withImages }, null, 2), { mode: 0o600 });
|
|
1959
2089
|
await tar.c({ gzip: true, file: artifactRef, cwd: staging, mode: 0o600 }, [
|
|
1960
2090
|
'tug.db',
|
|
1961
2091
|
'master.key',
|
|
1962
2092
|
'metadata.json',
|
|
1963
2093
|
'pki',
|
|
1964
|
-
'images',
|
|
2094
|
+
...(withImages ? ['images'] : []),
|
|
1965
2095
|
]);
|
|
1966
2096
|
}
|
|
1967
2097
|
catch (error) {
|
|
@@ -2007,11 +2137,16 @@ export async function createApp(options = {}) {
|
|
|
2007
2137
|
for (const secret of snapshot.all('SELECT * FROM secrets'))
|
|
2008
2138
|
decrypt(secret.encrypted_value, key);
|
|
2009
2139
|
const states = snapshot.all('SELECT * FROM desired_states');
|
|
2010
|
-
|
|
2140
|
+
const meta = JSON.parse(await fs.readFile(path.join(restoreDir, 'metadata.json'), 'utf8').catch(() => '{}'));
|
|
2141
|
+
// A backup taken without images restores the archive-less builds as pruned on the next start.
|
|
2142
|
+
for (const stateRow of meta.images === false ? [] : states) {
|
|
2011
2143
|
const state = JSON.parse(stateRow.state_json);
|
|
2012
2144
|
for (const service of Object.values(state.services)) {
|
|
2013
2145
|
if (!service.artifact)
|
|
2014
2146
|
continue;
|
|
2147
|
+
// `image prune` already removed this one before the backup.
|
|
2148
|
+
if (snapshot.get("SELECT 1 FROM builds WHERE id=? AND state='pruned'", service.artifact.buildId))
|
|
2149
|
+
continue;
|
|
2015
2150
|
const build = snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
|
|
2016
2151
|
if (!build?.artifact_path ||
|
|
2017
2152
|
!/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
|
|
@@ -2117,6 +2252,50 @@ export async function createApp(options = {}) {
|
|
|
2117
2252
|
res.json({ commandId: cmdId, nodeId: node.id });
|
|
2118
2253
|
});
|
|
2119
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
|
+
});
|
|
2120
2299
|
api('get', '/api/v1/status', null, async (req, res) => {
|
|
2121
2300
|
const staleSeconds = Number(process.env.TUG_STALE_SECONDS || 60);
|
|
2122
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',
|
|
@@ -53,6 +58,8 @@ export const ROUTE_ROLES = {
|
|
|
53
58
|
'DELETE /api/v1/backup/destination': 'admin',
|
|
54
59
|
'GET /api/v1/backup/repository-password': 'admin',
|
|
55
60
|
'POST /api/v1/control/backups/run': 'admin',
|
|
61
|
+
'POST /api/v1/images/prune': 'admin',
|
|
62
|
+
'POST /api/v1/holds/release': 'admin',
|
|
56
63
|
'POST /api/v1/control/restore': 'admin',
|
|
57
64
|
'GET /api/v1/diagnostics': 'admin',
|
|
58
65
|
'POST /api/v1/diagnostics/nodes': 'admin',
|
|
@@ -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
|
+
};
|