Skip to content

chaos: install harness configs atomically, and make the harness shellcheck-clean - #253

Merged
allamiro merged 1 commit into
mainfrom
fix/harness-atomic-config
Aug 5, 2026
Merged

chaos: install harness configs atomically, and make the harness shellcheck-clean#253
allamiro merged 1 commit into
mainfrom
fix/harness-atomic-config

Conversation

@allamiro

@allamiro allamiro commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Stacked on #252#251#250. Bases retarget automatically as each merges.

Scenario 01 failed intermittently with:

error: parse .../admin-1.yaml: missing field `endpoint`

An error about the harness's own scratch file, which reads like a product fault and points nowhere near its cause.

The race

Config emitters are called per invocation, not oncemeta_admin re-emits its client config on every call. Scenario 01 runs a proposal loop in the background while the foreground drives membership changes:

propose_loop &                                    # calls meta_admin in a loop
...
meta_admin "$LEADER_ID" add-learner --node-id 4   # and again here

Two writers re-emit the same path concurrently, and > "$cfg" truncates in place — leaving a window where a reader parses a fragment. All 12 emitters now write to a temp file and rename, which is atomic within a directory: a reader sees either the previous complete config or the new one, never a partial.

This was always latent; the same hazard existed under the old filename. It surfaced as a scenario that passed or failed depending on scheduling — the worst failure mode for a suite about to gate CI, because it teaches people to re-run instead of read.

Shellcheck-clean

Also silences the harness's one remaining finding with its reason rather than a blanket exclusion: the single quotes around the unshare probe are deliberate, because $1 must be expanded by the inner bash -c, not the outer shell.

The tree is now clean under:

shellcheck -x -P scripts/live-chaos/scenarios scripts/live-chaos/**/*.sh

-P matters — without it, source "$(dirname …)/../lib.sh" can't be resolved and every scenario reports SC1091 plus spurious SC2153 "may not be assigned" warnings for variables lib.sh does define. That is what lets the next change lint this tree in CI without drowning it in false positives.

Verification

Scenario 01 passes. Full-suite re-run in progress; the three scenarios fixed across this stack (09, 10, 11) all pass, and the remaining local failures are the four that need Linux (unshare for 05/05b/06, a clock shim for 07) and are expected to run on CI's ubuntu runners.


Summary by cubic

Install atomic config writes in the chaos harness to prevent partial YAML reads during concurrent emits, and clean up the last shellcheck warning. This removes the flaky Scenario 01 error and makes the harness ready for CI linting.

  • Bug Fixes
    • Add install_config to write via temp file + rename, avoiding truncation races (no more intermittent “missing field endpoint”).
    • Silence shellcheck in require_mount_namespace with a targeted SC2016 disable around the unshare probe so $1 expands in the inner bash -c.

Written for commit e19ecef. Summary will update on new commits.

Review in cubic

@cursor

cursor Bot commented Aug 5, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/live-chaos/lib.sh">

<violation number="1" location="scripts/live-chaos/lib.sh:318">
P2: Using `mktemp` here adds an unconditional dependency that the documented exact `CHAOS_WORKDIR` mode does not preflight. On a minimal supported host without `mktemp`, the run reaches config emission and fails only then; preflight `mktemp` for both modes or provide a supported fallback.</violation>

<violation number="2" location="scripts/live-chaos/lib.sh:318">
P3: Every emitted config file now silently changes permissions from the shell's default umask (typically 0644) to 0600, because `mktemp` creates the temp file with mode 0600 and `mv` preserves that mode. Today that is harmless — the harness launches vtop-node as the same user — but it is an unannounced regression across all 12 emitters, and it will break any future consumer that reads these configs as a different user (e.g. a container mount, sudo-run node, or a shared workdir). Consider pinning the final mode so the change in behavior is explicit and stable.</violation>

<violation number="3" location="scripts/live-chaos/lib.sh:319">
P1: A failed config write can still replace the live config with a partial file because `cat`'s status is ignored and `mv` always runs. On ENOSPC or another I/O error, readers get an atomically renamed but truncated YAML instead of the previous complete config; remove the temp file and return before renaming when `cat` fails.</violation>

<violation number="4" location="scripts/live-chaos/lib.sh:354">
P2: An installation failure is hidden from callers because every emitter prints `$cfg` after the pipeline regardless of its status, so command-substitution callers can start nodes or CLIs with a missing or stale config. Return the pipeline failure before printing the path (for example, `} | install_config "$cfg" || return 1`) in each emitter.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread scripts/live-chaos/lib.sh
Comment on lines +319 to +320
cat > "$tmp"
mv -f "$tmp" "$path"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A failed config write can still replace the live config with a partial file because cat's status is ignored and mv always runs. On ENOSPC or another I/O error, readers get an atomically renamed but truncated YAML instead of the previous complete config; remove the temp file and return before renaming when cat fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/live-chaos/lib.sh, line 319:

<comment>A failed config write can still replace the live config with a partial file because `cat`'s status is ignored and `mv` always runs. On ENOSPC or another I/O error, readers get an atomically renamed but truncated YAML instead of the previous complete config; remove the temp file and return before renaming when `cat` fails.</comment>

<file context>
@@ -296,6 +301,25 @@ require_binaries() {
+install_config() {
+  local path="$1" tmp
+  tmp="$(mktemp "$path.XXXXXX")" || return 1
+  cat > "$tmp"
+  mv -f "$tmp" "$path"
+}
</file context>
Suggested change
cat > "$tmp"
mv -f "$tmp" "$path"
if ! cat > "$tmp"; then
rm -f "$tmp"
return 1
fi
mv -f "$tmp" "$path"

Comment thread scripts/live-chaos/lib.sh
# either the previous complete config or the new one, never a fragment.
install_config() {
local path="$1" tmp
tmp="$(mktemp "$path.XXXXXX")" || return 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Using mktemp here adds an unconditional dependency that the documented exact CHAOS_WORKDIR mode does not preflight. On a minimal supported host without mktemp, the run reaches config emission and fails only then; preflight mktemp for both modes or provide a supported fallback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/live-chaos/lib.sh, line 318:

<comment>Using `mktemp` here adds an unconditional dependency that the documented exact `CHAOS_WORKDIR` mode does not preflight. On a minimal supported host without `mktemp`, the run reaches config emission and fails only then; preflight `mktemp` for both modes or provide a supported fallback.</comment>

<file context>
@@ -296,6 +301,25 @@ require_binaries() {
+# either the previous complete config or the new one, never a fragment.
+install_config() {
+  local path="$1" tmp
+  tmp="$(mktemp "$path.XXXXXX")" || return 1
+  cat > "$tmp"
+  mv -f "$tmp" "$path"
</file context>

Comment thread scripts/live-chaos/lib.sh
fi
echo "observability: { listen: \"$(meta_metrics_addr "$id")\" }"
} > "$cfg"
} | install_config "$cfg"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: An installation failure is hidden from callers because every emitter prints $cfg after the pipeline regardless of its status, so command-substitution callers can start nodes or CLIs with a missing or stale config. Return the pipeline failure before printing the path (for example, } | install_config "$cfg" || return 1) in each emitter.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/live-chaos/lib.sh, line 354:

<comment>An installation failure is hidden from callers because every emitter prints `$cfg` after the pipeline regardless of its status, so command-substitution callers can start nodes or CLIs with a missing or stale config. Return the pipeline failure before printing the path (for example, `} | install_config "$cfg" || return 1`) in each emitter.</comment>

<file context>
@@ -327,7 +351,7 @@ emit_meta_config() {
     fi
     echo "observability: { listen: \"$(meta_metrics_addr "$id")\" }"
-  } > "$cfg"
+  } | install_config "$cfg"
   echo "$cfg"
 }
</file context>

Comment thread scripts/live-chaos/lib.sh
# either the previous complete config or the new one, never a fragment.
install_config() {
local path="$1" tmp
tmp="$(mktemp "$path.XXXXXX")" || return 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Every emitted config file now silently changes permissions from the shell's default umask (typically 0644) to 0600, because mktemp creates the temp file with mode 0600 and mv preserves that mode. Today that is harmless — the harness launches vtop-node as the same user — but it is an unannounced regression across all 12 emitters, and it will break any future consumer that reads these configs as a different user (e.g. a container mount, sudo-run node, or a shared workdir). Consider pinning the final mode so the change in behavior is explicit and stable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/live-chaos/lib.sh, line 318:

<comment>Every emitted config file now silently changes permissions from the shell's default umask (typically 0644) to 0600, because `mktemp` creates the temp file with mode 0600 and `mv` preserves that mode. Today that is harmless — the harness launches vtop-node as the same user — but it is an unannounced regression across all 12 emitters, and it will break any future consumer that reads these configs as a different user (e.g. a container mount, sudo-run node, or a shared workdir). Consider pinning the final mode so the change in behavior is explicit and stable.</comment>

<file context>
@@ -296,6 +301,25 @@ require_binaries() {
+# either the previous complete config or the new one, never a fragment.
+install_config() {
+  local path="$1" tmp
+  tmp="$(mktemp "$path.XXXXXX")" || return 1
+  cat > "$tmp"
+  mv -f "$tmp" "$path"
</file context>

@allamiro
allamiro force-pushed the fix/10-uninitialized-membership branch from b47cfb2 to de308d3 Compare August 5, 2026 19:27
@allamiro
allamiro force-pushed the fix/harness-atomic-config branch from f5fbbff to 217f974 Compare August 5, 2026 19:27
allamiro added a commit that referenced this pull request Aug 5, 2026
The live-chaos harness drives real processes over real TLS — metadata Raft,
replication, fencing, promotion, failover — and it has never run in CI. No
workflow referenced it. It executed only when someone ran it by hand.

That gap is not theoretical. Scenario 09 sat red on main from #236, scenario
10 from #237, a data node presented a metadata node's certificate to the admin
endpoint across three issues, and scenario 01 failed on a scheduling race that
appeared and vanished between runs. Every one of those was found by running
the suite manually this week, not by CI, and each had been on main for weeks.

The harness was not linted either: the `shell` filter listed `docker/*.sh` and
`docker/tests/**`, so editing `lib.sh` — which all thirteen scenarios source —
triggered nothing at all.

Both are now covered:

  * `shell` gains `scripts/live-chaos/**`, and a second shellcheck step lints
    the harness with `-x -P scripts/live-chaos/scenarios` so the scenarios'
    dynamic `source` resolves. Without `-P`, every scenario reports SC1091 plus
    spurious "may not be assigned" warnings for variables lib.sh does define —
    noise that trains people to ignore the job. The tree is clean under it.

  * A new `chaos` filter and `live-chaos` job run the suite. Scoped, not
    blanket: only the harness itself and the four crates whose binaries it
    executes can change a scenario's outcome, so a docs or dashboard edit does
    not pay for a cluster. Debug binaries, because these scenarios assert
    correctness rather than throughput.

Unprivileged user and mount namespaces are enabled and then PROBED. Ubuntu
24.04 restricts unprivileged userns by default, and scenarios 05, 05b, and 06
need it. The probe does not skip on failure: these scenarios fail with a
remediation message rather than skipping, so a runner without namespaces turns
the job red honestly instead of reporting success for three scenarios that
never ran — which is exactly the silent gap this job exists to close.

Per-scenario logs upload on failure. A failing scenario names a log file, and
without the artifact that diagnosis dies with the runner.

Enabled only now because the suite had to be green first: #251, #252, and #253
fixed the three real failures. Local state is 9 of 13 passing, with the four
remaining failures all requiring Linux facilities absent on macOS — `unshare`
for 05, 05b, 06 and a clock shim for 07 — which is what this job exists to
exercise. Scenario 01 was run five times after the race fix: 5 of 5.
@allamiro
allamiro force-pushed the fix/10-uninitialized-membership branch from de308d3 to f6ba1ba Compare August 5, 2026 19:28
@allamiro
allamiro changed the base branch from fix/10-uninitialized-membership to main August 5, 2026 19:28
…check-clean

Scenario 01 failed intermittently with

  error: parse .../admin-1.yaml: missing field `endpoint`

an error about the harness's own scratch file that reads like a product fault
and points nowhere near its cause.

Config emitters are called per invocation, not once — `meta_admin` re-emits its
client config on every call. Scenario 01 runs a proposal loop in the background
while the foreground drives membership changes, so two writers re-emit the same
path concurrently. A plain `> "$cfg"` redirect truncates in place, leaving a
window in which a reader parses a fragment. Every emitter now writes to a temp
file and renames, which is atomic within a directory: a reader sees either the
previous complete config or the new one.

This was always latent — the same hazard existed under the old filename — and
it surfaced as a scenario that passed or failed depending on scheduling. That
is the worst failure mode for a suite about to gate CI, because it teaches
people to re-run rather than read.

Also silences the harness's one remaining shellcheck finding, with the reason
rather than a blanket exclusion: the single quotes around the `unshare` probe
are deliberate, since `$1` must be expanded by the inner `bash -c`. The tree
is now clean under `shellcheck -x -P scripts/live-chaos/scenarios`, which is
what lets the next change lint it in CI.
@allamiro
allamiro force-pushed the fix/harness-atomic-config branch from 217f974 to e19ecef Compare August 5, 2026 19:28
@allamiro
allamiro merged commit e589142 into main Aug 5, 2026
2 checks passed
allamiro added a commit that referenced this pull request Aug 5, 2026
The live-chaos harness drives real processes over real TLS — metadata Raft,
replication, fencing, promotion, failover — and it has never run in CI. No
workflow referenced it. It executed only when someone ran it by hand.

That gap is not theoretical. Scenario 09 sat red on main from #236, scenario
10 from #237, a data node presented a metadata node's certificate to the admin
endpoint across three issues, and scenario 01 failed on a scheduling race that
appeared and vanished between runs. Every one of those was found by running
the suite manually this week, not by CI, and each had been on main for weeks.

The harness was not linted either: the `shell` filter listed `docker/*.sh` and
`docker/tests/**`, so editing `lib.sh` — which all thirteen scenarios source —
triggered nothing at all.

Both are now covered:

  * `shell` gains `scripts/live-chaos/**`, and a second shellcheck step lints
    the harness with `-x -P scripts/live-chaos/scenarios` so the scenarios'
    dynamic `source` resolves. Without `-P`, every scenario reports SC1091 plus
    spurious "may not be assigned" warnings for variables lib.sh does define —
    noise that trains people to ignore the job. The tree is clean under it.

  * A new `chaos` filter and `live-chaos` job run the suite. Scoped, not
    blanket: only the harness itself and the four crates whose binaries it
    executes can change a scenario's outcome, so a docs or dashboard edit does
    not pay for a cluster. Debug binaries, because these scenarios assert
    correctness rather than throughput.

Unprivileged user and mount namespaces are enabled and then PROBED. Ubuntu
24.04 restricts unprivileged userns by default, and scenarios 05, 05b, and 06
need it. The probe does not skip on failure: these scenarios fail with a
remediation message rather than skipping, so a runner without namespaces turns
the job red honestly instead of reporting success for three scenarios that
never ran — which is exactly the silent gap this job exists to close.

Per-scenario logs upload on failure. A failing scenario names a log file, and
without the artifact that diagnosis dies with the runner.

Enabled only now because the suite had to be green first: #251, #252, and #253
fixed the three real failures. Local state is 9 of 13 passing, with the four
remaining failures all requiring Linux facilities absent on macOS — `unshare`
for 05, 05b, 06 and a clock shim for 07 — which is what this job exists to
exercise. Scenario 01 was run five times after the race fix: 5 of 5.
allamiro added a commit that referenced this pull request Aug 5, 2026
* ci: run and lint the live-chaos harness, path-gated

The live-chaos harness drives real processes over real TLS — metadata Raft,
replication, fencing, promotion, failover — and it has never run in CI. No
workflow referenced it. It executed only when someone ran it by hand.

That gap is not theoretical. Scenario 09 sat red on main from #236, scenario
10 from #237, a data node presented a metadata node's certificate to the admin
endpoint across three issues, and scenario 01 failed on a scheduling race that
appeared and vanished between runs. Every one of those was found by running
the suite manually this week, not by CI, and each had been on main for weeks.

The harness was not linted either: the `shell` filter listed `docker/*.sh` and
`docker/tests/**`, so editing `lib.sh` — which all thirteen scenarios source —
triggered nothing at all.

Both are now covered:

  * `shell` gains `scripts/live-chaos/**`, and a second shellcheck step lints
    the harness with `-x -P scripts/live-chaos/scenarios` so the scenarios'
    dynamic `source` resolves. Without `-P`, every scenario reports SC1091 plus
    spurious "may not be assigned" warnings for variables lib.sh does define —
    noise that trains people to ignore the job. The tree is clean under it.

  * A new `chaos` filter and `live-chaos` job run the suite. Scoped, not
    blanket: only the harness itself and the four crates whose binaries it
    executes can change a scenario's outcome, so a docs or dashboard edit does
    not pay for a cluster. Debug binaries, because these scenarios assert
    correctness rather than throughput.

Unprivileged user and mount namespaces are enabled and then PROBED. Ubuntu
24.04 restricts unprivileged userns by default, and scenarios 05, 05b, and 06
need it. The probe does not skip on failure: these scenarios fail with a
remediation message rather than skipping, so a runner without namespaces turns
the job red honestly instead of reporting success for three scenarios that
never ran — which is exactly the silent gap this job exists to close.

Per-scenario logs upload on failure. A failing scenario names a log file, and
without the artifact that diagnosis dies with the runner.

Enabled only now because the suite had to be green first: #251, #252, and #253
fixed the three real failures. Local state is 9 of 13 passing, with the four
remaining failures all requiring Linux facilities absent on macOS — `unshare`
for 05, 05b, 06 and a clock shim for 07 — which is what this job exists to
exercise. Scenario 01 was run five times after the race fix: 5 of 5.

* ci: run CI on every pull request, not only those targeting main

`pull_request: branches: [main]` meant a stacked PR ran no CI whatsoever. This
project stacks slices — each bases on the one below — so those PRs carried a
single check, an AI reviewer's comment, and nothing else. They could be
reviewed, approved, and merged with no build, no tests, and no lint.

Nor did merging the parent repair it. Retargeting a PR's base fires the
`edited` activity type, which is not in the default `pull_request` event set,
so the retargeted PR did not reliably re-trigger CI either. The first real
signal arrived on push to main — after the merge, on the branch that is
supposed to stay green.

The path filters already scope each run, so the added cost is only the jobs a
stacked slice actually needs.

This is the same shape as the gap the previous commit closes: a check that
exists and is trusted, but never runs on the change it is meant to guard.

* ci: make the live-chaos log artifact actually contain logs

The upload step collected nothing. Scenarios delete workdirs they generated
themselves, so by the time the step ran the `/tmp/vtop-chaos.*` tree was gone —
and `if-no-files-found: ignore` turned that into a silent success. The first
run confirmed it: the job passed and produced no artifact at all.

A diagnostic that appears to exist and is absent exactly when it is needed is
worse than none, because it stops anyone from looking for a real one.

Supplying CHAOS_WORKDIR fixes both halves: the logs land in a known path, and
because the harness only cleans up directories it generated itself, providing
one suppresses the cleanup. `if-no-files-found` becomes `error`, so a future
break in this wiring fails the job instead of quietly returning to collecting
nothing. Upload is now `if: failure()` — the logs are for diagnosing a failed
run, and uploading them on every green run is storage for nobody.

Verified by running a scenario with CHAOS_WORKDIR set and confirming the log
tree survives.

* ci: widen the chaos filter to the real dependency closure, and gate the upload correctly

Two review findings, both correct.

The chaos filter named four crates and asserted that nothing else could change
a scenario's outcome. That assertion was false. The two binaries this job runs
pull in every workspace crate:

  cargo tree -p vtop-node --edges normal
    → broker, log, meta, observe, protocol
  cargo tree -p vtop-cli --no-default-features --edges normal
    → adapters, broker, core, log, meta, observe, protocol, state, upload

The omissions were not marginal. vtop-log owns the segment format the
durability and recovery scenarios assert on, and vtop-protocol owns the
produce/fetch wire every scenario speaks — either could have broken the suite
with this job never running, which is precisely the hole this job exists to
close. The filter is now `crates/**`, derived from the closure rather than
from a guess about it.

That means the suite runs on most Rust changes. That is the honest cost of it
being the only thing validating replication and fencing against real
processes; a cheaper gate with a hole in it is what was just removed.

The log upload was gated on `failure()`, which is also true when the namespace
probe or the build failed. In those cases the suite never reached a scenario,
so no logs exist, and `if-no-files-found: error` would stack a spurious "no
files found" on top of the actual cause. It now keys on the run step's own
outcome, so the empty-artifact signal only fires once a run was actually
attempted — which is the only situation in which it means anything.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant