omp-conductor 0.3.25 → 0.4.1

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/src/upgrade.ts CHANGED
@@ -1,10 +1,18 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
3
3
  import { setPaused, statusSnapshot } from "./daemon.ts";
4
- import { fleetLayers, type DispatchLayer, type FleetLayers } from "./fleet.ts";
4
+ import { fleetLayers, telegramStateDir, type DispatchLayer, type FleetLayers } from "./fleet.ts";
5
5
  import { livingDaemon, restartDaemon } from "./lifecycle.ts";
6
- import { findProject, loadConfig } from "./config.ts";
6
+ import { findProject, loadConfig, resolveCaps } from "./config.ts";
7
7
  import { renderBriefForProject } from "./setup.ts";
8
+ import {
9
+ STAGED_SERVICE_NAME,
10
+ parseEffectiveUnit,
11
+ planHostRuntime,
12
+ unitDriftReason,
13
+ writeHostRuntime,
14
+ type EffectiveUnit,
15
+ } from "./setup-host.ts";
8
16
 
9
17
  const PACKAGE = "omp-conductor";
10
18
  const HERDR_PLUGIN = "herdr-conductor";
@@ -33,6 +41,15 @@ export interface UpgradeResult {
33
41
  dispatch: DispatchLayer;
34
42
  }
35
43
 
44
+ /** What the unit check reads, and how it repairs what it can. */
45
+ export interface UnitFiles {
46
+ rendered: string;
47
+ /** What systemd loaded, not what is on disk. Undefined means no systemd here. */
48
+ effective: EffectiveUnit | undefined;
49
+ /** Writes the corrected unit to the state dir; returns the commands root must run. */
50
+ stage?(): readonly string[];
51
+ }
52
+
36
53
  export interface UpgradeDeps {
37
54
  run(command: string, args: readonly string[]): Promise<UpgradeCommandResult>;
38
55
  snapshot(project?: string): { liveWorkers: number };
@@ -44,6 +61,12 @@ export interface UpgradeDeps {
44
61
  sleep(ms: number): Promise<void>;
45
62
  env: NodeJS.ProcessEnv;
46
63
  log(message: string): void;
64
+ /**
65
+ * What this version would render as the unit, and what systemd actually
66
+ * booted from. Injected so the drift check is testable without a systemd host
67
+ * — and defaulted, so no production caller has to know it exists.
68
+ */
69
+ unitFiles?(): Promise<UnitFiles | undefined> | UnitFiles | undefined;
47
70
  }
48
71
 
49
72
  async function runCommand(command: string, args: readonly string[]): Promise<UpgradeCommandResult> {
@@ -381,6 +404,49 @@ async function rollbackUpgrade(
381
404
  if (failures.length > 0) throw new Error(failures.join("; "));
382
405
  }
383
406
 
407
+ /**
408
+ * The rendered-vs-installed pair for {@link unitDriftReason}.
409
+ *
410
+ * Best-effort by design: a host with no config, no project, or no systemd is
411
+ * not a host with a broken unit, and an upgrade must not fail because it could
412
+ * not answer a question that does not apply there.
413
+ */
414
+ async function defaultUnitFiles(deps: UpgradeDeps): Promise<UnitFiles | undefined> {
415
+ try {
416
+ const cfg = loadConfig();
417
+ const project = cfg.projects[0];
418
+ if (project === undefined) return undefined;
419
+ const plan = planHostRuntime(project, resolveCaps(project, cfg.defaults), telegramStateDir());
420
+ const shown = await deps.run("systemctl", [
421
+ "show",
422
+ STAGED_SERVICE_NAME,
423
+ "-p",
424
+ "AmbientCapabilities",
425
+ "-p",
426
+ "CapabilityBoundingSet",
427
+ "-p",
428
+ "Environment",
429
+ "-p",
430
+ "NeedDaemonReload",
431
+ ]);
432
+ return {
433
+ rendered: plan.service.content,
434
+ effective: shown.code === 0 ? parseEffectiveUnit(shown.stdout) : undefined,
435
+ // Staging needs no privilege, so the repair is reduced to the two lines
436
+ // that genuinely do. Installing the unit is root's, and this command runs
437
+ // as the fleet user by design — so it goes as far as it can and then says
438
+ // exactly what is left, rather than sending the operator back through a
439
+ // wizard to regenerate a file it could write itself.
440
+ stage: () => {
441
+ writeHostRuntime(plan);
442
+ return plan.installCommands;
443
+ },
444
+ };
445
+ } catch {
446
+ return undefined;
447
+ }
448
+ }
449
+
384
450
  export async function upgradeConductor(
385
451
  options: UpgradeOptions = {},
386
452
  overrides: Partial<UpgradeDeps> = {},
@@ -407,6 +473,22 @@ export async function upgradeConductor(
407
473
  const surfaces = await inspectSurfaces(deps);
408
474
  const brief = deps.brief(project);
409
475
  const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
476
+ // Computed BEFORE the no-op decision, not inside verification, and that
477
+ // placement is the whole point. The upgrade that installs a version is run by
478
+ // the *previous* CLI, so a check living only in the new code never executes
479
+ // for the release that introduces it — and re-running afterwards would take
480
+ // the already-current early return and skip it forever. A fleet whose unit is
481
+ // stale is not current, whatever its package identities say.
482
+ const files = await (deps.unitFiles === undefined ? defaultUnitFiles(deps) : deps.unitFiles());
483
+ const drift = files === undefined ? undefined : unitDriftReason(files.rendered, files.effective);
484
+ if (drift !== undefined) {
485
+ const commands = files?.stage?.() ?? [];
486
+ throw new Error(
487
+ commands.length === 0
488
+ ? drift
489
+ : `${drift}\n\nThe corrected unit has been staged. Run:\n${commands.map((c) => ` ${c}`).join("\n")}`,
490
+ );
491
+ }
410
492
  if (!installNeeded && brief.current) {
411
493
  return {
412
494
  previousVersion: surfaces.cliVersion,
@@ -489,6 +571,17 @@ export async function upgradeConductor(
489
571
  const active = await mustRun(deps, "systemctl", ["is-active", HERDR_UNIT]);
490
572
  if (active.stdout.trim() !== "active") throw new Error(`${HERDR_UNIT} is not active after restart`);
491
573
  }
574
+ // The unit is the one surface `upgrade` never looked at, and since 0.4.0 it
575
+ // is the difference between a working per-run fleet and one that refuses
576
+ // every issue. Fatal rather than a warning: the whole point of this command
577
+ // is that a fleet is either upgraded or explicitly left paused, and a
578
+ // "verified" fleet that cannot dispatch is the worse outcome.
579
+ // Re-read rather than trust the earlier pass: the daemon has restarted
580
+ // since, and an operator who installed a unit mid-upgrade should not get a
581
+ // green verification for a file nobody checked.
582
+ const after = await (deps.unitFiles === undefined ? defaultUnitFiles(deps) : deps.unitFiles());
583
+ const stillDrifted = after === undefined ? undefined : unitDriftReason(after.rendered, after.effective);
584
+ if (stillDrifted !== undefined) throw new Error(stillDrifted);
492
585
  await waitForRecovery(deps, initial, project);
493
586
  deps.log("verify 2/2: recovered fleet remains stable");
494
587
  await deps.sleep(1_000);