omp-conductor 0.15.9 → 0.15.11

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.
Files changed (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # omp-conductor fleet recovery — offline tests for omp/systemd/omp-conductor-recover.sh
4
+ #
5
+ # No network, no real systemctl/journalctl/omp-conductor, and never a touch on
6
+ # this host's units. Every external call goes through stub binaries on a
7
+ # fixture PATH; a fixture state dir drives the unit state machine, so what is
8
+ # under test is the playbook's decision: evidence written before anything,
9
+ # exactly one bounded action from the fixed set, always an escalation through
10
+ # the outbox, and no loop.
11
+ #
12
+ # Prints one line per case and exits non-zero if any of them fails.
13
+
14
+ set -uo pipefail
15
+
16
+ here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
17
+ recover="$here/omp-conductor-recover.sh"
18
+
19
+ failures=0
20
+ pass() { printf 'ok %s\n' "$1"; }
21
+ fail() {
22
+ printf 'FAIL %s\n' "$1"
23
+ failures=$((failures + 1))
24
+ }
25
+
26
+ check() { # <name> <expected substring> <actual>
27
+ if [[ $3 == *"$2"* ]]; then
28
+ pass "$1"
29
+ else
30
+ fail "$1"
31
+ printf ' expected substring: %s\n' "$2"
32
+ printf ' actual: %s\n' "$3"
33
+ fi
34
+ }
35
+
36
+ # --------------------------------------------------------------------------
37
+ # syntax
38
+ # --------------------------------------------------------------------------
39
+
40
+ syntax_errors=$(
41
+ bash -n "$recover" 2>&1
42
+ bash -n "$here/recover-unit-test.sh" 2>&1
43
+ )
44
+ if [[ -z $syntax_errors ]]; then
45
+ pass 'syntax: recovery playbook and test suite'
46
+ else
47
+ fail 'syntax: recovery playbook and test suite'
48
+ printf '%s\n' "$syntax_errors"
49
+ fi
50
+
51
+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/omp-recover-test.XXXXXX")
52
+ trap 'rm -rf "$tmp"' EXIT
53
+ mkdir -p "$tmp/bin"
54
+
55
+ # --------------------------------------------------------------------------
56
+ # fixture stubs. The systemctl stub reads per-unit state files so tests can
57
+ # decide which unit is failed and whether `start` heals it; every call is
58
+ # logged so tests pin call ORDER (evidence before action) and the single
59
+ # action rule.
60
+ # --------------------------------------------------------------------------
61
+
62
+ cat >"$tmp/bin/systemctl" <<'STUB'
63
+ #!/usr/bin/env bash
64
+ echo "systemctl $*" >>"$STUB_CALLS"
65
+ unit="$2"
66
+ state_path="$STUB_UNITS/$unit"
67
+ state=$(cat "$state_path" 2>/dev/null || echo absent)
68
+ case "$1" in
69
+ is-failed)
70
+ [[ $state == failed ]] && echo failed || echo inactive ;;
71
+ is-active)
72
+ if [[ $state == failed ]]; then echo failed; elif [[ $state == absent ]]; then echo inactive; else echo active; fi ;;
73
+ reset-failed) : ;;
74
+ start)
75
+ [[ $unit == "$STUB_HEAL_ON_START" ]] && echo active > "$state_path" ;;
76
+ restart)
77
+ echo active > "$state_path" ;;
78
+ *)
79
+ echo "unexpected systemctl $*" >&2 ;;
80
+ esac
81
+ STUB
82
+
83
+ cat >"$tmp/bin/journalctl" <<'STUB'
84
+ #!/usr/bin/env bash
85
+ echo "journalctl $*" >>"$STUB_CALLS"
86
+ echo "journal: fleet crashed here"
87
+ echo "journal: last line before death"
88
+ STUB
89
+
90
+ cat >"$tmp/bin/omp-conductor" <<'STUB'
91
+ #!/usr/bin/env bash
92
+ echo "omp-conductor $*" >>"$STUB_CALLS"
93
+ case "$1" in
94
+ doctor)
95
+ printf '%s\n' '{"project":"demo","status":"fail","findings":[{"id":"systemd-unit","status":"fail","summary":"installed differs"}]}'
96
+ ;;
97
+ --version)
98
+ echo "0.15.11"
99
+ ;;
100
+ report)
101
+ echo "report 485-abc queued for demo (tier2)"
102
+ ;;
103
+ *)
104
+ exit 2 ;;
105
+ esac
106
+ STUB
107
+
108
+ chmod +x "$tmp/bin/"*
109
+
110
+ # --------------------------------------------------------------------------
111
+ # fixture helpers
112
+ # --------------------------------------------------------------------------
113
+
114
+ newcase() { # <name> -> case dir
115
+ local d="$tmp/case-$1"
116
+ mkdir -p "$d/state/backups/config" "$d/units"
117
+ printf '%s\n' "$d"
118
+ }
119
+
120
+ unit_state() { # <case-dir> <unit> <state|absent>
121
+ if [[ $3 == absent ]]; then
122
+ rm -f "$1/units/$2"
123
+ else
124
+ printf '%s\n' "$3" >"$1/units/$2"
125
+ fi
126
+ }
127
+
128
+ # A pre-upgrade snapshot `upgrade` durably keeps (configBackupDir()):
129
+ # config.json.pre-upgrade-<ts>. The test pass the age in seconds.
130
+ write_snapshot() { # <case-dir> <age-seconds>
131
+ local d="$1" age="${2:-0}" now
132
+ now=$(date +%s)
133
+ local snap="$d/state/backups/config/config.json.pre-upgrade-$now"
134
+ printf '%s\n' '{"preUpgrade":true}' >"$snap"
135
+ if (( age > 0 )); then
136
+ touch -d "@$(( now - age ))" "$snap"
137
+ fi
138
+ printf '%s\n' "$snap"
139
+ }
140
+
141
+ marker() { # <case-dir> -> attempts count or 0
142
+ local f="$1/state/recovery/attempts"
143
+ if [[ -f $f ]]; then
144
+ sed -n '1p' "$f"
145
+ else
146
+ echo 0
147
+ fi
148
+ }
149
+
150
+ run_recover() { # <case-dir> [env assignments…]
151
+ local d="$1"
152
+ shift
153
+ (
154
+ cd "$here" || exit 1
155
+ env \
156
+ PATH="$tmp/bin:/usr/bin:/bin" \
157
+ SYSTEMCTL_BIN="$tmp/bin/systemctl" \
158
+ JOURNALCTL_BIN="$tmp/bin/journalctl" \
159
+ OMP_CONDUCTOR_BIN="$tmp/bin/omp-conductor" \
160
+ RECOVER_STATE_DIR="$d/state" \
161
+ RECOVER_PROJECT=demo \
162
+ RECOVER_VERIFY_POLL_S=0 \
163
+ RECOVER_VERIFY_TRIES=3 \
164
+ STUB_CALLS="$d/calls" \
165
+ STUB_UNITS="$d/units" \
166
+ "$@" \
167
+ bash "$recover" >"$d/out" 2>"$d/stderr"
168
+ )
169
+ echo $?
170
+ }
171
+
172
+ # `systemctl restart` never heals the FAILED unit in this fixture (it only
173
+ # marks the named unit active, which is what a real peer restart does to a
174
+ # healthy peer), so tests wanting daemon-side healing pass HEAL_ON_START=<unit>
175
+ # and the stub's `start` flips that unit back to active.
176
+ run_with_heal() { # <case-dir> <heal-on-start>
177
+ (
178
+ cd "$here" || exit 1
179
+ env \
180
+ PATH="$tmp/bin:/usr/bin:/bin" \
181
+ SYSTEMCTL_BIN="$tmp/bin/systemctl" \
182
+ JOURNALCTL_BIN="$tmp/bin/journalctl" \
183
+ OMP_CONDUCTOR_BIN="$tmp/bin/omp-conductor" \
184
+ RECOVER_STATE_DIR="$1/state" \
185
+ RECOVER_PROJECT=demo \
186
+ RECOVER_VERIFY_POLL_S=0 \
187
+ RECOVER_VERIFY_TRIES=3 \
188
+ STUB_CALLS="$1/calls" \
189
+ STUB_UNITS="$1/units" \
190
+ STUB_HEAL_ON_START="$2" \
191
+ bash "$recover" >"$1/out" 2>"$1/stderr"
192
+ )
193
+ echo $?
194
+ }
195
+
196
+ # --------------------------------------------------------------------------
197
+ # the decision functions, driven directly. The unit text holds the other half
198
+ # of the bounds (StartLimitBurst=2, no OnFailure=) — setup-host.test.ts pins it.
199
+ # --------------------------------------------------------------------------
200
+
201
+ SCAFFOLD=$(newcase decisions)
202
+ FLEET_UNITS=herdr-fleet.service
203
+ RECOVER_STATE_DIR="$SCAFFOLD/state"
204
+ . "$recover"
205
+
206
+ # RECOVER_PANE is the only action for a failed herdr-fleet unit: herdr's own
207
+ # startup hook recovers the fleet pane, so the pane leg is `start herdr`.
208
+ check 'herdr failure chooses RECOVER_PANE' \
209
+ 'RECOVER_PANE|herdr-fleet.service' "$(choose_action herdr-fleet.service)"
210
+
211
+ D2=$(newcase decisions-daemon)
212
+ RECOVER_STATE_DIR="$D2/state"
213
+ BACKUP_ROOT="$D2/state/backups/config"
214
+
215
+ # The daemon's action hinges on a fresh pre-upgrade snapshot: with one, the
216
+ # mechanical conclusion is "the upgrade did it" and the action is ROLLBACK.
217
+ write_snapshot "$D2" 0 >/dev/null
218
+ check 'a fresh pre-upgrade snapshot chooses ROLLBACK' \
219
+ "ROLLBACK|$BACKUP_ROOT/config.json.pre-upgrade-" "$(choose_action omp-conductor.service)"
220
+
221
+ # Without one, the only safe motion is restarting the peer unit.
222
+ rm -f "$BACKUP_ROOT"/config.json.pre-upgrade-*
223
+ check 'no snapshot chooses RESTART_PEER' \
224
+ 'RESTART_PEER|herdr-fleet.service' "$(choose_action omp-conductor.service)"
225
+
226
+ # A snapshot too old to implicate THIS failure is not evidence.
227
+ write_snapshot "$D2" 7200 >/dev/null
228
+ check 'a stale snapshot does not choose ROLLBACK' \
229
+ 'RESTART_PEER|herdr-fleet.service' "$(choose_action omp-conductor.service)"
230
+ unset FLEET_UNITS RECOVER_STATE_DIR BACKUP_ROOT
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # case A — RECOVER_PANE: herdr-fleet failed and the start heals it in time.
234
+ # Evidence must be written before a single systemd mutation is attempted.
235
+ # ---------------------------------------------------------------------------
236
+
237
+ d=$(newcase recover-pane)
238
+ unit_state "$d" herdr-fleet.service failed
239
+ unit_state "$d" omp-conductor.service active
240
+ code=$(run_with_heal "$d" herdr-fleet.service)
241
+ check 'RECOVER_PANE exits 0 when the unit heals' '0' "$code"
242
+ check 'RECOVER_PANE reports success and names the pane hook' \
243
+ 'startup hook recovers the fleet pane' "$(grep 'outcome:' "$d/out")"
244
+
245
+ # Order the acceptance pins: journal + doctor + version (evidence) strictly
246
+ # before the first systemd mutation (reset-failed/start).
247
+ journal_line=$(grep -n '^journalctl' "$d/calls" | head -n 1 | cut -d: -f1 | tr -d ' ')
248
+ reset_line=$(grep -n '^systemctl reset-failed' "$d/calls" | head -n 1 | cut -d: -f1 | tr -d ' ')
249
+ if [[ -n $journal_line && -n $reset_line && $journal_line -lt $reset_line ]]; then
250
+ pass 'evidence collection precedes the single action'
251
+ else
252
+ fail "evidence collection precedes the single action (journal=$journal_line reset=$reset_line)"
253
+ fi
254
+
255
+ # Exactly one restorative action: one reset-failed + one start, no restart.
256
+ starts=$(grep -c '^systemctl start ' "$d/calls" || true)
257
+ resets=$(grep -c '^systemctl reset-failed ' "$d/calls" || true)
258
+ restarts=$(grep -c '^systemctl restart ' "$d/calls" || true)
259
+ if [[ $starts == 1 && $resets == 1 && $restarts == 0 ]]; then
260
+ pass 'exactly one restorative action (start herdr-fleet)'
261
+ else
262
+ fail "exactly one restorative action — starts=$starts resets=$resets restarts=$restarts"
263
+ fi
264
+
265
+ check 'successful recovery still escalates tier-2 through the outbox' \
266
+ 'omp-conductor report --kind tier2' "$(grep '^omp-conductor report' "$d/calls" | head -n 1)"
267
+ check 'the escalation names the durable evidence file' \
268
+ "$d/state/recovery/evidence-" "$(grep '^omp-conductor report' "$d/calls" | head -n 1)"
269
+ # The evidence bundle itself carries the collected facts.
270
+ ev=$(ls "$d/state/recovery"/evidence-* | head -n 1)
271
+ check 'the evidence bundle holds unit state, doctor JSON, versions and journal' \
272
+ '=== omp-conductor fleet recovery evidence' "$(head -n 1 "$ev")"
273
+ check 'the evidence bundle names the failed unit' \
274
+ 'herdr-fleet.service' "$(grep '^failed unit:' "$ev")"
275
+ check 'the evidence bundle carries the doctor output' \
276
+ '"systemd-unit"' "$(grep 'installed differs' "$ev")"
277
+ check 'the evidence bundle carries the journal tail' \
278
+ 'journal: last line before death' "$(grep 'journal:' "$ev")"
279
+
280
+ [[ $(marker "$d") == 0 ]]
281
+ pass 'the attempts counter is cleared after a successful recovery'
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # case ROLLBACK: daemon failed AND a fresh pre-upgrade snapshot exists.
285
+ # ---------------------------------------------------------------------------
286
+
287
+ d=$(newcase rollback)
288
+ write_snapshot "$d" 5 >/dev/null
289
+ printf '%s\n' '{"live":true}' >"$d/state/config.json"
290
+ unit_state "$d" omp-conductor.service failed
291
+ unit_state "$d" herdr-fleet.service active
292
+ code=$(run_with_heal "$d" omp-conductor.service)
293
+ check 'ROLLBACK exits 0 when the daemon heals' '0' "$code"
294
+ check 'ROLLBACK restores the pre-upgrade bytes over config.json' \
295
+ '{"preUpgrade":true}' "$(cat "$d/state/config.json")"
296
+ check 'ROLLBACK starts the daemon unit' \
297
+ 'systemctl start omp-conductor.service' "$(grep '^systemctl start ' "$d/calls" | head -n 1)"
298
+ check 'ROLLBACK escalates as well' \
299
+ 'omp-conductor report --kind tier2' "$(grep '^omp-conductor report' "$d/calls" | tail -n 1)"
300
+ check 'the pre-upgrade snapshot survives the rollback for the operator' \
301
+ '{"preUpgrade":true}' "$(cat "$d/state/backups/config"/config.json.pre-upgrade-*)"
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # case RESTART_PEER and the bound: attempts cap at two, then escalate-only.
305
+ # A daemon crash with no upgrade evidence must never get a blind daemon start.
306
+ # ---------------------------------------------------------------------------
307
+
308
+ d=$(newcase restart-peer)
309
+ unit_state "$d" omp-conductor.service failed
310
+ unit_state "$d" herdr-fleet.service active
311
+
312
+ code=$(run_recover "$d")
313
+ check 'RESTART_PEER restarts exactly the peer unit and nothing else' \
314
+ 'systemctl restart herdr-fleet.service' "$(cat "$d/calls")"
315
+ check 'RESTART_PEER never starts the failed daemon' \
316
+ '' "$(grep '^systemctl start omp-conductor' "$d/calls" || true)"
317
+ check 'a peer restart that leaves the daemon failed exits 1' '1' "$code"
318
+ check 'the failed attempt is recorded' '1' "$(marker "$d")"
319
+
320
+ # A second trigger in the same episode is one more attempt, not a loop…
321
+ code=$(run_recover "$d")
322
+ check 'a second attempt is allowed below the cap and also exits 1' '1' "$code"
323
+ check 'two failed attempts are recorded' '2' "$(marker "$d")"
324
+
325
+ # …and the third trigger only escalates: no mutating systemd call at all
326
+ # (is-failed probes for evidence are reads, not actions).
327
+ mutating() { grep -c '^systemctl \(start\|restart\|reset-failed\) ' "$1" || true; }
328
+ before=$(mutating "$d/calls")
329
+ code=$(run_recover "$d")
330
+ after=$(mutating "$d/calls")
331
+ check 'attempts exhausted — the run says so' \
332
+ 'attempts exhausted' "$(cat "$d/out")"
333
+ check 'an exhausted run performs no systemd action' \
334
+ "$before" "$after"
335
+ check 'the escalation is still queued after exhaustion' \
336
+ 'omp-conductor report --kind tier2' "$(grep '^omp-conductor report' "$d/calls" | tail -n 1)"
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # case — nothing failed when recovery ran (the "got itself up" case): always
340
+ # escalate, and a previously exhausted episode counter gets a fresh start.
341
+ # ---------------------------------------------------------------------------
342
+
343
+ d=$(newcase nothing-failed)
344
+ mkdir -p "$d/state/recovery"
345
+ printf '3\n%s\n' "$(date +%s)" >"$d/state/recovery/attempts"
346
+ unit_state "$d" omp-conductor.service active
347
+ unit_state "$d" herdr-fleet.service active
348
+ code=$(run_recover "$d")
349
+ check 'a trigger with nothing failed escalates (fleet came back)' \
350
+ 'no unit failed' "$(cat "$d/out")"
351
+ check 'a trigger with nothing failed exits 0' '0' "$code"
352
+ check 'a trigger with nothing failed clears the old counter' '0' "$(marker "$d")"
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # case DRY-RUN — the operator's rehearsal mode prints decisions and mutates
356
+ # nothing, not even the recovery dir or the fixture call log.
357
+ # ---------------------------------------------------------------------------
358
+
359
+ d=$(newcase dry-run)
360
+ unit_state "$d" herdr-fleet.service failed
361
+ unit_state "$d" omp-conductor.service active
362
+ (
363
+ cd "$here" || exit 1
364
+ env \
365
+ PATH="$tmp/bin:/usr/bin:/bin" \
366
+ SYSTEMCTL_BIN="$tmp/bin/systemctl" \
367
+ JOURNALCTL_BIN="$tmp/bin/journalctl" \
368
+ OMP_CONDUCTOR_BIN="$tmp/bin/omp-conductor" \
369
+ RECOVER_STATE_DIR="$d/state" \
370
+ RECOVER_PROJECT=demo \
371
+ RECOVER_DRY_RUN=1 \
372
+ STUB_CALLS="$d/calls" \
373
+ STUB_UNITS="$d/units" \
374
+ bash "$recover" >"$d/out" 2>"$d/stderr"
375
+ )
376
+ check 'a dry run names the action and the escalation' \
377
+ 'action: RECOVER_PANE' "$(cat "$d/out")"
378
+ check 'a dry run performs no systemctl call' \
379
+ '' "$(cat "$d/calls" 2>/dev/null || true)"
380
+ check 'a dry run writes no evidence or counter' \
381
+ '' "$(ls "$d/state/recovery" 2>/dev/null || true)"
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # case — escalation failure fails the recovery (bounded), evidence still there.
385
+ # ---------------------------------------------------------------------------
386
+
387
+ d=$(newcase escalation-fail)
388
+ mkdir -p "$d/bin"
389
+ cat >"$d/bin/omp-conductor" <<'STUB'
390
+ #!/usr/bin/env bash
391
+ echo "omp-conductor $*" >>"$STUB_CALLS"
392
+ case "$1" in
393
+ doctor) echo '{"status":"pass"}'; exit 0 ;;
394
+ --version) echo "0.15.11" ;;
395
+ report) exit 3 ;;
396
+ esac
397
+ STUB
398
+ chmod +x "$d/bin/omp-conductor"
399
+ unit_state "$d" herdr-fleet.service failed
400
+ unit_state "$d" omp-conductor.service absent
401
+ (
402
+ cd "$here" || exit 1
403
+ env \
404
+ PATH="$tmp/bin:/usr/bin:/bin" \
405
+ SYSTEMCTL_BIN="$tmp/bin/systemctl" \
406
+ JOURNALCTL_BIN="$tmp/bin/journalctl" \
407
+ OMP_CONDUCTOR_BIN="$d/bin/omp-conductor" \
408
+ RECOVER_STATE_DIR="$d/state" \
409
+ RECOVER_PROJECT=demo \
410
+ RECOVER_VERIFY_POLL_S=0 \
411
+ RECOVER_VERIFY_TRIES=2 \
412
+ STUB_CALLS="$d/calls" \
413
+ STUB_UNITS="$d/units" \
414
+ bash "$recover" >"$d/out" 2>"$d/stderr"
415
+ )
416
+ code=$?
417
+ check 'a failed escalation fails the recovery run' '1' "$code"
418
+ check 'the evidence file exists even when the escalation failed' \
419
+ '=== omp-conductor fleet recovery evidence (#485) ===' "$(head -n 1 "$d/state/recovery"/evidence-*)"
420
+
421
+ # ---------------------------------------------------------------------------
422
+
423
+ if (( failures > 0 )); then
424
+ printf 'FAIL: %s test(s) failed\n' "$failures"
425
+ exit 1
426
+ fi
427
+ echo 'all tests passed'
428
+ exit 0