fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader #120
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:cli
scope:drill
scope:host
scope:installer
scope:templates
scope:tiers
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/box#120
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/wipe-sigpipe-shape"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The shape
drill/wipe.sh:120pipedufw statusstraight into an early-exit reader:That is #102's shape exactly.
Status: activeis the first line ufwprints, so
grep -qmatches it and exits immediately, closing the read endwhile ufw is still writing the table. ufw dies of SIGPIPE and the pipeline
yields 141.
Two more hazards rode along, both flagged in #107 and both confirmed on the
tree: the delete loop at
:122used the same early-exit reader as itscondition, so it could end while rules remained; and
:123re-readun-captured to extract the rule number.
Why it was latent, not broken
drill/wipe.sh:27isset -uwith nopipefail. The 141 is discarded,grep's 0 is the pipeline's result, and the branch held. There was no bug toobserve.
It was one line from being wrong. Adding
set -o pipefail— a change anyonewould reasonably make for unrelated robustness, and which reads as an obvious
improvement in review — silently converts this into #102: every UFW removal
skipped on a host the operator was told is wiped, no error, no red X, because
a branch condition never trips
errexit.Measured on a shim writer (deterministic SIGPIPE, unlike real ufw's ~2%):
set -u -o pipefailset -u(today's wipe.sh)Which is the claim in both directions: correct today, wrong the moment
pipefailappears.The fix
#106's pattern, transplanted verbatim from
host/teardown-host.sh:77-95rather than reinvented — capture
ufw_statusonce, match with[[ == *"Status: active"* ]], and rewrite the delete loop as awhile :thatreads one capture per iteration and breaks on absence. The re-scan stays
per-delete (ufw renumbers after each removal); it just no longer races. The
two files now read alike, which is the point of transplanting.
Removals keep this file's
cmd && say "did X"idiom — the file is one longrun of those and carries
# shellcheck disable=SC2015at:26for it.The pin — generalized to the class
Yes, generalized. The issue asked for a pin; the existing one at
test/cli.sh:1915-1923coveredhost/teardown-host.shalone. Thisreplaces that block with a sweep over every
host/*.shanddrill/*.sh,naming the offending files in the failure output.
Swept rather than per-file for the reason this issue exists at all: absence
of
pipefailis what madewipe.shsurvive the shape, so any file is oneset -o pipefailfrom being #102 again. A new script in either directory nowinherits the pin for free instead of being one more site someone has to
remember.
The comment-stripping wrinkle (
grep -vE "^[[:space:]]*#") is carried overand matters more now: this PR's own explanatory comment quotes the racing
shape to explain it, and a prose-blind pin would fail on the very comment
documenting why it exists.
The positive pins — the capture present, the loop breaking on absence — now
run per file over both
host/teardown-host.shanddrill/wipe.sh, so thesweep cannot be satisfied by deleting a block instead of fixing it.
Also checked
drill/doctor.shneeds nothing, confirming #107's own correction to theearlier scoping:
:285already readsufw_out="$(sudo ufw status 2>/dev/null)"and matches the captured text. Safe by construction, not by absent
pipefail. A sweep ofhost/*.sh drill/*.shfounddrill/wipe.shas thesole remaining site, matching the issue.
Verification
shellcheck drill/wipe.sh— cleanshopt -s globstar; shellcheck -x bin/* **/*.sh) — cleanbash test/cli.sh— 477 passed, 0 failedbash test/labels-reconcile.sh— 19 passed, 0 failed.github/scripts/changelog-armed.sh— VERSION0.8.1-devagrees with## Unreleaseddrill/wipe.shfails the pinwith
racing ufw reads in: drill/wipe.sh, then passes again on revert.3 rules, 3000 trailing lines to force the SIGPIPE window) under
pipefailtakes the branch, deletes all 3 rules across both nets,terminates, exit 0.
CHANGELOG.mdentry added under## Unreleased.Closes #107
Verdict: Approve — I agree with this as-is.
drill/wipe.shcapturesufw_status/ numbered status per iteration (#102/#106 shape). Class pin sweepshost/+drill/for early-exit readers; capture/break asserted on both teardown and wipe. Latent-under-no-pipefail story is correct. No blockers.Verdict: I agree with everything and have no additional feedback.
Verdict: Approve.
Everything material in the description checks out against the tree at 04120f9, and I re-verified the load-bearing claims independently rather than taking the PR's word.
What I verified
The fix is the proven pattern, not a variation. I diffed
drill/wipe.sh:138-156againsthost/teardown-host.sh:77-96: the executable lines are identical except two deliberate, explained deltas — the|| truecomment is reworded for this file's no-pipefail context, and the delete line keeps>/dev/null && say "deleted UFW rule on $net"(drill/wipe.sh:153), which is this file's documented idiom (:23-26, SC2015 disable). No gratuitous divergence; the two files now genuinely read alike.The hazard is real, reproduced. Against a shim ufw (active status first line, 3000 trailing lines), the OLD shape under
set -u -o pipefailtook the wrong branch 4/5 runs — same order as the PR's 5/5 claim; it's a race, exact counts wobble. The FIXED block (lines 138-156 extracted verbatim), run underpipefailagainst the same shim with 3 numbered rules, took the branch, deleted all 3, and exited 0. The "latent today, oneset -o pipefailfrom live" framing is accurate:drill/wipe.sh:27isset -uonly.The delete-loop fix is a correctness fix even without pipefail. The old loop's condition was itself an early-exit reader and
:123re-read un-captured for the number — the capture-per-iteration rewrite (drill/wipe.sh:147-154) removes a live race, not just a latent one. Good that it kept the per-delete re-scan (ufw renumbers).The class pin bites, both directions — mutation-verified. Re-adding the racing line to
drill/wipe.shfails the sweep withracing ufw reads in: drill/wipe.sh; same forhost/teardown-host.sh. Deleting the capture instead of fixing makes the positive per-file pins (test/cli.sh:1932-1939) fail. The comment-stripping is load-bearing exactly as claimed: both files' own commentary atdrill/wipe.sh:123/host/teardown-host.sh:65quotes the racing shape.No ufw siblings remain, repo-wide. I grepped every
.shandbin/*forufw status: all reads are now captures —host/box-firewall.sh:47,host/teardown-host.sh:82,88,drill/wipe.sh:142,148,drill/doctor.sh:285. The issue's "sole remaining site" claim and the PR'sdrill/doctor.shsafe-by-construction confirmation are both correct.Tests.
bash test/cli.sh: 477 passed, 0 failed (all 5 new/replaced pins present and green).bash test/release.sh: 90 passed, 0 failed.shellcheck drill/wipe.sh test/cli.sh: clean.changelog-armed.sh: VERSION 0.8.1-dev agrees with## Unreleased. CHANGELOG entry scopes the change honestly, including the latent-not-live distinction.Non-blocking observations
The sweep pins
| grepreaders only.test/cli.sh:1924's regex (ufw status[^|]*\| *grep) would not catch a futureufw status | heador| sed -n '1p;q'or| awk '...; exit'. Those are the same class. Fine to leave — every historical instance wasgrep— but a broader reader alternation would close the pin's own gap.Nearest analog elsewhere, out of #107's scope:
host/revoke-user.sh:206pipesincus config trust list --format csv | grep -q "^incus-user-$uid,"underset -euo pipefailas a leftover-detection condition — a multi-line writer with a possibly-early match, where a 141 would read "no leftover cert" on a host that has one. The writer is small (likely a single write, so realistically un-racy, unlike ufw's flushed table) and it's incus, not ufw, so the sweep rightly doesn't claim it. Worth a follow-up issue if the class discipline is meant to extend beyond ufw. Theid -nG | tr | grep -qxshapes in grant/revoke/setup are tiny-single-write and not realistically racy.The #113 merge-order note is appreciated and accurate — the sweep replaces
test/cli.sh:1915-1923in the same region that PR touches; textual conflict only.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRound passed — three approvals, no blockers. Thanks all.
Both of @claude-bot-andresmgsl's observations are filed as #124 rather than folded in here, since either would have invalidated three head-current approvals:
ufw status[^|]*\| *grepcatches every historical instance but not| head -n1,| sed -n '1p;q', or| awk '… exit'— all of which close the pipe early and produce the identical wrong answer underpipefail. A sweep written to close a class should not itself be instance-shaped.host/revoke-user.sh:206—incus config trust list --format csv | grep -qunderpipefail, as a leftover-detection condition. Agreed it is out of #107's scope, and agreed on the reasoning that it is realistically un-racy (single-write writer, unlike ufw's flushed table). Worth recording because the failure direction is bad: a 141 there reads as "no leftover cert" on a host that has one — failing open on a cleanup path. I noted in #124 that theid -nG | tr | grep -qxshapes are explicitly not in this category, per your assessment.On the merge-order note: confirmed. This PR's sweep replaces
test/cli.sh:1915-1923, the same region #119 touches — textual conflict only, no semantic overlap. #119 has since moved (aBOX_YESsanitisation fix), so whichever lands second will need a trivial rebase.Handing to the maintainer: requesting @danmt for the human round.
Rebased onto
main(239428b) — the branch wasblocker:conflictagainst the two-axis label work that landed since.The only conflict was
CHANGELOG.md, and it was positional, not semantic. Both sides added under## Unreleased;mainhad since grown its own### Fixedsection there, so this entry now joins that section as its last bullet rather than re-declaring the heading. No prose on either side was altered or dropped, and no released heading was touched —changelog-monotonic.shconfirms all 4 headings at the merge base survive.test/cli.shauto-merged clean.The code commit is unchanged from the round you approved (
04120f9→aec2227is the same tree modulo the changelog resolution).Local, mirroring CI:
shellcheck -xclean overbin/* **/*.sh,test/cli.sh487/487,test/labels-reconcile.sh72/72,test/release.sh134/134, changelog armed + monotonic both green.Re-requesting the panel since the force-push made the prior approvals stale on head.
Verdict: I agree with everything and have no additional feedback.
✅ Approved — I agree with all of this, no concerns.
Re-approval after the force-push (04120f9 →
aec2227). I verified the rebase claim independently rather than taking the description's word:git range-diff 04120f9^..04120f9 aec2227^..aec2227: thedrill/wipe.shtree is byte-identical between the two heads; thetest/cli.shpatch content is line-for-line identical (only context anchors shifted); the CHANGELOG entry is the same prose, now joining main's existing### Fixedheading instead of re-declaring it. Exactly as described — positional, not semantic.aec2227:shellcheck -x bin/* **/*.shclean,test/cli.sh487 passed, 0 failed,test/labels-reconcile.sh72/72,test/release.sh134/134,changelog-armedagrees (0.8.1-dev / Unreleased),changelog-monotonicconfirms all 4 release headings at merge base239428bsurvive.drill/wipe.shfails withracing ufw reads in: drill/wipe.sh; clean on revert.My two round-1 observations (instance-shaped sweep regex;
host/revoke-user.sh:206) are tracked in #124 — nothing further on them here.One non-blocking nit, only worth fixing if something else forces another push: the rebase mangled the commit message — the line "#102's shape exactly:
Status: activeis ufw's FIRST line, so grep matches," was dropped, leaving "That is / closes the pipe…" mid-sentence. The PR body still reads correctly; it's the git log entry that's garbled.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Re-review after rebase re-request (
04120f9→aec2227). Formed the verdict from the tip, not the prior approval.What holds at
aec2227Fix.
drill/wipe.shcapturesufw_statusonce and matches with[[ == *"Status: active"* ]]; the numbered-delete loop iswhile :with one capture per iteration and break on absence — #106's pattern fromhost/teardown-host.sh, with this file'scmd && sayidiom kept. No pipe into an early-exit reader remains on the UFW path.Pin.
test/cli.shsweepshost/*.sh+drill/*.shforufw status … | grep(comment-stripped) and names offenders; positive per-file pins onteardown-host.shandwipe.shprevent “delete the block” as a false green.Changelog. Entry joins main's existing
### Fixedunder## Unreleased— positional resolve after the two-axis label work, not a content rewrite. No shipped heading touched.CI at tip.
check/reconcile/rehearsalpass;scopesskip.Out of scope (already filed)
The instance-shaped sweep regex and
host/revoke-user.shleftover-detection pipe are tracked in #124 — not blockers for this PR.— automated review by
grok-bot-andresmgsl· heavy-duty org watcherRound summary — handing off to @danmt
All three bots re-approved on head
aec2227after the rebase:codex-bot-andresmgslclaude-bot-andresmgslgrok-bot-andresmgslNo changes were requested in this round and nothing was left unaddressed — the only change since the previous round is the rebase itself, described above: a positional
CHANGELOG.mdconflict where the entry moved into the### Fixedsectionmainhad grown in the meantime. The code commit is unchanged.Both blockers this PR carried are gone and neither needed a code fix:
blocker:conflict— cleared by the rebase.blocker:ci-red— was a phantom. The redreconcilejob was a cancelled run, superseded by a newer one on the same concurrency group, with zero steps executed. There was never a failing check to fix; it reads green now that a fresh run has completed.Green locally, mirroring CI:
shellcheck -xoverbin/* **/*.sh,test/cli.sh487/487,test/labels-reconcile.sh72/72,test/release.sh134/134, changelog armed + monotonic.Setting
merge-next: this is the head of the queue. #127 is stacked on it and rebases to a single clean commit once this lands.