omp-conductor 0.19.4 → 0.19.6
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/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +18 -8
- package/src/command-manifest.ts +22 -5
- package/src/commands/context.ts +1 -0
- package/src/commands/report.ts +63 -0
- package/src/commands/restore-db.ts +1 -1
- package/src/commands/resume.ts +5 -0
- package/src/commands/snapshot-db.ts +67 -0
- package/src/commands/upgrade-install.ts +1 -1
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/verb.ts +1 -0
- package/src/commands/watch.ts +14 -2
- package/src/config.ts +17 -0
- package/src/daemon.ts +54 -8
- package/src/dashboard/controls.ts +3 -0
- package/src/doctor.ts +36 -3
- package/src/fleet.ts +51 -3
- package/src/orchestrator-tick.ts +14 -3
- package/src/setup-host.ts +10 -10
- package/src/setup-wizard.ts +1 -1
- package/src/status-render.ts +21 -0
- package/src/store.ts +296 -22
- package/src/types.ts +32 -3
- package/src/upgrade-journal.ts +37 -1
- package/src/upgrade-verify.ts +272 -37
- package/src/upgrade.ts +324 -23
- package/src/verbs/actions.ts +6 -1
- package/src/verbs/server.ts +54 -20
- package/systemd/omp-conductor-recover.sh +55 -7
- package/systemd/recover-unit-test.sh +99 -6
package/src/verbs/server.ts
CHANGED
|
@@ -42,7 +42,7 @@ import { randomUUID } from "node:crypto";
|
|
|
42
42
|
import { createServer, type Server, type Socket } from "node:net";
|
|
43
43
|
import { join } from "node:path";
|
|
44
44
|
|
|
45
|
-
import { resolvePolicy, resolveReleaseGrants, resolveReview } from "../config.ts";
|
|
45
|
+
import { resolvePolicy, resolveReleaseGrants, resolveReview, resolveSharedInstallAuthority } from "../config.ts";
|
|
46
46
|
import { wakeDispatch } from "../wake.ts";
|
|
47
47
|
// The mediated release is the drain's closing gesture (#791): a successful
|
|
48
48
|
// release ends this project's bounded release window, so the privileged half
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
import { releaseRefusal } from "../release-policy.ts";
|
|
61
61
|
import { PR_LOOKUP_WINDOW_MS, REVISABLE_RUN_STATES, prReviewReadiness } from "../decisions.ts";
|
|
62
62
|
import { LIVE_STATES } from "../store.ts";
|
|
63
|
+
import { DENIED_RELEASE_GRANTS } from "../types.ts";
|
|
63
64
|
import type {
|
|
64
65
|
FileLane,
|
|
65
66
|
IssueComment,
|
|
@@ -71,6 +72,7 @@ import type {
|
|
|
71
72
|
ReleaseRequirement,
|
|
72
73
|
ReleaseShape,
|
|
73
74
|
RepoTarget,
|
|
75
|
+
ResolvedGrants,
|
|
74
76
|
ReviewReason,
|
|
75
77
|
RunRecord,
|
|
76
78
|
RunState,
|
|
@@ -172,6 +174,9 @@ export interface ReleaseExecution {
|
|
|
172
174
|
export interface InstallExecution {
|
|
173
175
|
/** The published semver to pin all three surfaces to, exactly as npm has it. */
|
|
174
176
|
version: string;
|
|
177
|
+
/** Shared host authority and scope, carried into the detached report. */
|
|
178
|
+
holder: ResolvedGrants["install"];
|
|
179
|
+
projects: readonly string[];
|
|
175
180
|
}
|
|
176
181
|
|
|
177
182
|
/**
|
|
@@ -222,6 +227,11 @@ export interface VerbDeps {
|
|
|
222
227
|
* unreadable, and cannot pick up an operator's edit either.
|
|
223
228
|
*/
|
|
224
229
|
project: () => ProjectConfig;
|
|
230
|
+
/**
|
|
231
|
+
* Every project served by the shared daemon, re-read from the same config.
|
|
232
|
+
* Host-global mutations use this set rather than the calling socket's project.
|
|
233
|
+
*/
|
|
234
|
+
projects: () => readonly ProjectConfig[];
|
|
225
235
|
store: Store;
|
|
226
236
|
tracker: Tracker;
|
|
227
237
|
actions: VerbActions;
|
|
@@ -632,7 +642,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
632
642
|
case "conductor_release":
|
|
633
643
|
return releaseVerb(deps, project, channel, args, refuse, allow);
|
|
634
644
|
case "conductor_install":
|
|
635
|
-
return installVerb(deps,
|
|
645
|
+
return installVerb(deps, channel, args, refuse, allow);
|
|
636
646
|
case "conductor_pr_review":
|
|
637
647
|
return prReviewVerb(deps, project, channel, args, refuse, allow);
|
|
638
648
|
case "conductor_pr_review_clear":
|
|
@@ -1660,31 +1670,50 @@ async function releaseVerb(
|
|
|
1660
1670
|
|
|
1661
1671
|
|
|
1662
1672
|
/**
|
|
1663
|
-
* The
|
|
1664
|
-
*
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
1667
|
-
* package floor's "nobody patches the running conductor" is the deny default
|
|
1668
|
-
* an operator deliberately moves, never something this verb works around.
|
|
1669
|
-
*
|
|
1670
|
-
* Everything past the gate is the privileged half's job: `actions.install`
|
|
1671
|
-
* refussses a version npm does not expose and starts the detached transient
|
|
1672
|
-
* unit. This verb records the request durably (the journal the post-restart
|
|
1673
|
-
* tick verifies against) and tells the caller where the evidence will land.
|
|
1673
|
+
* The install is host-global: one package tree and daemon serve every project.
|
|
1674
|
+
* Authority is therefore the consensus holder across the configured project
|
|
1675
|
+
* set, never whichever project happened to invoke the verb (#1018). Mixed
|
|
1676
|
+
* holders refuse before version lookup or transient-unit creation.
|
|
1674
1677
|
*/
|
|
1675
1678
|
async function installVerb(
|
|
1676
1679
|
deps: VerbDeps,
|
|
1677
|
-
project: ProjectConfig,
|
|
1678
1680
|
channel: VerbChannel,
|
|
1679
1681
|
args: Record<string, unknown>,
|
|
1680
1682
|
refuse: Refuse,
|
|
1681
1683
|
allow: Allow,
|
|
1682
1684
|
): Promise<Verdict> {
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
+
let projects: readonly ProjectConfig[];
|
|
1686
|
+
try {
|
|
1687
|
+
projects = deps.projects();
|
|
1688
|
+
} catch (err) {
|
|
1689
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
1690
|
+
return refuse("config-unreadable", `refused: shared install authority could not be read (${why}).`);
|
|
1691
|
+
}
|
|
1692
|
+
if (projects.length === 0) {
|
|
1693
|
+
return refuse("config-unreadable", "refused: shared install authority has no configured projects.");
|
|
1694
|
+
}
|
|
1695
|
+
const authority = resolveSharedInstallAuthority(projects);
|
|
1696
|
+
const holderDetail = authority.entries.map((entry) => `${entry.project}=${entry.holder}`).join(", ");
|
|
1697
|
+
if (authority.holder === undefined) {
|
|
1698
|
+
return refuse(
|
|
1699
|
+
"release-not-granted",
|
|
1700
|
+
`refused: conductor_install is host-global, but configured install holders conflict (${holderDetail}). ` +
|
|
1701
|
+
"No dispatch pause or transient install unit was created.",
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
const globalHolder = authority.holder;
|
|
1705
|
+
const granted = releaseRefusal(
|
|
1706
|
+
{ ...DENIED_RELEASE_GRANTS, install: globalHolder },
|
|
1707
|
+
channel.role,
|
|
1708
|
+
"install",
|
|
1709
|
+
);
|
|
1685
1710
|
if (granted !== undefined) {
|
|
1686
|
-
return refuse(
|
|
1711
|
+
return refuse(
|
|
1712
|
+
"release-not-granted",
|
|
1713
|
+
`refused: ${granted.reason} Shared-host holders: ${holderDetail}.`,
|
|
1714
|
+
);
|
|
1687
1715
|
}
|
|
1716
|
+
const affectedProjects = authority.entries.map((entry) => entry.project);
|
|
1688
1717
|
|
|
1689
1718
|
const version = args["version"];
|
|
1690
1719
|
if (typeof version !== "string" || version.trim().length === 0) {
|
|
@@ -1695,16 +1724,21 @@ async function installVerb(
|
|
|
1695
1724
|
);
|
|
1696
1725
|
}
|
|
1697
1726
|
|
|
1698
|
-
const outcome = await deps.actions.install({
|
|
1727
|
+
const outcome = await deps.actions.install({
|
|
1728
|
+
version: version.trim(),
|
|
1729
|
+
holder: globalHolder,
|
|
1730
|
+
projects: affectedProjects,
|
|
1731
|
+
});
|
|
1699
1732
|
if (!outcome.ok) {
|
|
1700
1733
|
return refuse("action-failed", `refused: the install request failed:\n${outcome.stderr}`);
|
|
1701
1734
|
}
|
|
1702
1735
|
const unit = outcome.detail;
|
|
1703
1736
|
return allow(
|
|
1704
|
-
`install requested: omp-conductor@${version} will be installed
|
|
1737
|
+
`install requested by host-global holder ${globalHolder}: omp-conductor@${version} will be installed ` +
|
|
1738
|
+
`for projects ${affectedProjects.join(", ")} by a detached unit` +
|
|
1705
1739
|
`${unit === undefined ? "" : ` (${unit})`} that survives the restart. ` +
|
|
1706
1740
|
"The first tick after the fleet restarts verifies version, /healthz, ticks, pane and doctor, " +
|
|
1707
|
-
"and the outcome is reported through the durable outbox.",
|
|
1741
|
+
"and the outcome is reported through the durable outbox.",
|
|
1708
1742
|
);
|
|
1709
1743
|
}
|
|
1710
1744
|
|
|
@@ -61,6 +61,7 @@ PROG=omp-conductor-recover
|
|
|
61
61
|
SYSTEMCTL=${SYSTEMCTL_BIN:-systemctl}
|
|
62
62
|
JOURNALCTL=${JOURNALCTL_BIN:-journalctl}
|
|
63
63
|
CONDUCTOR=${OMP_CONDUCTOR_BIN:-omp-conductor}
|
|
64
|
+
BUN=${BUN_BIN:-bun}
|
|
64
65
|
|
|
65
66
|
# The fleet's conductor home: where config.json, the sqlite store and the
|
|
66
67
|
# upgrade's durable snapshots live. The unit renders OMP_CONDUCTOR_HOME and
|
|
@@ -84,6 +85,12 @@ JOURNAL_LINES=${RECOVER_JOURNAL_LINES:-200}
|
|
|
84
85
|
VERIFY_POLL_S=${RECOVER_VERIFY_POLL_S:-2}
|
|
85
86
|
VERIFY_TRIES=${RECOVER_VERIFY_TRIES:-10}
|
|
86
87
|
PROJECT=${RECOVER_PROJECT:-}
|
|
88
|
+
# Recovery action scope and durable-report ownership are deliberately separate.
|
|
89
|
+
# `RECOVER_PROJECT` is only safe on a single-project host; the report still
|
|
90
|
+
# needs one configured project to own its outbox row on a shared host.
|
|
91
|
+
REPORT_PROJECT=${RECOVER_REPORT_PROJECT:-$PROJECT}
|
|
92
|
+
AFFECTED_PROJECTS=${RECOVER_PROJECTS:-$PROJECT}
|
|
93
|
+
REPORT_ROUTING_NOTE=
|
|
87
94
|
# The bound on the post-restore re-arm. `arm` with a claim-only proof performs
|
|
88
95
|
# no Telegram send or wait, so it completes in milliseconds; the timeout exists
|
|
89
96
|
# so a misbehaving conductor can never hang this bounded playbook (#613).
|
|
@@ -190,6 +197,40 @@ append_evidence() { # <evidence path> <text>
|
|
|
190
197
|
printf '%s\n' "$2" >>"$1" 2>/dev/null || true
|
|
191
198
|
}
|
|
192
199
|
|
|
200
|
+
|
|
201
|
+
# Resolve the host-global report owner from the live machine-written config.
|
|
202
|
+
# Bun is not an extra prerequisite: omp-conductor itself runs under Bun, and
|
|
203
|
+
# the recovery unit's canonical PATH already contains that executable. When a
|
|
204
|
+
# manual/degraded invocation cannot read the config, the script still attempts
|
|
205
|
+
# the legacy unscoped report and records that degradation in the evidence.
|
|
206
|
+
resolve_report_scope() {
|
|
207
|
+
if [[ -n $REPORT_PROJECT && -n $AFFECTED_PROJECTS ]]; then
|
|
208
|
+
return 0
|
|
209
|
+
fi
|
|
210
|
+
local lines first all
|
|
211
|
+
if lines=$(
|
|
212
|
+
"$BUN" -e '
|
|
213
|
+
const path = Bun.argv.at(-1);
|
|
214
|
+
const config = await Bun.file(path).json();
|
|
215
|
+
const names = Array.isArray(config.projects)
|
|
216
|
+
? config.projects.map((project) => project?.name).filter((name) => typeof name === "string" && name.length > 0)
|
|
217
|
+
: [];
|
|
218
|
+
if (names.length === 0) process.exit(2);
|
|
219
|
+
console.log(names[0]);
|
|
220
|
+
console.log(names.join(","));
|
|
221
|
+
' "$CONFIG_PATH" 2>/dev/null
|
|
222
|
+
); then
|
|
223
|
+
first=${lines%%$'\n'*}
|
|
224
|
+
all=${lines#*$'\n'}
|
|
225
|
+
REPORT_PROJECT=${REPORT_PROJECT:-$first}
|
|
226
|
+
AFFECTED_PROJECTS=${AFFECTED_PROJECTS:-$all}
|
|
227
|
+
fi
|
|
228
|
+
if [[ -z $REPORT_PROJECT || -z $AFFECTED_PROJECTS ]]; then
|
|
229
|
+
AFFECTED_PROJECTS=${AFFECTED_PROJECTS:-unknown}
|
|
230
|
+
REPORT_ROUTING_NOTE="configured project resolution failed for $CONFIG_PATH; attempting the report without a project selector"
|
|
231
|
+
fi
|
|
232
|
+
return 0
|
|
233
|
+
}
|
|
193
234
|
# --------------------------------------------------------------------------
|
|
194
235
|
# escalation — always, through the durable outbox (#123, #288)
|
|
195
236
|
# --------------------------------------------------------------------------
|
|
@@ -199,23 +240,27 @@ append_evidence() { # <evidence path> <text>
|
|
|
199
240
|
# (the durable outbox) before anything is sent, and the daemon owns delivery
|
|
200
241
|
# with bounded retries once it is back. This script only ever enqueues.
|
|
201
242
|
escalation_text() { # <failed-unit or empty> <action> <outcome> <evidence>
|
|
243
|
+
local projects=${AFFECTED_PROJECTS:-unknown}
|
|
202
244
|
if [[ ${1:-none} == none ]]; then
|
|
203
245
|
printf '%s\n' \
|
|
204
|
-
"fleet recovery (#485): a recovery trigger fired but no fleet unit was failing when it ran — the fleet got itself up or was started externally; evidence=$4"
|
|
246
|
+
"fleet recovery (#485): a recovery trigger fired but no fleet unit was failing when it ran — the fleet got itself up or was started externally; affected-projects=$projects; evidence=$4"
|
|
205
247
|
return
|
|
206
248
|
fi
|
|
207
249
|
printf '%s\n' \
|
|
208
|
-
"fleet recovery (#485): $1 entered failed state; recovery=${2:-none}; outcome=${3}; evidence=${4}; next: read the evidence file (and its doctor output) before touching the fleet"
|
|
250
|
+
"fleet recovery (#485): $1 entered failed state; affected-projects=$projects; recovery=${2:-none}; outcome=${3}; evidence=${4}; next: read the evidence file (and its doctor output) before touching the fleet"
|
|
209
251
|
}
|
|
210
252
|
|
|
211
253
|
escalate() { # <failed-unit or empty> <action-name> <outcome> <evidence-path>
|
|
212
254
|
local unit=${1:-none} text args
|
|
213
|
-
|
|
214
|
-
if [[ -n $
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
args=("$CONDUCTOR" report --kind tier2 --text "$text")
|
|
255
|
+
resolve_report_scope
|
|
256
|
+
if [[ -n $REPORT_ROUTING_NOTE ]]; then
|
|
257
|
+
log "$REPORT_ROUTING_NOTE"
|
|
258
|
+
append_evidence "$4" "report routing: $REPORT_ROUTING_NOTE"
|
|
218
259
|
fi
|
|
260
|
+
text=$(escalation_text "$unit" "$2" "$3" "$4")
|
|
261
|
+
args=("$CONDUCTOR" report --kind tier2)
|
|
262
|
+
[[ -n $REPORT_PROJECT ]] && args+=(--project "$REPORT_PROJECT")
|
|
263
|
+
args+=(--text "$text")
|
|
219
264
|
say "escalation: ${args[*]}"
|
|
220
265
|
if dry_run; then
|
|
221
266
|
return 0
|
|
@@ -365,6 +410,9 @@ run_recovery() {
|
|
|
365
410
|
done
|
|
366
411
|
|
|
367
412
|
if dry_run; then
|
|
413
|
+
resolve_report_scope
|
|
414
|
+
say "plan: report owner — ${REPORT_PROJECT:-unscoped}; affected projects — $AFFECTED_PROJECTS"
|
|
415
|
+
[[ -n $REPORT_ROUTING_NOTE ]] && say "plan: report routing degraded — $REPORT_ROUTING_NOTE"
|
|
368
416
|
say "dry run: nothing will be written or executed"
|
|
369
417
|
[[ -z $failed ]] && say "plan: no unit failed — escalate only"
|
|
370
418
|
if [[ -n $failed ]]; then
|
|
@@ -50,6 +50,7 @@ fi
|
|
|
50
50
|
|
|
51
51
|
tmp=$(mktemp -d "${TMPDIR:-/tmp}/omp-recover-test.XXXXXX")
|
|
52
52
|
trap 'rm -rf "$tmp"' EXIT
|
|
53
|
+
bun_bin=$(command -v bun)
|
|
53
54
|
mkdir -p "$tmp/bin"
|
|
54
55
|
|
|
55
56
|
# --------------------------------------------------------------------------
|
|
@@ -156,6 +157,18 @@ write_project_config() { # <case-dir> <proof>
|
|
|
156
157
|
printf '{\n "version": 2,\n "projects": [\n { "name": "demo", "arm": { "proof": "%s" } }\n ]\n}\n' "$2" >"$1/state/config.json"
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
write_multi_project_config() { # <case-dir>
|
|
161
|
+
cat >"$1/state/config.json" <<'JSON'
|
|
162
|
+
{
|
|
163
|
+
"version": 2,
|
|
164
|
+
"projects": [
|
|
165
|
+
{ "name": "alpha" },
|
|
166
|
+
{ "name": "beta" }
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
JSON
|
|
170
|
+
}
|
|
171
|
+
|
|
159
172
|
run_recover() { # <case-dir> [env assignments…]
|
|
160
173
|
local d="$1"
|
|
161
174
|
shift
|
|
@@ -182,7 +195,9 @@ run_recover() { # <case-dir> [env assignments…]
|
|
|
182
195
|
# marks the named unit active, which is what a real peer restart does to a
|
|
183
196
|
# healthy peer), so tests wanting daemon-side healing pass HEAL_ON_START=<unit>
|
|
184
197
|
# and the stub's `start` flips that unit back to active.
|
|
185
|
-
run_with_heal() { # <case-dir> <heal-on-start>
|
|
198
|
+
run_with_heal() { # <case-dir> <heal-on-start> [env assignments…]
|
|
199
|
+
local d="$1" heal="$2"
|
|
200
|
+
shift 2
|
|
186
201
|
(
|
|
187
202
|
cd "$here" || exit 1
|
|
188
203
|
env \
|
|
@@ -190,14 +205,15 @@ run_with_heal() { # <case-dir> <heal-on-start>
|
|
|
190
205
|
SYSTEMCTL_BIN="$tmp/bin/systemctl" \
|
|
191
206
|
JOURNALCTL_BIN="$tmp/bin/journalctl" \
|
|
192
207
|
OMP_CONDUCTOR_BIN="$tmp/bin/omp-conductor" \
|
|
193
|
-
RECOVER_STATE_DIR="$
|
|
208
|
+
RECOVER_STATE_DIR="$d/state" \
|
|
194
209
|
RECOVER_PROJECT=demo \
|
|
195
210
|
RECOVER_VERIFY_POLL_S=0 \
|
|
196
211
|
RECOVER_VERIFY_TRIES=3 \
|
|
197
|
-
STUB_CALLS="$
|
|
198
|
-
STUB_UNITS="$
|
|
199
|
-
STUB_HEAL_ON_START="$
|
|
200
|
-
|
|
212
|
+
STUB_CALLS="$d/calls" \
|
|
213
|
+
STUB_UNITS="$d/units" \
|
|
214
|
+
STUB_HEAL_ON_START="$heal" \
|
|
215
|
+
"$@" \
|
|
216
|
+
bash "$recover" >"$d/out" 2>"$d/stderr"
|
|
201
217
|
)
|
|
202
218
|
echo $?
|
|
203
219
|
}
|
|
@@ -412,6 +428,83 @@ check 'a trigger with nothing failed escalates (fleet came back)' \
|
|
|
412
428
|
check 'a trigger with nothing failed exits 0' '0' "$code"
|
|
413
429
|
check 'a trigger with nothing failed clears the old counter' '0' "$(marker "$d")"
|
|
414
430
|
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
# case — a host-global recovery on a multi-project config still has one
|
|
433
|
+
# deterministic outbox owner, while its body names every affected project.
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
d=$(newcase multi-project-report)
|
|
437
|
+
write_multi_project_config "$d"
|
|
438
|
+
unit_state "$d" omp-conductor.service active
|
|
439
|
+
unit_state "$d" herdr-fleet.service active
|
|
440
|
+
code=$(run_recover "$d" \
|
|
441
|
+
RECOVER_PROJECT= \
|
|
442
|
+
RECOVER_REPORT_PROJECT= \
|
|
443
|
+
RECOVER_PROJECTS= \
|
|
444
|
+
BUN_BIN="$bun_bin")
|
|
445
|
+
report_call=$(grep '^omp-conductor report' "$d/calls" | head -n 1)
|
|
446
|
+
check 'multi-project recovery exits after queuing its report' '0' "$code"
|
|
447
|
+
check 'multi-project recovery uses the first configured outbox owner' \
|
|
448
|
+
'--project alpha' "$report_call"
|
|
449
|
+
check 'multi-project recovery names every affected project' \
|
|
450
|
+
'affected-projects=alpha,beta' "$report_call"
|
|
451
|
+
check 'multi-project recovery queues exactly one report' \
|
|
452
|
+
'1' "$(grep -c '^omp-conductor report' "$d/calls")"
|
|
453
|
+
|
|
454
|
+
d=$(newcase multi-project-recovery-action)
|
|
455
|
+
write_multi_project_config "$d"
|
|
456
|
+
unit_state "$d" omp-conductor.service active
|
|
457
|
+
unit_state "$d" herdr-fleet.service failed
|
|
458
|
+
code=$(run_with_heal "$d" herdr-fleet.service \
|
|
459
|
+
RECOVER_PROJECT= \
|
|
460
|
+
RECOVER_REPORT_PROJECT= \
|
|
461
|
+
RECOVER_PROJECTS= \
|
|
462
|
+
BUN_BIN="$bun_bin")
|
|
463
|
+
report_call=$(grep '^omp-conductor report' "$d/calls" | head -n 1)
|
|
464
|
+
check 'verified multi-project recovery action exits cleanly' '0' "$code"
|
|
465
|
+
check 'verified multi-project recovery action queues one scoped report' \
|
|
466
|
+
'1' "$(grep -c '^omp-conductor report' "$d/calls")"
|
|
467
|
+
check 'verified multi-project recovery action uses configured owner' \
|
|
468
|
+
'--project alpha' "$report_call"
|
|
469
|
+
check 'verified multi-project recovery action names every project' \
|
|
470
|
+
'affected-projects=alpha,beta' "$report_call"
|
|
471
|
+
|
|
472
|
+
d=$(newcase multi-project-dry-run)
|
|
473
|
+
write_multi_project_config "$d"
|
|
474
|
+
unit_state "$d" omp-conductor.service active
|
|
475
|
+
unit_state "$d" herdr-fleet.service failed
|
|
476
|
+
code=$(run_recover "$d" \
|
|
477
|
+
RECOVER_PROJECT= \
|
|
478
|
+
RECOVER_REPORT_PROJECT= \
|
|
479
|
+
RECOVER_PROJECTS= \
|
|
480
|
+
RECOVER_DRY_RUN=1 \
|
|
481
|
+
BUN_BIN="$bun_bin")
|
|
482
|
+
check 'multi-project dry run exits cleanly' '0' "$code"
|
|
483
|
+
check 'multi-project dry run names its report owner' \
|
|
484
|
+
'plan: report owner — alpha' "$(cat "$d/out")"
|
|
485
|
+
check 'multi-project dry run names every affected project' \
|
|
486
|
+
'affected projects — alpha,beta' "$(cat "$d/out")"
|
|
487
|
+
|
|
488
|
+
d=$(newcase report-routing-fallback)
|
|
489
|
+
unit_state "$d" omp-conductor.service active
|
|
490
|
+
unit_state "$d" herdr-fleet.service active
|
|
491
|
+
code=$(run_recover "$d" \
|
|
492
|
+
RECOVER_PROJECT= \
|
|
493
|
+
RECOVER_REPORT_PROJECT= \
|
|
494
|
+
RECOVER_PROJECTS= \
|
|
495
|
+
BUN_BIN="$bun_bin")
|
|
496
|
+
report_call=$(grep '^omp-conductor report' "$d/calls" | head -n 1)
|
|
497
|
+
check 'unreadable config routing still attempts a durable report' \
|
|
498
|
+
'omp-conductor report --kind tier2 --text' "$report_call"
|
|
499
|
+
if [[ $report_call == *"--project "* ]]; then
|
|
500
|
+
fail "routing fallback does not guess an unconfigured project — $report_call"
|
|
501
|
+
else
|
|
502
|
+
pass 'routing fallback does not guess an unconfigured project'
|
|
503
|
+
fi
|
|
504
|
+
check 'routing fallback remains a successful recovered run' '0' "$code"
|
|
505
|
+
check 'routing fallback is preserved in durable evidence' \
|
|
506
|
+
'attempting the report without a project selector' "$(cat "$d/state/recovery"/evidence-*)"
|
|
507
|
+
|
|
415
508
|
# ---------------------------------------------------------------------------
|
|
416
509
|
# case DRY-RUN — the operator's rehearsal mode prints decisions and mutates
|
|
417
510
|
# nothing, not even the recovery dir or the fixture call log.
|