@wpelevator/tug 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -11
- package/dist/agent-jobs.d.ts +41 -0
- package/dist/agent-jobs.js +161 -0
- package/dist/agent.d.ts +1 -3
- package/dist/agent.js +58 -35
- package/dist/backup-config.d.ts +47 -0
- package/dist/backup-config.js +256 -0
- package/dist/backup-destination.d.ts +22 -0
- package/dist/backup-destination.js +72 -0
- package/dist/backup-job.d.ts +32 -0
- package/dist/backup-job.js +352 -0
- package/dist/backup-restore.d.ts +67 -0
- package/dist/backup-restore.js +297 -0
- package/dist/backup-scheduler.d.ts +54 -0
- package/dist/backup-scheduler.js +403 -0
- package/dist/build.d.ts +2 -3
- package/dist/build.js +1 -3
- package/dist/cli-build.d.ts +3 -3
- package/dist/cli-build.js +18 -6
- package/dist/cli-config.d.ts +45 -5
- package/dist/cli-config.js +269 -19
- package/dist/cli-live-transport.d.ts +1 -1
- package/dist/cli-live-transport.js +2 -2
- package/dist/cli-live.d.ts +2 -0
- package/dist/cli-live.js +1 -0
- package/dist/cli.js +217 -49
- package/dist/compose.d.ts +1 -2
- package/dist/compose.js +7 -6
- package/dist/container-spec.d.ts +3 -2
- package/dist/container-spec.js +9 -34
- package/dist/control.d.ts +4 -2
- package/dist/control.js +591 -381
- package/dist/cron.d.ts +17 -0
- package/dist/cron.js +124 -0
- package/dist/db-driver.d.ts +20 -0
- package/dist/db-driver.js +11 -0
- package/dist/db-node.d.ts +3 -0
- package/dist/db-node.js +60 -0
- package/dist/db.d.ts +79 -6
- package/dist/db.js +47 -26
- package/dist/docker-api.d.ts +7 -0
- package/dist/docker-api.js +15 -3
- package/dist/live.d.ts +8 -0
- package/dist/live.js +84 -0
- package/dist/protocol.d.ts +121 -12
- package/dist/protocol.js +7 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +133 -0
- package/dist/templates/control-compose.yml +0 -4
- package/dist/templates/node-install.sh +0 -1
- package/dist/tokens.d.ts +7 -7
- package/dist/tokens.js +34 -21
- package/package.json +2 -3
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, Option } from 'commander';
|
|
3
3
|
import { openAsBlob } from 'node:fs';
|
|
4
|
-
import {
|
|
4
|
+
import { applyConfigDefaults, integerOption, normalizeControlUrl, nodeSettings, resolveCliConfig, writeInitConfig, } from "./cli-config.js";
|
|
5
5
|
import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, MESH_RUN_FLAGS, controlPortCheck, localImageBuildArgs, meshPortCheck, } from "./cli-install.js";
|
|
6
6
|
import { createSourceArchive, prepareLocalImages } from "./cli-build.js";
|
|
7
7
|
import { saveImageArchive } from "./artifacts.js";
|
|
@@ -22,29 +22,32 @@ import { createCliLiveTransport } from "./cli-live-transport.js";
|
|
|
22
22
|
// built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
|
|
23
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
const templatesDir = path.join(__dirname, 'templates');
|
|
25
|
+
const CONFIG_HELP = 'CLI config file, YAML or JSON (default: TUG_CONFIG, else tug.yml, tug.yaml, tug.json or .tug.json)';
|
|
25
26
|
const program = new Command();
|
|
26
27
|
program
|
|
27
28
|
.name('tug')
|
|
28
29
|
.description('Portable VPS deployments with Docker Compose')
|
|
29
30
|
.version(packageVersion())
|
|
30
31
|
.enablePositionalOptions()
|
|
31
|
-
.option('--config <file>',
|
|
32
|
+
.option('--config <file>', CONFIG_HELP)
|
|
32
33
|
.option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)')
|
|
33
34
|
.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
|
|
34
35
|
let resolvedConfig;
|
|
35
|
-
const cfgPath = () => resolvedConfig.file;
|
|
36
36
|
async function loadCfg() {
|
|
37
37
|
return resolvedConfig;
|
|
38
38
|
}
|
|
39
39
|
const collect = (v, a) => [...a, v];
|
|
40
|
-
async function
|
|
41
|
-
|
|
40
|
+
async function readStdin() {
|
|
41
|
+
const chunks = [];
|
|
42
|
+
for await (const chunk of process.stdin)
|
|
43
|
+
chunks.push(Buffer.from(chunk));
|
|
44
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
42
45
|
}
|
|
43
46
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
47
|
async function request(method, route, body, multipart = false) {
|
|
45
48
|
const c = await loadCfg();
|
|
46
49
|
const base = c.controlUrl;
|
|
47
|
-
const tok = c.
|
|
50
|
+
const tok = c.token;
|
|
48
51
|
const headers = tok ? { Authorization: `Bearer ${tok}` } : {};
|
|
49
52
|
if (!multipart)
|
|
50
53
|
headers['content-type'] = 'application/json';
|
|
@@ -180,11 +183,15 @@ async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
|
180
183
|
program
|
|
181
184
|
.command('init')
|
|
182
185
|
.option('--token <token>', 'operator token (prefer TUG_TOKEN to avoid process-list exposure)')
|
|
183
|
-
.
|
|
186
|
+
.description('create the config file if missing and store the operator token in its git-ignored .local overlay')
|
|
187
|
+
.action(async (o, command) => {
|
|
184
188
|
const cfg = await loadCfg();
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
189
|
+
const { controlUrl, project } = command.optsWithGlobals();
|
|
190
|
+
for (const file of await writeInitConfig(cfg, o.token || cfg.token || token('tug_op'), {
|
|
191
|
+
controlUrl,
|
|
192
|
+
project,
|
|
193
|
+
}))
|
|
194
|
+
console.log(`wrote ${file}`);
|
|
188
195
|
});
|
|
189
196
|
program
|
|
190
197
|
.command('node-register <name>')
|
|
@@ -201,12 +208,6 @@ program
|
|
|
201
208
|
});
|
|
202
209
|
console.log(JSON.stringify(r, null, 2));
|
|
203
210
|
});
|
|
204
|
-
program
|
|
205
|
-
.command('node-rotate-registry-token <name>')
|
|
206
|
-
.description('issue a new registry pull token for a node, invalidating the old one')
|
|
207
|
-
.action(async (name) => {
|
|
208
|
-
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-registry-token`, {}), null, 2));
|
|
209
|
-
});
|
|
210
211
|
program
|
|
211
212
|
.command('node-rotate-wg-key <name>')
|
|
212
213
|
.description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll')
|
|
@@ -234,12 +235,6 @@ program
|
|
|
234
235
|
enabled: !o.disable,
|
|
235
236
|
}), null, 2));
|
|
236
237
|
});
|
|
237
|
-
program
|
|
238
|
-
.command('node-revoke-registry-token <name>')
|
|
239
|
-
.description("revoke a node's registry pull token, blocking future pulls until rotated")
|
|
240
|
-
.action(async (name) => {
|
|
241
|
-
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/revoke-registry-token`, {}), null, 2));
|
|
242
|
-
});
|
|
243
238
|
program
|
|
244
239
|
.command('scale <service> <replicas>')
|
|
245
240
|
.description('scale a service to N replicas across matching nodes')
|
|
@@ -263,9 +258,6 @@ program
|
|
|
263
258
|
.option('--token <token>', 'operator token')
|
|
264
259
|
.option('--port <port>', 'published API port', '8080')
|
|
265
260
|
.option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
|
|
266
|
-
.option('--registry <host>', 'registry host used in desired images; bring your own registry, Tug does not provision one')
|
|
267
|
-
.option('--registry-auth-path <path>', 'container path to an htpasswd file tug-control should manage credentials in, if your registry uses one')
|
|
268
|
-
.option('--registry-container <name>', "docker container name to restart when a node's registry credentials change, if your registry uses an htpasswd file")
|
|
269
261
|
.action(async (sshTarget, o) => {
|
|
270
262
|
const operatorToken = o.token || process.env.TUG_TOKEN || token('tug_op');
|
|
271
263
|
if (o.installDocker)
|
|
@@ -274,16 +266,12 @@ program
|
|
|
274
266
|
const image = resolveImage(o.image, o.buildLocalImage);
|
|
275
267
|
if (o.buildLocalImage)
|
|
276
268
|
await transferTugImage(sshTarget, image);
|
|
277
|
-
const registryHost = o.registry || '';
|
|
278
269
|
const yamlValue = (value) => JSON.stringify(value);
|
|
279
270
|
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
280
271
|
IMAGE: yamlValue(image),
|
|
281
272
|
OPERATOR_TOKEN: yamlValue(operatorToken),
|
|
282
|
-
REGISTRY_HOST: yamlValue(registryHost),
|
|
283
273
|
PORT: o.port,
|
|
284
274
|
MESH: yamlValue(o.mesh ? 'true' : 'false'),
|
|
285
|
-
REGISTRY_AUTH_PATH: yamlValue(o.registryAuthPath || ''),
|
|
286
|
-
REGISTRY_CONTAINER: yamlValue(o.registryContainer || ''),
|
|
287
275
|
});
|
|
288
276
|
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
289
277
|
COMPOSE: compose,
|
|
@@ -295,26 +283,27 @@ program
|
|
|
295
283
|
sshTarget,
|
|
296
284
|
operatorToken,
|
|
297
285
|
url: `http://${remoteHost(sshTarget)}:${o.port}`,
|
|
298
|
-
registry: registryHost,
|
|
299
286
|
}, null, 2));
|
|
300
287
|
});
|
|
301
288
|
program
|
|
302
|
-
.command('node-install
|
|
303
|
-
.description('register and install an agent container on a Docker-capable host over SSH')
|
|
289
|
+
.command('node-install [sshTarget]')
|
|
290
|
+
.description('register and install an agent container on a Docker-capable host over SSH (the SSH target defaults to nodes.<name>.ssh in the config file)')
|
|
304
291
|
.requiredOption('--name <name>')
|
|
305
292
|
.option('--address <addr>')
|
|
306
293
|
.option('--public-address <addr>')
|
|
307
294
|
.option('--label <label>', 'node label (repeatable)', collect, [])
|
|
308
295
|
.option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
|
|
309
|
-
.option('--registry <host>', 'registry host for docker login')
|
|
310
296
|
.option('--http-only-dev', 'allow plaintext HTTP control URL for local/private development')
|
|
311
297
|
.option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
|
|
312
298
|
.option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
|
|
313
299
|
.option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
|
|
314
300
|
.option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
|
|
315
301
|
.option('--build-local-image', 'build the image from the current checkout and copy it to the host over SSH (tags it tug:local unless --image is set); for development')
|
|
316
|
-
.action(async (
|
|
302
|
+
.action(async (sshArgument, o) => {
|
|
317
303
|
const cfg = await loadCfg();
|
|
304
|
+
const sshTarget = sshArgument ?? nodeSettings(cfg, o.name).ssh;
|
|
305
|
+
if (!sshTarget)
|
|
306
|
+
throw new Error(`node-install needs an SSH target: pass <ssh-target> or set nodes.${o.name}.ssh in ${cfg.file}`);
|
|
318
307
|
const controlUrl = normalizeControlUrl(o.agentControlUrl ?? cfg.controlUrl);
|
|
319
308
|
assertHttps(controlUrl, o.httpOnlyDev);
|
|
320
309
|
if (o.installDocker)
|
|
@@ -338,17 +327,13 @@ program
|
|
|
338
327
|
NODE_NAME: o.name,
|
|
339
328
|
AGENT_TOKEN: registered.agentToken,
|
|
340
329
|
})) + (o.mesh ? 'TUG_MESH=true\n' : '');
|
|
341
|
-
const dockerLogin = o.registry
|
|
342
|
-
? `printf %s ${sh(registered.registryToken)} | docker login ${sh(o.registry)} --username ${sh(o.name)} --password-stdin`
|
|
343
|
-
: 'true';
|
|
344
330
|
const remote = await loadTemplate(templatesDir, 'node-install.sh', {
|
|
345
331
|
ENV: env,
|
|
346
|
-
DOCKER_LOGIN: dockerLogin,
|
|
347
332
|
IMAGE_SH: sh(image),
|
|
348
333
|
RUN_FLAGS: `${o.mesh ? MESH_RUN_FLAGS : HOST_NETWORK_FLAG} `,
|
|
349
334
|
});
|
|
350
335
|
runLocal('ssh', ['--', sshTarget, remote]);
|
|
351
|
-
console.log(JSON.stringify({ ok: true, node: registered.node
|
|
336
|
+
console.log(JSON.stringify({ ok: true, node: registered.node }, null, 2));
|
|
352
337
|
});
|
|
353
338
|
program
|
|
354
339
|
.command('node-enroll')
|
|
@@ -419,6 +404,172 @@ program
|
|
|
419
404
|
program
|
|
420
405
|
.command('secret-remove <name>')
|
|
421
406
|
.action(async (name, o) => console.log(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`)));
|
|
407
|
+
const formatBytes = (bytes) => bytes === null
|
|
408
|
+
? '-'
|
|
409
|
+
: bytes < 1024 ** 2
|
|
410
|
+
? `${Math.ceil(bytes / 1024)} KiB`
|
|
411
|
+
: `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
|
|
412
|
+
const BACKUP_TERMINAL = ['succeeded', 'failed', 'missed', 'cancelled'];
|
|
413
|
+
program
|
|
414
|
+
.command('backup-run [targets...]')
|
|
415
|
+
.description('back up the named services or volumes now, or every backup target of the project; waits and exits nonzero if any backup fails')
|
|
416
|
+
.option('--no-wait', 'return once the backups are queued')
|
|
417
|
+
.option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
|
|
418
|
+
.action(async (targets, o) => {
|
|
419
|
+
const project = (await loadCfg()).project || 'site';
|
|
420
|
+
const queued = await request('POST', '/api/v1/backups/run', { project, targets: targets.length ? targets : undefined });
|
|
421
|
+
for (const message of queued.errors)
|
|
422
|
+
console.error(message);
|
|
423
|
+
for (const run of queued.runs)
|
|
424
|
+
console.log(`queued ${run.target} as ${run.runId}`);
|
|
425
|
+
let failed = queued.errors.length > 0;
|
|
426
|
+
if (o.wait) {
|
|
427
|
+
const deadline = Date.now() + Number(o.timeout) * 1000;
|
|
428
|
+
const pending = new Set(queued.runs.map((run) => run.runId));
|
|
429
|
+
while (pending.size && Date.now() < deadline) {
|
|
430
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
431
|
+
const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=200`);
|
|
432
|
+
for (const run of listing.runs)
|
|
433
|
+
if (pending.has(run.id) && BACKUP_TERMINAL.includes(run.state)) {
|
|
434
|
+
pending.delete(run.id);
|
|
435
|
+
const ok = run.state === 'succeeded';
|
|
436
|
+
failed ||= !ok;
|
|
437
|
+
console.log(ok
|
|
438
|
+
? `${run.target}: snapshot ${run.snapshot_id?.slice(0, 8)}, ${formatBytes(run.added_bytes)} added`
|
|
439
|
+
: `${run.target}: ${run.state}${run.error ? ` (${run.error})` : ''}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (pending.size) {
|
|
443
|
+
console.error(`timed out waiting for ${pending.size} backup(s); they keep running on the node`);
|
|
444
|
+
failed = true;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (failed)
|
|
448
|
+
process.exitCode = 1;
|
|
449
|
+
});
|
|
450
|
+
program
|
|
451
|
+
.command('backup-list')
|
|
452
|
+
.description('show each backup target with its last success and next run, then recent runs')
|
|
453
|
+
.option('--limit <n>', 'number of recent runs to show', '20')
|
|
454
|
+
.action(async (o) => {
|
|
455
|
+
const project = (await loadCfg()).project || 'site';
|
|
456
|
+
const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=${encodeURIComponent(o.limit)}`);
|
|
457
|
+
if (!listing.destinationConfigured)
|
|
458
|
+
console.error('no backup destination is configured; run tug backup-destination-set');
|
|
459
|
+
if (!listing.targets.length)
|
|
460
|
+
console.log('no backup targets; add x-tug.backup to a database service or volume');
|
|
461
|
+
for (const t of listing.targets)
|
|
462
|
+
console.log(`${t.target}\t${t.node ?? '-'}\t${t.schedule}\tlast success ${t.lastSuccessAt ?? 'never'}\tnext ${t.enabled ? t.nextRunAt : 'disabled'}`);
|
|
463
|
+
for (const r of listing.runs)
|
|
464
|
+
console.log(`${r.finished_at ?? r.queued_at}\t${r.target}\t${r.trigger}\t${r.state}\t${r.snapshot_id?.slice(0, 8) ?? '-'}\t${formatBytes(r.size_bytes)}${r.error ? `\t${r.error}` : ''}`);
|
|
465
|
+
});
|
|
466
|
+
program
|
|
467
|
+
.command('restore <target>')
|
|
468
|
+
.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')
|
|
469
|
+
.option('--from <id>', 'backup run id, restic snapshot id, or latest', 'latest')
|
|
470
|
+
.option('--scratch', 'restore a volume into a new volume next to the live one, touching nothing live')
|
|
471
|
+
.option('--no-safety-backup', 'skip the backup of the current data taken before an in-place restore')
|
|
472
|
+
.option('--writers <service>', 'service to stop during the restore, instead of discovering them (repeatable)', collect, [])
|
|
473
|
+
.option('--no-writers', 'restore without stopping any service')
|
|
474
|
+
.option('--confirm', 'perform the restore')
|
|
475
|
+
.option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
|
|
476
|
+
.action(async (target, o) => {
|
|
477
|
+
const project = (await loadCfg()).project || 'site';
|
|
478
|
+
const explicit = Array.isArray(o.writers) && o.writers.length > 0 ? o.writers : undefined;
|
|
479
|
+
const result = await request('POST', '/api/v1/restores', {
|
|
480
|
+
project,
|
|
481
|
+
target,
|
|
482
|
+
from: o.from,
|
|
483
|
+
scratch: o.scratch === true,
|
|
484
|
+
safetyBackup: o.safetyBackup,
|
|
485
|
+
writers: explicit,
|
|
486
|
+
noWriters: o.writers === false,
|
|
487
|
+
confirm: o.confirm === true,
|
|
488
|
+
});
|
|
489
|
+
const plan = result.plan;
|
|
490
|
+
console.log(`target: ${plan.target} on ${plan.node}`);
|
|
491
|
+
console.log(`snapshot: ${plan.snapshotId}${plan.snapshotTime ? ` (${plan.snapshotTime})` : ''}`);
|
|
492
|
+
console.log(`mode: ${plan.mode}${plan.scratchVolume ? ` into new volume ${plan.scratchVolume}` : ''}`);
|
|
493
|
+
if (plan.mode === 'in-place') {
|
|
494
|
+
console.log(`stops: ${plan.writers.length ? plan.writers.join(', ') : 'nothing'}`);
|
|
495
|
+
console.log(`safety: ${plan.safetyBackup ? 'a backup of the current data is taken first' : 'none (--no-safety-backup)'}`);
|
|
496
|
+
}
|
|
497
|
+
if (!result.confirmed) {
|
|
498
|
+
console.log('\nNothing was changed. Run again with --confirm to restore.');
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const deadline = Date.now() + Number(o.timeout) * 1000;
|
|
502
|
+
for (;;) {
|
|
503
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
504
|
+
const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
|
|
505
|
+
const restore = restores.find((item) => item.id === result.restoreId);
|
|
506
|
+
if (restore?.state === 'succeeded') {
|
|
507
|
+
console.log(`restore ${restore.id} completed${restore.scratch_volume ? `; files are in volume ${restore.scratch_volume}` : ''}`);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (restore && ['failed', 'interrupted', 'released'].includes(restore.state)) {
|
|
511
|
+
console.error(`restore ${restore.id} ${restore.state}: ${restore.error ?? ''}`);
|
|
512
|
+
if (restore.state === 'interrupted')
|
|
513
|
+
console.error(`the services stay stopped; run tug restore-resume ${restore.id} to try again or tug restore-release ${restore.id} to start them (data may be partly restored)`);
|
|
514
|
+
process.exitCode = 1;
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (Date.now() > deadline) {
|
|
518
|
+
console.error(`timed out waiting; restore ${result.restoreId} continues on control (tug restore-list)`);
|
|
519
|
+
process.exitCode = 1;
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
program
|
|
525
|
+
.command('restore-list')
|
|
526
|
+
.description('show recent restores and their state')
|
|
527
|
+
.action(async () => {
|
|
528
|
+
const project = (await loadCfg()).project || 'site';
|
|
529
|
+
const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
|
|
530
|
+
if (!restores.length)
|
|
531
|
+
console.log('no restores');
|
|
532
|
+
for (const r of restores)
|
|
533
|
+
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}` : ''}`);
|
|
534
|
+
});
|
|
535
|
+
program
|
|
536
|
+
.command('restore-resume <id>')
|
|
537
|
+
.description('run an interrupted restore again; the stopped services stay stopped meanwhile')
|
|
538
|
+
.action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/resume`))));
|
|
539
|
+
program
|
|
540
|
+
.command('restore-release <id>')
|
|
541
|
+
.description('start the services an interrupted restore stopped and unlock the project; the data may be partly restored')
|
|
542
|
+
.action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/release`))));
|
|
543
|
+
program
|
|
544
|
+
.command('backup-destination-set <url>')
|
|
545
|
+
.description('set where backups go, such as s3:https://s3.example.com/bucket/tug-backups; credentials are stored encrypted on control')
|
|
546
|
+
.option('--env <name>', 'credential variable for the backend, such as AWS_ACCESS_KEY_ID (repeatable); each value is read from the variable of that name in your environment, else from a line on stdin', collect, [])
|
|
547
|
+
.action(async (url, o) => {
|
|
548
|
+
const missing = o.env.filter((name) => process.env[name] === undefined);
|
|
549
|
+
const lines = missing.length
|
|
550
|
+
? (await readStdin()).split(/\r?\n/).filter((line) => line.length > 0)
|
|
551
|
+
: [];
|
|
552
|
+
if (lines.length < missing.length)
|
|
553
|
+
throw new Error(`expected a value on stdin for ${missing.slice(lines.length).join(', ')}`);
|
|
554
|
+
const env = Object.fromEntries(o.env.map((name) => [name, process.env[name] ?? lines[missing.indexOf(name)]]));
|
|
555
|
+
console.log(JSON.stringify(await request('POST', '/api/v1/backup/destination', { url, env })));
|
|
556
|
+
});
|
|
557
|
+
program
|
|
558
|
+
.command('backup-destination-show')
|
|
559
|
+
.description('show the backup destination and the names of its credential variables, never values')
|
|
560
|
+
.action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/backup/destination'), null, 2)));
|
|
561
|
+
program
|
|
562
|
+
.command('backup-destination-remove')
|
|
563
|
+
.description('remove the backup destination and its stored credentials; existing backups stay in the repository')
|
|
564
|
+
.action(async () => console.log(JSON.stringify(await request('DELETE', '/api/v1/backup/destination'))));
|
|
565
|
+
program
|
|
566
|
+
.command('backup-key')
|
|
567
|
+
.description("print the project's repository and its password for offline escrow; anyone holding the password and destination credentials can read the project's backups")
|
|
568
|
+
.action(async () => {
|
|
569
|
+
const c = await loadCfg();
|
|
570
|
+
const project = c.project || 'site';
|
|
571
|
+
console.log(JSON.stringify(await request('GET', `/api/v1/backup/key?project=${encodeURIComponent(project)}`), null, 2));
|
|
572
|
+
});
|
|
422
573
|
program
|
|
423
574
|
.command('token-create <name>')
|
|
424
575
|
.description('create a named API token for CI or a teammate; the secret is printed once and cannot be shown again')
|
|
@@ -450,7 +601,10 @@ program
|
|
|
450
601
|
async function makeContext() {
|
|
451
602
|
const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
|
|
452
603
|
try {
|
|
453
|
-
await createSourceArchive(process.cwd(), tmp, [
|
|
604
|
+
await createSourceArchive(process.cwd(), tmp, [
|
|
605
|
+
resolvedConfig.file,
|
|
606
|
+
resolvedConfig.localFile,
|
|
607
|
+
]);
|
|
454
608
|
}
|
|
455
609
|
catch (error) {
|
|
456
610
|
await fs.rm(tmp, { force: true });
|
|
@@ -463,7 +617,6 @@ program
|
|
|
463
617
|
.option('-f, --file <file>', 'compose file', 'docker-compose.yml')
|
|
464
618
|
.option('--version <version>', 'display version (default: git-<Compose hash>)')
|
|
465
619
|
.option('--build-local', 'build application images locally and upload them without source')
|
|
466
|
-
.option('--no-build-upload', 'deploy published images without uploading source; conflicts with --build-local')
|
|
467
620
|
.option('--no-wait', 'return after desired state is created')
|
|
468
621
|
.option('--timeout <seconds>', 'wait timeout in seconds', '300')
|
|
469
622
|
.action(async (o) => {
|
|
@@ -488,8 +641,7 @@ program
|
|
|
488
641
|
await fs.rm(directory, { recursive: true, force: true });
|
|
489
642
|
}
|
|
490
643
|
}
|
|
491
|
-
else if (
|
|
492
|
-
Object.values(compose.services).some((service) => service.build)) {
|
|
644
|
+
else if (Object.values(compose.services).some((service) => service.build)) {
|
|
493
645
|
const tarball = await makeContext();
|
|
494
646
|
try {
|
|
495
647
|
const form = new FormData();
|
|
@@ -636,7 +788,7 @@ program
|
|
|
636
788
|
});
|
|
637
789
|
async function runLive(operation, input) {
|
|
638
790
|
const cfg = await loadCfg();
|
|
639
|
-
process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.controlUrl, cfg.
|
|
791
|
+
process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.controlUrl, cfg.token));
|
|
640
792
|
}
|
|
641
793
|
program
|
|
642
794
|
.command('exec <service> [command...]')
|
|
@@ -647,6 +799,15 @@ program
|
|
|
647
799
|
.action(async (service, command, o) => {
|
|
648
800
|
await runLive('exec', { service, command, ...o });
|
|
649
801
|
});
|
|
802
|
+
program
|
|
803
|
+
.command('run <service> [command...]')
|
|
804
|
+
.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")
|
|
805
|
+
.option('--node <name>', 'select the node when a service has multiple placements')
|
|
806
|
+
.action(async (service, command, o) => {
|
|
807
|
+
if (!command.length)
|
|
808
|
+
throw new Error('run needs a command, such as: tug run web -- wp core update-db');
|
|
809
|
+
await runLive('exec', { service, command, node: o.node, oneOff: true });
|
|
810
|
+
});
|
|
650
811
|
program
|
|
651
812
|
.command('ps')
|
|
652
813
|
.description('print per-container state as JSON')
|
|
@@ -705,10 +866,6 @@ program.hook('preAction', async (_root, command) => {
|
|
|
705
866
|
if (options[key] !== undefined)
|
|
706
867
|
integerOption(String(options[key]), '--' + key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()), min, max);
|
|
707
868
|
}
|
|
708
|
-
if (options.buildLocal && options.buildUpload === false)
|
|
709
|
-
throw new Error('--build-local and --no-build-upload cannot be combined');
|
|
710
|
-
if ((options.registryAuthPath || options.registryContainer) && !options.registry)
|
|
711
|
-
throw new Error('--registry-auth-path and --registry-container require --registry');
|
|
712
869
|
if (command.name() === 'control-restore' && !options.confirm)
|
|
713
870
|
throw new Error('control-restore requires --confirm');
|
|
714
871
|
const positional = command.name() === 'exec' ? command.args.slice(0, 1) : command.args;
|
|
@@ -732,10 +889,21 @@ program.hook('preAction', async (_root, command) => {
|
|
|
732
889
|
connection.project = undefined;
|
|
733
890
|
}
|
|
734
891
|
resolvedConfig = await resolveCliConfig(connection, process.env, process.cwd(), command.name() === 'init');
|
|
892
|
+
// Config values are defaults for options not given on the command line; nodes are only read by the
|
|
893
|
+
// commands that create or register one, never by commands that talk to an existing node.
|
|
894
|
+
const nodeName = command.name() === 'node-register'
|
|
895
|
+
? command.args[0]
|
|
896
|
+
: ['node-install', 'node-enroll'].includes(command.name())
|
|
897
|
+
? options.name
|
|
898
|
+
: undefined;
|
|
899
|
+
applyConfigDefaults(command, options, {
|
|
900
|
+
...resolvedConfig.commandDefaults,
|
|
901
|
+
...(command.name().startsWith('node-') ? nodeSettings(resolvedConfig, nodeName) : {}),
|
|
902
|
+
});
|
|
735
903
|
});
|
|
736
904
|
for (const command of program.commands) {
|
|
737
905
|
command
|
|
738
|
-
.option('--config <file>',
|
|
906
|
+
.option('--config <file>', CONFIG_HELP)
|
|
739
907
|
.option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)');
|
|
740
908
|
if (!command.options.some((option) => option.long === '--project'))
|
|
741
909
|
command.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
|
package/dist/compose.d.ts
CHANGED
|
@@ -39,7 +39,6 @@ export interface BuildDesiredStateOptions {
|
|
|
39
39
|
project: string;
|
|
40
40
|
version: string;
|
|
41
41
|
compose: ComposeFile;
|
|
42
|
-
registry: string;
|
|
43
42
|
revision?: string;
|
|
44
43
|
defaultNode?: string | null;
|
|
45
44
|
nodes?: Array<{
|
|
@@ -47,7 +46,7 @@ export interface BuildDesiredStateOptions {
|
|
|
47
46
|
labels?: string[];
|
|
48
47
|
}>;
|
|
49
48
|
}
|
|
50
|
-
export declare function buildDesiredState({ project, version, compose,
|
|
49
|
+
export declare function buildDesiredState({ project, version, compose, revision, defaultNode, nodes, }: BuildDesiredStateOptions): DesiredState;
|
|
51
50
|
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
52
51
|
export declare function crossNodeHosts(project: string, endpoints: Record<string, ServiceEndpoint[]>, local: Set<string>): string[];
|
|
53
52
|
/**
|
package/dist/compose.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import { resolveBackups, validateBackupDefaults } from "./backup-config.js";
|
|
2
3
|
import { isDuration } from "./util.js";
|
|
3
4
|
import { sentinelNames } from "./normalize.js";
|
|
4
5
|
export function composeHash(compose) {
|
|
@@ -166,10 +167,11 @@ function validateProjectExtension(compose, errors) {
|
|
|
166
167
|
return;
|
|
167
168
|
}
|
|
168
169
|
for (const key of Object.keys(ext))
|
|
169
|
-
if (key !== 'rollback')
|
|
170
|
-
errors.push(`x-tug.${key} is not supported (supported: rollback)`);
|
|
170
|
+
if (key !== 'rollback' && key !== 'backup')
|
|
171
|
+
errors.push(`x-tug.${key} is not supported (supported: rollback, backup)`);
|
|
171
172
|
if (ext.rollback !== undefined && !['auto', 'manual'].includes(ext.rollback))
|
|
172
173
|
errors.push('x-tug.rollback must be auto or manual');
|
|
174
|
+
validateBackupDefaults(ext.backup, errors);
|
|
173
175
|
}
|
|
174
176
|
/** Service keys the agent can create a container from; anything else is rejected with its name. */
|
|
175
177
|
export const SUPPORTED_SERVICE_KEYS = [
|
|
@@ -302,10 +304,11 @@ export function validateCompose(compose, nodes = []) {
|
|
|
302
304
|
}
|
|
303
305
|
validateTopLevel(compose, errors);
|
|
304
306
|
validateProjectExtension(compose, errors);
|
|
307
|
+
errors.push(...resolveBackups(compose).errors);
|
|
305
308
|
if (errors.length)
|
|
306
309
|
throw new Error(errors.join('; '));
|
|
307
310
|
}
|
|
308
|
-
export function buildDesiredState({ project, version, compose,
|
|
311
|
+
export function buildDesiredState({ project, version, compose, revision, defaultNode = null, nodes, }) {
|
|
309
312
|
const services = {};
|
|
310
313
|
for (const [name, src] of Object.entries(compose.services || {})) {
|
|
311
314
|
const lp = src['x-tug'] || {};
|
|
@@ -323,9 +326,7 @@ export function buildDesiredState({ project, version, compose, registry, revisio
|
|
|
323
326
|
}
|
|
324
327
|
placement = { ...placement, nodes: selected.map((node) => node.name) };
|
|
325
328
|
}
|
|
326
|
-
const image = src.build
|
|
327
|
-
? `${registry ? `${registry}/` : 'tug-build/'}${project}/${name}:${revision || version}`
|
|
328
|
-
: src.image;
|
|
329
|
+
const image = src.build ? `tug-build/${project}/${name}:${revision || version}` : src.image;
|
|
329
330
|
services[name] = {
|
|
330
331
|
image,
|
|
331
332
|
build: src.build || null,
|
package/dist/container-spec.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { CreateContainerBody } from './docker-api.ts';
|
|
|
2
2
|
import type { DesiredState, ServiceEndpoint } from './protocol.ts';
|
|
3
3
|
import type { PortBind } from './compose.ts';
|
|
4
4
|
export declare const LABEL_PROJECT = "tug.project";
|
|
5
|
+
/** Labels helper containers (backup jobs, one-off runs) so a restarted agent can clean up after them. */
|
|
6
|
+
export declare const LABEL_JOB = "tug.job";
|
|
5
7
|
export declare const LABEL_SERVICE = "tug.service";
|
|
6
8
|
export declare const LABEL_REVISION = "tug.revision";
|
|
7
9
|
export declare const LABEL_HASH = "tug.config-hash";
|
|
@@ -41,7 +43,6 @@ export interface ContainerSpecOptions {
|
|
|
41
43
|
/** Secret versions by name; they are part of the hash instead of the values. */
|
|
42
44
|
secretVersions?: Record<string, number>;
|
|
43
45
|
endpoints?: Record<string, ServiceEndpoint[]>;
|
|
44
|
-
nodeAddresses?: Record<string, string>;
|
|
45
46
|
/** Host IP for published ports that name none; without it `ports` stay as written. */
|
|
46
47
|
bind?: PortBind;
|
|
47
48
|
}
|
|
@@ -49,7 +50,7 @@ export interface ContainerSpecOptions {
|
|
|
49
50
|
export declare function composeDurationMs(value: unknown): number | undefined;
|
|
50
51
|
/** Compose sizes: bytes as a number, or `64m` / `1g` style strings. */
|
|
51
52
|
export declare function sizeBytes(value: unknown): number | undefined;
|
|
52
|
-
export declare function containerSpecs(desired: DesiredState, node: string, { secrets, secretVersions, endpoints,
|
|
53
|
+
export declare function containerSpecs(desired: DesiredState, node: string, { secrets, secretVersions, endpoints, bind }?: ContainerSpecOptions): NodePlan;
|
|
53
54
|
/** Start order: dependencies first, ties by service name; a cycle is an error. */
|
|
54
55
|
export declare function startOrder(specs: Record<string, ContainerSpec>): string[];
|
|
55
56
|
/** `body` with the host side of its (single) published port replaced; used for rolling revisions. */
|
package/dist/container-spec.js
CHANGED
|
@@ -4,6 +4,8 @@ import crypto from 'node:crypto';
|
|
|
4
4
|
import { bindPorts, crossNodeHosts } from "./compose.js";
|
|
5
5
|
import { sentinelNames, substituteSentinels } from "./normalize.js";
|
|
6
6
|
export const LABEL_PROJECT = 'tug.project';
|
|
7
|
+
/** Labels helper containers (backup jobs, one-off runs) so a restarted agent can clean up after them. */
|
|
8
|
+
export const LABEL_JOB = 'tug.job';
|
|
7
9
|
export const LABEL_SERVICE = 'tug.service';
|
|
8
10
|
export const LABEL_REVISION = 'tug.revision';
|
|
9
11
|
export const LABEL_HASH = 'tug.config-hash';
|
|
@@ -105,8 +107,8 @@ function portMaps(svc, ports) {
|
|
|
105
107
|
exposed[`${port.split('/')[0]}/${port.split('/')[1] || 'tcp'}`] = {};
|
|
106
108
|
return { exposed, bindings };
|
|
107
109
|
}
|
|
108
|
-
/** Volume definitions this node needs
|
|
109
|
-
function volumeSpecs(desired, node, used
|
|
110
|
+
/** Volume definitions this node needs. */
|
|
111
|
+
function volumeSpecs(desired, node, used) {
|
|
110
112
|
const out = [];
|
|
111
113
|
for (const key of [...used].sort()) {
|
|
112
114
|
const def = desired.compose.volumes?.[key] || {};
|
|
@@ -128,42 +130,15 @@ function volumeSpecs(desired, node, used, nodeAddresses) {
|
|
|
128
130
|
}
|
|
129
131
|
const name = volumeName(desired.project, key);
|
|
130
132
|
const nodes = xe.placement?.nodes || [];
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
if (nodes.length && !nodes.includes(node))
|
|
134
|
+
continue;
|
|
135
|
+
out.push({
|
|
133
136
|
name,
|
|
134
137
|
driver: String(def.driver || 'local'),
|
|
135
138
|
opts: def.driver_opts || {},
|
|
136
139
|
labels,
|
|
137
140
|
external: false,
|
|
138
141
|
});
|
|
139
|
-
if (isNfs) {
|
|
140
|
-
const exporter = nodes[0];
|
|
141
|
-
const exportPath = xe.export || `/srv/tug/${key}`;
|
|
142
|
-
if (!exporter)
|
|
143
|
-
out.push(plain());
|
|
144
|
-
else if (nodes.includes(node))
|
|
145
|
-
// The exporter mounts the exported directory directly; an NFS server on this node must export it.
|
|
146
|
-
out.push({
|
|
147
|
-
name,
|
|
148
|
-
driver: 'local',
|
|
149
|
-
opts: { type: 'none', o: 'bind', device: exportPath },
|
|
150
|
-
labels,
|
|
151
|
-
external: false,
|
|
152
|
-
});
|
|
153
|
-
else {
|
|
154
|
-
const addr = xe.address || nodeAddresses[exporter] || exporter;
|
|
155
|
-
out.push({
|
|
156
|
-
name,
|
|
157
|
-
driver: 'local',
|
|
158
|
-
opts: { type: 'nfs', o: `addr=${addr},rw,nolock,soft`, device: `:${exportPath}` },
|
|
159
|
-
labels,
|
|
160
|
-
external: false,
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
if (!nodes.length || nodes.includes(node))
|
|
166
|
-
out.push(plain());
|
|
167
142
|
}
|
|
168
143
|
return out;
|
|
169
144
|
}
|
|
@@ -176,7 +151,7 @@ function walkStrings(value, fn) {
|
|
|
176
151
|
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, walkStrings(v, fn)]));
|
|
177
152
|
return value;
|
|
178
153
|
}
|
|
179
|
-
export function containerSpecs(desired, node, { secrets = {}, secretVersions = {}, endpoints = {},
|
|
154
|
+
export function containerSpecs(desired, node, { secrets = {}, secretVersions = {}, endpoints = {}, bind } = {}) {
|
|
180
155
|
const { project } = desired;
|
|
181
156
|
const network = networkName(project);
|
|
182
157
|
const placed = new Set();
|
|
@@ -322,7 +297,7 @@ export function containerSpecs(desired, node, { secrets = {}, secretVersions = {
|
|
|
322
297
|
dependsOn,
|
|
323
298
|
};
|
|
324
299
|
}
|
|
325
|
-
return { network, volumes: volumeSpecs(desired, node, usedVolumes
|
|
300
|
+
return { network, volumes: volumeSpecs(desired, node, usedVolumes), specs };
|
|
326
301
|
}
|
|
327
302
|
/** Start order: dependencies first, ties by service name; a cycle is an error. */
|
|
328
303
|
export function startOrder(specs) {
|
package/dist/control.d.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import express from 'express';
|
|
3
|
+
import { resolveImage } from './registry.ts';
|
|
3
4
|
import { saveImageArchive, inspectImage, validateImageArchive } from './artifacts.ts';
|
|
4
5
|
import https from 'node:https';
|
|
5
6
|
import http from 'node:http';
|
|
6
7
|
import { openDb } from './db.ts';
|
|
7
8
|
import type { NodeRow } from './db.ts';
|
|
8
|
-
import {
|
|
9
|
+
import { buildImage, run as runCommand } from './build.ts';
|
|
9
10
|
declare module 'express-serve-static-core' {
|
|
10
11
|
interface Request {
|
|
11
12
|
node?: NodeRow;
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
15
|
export interface ControlImageOperations {
|
|
15
|
-
|
|
16
|
+
resolve: typeof resolveImage;
|
|
17
|
+
build: typeof buildImage;
|
|
16
18
|
save: typeof saveImageArchive;
|
|
17
19
|
inspect: typeof inspectImage;
|
|
18
20
|
validate: typeof validateImageArchive;
|