|
| 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