omp-conductor 0.18.2 → 0.19.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/README.md +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/doctor.ts +379 -22
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +511 -101
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +325 -1159
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +326 -47
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
package/src/upgrade.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
|
|
3
4
|
import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
|
|
4
5
|
import { pauseInstance, setPaused, statusSnapshot, type AdmissionAckRecord } from "./daemon.ts";
|
|
5
6
|
import {
|
|
6
|
-
DEFAULT_HERDR_SESSION,
|
|
7
7
|
fleetLayers,
|
|
8
|
-
LEGACY_HERDR_SESSION_HINT,
|
|
9
8
|
resolveHerdrSession,
|
|
10
9
|
telegramStateDir,
|
|
11
10
|
} from "./fleet.ts";
|
|
@@ -23,6 +22,7 @@ import {
|
|
|
23
22
|
appendJournal,
|
|
24
23
|
readUpgradeJournal,
|
|
25
24
|
upgradeJournalPath,
|
|
25
|
+
type UpgradeCheck,
|
|
26
26
|
type UpgradeJournalEntry,
|
|
27
27
|
} from "./upgrade-journal.ts";
|
|
28
28
|
import {
|
|
@@ -43,6 +43,13 @@ import {
|
|
|
43
43
|
const PACKAGE = "omp-conductor";
|
|
44
44
|
const HERDR_PLUGIN = "herdr-conductor";
|
|
45
45
|
const HERDR_SOURCE = "TerrifiedBug/conductor/herdr";
|
|
46
|
+
|
|
47
|
+
/** The package root inside the repo, for a bootstrap install by git ref. */
|
|
48
|
+
const PACKAGE_SOURCE = "TerrifiedBug/conductor/omp";
|
|
49
|
+
|
|
50
|
+
/** A 40-hex commit: the only identity shape a bootstrap accepts. A branch or a
|
|
51
|
+
* tag is a moving target, and "the exact build I verified" is the whole point. */
|
|
52
|
+
const COMMIT_SHA = /^[0-9a-f]{40}$/i;
|
|
46
53
|
const HERDR_UNIT = "herdr-fleet.service";
|
|
47
54
|
const DRAIN_POLL_MS = 5_000;
|
|
48
55
|
const RECOVERY_POLL_MS = 2_000;
|
|
@@ -50,6 +57,22 @@ const RECOVERY_ATTEMPTS = 30;
|
|
|
50
57
|
|
|
51
58
|
export interface UpgradeOptions {
|
|
52
59
|
version?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Bootstrap from an exact source/build identity instead of a published semver
|
|
62
|
+
* (#908) — the path that exists because a reliability release cannot be cut
|
|
63
|
+
* when the *installed* conductor is the thing that is broken: publishing
|
|
64
|
+
* needs working workers, and the workers are what is broken.
|
|
65
|
+
*
|
|
66
|
+
* `sha` is the whole identity (a 40-hex commit, never a range), and `source`
|
|
67
|
+
* is a checkout of exactly that commit — its only job is to be the tree the
|
|
68
|
+
* package's own checks run against before anything is installed. The install
|
|
69
|
+
* itself is by git ref, so the identity is globally attestable rather than
|
|
70
|
+
* dependent on one machine's directory.
|
|
71
|
+
*
|
|
72
|
+
* Mutually exclusive with {@link version}: two identity kinds in one call is
|
|
73
|
+
* an ambiguity, not a merge.
|
|
74
|
+
*/
|
|
75
|
+
bootstrap?: { sha: string; source: string };
|
|
53
76
|
project?: string;
|
|
54
77
|
/**
|
|
55
78
|
* Run as the detached fleet installer (#486): journal every surface the
|
|
@@ -71,6 +94,15 @@ export interface UpgradeResult {
|
|
|
71
94
|
|
|
72
95
|
export interface UpgradeDeps {
|
|
73
96
|
run(command: string, args: readonly string[]): Promise<UpgradeCommandResult>;
|
|
97
|
+
/**
|
|
98
|
+
* A command run in a named working directory (#908): the bootstrap's checks
|
|
99
|
+
* run inside the source tree, and `run` is deliberately cwd-free everywhere
|
|
100
|
+
* else so no other step can depend on where it was invoked from.
|
|
101
|
+
*/
|
|
102
|
+
runIn(cwd: string, command: string, args: readonly string[]): Promise<UpgradeCommandResult>;
|
|
103
|
+
/** Read one file as text. Injected so a bootstrap's manifest read is
|
|
104
|
+
* testable without a checkout on disk. */
|
|
105
|
+
readFile(path: string): string;
|
|
74
106
|
snapshot(project?: string): { liveWorkers: number };
|
|
75
107
|
layers(project?: string): FleetLayers;
|
|
76
108
|
brief(project?: string): { kind: BriefLayout["kind"]; current: boolean };
|
|
@@ -99,6 +131,14 @@ export interface UpgradeDeps {
|
|
|
99
131
|
setPaused(value: boolean, project?: string): void;
|
|
100
132
|
restartDaemon(): Promise<void>;
|
|
101
133
|
sleep(ms: number): Promise<void>;
|
|
134
|
+
/**
|
|
135
|
+
* The environment every surface of this transaction resolves from — the
|
|
136
|
+
* herdr session included, through {@link resolveHerdrSession}. One source,
|
|
137
|
+
* because the `herdr plugin list` read and the pane probe MUST agree on
|
|
138
|
+
* which session this fleet is: when they did not, `upgrade` installed
|
|
139
|
+
* against one session and failed verification against another, then rolled
|
|
140
|
+
* a good release back (#976).
|
|
141
|
+
*/
|
|
102
142
|
env: NodeJS.ProcessEnv;
|
|
103
143
|
log(message: string): void;
|
|
104
144
|
/**
|
|
@@ -108,11 +148,6 @@ export interface UpgradeDeps {
|
|
|
108
148
|
* session process actually loaded.
|
|
109
149
|
*/
|
|
110
150
|
health(port: number): Promise<{ ok: boolean; body?: string }>;
|
|
111
|
-
/**
|
|
112
|
-
* The herdr session the fleet pane lives in, for the external-orchestrator
|
|
113
|
-
* pane probe (#832).
|
|
114
|
-
*/
|
|
115
|
-
herdrSession(): string;
|
|
116
151
|
/**
|
|
117
152
|
* Every omp process the fleet pane currently claims by herdr, resolved to
|
|
118
153
|
* its start time — the evidence an external (pane-owned) orchestrator
|
|
@@ -145,6 +180,8 @@ export interface UpgradeDeps {
|
|
|
145
180
|
* post-restart verifier and the rollback unit run processes the same way. */
|
|
146
181
|
export const DEFAULT_DEPS: UpgradeDeps = {
|
|
147
182
|
run: runCommand,
|
|
183
|
+
runIn: (cwd, command, args) => runCommand(command, args, cwd),
|
|
184
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
148
185
|
snapshot: statusSnapshot,
|
|
149
186
|
layers: fleetLayers,
|
|
150
187
|
brief: (projectName) => {
|
|
@@ -172,7 +209,6 @@ export const DEFAULT_DEPS: UpgradeDeps = {
|
|
|
172
209
|
await restartDaemon({});
|
|
173
210
|
},
|
|
174
211
|
health: async (port) => healthCheck(port),
|
|
175
|
-
herdrSession: () => resolveHerdrSession(process.env),
|
|
176
212
|
probePaneOmp: (session) => herdrPaneOmpStarts(runCommand, session, processStartTimeMs),
|
|
177
213
|
// The bare host-global plan — the same render the advisory's `setup host`
|
|
178
214
|
// command would install: no per-project tail, the recovery unit encoding no
|
|
@@ -191,7 +227,7 @@ function commandLine(command: string, args: readonly string[]): string {
|
|
|
191
227
|
}
|
|
192
228
|
|
|
193
229
|
async function mustRun(
|
|
194
|
-
deps:
|
|
230
|
+
deps: SurfaceProbeDeps,
|
|
195
231
|
command: string,
|
|
196
232
|
args: readonly string[],
|
|
197
233
|
): Promise<UpgradeCommandResult> {
|
|
@@ -222,6 +258,108 @@ function parseRegistry(raw: string): { version: string; gitHead: string } {
|
|
|
222
258
|
return { version, gitHead };
|
|
223
259
|
}
|
|
224
260
|
|
|
261
|
+
/**
|
|
262
|
+
* One release identity plus how to install it (#908).
|
|
263
|
+
*
|
|
264
|
+
* Two kinds, one shape downstream: everything after this — the currentness
|
|
265
|
+
* comparison, the journal, the install, the attestation and the rollback —
|
|
266
|
+
* reads a `{ version, gitHead }` pair and an install spec, so a bootstrap is a
|
|
267
|
+
* second accepted identity rather than a parallel installer.
|
|
268
|
+
*/
|
|
269
|
+
interface ReleaseIdentityPlan {
|
|
270
|
+
version: string;
|
|
271
|
+
gitHead: string;
|
|
272
|
+
/** What `bun add -g` / `omp plugin install` are given for this identity. */
|
|
273
|
+
packageSpec: string;
|
|
274
|
+
/** Named checks already run to earn this identity, journalled as evidence. */
|
|
275
|
+
checks: UpgradeCheck[];
|
|
276
|
+
/** True for a source/build identity: npm does not have this version. */
|
|
277
|
+
bootstrap: boolean;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The version a source tree declares — the identity's other half, read from
|
|
281
|
+
* the tree itself because npm has never seen it. */
|
|
282
|
+
function sourceVersion(deps: UpgradeDeps, source: string): string {
|
|
283
|
+
const manifest = join(source, "omp", "package.json");
|
|
284
|
+
let raw: string;
|
|
285
|
+
try {
|
|
286
|
+
raw = deps.readFile(manifest);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`bootstrap refused: cannot read ${manifest} (${err instanceof Error ? err.message : String(err)}) — ` +
|
|
290
|
+
"--source must be a checkout of this repository",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
let parsed: unknown;
|
|
294
|
+
try {
|
|
295
|
+
parsed = JSON.parse(raw);
|
|
296
|
+
} catch {
|
|
297
|
+
throw new Error(`bootstrap refused: ${manifest} is not valid JSON`);
|
|
298
|
+
}
|
|
299
|
+
const version = parsed !== null && typeof parsed === "object" ? Reflect.get(parsed, "version") : undefined;
|
|
300
|
+
if (typeof version !== "string" || version.length === 0) {
|
|
301
|
+
throw new Error(`bootstrap refused: ${manifest} declares no version`);
|
|
302
|
+
}
|
|
303
|
+
return version;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve which release this run installs, and prove a bootstrap identity
|
|
308
|
+
* before it can touch anything (#908).
|
|
309
|
+
*
|
|
310
|
+
* The three refusals are the deliverable, not the install: a sha that is not a
|
|
311
|
+
* sha, a source tree that is not at that sha, and a source tree whose own
|
|
312
|
+
* checks fail. Each names the identity and what failed, and each happens before
|
|
313
|
+
* the first surface is replaced — an unverified tree must never become a live
|
|
314
|
+
* install, which is the difference between this and the `bun add github:...`
|
|
315
|
+
* POLICY forbids.
|
|
316
|
+
*/
|
|
317
|
+
async function resolveIdentity(deps: UpgradeDeps, options: UpgradeOptions): Promise<ReleaseIdentityPlan> {
|
|
318
|
+
const bootstrap = options.bootstrap;
|
|
319
|
+
if (bootstrap === undefined) {
|
|
320
|
+
const requested = options.version === undefined ? `${PACKAGE}@latest` : `${PACKAGE}@${options.version}`;
|
|
321
|
+
const release = parseRegistry(
|
|
322
|
+
(await mustRun(deps, "npm", ["view", requested, "version", "gitHead", "--json"])).stdout,
|
|
323
|
+
);
|
|
324
|
+
return { ...release, packageSpec: `${PACKAGE}@${release.version}`, checks: [], bootstrap: false };
|
|
325
|
+
}
|
|
326
|
+
if (options.version !== undefined) {
|
|
327
|
+
throw new Error("bootstrap refused: pass either --to VERSION or --bootstrap SHA, never both");
|
|
328
|
+
}
|
|
329
|
+
const sha = bootstrap.sha.toLowerCase();
|
|
330
|
+
if (!COMMIT_SHA.test(sha)) {
|
|
331
|
+
throw new Error(`bootstrap refused: "${bootstrap.sha}" is not a 40-character commit sha`);
|
|
332
|
+
}
|
|
333
|
+
const checks: UpgradeCheck[] = [];
|
|
334
|
+
// The tree must BE that commit. Without this the identity is a label the
|
|
335
|
+
// operator typed, and the checks below would attest a different build than
|
|
336
|
+
// the one installed.
|
|
337
|
+
const head = await deps.run("git", ["-C", bootstrap.source, "rev-parse", "HEAD"]);
|
|
338
|
+
const at = head.stdout.trim().toLowerCase();
|
|
339
|
+
if (head.code !== 0 || at !== sha) {
|
|
340
|
+
checks.push({ name: "source-at-sha", ok: false, detail: at.length === 0 ? head.stderr.trim() : at });
|
|
341
|
+
throw new Error(
|
|
342
|
+
`bootstrap refused: ${bootstrap.source} is at ${at.length === 0 ? "an unreadable HEAD" : at}, not ${sha} — ` +
|
|
343
|
+
"the checks would attest a different build than the one installed",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
checks.push({ name: "source-at-sha", ok: true, detail: sha });
|
|
347
|
+
const version = sourceVersion(deps, bootstrap.source);
|
|
348
|
+
// The package's own declared gate, against the source, before anything is
|
|
349
|
+
// installed. A failure refuses: a partially verified tree must not go live.
|
|
350
|
+
deps.log(`bootstrap: running the package checks against ${bootstrap.source} (${sha.slice(0, 12)})`);
|
|
351
|
+
const gate = await deps.runIn(join(bootstrap.source, "omp"), "bun", ["run", "check"]);
|
|
352
|
+
if (gate.code !== 0) {
|
|
353
|
+
const detail = (gate.stderr.trim() || gate.stdout.trim()).split("\n").slice(-3).join("; ");
|
|
354
|
+
checks.push({ name: "package-checks", ok: false, detail });
|
|
355
|
+
throw new Error(
|
|
356
|
+
`bootstrap refused: \`bun run check\` failed against ${bootstrap.source} at ${sha} — ${detail}`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
checks.push({ name: "package-checks", ok: true });
|
|
360
|
+
return { version, gitHead: sha, packageSpec: `github:${PACKAGE_SOURCE}#${sha}`, checks, bootstrap: true };
|
|
361
|
+
}
|
|
362
|
+
|
|
225
363
|
function ompPluginVersion(raw: string): string | undefined {
|
|
226
364
|
let parsed: unknown;
|
|
227
365
|
try {
|
|
@@ -255,39 +393,44 @@ function herdrPluginSource(raw: string): string | undefined {
|
|
|
255
393
|
return source;
|
|
256
394
|
}
|
|
257
395
|
|
|
258
|
-
|
|
396
|
+
/** The three installed identities one host carries: the Bun-global CLI/daemon
|
|
397
|
+
* tree, the omp plugin, and the herdr recovery plugin's pin. */
|
|
398
|
+
export interface InstalledSurfaces {
|
|
259
399
|
cliVersion: string;
|
|
260
400
|
ompVersion?: string;
|
|
261
401
|
herdrSource?: string;
|
|
262
402
|
}
|
|
263
403
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
deps
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
404
|
+
/** What reading the three surfaces needs — deliberately narrower than
|
|
405
|
+
* {@link UpgradeDeps} so a read-only consumer (`doctor`'s surface-parity
|
|
406
|
+
* finding, #904) can supply three fields instead of a whole transaction. */
|
|
407
|
+
export type SurfaceProbeDeps = Pick<UpgradeDeps, "run" | "log" | "env">;
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Read the CLI, omp-plugin and herdr-plugin identities actually installed on
|
|
411
|
+
* this host. Exported because `upgrade` is not the only thing that needs to
|
|
412
|
+
* know whether the three agree: a host whose installs are manual diverges
|
|
413
|
+
* silently between transactions, and `doctor` reads them through this one
|
|
414
|
+
* seam rather than growing a second implementation (#904).
|
|
415
|
+
*/
|
|
416
|
+
export async function inspectSurfaces(
|
|
417
|
+
deps: SurfaceProbeDeps,
|
|
418
|
+
/** `readHerdr: false` skips the herdr session probe and plugin read
|
|
419
|
+
* entirely, so a host with no herdr on PATH can still be asked what its
|
|
420
|
+
* CLI and omp plugin are (#904). The upgrade transaction always reads all
|
|
421
|
+
* three — a release pins every surface. */
|
|
422
|
+
opts: { readHerdr?: boolean } = {},
|
|
423
|
+
): Promise<InstalledSurfaces> {
|
|
424
|
+
if (opts.readHerdr === false) {
|
|
425
|
+
const [cliOnly, ompOnly] = await Promise.all([
|
|
426
|
+
mustRun(deps, "omp-conductor", ["--version"]),
|
|
427
|
+
mustRun(deps, "omp", ["plugin", "list", "--json"]),
|
|
428
|
+
]);
|
|
429
|
+
const version = cliOnly.stdout.trim();
|
|
430
|
+
if (version.length === 0) throw new Error("installed omp-conductor CLI has no version");
|
|
431
|
+
return { cliVersion: version, ompVersion: ompPluginVersion(ompOnly.stdout) };
|
|
290
432
|
}
|
|
433
|
+
const session = resolveHerdrSession(deps.env);
|
|
291
434
|
const [cli, omp, herdr] = await Promise.all([
|
|
292
435
|
mustRun(deps, "omp-conductor", ["--version"]),
|
|
293
436
|
mustRun(deps, "omp", ["plugin", "list", "--json"]),
|
|
@@ -302,12 +445,25 @@ async function inspectSurfaces(deps: UpgradeDeps): Promise<InstalledSurfaces> {
|
|
|
302
445
|
};
|
|
303
446
|
}
|
|
304
447
|
|
|
305
|
-
function expectedHerdrSource(gitHead: string): string {
|
|
448
|
+
export function expectedHerdrSource(gitHead: string): string {
|
|
306
449
|
return `github:${HERDR_SOURCE}@${gitHead}`;
|
|
307
450
|
}
|
|
308
451
|
|
|
452
|
+
/**
|
|
453
|
+
* The published identity of one release: its version and the exact commit it
|
|
454
|
+
* was cut from, straight from the registry metadata `upgrade` already trusts.
|
|
455
|
+
* Exported so a read-only consumer can answer "which commit should this
|
|
456
|
+
* host's herdr plugin be pinned to?" without re-deriving the mapping (#904).
|
|
457
|
+
*/
|
|
458
|
+
export async function releaseIdentity(
|
|
459
|
+
deps: SurfaceProbeDeps,
|
|
460
|
+
version: string,
|
|
461
|
+
): Promise<{ version: string; gitHead: string }> {
|
|
462
|
+
return parseRegistry((await mustRun(deps, "npm", ["view", `${PACKAGE}@${version}`, "version", "gitHead", "--json"])).stdout);
|
|
463
|
+
}
|
|
464
|
+
|
|
309
465
|
function surfacesCurrent(
|
|
310
|
-
surfaces:
|
|
466
|
+
surfaces: InstalledSurfaces,
|
|
311
467
|
version: string,
|
|
312
468
|
gitHead: string,
|
|
313
469
|
): boolean {
|
|
@@ -727,7 +883,7 @@ async function sessionVerifyProblem(
|
|
|
727
883
|
// No daemon to attest: only the pane could have reloaded anything, so the
|
|
728
884
|
// pane probe decides alone, exactly as the external leg of the detached
|
|
729
885
|
// verifier does.
|
|
730
|
-
const pane = await deps.probePaneOmp(deps.
|
|
886
|
+
const pane = await deps.probePaneOmp(resolveHerdrSession(deps.env));
|
|
731
887
|
if ("problem" in pane) return `the fleet pane could not be probed: ${pane.problem}`;
|
|
732
888
|
return paneRestartProblem(pane.starts, reloadAfterMs) ?? undefined;
|
|
733
889
|
}
|
|
@@ -753,7 +909,7 @@ async function sessionVerifyProblem(
|
|
|
753
909
|
if (names.length === 0) {
|
|
754
910
|
return `the restarted daemon's /healthz on :${port} named no project — the live session cannot be attested`;
|
|
755
911
|
}
|
|
756
|
-
const pane = await deps.probePaneOmp(deps.
|
|
912
|
+
const pane = await deps.probePaneOmp(resolveHerdrSession(deps.env));
|
|
757
913
|
for (const project of names) {
|
|
758
914
|
const problem = sessionReloadProblem({
|
|
759
915
|
facts: orchestratorFactsFromHealth(health.body, project),
|
|
@@ -992,6 +1148,26 @@ export async function rollbackUpgrade(
|
|
|
992
1148
|
}
|
|
993
1149
|
}
|
|
994
1150
|
|
|
1151
|
+
// The units the forward transaction reconciled (#905). Restoring them is the
|
|
1152
|
+
// same operation in the other direction: the *restored* CLI renders the
|
|
1153
|
+
// previous version's templates, so running its own reconcile puts the
|
|
1154
|
+
// pre-upgrade bytes back at every destination — and leaves anything the
|
|
1155
|
+
// operator edited alone, because that drift is protected on both legs.
|
|
1156
|
+
//
|
|
1157
|
+
// Ordered before the restarts below, exactly as the forward leg is, and
|
|
1158
|
+
// best-effort: a rollback that restored three surfaces must not fail because
|
|
1159
|
+
// a unit file could not be rewritten. The failure is collected and reported
|
|
1160
|
+
// with the rest.
|
|
1161
|
+
if (installTouched) {
|
|
1162
|
+
const restoreUnits = await deps.run("omp-conductor", ["reconcile-units", "--yes"]);
|
|
1163
|
+
const summary = (restoreUnits.stdout.trim() || restoreUnits.stderr.trim()).split("\n").pop() ?? "";
|
|
1164
|
+
if (restoreUnits.code === 0) {
|
|
1165
|
+
deps.log(`rollback: ${summary.length === 0 ? "host units restored" : summary}`);
|
|
1166
|
+
} else {
|
|
1167
|
+
failures.push(`host units not restored: ${summary}`);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
995
1171
|
if (herdrReloadStarted) {
|
|
996
1172
|
await restore("restart herdr-fleet.service", "systemctl", ["restart", HERDR_UNIT]);
|
|
997
1173
|
}
|
|
@@ -1053,7 +1229,16 @@ function logHostRuntimeDrift(deps: UpgradeDeps): void {
|
|
|
1053
1229
|
if (plan.drift.length === 0) return;
|
|
1054
1230
|
deps.log("host runtime:");
|
|
1055
1231
|
for (const path of plan.drift) deps.log(` ${path} differs from this version's render`);
|
|
1056
|
-
|
|
1232
|
+
// Units are this transaction's own to reconcile (#905), and the reconcile
|
|
1233
|
+
// verb is bounded to them. Anything else the render owns — the herdr
|
|
1234
|
+
// pane-shell config, the herdr-conductor `config.env`, the worker identity —
|
|
1235
|
+
// still needs `setup host`, so the advisory names the right tool for each
|
|
1236
|
+
// rather than routing every drift into the flow that restarts (and kills)
|
|
1237
|
+
// the caller's own pane (#834).
|
|
1238
|
+
deps.log(
|
|
1239
|
+
"fix: `omp-conductor reconcile-units` re-installs the host units; anything else above needs " +
|
|
1240
|
+
"`omp-conductor setup host` from the fleet account",
|
|
1241
|
+
);
|
|
1057
1242
|
}
|
|
1058
1243
|
|
|
1059
1244
|
export async function upgradeConductor(
|
|
@@ -1069,14 +1254,29 @@ export async function upgradeConductor(
|
|
|
1069
1254
|
throw new Error("run omp-conductor upgrade from a shell outside the target Herdr session");
|
|
1070
1255
|
}
|
|
1071
1256
|
|
|
1072
|
-
const requested = options.version === undefined ? `${PACKAGE}@latest` : `${PACKAGE}@${options.version}`;
|
|
1073
1257
|
// Host-wide by default (#389): one daemon serves every configured project,
|
|
1074
1258
|
// so an upgrade that restarts it drains them all and refreshes every brief.
|
|
1075
1259
|
const scope = resolveScope(deps, "upgrade", options.project);
|
|
1076
1260
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
);
|
|
1261
|
+
// Published semver, or an exact source/build identity whose checks have
|
|
1262
|
+
// already passed (#908). Everything below reads the same pair either way.
|
|
1263
|
+
const identity = await resolveIdentity(deps, options);
|
|
1264
|
+
const release = { version: identity.version, gitHead: identity.gitHead };
|
|
1265
|
+
if (identity.bootstrap) {
|
|
1266
|
+
// The bootstrap's evidence lands in the journal before the first surface
|
|
1267
|
+
// moves, so an interrupted bootstrap leaves a record of what was verified
|
|
1268
|
+
// rather than an unexplained half-install.
|
|
1269
|
+
journal({
|
|
1270
|
+
kind: "request",
|
|
1271
|
+
ok: true,
|
|
1272
|
+
version: release.version,
|
|
1273
|
+
gitHead: release.gitHead,
|
|
1274
|
+
phase: "bootstrap",
|
|
1275
|
+
detail: `source identity from ${options.bootstrap?.source ?? "(source)"}`,
|
|
1276
|
+
checks: identity.checks,
|
|
1277
|
+
unit: deps.env["OMP_CONDUCTOR_UNIT"],
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1080
1280
|
if (detached) {
|
|
1081
1281
|
// The durable request: written before anything can pause, so a unit that
|
|
1082
1282
|
// dies after this line leaves a journal the returning process can act on
|
|
@@ -1096,6 +1296,18 @@ export async function upgradeConductor(
|
|
|
1096
1296
|
...deps.brief(selector),
|
|
1097
1297
|
}));
|
|
1098
1298
|
const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
|
|
1299
|
+
// The host-unit baseline, read while the OLD package is still the one
|
|
1300
|
+
// rendering (#905). A destination already differing from the old render was
|
|
1301
|
+
// edited outside conductor, so the reconcile after the install must not
|
|
1302
|
+
// overwrite it; `undefined` means the baseline could not be established at
|
|
1303
|
+
// all, and the reconcile is skipped rather than guessing.
|
|
1304
|
+
const preInstallDrift = ((): string[] | undefined => {
|
|
1305
|
+
try {
|
|
1306
|
+
return [...deps.hostRuntime().drift];
|
|
1307
|
+
} catch {
|
|
1308
|
+
return undefined;
|
|
1309
|
+
}
|
|
1310
|
+
})();
|
|
1099
1311
|
if (!installNeeded && briefs.every((b) => b.current)) {
|
|
1100
1312
|
if (detached) {
|
|
1101
1313
|
journal({
|
|
@@ -1151,6 +1363,23 @@ export async function upgradeConductor(
|
|
|
1151
1363
|
}
|
|
1152
1364
|
if (initial.herdr === "unknown") throw new Error("cannot determine whether herdr-fleet.service is active");
|
|
1153
1365
|
|
|
1366
|
+
// The one verification input that can be read before anything moves (#976).
|
|
1367
|
+
// Verification probes the fleet pane in `resolveHerdrSession(deps.env)`; a
|
|
1368
|
+
// host whose session answers to another name only discovers that after three
|
|
1369
|
+
// installs and two restarts, and a good release is then rolled back. Read it
|
|
1370
|
+
// first: the same probe, at zero cost, as a refusal.
|
|
1371
|
+
if (!detached && (initial.dispatch !== "stopped" || initial.herdr === "active")) {
|
|
1372
|
+
const session = resolveHerdrSession(deps.env);
|
|
1373
|
+
const pane = await deps.probePaneOmp(session);
|
|
1374
|
+
if ("problem" in pane) {
|
|
1375
|
+
throw new Error(
|
|
1376
|
+
`the fleet pane in herdr session "${session}" cannot be probed, so this upgrade could not be ` +
|
|
1377
|
+
`verified: ${pane.problem}. Set HERDR_SESSION to the session that owns the fleet pane and ` +
|
|
1378
|
+
`re-run, or bring that session up first — nothing has been installed.`,
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1154
1383
|
if (detached) {
|
|
1155
1384
|
// The durable record the returning process rolls back or verifies against:
|
|
1156
1385
|
// what was installed, what dispatch state the fleet began in, which pause
|
|
@@ -1209,10 +1438,10 @@ export async function upgradeConductor(
|
|
|
1209
1438
|
if (installNeeded) {
|
|
1210
1439
|
installTouched = true;
|
|
1211
1440
|
deps.log(`install 1/3: Bun-global omp-conductor CLI → ${release.version}`);
|
|
1212
|
-
await mustRun(deps, "bun", ["add", "-g",
|
|
1441
|
+
await mustRun(deps, "bun", ["add", "-g", identity.packageSpec]);
|
|
1213
1442
|
journal({ kind: "phase", phase: "install", surface: "cli", ok: true, version: release.version, gitHead: release.gitHead });
|
|
1214
1443
|
deps.log(`install 2/3: omp plugin omp-conductor → ${release.version}`);
|
|
1215
|
-
await mustRun(deps, "omp", ["plugin", "install",
|
|
1444
|
+
await mustRun(deps, "omp", ["plugin", "install", identity.packageSpec]);
|
|
1216
1445
|
journal({ kind: "phase", phase: "install", surface: "omp", ok: true, version: release.version, gitHead: release.gitHead });
|
|
1217
1446
|
if (surfaces.herdrSource?.startsWith("local:")) {
|
|
1218
1447
|
await mustRun(deps, "herdr", ["plugin", "unlink", HERDR_PLUGIN]);
|
|
@@ -1236,6 +1465,56 @@ export async function upgradeConductor(
|
|
|
1236
1465
|
await upgradeBriefs(deps, briefs);
|
|
1237
1466
|
journal({ kind: "phase", phase: "brief", surface: "brief", ok: true, version: release.version });
|
|
1238
1467
|
|
|
1468
|
+
// #905: the templates this release re-rendered. The render is CODE, so
|
|
1469
|
+
// this process — which loaded the previous version before replacing it —
|
|
1470
|
+
// cannot see the new one: the reconcile runs as a fresh child of the
|
|
1471
|
+
// just-installed CLI, which is also why it is spawned rather than called.
|
|
1472
|
+
// (Not an escalation of conductor itself: the child escalates only its own
|
|
1473
|
+
// bounded `install`/`daemon-reload` steps, which is exactly the boundary
|
|
1474
|
+
// `privileged.ts` draws.)
|
|
1475
|
+
//
|
|
1476
|
+
// It lands here, before the restarts below, so the daemon and the herdr
|
|
1477
|
+
// session come up reading the units this release actually ships — and
|
|
1478
|
+
// inside the same pause/drain window, so nothing is admitted against a
|
|
1479
|
+
// half-reconciled host.
|
|
1480
|
+
{
|
|
1481
|
+
// No baseline means no *refresh*: drift the operator made cannot be told
|
|
1482
|
+
// from drift the upgrade made, so nothing is overwritten. It does not
|
|
1483
|
+
// mean no *retirement* (#895) — a unit this release stopped shipping is
|
|
1484
|
+
// not a comparison against any render, and skipping the whole child
|
|
1485
|
+
// there left an affected host mounting an obsolete bind after every
|
|
1486
|
+
// upgrade, forever. So the child always runs; only its refresh half is
|
|
1487
|
+
// gated.
|
|
1488
|
+
const refreshable = preInstallDrift !== undefined;
|
|
1489
|
+
if (!refreshable) {
|
|
1490
|
+
deps.log(
|
|
1491
|
+
"host units: refresh skipped — this version's host state could not be read before the install, " +
|
|
1492
|
+
"so drift the operator made cannot be told from drift the upgrade made. Retirement still runs; " +
|
|
1493
|
+
"run `omp-conductor reconcile-units --dry-run` to see what differs.",
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
const reconcile = await deps.run("omp-conductor", [
|
|
1497
|
+
"reconcile-units",
|
|
1498
|
+
"--yes",
|
|
1499
|
+
...(refreshable ? [] : ["--no-refresh"]),
|
|
1500
|
+
// Destinations that already differed from the OLD render are the
|
|
1501
|
+
// operator's own edits, not this upgrade's doing: named here so the
|
|
1502
|
+
// child leaves them alone and reports them.
|
|
1503
|
+
...(preInstallDrift ?? []).flatMap((path) => ["--protect", path]),
|
|
1504
|
+
]);
|
|
1505
|
+
const summary = (reconcile.stdout.trim() || reconcile.stderr.trim()).split("\n").pop() ?? "";
|
|
1506
|
+
if (reconcile.code === 0) {
|
|
1507
|
+
deps.log(summary.length === 0 ? "host units: reconciled" : summary);
|
|
1508
|
+
journal({ kind: "phase", phase: "reconcile", surface: "units", ok: true, version: release.version, detail: summary });
|
|
1509
|
+
} else {
|
|
1510
|
+
// A failed reconcile is a drifted host, not a failed upgrade: the
|
|
1511
|
+
// packages are installed and verifiable, and the units are exactly as
|
|
1512
|
+
// they were. Say so precisely instead of rolling three surfaces back.
|
|
1513
|
+
deps.log(`host units: reconcile did not complete — ${summary}`);
|
|
1514
|
+
journal({ kind: "phase", phase: "reconcile", surface: "units", ok: false, version: release.version, detail: summary });
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1239
1518
|
if (initial.herdr === "active") {
|
|
1240
1519
|
herdrReloadStarted = true;
|
|
1241
1520
|
deps.log("reload: restarting herdr-fleet.service and recovering the orchestrator pane");
|