Skip to content

test(tests): make negated BATS assertions able to fail - #3790

Merged
IvanHunters merged 5 commits into
mainfrom
fix/cozytest-negated-assertions
Oct 5, 2026
Merged

IvanHunters merged 5 commits into
mainfrom
fix/cozytest-negated-assertions

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

A hundred and twelve assertions across twelve BATS suites begin with !. A hundred and seven of them pass no matter what they find. POSIX exempts a command whose return value is inverted with ! from set -e, so the failing status is dropped where it is produced, and hack/cozytest.sh closes each test function with return 0, so it never resurfaces as the test's own status either. The other five are followed by || { ... exit 1; }, which reads the inverted status and does work; they are rewritten too, because telling the two apart needs the tail of every statement parsed, and a guard that guesses wrong there is green on a real one.

Switching to the bats binary does not fix the hundred and seven. Its ERR trap carries the identical exemption: bash -c 'set -eE; trap "echo TRAP" ERR; ! true; echo REACHED' prints REACHED, runs no trap and exits zero. The only negated assertion bats reports is one standing last in the test body, where the inverted status happens to be what the function returns. Two of the hundred and seven stood last; the rest did not.

What was lost is specific rather than diffuse. ! is how you spell "this must not appear", and an absence is what no other assertion in a test covers. The affected suites assert that a migration ran nothing destructive, that no image was copied, that a flag was not passed, that a backup cleanup never flushed the master, that an object does not exist. On that half of their contract they held for any input.

Each site is now written as if cmd; then echo "FAIL: ..."; false; fi, the form already used in the files where this had been noticed one at a time, and each carries a message naming what must not have happened. hack/bats-no-negated-assert.bats scans every hack/**/*.bats and refuses the old form, so a new one cannot be added silently. Inverted conditions (if !, while !, until !) are untouched, because set -e is not what decides a condition. [ ! -f x ] is untouched for a different reason and remains an ordinary failing assertion: the ! there is an operator of test, not negation of a command, so nothing exempts its status.

The guard pins its own premise instead of describing it, on both sides. One test asserts the runner still passes a bare negated assertion; a second, that neither set -e nor the ERR trap sees an inverted status; a third, that the prescribed if cmd; then ...; false; fi does fail, under both runners, and at the form rather than somewhere before it, since a run that died early would satisfy a bare status check and pin nothing.

Every rewrite here rests on the third test. If false inside a then body ever stopped propagating, the first test would still pass, because a negated assertion would still be vacuous, while every converted absence went silently green. A ban that prescribes a replacement has to test the replacement too.

Every claim the guard makes is pinned by removing the thing it claims. The recursion is exercised through the audit function against a fixture in a subdirectory, not through a second copy of its find expression: a copy keeps passing while the function loses its recursion, and an audit that returns empty reads as a clean tree. Recursion matters because BATS_UNIT_FILES is a non-recursive wildcard, so make unit-tests never runs a suite placed in a subdirectory. hack/e2e-apps/ held such suites until they moved to Chainsaw and is gone now, which is why the test pins against a fixture and not a real directory.

The rule's surface was checked in the other direction too. The shape appears in no hack/**/*.sh script as an assertion: the one hit, hack/testdata/install-wait/call-shapes.sh, is fixture text that hack/run-kubernetes-install-wait_test.bats parses, not a command anything runs. It appears in no Chainsaw script: step either, so .bats is the whole surface rather than the part that was convenient to guard.

The scan is lexical, so its header names the statement boundaries it owns rather than implying it understands shell: ; & && || { (, the ) closing a case pattern, and the words then do else, each measured against the runner before being added. A keyword may legally be preceded only by whitespace or by ;, since every other separator in front of then, do or else is a shell syntax error, checked with sh -n rather than recalled. The scan therefore admits exactly those two, and that part of the class is closed rather than patched one spelling at a time. All three keywords are vacuous after either spelling, so missing one is a silent miss, the single direction this scan must not err in, given that its whole design trades noise for safety. For the same reason it takes !(cmd) with no space before the subshell, which is vacuous on dash and bash alike, and reads through a time or time -p prefix, which is vacuous under bats and under cozytest wherever /bin/sh is bash.

Five over-counts remain and are deliberate, each named in the header: ( ! cmd ) does propagate its inverted status and is reported anyway; so would a ! opening a statement after a redirection &; x=$(true || ! false) is reported although the || reads the inverted status; a condition continued by a trailing && instead of a backslash is reported, because the strip only recognises a condition whose end it can see; and time ! cmd is reported although on a dash /bin/sh, which has no time keyword, it runs an external time that fails. Each costs one rewritten line, where the opposite error costs a silent pass. They are enumerated rather than engineered away on purpose, because a removed error leaves no trace while a listed one tells the next reader what they are looking at. The scan also errs quiet in at least two places, named here rather than in the header. A file cut off midway through a line continuation loses its last buffered statement, but that input is not a runnable test, since sh -n rejects it with unexpected end of file. And time -- ! cmd, which bash accepts, is vacuous under bats and not peeled by the scan; nothing in the tree uses it, and I stopped adding spellings there rather than chase every prefix bash allows. The test naming the shapes claims only what its fixture holds. A lexical scan over shell cannot promise it has enumerated the language, and a name that promised it would be false whether or not a gap existed today.

The branch name is narrower than the subject and I am leaving it as it is rather than churn it. This is not a defect in hack/cozytest.sh that moving to the bats binary would fix. It is a POSIX rule both runners inherit, and moving to bats would close two of the hundred and seven sites.

One suite went red for real once its assertions could fail. seaweedfs-guard-parity forbids four mutable discriminators by grepping each chart, and both charts carry a template comment block arguing why those signals were rejected, so the grep matched the argument and would have reported a chart for documenting the rule it follows. The charts are correct; the assertion was not. It now strips {{/* ... */}} before matching. Two details there are load-bearing and neither is obvious. The strip removes the comment span, not the line that opens it: dropping the line is shorter and silently loses a discriminator sharing a line with a comment, which is a chart classifying on a forbidden signal and a test reporting that it does not. And the strip reports when the file ended inside a comment, and the checks refuse to read its output when it did, because every {{/* is read as an opener, so one inside a quoted string (these charts carry long fail (printf ...) messages) would swallow the rest of the file and leave the checks reading nothing.

One commit widens six comments that explained this trap as applying to a "negated pipeline". It is not about pipelines, and the narrower wording invites the conclusion that the other shapes are safe. Comment text only; each of those files already uses the working form.

The last commit makes the one absence check in hack/e2e-install-cozystack.bats that already used the if form tell a missing object from a failed read. It asserted that the version configmap was gone on kubectl get's exit status alone, and that status is 1 both for a NotFound and for an api-server the test cannot reach.

Verification

Every unit suite this branch touches was run through hack/cozytest.sh on the current base and is green. migration-seaweedfs-db-adopt passes 15 of 15 on macOS only when its temp directory sits under $HOME. Its container cannot write into a bind mount under /var/folders, and /usr/bin/mktemp -d ignores TMPDIR, so the run needs GNU mktemp first on PATH as well. Measured on Linux before the rebase, it passed 15 of 15 as is.

The six sites that reached main after this branch was first written, in redis-backup-strategy and proxmox-e2e-fixture_test, were each shown to be able to fail: with the condition inverted, the suite goes red at that assertion and prints its own FAIL: message. The nine sites in migration-seaweedfs-db-adopt carry a two-sided check as well: with the pattern swapped for one the same test proves is present, the rewritten form fails the suite and the original ! form passes it.

The guard itself is green under cozytest with bash and with dash as /bin/sh, under the bats binary, and with mawk, gawk and BSD awk. Each branch of its separator class and of the time and !(cmd) handling was removed in turn, and each removal turns the shapes test red.

e2e-install-cozystack.bats is an e2e suite and runs only in the E2E workflow, so its one changed absence check is not exercised locally.

Screenshots

Not a UI change.

Downstream repositories

Walked the trigger map against the diff file by file. The change touches eighteen existing BATS files, adds one, and adds a convention to docs/agents/e2e-testing.md. It does not touch hack/e2e-prepare-cluster.bats, which is the node contract ansible-cozystack and talm restate; it moves and renames nothing under hack/ and changes no make target's behaviour, which is what ccp gates on; and it touches no package, schema, enum, default, CRD, label, annotation or metric.

Release note

NONE

Closes #3565

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cebe3e36-ba30-45f2-8b52-8c83cb0d6942

📥 Commits

Reviewing files that changed from the base of the PR and between 3f24b65 and f7e64af.

📒 Files selected for processing (1)
  • docs/agents/e2e-testing.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/agents/e2e-testing.md

📝 Walkthrough

Walkthrough

The PR documents a rule against negated BATS assertions, adds a repository-wide scanner, converts existing assertions to explicit failure branches, and strengthens SeaweedFS Helm template parity checks.

Changes

Negated BATS assertion enforcement

Layer / File(s) Summary
Policy and repository audit
AGENTS.md, docs/agents/e2e-testing.md, hack/bats-no-negated-assert.bats
Documents the prohibited ! assertion form, adds the explicit if ...; then ...; false; fi pattern, and tests recursive scanning, exclusions, line reporting, and runner behavior.
Explicit failure assertion migration
hack/*.bats, hack/e2e-apps/*, hack/migration-50-etcd-adopt.bats, hack/migration-seaweedfs-db-adopt.bats, hack/nightly-mirror_test.bats, hack/promote-retag_test.bats, hack/release-changelog-contract.bats
Replaces negated assertions with explicit conditional failures and diagnostic messages. Related comments explain the set -e behavior.

SeaweedFS template parity validation

Layer / File(s) Summary
Comment-stripped template validation
hack/seaweedfs-guard-parity.bats
Adds Helm comment stripping with inline and multiline fixture coverage. The discriminator checks validate balanced comments and inspect executable template content.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BatsRunner
  participant NegatedAssertionAudit
  participant BatsSuite
  BatsRunner->>NegatedAssertionAudit: Run repository audit
  NegatedAssertionAudit->>BatsSuite: Scan .bats statements
  BatsSuite-->>NegatedAssertionAudit: Report statements beginning with !
  NegatedAssertionAudit-->>BatsRunner: Fail with replacement guidance
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: myasnikovdaniil

Merge Risk: ⚪ Minimal · up to f7e64

The PR makes previously ineffective negated BATS assertions fail and adds a guard against regressions; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: updating negated BATS assertions so they fail correctly. It is concise and specific.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cozytest-negated-assertions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/agents/e2e-testing.md`:
- Line 141: Qualify the negated-assertion guidance so it says ! cmd is
unreliable and may pass unexpectedly rather than claiming it cannot fail; retain
the prohibition, including the tail-position bats exception. Apply this wording
change in docs/agents/e2e-testing.md:141-141 and AGENTS.md:31-31.

In `@hack/e2e-apps/monitoring-oidc-customconfig.bats`:
- Around line 123-128: Update both absence-check blocks in
hack/e2e-apps/monitoring-oidc-customconfig.bats:123-128 and
hack/e2e-install-cozystack.bats:617-621 to run kubectl get with
--ignore-not-found -o name, explicitly fail when kubectl returns an error, and
fail when the command produces non-empty output. Preserve the existing failure
messages for detected KeycloakClient and KeycloakClientScope resources.

In `@hack/seaweedfs-guard-parity.bats`:
- Around line 53-70: Replace the raw opener/closer counting checks with the
stateful parsing in template_code. Make template_code return nonzero when inc
remains set at EOF, while preserving its existing output behavior for valid
templates, and add a fixture covering {{/* inside an active Helm comment.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1de527b3-132f-43fa-9a26-9bafd83c3d7e

📥 Commits

Reviewing files that changed from the base of the PR and between e944619 and 001d62c.

📒 Files selected for processing (16)
  • AGENTS.md
  • docs/agents/e2e-testing.md
  • hack/bats-no-negated-assert.bats
  • hack/build-matrix_test.bats
  • hack/capture-dataplane.bats
  • hack/capture-previous-logs.bats
  • hack/common-envs_test.bats
  • hack/cozyreport.bats
  • hack/e2e-apps/monitoring-oidc-customconfig.bats
  • hack/e2e-install-cozystack.bats
  • hack/migration-50-etcd-adopt.bats
  • hack/migration-seaweedfs-db-adopt.bats
  • hack/nightly-mirror_test.bats
  • hack/promote-retag_test.bats
  • hack/release-changelog-contract.bats
  • hack/seaweedfs-guard-parity.bats

Comment thread docs/agents/e2e-testing.md Outdated
Comment thread hack/e2e-apps/monitoring-oidc-customconfig.bats Outdated
Comment thread hack/seaweedfs-guard-parity.bats
@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files area/testing Issues or PRs related to testing (e2e, bats, unit tests) labels Aug 12, 2026
@lexfrei Aleksei Sviridkin (lexfrei) added the debug Debugging in progress label Aug 14, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/cozytest-negated-assertions branch 3 times, most recently from cd55ebf to 3c84002 Compare August 15, 2026 07:54
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/cozytest-negated-assertions branch 8 times, most recently from 22b8dd9 to 9cf097e Compare August 17, 2026 00:10
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/cozytest-negated-assertions branch 4 times, most recently from e450a5c to e54ccf9 Compare August 20, 2026 08:48
@lexfrei Aleksei Sviridkin (lexfrei) removed the debug Debugging in progress label Aug 20, 2026
IvanHunters
IvanHunters previously approved these changes Aug 21, 2026

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM.

Empirically confirmed the crux under real bats: the old ! echo|grep -q FORBIDDEN passes vacuously (exit 0) when the token is present, while the new if grep; then false; fi fails under both runners. Completeness is proven: an independent grep over hack/**/*.bats finds zero remaining negated asserts outside the guard file, and the new lexical guard (9/9) blocks regressions. Ran six converted suites, no not-ok, so no new always-failing asserts.

  • [MINOR] hack/monitoring-oidc-customconfig.bats — the one conversion under hack/e2e-apps/ is not run by make unit-tests and needs a live cluster; pattern is identical to the rest, verified statically.

@github-actions github-actions Bot added size/XXL This PR changes 1000+ lines, ignoring generated files and removed size/XL This PR changes 500-999 lines, ignoring generated files labels Sep 7, 2026
@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files and removed size/XXL This PR changes 1000+ lines, ignoring generated files labels Sep 7, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/cozytest-negated-assertions branch 2 times, most recently from 1a51af2 to 9a30fd3 Compare October 2, 2026 11:41
The parity suite forbids four mutable discriminators -- claim
timestamps, ready-replica counts and the two birth-order flags -- by
grepping each chart's whole file. Both charts carry a template comment
block arguing why those signals were rejected, so the grep matched the
argument and would have reported a chart for documenting the rule it
follows. It never did report: the checks were spelled `! grep`, a form
POSIX exempts from errexit, so they could not fail whatever they found.

Strip the {{/* ... */}} spans before matching and let the loop fail
through `false`, naming the signal it matched.

The span and not the line. Dropping every line that opens a comment is
shorter and silently loses a discriminator that shares its line with
one -- `{{- $x := .creationTimestamp }} {{/* why */}}` leaves with the
comment and the checks find nothing. The two spellings agree on
today's charts, so a fixture pins the difference rather than trusting
it.

Have the strip say when it ended inside a comment, and ask before
reading what it returned. Every `{{/*` is read as an opener, including
one inside a quoted string -- these charts carry long `fail (printf
...)` messages -- and a lone opener swallows the rest of the file,
leaving the checks reading nothing. A canary token cannot cover that:
it only proves whatever precedes IT survived, and the block most
likely to swallow is the last one in the file.

It says so on stdout rather than through an exit status, because
hack/cozytest.sh rewrites every `}` at column 0 into `return 0; }` --
the strip's own closing brace included -- so a status it returned
would never reach the caller.

Counting delimiters cannot stand in for the state the strip already
carries. Go template comments do not nest, so the first `*/}}` closes
the block and `{{/* the opener is spelled {{/* */}}` is a comment Helm
renders, while its two openers against one closer read as an imbalance
that is not one.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
An assertion written `! cmd` cannot fail a test. POSIX exempts a
command whose return value is inverted with `!` from errexit, so the
status is dropped where it is produced. hack/cozytest.sh then closes
each test function with `return 0`, and the bats binary's ERR trap
carries the same exemption -- it reports only a negated assertion
standing last in the body, where the inverted status happens to be
what the function returns.

Every absence these suites assert was written that way: a command that
must not reach the log, an image that must not be copied, a flag that
must not be passed, an object that must not exist. On that half of
each suite's contract the tests held for any input.

Rewrite each as `if cmd; then echo "FAIL: ..."; false; fi`, the form
already used where the trap had been noticed one file at a time, and
give every one a message naming what must not have happened. The
message states the property, not the pattern: where the forbidden
string is a substring of a required value -- an https endpoint the
test requires in its http form -- naming the string would describe the
opposite of what is asserted.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/cozytest-negated-assertions branch from 9a30fd3 to 3453817 Compare October 2, 2026 12:02
A negated assertion is the natural way to spell "this must not
appear", and an absence is what nothing else in a test covers, so the
errexit exemption lands where it is least likely to be noticed and
invisible in a green run. Switching runners does not lift it: the ERR
trap the bats binary reports through carries the same exemption, so
neither runner sees a negated assertion that has any statement below
it.

Add a suite that scans every hack/**/*.bats, subdirectories included,
and fails on a statement whose first token is `!` -- naming the file,
the line the statement opens on, and the form to use instead.

Statement boundaries are the POSIX separators, each measured against
the runner before being admitted: `;` `&` `&&` `||` `{` `(`, the `)`
closing a case pattern, and the words `then` `do` `else`. A keyword may
legally be preceded only by whitespace or by `;` -- every other
separator in front of one is a shell syntax error, checked with `sh -n`
rather than recalled -- so the scan admits exactly those two and that
part of the class is closed rather than patched a spelling at a time.
Both spellings are vacuous under the runner, so missing either is a
silent miss, the one direction this scan must not err in. For the same
reason the scan takes a `!` with no space before its subshell,
`!(cmd)`, which is vacuous on dash and bash alike, and reads through a
`time` or `time -p` prefix, which is vacuous under bats and under
cozytest where /bin/sh is bash. A scan that wants `!` and then
whitespace as the first token misses both. On a dash /bin/sh `time` is
not a keyword and `time ! cmd` fails, so there it is an over-count.
Five over-counts remain, all erring loud, each named in the header:
they are enumerated rather than removed, because a removed error
leaves no trace while a named one tells the next reader what they are
looking at.

Inverted conditions are untouched because errexit is not what decides
a condition. `[ ! -f x ]` is untouched for a different reason and
stays a normal failing assertion: the `!` there is an operator of
`test`, not negation of a command, so nothing exempts its status.

Each claim the suite makes is pinned against removing the thing it
claims, and the separator class is measured branch by branch rather than
only at its newest entries. The recursion is exercised through the audit
function on a fixture in a subdirectory rather than through a second
copy of its find expression, because a copy passes while the function
loses its recursion -- and an audit that returns empty reads as a clean
tree. The condition-strip carries fixtures that put the negation after
an `&&` inside the condition, where only the strip can save it. The
continuation fold is pinned by a fixture whose negation sits on the
continued line, so folded and unfolded name different lines. Both
keyword spellings, the `time` prefix and `!(cmd)` have fixtures of
their own. The subshell over-count carries a fixture for both of its
halves -- that the scan reports the shape, and that the shape really
does fail.

Both halves of the premise are pinned, not just the half about the
banned form. A test asserts the runner still passes a bare negated
assertion; a second asserts the prescribed `if cmd; then ...; false; fi`
does fail, under both runners, and fails AT the form rather than
somewhere before it. Every converted absence rests on that second
half, and its failure mode is asymmetric: were `false` in a `then`
body to stop propagating, the first test would still pass while every
conversion went silently green.

The rule joins the E2E conventions doc as its own section, and the
inline list in AGENTS.md that an agent may read instead of the doc --
it belongs beside the EXIT-trap ban for the same reason, both leaving
a green suite that checks nothing.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
Six suites explain why they avoid `! cmd` by saying errexit is
suppressed for a "negated pipeline". The exemption is not about
pipelines: a bare `! true`, a negated `[ ... ]`, one inside a `for`
body or a brace group, one in a case branch, and one following `;`,
`&`, `&&` or `||` are all exempt too. Anyone reading the narrower
wording could conclude the other shapes are safe.

One of the six carries the sentence twice, and the second copy is
split across two physical lines -- `` `!` `` ending one and
`pipeline)` opening the next -- so a line-oriented search for the
phrase does not see it. Prose wraps; a grep for it has to fold the
lines first.

Comment text only; every one of these files already uses the working
form the comment recommends.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
The assertion that the no-delete guardrail can be bypassed decides on
`kubectl get`'s exit status alone. That status is 1 for a NotFound and
1 for a connection, auth or discovery failure, so an api-server the
test cannot reach reads as proof the configmap is gone.

`--ignore-not-found` collapses the NotFound alone into success with
empty output, which leaves the status free to mean a failed read.
Read the two separately: a non-zero status fails the test naming the
read that did not answer, non-empty output fails it naming the object
that must not be there.

Capture stdout only. kubectl writes deprecation and throttling notices
to stderr on a call that succeeded, and folding those into the value
under test would report an object that is not there.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM.

Test-tooling and docs only (hack/, AGENTS.md, docs/agents/e2e-testing.md), no product code moves.

I ran it rather than read it. Checked against head 0a3546e under bats 1.13.0, and the guard suite is green under cozytest too:

  • The premise holds on the runner itself. cozytest.sh injects set -e per test and return 0 before every column-0 }, so ! cmd is vacuous (the test "a negated assertion still passes under this runner whatever it finds" is green), while if cmd; then echo FAIL; false; fi fails under both cozytest and bats (the test "the form this ban prescribes does fail, under both runners" is green). A bare false aborts under errexit from any position, not just the last line, so the prescribed form still fails when more assertions follow it.

  • The guard reddens on regression. Put a !-first assertion back into hack/promote-retag_test.bats and "no suite states an absence in a form that cannot fail" flips to not ok.

  • Completeness: no inert !-first assertion is left in hack/**/*.bats outside the guard file. The surviving || ! grep occurrences are inverted conditions inside if ...; then, which errexit does not govern, and they are correctly left alone.

  • seaweedfs-guard-parity: the comment strip earns its keep. A forbidden signal inside a {{/* ... */}} comment stays green (documenting the rule is not classifying on it), while the same signal in executable template code reddens the discriminator check.

One thing for later, out of scope here: the guard enforces the ! ban, but an if cmd; then echo FAIL; fi that drops the false is just as inert and carries no !, so nothing flags it. Every conversion in this PR keeps the false, so this is a future footgun worth a line in the doc rather than a change request.

# cannot reach would read as proof the delete worked. `--ignore-not-found`
# collapses the NotFound alone into success with empty output, leaving the
# status free to mean a failed read.
if ! still_there=$(kubectl get configmap cozystack-version -n cozy-system --ignore-not-found -o name); then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[NIT] this conversion also fixes a NotFound-vs-read-failure ambiguity

Not a mechanical ! cmd conversion: using if ! still_there=$(kubectl get ... --ignore-not-found -o name) plus [ -n "$still_there" ] separates a real NotFound from an unreachable api-server. Before, a read that failed for any reason would have read as proof the delete worked. Good catch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, that was the point of the if ! form. The old one read any failed get as a successful delete.

@IvanHunters
IvanHunters merged commit 9485f91 into main Oct 5, 2026
20 checks passed
@IvanHunters
IvanHunters deleted the fix/cozytest-negated-assertions branch October 5, 2026 11:05
Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Oct 5, 2026
…ion (#4738)

## What this PR does

Migration 50 scales the legacy etcd-operator to 0, adopts every legacy
`etcd.aenix.io` cluster with `etcd-migrate`, and then scales the
operator back to 1. At that point the Deployment still runs the legacy
operator, and after a successful adoption it has nothing left to manage.
Bringing it back undoes part of the adoption.

Observed on a production cluster upgraded from 1.4.3 to 1.6.4. The API
audit log and the Helm history of a tenant's `etcd` release show this
sequence, in UTC on the same day.

- 13:21:12 migration 50 scales
`cozy-etcd-operator/etcd-operator-controller-manager` to 0.
- 13:22:59 `etcd-migrate` updates `etcd-headless`, pointing its owner
references at the adopted EtcdMembers, and orphan-deletes the legacy
EtcdCluster and StatefulSet.
- 13:23:04 migration 50 scales the operator back to 1. It is still the
legacy operator, the v1alpha2 one replaced it only at 13:40.
- 13:23:42 Flux upgrades the tenant's `etcd` release with the old chart
(Helm revision 87), because the new chart artifact appeared only at
13:24:40. The old chart re-creates the legacy EtcdCluster at 13:23:43.
- 13:23:43 to 13:23:45 the legacy operator reconciles it, updates
`etcd-headless` twice and re-creates the legacy StatefulSet `etcd`.
- 13:36:49 Flux upgrades the release to the new chart (revision 88),
which prunes the legacy EtcdCluster.
- 13:36:50 the garbage collector deletes `etcd-headless` and the
re-created StatefulSet.

The same happened in all five tenants that run an etcd. The pods
survived because EtcdMembers own them, but they keep `spec.subdomain:
etcd-headless`, so their `etcd-<i>.etcd-headless.<ns>.svc` names stopped
resolving. From then on every member logged `no such host` for its
peers, the v1alpha2 EtcdCluster had no status conditions, and the
`etcd-defrag` CronJob failed every hour with `context deadline
exceeded`. The transitional `etcd-headless` Service the etcd chart ships
for this window (#3270) does not cover it. Helm patched the existing
Service 202 ms before the garbage collector removed it, and did not
create it again because it was already part of the release.

With this change the operator stays at 0 after a successful adoption.
The etcd-operator HelmRelease replaces the Deployment with the v1alpha2
operator later in the same upgrade, and no later migration waits for the
operator. A failed or interrupted adoption still restores it to 1
through the existing EXIT trap, so a failed run does not leave the
tenant etcds without an operator. If that HelmRelease cannot reconcile,
the adopted clusters wait for it with or without this change, because
the legacy operator does not reconcile v1alpha2 EtcdClusters.

Clusters already upgraded with this bug can be repaired without
restarting anything. Creating the Service from the tenant's own `etcd`
release is enough.

```
helm -n <ns> get manifest etcd | awk 'BEGIN{RS="\n---\n"} /\nkind: Service\n/ && /\n  name: etcd-headless\n/' | kubectl -n <ns> create -f -
```

On the cluster above the v1alpha2 EtcdCluster reported `Available` with
reason `QuorumHealthy` within 11 seconds of the Service being created,
in every tenant, and the members stopped logging `no such host`.

### Validation

- `hack/cozytest.sh hack/migration-50-etcd-adopt.bats` passes all 29
tests. A new test asserts that a successful adoption scales the operator
to 0 and never back to 1. Against the current script it fails with
`FAIL: the legacy operator was scaled back up after a successful
adoption`.
- The existing test for a failed adoption still passes, so the EXIT trap
still restores the operator to 1.
- The new test checks the absence with `if grep ...; then echo "FAIL:
..."; false; fi`, the form the other suites use. A bare `! grep`
assertion cannot fail a test under `hack/cozytest.sh`, so it could not
have caught this. The other negated assertions in this file had the same
weakness, and #3790 has rewritten them on main. With all of them made
able to fail in a scratch copy, the suite still passes against this
change.

### Screenshots

Not a UI change.

### Downstream repositories

Walked the trigger map in `docs/agents/contributing.md` against the
diff. It changes a migration script and its bats test. No package,
values, schema, CRD, make target or `hack/` layout changes, so no entry
matches.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
fix(platform): keep the legacy etcd-operator stopped after migration 50 adopts the legacy etcd clusters. Scaling it back up let it reconcile the legacy EtcdCluster that Flux re-created from a not yet upgraded etcd chart, re-create the legacy StatefulSet and take the legacy etcd-headless Service away from the adopted members, which the garbage collector then deleted. The adopted members lost their DNS names, the EtcdCluster never reported a status and etcd defrag failed every hour. Clusters already upgraded can recreate the Service from the etcd release manifest.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Migration Updates**
* After successful etcd adoption, the legacy operator remains scaled to
zero replicas for replacement by the etcd operator; it is no longer
scaled back up before the migration is stamped.
* If adoption fails or exits before completion, the legacy operator is
restored to one replica.
* The dry-run message now describes the zero-replica outcome of
successful adoption.
  * Successful adoption continues to record the migration version stamp.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/testing Issues or PRs related to testing (e2e, bats, unit tests) size/XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Assertions written as ! command cannot fail a test

2 participants