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