@wpelevator/tug 0.5.0 → 0.7.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.
Files changed (43) hide show
  1. package/README.md +3 -3
  2. package/dist/agent-jobs.d.ts +41 -0
  3. package/dist/agent-jobs.js +161 -0
  4. package/dist/agent.d.ts +8 -1
  5. package/dist/agent.js +76 -7
  6. package/dist/backup-check.d.ts +11 -0
  7. package/dist/backup-check.js +10 -0
  8. package/dist/backup-config.d.ts +52 -0
  9. package/dist/backup-config.js +276 -0
  10. package/dist/backup-destination.d.ts +22 -0
  11. package/dist/backup-destination.js +72 -0
  12. package/dist/backup-job.d.ts +32 -0
  13. package/dist/backup-job.js +352 -0
  14. package/dist/backup-restore.d.ts +67 -0
  15. package/dist/backup-restore.js +317 -0
  16. package/dist/backup-scheduler.d.ts +60 -0
  17. package/dist/backup-scheduler.js +418 -0
  18. package/dist/cli-build.d.ts +1 -1
  19. package/dist/cli-build.js +15 -2
  20. package/dist/cli-config.d.ts +45 -5
  21. package/dist/cli-config.js +269 -19
  22. package/dist/cli-live-transport.d.ts +1 -1
  23. package/dist/cli-live-transport.js +2 -2
  24. package/dist/cli-live.d.ts +2 -0
  25. package/dist/cli-live.js +1 -0
  26. package/dist/cli.js +226 -16
  27. package/dist/compose.js +5 -2
  28. package/dist/container-spec.d.ts +3 -2
  29. package/dist/container-spec.js +9 -34
  30. package/dist/control.js +253 -19
  31. package/dist/cron.d.ts +17 -0
  32. package/dist/cron.js +124 -0
  33. package/dist/db.d.ts +70 -0
  34. package/dist/db.js +7 -0
  35. package/dist/docker-api.d.ts +7 -0
  36. package/dist/live.d.ts +8 -0
  37. package/dist/live.js +84 -0
  38. package/dist/normalize.d.ts +6 -0
  39. package/dist/normalize.js +15 -1
  40. package/dist/protocol.d.ts +118 -7
  41. package/dist/protocol.js +7 -0
  42. package/dist/tokens.js +10 -0
  43. package/package.json +2 -1
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 { resolveCliConfig, integerOption, normalizeControlUrl } from "./cli-config.js";
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";
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
13
13
  import { composeHash } from "./compose.js";
14
14
  import { normalizeCompose } from "./normalize.js";
15
15
  import { errMessage, token, loadTemplate } from "./util.js";
16
+ import { backupCheckProblems } from "./backup-check.js";
16
17
  import { PROJECT_NAME_PATTERN, ROLES, TOKEN_NAME_PATTERN } from "./tokens.js";
17
18
  import { packageVersion, resolveImage } from "./image.js";
18
19
  import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
@@ -22,29 +23,32 @@ import { createCliLiveTransport } from "./cli-live-transport.js";
22
23
  // built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
23
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
25
  const templatesDir = path.join(__dirname, 'templates');
26
+ const CONFIG_HELP = 'CLI config file, YAML or JSON (default: TUG_CONFIG, else tug.yml, tug.yaml, tug.json or .tug.json)';
25
27
  const program = new Command();
26
28
  program
27
29
  .name('tug')
28
30
  .description('Portable VPS deployments with Docker Compose')
29
31
  .version(packageVersion())
30
32
  .enablePositionalOptions()
31
- .option('--config <file>', 'CLI JSON config file (default: TUG_CONFIG or .tug.json)')
33
+ .option('--config <file>', CONFIG_HELP)
32
34
  .option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)')
33
35
  .option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
34
36
  let resolvedConfig;
35
- const cfgPath = () => resolvedConfig.file;
36
37
  async function loadCfg() {
37
38
  return resolvedConfig;
38
39
  }
39
40
  const collect = (v, a) => [...a, v];
40
- async function saveCfg(c) {
41
- await fs.writeFile(cfgPath(), JSON.stringify(c, null, 2), { mode: 0o600 });
41
+ async function readStdin() {
42
+ const chunks = [];
43
+ for await (const chunk of process.stdin)
44
+ chunks.push(Buffer.from(chunk));
45
+ return Buffer.concat(chunks).toString('utf8');
42
46
  }
43
47
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
48
  async function request(method, route, body, multipart = false) {
45
49
  const c = await loadCfg();
46
50
  const base = c.controlUrl;
47
- const tok = c.operatorToken;
51
+ const tok = c.token;
48
52
  const headers = tok ? { Authorization: `Bearer ${tok}` } : {};
49
53
  if (!multipart)
50
54
  headers['content-type'] = 'application/json';
@@ -180,11 +184,15 @@ async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
180
184
  program
181
185
  .command('init')
182
186
  .option('--token <token>', 'operator token (prefer TUG_TOKEN to avoid process-list exposure)')
183
- .action(async (o) => {
187
+ .description('create the config file if missing and store the operator token in its git-ignored .local overlay')
188
+ .action(async (o, command) => {
184
189
  const cfg = await loadCfg();
185
- const operatorToken = o.token || cfg.operatorToken || token('tug_op');
186
- await saveCfg({ controlUrl: cfg.controlUrl, operatorToken, project: cfg.project });
187
- console.log(`wrote ${cfgPath()}`);
190
+ const { controlUrl, project } = command.optsWithGlobals();
191
+ for (const file of await writeInitConfig(cfg, o.token || cfg.token || token('tug_op'), {
192
+ controlUrl,
193
+ project,
194
+ }))
195
+ console.log(`wrote ${file}`);
188
196
  });
189
197
  program
190
198
  .command('node-register <name>')
@@ -279,8 +287,8 @@ program
279
287
  }, null, 2));
280
288
  });
281
289
  program
282
- .command('node-install <sshTarget>')
283
- .description('register and install an agent container on a Docker-capable host over SSH')
290
+ .command('node-install [sshTarget]')
291
+ .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)')
284
292
  .requiredOption('--name <name>')
285
293
  .option('--address <addr>')
286
294
  .option('--public-address <addr>')
@@ -292,8 +300,11 @@ program
292
300
  .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
293
301
  .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
294
302
  .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')
295
- .action(async (sshTarget, o) => {
303
+ .action(async (sshArgument, o) => {
296
304
  const cfg = await loadCfg();
305
+ const sshTarget = sshArgument ?? nodeSettings(cfg, o.name).ssh;
306
+ if (!sshTarget)
307
+ throw new Error(`node-install needs an SSH target: pass <ssh-target> or set nodes.${o.name}.ssh in ${cfg.file}`);
297
308
  const controlUrl = normalizeControlUrl(o.agentControlUrl ?? cfg.controlUrl);
298
309
  assertHttps(controlUrl, o.httpOnlyDev);
299
310
  if (o.installDocker)
@@ -394,6 +405,182 @@ program
394
405
  program
395
406
  .command('secret-remove <name>')
396
407
  .action(async (name, o) => console.log(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`)));
408
+ const formatBytes = (bytes) => bytes === null
409
+ ? '-'
410
+ : bytes < 1024 ** 2
411
+ ? `${Math.ceil(bytes / 1024)} KiB`
412
+ : `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
413
+ const BACKUP_TERMINAL = ['succeeded', 'failed', 'missed', 'cancelled'];
414
+ program
415
+ .command('backup-run [targets...]')
416
+ .description('back up the named services or volumes now, or every backup target of the project; waits and exits nonzero if any backup fails')
417
+ .option('--no-wait', 'return once the backups are queued')
418
+ .option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
419
+ .action(async (targets, o) => {
420
+ const project = (await loadCfg()).project || 'site';
421
+ const queued = await request('POST', '/api/v1/backups/run', { project, targets: targets.length ? targets : undefined });
422
+ for (const message of queued.errors)
423
+ console.error(message);
424
+ for (const run of queued.runs)
425
+ console.log(`queued ${run.target} as ${run.runId}`);
426
+ let failed = queued.errors.length > 0;
427
+ if (o.wait) {
428
+ const deadline = Date.now() + Number(o.timeout) * 1000;
429
+ const pending = new Set(queued.runs.map((run) => run.runId));
430
+ while (pending.size && Date.now() < deadline) {
431
+ await new Promise((resolve) => setTimeout(resolve, 2000));
432
+ const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=200`);
433
+ for (const run of listing.runs)
434
+ if (pending.has(run.id) && BACKUP_TERMINAL.includes(run.state)) {
435
+ pending.delete(run.id);
436
+ const ok = run.state === 'succeeded';
437
+ failed ||= !ok;
438
+ console.log(ok
439
+ ? `${run.target}: snapshot ${run.snapshot_id?.slice(0, 8)}, ${formatBytes(run.added_bytes)} added`
440
+ : `${run.target}: ${run.state}${run.error ? ` (${run.error})` : ''}`);
441
+ }
442
+ }
443
+ if (pending.size) {
444
+ console.error(`timed out waiting for ${pending.size} backup(s); they keep running on the node`);
445
+ failed = true;
446
+ }
447
+ }
448
+ if (failed)
449
+ process.exitCode = 1;
450
+ });
451
+ program
452
+ .command('backup-list')
453
+ .description('show each backup target with its last success and next run, then recent runs')
454
+ .option('--limit <n>', 'number of recent runs to show', '20')
455
+ .option('--check', 'exit nonzero when a target is stale, or its last run failed or was missed, or there is no destination; for cron and monitors')
456
+ .action(async (o) => {
457
+ const project = (await loadCfg()).project || 'site';
458
+ const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=${encodeURIComponent(o.limit)}`);
459
+ if (!listing.destinationConfigured)
460
+ console.error('no backup destination is configured; run tug backup-destination-set');
461
+ if (!listing.targets.length)
462
+ console.log('no backup targets; add x-tug.backup to a database service or volume');
463
+ for (const t of listing.targets)
464
+ console.log(`${t.target}\t${t.node ?? '-'}\t${t.schedule}\tlast success ${t.lastSuccessAt ?? 'never'}\tnext ${t.enabled ? t.nextRunAt : 'disabled'}`);
465
+ for (const r of listing.runs)
466
+ 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}` : ''}`);
467
+ if (o.check) {
468
+ const problems = backupCheckProblems(listing);
469
+ for (const problem of problems)
470
+ console.error(`PROBLEM ${problem}`);
471
+ if (problems.length)
472
+ process.exitCode = 1;
473
+ }
474
+ });
475
+ program
476
+ .command('restore <target>')
477
+ .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')
478
+ .option('--from <id>', 'backup run id, restic snapshot id, or latest', 'latest')
479
+ .option('--scratch', 'restore a volume into a new volume next to the live one, touching nothing live')
480
+ .option('--no-safety-backup', 'skip the backup of the current data taken before an in-place restore')
481
+ .option('--writers <service>', 'service to stop during the restore, instead of discovering them (repeatable)', collect, [])
482
+ .option('--no-writers', 'restore without stopping any service')
483
+ .option('--confirm', 'perform the restore')
484
+ .option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
485
+ .action(async (target, o) => {
486
+ const project = (await loadCfg()).project || 'site';
487
+ const explicit = Array.isArray(o.writers) && o.writers.length > 0 ? o.writers : undefined;
488
+ const result = await request('POST', '/api/v1/restores', {
489
+ project,
490
+ target,
491
+ from: o.from,
492
+ scratch: o.scratch === true,
493
+ safetyBackup: o.safetyBackup,
494
+ writers: explicit,
495
+ noWriters: o.writers === false,
496
+ confirm: o.confirm === true,
497
+ });
498
+ const plan = result.plan;
499
+ console.log(`target: ${plan.target} on ${plan.node}`);
500
+ console.log(`snapshot: ${plan.snapshotId}${plan.snapshotTime ? ` (${plan.snapshotTime})` : ''}`);
501
+ console.log(`mode: ${plan.mode}${plan.scratchVolume ? ` into new volume ${plan.scratchVolume}` : ''}`);
502
+ if (plan.mode === 'in-place') {
503
+ console.log(`stops: ${plan.writers.length ? plan.writers.join(', ') : 'nothing'}`);
504
+ console.log(`safety: ${plan.safetyBackup ? 'a backup of the current data is taken first' : 'none (--no-safety-backup)'}`);
505
+ }
506
+ if (!result.confirmed) {
507
+ console.log('\nNothing was changed. Run again with --confirm to restore.');
508
+ return;
509
+ }
510
+ const deadline = Date.now() + Number(o.timeout) * 1000;
511
+ for (;;) {
512
+ await new Promise((resolve) => setTimeout(resolve, 2000));
513
+ const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
514
+ const restore = restores.find((item) => item.id === result.restoreId);
515
+ if (restore?.state === 'succeeded') {
516
+ if (restore.error)
517
+ console.error(`warning: ${restore.error}`);
518
+ console.log(`restore ${restore.id} completed${restore.scratch_volume ? `; files are in volume ${restore.scratch_volume}` : ''}`);
519
+ return;
520
+ }
521
+ if (restore && ['failed', 'interrupted', 'released'].includes(restore.state)) {
522
+ console.error(`restore ${restore.id} ${restore.state}: ${restore.error ?? ''}`);
523
+ if (restore.state === 'interrupted')
524
+ 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)`);
525
+ process.exitCode = 1;
526
+ return;
527
+ }
528
+ if (Date.now() > deadline) {
529
+ console.error(`timed out waiting; restore ${result.restoreId} continues on control (tug restore-list)`);
530
+ process.exitCode = 1;
531
+ return;
532
+ }
533
+ }
534
+ });
535
+ program
536
+ .command('restore-list')
537
+ .description('show recent restores and their state')
538
+ .action(async () => {
539
+ const project = (await loadCfg()).project || 'site';
540
+ const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
541
+ if (!restores.length)
542
+ console.log('no restores');
543
+ for (const r of restores)
544
+ 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}` : ''}`);
545
+ });
546
+ program
547
+ .command('restore-resume <id>')
548
+ .description('run an interrupted restore again; the stopped services stay stopped meanwhile')
549
+ .action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/resume`))));
550
+ program
551
+ .command('restore-release <id>')
552
+ .description('start the services an interrupted restore stopped and unlock the project; the data may be partly restored')
553
+ .action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/release`))));
554
+ program
555
+ .command('backup-destination-set <url>')
556
+ .description('set where backups go, such as s3:https://s3.example.com/bucket/tug-backups; credentials are stored encrypted on control')
557
+ .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, [])
558
+ .action(async (url, o) => {
559
+ const missing = o.env.filter((name) => process.env[name] === undefined);
560
+ const lines = missing.length
561
+ ? (await readStdin()).split(/\r?\n/).filter((line) => line.length > 0)
562
+ : [];
563
+ if (lines.length < missing.length)
564
+ throw new Error(`expected a value on stdin for ${missing.slice(lines.length).join(', ')}`);
565
+ const env = Object.fromEntries(o.env.map((name) => [name, process.env[name] ?? lines[missing.indexOf(name)]]));
566
+ console.log(JSON.stringify(await request('POST', '/api/v1/backup/destination', { url, env })));
567
+ });
568
+ program
569
+ .command('backup-destination-show')
570
+ .description('show the backup destination and the names of its credential variables, never values')
571
+ .action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/backup/destination'), null, 2)));
572
+ program
573
+ .command('backup-destination-remove')
574
+ .description('remove the backup destination and its stored credentials; existing backups stay in the repository')
575
+ .action(async () => console.log(JSON.stringify(await request('DELETE', '/api/v1/backup/destination'))));
576
+ program
577
+ .command('backup-key')
578
+ .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")
579
+ .action(async () => {
580
+ const c = await loadCfg();
581
+ const project = c.project || 'site';
582
+ console.log(JSON.stringify(await request('GET', `/api/v1/backup/key?project=${encodeURIComponent(project)}`), null, 2));
583
+ });
397
584
  program
398
585
  .command('token-create <name>')
399
586
  .description('create a named API token for CI or a teammate; the secret is printed once and cannot be shown again')
@@ -425,7 +612,10 @@ program
425
612
  async function makeContext() {
426
613
  const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
427
614
  try {
428
- await createSourceArchive(process.cwd(), tmp, [cfgPath()]);
615
+ await createSourceArchive(process.cwd(), tmp, [
616
+ resolvedConfig.file,
617
+ resolvedConfig.localFile,
618
+ ]);
429
619
  }
430
620
  catch (error) {
431
621
  await fs.rm(tmp, { force: true });
@@ -609,7 +799,7 @@ program
609
799
  });
610
800
  async function runLive(operation, input) {
611
801
  const cfg = await loadCfg();
612
- process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.controlUrl, cfg.operatorToken));
802
+ process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.controlUrl, cfg.token));
613
803
  }
614
804
  program
615
805
  .command('exec <service> [command...]')
@@ -620,6 +810,15 @@ program
620
810
  .action(async (service, command, o) => {
621
811
  await runLive('exec', { service, command, ...o });
622
812
  });
813
+ program
814
+ .command('run <service> [command...]')
815
+ .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")
816
+ .option('--node <name>', 'select the node when a service has multiple placements')
817
+ .action(async (service, command, o) => {
818
+ if (!command.length)
819
+ throw new Error('run needs a command, such as: tug run web -- wp core update-db');
820
+ await runLive('exec', { service, command, node: o.node, oneOff: true });
821
+ });
623
822
  program
624
823
  .command('ps')
625
824
  .description('print per-container state as JSON')
@@ -701,10 +900,21 @@ program.hook('preAction', async (_root, command) => {
701
900
  connection.project = undefined;
702
901
  }
703
902
  resolvedConfig = await resolveCliConfig(connection, process.env, process.cwd(), command.name() === 'init');
903
+ // Config values are defaults for options not given on the command line; nodes are only read by the
904
+ // commands that create or register one, never by commands that talk to an existing node.
905
+ const nodeName = command.name() === 'node-register'
906
+ ? command.args[0]
907
+ : ['node-install', 'node-enroll'].includes(command.name())
908
+ ? options.name
909
+ : undefined;
910
+ applyConfigDefaults(command, options, {
911
+ ...resolvedConfig.commandDefaults,
912
+ ...(command.name().startsWith('node-') ? nodeSettings(resolvedConfig, nodeName) : {}),
913
+ });
704
914
  });
705
915
  for (const command of program.commands) {
706
916
  command
707
- .option('--config <file>', 'CLI JSON config file (default: TUG_CONFIG or .tug.json)')
917
+ .option('--config <file>', CONFIG_HELP)
708
918
  .option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)');
709
919
  if (!command.options.some((option) => option.long === '--project'))
710
920
  command.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
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,6 +304,7 @@ 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
  }
@@ -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, nodeAddresses, bind, }?: ContainerSpecOptions): NodePlan;
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. */
@@ -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, with NFS volumes mounted from the exporter's directory or address. */
109
- function volumeSpecs(desired, node, used, nodeAddresses) {
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
- const isNfs = xe.type === 'nfs' || xe.driver === 'nfs' || (xe.type === 'shared' && xe.driver === 'nfs');
132
- const plain = () => ({
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 = {}, nodeAddresses = {}, bind, } = {}) {
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, nodeAddresses), specs };
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) {