@wpelevator/tug 0.3.0 → 0.5.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/dist/agent.js CHANGED
@@ -1,22 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
- import { createWriteStream } from 'node:fs';
3
+ import { imageLocation } from "./registry.js";
4
+ import { createReadStream, createWriteStream } from 'node:fs';
4
5
  import { Readable } from 'node:stream';
5
6
  import { pipeline } from 'node:stream/promises';
6
7
  import { validateImageArchive, verifyDownload } from "./artifacts.js";
7
8
  import path from 'node:path';
8
- import { spawn, spawnSync } from 'node:child_process';
9
+ import { spawnSync } from 'node:child_process';
9
10
  import net from 'node:net';
10
11
  import dns from 'node:dns/promises';
11
12
  import { WebSocket } from 'ws';
12
13
  import { createHash } from 'node:crypto';
13
- import { addressBoundServices, composeForNode, dumpYaml, isRolling, serviceSecretVersions, } from "./compose.js";
14
+ import { addressBoundServices, isRolling, serviceSecretVersions } from "./compose.js";
15
+ import { containerSpecs, networkName, volumeName } from "./container-spec.js";
16
+ import { API_VERSION, DockerEngine, isNotFound } from "./docker-api.js";
14
17
  import { LIVE_MAX_FRAME_BYTES, LIVE_START_TIMEOUT_MS } from "./protocol.js";
15
18
  import { LiveOperationEngine } from "./live.js";
16
- import { loadRolloutState, activeRollingStates, composeBase, hasLegacyContainers, reconcileRolling, retireAll, } from "./rollout.js";
19
+ import { activeRollingStates, clearRolloutState, ensureProjectResources, loadRolloutState, projectContainers, reconcileRecreate, reconcileRolling, sweepOrphans, } from "./reconciler.js";
17
20
  import { ensureDir, errMessage, isPublicAddress, now, parseDuration } from "./util.js";
18
21
  import { openAgentDb } from "./db.js";
19
- import { createDockerRunner, createHostRunner } from "./runners.js";
22
+ import { createHostRunner } from "./runners.js";
20
23
  import { CADDY_CONTAINER, reconcileIngress, removeIngress } from "./ingress.js";
21
24
  import { WG_INTERFACE, ensureWgKeys, readReportedKey, reconcileMesh, rotateWgKeys, writeReportedKey, } from "./mesh.js";
22
25
  const CONTROL_URL_FROM_ENV = Boolean(process.env.TUG_CONTROL_URL);
@@ -30,7 +33,7 @@ const INTERVAL = Number(process.env.TUG_POLL_INTERVAL_MS || 5000);
30
33
  const MESH = process.env.TUG_MESH === 'true';
31
34
  export function defaultContext() {
32
35
  return {
33
- docker: createDockerRunner(),
36
+ docker: new DockerEngine(),
34
37
  http: fetch,
35
38
  nodeName: NODE_NAME,
36
39
  stateDir: STATE_DIR,
@@ -230,29 +233,18 @@ export async function resolveLiveContainer(ctx, request) {
230
233
  const rolling = state.services[request.service];
231
234
  if (rolling && !rolling.active)
232
235
  throw new Error('Rolling service has no active revision');
233
- const project = rolling?.active?.project ?? request.project;
234
- if (!name.test(project))
235
- throw new Error('Invalid active rollout project');
236
- const result = await ctx.docker([
237
- 'ps',
238
- '--all',
239
- '--no-trunc',
240
- '--quiet',
241
- '--filter',
242
- 'label=com.docker.compose.project=' + project,
243
- '--filter',
244
- 'label=com.docker.compose.service=' + request.service,
245
- '--filter',
246
- 'label=com.docker.compose.oneoff=False',
247
- ]);
248
- const ids = result.out.trim().split(/\s+/).filter(Boolean);
249
- if (ids.length !== 1)
250
- throw new Error(ids.length
236
+ const containers = await projectContainers(ctx.docker, request.project, request.service);
237
+ // A rolling service has the draining revision beside the serving one; only the serving one is addressed.
238
+ const candidates = rolling?.active
239
+ ? containers.filter((c) => c.Names.includes(`/${rolling.active.container}`))
240
+ : containers;
241
+ if (candidates.length !== 1)
242
+ throw new Error(candidates.length
251
243
  ? 'Service has multiple containers; select an unambiguous service'
252
244
  : 'Service has no container on this node');
253
- if (!/^[a-f0-9]{12,64}$/.test(ids[0]))
245
+ if (!/^[a-f0-9]{12,64}$/.test(candidates[0].Id))
254
246
  throw new Error('Docker returned an invalid container ID');
255
- return ids[0];
247
+ return candidates[0].Id;
256
248
  }
257
249
  export class AgentLiveOperations {
258
250
  ctx;
@@ -263,6 +255,7 @@ export class AgentLiveOperations {
263
255
  this.ctx = ctx;
264
256
  this.emit = emit;
265
257
  this.engine = new LiveOperationEngine({
258
+ docker: ctx.docker,
266
259
  ...options,
267
260
  emit: (event) => {
268
261
  if (!this.sessions.has(event.sessionId))
@@ -381,6 +374,8 @@ export class AgentLiveOperations {
381
374
  this.engine.cancel(id);
382
375
  }
383
376
  async open(request, entry) {
377
+ const current = () => this.sessions.get(request.sessionId) === entry;
378
+ const started = () => this.emit({ v: 1, type: 'live.started', sessionId: request.sessionId });
384
379
  try {
385
380
  if (this.sessions.size > 16)
386
381
  throw new Error('Live session limit reached');
@@ -392,83 +387,71 @@ export class AgentLiveOperations {
392
387
  !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(request.project))
393
388
  throw new Error('Invalid project');
394
389
  if (request.operation === 'ps') {
395
- const rollout = await loadRolloutState(path.join(this.ctx.stateDir, 'projects', request.project));
396
- if (this.sessions.get(request.sessionId) !== entry)
397
- return;
398
- const projects = [
399
- ...new Set([
400
- request.project,
401
- ...Object.values(rollout.services).flatMap((service) => service.active ? [service.active.project] : []),
402
- ]),
403
- ];
404
- this.engine.start({ operation: 'ps', sessionId: request.sessionId, projects });
390
+ // A query's result and exit follow `started` immediately, so announce it first.
391
+ started();
392
+ await this.engine.start({
393
+ operation: 'ps',
394
+ sessionId: request.sessionId,
395
+ projects: [request.project],
396
+ });
397
+ return;
405
398
  }
406
- else {
407
- if (!['exec', 'logs', 'inspect'].includes(request.operation))
408
- throw new Error('Invalid live operation');
409
- let timer;
410
- let container;
411
- try {
412
- container = await Promise.race([
413
- resolveLiveContainer(this.ctx, request),
414
- new Promise((_resolve, reject) => {
415
- timer = setTimeout(() => reject(new Error('Live container resolution timed out')), LIVE_START_TIMEOUT_MS);
416
- timer.unref();
417
- }),
418
- ]);
419
- }
420
- finally {
421
- clearTimeout(timer);
422
- }
423
- if (this.sessions.get(request.sessionId) !== entry)
424
- return;
425
- if (request.operation === 'exec')
426
- this.engine.start({
427
- operation: 'exec',
428
- sessionId: request.sessionId,
429
- container,
430
- command: request.command,
431
- interactive: request.interactive,
432
- tty: request.tty,
433
- columns: request.columns,
434
- rows: request.rows,
435
- });
436
- else if (request.operation === 'logs')
437
- this.engine.start({
438
- operation: 'logs',
439
- sessionId: request.sessionId,
440
- container,
441
- tail: request.tail,
442
- });
443
- else
444
- this.engine.start({ operation: 'inspect', sessionId: request.sessionId, container });
399
+ if (!['exec', 'logs', 'inspect'].includes(request.operation))
400
+ throw new Error('Invalid live operation');
401
+ let timer;
402
+ let container;
403
+ try {
404
+ container = await Promise.race([
405
+ resolveLiveContainer(this.ctx, request),
406
+ new Promise((_resolve, reject) => {
407
+ timer = setTimeout(() => reject(new Error('Live container resolution timed out')), LIVE_START_TIMEOUT_MS);
408
+ timer.unref();
409
+ }),
410
+ ]);
411
+ }
412
+ finally {
413
+ clearTimeout(timer);
445
414
  }
446
- if (this.sessions.get(request.sessionId) === entry)
447
- this.emit({ v: 1, type: 'live.started', sessionId: request.sessionId });
415
+ if (!current())
416
+ return;
417
+ if (request.operation === 'inspect') {
418
+ started();
419
+ await this.engine.start({ operation: 'inspect', sessionId: request.sessionId, container });
420
+ return;
421
+ }
422
+ if (request.operation === 'exec')
423
+ await this.engine.start({
424
+ operation: 'exec',
425
+ sessionId: request.sessionId,
426
+ container,
427
+ command: request.command,
428
+ interactive: request.interactive,
429
+ tty: request.tty,
430
+ columns: request.columns,
431
+ rows: request.rows,
432
+ });
433
+ else
434
+ await this.engine.start({
435
+ operation: 'logs',
436
+ sessionId: request.sessionId,
437
+ container,
438
+ tail: request.tail,
439
+ });
440
+ if (current())
441
+ started();
442
+ else
443
+ this.engine.cancel(request.sessionId);
448
444
  }
449
445
  catch (error) {
450
446
  this.fail(request.sessionId, entry, error);
451
447
  }
452
448
  }
453
449
  }
454
- async function writeEnv(file, secrets) {
455
- const body = Object.entries(secrets)
456
- .map(([k, v]) => `${k}=${String(v).replace(/\n/g, '\\n')}`)
457
- .join('\n') + '\n';
458
- await fs.writeFile(file, body, { mode: 0o600 });
459
- }
460
- /** `docker compose` prefix for a project; rolling revision projects pass their own Compose file. */
461
- const composeCmd = (project, cwd, composeFile) => composeFile ? composeBase(project, cwd, composeFile) : ['compose', '-p', project];
462
- export async function containerIp(ctx, project, service, cwd, composeFile) {
463
- const ids = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out
464
- .trim()
465
- .split(/\s+/)
466
- .filter(Boolean);
467
- if (!ids.length)
468
- throw new Error(`${service}: no container found`);
469
- const inspected = JSON.parse((await ctx.docker(['inspect', ids[0]], { cwd })).out)[0];
470
- const networks = inspected?.NetworkSettings?.Networks || {};
471
- return Object.values(networks)[0]?.IPAddress || '127.0.0.1';
450
+ /** The container's address on the project network, which health checks dial. */
451
+ export async function containerIp(ctx, project, container) {
452
+ const networks = (await ctx.docker.inspectContainer(container)).NetworkSettings?.Networks || {};
453
+ const net = networks[networkName(project)] ?? Object.values(networks)[0];
454
+ return net?.IPAddress || net?.GlobalIPv6Address || '127.0.0.1';
472
455
  }
473
456
  function tcpCheck(host, port, timeoutMs = 2000) {
474
457
  return new Promise((resolve, reject) => {
@@ -483,7 +466,7 @@ function tcpCheck(host, port, timeoutMs = 2000) {
483
466
  s.on('error', reject);
484
467
  });
485
468
  }
486
- export async function waitForHealth(ctx, project, service, health, cwd, composeFile) {
469
+ export async function waitForHealth(ctx, project, container, service, health) {
487
470
  if (!ctx.apply || !health)
488
471
  return;
489
472
  const retries = Number(health.retries || 10);
@@ -492,20 +475,17 @@ export async function waitForHealth(ctx, project, service, health, cwd, composeF
492
475
  for (let i = 0; i < retries; i++) {
493
476
  try {
494
477
  if (health.type === 'docker') {
495
- const id = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out
496
- .trim()
497
- .split(/\s+/)[0];
498
- const state = JSON.parse((await ctx.docker(['inspect', id], { cwd })).out)[0]?.State;
478
+ const state = (await ctx.docker.inspectContainer(container)).State;
499
479
  if (!state?.Running)
500
480
  throw new Error(`${service}: not running`);
501
481
  if (state.Health && state.Health.Status !== 'healthy')
502
482
  throw new Error(`${service}: docker health ${state.Health.Status}`);
503
483
  }
504
484
  else if (health.type === 'tcp') {
505
- await tcpCheck(await containerIp(ctx, project, service, cwd, composeFile), health.port, parseDuration(health.timeout || '2s'));
485
+ await tcpCheck(await containerIp(ctx, project, container), health.port, parseDuration(health.timeout || '2s'));
506
486
  }
507
487
  else if (health.type === 'http') {
508
- const ip = await containerIp(ctx, project, service, cwd, composeFile);
488
+ const ip = await containerIp(ctx, project, container);
509
489
  const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, {
510
490
  signal: AbortSignal.timeout(parseDuration(health.timeout || '2s')),
511
491
  });
@@ -535,19 +515,22 @@ function describeHealthCheck(health) {
535
515
  return 'docker health check';
536
516
  }
537
517
  export { parseDuration };
538
- /**
539
- * Containers of Tug projects (`projects`, plus their rolling revision projects `<project>-…`) that
540
- * listen on all interfaces or on a public IP, from `docker ps --format '{{.Label "com.docker.compose.project"}}\t{{.Names}}\t{{.Ports}}'`.
541
- */
542
- export function publicListeners(psOutput, projects) {
518
+ /** Containers of Tug projects (`projects`) that publish a port on all interfaces or on a public IP. */
519
+ export function publicListeners(containers, projects) {
543
520
  const out = [];
544
- for (const line of psOutput.split('\n')) {
545
- const [project, container, ports = ''] = line.split('\t');
546
- if (!project || !projects.some((p) => project === p || project.startsWith(`${p}-`)))
521
+ for (const c of containers) {
522
+ const project = c.Labels?.['tug.project'];
523
+ if (!project || !projects.includes(project))
547
524
  continue;
548
- for (const m of ports.matchAll(/(\[[0-9a-fA-F:.]+\]|[0-9a-fA-F:.]+):(\d+(?:-\d+)?)->(\S+?)(?:,|$)/g)) {
549
- if (isPublicAddress(m[1]))
550
- out.push({ project, container, listen: `${m[1]}:${m[2]}->${m[3]}` });
525
+ for (const p of c.Ports || []) {
526
+ if (!p.PublicPort || !p.IP || !isPublicAddress(p.IP))
527
+ continue;
528
+ const host = p.IP.includes(':') ? `[${p.IP}]` : p.IP;
529
+ out.push({
530
+ project,
531
+ container: (c.Names?.[0] || c.Id).replace(/^\//, ''),
532
+ listen: `${host}:${p.PublicPort}->${p.PrivatePort}/${p.Type}`,
533
+ });
551
534
  }
552
535
  }
553
536
  return out;
@@ -558,14 +541,8 @@ async function publicPortsCheck(ctx) {
558
541
  const projects = await fs
559
542
  .readdir(path.join(ctx.stateDir, 'projects'))
560
543
  .catch(() => []);
561
- const ps = await ctx.docker([
562
- 'ps',
563
- '--filter',
564
- 'label=com.docker.compose.project',
565
- '--format',
566
- '{{.Label "com.docker.compose.project"}}\t{{.Names}}\t{{.Ports}}',
567
- ]);
568
- const listeners = publicListeners(ps.out, projects);
544
+ const containers = await ctx.docker.listContainers({ label: ['tug.project'] }, false);
545
+ const listeners = publicListeners(containers, projects);
569
546
  const stdout = listeners.map((l) => `${l.project}\t${l.container}\t${l.listen}`).join('\n');
570
547
  return listeners.length
571
548
  ? {
@@ -584,25 +561,30 @@ async function publicPortsCheck(ctx) {
584
561
  return { name: 'ports.public', ok: false, error: errMessage(e).slice(-4000) };
585
562
  }
586
563
  }
564
+ const MIN_API_VERSION = API_VERSION.replace(/^v/, '');
565
+ /** The minor number of an Engine API version such as `1.41`; 0 when unparseable. */
566
+ const apiMinor = (version) => Number(version?.split('.')[1]) || 0;
587
567
  export async function executeCommand(command, ctx = defaultContext()) {
588
568
  const payload = typeof command.payload_json === 'string'
589
569
  ? JSON.parse(command.payload_json || '{}')
590
570
  : command.payload || {};
591
571
  if (command.type === 'diagnostics.run') {
592
572
  const checks = [];
593
- for (const [name, args] of [
594
- ['docker.info', ['info']],
595
- ['docker.version', ['version']],
596
- ['docker.compose.version', ['compose', 'version']],
597
- ]) {
573
+ const daemon = [
574
+ ['docker.info', async () => JSON.stringify(await ctx.docker.info(), null, 2)],
575
+ [
576
+ 'docker.version',
577
+ async () => {
578
+ const version = await ctx.docker.version();
579
+ if (apiMinor(version.ApiVersion) < apiMinor(MIN_API_VERSION))
580
+ throw new Error(`Docker Engine API ${version.ApiVersion} is older than the ${MIN_API_VERSION} the agent needs (Docker 20.10+)`);
581
+ return JSON.stringify(version, null, 2);
582
+ },
583
+ ],
584
+ ];
585
+ for (const [name, fn] of daemon) {
598
586
  try {
599
- const result = await ctx.docker([...args]);
600
- checks.push({
601
- name,
602
- ok: true,
603
- stdout: result.out.slice(-4000),
604
- stderr: result.err.slice(-4000),
605
- });
587
+ checks.push({ name, ok: true, stdout: (await fn()).slice(-4000), stderr: '' });
606
588
  }
607
589
  catch (e) {
608
590
  checks.push({ name, ok: false, error: errMessage(e).slice(-4000) });
@@ -616,7 +598,10 @@ export async function executeCommand(command, ctx = defaultContext()) {
616
598
  if (await fs.stat(path.join(ctx.stateDir, 'ingress')).then(() => true, () => false)) {
617
599
  extra.push([
618
600
  'ingress.caddy',
619
- () => ctx.docker(['container', 'inspect', '-f', '{{.State.Status}}', CADDY_CONTAINER]),
601
+ async () => ({
602
+ out: String((await ctx.docker.inspectContainer(CADDY_CONTAINER)).State?.Status),
603
+ err: '',
604
+ }),
620
605
  ]);
621
606
  }
622
607
  for (const [name, fn] of extra) {
@@ -641,10 +626,8 @@ export async function executeCommand(command, ctx = defaultContext()) {
641
626
  };
642
627
  }
643
628
  if (command.type === 'service.restart') {
644
- const project = payload.project;
645
- const service = payload.service;
646
- const dir = path.join(ctx.stateDir, 'projects', project);
647
- await ctx.docker(['compose', '-p', project, 'restart', service], { cwd: dir });
629
+ const { project, service } = payload;
630
+ await ctx.docker.restartContainer(await resolveLiveContainer(ctx, { project, service }));
648
631
  return {
649
632
  commandId: command.id,
650
633
  state: 'succeeded',
@@ -652,28 +635,33 @@ export async function executeCommand(command, ctx = defaultContext()) {
652
635
  };
653
636
  }
654
637
  if (command.type === 'logs.fetch') {
655
- const project = payload.project;
656
- const service = payload.service;
657
- const tail = String(payload.tail || 200);
658
- const dir = path.join(ctx.stateDir, 'projects', project);
659
- const result = await ctx.docker(['compose', '-p', project, 'logs', '--no-color', '--tail', tail, service], { cwd: dir });
638
+ const { project, service } = payload;
639
+ const id = await resolveLiveContainer(ctx, { project, service });
640
+ const logs = await ctx.docker.logs(id, { follow: false, tail: Number(payload.tail || 200) });
641
+ const out = [];
642
+ const err = [];
643
+ logs.stdout.on('data', (d) => out.push(d));
644
+ logs.stderr.on('data', (d) => err.push(d));
645
+ await new Promise((resolve, reject) => {
646
+ logs.on('error', reject);
647
+ logs.on('close', () => resolve());
648
+ });
660
649
  return {
661
650
  commandId: command.id,
662
651
  state: 'succeeded',
663
- result: { stdout: result.out, stderr: result.err },
652
+ result: {
653
+ stdout: Buffer.concat(out).toString('utf8'),
654
+ stderr: Buffer.concat(err).toString('utf8'),
655
+ },
664
656
  };
665
657
  }
666
658
  if (command.type === 'volume.delete') {
667
- const project = payload.project;
668
- const volume = payload.volume;
669
- const composeVolume = `${project}_${volume}`;
670
- try {
671
- await ctx.docker(['volume', 'rm', composeVolume]);
672
- }
673
- catch (e) {
674
- if (!errMessage(e).includes('No such volume'))
659
+ const { project, volume } = payload;
660
+ // Volumes are never removed by reconciliation; this explicit command is the only way one goes away.
661
+ await ctx.docker.removeVolume(volumeName(project, volume)).catch((e) => {
662
+ if (!isNotFound(e))
675
663
  throw e;
676
- }
664
+ });
677
665
  return {
678
666
  commandId: command.id,
679
667
  state: 'succeeded',
@@ -731,23 +719,26 @@ export async function ensureArtifact(ctx, artifact) {
731
719
  if (!refs.includes(artifact.imageRef))
732
720
  await fs.writeFile(await managedImageFile(ctx), JSON.stringify([...refs, artifact.imageRef]), { mode: 0o600 });
733
721
  };
734
- const inspect = async () => JSON.parse((await ctx.docker(['image', 'inspect', artifact.imageRef])).out)[0];
722
+ const inspect = () => ctx.docker.inspectImage(artifact.imageRef);
735
723
  try {
736
724
  const image = await inspect();
737
- if ((image?.Id === artifact.imageId ||
738
- (identities[artifact.imageRef]?.daemonId === image?.Id &&
739
- identities[artifact.imageRef]?.artifactId === artifact.imageId &&
740
- identities[artifact.imageRef]?.sha256 === artifact.sha256)) &&
725
+ if (image &&
726
+ (image?.Id === artifact.imageId ||
727
+ (identities[artifact.imageRef]?.daemonId === image?.Id &&
728
+ identities[artifact.imageRef]?.artifactId === artifact.imageId &&
729
+ identities[artifact.imageRef]?.sha256 === artifact.sha256)) &&
741
730
  `${image.Os}/${image.Architecture}` === artifact.platform) {
742
731
  await remember(image.Id);
743
732
  return;
744
733
  }
745
734
  }
746
735
  catch { }
747
- const host = JSON.parse((await ctx.docker(['info', '--format', '{{json .}}'])).out);
748
- const arch = { x86_64: 'amd64', aarch64: 'arm64' }[host.Architecture] ||
749
- host.Architecture;
750
- if (host.OSType && host.Architecture && `${host.OSType}/${arch}` !== artifact.platform)
736
+ const host = await ctx.docker.info();
737
+ const arch = host.Architecture
738
+ ? { x86_64: 'amd64', aarch64: 'arm64' }[host.Architecture] ||
739
+ host.Architecture
740
+ : undefined;
741
+ if (host.OSType && arch && `${host.OSType}/${arch}` !== artifact.platform)
751
742
  throw new Error(`image platform ${artifact.platform} does not match node ${host.OSType}/${arch}; build for the target platform`);
752
743
  const downloadDir = await fs.mkdtemp(path.join(ctx.stateDir, 'image-download-'));
753
744
  const file = path.join(downloadDir, 'image.tar.gz');
@@ -759,7 +750,7 @@ export async function ensureArtifact(ctx, artifact) {
759
750
  const archive = await validateImageArchive(file, artifact.imageRef);
760
751
  if (archive.imageId !== artifact.imageId || archive.platform !== artifact.platform)
761
752
  throw new Error('downloaded archive image metadata differs from desired artifact');
762
- await ctx.docker(['load', '-i', file]);
753
+ await ctx.docker.loadImage(createReadStream(file));
763
754
  const image = await inspect();
764
755
  if (!image?.Id || `${image.Os}/${image.Architecture}` !== artifact.platform)
765
756
  throw new Error('loaded image identity or platform does not match desired artifact');
@@ -780,12 +771,11 @@ export async function pruneManagedImages(ctx, retained) {
780
771
  continue;
781
772
  }
782
773
  try {
783
- const used = (await ctx.docker(['ps', '-aq', '--filter', `ancestor=${ref}`])).out.trim();
784
- if (used) {
774
+ if ((await ctx.docker.listContainers({ ancestor: ref })).length) {
785
775
  remaining.push(ref);
786
776
  continue;
787
777
  }
788
- await ctx.docker(['image', 'rm', ref]);
778
+ await ctx.docker.removeImage(ref);
789
779
  }
790
780
  catch {
791
781
  remaining.push(ref);
@@ -815,11 +805,12 @@ const publicPortWarnings = new Map();
815
805
  export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}, ports = { address: null, loopback: {} }) {
816
806
  const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
817
807
  await ensureDir(dir);
818
- const plannedCompose = composeForNode(desiredState, ctx.nodeName, {});
819
- const serviceNames = Object.keys(plannedCompose.services || {});
808
+ const serviceNames = Object.keys(containerSpecs(desiredState, ctx.nodeName).specs);
820
809
  if (!serviceNames.length) {
821
- if (ctx.apply)
822
- await retireAll(ctx, dir, desiredState).catch(() => { });
810
+ if (ctx.apply) {
811
+ await sweepOrphans(ctx.docker, desiredState.project, new Set()).catch(() => { });
812
+ await clearRolloutState(dir).catch(() => { });
813
+ }
823
814
  return {
824
815
  project: desiredState.project,
825
816
  observedVersion: desiredState.version,
@@ -847,23 +838,17 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
847
838
  const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
848
839
  const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
849
840
  const db = await agentDb(ctx.stateDir);
850
- const previousRow = await db.get('SELECT version, revision, secret_versions_json FROM applied_state WHERE project = ?', desiredState.project);
851
- const previous = previousRow
852
- ? {
853
- version: previousRow.version,
854
- revision: previousRow.revision,
855
- secretVersions: JSON.parse(previousRow.secret_versions_json),
856
- }
857
- : {};
841
+ const previousRow = db.get('SELECT version, revision FROM applied_state WHERE project = ?', desiredState.project);
858
842
  const rolling = serviceNames.filter((service) => isRolling(desiredState.compose.services[service]));
859
843
  const rollingSet = new Set(rolling);
860
844
  const mainServices = serviceNames.filter((service) => !rollingSet.has(service));
861
- const secretChangedServices = mainServices.filter((service) => JSON.stringify(previous.secretVersions?.[service] || {}) !==
862
- JSON.stringify(currentSecretVersions[service] || {}));
863
- const sameDesiredVersion = desiredState.revision
864
- ? previous.revision === desiredState.revision
865
- : previous.version === desiredState.version;
866
- // Rolling services run in their own revision projects; the main project holds only the recreated ones.
845
+ // Published images are pulled only for a new desired revision. Pulling on every poll would let a tag that moved
846
+ // upstream (`mariadb:11`) recreate the container without a deploy.
847
+ const sameDesiredVersion = previousRow
848
+ ? desiredState.revision
849
+ ? previousRow.revision === desiredState.revision
850
+ : previousRow.version === desiredState.version
851
+ : false;
867
852
  const loopback = new Set(ports.loopback[desiredState.project] || []);
868
853
  // Resolve only when some port actually binds to the node address; a hostname nobody needs must not fail the apply.
869
854
  const needsAddress = addressBoundServices(desiredState.compose, serviceNames, loopback).length > 0;
@@ -871,15 +856,13 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
871
856
  address: needsAddress ? await resolveBindAddress(ports.address) : '127.0.0.1',
872
857
  loopback,
873
858
  };
874
- const nodeCompose = composeForNode(desiredState, ctx.nodeName, {
859
+ const plan = containerSpecs(desiredState, ctx.nodeName, {
875
860
  secrets: sec.secrets || {},
861
+ secretVersions: sec.versions || {},
876
862
  endpoints,
877
- exclude: rollingSet,
878
863
  bind,
879
864
  });
880
865
  await warnPublicPorts(ctx, desiredState, serviceNames, bind);
881
- await fs.writeFile(path.join(dir, 'docker-compose.yml'), dumpYaml(nodeCompose));
882
- await writeEnv(path.join(dir, '.env'), sec.secrets || {});
883
866
  const failedStatus = (error) => ({
884
867
  project: desiredState.project,
885
868
  observedVersion: desiredState.version,
@@ -889,99 +872,80 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
889
872
  serviceStates,
890
873
  error,
891
874
  });
892
- // Lifecycle events say what changed, not that the agent is still polling. They are sent when the desired revision,
893
- // this node's rendered configuration or the secrets change, when an apply fails (once) and when it recovers (once).
875
+ // Lifecycle events say what changed, not that the agent is still polling. They are sent when the desired revision
876
+ // or any container definition (which includes the secret versions it uses) changes, when an apply fails (once)
877
+ // and when it recovers (once).
894
878
  const eventFingerprint = createHash('sha256')
895
879
  .update(JSON.stringify([
896
880
  desiredState.revision || desiredState.version,
897
- dumpYaml(nodeCompose),
898
- mainServices.map((service) => currentSecretVersions[service] || {}),
881
+ mainServices.map((service) => plan.specs[service].hash),
899
882
  ]))
900
883
  .digest('hex')
901
884
  .slice(0, 16);
902
885
  const knownEvents = mainServices.length
903
- ? await db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project)
886
+ ? db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project)
904
887
  : undefined;
905
888
  const announce = knownEvents?.fingerprint !== eventFingerprint;
906
889
  const wasFailed = !announce && knownEvents?.phase === 'failed';
907
890
  const markEvents = (phase) => db.run('INSERT INTO event_state(project, fingerprint, phase) VALUES(?,?,?) ON CONFLICT(project) DO UPDATE SET fingerprint=excluded.fingerprint, phase=excluded.phase', desiredState.project, eventFingerprint, phase);
891
+ const deps = {
892
+ waitForHealth: (container, service, dependency) => waitForHealth(ctx, desiredState.project, container, service, desiredState.services[service]?.health ?? (dependency ? { type: 'docker' } : null)),
893
+ ensureArtifact: async (service) => {
894
+ const artifact = desiredState.services[service].artifact;
895
+ if (artifact)
896
+ await ensureArtifact(ctx, artifact);
897
+ },
898
+ pullImage: (service) => ctx.docker.pullImage(plan.specs[service].body.Image, sec.registryAuth?.[imageLocation(plan.specs[service].body.Image).host]),
899
+ emit,
900
+ };
908
901
  let failing;
909
902
  if (ctx.apply) {
910
903
  try {
911
- for (const service of mainServices) {
912
- serviceStates[service].rolloutState = 'pulling';
913
- if (announce)
914
- await emit(service, 'pulling');
915
- }
916
- for (const service of mainServices) {
917
- const artifact = desiredState.services[service].artifact;
918
- if (artifact)
919
- await ensureArtifact(ctx, artifact);
920
- }
921
- // Registry images are pulled only for a new desired revision. Pulling on every poll would let a tag that moved
922
- // upstream (`mariadb:11`) recreate the container without a deploy; `up -d` still pulls images that are missing.
923
- const pullServices = sameDesiredVersion
924
- ? []
925
- : mainServices.filter((service) => !desiredState.services[service].artifact);
926
- if (pullServices.length)
927
- await ctx.docker([
928
- 'compose',
929
- '-p',
930
- desiredState.project,
931
- 'pull',
932
- ...(pullServices.length === mainServices.length ? [] : pullServices),
933
- ], { cwd: dir });
934
- for (const service of mainServices) {
935
- serviceStates[service].rolloutState = 'starting';
936
- if (announce)
937
- await emit(service, 'starting');
938
- }
939
- if (mainServices.length) {
940
- // Services that just became rolling still run here until their replacement takes over; keep them.
941
- const keepLegacy = rolling.length > 0 && (await hasLegacyContainers(ctx, desiredState.project, rolling));
942
- await ctx.docker([
943
- 'compose',
944
- '-p',
945
- desiredState.project,
946
- 'up',
947
- '-d',
948
- ...(keepLegacy ? [] : ['--remove-orphans']),
949
- ], { cwd: dir });
950
- }
951
- if (sameDesiredVersion && secretChangedServices.length) {
952
- for (const service of secretChangedServices)
953
- await emit(service, 'secret_recreate', 'secret version changed; recreating service');
954
- await ctx.docker([
955
- 'compose',
956
- '-p',
957
- desiredState.project,
958
- 'up',
959
- '-d',
960
- '--no-deps',
961
- '--force-recreate',
962
- ...secretChangedServices,
963
- ], { cwd: dir });
964
- }
965
- for (const service of mainServices) {
966
- serviceStates[service].rolloutState = 'health_checking';
967
- if (announce)
968
- await emit(service, 'health_checking');
969
- failing = service;
970
- await waitForHealth(ctx, desiredState.project, service, desiredState.services[service]?.health, dir);
971
- failing = undefined;
972
- serviceStates[service].rolloutState = 'serving';
973
- serviceStates[service].healthyAt = now();
974
- if (announce || wasFailed)
975
- await emit(service, 'serving', wasFailed ? 'serving again after a failure' : 'serving');
976
- }
904
+ await ensureProjectResources(ctx.docker, desiredState.project, plan);
905
+ await sweepOrphans(ctx.docker, desiredState.project, new Set(serviceNames));
906
+ if (mainServices.length)
907
+ await reconcileRecreate(ctx.docker, {
908
+ desired: desiredState,
909
+ dir,
910
+ plan,
911
+ nodeName: ctx.nodeName,
912
+ deps,
913
+ services: mainServices,
914
+ pull: !sameDesiredVersion,
915
+ }, {
916
+ pulling: async (service) => {
917
+ serviceStates[service].rolloutState = 'pulling';
918
+ if (announce)
919
+ await emit(service, 'pulling');
920
+ },
921
+ starting: async (service) => {
922
+ serviceStates[service].rolloutState = 'starting';
923
+ if (announce)
924
+ await emit(service, 'starting');
925
+ },
926
+ checking: async (service) => {
927
+ serviceStates[service].rolloutState = 'health_checking';
928
+ if (announce)
929
+ await emit(service, 'health_checking');
930
+ },
931
+ serving: async (service) => {
932
+ serviceStates[service].rolloutState = 'serving';
933
+ serviceStates[service].healthyAt = now();
934
+ if (announce || wasFailed)
935
+ await emit(service, 'serving', wasFailed ? 'serving again after a failure' : 'serving');
936
+ },
937
+ failing: (service) => {
938
+ failing = service;
939
+ },
940
+ });
977
941
  if (mainServices.length && (announce || wasFailed))
978
- await markEvents('serving');
942
+ markEvents('serving');
979
943
  }
980
944
  catch (e) {
981
945
  if (mainServices.length && !wasFailed) {
982
946
  // Announce a failure once; retries of the same failure stay quiet until it recovers or the inputs change.
983
947
  await emit(failing ?? mainServices.join(', '), 'failed', errMessage(e), 'error');
984
- await markEvents('failed');
948
+ markEvents('failed');
985
949
  }
986
950
  // Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
987
951
  if (!rolling.length)
@@ -990,25 +954,14 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
990
954
  return failedStatus(errMessage(e));
991
955
  }
992
956
  if (rolling.length) {
993
- const rolled = await reconcileRolling(ctx, {
957
+ const rolled = await reconcileRolling(ctx.docker, {
994
958
  desired: desiredState,
995
959
  dir,
996
960
  services: rolling,
997
- secretVersions: currentSecretVersions,
998
- full: composeForNode(desiredState, ctx.nodeName, {
999
- secrets: sec.secrets || {},
1000
- endpoints,
1001
- bind,
1002
- }),
1003
- deps: {
1004
- waitForHealth: (project, service, composeFile) => waitForHealth(ctx, project, service, desiredState.services[service]?.health, dir, composeFile),
1005
- ensureArtifact: async (service) => {
1006
- const artifact = desiredState.services[service].artifact;
1007
- if (artifact)
1008
- await ensureArtifact(ctx, artifact);
1009
- },
1010
- emit,
1011
- },
961
+ plan,
962
+ nodeName: ctx.nodeName,
963
+ rolloutPorts: ctx.rolloutPorts,
964
+ deps,
1012
965
  });
1013
966
  Object.assign(serviceStates, rolled.serviceStates);
1014
967
  const errors = Object.entries(rolled.errors);
@@ -1016,13 +969,13 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
1016
969
  return failedStatus(errors.map(([service, error]) => `${service}: ${error}`).join('; '));
1017
970
  }
1018
971
  else
1019
- await retireAll(ctx, dir, desiredState);
972
+ await clearRolloutState(dir);
1020
973
  }
1021
974
  else {
1022
975
  for (const service of serviceNames)
1023
976
  serviceStates[service].rolloutState = 'serving';
1024
977
  }
1025
- await db.run(`INSERT INTO applied_state(project, version, revision, applied_at, service_states_json, secret_versions_json)
978
+ db.run(`INSERT INTO applied_state(project, version, revision, applied_at, service_states_json, secret_versions_json)
1026
979
  VALUES(?,?,?,?,?,?)
1027
980
  ON CONFLICT(project) DO UPDATE SET version=excluded.version, revision=excluded.revision, applied_at=excluded.applied_at,
1028
981
  service_states_json=excluded.service_states_json, secret_versions_json=excluded.secret_versions_json`, desiredState.project, desiredState.version, desiredState.revision || null, now(), JSON.stringify(serviceStates), JSON.stringify(currentSecretVersions));
@@ -1170,19 +1123,6 @@ async function readJoinKey() {
1170
1123
  }
1171
1124
  return null;
1172
1125
  }
1173
- function dockerLogin(registry, user, password) {
1174
- return new Promise((resolve, reject) => {
1175
- const p = spawn('docker', ['login', registry, '-u', user, '--password-stdin'], {
1176
- stdio: ['pipe', 'ignore', 'pipe'],
1177
- });
1178
- let err = '';
1179
- p.stderr.on('data', (d) => (err += d));
1180
- p.on('error', reject);
1181
- p.on('close', (code) => code === 0 ? resolve() : reject(new Error(err.trim() || `exit ${code}`)));
1182
- p.stdin.write(password);
1183
- p.stdin.end();
1184
- });
1185
- }
1186
1126
  async function enroll(joinKey) {
1187
1127
  let decoded;
1188
1128
  try {
@@ -1232,8 +1172,6 @@ async function enroll(joinKey) {
1232
1172
  agentToken: out.agentToken,
1233
1173
  nodeName: out.node?.name || NODE_NAME,
1234
1174
  controlUrl,
1235
- registry: out.registry || null,
1236
- registryToken: out.registryToken || null,
1237
1175
  };
1238
1176
  await saveCreds(creds);
1239
1177
  if (wgPublicKey)
@@ -1257,14 +1195,6 @@ export async function loadOrEnroll() {
1257
1195
  NODE_NAME = creds.nodeName || NODE_NAME;
1258
1196
  if (!CONTROL_URL_FROM_ENV && creds.controlUrl)
1259
1197
  CONTROL_URL = creds.controlUrl;
1260
- if (APPLY && creds.registry && creds.registryToken) {
1261
- try {
1262
- await dockerLogin(creds.registry, NODE_NAME, creds.registryToken);
1263
- }
1264
- catch (e) {
1265
- console.error(`registry login failed (pulls from ${creds.registry} may fail): ${errMessage(e)}`);
1266
- }
1267
- }
1268
1198
  return creds;
1269
1199
  }
1270
1200
  export async function main() {