Skip to content

Commit cc04f77

Browse files
committed
test(e2e): pin the release-history read against the upstream type
The test that claimed to pin the HelmRelease v2 history shape wrote a HelmRelease document containing the field names it expected, then read that same document back with the same jsonpath. It established that yq can read a fixture the test itself composed. Its own comment described the failure it was meant to catch: a future Flux release renames the field, the jsonpath matches nothing, the guard reports no remediation cycle, and real cycles pass the e2e assertion. A rename moves neither the fixture nor the expression, so the test stays green through exactly that event. The comment also named where the truth lives, github.com/fluxcd/helm-controller/api/v2, and pinned a copy of the author's expectations instead. Replace it with a Go test that supplies neither half. The expression is read out of the shell library that ships it, and the object is built from the upstream type at the version go.mod holds, then serialized and matched the way kubectl does. A renamed or retyped field is a compile error or a failed match; a guard that stops reading history fails the lookup with a message saying so. Both directions were confirmed by mutation. Locating the expression by searching the file is not enough on its own. Inline in a long script, locating it means deciding which occurrences are code and which are prose, and every way of getting that wrong fails green: an example quoted near a broken read satisfies the pin while the guard reads nothing. So the expression moves into a named assignment in remediation-guard.sh, which the guard already sources, and the test anchors on that assignment at the start of a line. Text that only mentions the expression cannot begin a line with the variable name and an `=`, so the question of comment against code is removed rather than answered. The anchor is enough because of the file it is pointed at, a short library of assignments and functions; that is a property of the file, not of anchors. Moving the expression out of the call site puts a step between what is pinned and what runs, so the call site is checked too: an anchored assignment taking the shared name, and no release-history jsonpath written out beside it. The library is checked the same way, since an assignment indented inside a function is invisible to an anchor, runs anyway, and wins. Each rule is pinned by its own test, so a regression names which one it opened rather than only that extraction broke. How kubectl's printer treats an absent path is its own configuration rather than part of this contract, so it is left unasserted: guessing at it would put the expectation and the fixture back in the same hands. Assisted-By: Claude <[email protected]> Signed-off-by: Aleksei Sviridkin <[email protected]>
1 parent 677d80a commit cc04f77

5 files changed

Lines changed: 290 additions & 49 deletions

File tree

‎docs/agents/e2e-testing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ The install test fails if **any** HelmRelease is not Ready. A toothless gate (ba
6262

6363
A parent HelmRelease that hit its wait timeout, uninstalled, and reinstalled is a silent race we want to catch. **Do not** check `.status.installFailures` / `.status.upgradeFailures`: Flux's `ClearFailures()` zeroes those on every successful reconcile, so checking them after the HR is Ready is **vacuous** and passes against a reverted fix.
6464

65-
- Inspect `.status.history` instead — a `failed` or `uninstalled` Snapshot survives a later successful reconcile. Use the shared helper in `hack/e2e-chainsaw/_lib/remediation-guard.sh` (`helmrelease_has_remediation_cycle`) from a `script` step.
65+
- Inspect `.status.history` instead — a `failed` or `uninstalled` Snapshot survives a later successful reconcile. Use the shared helper in `hack/e2e-chainsaw/_lib/remediation-guard.sh` (`helmrelease_has_remediation_cycle`) from a `script` step. Take the jsonpath from `HELMRELEASE_HISTORY_JSONPATH` in that same file rather than writing it out: `internal/fluxcontract` runs that assignment against the upstream Flux type, and a literal copied elsewhere is not covered by it.
6666

6767
### 7. Test-Impact Analysis (TIA), default-on
6868

‎hack/e2e-chainsaw/_lib/remediation-guard.sh‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@
2222
# "uninstalled", 1 otherwise. Empty input is treated as "no history yet,
2323
# no cycle observed".
2424

25+
# The jsonpath a caller hands to kubectl to read those statuses, one per
26+
# line. It lives here rather than inline at the call site so that it is a
27+
# whole line with a name, which is what lets internal/fluxcontract read it
28+
# back out and run it against the upstream Flux type: renaming a field
29+
# upstream fails that test instead of turning the read into a silent empty
30+
# string. That test runs under `make test-controllers`, not
31+
# `make unit-tests`.
32+
# shellcheck disable=SC2034 # used by callers that source this file
33+
HELMRELEASE_HISTORY_JSONPATH='{range .status.history[*]}{.status}{"\n"}{end}'
34+
2535
helmrelease_has_remediation_cycle() {
2636
statuses="$1"
2737
if [ -z "${statuses}" ]; then

‎hack/e2e-chainsaw/_lib/run-kubernetes.sh‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2280,10 +2280,10 @@ EOF
22802280
# leaves a "failed" or "uninstalled" entry behind that survives a later
22812281
# successful reinstall, unlike the installFailures/upgradeFailures
22822282
# counters (which ClearFailures zeroes on every successful reconcile).
2283-
# The shape is pinned by hack/remediation-guard.bats; the upstream
2284-
# types are github.com/fluxcd/helm-controller/api v2 Snapshot.
2283+
# The expression comes from remediation-guard.sh, sourced at the top of this
2284+
# file, where it is pinned against the upstream Flux type.
22852285
history_statuses=$(kubectl get hr -n tenant-test "kubernetes-${test_name}" \
2286-
-ojsonpath='{range .status.history[*]}{.status}{"\n"}{end}')
2286+
-o"jsonpath=${HELMRELEASE_HISTORY_JSONPATH}")
22872287
# Always emit the raw value so a silent future-Flux field rename shows
22882288
# up as "empty history on a Ready HR" in CI logs rather than vanishing.
22892289
echo "Parent HelmRelease history statuses:"

‎hack/remediation-guard.bats‎

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -80,48 +80,3 @@
8080
exit 1
8181
fi
8282
}
83-
84-
@test "status.history extraction pins HR v2 status.history shape" {
85-
# Pins the Flux HelmRelease v2 .status.history[].status shape that
86-
# run-kubernetes.sh relies on. If a future flux release renames the
87-
# field, the jsonpath returns nothing, the guard reports no cycle,
88-
# and real remediation loops slip past the e2e assertion. This test
89-
# uses yq to read the exact path used in the e2e script; the upstream
90-
# Snapshot type lives at
91-
# github.com/fluxcd/helm-controller/api/v2.Snapshot (via go.mod).
92-
tmp=$(mktemp -d)
93-
94-
cat > "$tmp/hr.yaml" <<'YAML'
95-
apiVersion: helm.toolkit.fluxcd.io/v2
96-
kind: HelmRelease
97-
metadata:
98-
name: kubernetes-test
99-
namespace: tenant-test
100-
status:
101-
history:
102-
- name: kubernetes-test
103-
namespace: tenant-test
104-
version: 1
105-
status: uninstalled
106-
- name: kubernetes-test
107-
namespace: tenant-test
108-
version: 2
109-
status: deployed
110-
YAML
111-
112-
# Default yq output is yaml scalar format, which for string values emits
113-
# bare unquoted tokens - matching what kubectl -o jsonpath produces in
114-
# e2e. Do not switch to JSON output here; that would quote the values
115-
# and break the loop in helmrelease_has_remediation_cycle.
116-
statuses=$(yq '.status.history[].status' "$tmp/hr.yaml")
117-
118-
[ -n "$statuses" ]
119-
echo "$statuses" | grep --quiet '^uninstalled$'
120-
121-
. hack/e2e-chainsaw/_lib/remediation-guard.sh
122-
if ! helmrelease_has_remediation_cycle "$statuses"; then
123-
echo "expected detected for pinned HR snippet with uninstalled + deployed history" >&2
124-
exit 1
125-
fi
126-
rm -rf "$tmp"
127-
}
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// Package fluxcontract holds contract tests for upstream Flux types this
2+
// repository reads through kubectl rather than through the Go API.
3+
//
4+
// A shell script that pulls a field out of a Kubernetes object with
5+
// `kubectl -o jsonpath` depends on the serialized shape of an upstream Go
6+
// struct, and nothing in the shell layer can observe that dependency. Rename
7+
// the field upstream and the expression stops matching: kubectl prints
8+
// nothing, exits zero, and the caller reads the empty output as an answer
9+
// about the object rather than as a question that no longer parses.
10+
//
11+
// The test for that cannot live in the shell layer either. Pointing an
12+
// expression at a document written by the same test proves the reader can read
13+
// that document and nothing else, because a rename moves neither side.
14+
//
15+
// So this file supplies neither half. The expression is read out of the shell
16+
// library that ships it, and the object is built from the upstream type at the
17+
// version go.mod pins. What is left to assert is that the one still finds the
18+
// other.
19+
package fluxcontract
20+
21+
import (
22+
"encoding/json"
23+
"fmt"
24+
"os"
25+
"path/filepath"
26+
"regexp"
27+
"slices"
28+
"strings"
29+
"testing"
30+
31+
helmv2 "github.com/fluxcd/helm-controller/api/v2"
32+
"k8s.io/client-go/util/jsonpath"
33+
)
34+
35+
// The shell library the e2e remediation guard sources, read at test time so
36+
// the expression under test is the one that ships rather than a copy of it.
37+
const guardLibrary = "../../hack/e2e-chainsaw/_lib/remediation-guard.sh"
38+
39+
// The assignment carrying the release-history jsonpath.
40+
//
41+
// Anchored to the start of a line, which is the whole reason the expression
42+
// lives in a named assignment instead of inline at the call site: a commented
43+
// example cannot begin a line with the variable name and an `=`, so it cannot
44+
// be mistaken for the assignment the shell executes. Matching the expression
45+
// wherever it appeared in a script would mean deciding which occurrences are
46+
// code, and getting that wrong reads a comment as the contract while the guard
47+
// reads nothing.
48+
//
49+
// The anchor is enough because of what it is pointed at. This library is a
50+
// short file of assignments and functions; a heredoc body line starts at
51+
// column zero like any other, so moving the assignment into a script that
52+
// writes heredocs would need more than an anchor to find it again.
53+
//
54+
// Single quotes because the expression contains `{"\n"}`: a double-quoted
55+
// shell assignment would have to escape its way around its own delimiter,
56+
// nobody writes it that way, and matching it would mean giving up the
57+
// delimiter as a terminator.
58+
var historyAssignment = regexp.MustCompile(
59+
`(?m)^HELMRELEASE_HISTORY_JSONPATH='([^']*)'`)
60+
61+
// historyExpressions returns the jsonpath from every such assignment, in file
62+
// order.
63+
func historyExpressions(src []byte) []string {
64+
var exprs []string
65+
for _, m := range historyAssignment.FindAllSubmatch(src, -1) {
66+
exprs = append(exprs, string(m[1]))
67+
}
68+
return exprs
69+
}
70+
71+
// readGuardExpressions pulls the release-history jsonpaths out of the guard
72+
// library. A miss fails rather than skips: the guard losing this read is the
73+
// change this test exists to notice.
74+
func readGuardExpressions(t *testing.T) []string {
75+
t.Helper()
76+
src, err := os.ReadFile(filepath.Clean(guardLibrary))
77+
if err != nil {
78+
t.Fatalf("reading %s: %v", guardLibrary, err)
79+
}
80+
exprs := historyExpressions(src)
81+
if len(exprs) == 0 {
82+
t.Fatalf("no HELMRELEASE_HISTORY_JSONPATH assignment starting a line in %s. "+
83+
"The assignment has to begin a line for this test to find it, so an indented or "+
84+
"relocated one reads the same as a missing one from here. "+
85+
"If the guard reads release history another way now, this test has to follow it there. "+
86+
"If it stopped reading history at all, the e2e remediation guard asserts nothing.",
87+
guardLibrary)
88+
}
89+
return exprs
90+
}
91+
92+
// A HelmRelease whose history carries the statuses the guard reads. Built from
93+
// the upstream types, so a renamed or retyped field is a compile error here
94+
// before it is a silent empty read against a live cluster.
95+
func helmReleaseWithHistory() *helmv2.HelmRelease {
96+
return &helmv2.HelmRelease{
97+
Status: helmv2.HelmReleaseStatus{
98+
History: helmv2.Snapshots{
99+
{Version: 2, Status: "deployed"},
100+
{Version: 1, Status: "uninstalled"},
101+
},
102+
},
103+
}
104+
}
105+
106+
// The guard's expressions, run over the upstream type through JSON, the way
107+
// kubectl runs them. Serialization is the whole point: the struct's json tags
108+
// are what an expression actually sees.
109+
//
110+
// Every assignment is asserted rather than the first. The shell would use the
111+
// last one, so checking all of them is stricter than the runtime in the case
112+
// where they disagree, which is the safe direction for a pin.
113+
//
114+
// Only the match is asserted. How kubectl's printer behaves on a path that is
115+
// absent is kubectl's own configuration, not part of the Flux contract, and
116+
// guessing at it here would put the expectation and the fixture back in the
117+
// same hands.
118+
func TestGuardExpressionReadsUpstreamHistoryStatuses(t *testing.T) {
119+
raw, err := json.Marshal(helmReleaseWithHistory())
120+
if err != nil {
121+
t.Fatalf("marshalling HelmRelease: %v", err)
122+
}
123+
var generic any
124+
if err := json.Unmarshal(raw, &generic); err != nil {
125+
t.Fatalf("unmarshalling HelmRelease: %v", err)
126+
}
127+
128+
for n, expr := range readGuardExpressions(t) {
129+
t.Run(fmt.Sprintf("assignment_%d", n+1), func(t *testing.T) {
130+
jp := jsonpath.New("guard")
131+
if err := jp.Parse(expr); err != nil {
132+
t.Fatalf("parsing the guard's expression %q: %v", expr, err)
133+
}
134+
var out strings.Builder
135+
if err := jp.Execute(&out, generic); err != nil {
136+
t.Fatalf("running the guard's expression %q over an upstream HelmRelease: %v. "+
137+
"The serialized shape no longer satisfies the expression.", expr, err)
138+
}
139+
140+
var got []string
141+
for _, line := range strings.Split(out.String(), "\n") {
142+
if line != "" {
143+
got = append(got, line)
144+
}
145+
}
146+
want := []string{"deployed", "uninstalled"}
147+
if len(got) != len(want) {
148+
t.Fatalf("expression %q returned %q, want one line per Snapshot status %q",
149+
expr, out.String(), want)
150+
}
151+
for i := range want {
152+
if got[i] != want[i] {
153+
t.Errorf("status %d: got %q, want %q", i, got[i], want[i])
154+
}
155+
}
156+
})
157+
}
158+
}
159+
160+
// The line anchor is what makes a match the contract rather than a mention of
161+
// it. Dropping it fails green: the expression would still be found, in text
162+
// the shell never runs, while the assignment it is meant to pin could be
163+
// anything.
164+
//
165+
// The indented case is not a mention but real shell, an assignment inside a
166+
// function. It is listed because the anchor rejects it too, and that is worth
167+
// pinning: the rejection is safe, since a relocated assignment fails the
168+
// lookup rather than passing on stale text, but only as long as the lookup
169+
// says the assignment must begin a line.
170+
func TestHistoryExpressionsRequireAnAssignmentThatStartsALine(t *testing.T) {
171+
for _, tc := range []struct {
172+
placement string
173+
line string
174+
}{
175+
{"comment", `# HELMRELEASE_HISTORY_JSONPATH='{range .status.history[*]}{.status}{"\n"}{end}'`},
176+
{"indented shell", ` HELMRELEASE_HISTORY_JSONPATH='{range .status.history[*]}{.status}{"\n"}{end}'`},
177+
{"mid-line", `echo HELMRELEASE_HISTORY_JSONPATH='{range .status.history[*]}{.status}{"\n"}{end}'`},
178+
} {
179+
t.Run(tc.placement, func(t *testing.T) {
180+
if got := historyExpressions([]byte(tc.line)); len(got) != 0 {
181+
t.Errorf("returned as the assignment: %q", got)
182+
}
183+
})
184+
}
185+
}
186+
187+
// The read at the call site, as an assignment rather than as text occurring
188+
// somewhere in the script. Anchored for the same reason the assignment in the
189+
// library is: a mention satisfies a search over a file, so the shared name
190+
// quoted in a trailing `# was:` note would stand in for a read that has been
191+
// rewritten to something else.
192+
//
193+
// What it requires is the substance and nothing beside it: a line-starting
194+
// assignment, from a kubectl call, whose jsonpath comes from the shared name.
195+
// The local's name and the spelling of `-o` are deliberately left free, since
196+
// this file mixes several spellings that behave identically and a pin on the
197+
// incidental ones is a red for a rename that changed nothing.
198+
var sharedHistoryRead = regexp.MustCompile(
199+
`(?m)^[ \t]*[A-Za-z_][A-Za-z0-9_]*=\$\(kubectl[^)]*jsonpath=[^)]*HELMRELEASE_HISTORY_JSONPATH`)
200+
201+
// The shared name being assigned, anywhere and however indented. Every
202+
// occurrence has to be one the anchored search above already found: a second
203+
// assignment tucked inside a function is invisible to an anchor, runs anyway,
204+
// and wins, so the pin would be green about an expression kubectl never sees.
205+
var anyHistoryAssignment = regexp.MustCompile(`HELMRELEASE_HISTORY_JSONPATH=`)
206+
207+
// A release-history jsonpath written out rather than referenced, in either
208+
// quoting the script mixes and whatever the path around it spells. Forbidding
209+
// one spelling would leave every other one free, which is how a rewritten read
210+
// keeps a green pin.
211+
var literalHistoryRead = regexp.MustCompile(`jsonpath=['"]?[^"']*\.status\.history`)
212+
213+
// The consumer of the shared expression, checked because moving the
214+
// expression out of the call site put a step between what is pinned and what
215+
// runs. A pin on a variable nobody reads is green about a value the guard
216+
// never passes to kubectl, so the call site has to take the expression from
217+
// the variable and carry no history jsonpath of its own.
218+
//
219+
// The second half is a should-not-exist check, and it is loud when wrong: an
220+
// example of the expression written into this script trips it, which asks for
221+
// the example to move rather than letting a literal drift away from the pin
222+
// unnoticed.
223+
func TestGuardCallSiteUsesTheSharedExpression(t *testing.T) {
224+
const callSite = "../../hack/e2e-chainsaw/_lib/run-kubernetes.sh"
225+
src, err := os.ReadFile(filepath.Clean(callSite))
226+
if err != nil {
227+
t.Fatalf("reading %s: %v", callSite, err)
228+
}
229+
230+
if !sharedHistoryRead.Match(src) {
231+
t.Errorf("%s has no history_statuses assignment reading "+
232+
"-o\"jsonpath=${HELMRELEASE_HISTORY_JSONPATH}\". "+
233+
"The expression pinned here is not the one the guard passes to kubectl.", callSite)
234+
}
235+
if m := literalHistoryRead.Find(src); m != nil {
236+
t.Errorf("%s spells a release-history jsonpath out (%q) instead of referencing the "+
237+
"shared assignment, so the pin would cover a value nobody passes to kubectl.",
238+
callSite, m)
239+
}
240+
}
241+
242+
// The anchor decides what this pin can see, so what it cannot see has to be
243+
// zero rather than assumed. An assignment the anchor misses is not inert: the
244+
// shell runs it, the last one wins, and the pin reports on a value that was
245+
// overwritten. Relocating the only assignment fails the lookup and is caught;
246+
// adding a second one beside it is what needs its own check.
247+
func TestGuardLibraryAssignsTheExpressionWhereThePinCanSeeIt(t *testing.T) {
248+
src, err := os.ReadFile(filepath.Clean(guardLibrary))
249+
if err != nil {
250+
t.Fatalf("reading %s: %v", guardLibrary, err)
251+
}
252+
253+
all := len(anyHistoryAssignment.FindAll(src, -1))
254+
if pinned := len(historyExpressions(src)); all != pinned {
255+
t.Errorf("%s assigns HELMRELEASE_HISTORY_JSONPATH %d times, %d of them starting a line. "+
256+
"An assignment this test cannot see still runs, and the last one to run wins.",
257+
guardLibrary, all, pinned)
258+
}
259+
}
260+
261+
// Stopping at the first assignment would pin whichever comes first in the file
262+
// and say nothing about the one the shell actually leaves in the variable.
263+
func TestHistoryExpressionsReturnEveryAssignment(t *testing.T) {
264+
src := []byte(strings.Join([]string{
265+
`HELMRELEASE_HISTORY_JSONPATH='{range .status.history[*]}{.first}{"\n"}{end}'`,
266+
`HELMRELEASE_HISTORY_JSONPATH='{range .status.history[*]}{.second}{"\n"}{end}'`,
267+
}, "\n"))
268+
269+
want := []string{
270+
`{range .status.history[*]}{.first}{"\n"}{end}`,
271+
`{range .status.history[*]}{.second}{"\n"}{end}`,
272+
}
273+
if got := historyExpressions(src); !slices.Equal(got, want) {
274+
t.Errorf("got %q, want %q", got, want)
275+
}
276+
}

0 commit comments

Comments
 (0)