@wpelevator/tug 0.10.0 → 0.12.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 +172 -75
- package/dist/agent-credential.js +3 -3
- package/dist/agent.js +39 -3
- package/dist/backup-check.d.ts +1 -1
- package/dist/backup-config.js +33 -25
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/backup-restore.d.ts +8 -1
- package/dist/backup-restore.js +135 -71
- package/dist/backup-scheduler.js +1 -1
- package/dist/cli-build.js +1 -1
- package/dist/cli-config.d.ts +7 -2
- package/dist/cli-config.js +25 -46
- package/dist/cli-install.d.ts +4 -2
- package/dist/cli-install.js +12 -7
- package/dist/cli-live.js +4 -6
- package/dist/cli-onboarding.d.ts +31 -0
- package/dist/cli-onboarding.js +66 -0
- package/dist/cli-prompt.d.ts +9 -0
- package/dist/cli-prompt.js +60 -0
- package/dist/cli-secrets.d.ts +12 -0
- package/dist/cli-secrets.js +71 -0
- package/dist/cli-status.d.ts +11 -0
- package/dist/cli-status.js +37 -7
- package/dist/cli.js +66 -981
- package/dist/commands/backup.d.ts +2 -0
- package/dist/commands/backup.js +109 -0
- package/dist/commands/context.d.ts +58 -0
- package/dist/commands/context.js +111 -0
- package/dist/commands/control.d.ts +2 -0
- package/dist/commands/control.js +111 -0
- package/dist/commands/credential.d.ts +2 -0
- package/dist/commands/credential.js +36 -0
- package/dist/commands/deploy.d.ts +6 -0
- package/dist/commands/deploy.js +159 -0
- package/dist/commands/hold.d.ts +2 -0
- package/dist/commands/hold.js +13 -0
- package/dist/commands/host.d.ts +24 -0
- package/dist/commands/host.js +122 -0
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +13 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +40 -0
- package/dist/commands/node.d.ts +28 -0
- package/dist/commands/node.js +354 -0
- package/dist/commands/observe.d.ts +2 -0
- package/dist/commands/observe.js +71 -0
- package/dist/commands/restore.d.ts +2 -0
- package/dist/commands/restore.js +85 -0
- package/dist/commands/secret.d.ts +2 -0
- package/dist/commands/secret.js +26 -0
- package/dist/commands/service.d.ts +2 -0
- package/dist/commands/service.js +62 -0
- package/dist/commands/volume.d.ts +2 -0
- package/dist/commands/volume.js +13 -0
- package/dist/control.js +145 -14
- package/dist/credentials.js +2 -0
- package/dist/diagnostics.js +1 -1
- package/dist/docker-api.d.ts +1 -0
- package/dist/live.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { errMessage } from "../util.js";
|
|
2
|
+
import { formatStatus } from "../cli-status.js";
|
|
3
|
+
import { loadCfg, print, request, runCapture, waitForCommand } from "./context.js";
|
|
4
|
+
export function registerObserve(program) {
|
|
5
|
+
program
|
|
6
|
+
.command('status')
|
|
7
|
+
.description('summarize projects, deployments and nodes')
|
|
8
|
+
.option('--json', 'print the raw status as JSON for scripts')
|
|
9
|
+
.action(async (o) => {
|
|
10
|
+
const status = await request('GET', '/api/v1/status');
|
|
11
|
+
console.log(o.json ? JSON.stringify(status, null, 2) : formatStatus(status));
|
|
12
|
+
});
|
|
13
|
+
program
|
|
14
|
+
.command('events')
|
|
15
|
+
.description('list recent events with the credential that caused each control action; defaults to the resolved project')
|
|
16
|
+
.option('--limit <n>', 'number of events', '100')
|
|
17
|
+
.option('--all', 'list events across all projects (limited to your projects for scoped credentials)')
|
|
18
|
+
.action(async (o) => {
|
|
19
|
+
if (o.all && o.project)
|
|
20
|
+
throw new Error('--all and --project cannot be combined');
|
|
21
|
+
const cfg = await loadCfg();
|
|
22
|
+
const project = o.all ? undefined : o.project || cfg.project;
|
|
23
|
+
const query = `limit=${encodeURIComponent(o.limit)}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
|
|
24
|
+
print(await request('GET', `/api/v1/events?${query}`));
|
|
25
|
+
});
|
|
26
|
+
program
|
|
27
|
+
.command('doctor')
|
|
28
|
+
.description('check this machine and the control plane: certificate expiry, node connectivity and clock skew')
|
|
29
|
+
.option('--nodes', 'also run host diagnostics on every node through its agent and wait')
|
|
30
|
+
.option('--node <name>', 'run host diagnostics on one node through its agent and wait')
|
|
31
|
+
.option('--timeout <seconds>', 'wait timeout for node diagnostics', '60')
|
|
32
|
+
.action(async (o) => {
|
|
33
|
+
const local = [];
|
|
34
|
+
for (const [name, cmd, args] of [
|
|
35
|
+
['node', 'node', ['--version']],
|
|
36
|
+
['docker', 'docker', ['version', '--format', '{{.Server.Version}}']],
|
|
37
|
+
['docker_compose', 'docker', ['compose', 'version']],
|
|
38
|
+
]) {
|
|
39
|
+
try {
|
|
40
|
+
local.push({ name, ok: true, output: runCapture(cmd, args).trim() });
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
local.push({ name, ok: false, error: errMessage(e) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
|
|
47
|
+
const warnings = (controlDiagnostics.checks ?? []).flatMap((check) => (check.warnings ?? []).map((warning) => `${check.name}: ${warning}`));
|
|
48
|
+
const output = {
|
|
49
|
+
ok: local.every((c) => c.ok) && controlDiagnostics.ok,
|
|
50
|
+
...(warnings.length && { warnings }),
|
|
51
|
+
local,
|
|
52
|
+
control: controlDiagnostics,
|
|
53
|
+
};
|
|
54
|
+
if (o.nodes || o.node) {
|
|
55
|
+
const queued = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
|
|
56
|
+
const commands = [];
|
|
57
|
+
for (const command of queued.commands) {
|
|
58
|
+
const finished = await waitForCommand(command.commandId, Number(o.timeout));
|
|
59
|
+
commands.push({
|
|
60
|
+
nodeId: command.nodeId,
|
|
61
|
+
state: finished.state,
|
|
62
|
+
result: JSON.parse(finished.result_json || '{}'),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
output.nodeDiagnostics = { commands };
|
|
66
|
+
if (commands.some((command) => command.state !== 'succeeded'))
|
|
67
|
+
output.ok = false;
|
|
68
|
+
}
|
|
69
|
+
print(output);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { collect, loadCfg, request } from "./context.js";
|
|
2
|
+
export function registerRestore(program) {
|
|
3
|
+
const restore = program
|
|
4
|
+
.command('restore')
|
|
5
|
+
.description('restore services and volumes from backups');
|
|
6
|
+
restore
|
|
7
|
+
.command('run <target>')
|
|
8
|
+
.description('restore a service or volume from a backup. Without --confirm it only prints the plan. An in-place restore locks the project, stops the services that write to the target, takes a safety backup, restores, then starts them again')
|
|
9
|
+
.option('--from <id>', 'backup run id, restic snapshot id, or latest', 'latest')
|
|
10
|
+
.option('--scratch', 'restore a volume into a new volume next to the live one, touching nothing live')
|
|
11
|
+
.option('--no-safety-backup', 'skip the backup of the current data taken before an in-place restore')
|
|
12
|
+
.option('--writers <service>', 'service to stop during the restore, instead of discovering them (repeatable)', collect, [])
|
|
13
|
+
.option('--no-writers', 'restore without stopping any service')
|
|
14
|
+
.option('--confirm', 'perform the restore')
|
|
15
|
+
.option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
|
|
16
|
+
.action(async (target, o) => {
|
|
17
|
+
const project = (await loadCfg()).project || 'site';
|
|
18
|
+
const explicit = Array.isArray(o.writers) && o.writers.length > 0 ? o.writers : undefined;
|
|
19
|
+
const result = await request('POST', '/api/v1/restores', {
|
|
20
|
+
project,
|
|
21
|
+
target,
|
|
22
|
+
from: o.from,
|
|
23
|
+
scratch: o.scratch === true,
|
|
24
|
+
safetyBackup: o.safetyBackup,
|
|
25
|
+
writers: explicit,
|
|
26
|
+
noWriters: o.writers === false,
|
|
27
|
+
confirm: o.confirm === true,
|
|
28
|
+
});
|
|
29
|
+
const plan = result.plan;
|
|
30
|
+
console.log(`target: ${plan.target} on ${plan.node}`);
|
|
31
|
+
console.log(`snapshot: ${plan.snapshotId}${plan.snapshotTime ? ` (${plan.snapshotTime})` : ''}`);
|
|
32
|
+
console.log(`mode: ${plan.mode}${plan.scratchVolume ? ` into new volume ${plan.scratchVolume}` : ''}`);
|
|
33
|
+
if (plan.mode === 'in-place') {
|
|
34
|
+
console.log(`stops: ${plan.writers.length ? plan.writers.join(', ') : 'nothing'}`);
|
|
35
|
+
console.log(`safety: ${plan.safetyBackup ? 'a backup of the current data is taken first' : 'none (--no-safety-backup)'}`);
|
|
36
|
+
}
|
|
37
|
+
if (!result.confirmed) {
|
|
38
|
+
console.log('\nNothing was changed. Run again with --confirm to restore.');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const deadline = Date.now() + Number(o.timeout) * 1000;
|
|
42
|
+
for (;;) {
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
44
|
+
const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
|
|
45
|
+
const record = restores.find((item) => item.id === result.restoreId);
|
|
46
|
+
if (record?.state === 'succeeded') {
|
|
47
|
+
if (record.error)
|
|
48
|
+
console.error(`warning: ${record.error}`);
|
|
49
|
+
console.log(`restore ${record.id} completed${record.scratch_volume ? `; files are in volume ${record.scratch_volume}` : ''}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (record && ['failed', 'interrupted', 'released'].includes(record.state)) {
|
|
53
|
+
console.error(`restore ${record.id} ${record.state}: ${record.error ?? ''}`);
|
|
54
|
+
if (record.state === 'interrupted')
|
|
55
|
+
console.error(`the services stay stopped; run tug restore resume ${record.id} to try again or tug restore release ${record.id} to start them (data may be partly restored)`);
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (Date.now() > deadline) {
|
|
60
|
+
console.error(`timed out waiting; restore ${result.restoreId} continues on control (tug restore list)`);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
restore
|
|
67
|
+
.command('list')
|
|
68
|
+
.description('show recent restores and their state')
|
|
69
|
+
.action(async () => {
|
|
70
|
+
const project = (await loadCfg()).project || 'site';
|
|
71
|
+
const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
|
|
72
|
+
if (!restores.length)
|
|
73
|
+
console.log('no restores');
|
|
74
|
+
for (const r of restores)
|
|
75
|
+
console.log(`${r.started_at}\t${r.id}\t${r.target}\t${r.mode}\t${r.state}\t${r.snapshot_id.slice(0, 8)}${r.error ? `\t${r.error}` : ''}`);
|
|
76
|
+
});
|
|
77
|
+
restore
|
|
78
|
+
.command('resume <id>')
|
|
79
|
+
.description('run an interrupted restore again; the stopped services stay stopped meanwhile')
|
|
80
|
+
.action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/resume`))));
|
|
81
|
+
restore
|
|
82
|
+
.command('release <id>')
|
|
83
|
+
.description('start the services an interrupted restore stopped and unlock the project; the data may be partly restored')
|
|
84
|
+
.action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/release`))));
|
|
85
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { print, projectOf, request } from "./context.js";
|
|
2
|
+
export function registerSecret(program) {
|
|
3
|
+
const secret = program
|
|
4
|
+
.command('secret')
|
|
5
|
+
.description('manage the project secrets that Compose files reference');
|
|
6
|
+
secret
|
|
7
|
+
.command('set <name>')
|
|
8
|
+
.description('set a secret, reading the value from stdin unless --value is given')
|
|
9
|
+
.option('--value <value>')
|
|
10
|
+
.action(async (name, o) => {
|
|
11
|
+
const value = o.value ??
|
|
12
|
+
(await new Promise((resolve) => {
|
|
13
|
+
process.stdout.write(`${name}: `);
|
|
14
|
+
process.stdin.once('data', (d) => resolve(String(d).trim()));
|
|
15
|
+
}));
|
|
16
|
+
print(await request('POST', '/api/v1/secrets', { project: await projectOf(o), name, value }));
|
|
17
|
+
});
|
|
18
|
+
secret
|
|
19
|
+
.command('list')
|
|
20
|
+
.description('list secret names and versions, never values')
|
|
21
|
+
.action(async (o) => print(await request('GET', `/api/v1/secrets?project=${encodeURIComponent(await projectOf(o))}`)));
|
|
22
|
+
secret
|
|
23
|
+
.command('remove <name>')
|
|
24
|
+
.description('delete a secret')
|
|
25
|
+
.action(async (name, o) => print(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(await projectOf(o))}`)));
|
|
26
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { projectOf, request, runLive, waitForCommand } from "./context.js";
|
|
2
|
+
export function registerService(program) {
|
|
3
|
+
program
|
|
4
|
+
.command('exec <service> [command...]')
|
|
5
|
+
.description('execute a command on a service container (default: sh)')
|
|
6
|
+
.option('-i, --interactive', 'forward stdin to the remote command')
|
|
7
|
+
.option('-t, --tty', 'allocate a terminal (requires a local terminal and agent TTY support)')
|
|
8
|
+
.option('--node <name>', 'select the node when a service has multiple placements')
|
|
9
|
+
.action(async (service, command, o) => {
|
|
10
|
+
await runLive('exec', { service, command, ...o });
|
|
11
|
+
});
|
|
12
|
+
program
|
|
13
|
+
.command('run <service> [command...]')
|
|
14
|
+
.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
|
+
.option('--node <name>', 'select the node when a service has multiple placements')
|
|
16
|
+
.action(async (service, command, o) => {
|
|
17
|
+
if (!command.length)
|
|
18
|
+
throw new Error('run needs a command, such as: tug run web -- wp core update-db');
|
|
19
|
+
await runLive('exec', { service, command, node: o.node, oneOff: true });
|
|
20
|
+
});
|
|
21
|
+
program
|
|
22
|
+
.command('ps')
|
|
23
|
+
.description('print per-container state as JSON')
|
|
24
|
+
.option('--node <name>', 'select one node')
|
|
25
|
+
.action(async (o) => {
|
|
26
|
+
await runLive('ps', o);
|
|
27
|
+
});
|
|
28
|
+
program
|
|
29
|
+
.command('inspect <service>')
|
|
30
|
+
.description('print container details as JSON')
|
|
31
|
+
.option('--node <name>', 'select one node')
|
|
32
|
+
.action(async (service, o) => {
|
|
33
|
+
await runLive('inspect', { service, ...o });
|
|
34
|
+
});
|
|
35
|
+
program
|
|
36
|
+
.command('logs <service>')
|
|
37
|
+
.description('fetch service container logs from the node that runs the service')
|
|
38
|
+
.option('--tail <n>', 'number of lines', '200')
|
|
39
|
+
.option('-f, --follow', 'follow live logs')
|
|
40
|
+
.option('--node <name>', 'select a node for followed logs')
|
|
41
|
+
.action(async (svc, o) => {
|
|
42
|
+
if (o.follow) {
|
|
43
|
+
await runLive('logs', { service: svc, node: o.node, tail: o.tail });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (o.node)
|
|
47
|
+
throw new Error('--node requires --follow for logs');
|
|
48
|
+
const created = await request('POST', '/api/v1/logs', {
|
|
49
|
+
project: await projectOf(o),
|
|
50
|
+
service: svc,
|
|
51
|
+
tail: Number(o.tail),
|
|
52
|
+
});
|
|
53
|
+
const cmd = await waitForCommand(created.commandId, 60);
|
|
54
|
+
const result = JSON.parse(cmd.result_json || '{}');
|
|
55
|
+
if (result.stdout)
|
|
56
|
+
process.stdout.write(result.stdout);
|
|
57
|
+
if (result.stderr)
|
|
58
|
+
process.stderr.write(result.stderr);
|
|
59
|
+
if (result.error)
|
|
60
|
+
throw new Error(result.error);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { print, projectOf, request } from "./context.js";
|
|
2
|
+
export function registerVolume(program) {
|
|
3
|
+
const volume = program.command('volume').description("list and remove a project's volumes");
|
|
4
|
+
volume
|
|
5
|
+
.command('list')
|
|
6
|
+
.description('list the volumes of the project and where they live')
|
|
7
|
+
.action(async (o) => print(await request('GET', `/api/v1/volumes?project=${encodeURIComponent(await projectOf(o))}`)));
|
|
8
|
+
volume
|
|
9
|
+
.command('remove <name>')
|
|
10
|
+
.description('remove a volume from the project')
|
|
11
|
+
.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
|
+
}
|
package/dist/control.js
CHANGED
|
@@ -29,7 +29,7 @@ import { parseStatusReport } from "./status-report.js";
|
|
|
29
29
|
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
30
|
import { BACKUP_SECRET_PROJECT, isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
|
|
31
31
|
import { createBackupService } from "./backup-scheduler.js";
|
|
32
|
-
import { createRestoreService } from "./backup-restore.js";
|
|
32
|
+
import { createRestoreService, discoverWriters } from "./backup-restore.js";
|
|
33
33
|
import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createCredential, listCredentials, parseCredentialRequest, queryProject, recordEvent, revokeCredential, } from "./credentials.js";
|
|
34
34
|
class WakeHub {
|
|
35
35
|
cursors = new Map();
|
|
@@ -758,6 +758,7 @@ export async function createApp(options = {}) {
|
|
|
758
758
|
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
759
|
if (!previousDs)
|
|
760
760
|
throw httpError(404, 'no previous deployment found');
|
|
761
|
+
assertArtifactsAvailable(JSON.parse(previousDs.state_json), `roll back to ${previousDs.version}`);
|
|
761
762
|
const ts = now();
|
|
762
763
|
const depId = id('dep');
|
|
763
764
|
const previousState = JSON.parse(previousDs.state_json);
|
|
@@ -934,10 +935,17 @@ export async function createApp(options = {}) {
|
|
|
934
935
|
const global = ext.global === true;
|
|
935
936
|
const labels = Array.isArray(placement.labels) ? placement.labels : [];
|
|
936
937
|
const matched = nodeInfos.filter((node) => labels.every((label) => node.labels.includes(label)));
|
|
937
|
-
|
|
938
|
+
// Explicit `placement.nodes` (without labels or global, which deploy resolves into nodes) bound the pool; scaling never re-homes a service off them.
|
|
939
|
+
const pinned = !labels.length && !global && Array.isArray(placement.nodes)
|
|
940
|
+
? placement.nodes
|
|
941
|
+
: [];
|
|
942
|
+
const candidates = pinned.length
|
|
943
|
+
? matched.filter((node) => pinned.includes(node.name))
|
|
944
|
+
: matched;
|
|
945
|
+
const pool = candidates;
|
|
938
946
|
if (!pool.length)
|
|
939
|
-
throw httpError(400, labels.length
|
|
940
|
-
? 'no healthy nodes match the service placement
|
|
947
|
+
throw httpError(400, labels.length || pinned.length
|
|
948
|
+
? 'no healthy nodes match the service placement'
|
|
941
949
|
: 'no nodes available for scaling');
|
|
942
950
|
const selected = global ? pool : pool.slice(0, Math.min(replicas, pool.length));
|
|
943
951
|
state.services[service].placement = {
|
|
@@ -1008,7 +1016,7 @@ export async function createApp(options = {}) {
|
|
|
1008
1016
|
return res.status(400).json({ error: "mode must be 'create' or 'replace'" });
|
|
1009
1017
|
try {
|
|
1010
1018
|
// The certificate names the immutable node ID: the existing node's, or a fresh one for a new name. A name freed by
|
|
1011
|
-
// node
|
|
1019
|
+
// `tug node remove` therefore never inherits the old ID, and certificates for the removed node cannot match the new one.
|
|
1012
1020
|
let nodeId = db.get('SELECT * FROM nodes WHERE name=?', name)?.id ?? id('node');
|
|
1013
1021
|
let signed = await pki.signClientCsr(body.csr, identityUri('node', nodeId), NODE_LIFETIME_MS);
|
|
1014
1022
|
// Signing is asynchronous: re-read, and re-sign once if a concurrent request created the node meanwhile.
|
|
@@ -1024,7 +1032,7 @@ export async function createApp(options = {}) {
|
|
|
1024
1032
|
if (existing) {
|
|
1025
1033
|
if (existing.key_hash !== signed.keyHash)
|
|
1026
1034
|
return res.status(409).json({
|
|
1027
|
-
error: `node ${name} already exists; use node
|
|
1035
|
+
error: `node ${name} already exists; use tug node replace to install a new key`,
|
|
1028
1036
|
});
|
|
1029
1037
|
}
|
|
1030
1038
|
else {
|
|
@@ -1137,7 +1145,7 @@ export async function createApp(options = {}) {
|
|
|
1137
1145
|
const signed = await pki.signClientCsr(req.body?.csr, identityUri('node', node.id), NODE_LIFETIME_MS);
|
|
1138
1146
|
if (signed.keyHash !== node.key_hash)
|
|
1139
1147
|
return res.status(403).json({
|
|
1140
|
-
error: 'renewal requires a CSR for the node key; use node
|
|
1148
|
+
error: 'renewal requires a CSR for the node key; use tug node replace to change keys',
|
|
1141
1149
|
});
|
|
1142
1150
|
const last = node.last_renewed_at ? Date.parse(node.last_renewed_at) : 0;
|
|
1143
1151
|
if (Date.now() - last < NODE_RENEW_MIN_INTERVAL_MS())
|
|
@@ -1150,7 +1158,7 @@ export async function createApp(options = {}) {
|
|
|
1150
1158
|
sendError(res, e);
|
|
1151
1159
|
}
|
|
1152
1160
|
});
|
|
1153
|
-
// Blocks a node's key without deleting its metadata or changing placement. It returns only through node
|
|
1161
|
+
// Blocks a node's key without deleting its metadata or changing placement. It returns only through `tug node replace`.
|
|
1154
1162
|
api('post', '/api/v1/nodes/:name/disable', null, async (req, res) => {
|
|
1155
1163
|
const node = findNode(String(req.params.name));
|
|
1156
1164
|
if (!node)
|
|
@@ -1536,7 +1544,70 @@ export async function createApp(options = {}) {
|
|
|
1536
1544
|
}
|
|
1537
1545
|
// Previous-process builds cannot finish after a restart; clear their unpublished files.
|
|
1538
1546
|
db.run("UPDATE deployments SET state='build_failed',error_message='control restarted during build' WHERE state IN ('uploading','building')");
|
|
1547
|
+
/** Archives deleted by `image prune`, or missing from a backup taken without images, can no longer be deployed. */
|
|
1548
|
+
async function markMissingArtifacts() {
|
|
1549
|
+
for (const row of db.all("SELECT * FROM builds WHERE state='complete' AND artifact_path IS NOT NULL"))
|
|
1550
|
+
if (!(await fs.stat(path.join(DATA_DIR, row.artifact_path)).catch(() => null)))
|
|
1551
|
+
db.run("UPDATE builds SET state='pruned',artifact_path=NULL,updated_at=? WHERE id=?", now(), row.id);
|
|
1552
|
+
}
|
|
1553
|
+
/** Throws a 409 when a desired state needs a build archive that was pruned. */
|
|
1554
|
+
function assertArtifactsAvailable(state, action) {
|
|
1555
|
+
for (const [name, service] of Object.entries(state.services)) {
|
|
1556
|
+
if (!service.artifact)
|
|
1557
|
+
continue;
|
|
1558
|
+
const build = db.get('SELECT * FROM builds WHERE id=?', service.artifact.buildId);
|
|
1559
|
+
if (build?.state === 'pruned')
|
|
1560
|
+
throw httpError(409, `cannot ${action}: the build archive of ${name} was pruned (tug image prune); deploy it again instead`);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Deletes build archives no longer needed: everything except the newest `keep` desired states of each project,
|
|
1565
|
+
* the current deployment and any version a node still reports. The desired states stay, so history lists them,
|
|
1566
|
+
* but rolling back to a pruned one is refused.
|
|
1567
|
+
*/
|
|
1568
|
+
async function pruneArtifacts(keep, dryRun) {
|
|
1569
|
+
const kept = new Set();
|
|
1570
|
+
const reported = db
|
|
1571
|
+
.all('SELECT project_id,status_json FROM node_statuses')
|
|
1572
|
+
.map((row) => ({
|
|
1573
|
+
project: row.project_id,
|
|
1574
|
+
...JSON.parse(row.status_json),
|
|
1575
|
+
}));
|
|
1576
|
+
const current = new Set(db
|
|
1577
|
+
.all('SELECT current_deployment_id FROM projects')
|
|
1578
|
+
.map((row) => row.current_deployment_id));
|
|
1579
|
+
const rows = db.all('SELECT * FROM desired_states ORDER BY created_at DESC');
|
|
1580
|
+
const seen = new Map();
|
|
1581
|
+
for (const row of rows) {
|
|
1582
|
+
const index = seen.get(row.project_id) ?? 0;
|
|
1583
|
+
seen.set(row.project_id, index + 1);
|
|
1584
|
+
const state = JSON.parse(row.state_json);
|
|
1585
|
+
const lagging = reported.some((r) => r.project === row.project_id &&
|
|
1586
|
+
r.observedVersion === state.version &&
|
|
1587
|
+
(!r.observedRevision || r.observedRevision === state.revision));
|
|
1588
|
+
if (index >= keep && !lagging && !current.has(row.deployment_id))
|
|
1589
|
+
continue;
|
|
1590
|
+
for (const service of Object.values(state.services))
|
|
1591
|
+
if (service.artifact)
|
|
1592
|
+
kept.add(service.artifact.buildId);
|
|
1593
|
+
}
|
|
1594
|
+
const pruned = [];
|
|
1595
|
+
for (const build of db.all("SELECT * FROM builds WHERE state='complete' AND artifact_path IS NOT NULL")) {
|
|
1596
|
+
if (kept.has(build.id))
|
|
1597
|
+
continue;
|
|
1598
|
+
pruned.push({ id: build.id, service: build.service, sizeBytes: build.size_bytes });
|
|
1599
|
+
if (dryRun)
|
|
1600
|
+
continue;
|
|
1601
|
+
await fs.rm(path.join(DATA_DIR, build.artifact_path), { force: true });
|
|
1602
|
+
db.run("UPDATE builds SET state='pruned',artifact_path=NULL,updated_at=? WHERE id=?", now(), build.id);
|
|
1603
|
+
const shared = db.get("SELECT 1 FROM builds WHERE image_ref=? AND state='complete'", build.image_ref);
|
|
1604
|
+
if (!shared)
|
|
1605
|
+
await images.run('docker', ['image', 'rm', build.image_ref]).catch(() => { });
|
|
1606
|
+
}
|
|
1607
|
+
return { pruned, freedBytes: pruned.reduce((sum, build) => sum + build.sizeBytes, 0) };
|
|
1608
|
+
}
|
|
1539
1609
|
await collectArtifacts();
|
|
1610
|
+
await markMissingArtifacts();
|
|
1540
1611
|
const receiveDeployment = upload.any();
|
|
1541
1612
|
api('post', '/api/v1/deployments', null, (req, res) => {
|
|
1542
1613
|
receiveDeployment(req, res, (error) => {
|
|
@@ -1547,6 +1618,25 @@ export async function createApp(options = {}) {
|
|
|
1547
1618
|
});
|
|
1548
1619
|
});
|
|
1549
1620
|
});
|
|
1621
|
+
/**
|
|
1622
|
+
* Services that keep data on a node and are placed on different nodes by `compose` than by the current
|
|
1623
|
+
* deployment. Deploying such a change starts the service empty on the new node: the data does not move.
|
|
1624
|
+
*/
|
|
1625
|
+
function movedServices(project, compose) {
|
|
1626
|
+
const current = currentDesired(project);
|
|
1627
|
+
if (!current)
|
|
1628
|
+
return [];
|
|
1629
|
+
const before = JSON.parse(current.state_json);
|
|
1630
|
+
return Object.entries(compose.services || {})
|
|
1631
|
+
.filter(([name, svc]) => {
|
|
1632
|
+
const old = before.services?.[name]?.placement?.nodes ?? [];
|
|
1633
|
+
const next = svc['x-tug']?.placement?.nodes ?? [];
|
|
1634
|
+
const keepsData = Boolean(svc['x-tug']?.backup) ||
|
|
1635
|
+
(svc.volumes ?? []).some((mount) => typeof mount === 'object' && mount.type === 'volume');
|
|
1636
|
+
return keepsData && old.length > 0 && next.length > 0 && old.some((n) => !next.includes(n));
|
|
1637
|
+
})
|
|
1638
|
+
.map(([name]) => name);
|
|
1639
|
+
}
|
|
1550
1640
|
async function deployRequest(req, res, uploadError) {
|
|
1551
1641
|
const files = (Array.isArray(req.files) ? req.files : []);
|
|
1552
1642
|
let responseBody;
|
|
@@ -1620,6 +1710,10 @@ export async function createApp(options = {}) {
|
|
|
1620
1710
|
throw httpError(400, errMessage(e));
|
|
1621
1711
|
}
|
|
1622
1712
|
assertIngressHostsFree(project, compose);
|
|
1713
|
+
const acknowledgedMoves = Array.isArray(body.moves) ? body.moves.map(String) : [];
|
|
1714
|
+
const unacknowledged = movedServices(project, compose).filter((name) => !acknowledgedMoves.includes(name));
|
|
1715
|
+
if (unacknowledged.length)
|
|
1716
|
+
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
1717
|
// The CLI interpolates control secrets as sentinels; a reference to a secret that is gone would deploy an empty value.
|
|
1624
1718
|
const referenced = sentinelNames(compose);
|
|
1625
1719
|
if (referenced.includes(REGISTRY_AUTH_SECRET))
|
|
@@ -1631,7 +1725,7 @@ export async function createApp(options = {}) {
|
|
|
1631
1725
|
.map((row) => row.name));
|
|
1632
1726
|
const missing = referenced.filter((name) => !known.has(name));
|
|
1633
1727
|
if (missing.length)
|
|
1634
|
-
throw httpError(400, `secrets not found in project ${project}: ${missing.join(', ')}; create them with tug secret
|
|
1728
|
+
throw httpError(400, `secrets not found in project ${project}: ${missing.join(', ')}; create them with tug secret set`);
|
|
1635
1729
|
}
|
|
1636
1730
|
const builtServices = Object.entries(compose.services)
|
|
1637
1731
|
.filter(([, service]) => service.build)
|
|
@@ -1672,7 +1766,7 @@ export async function createApp(options = {}) {
|
|
|
1672
1766
|
continue;
|
|
1673
1767
|
for (const secret of [ing.cert, ing.key])
|
|
1674
1768
|
if (!db.get('SELECT 1 FROM secrets WHERE project_id=? AND name=?', project, secret ?? null))
|
|
1675
|
-
throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with secret
|
|
1769
|
+
throw httpError(400, `${svcName}: custom TLS secret ${secret} not found in project ${project}; set it with tug secret set first`);
|
|
1676
1770
|
}
|
|
1677
1771
|
lock = acquireProjectLock(project, 'deploy');
|
|
1678
1772
|
const active = db.get(`SELECT * FROM deployments WHERE project_id=? AND state IN (${ACTIVE_STATES_SQL}) LIMIT 1`, project);
|
|
@@ -1770,6 +1864,11 @@ export async function createApp(options = {}) {
|
|
|
1770
1864
|
!db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now()))
|
|
1771
1865
|
throw httpError(409, 'deployment was cancelled or its build lease was lost');
|
|
1772
1866
|
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
|
+
// The moved service starts empty: keep what writes to it stopped until its data is restored.
|
|
1868
|
+
for (const moved of acknowledgedMoves.filter((name) => compose.services?.[name]))
|
|
1869
|
+
for (const writer of discoverWriters(compose, `service:${moved}`))
|
|
1870
|
+
if (!acknowledgedMoves.includes(writer))
|
|
1871
|
+
db.run('INSERT OR REPLACE INTO service_holds(project_id,service,reason,restore_id,created_at) VALUES(?,?,?,?,?)', project, writer, `moved ${moved}`, null, ts);
|
|
1773
1872
|
for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
|
|
1774
1873
|
const lp = volumeSpec?.['x-tug'] || {};
|
|
1775
1874
|
const nodeName = lp.placement?.nodes?.[0] || null;
|
|
@@ -1933,6 +2032,31 @@ export async function createApp(options = {}) {
|
|
|
1933
2032
|
releaseLock(lock);
|
|
1934
2033
|
}
|
|
1935
2034
|
});
|
|
2035
|
+
api('post', '/api/v1/holds/release', bodyProject, async (req, res) => withWriter(async () => {
|
|
2036
|
+
const project = String(req.body?.project || 'site');
|
|
2037
|
+
const services = Array.isArray(req.body?.services)
|
|
2038
|
+
? req.body.services.map(String)
|
|
2039
|
+
: [];
|
|
2040
|
+
// Only holds a deploy placed for a move; a restore's holds are dropped by `restore release`.
|
|
2041
|
+
const rows = db.all('SELECT service FROM service_holds WHERE project_id=? AND restore_id IS NULL', project);
|
|
2042
|
+
const released = rows
|
|
2043
|
+
.map((row) => row.service)
|
|
2044
|
+
.filter((service) => !services.length || services.includes(service));
|
|
2045
|
+
for (const service of released)
|
|
2046
|
+
db.run('DELETE FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', project, service);
|
|
2047
|
+
wakeAll(project);
|
|
2048
|
+
res.json({ ok: true, project, released });
|
|
2049
|
+
}));
|
|
2050
|
+
api('post', '/api/v1/images/prune', null, async (req, res) => withWriter(async () => {
|
|
2051
|
+
const keep = req.body?.keep === undefined ? 5 : Number(req.body.keep);
|
|
2052
|
+
if (!Number.isInteger(keep) || keep < 1)
|
|
2053
|
+
return res.status(400).json({ error: 'keep must be a positive integer' });
|
|
2054
|
+
res.json({
|
|
2055
|
+
dryRun: Boolean(req.body?.dryRun),
|
|
2056
|
+
keep,
|
|
2057
|
+
...(await pruneArtifacts(keep, Boolean(req.body?.dryRun))),
|
|
2058
|
+
});
|
|
2059
|
+
}));
|
|
1936
2060
|
api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
|
|
1937
2061
|
const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
|
|
1938
2062
|
// The archive holds the master key and the CA key, so nothing here is ever readable beyond the control user.
|
|
@@ -1954,14 +2078,16 @@ export async function createApp(options = {}) {
|
|
|
1954
2078
|
recursive: true,
|
|
1955
2079
|
filter: (src) => path.basename(src) !== 'server.json',
|
|
1956
2080
|
});
|
|
1957
|
-
|
|
1958
|
-
|
|
2081
|
+
const withImages = req.body?.images !== false;
|
|
2082
|
+
if (withImages)
|
|
2083
|
+
await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
|
|
2084
|
+
await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control', images: withImages }, null, 2), { mode: 0o600 });
|
|
1959
2085
|
await tar.c({ gzip: true, file: artifactRef, cwd: staging, mode: 0o600 }, [
|
|
1960
2086
|
'tug.db',
|
|
1961
2087
|
'master.key',
|
|
1962
2088
|
'metadata.json',
|
|
1963
2089
|
'pki',
|
|
1964
|
-
'images',
|
|
2090
|
+
...(withImages ? ['images'] : []),
|
|
1965
2091
|
]);
|
|
1966
2092
|
}
|
|
1967
2093
|
catch (error) {
|
|
@@ -2007,11 +2133,16 @@ export async function createApp(options = {}) {
|
|
|
2007
2133
|
for (const secret of snapshot.all('SELECT * FROM secrets'))
|
|
2008
2134
|
decrypt(secret.encrypted_value, key);
|
|
2009
2135
|
const states = snapshot.all('SELECT * FROM desired_states');
|
|
2010
|
-
|
|
2136
|
+
const meta = JSON.parse(await fs.readFile(path.join(restoreDir, 'metadata.json'), 'utf8').catch(() => '{}'));
|
|
2137
|
+
// A backup taken without images restores the archive-less builds as pruned on the next start.
|
|
2138
|
+
for (const stateRow of meta.images === false ? [] : states) {
|
|
2011
2139
|
const state = JSON.parse(stateRow.state_json);
|
|
2012
2140
|
for (const service of Object.values(state.services)) {
|
|
2013
2141
|
if (!service.artifact)
|
|
2014
2142
|
continue;
|
|
2143
|
+
// `image prune` already removed this one before the backup.
|
|
2144
|
+
if (snapshot.get("SELECT 1 FROM builds WHERE id=? AND state='pruned'", service.artifact.buildId))
|
|
2145
|
+
continue;
|
|
2015
2146
|
const build = snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
|
|
2016
2147
|
if (!build?.artifact_path ||
|
|
2017
2148
|
!/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
|
package/dist/credentials.js
CHANGED
|
@@ -53,6 +53,8 @@ export const ROUTE_ROLES = {
|
|
|
53
53
|
'DELETE /api/v1/backup/destination': 'admin',
|
|
54
54
|
'GET /api/v1/backup/repository-password': 'admin',
|
|
55
55
|
'POST /api/v1/control/backups/run': 'admin',
|
|
56
|
+
'POST /api/v1/images/prune': 'admin',
|
|
57
|
+
'POST /api/v1/holds/release': 'admin',
|
|
56
58
|
'POST /api/v1/control/restore': 'admin',
|
|
57
59
|
'GET /api/v1/diagnostics': 'admin',
|
|
58
60
|
'POST /api/v1/diagnostics/nodes': 'admin',
|
package/dist/diagnostics.js
CHANGED
|
@@ -50,7 +50,7 @@ export function diagnoseNode(node, nowMs, staleSeconds) {
|
|
|
50
50
|
// A disabled node's certificate is irrelevant until it is replaced, so it never fails the diagnostics.
|
|
51
51
|
const report = connectivity === 'disabled' ? warnings : problems;
|
|
52
52
|
if (certificate?.state === 'expired')
|
|
53
|
-
report.push(`certificate expired ${certificate.notAfter}; run node
|
|
53
|
+
report.push(`certificate expired ${certificate.notAfter}; run tug node replace ${node.name}`);
|
|
54
54
|
else if (certificate?.state === 'expiring')
|
|
55
55
|
warnings.push(`certificate expires in ${certificate.daysLeft} days and has not renewed; check the agent logs on node ${node.name}`);
|
|
56
56
|
if (clockSkew.state === 'observed') {
|
package/dist/docker-api.d.ts
CHANGED
package/dist/live.js
CHANGED
|
@@ -312,6 +312,7 @@ export async function startOneOff(docker, template, command) {
|
|
|
312
312
|
NetworkMode: host.NetworkMode,
|
|
313
313
|
...(host.Binds?.length ? { Binds: host.Binds } : {}),
|
|
314
314
|
...(host.Mounts?.length ? { Mounts: host.Mounts } : {}),
|
|
315
|
+
...(host.ExtraHosts?.length ? { ExtraHosts: host.ExtraHosts } : {}),
|
|
315
316
|
},
|
|
316
317
|
});
|
|
317
318
|
const remove = () => docker.removeContainer(id).catch(() => { });
|