omp-conductor 0.16.1 → 0.17.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/README.md +63 -4
- package/REFERENCE.md +20 -11
- package/package.json +3 -1
- package/src/briefs/orchestrator.md +21 -6
- package/src/briefs/policy.md +13 -5
- package/src/clack-ui.ts +83 -0
- package/src/cli.ts +110 -381
- package/src/command-help.ts +220 -0
- package/src/command-manifest.ts +480 -0
- package/src/commands/arm.ts +7 -2
- package/src/commands/complete.ts +93 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/decision.ts +17 -7
- package/src/commands/doctor.ts +18 -1
- package/src/commands/hold.ts +9 -7
- package/src/commands/ledger.ts +25 -4
- package/src/commands/setup.ts +128 -11
- package/src/commands/stats.ts +9 -5
- package/src/commands/status.ts +32 -5
- package/src/commands/tail.ts +13 -1
- package/src/commands/watch.ts +16 -7
- package/src/fleet.ts +55 -15
- package/src/orchestrator-tick.ts +46 -7
- package/src/privileged.ts +3 -0
- package/src/setup-answers.ts +135 -0
- package/src/setup-host.ts +29 -11
- package/src/setup-install.ts +2 -0
- package/src/setup-probe.ts +1 -0
- package/src/setup-wizard.ts +130 -38
- package/src/ui/progress.ts +32 -0
- package/src/ui/style.ts +11 -0
- package/src/upgrade-verify.ts +25 -3
- package/src/upgrade.ts +61 -2
- package/src/wizard-ui.ts +14 -5
- package/systemd/omp-conductor-recover.sh +1 -1
- package/systemd/recover-unit-test.sh +2 -2
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Whether the CLI owns an interactive terminal rather than a pipe or plain-UI session. */
|
|
2
|
+
export function interactiveUi(): boolean {
|
|
3
|
+
return Boolean(
|
|
4
|
+
process.stdin.isTTY &&
|
|
5
|
+
process.stdout.isTTY &&
|
|
6
|
+
!process.env.OMP_CONDUCTOR_PLAIN_UI,
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Run one long, silent operation with safe Clack progress on an interactive TTY. */
|
|
11
|
+
export async function withProgress<T>(
|
|
12
|
+
message: string,
|
|
13
|
+
success: string,
|
|
14
|
+
operation: () => Promise<T>,
|
|
15
|
+
options: { plainMessage?: boolean } = {},
|
|
16
|
+
): Promise<T> {
|
|
17
|
+
if (!interactiveUi()) {
|
|
18
|
+
if (options.plainMessage) process.stdout.write(`${message}\n`);
|
|
19
|
+
return operation();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { log } = await import("@clack/prompts");
|
|
23
|
+
log.step(message);
|
|
24
|
+
try {
|
|
25
|
+
const result = await operation();
|
|
26
|
+
log.success(success);
|
|
27
|
+
return result;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
log.error(`${message} failed`);
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/ui/style.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
|
|
3
|
+
function styled(format: Parameters<typeof styleText>[0], text: string): string {
|
|
4
|
+
return process.stdout.isTTY ? styleText(format, text) : text;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const heading = (text: string): string => styled(["bold", "cyan"], text);
|
|
8
|
+
export const ok = (text: string): string => styled("green", text);
|
|
9
|
+
export const warn = (text: string): string => styled("yellow", text);
|
|
10
|
+
export const fail = (text: string): string => styled("red", text);
|
|
11
|
+
export const dim = (text: string): string => styled("dim", text);
|
package/src/upgrade-verify.ts
CHANGED
|
@@ -382,6 +382,19 @@ function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequ
|
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Doctor findings that only a privileged `setup host` can clear — the systemd
|
|
387
|
+
* units (and the recovery pair they name), and the herdr pane-shell key. A
|
|
388
|
+
* release that re-renders any of those templates fails them on every host
|
|
389
|
+
* whose `setup host` has not been re-run, so they are not rollback-worthy
|
|
390
|
+
* regression evidence: the upgrade is deliberately unable to perform the
|
|
391
|
+
* privileged install, and rolling back over drift it cannot clear would
|
|
392
|
+
* strand the fleet on the previous release with the same outstanding `setup
|
|
393
|
+
* host` (#598). The verifier escalates them instead — the doctor check's
|
|
394
|
+
* detail and the verify report name the drift and its fix.
|
|
395
|
+
*/
|
|
396
|
+
const PRIVILEGED_INSTALL_DRIFT = new Set(["systemd-unit", "systemd-recovery", "herdr-resume"]);
|
|
397
|
+
|
|
385
398
|
/**
|
|
386
399
|
* The independent checks the returning process runs. Each compares a live
|
|
387
400
|
* fact with the journal's record of the world the install began in:
|
|
@@ -392,7 +405,9 @@ function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequ
|
|
|
392
405
|
* same predicate the in-process upgrade used, so a fleet that was armed
|
|
393
406
|
* stays armed and a live pane stays live;
|
|
394
407
|
* - `health` — the daemon that came back answers `/healthz`;
|
|
395
|
-
* - `doctor` — no failing finding
|
|
408
|
+
* - `doctor` — no failing finding beyond the privileged-install drift
|
|
409
|
+
* ({@link PRIVILEGED_INSTALL_DRIFT}) only `setup host` can clear, which is
|
|
410
|
+
* escalated rather than rolled back.
|
|
396
411
|
*/
|
|
397
412
|
export async function runUpgradeChecks(
|
|
398
413
|
deps: UpgradeVerifyDeps,
|
|
@@ -423,10 +438,17 @@ export async function runUpgradeChecks(
|
|
|
423
438
|
|
|
424
439
|
const doctor = await deps.doctor(deps.projectName);
|
|
425
440
|
const failing = doctor.findings.filter((finding) => finding.status === "fail");
|
|
441
|
+
const drift = failing.filter((finding) => PRIVILEGED_INSTALL_DRIFT.has(finding.id));
|
|
442
|
+
const regression = failing.filter((finding) => !PRIVILEGED_INSTALL_DRIFT.has(finding.id));
|
|
426
443
|
checks.push({
|
|
427
444
|
name: "doctor",
|
|
428
|
-
ok:
|
|
429
|
-
detail:
|
|
445
|
+
ok: regression.length === 0,
|
|
446
|
+
detail:
|
|
447
|
+
regression.length > 0
|
|
448
|
+
? `${regression[0]!.id}: ${regression[0]!.summary}`
|
|
449
|
+
: drift.length > 0
|
|
450
|
+
? `status ${doctor.status}; ${drift.map((f) => f.id).join(", ")} — privileged-install drift, run \`omp-conductor setup host\` from the fleet account`
|
|
451
|
+
: `status ${doctor.status}`,
|
|
430
452
|
});
|
|
431
453
|
}
|
|
432
454
|
|
package/src/upgrade.ts
CHANGED
|
@@ -14,9 +14,9 @@ import { livingDaemon, restartDaemon } from "./lifecycle.ts";
|
|
|
14
14
|
import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, stateDir, writeConfigRaw } from "./config.ts";
|
|
15
15
|
import { renderBriefForProject } from "./setup.ts";
|
|
16
16
|
import {
|
|
17
|
-
STAGED_SERVICE_NAME,
|
|
18
17
|
planHostRuntime,
|
|
19
|
-
|
|
18
|
+
totalConfiguredWorkers,
|
|
19
|
+
type HostRuntimePlan,
|
|
20
20
|
} from "./setup-host.ts";
|
|
21
21
|
import {
|
|
22
22
|
appendJournal,
|
|
@@ -95,6 +95,17 @@ export interface UpgradeDeps {
|
|
|
95
95
|
sleep(ms: number): Promise<void>;
|
|
96
96
|
env: NodeJS.ProcessEnv;
|
|
97
97
|
log(message: string): void;
|
|
98
|
+
/**
|
|
99
|
+
* The host runtime this version's package renders, compared against what is
|
|
100
|
+
* actually installed: the daemon and herdr units, the recovery unit and its
|
|
101
|
+
* playbook at {@link RECOVER_SCRIPT_INSTALL_PATH}, the herdr pane-shell
|
|
102
|
+
* config and the herdr-conductor `config.env`. `upgrade` prints
|
|
103
|
+
* {@link HostRuntimePlan.drift} after a release that re-rendered those
|
|
104
|
+
* files, so the operator is told that `setup host` is owed before a later
|
|
105
|
+
* `doctor` has to discover it. Read-only: the plan renders and compares,
|
|
106
|
+
* it never writes a host file.
|
|
107
|
+
*/
|
|
108
|
+
hostRuntime(): HostRuntimePlan;
|
|
98
109
|
/**
|
|
99
110
|
* The durable journal sink for the detached fleet installer (#486). Wired
|
|
100
111
|
* to the state-dir journal by `upgrade-install`/`upgrade-rollback`; absent
|
|
@@ -135,6 +146,13 @@ export const DEFAULT_DEPS: UpgradeDeps = {
|
|
|
135
146
|
restartDaemon: async () => {
|
|
136
147
|
await restartDaemon({});
|
|
137
148
|
},
|
|
149
|
+
// The bare host-global plan — the same render the advisory's `setup host`
|
|
150
|
+
// command would install: no per-project tail, the recovery unit encoding no
|
|
151
|
+
// one project's name, and FLEET_CWDS derived from every configured project.
|
|
152
|
+
hostRuntime: () => {
|
|
153
|
+
const cfg = loadConfig();
|
|
154
|
+
return planHostRuntime(undefined, cfg.defaults, telegramStateDir(), undefined, totalConfiguredWorkers(cfg));
|
|
155
|
+
},
|
|
138
156
|
sleep: Bun.sleep,
|
|
139
157
|
env: process.env,
|
|
140
158
|
log: (message) => process.stdout.write(`${message}\n`),
|
|
@@ -821,6 +839,37 @@ export async function rollbackUpgrade(
|
|
|
821
839
|
if (failures.length > 0) throw new Error(failures.join("; "));
|
|
822
840
|
}
|
|
823
841
|
|
|
842
|
+
/**
|
|
843
|
+
* Name the host-runtime destinations this version's render no longer matches,
|
|
844
|
+
* after a successful upgrade — the installed units, the recovery playbook at
|
|
845
|
+
* {@link RECOVER_SCRIPT_INSTALL_PATH}, the herdr pane-shell config and the
|
|
846
|
+
* herdr-conductor `config.env`. A release that re-renders any of those files
|
|
847
|
+
* silently invalidates the installed copies; telling the operator here — with
|
|
848
|
+
* the exact command — is what stops the drift from surviving until a later
|
|
849
|
+
* `doctor` finds it (#598).
|
|
850
|
+
*
|
|
851
|
+
* The plan *is* the comparison: {@link UpgradeDeps.hostRuntime} renders every
|
|
852
|
+
* destination against disk, so a current host prints nothing and a drifted one
|
|
853
|
+
* names exactly the files only a privileged `setup host` can refresh. This
|
|
854
|
+
* never mutates a host file and never fails the upgrade — an unreadable host
|
|
855
|
+
* state is a warning line, not a reason to roll a successful install back.
|
|
856
|
+
*/
|
|
857
|
+
function logHostRuntimeDrift(deps: UpgradeDeps): void {
|
|
858
|
+
let plan: HostRuntimePlan;
|
|
859
|
+
try {
|
|
860
|
+
plan = deps.hostRuntime();
|
|
861
|
+
} catch (err) {
|
|
862
|
+
deps.log(
|
|
863
|
+
`host runtime: could not compare against this version's render — ${err instanceof Error ? err.message : String(err)}`,
|
|
864
|
+
);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (plan.drift.length === 0) return;
|
|
868
|
+
deps.log("host runtime:");
|
|
869
|
+
for (const path of plan.drift) deps.log(` ${path} differs from this version's render`);
|
|
870
|
+
deps.log("fix: run `omp-conductor setup host` from the fleet account to re-install the host units");
|
|
871
|
+
}
|
|
872
|
+
|
|
824
873
|
export async function upgradeConductor(
|
|
825
874
|
options: UpgradeOptions = {},
|
|
826
875
|
overrides: Partial<UpgradeDeps> = {},
|
|
@@ -871,6 +920,11 @@ export async function upgradeConductor(
|
|
|
871
920
|
detail: "all three surfaces and every brief were already pinned to the target release",
|
|
872
921
|
});
|
|
873
922
|
}
|
|
923
|
+
// Packages are current, but the host units they render may be behind: the
|
|
924
|
+
// same drift this advisory names after an install applies when a release
|
|
925
|
+
// re-rendered a template since the operator's last `setup host`. Silent on
|
|
926
|
+
// a host whose runtime matches (#598).
|
|
927
|
+
logHostRuntimeDrift(deps);
|
|
874
928
|
return {
|
|
875
929
|
previousVersion: surfaces.cliVersion,
|
|
876
930
|
...release,
|
|
@@ -1118,6 +1172,11 @@ export async function upgradeConductor(
|
|
|
1118
1172
|
throw new Error(`upgrade failed: ${failure}; previous installation restored; dispatch remains paused`);
|
|
1119
1173
|
}
|
|
1120
1174
|
|
|
1175
|
+
// The install landed; name any host-runtime destination this version no
|
|
1176
|
+
// longer renders identically, so the operator knows `setup host` is owed
|
|
1177
|
+
// before a later `doctor` reports it as drift (#598).
|
|
1178
|
+
logHostRuntimeDrift(deps);
|
|
1179
|
+
|
|
1121
1180
|
return {
|
|
1122
1181
|
previousVersion: surfaces.cliVersion,
|
|
1123
1182
|
...release,
|
package/src/wizard-ui.ts
CHANGED
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
* (`Cancelled`). The terminal UI guarantees the readline interface is closed on
|
|
25
25
|
* every exit path so the verb cannot hang the terminal.
|
|
26
26
|
*/
|
|
27
|
+
export interface PromptOptions {
|
|
28
|
+
/** Stable answer-file key. This is a permanent CLI API, not display copy. */
|
|
29
|
+
key: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SelectOptions extends PromptOptions {
|
|
33
|
+
initialIndex?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
export interface WizardUi {
|
|
28
37
|
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
29
38
|
/**
|
|
@@ -32,17 +41,17 @@ export interface WizardUi {
|
|
|
32
41
|
* `Cancelled` and abandons the run. Collapsing the two is how Ctrl-C at a
|
|
33
42
|
* confirm used to record a silent "no" and carry on to the next question.
|
|
34
43
|
*/
|
|
35
|
-
confirm(title: string, message: string): Promise<boolean | undefined>;
|
|
44
|
+
confirm(title: string, message: string, promptOptions: PromptOptions): Promise<boolean | undefined>;
|
|
36
45
|
/** Single-line text prompt. `undefined` dismisses it; an empty submit accepts
|
|
37
46
|
* the placeholder. */
|
|
38
|
-
input(title: string, placeholder
|
|
47
|
+
input(title: string, placeholder: string | undefined, promptOptions: PromptOptions): Promise<string | undefined>;
|
|
39
48
|
/** Single-choice list. On an interactive TTY the current option is rendered
|
|
40
49
|
* inline and moved with ↑/↓ or j/k; on a pipe it stays the numbered wall.
|
|
41
50
|
* Resolves the chosen option's label, or `undefined` when dismissed. */
|
|
42
51
|
select(
|
|
43
52
|
title: string,
|
|
44
53
|
options: { label: string; description?: string }[],
|
|
45
|
-
dialogOptions
|
|
54
|
+
dialogOptions: SelectOptions,
|
|
46
55
|
): Promise<string | undefined>;
|
|
47
56
|
}
|
|
48
57
|
|
|
@@ -83,8 +92,8 @@ import type { Readable, Writable } from "node:stream";
|
|
|
83
92
|
* rather than only through a spawned CLI; production callers pass nothing.
|
|
84
93
|
*/
|
|
85
94
|
export interface TerminalUi extends WizardUi {
|
|
86
|
-
/** Releases stdin
|
|
87
|
-
close(): void;
|
|
95
|
+
/** Releases stdin; interactive drivers may render the outcome message. Idempotent. */
|
|
96
|
+
close(message?: string): void;
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
export function terminalUi(io: { input?: Readable; output?: Writable } = {}): TerminalUi {
|
|
@@ -273,7 +273,7 @@ fresh_rollback_snapshot() {
|
|
|
273
273
|
newest=$(ls -t "$BACKUP_ROOT"/config.json.pre-upgrade-* 2>/dev/null | head -n 1 || true)
|
|
274
274
|
[[ -n $newest ]] || return 1
|
|
275
275
|
now=$(epoch_now)
|
|
276
|
-
ts=$(stat -c %Y "$newest" 2>/dev/null || true)
|
|
276
|
+
ts=$(stat -c %Y "$newest" 2>/dev/null || stat -f %m "$newest" 2>/dev/null || true)
|
|
277
277
|
[[ -n $ts && $((now - ts)) -le $ROLLBACK_AGE_S ]] || return 1
|
|
278
278
|
printf '%s\n' "$newest"
|
|
279
279
|
}
|
|
@@ -126,14 +126,14 @@ unit_state() { # <case-dir> <unit> <state|absent>
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
# A pre-upgrade snapshot `upgrade` durably keeps (configBackupDir()):
|
|
129
|
-
# config.json.pre-upgrade-<ts>. The test
|
|
129
|
+
# config.json.pre-upgrade-<ts>. The test passes the age in seconds.
|
|
130
130
|
write_snapshot() { # <case-dir> <age-seconds>
|
|
131
131
|
local d="$1" age="${2:-0}" now
|
|
132
132
|
now=$(date +%s)
|
|
133
133
|
local snap="$d/state/backups/config/config.json.pre-upgrade-$now"
|
|
134
134
|
printf '%s\n' '{"preUpgrade":true}' >"$snap"
|
|
135
135
|
if (( age > 0 )); then
|
|
136
|
-
|
|
136
|
+
perl -e 'utime $ARGV[1], $ARGV[1], $ARGV[0] or die "utime: $!"' "$snap" "$(( now - age ))"
|
|
137
137
|
fi
|
|
138
138
|
printf '%s\n' "$snap"
|
|
139
139
|
}
|