Skip to content

Commit b4fbaf0

Browse files
authored
test(e2e): keep gateway and redis-tls op ceilings above their own waits (#4643)
<!-- Thank you for making a contribution! Here are some tips for you: - Use Conventional Commits for the PR title: `type(scope): description` - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore - Scopes are not an exhaustive list — pick the most specific scope for the change and extend the list when a genuinely new area appears. Examples: - System components: dashboard, platform, operator, cilium, kube-ovn, linstor, fluxcd, cluster-api - Managed apps: postgres, mariadb, redis, kafka, clickhouse, virtual-machine, kubernetes - Development and maintenance: api, hack, tests, ci, docs, maintenance - Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or add a `BREAKING CHANGE:` footer - If it's a work in progress, consider creating this PR as a draft. - Don't hesistate to ask for opinion and review in the community chats, even if it's still a draft. - Add the label `kind/backport` if it's a bugfix that needs to be backported to a previous version. --> ## What this PR does Two Chainsaw script ops had a timeout smaller than the waits inside them. When the op ceiling fires first, Chainsaw kills the shell: the script never prints its own error, and a cleanup trap never runs. The gateway listener-set step could poll for 660s under a 6m op. Its cleanup trap also deleted two tenants with no limit on the wait. Each delete now runs under an outer `timeout`, because kubectl's own `--timeout` does not cover discovery or a stalled request. A tenant delete gets 300s, the same 5m that `.chainsaw.yaml` gives a delete op. The parent is deleted only after the child is gone, because deleting a parent while its child is still uninstalling wedges the parent's cleanup. A failed tenant delete fails the step with kubectl's own error, so a leaked tenant shows up here and not in a later suite. The op is now 24m, which covers the polls plus those bounds. The redis-tls CA-projection step has 30s read caps that add up to well over its 5m op. Raising the op would not hold, because several reads run in loops. So all reads now share a 240s step budget. No read starts after the budget runs out, and a read that is still running gets cut off when it does. A read that hits its bound, or finds the budget already spent, says which read it was. Two exec checks in the same step blamed the 15s redis-cli cap for any failure, including a refused exec or a missing container. They now print the exit status and what the exec printed. The step also runs under bash now, for pipefail. The sandbox's `/bin/sh` is dash, which rejects `set -o pipefail`. Chainsaw 0.2.15, the version the sandbox image installs, supports a `shell:` field on script ops. Without pipefail, a failed Secret read took base64's zero exit status and was reported as a wrong source name. The private-key check read the projection a second time, so a failed second read made it pass. It now checks the value the step already fetched. I added `hack/chainsaw-op-budgets.bats` to guard all of this. It holds the gateway op above its polls plus its delete bounds, keeps the child-before-parent order, holds the redis budget under its op with every kubectl call going through the clamped helper, and runs the redis step's script against a stubbed kubectl to check each failure message and the clamp. On main, 11 of its 12 tests fail. The one that passes is the healthy-stub control. Closes #4114 Closes #4250 Closes #4246 ### Screenshots <!-- REQUIRED for UI changes: attach screenshots or screen recordings demonstrating the visual impact of your changes. PRs with UI changes without screenshots will not be merged. --> Not a UI change. ### Downstream repositories - [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: The diff touches two Chainsaw suites and adds one unit test file under `hack/`. It moves or renames nothing a downstream repository reads. ### Release note ```release-note test(e2e): the gateway listener-set and redis-tls projection steps now fail on their own errors instead of being killed by the op timeout, and redis-tls reports the real cause of a failed read or exec. ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Added automated checks to verify gateway operations have sufficient time to complete and cleanup deletions are bounded and correctly ordered. * Expanded gateway scenarios to cover cleanup failures: if child-tenant deletion fails or times out, the parent is left in place; deletion failures are reported. * Updated Redis TLS checks with bounded read times and a shared execution limit. Failure reports include command output and status, and checks cover private-key material in the decoded projection. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 1ca603a + cc731e3 commit b4fbaf0

3 files changed

Lines changed: 416 additions & 25 deletions

File tree

‎hack/chainsaw-op-budgets.bats‎

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
#!/usr/bin/env bats
2+
# -----------------------------------------------------------------------------
3+
# Contract: a Chainsaw script op whose script carries its own waits gives those
4+
# waits room to fire first. When the op ceiling fires instead, Chainsaw kills
5+
# the shell, the script's own failure message never prints, and a cleanup trap
6+
# never runs (.chainsaw.yaml states the rule above its catch scripts).
7+
#
8+
# The gateway step is held by arithmetic: its polls are `timeout N` prefixes
9+
# and its cleanup deletes run under `timeout -k K N`, so their sum, kill
10+
# grace included, is readable from the text. kubectl's own `--timeout` is not
11+
# counted: it bounds only the wait after the delete request, not discovery or
12+
# the request itself. Only those two spellings are summed: a wait written any
13+
# other way, a `kubectl wait` or a sleep loop, is invisible to the sum. The
14+
# redis-tls step cannot be summed that way, because some of its
15+
# reads sit in loops and one loop polls, so it carries a step budget that
16+
# every kubectl call is clamped to, and the guard holds that budget under the
17+
# op. Its failure messages are checked by running the step's script against a
18+
# stubbed kubectl.
19+
#
20+
# cozytest.sh's awk parser recognizes only @test blocks and a bare `}` on its
21+
# own line; a `}` at column zero gets `return 0` inserted ahead of it, so the
22+
# helpers below report through stdout only.
23+
#
24+
# Run with: hack/cozytest.sh hack/chainsaw-op-budgets.bats
25+
# -----------------------------------------------------------------------------
26+
27+
GATEWAY_SUITE="hack/e2e-chainsaw/gateway/chainsaw-test.yaml"
28+
GATEWAY_STEP="parent-child-and-route-drive-listener-set"
29+
REDIS_SUITE="hack/e2e-chainsaw/redis/chainsaw-test.yaml"
30+
REDIS_STEP="verify-tenant-ca-projection"
31+
32+
# Seconds the gateway op keeps above its summed waits for what carries no cap
33+
# of its own: three applies and one label read.
34+
GATEWAY_UNCAPPED=60
35+
# Seconds the redis op keeps above its step budget: the kill grace on the last
36+
# clamped read, one 5s poll sleep, and the openssl and shell work between reads.
37+
REDIS_AFTER_BUDGET=30
38+
39+
# Print the lines of the first `- script:` op inside step $2 of file $1, with
40+
# the op's own indentation, from `- script:` up to the next op or step.
41+
op_lines() {
42+
awk -v step="$2" '
43+
$0 ~ "^[[:space:]]*- name: " step "[[:space:]]*$" { instep = 1; next }
44+
instep && !inop && /^[[:space:]]*- script:[[:space:]]*$/ {
45+
match($0, /^[[:space:]]*/); opindent = RLENGTH; inop = 1; print; next
46+
}
47+
inop && /[^[:space:]]/ {
48+
match($0, /^[[:space:]]*/)
49+
if (RLENGTH <= opindent) exit
50+
}
51+
inop { print }
52+
instep && /^---/ { exit }
53+
' "$1"
54+
}
55+
56+
# Print the op timeout in seconds of the op in $1 (op_lines output), or
57+
# nothing when it states none in minutes or seconds.
58+
op_timeout_seconds() {
59+
printf '%s\n' "$1" | awk '
60+
/^[[:space:]]*timeout:[[:space:]]*[0-9]+m[[:space:]]*$/ { match($0, /[0-9]+/); print substr($0, RSTART, RLENGTH) * 60; exit }
61+
/^[[:space:]]*timeout:[[:space:]]*[0-9]+s[[:space:]]*$/ { match($0, /[0-9]+/); print substr($0, RSTART, RLENGTH); exit }
62+
/^[[:space:]]*content:/ { exit }
63+
'
64+
}
65+
66+
# Print the op's shell, or `sh`, the Chainsaw default, when it names none.
67+
op_shell() {
68+
sh_name=$(printf '%s\n' "$1" | awk '
69+
/^[[:space:]]*shell:[[:space:]]*[^[:space:]]+[[:space:]]*$/ { sub(/^[[:space:]]*shell:[[:space:]]*/, ""); sub(/[[:space:]]*$/, ""); print; exit }
70+
/^[[:space:]]*content:/ { exit }
71+
')
72+
printf '%s\n' "${sh_name:-sh}"
73+
}
74+
75+
# Print the op's script, dedented, without the content key.
76+
op_content() {
77+
printf '%s\n' "$1" | awk '
78+
!incontent && /^[[:space:]]*content:[[:space:]]*\|[[:space:]]*$/ { match($0, /^[[:space:]]*/); keyindent = RLENGTH; incontent = 1; next }
79+
incontent && /[^[:space:]]/ {
80+
match($0, /^[[:space:]]*/)
81+
if (RLENGTH <= keyindent) exit
82+
if (!bodyindent) bodyindent = RLENGTH
83+
}
84+
incontent { print substr($0, bodyindent + 1) }
85+
'
86+
}
87+
88+
# Write a kubectl stub into directory $1 that answers the redis-tls step's
89+
# reads as a healthy cluster would. STUB_FAIL, when set, is a pattern matched
90+
# against the arguments: a matching call prints STUB_ERR to stderr and exits
91+
# STUB_RC instead, after printing STUB_OUT to stdout when that is set, from the
92+
# STUB_FAIL_FROM-th matching call on (default the first). STUB_PROJ_KEY=1 puts
93+
# PEM private key material into the projection. STUB_SLEEP_ON is a pattern
94+
# whose matching calls sleep STUB_SLEEP seconds first; the stub execs sleep,
95+
# so a timeout that kills it leaves no child holding the output pipe.
96+
write_redis_stubs() {
97+
cat > "$1/kubectl" <<'STUB'
98+
#!/bin/sh
99+
args="$*"
100+
if [ -n "${STUB_SLEEP_ON:-}" ]; then
101+
case "$args" in
102+
$STUB_SLEEP_ON) exec sleep "${STUB_SLEEP:-60}" ;;
103+
esac
104+
fi
105+
if [ -n "${STUB_FAIL:-}" ]; then
106+
case "$args" in
107+
$STUB_FAIL)
108+
echo x >> "$(dirname "$0")/fail-matches"
109+
if [ "$(wc -l < "$(dirname "$0")/fail-matches")" -ge "${STUB_FAIL_FROM:-1}" ]; then
110+
[ -z "${STUB_OUT:-}" ] || echo "$STUB_OUT"
111+
echo "${STUB_ERR:-error: stubbed failure}" >&2; exit "${STUB_RC:-1}"
112+
fi ;;
113+
esac
114+
fi
115+
cert_b64=$(printf 'CERT' | base64)
116+
proj_b64=$cert_b64
117+
if [ "${STUB_PROJ_KEY:-}" = 1 ]; then
118+
proj_b64=$(printf 'CERT\n-----BEGIN PRIVATE KEY-----\nKEY\n' | base64 | tr -d '\n')
119+
fi
120+
case "$args" in
121+
*"get secret redis-secure.tenant-ca -o go-template"*) printf 'ca.crt\n' ;;
122+
*"get secret redis-secure.ca-cert -o go-template"*) printf 'ca.crt\n' ;;
123+
*"get secret redis-secure.ca-tls -o go-template"*) printf 'ca.crt\ntls.crt\ntls.key\n' ;;
124+
*"get secret redis-secure.ca-tls -o jsonpath"*) printf '%s' "$cert_b64" ;;
125+
*"get secret redis-secure.tenant-ca -o jsonpath"*) printf '%s' "$proj_b64" ;;
126+
*"get tenantsecret redis-secure.tenant-ca -o jsonpath"*) printf '%s' "$(printf -- '-----BEGIN CERTIFICATE-----' | base64)" ;;
127+
*"get tenantsecret "*) echo "Error from server (NotFound): tenantsecrets \"x\" not found" >&2; exit 1 ;;
128+
*"exec rfr-redis-secure-0 -c redis -- cat /tls/ca.crt"*) printf 'CERT\n' ;;
129+
*"exec rfr-redis-secure-0 -c redis -- timeout 15 redis-cli --tls"*) printf 'PONG\n' ;;
130+
*"exec rfr-redis-secure-0 -c redis -- timeout 15 redis-cli -h"*) printf 'I/O error\n'; exit 1 ;;
131+
*"exec rfr-redis-secure-0 -c redis -- sh -c"*) printf 'role:master\n' ;;
132+
*"exec rfr-redis-secure-1 -c redis -- sh -c"*) printf 'role:slave\nmaster_link_status:up\n' ;;
133+
*"get pod -l app.kubernetes.io/component=sentinel"*) printf 'rfs-redis-secure-0' ;;
134+
*"exec rfs-redis-secure-0 -c sentinel"*) printf '10.0.0.1\n6379\n' ;;
135+
*) echo "kubectl stub: unexpected call: $args" >&2; exit 99 ;;
136+
esac
137+
STUB
138+
cat > "$1/openssl" <<'STUB'
139+
#!/bin/sh
140+
cat >/dev/null
141+
echo "sha256 Fingerprint=AA"
142+
STUB
143+
chmod +x "$1/kubectl" "$1/openssl"
144+
}
145+
146+
# Run the redis-tls step's script, as its op's shell would, against the stubs
147+
# in $1. Prints the script's combined output and then `rc=<status>`. RS_BUDGET,
148+
# when set, replaces the step's budget so the clamp can be driven in seconds.
149+
run_redis_step() {
150+
rs_op=$(op_lines "$REDIS_SUITE" "$REDIS_STEP")
151+
rs_shell=$(op_shell "$rs_op")
152+
op_content "$rs_op" > "$1/step.sh"
153+
if [ -n "${RS_BUDGET:-}" ]; then
154+
sed "s/^step_budget=[0-9]*\$/step_budget=${RS_BUDGET}/" "$1/step.sh" > "$1/step.budget"
155+
if cmp -s "$1/step.sh" "$1/step.budget"; then echo "no step_budget line to override"; echo "rc=97"; return; fi
156+
mv "$1/step.budget" "$1/step.sh"
157+
fi
158+
rs_rc=0
159+
rs_out=$(cd "$1" && PATH="$1:$PATH" NAMESPACE=tenant-test STUB_FAIL="${STUB_FAIL:-}" STUB_FAIL_FROM="${STUB_FAIL_FROM:-1}" STUB_RC="${STUB_RC:-1}" STUB_ERR="${STUB_ERR:-}" STUB_OUT="${STUB_OUT:-}" STUB_SLEEP_ON="${STUB_SLEEP_ON:-}" STUB_SLEEP="${STUB_SLEEP:-}" STUB_PROJ_KEY="${STUB_PROJ_KEY:-}" "$rs_shell" "$1/step.sh" 2>&1) || rs_rc=$?
160+
printf '%s\nrc=%s\n' "$rs_out" "$rs_rc"
161+
}
162+
163+
@test "the gateway listener-set op outlasts the waits inside it" {
164+
gw_op=$(op_lines "$GATEWAY_SUITE" "$GATEWAY_STEP")
165+
[ -n "$gw_op" ] || { echo "no script op found in step $GATEWAY_STEP of $GATEWAY_SUITE" >&2; exit 1; }
166+
gw_timeout=$(op_timeout_seconds "$gw_op")
167+
case "$gw_timeout" in ''|*[!0-9]*) echo "the $GATEWAY_STEP op states no timeout in m or s (got '${gw_timeout}')" >&2; exit 1 ;; esac
168+
gw_body=$(op_content "$gw_op")
169+
gw_polls=$(printf '%s\n' "$gw_body" | grep -oE '^[[:space:]]*timeout +[0-9]+' | grep -oE '[0-9]+$' || true)
170+
gw_n=$(printf '%s\n' "$gw_polls" | grep -c . || true)
171+
[ "$gw_n" -ge 5 ] || { echo "found ${gw_n} capped polls in $GATEWAY_STEP, expected at least 5; the extractor is reading the wrong lines" >&2; exit 1; }
172+
gw_deletes=$(printf '%s\n' "$gw_body" | grep -E '(^|[[:space:]])kubectl .* delete ' | grep -oE '(^|[[:space:]])timeout -k [0-9]+ [0-9]+ kubectl ' | awk '{ print $3 + $4 }' || true)
173+
gw_sum=0
174+
for gw_w in $gw_polls $gw_deletes; do gw_sum=$(( gw_sum + gw_w )); done
175+
[ $(( gw_timeout - gw_sum )) -ge "$GATEWAY_UNCAPPED" ] || {
176+
echo "the $GATEWAY_STEP op allows ${gw_timeout}s; its polls ($(printf '%s ' $gw_polls)) and cleanup deletes ($(printf '%s ' $gw_deletes)) may wait ${gw_sum}s" >&2
177+
echo "it must keep ${GATEWAY_UNCAPPED}s above them for the uncapped applies and reads" >&2
178+
exit 1
179+
}
180+
}
181+
182+
@test "every delete in the gateway listener-set cleanup is bounded as a whole" {
183+
# A tenant delete blocks until its child namespaces drain, and a stalled
184+
# API request blocks with no bound at all, so a delete not wrapped in an
185+
# outer timeout is a wait the op sum above cannot see.
186+
gw_op=$(op_lines "$GATEWAY_SUITE" "$GATEWAY_STEP")
187+
gw_deletes=$(op_content "$gw_op" | grep -E '(^|[[:space:]])kubectl .* delete ' || true)
188+
[ -n "$gw_deletes" ] || { echo "no kubectl delete found in $GATEWAY_STEP; the cleanup this test reasons about is gone" >&2; exit 1; }
189+
gw_bad=$(printf '%s\n' "$gw_deletes" | grep -vE '(^|[[:space:]])timeout -k [0-9]+ [0-9]+ kubectl ' || true)
190+
[ -z "$gw_bad" ] || { echo "deletes in $GATEWAY_STEP whose wait has no bound:" >&2; printf '%s\n' "$gw_bad" >&2; exit 1; }
191+
}
192+
193+
@test "the gateway listener-set cleanup deletes the parent only once the child is gone" {
194+
# A parent deleted while its child is still uninstalling wedges the
195+
# parent's cleanup, so a child delete that ran into its bound must leave
196+
# the parent alone.
197+
gw_op=$(op_lines "$GATEWAY_SUITE" "$GATEWAY_STEP")
198+
gw_order=$(op_content "$gw_op" | awk '
199+
/^[[:space:]]*if timeout -k [0-9]+ [0-9]+ kubectl .* delete tenant rchild .*; then[[:space:]]*$/ { guarded = 1; next }
200+
guarded && /[^[:space:]]/ { print (/delete tenant rparent/ ? "ok" : "unguarded"); exit }
201+
/delete tenant rparent/ { print "unguarded"; exit }
202+
')
203+
[ "$gw_order" = ok ] || { echo "the rparent delete in $GATEWAY_STEP does not sit directly under a successful rchild delete (got '${gw_order}')" >&2; exit 1; }
204+
}
205+
206+
@test "a failed tenant delete in the gateway cleanup fails the step with its own error" {
207+
# A tenant left in place is one every later suite inherits, so
208+
# the step must say so and fail rather than pass with the leak.
209+
gw_dir=$(mktemp -d)
210+
op_content "$(op_lines "$GATEWAY_SUITE" "$GATEWAY_STEP")" | awk '/^cleanup\(\) \{/ { on = 1 } on { print } on && /^\}/ { exit }' > "$gw_dir/cleanup.sh"
211+
grep -q 'delete tenant rchild' "$gw_dir/cleanup.sh" || { echo "no cleanup() with the rchild delete found in $GATEWAY_STEP" >&2; exit 1; }
212+
# The stub fails the delete of the tenant named in STUB_FAIL_TENANT with a
213+
# Forbidden error, which is not the bound firing, and logs every delete.
214+
printf '%s\n' '#!/bin/sh' 'echo "$*" >> "$(dirname "$0")/calls"' 'case "$*" in *"delete tenant $STUB_FAIL_TENANT "*) echo "Error from server (Forbidden): stubbed" >&2; exit 3 ;; esac' > "$gw_dir/kubectl"
215+
chmod +x "$gw_dir/kubectl"
216+
for gw_tenant in rchild rparent; do
217+
: > "$gw_dir/calls"
218+
gw_rc=0
219+
gw_out=$(PATH="$gw_dir:$PATH" STUB_FAIL_TENANT="$gw_tenant" sh -c '. "$1/cleanup.sh"; ns=tenant-test; cleanup' sh "$gw_dir" 2>&1) || gw_rc=$?
220+
[ "$gw_rc" -ne 0 ] || { printf '%s\n' "$gw_out" >&2; echo "cleanup exited 0 with the $gw_tenant delete failed" >&2; exit 1; }
221+
case "$gw_out" in *"Forbidden"*"$gw_tenant delete exited 3"*"rparent is left in tenant-test"*) ;; *) printf '%s\n' "$gw_out" >&2; echo "with the $gw_tenant delete failed, the output does not carry kubectl's error, its exit status and the tenant left behind" >&2; exit 1 ;; esac
222+
if [ "$gw_tenant" = rchild ] && grep -q 'delete tenant rparent' "$gw_dir/calls"; then echo "the parent was deleted although the child delete failed" >&2; exit 1; fi
223+
done
224+
rm -rf "$gw_dir"
225+
}
226+
227+
@test "the redis-tls projection op outlasts the step budget its reads are clamped to" {
228+
rt_op=$(op_lines "$REDIS_SUITE" "$REDIS_STEP")
229+
[ -n "$rt_op" ] || { echo "no script op found in step $REDIS_STEP of $REDIS_SUITE" >&2; exit 1; }
230+
rt_timeout=$(op_timeout_seconds "$rt_op")
231+
case "$rt_timeout" in ''|*[!0-9]*) echo "the $REDIS_STEP op states no timeout in m or s (got '${rt_timeout}')" >&2; exit 1 ;; esac
232+
rt_body=$(op_content "$rt_op")
233+
rt_budget=$(printf '%s\n' "$rt_body" | sed -n 's/^step_budget=\([0-9][0-9]*\)$/\1/p')
234+
[ -n "$rt_budget" ] || {
235+
echo "$REDIS_STEP sets no step_budget, so nothing bounds the sum of its reads below the ${rt_timeout}s op" >&2
236+
echo "its kubectl call sites alone come to $(printf '%s\n' "$rt_body" | grep -cE '(^|[^[:alnum:]_])k -n ')" >&2
237+
exit 1
238+
}
239+
[ $(( rt_timeout - rt_budget )) -ge "$REDIS_AFTER_BUDGET" ] || {
240+
echo "the $REDIS_STEP op allows ${rt_timeout}s and the step budget is ${rt_budget}s" >&2
241+
echo "it must keep ${REDIS_AFTER_BUDGET}s above the budget for the last read's kill grace and the work between reads" >&2
242+
exit 1
243+
}
244+
# The budget bounds only the reads that go through k(). A bare kubectl
245+
# outside it would add an uncapped read the arithmetic above never sees.
246+
rt_bare=$(printf '%s\n' "$rt_body" | grep -vE '^[[:space:]]*#' | grep -nE '(^|[^[:alnum:]_-])kubectl([[:space:]]|$)' | grep -v '"\$k_left" kubectl' || true)
247+
[ -z "$rt_bare" ] || { echo "kubectl calls in $REDIS_STEP that bypass the clamped k():" >&2; printf '%s\n' "$rt_bare" >&2; exit 1; }
248+
}
249+
250+
@test "the redis-tls projection script passes against a healthy stub" {
251+
# The positive control for the two failure tests below: without it, a
252+
# stub that broke the script somewhere else would make them pass.
253+
rt_dir=$(mktemp -d)
254+
write_redis_stubs "$rt_dir"
255+
rt_out=$(run_redis_step "$rt_dir")
256+
case "$rt_out" in *"
257+
rc=0") ;; *) printf '%s\n' "$rt_out" >&2; echo "the step failed against a healthy stub" >&2; exit 1 ;; esac
258+
rm -rf "$rt_dir"
259+
}
260+
261+
@test "a failed replication exec reports its own error, not the 15s cap" {
262+
rt_dir=$(mktemp -d)
263+
write_redis_stubs "$rt_dir"
264+
rt_out=$(STUB_FAIL='*exec rfr-redis-secure-1 -c redis -- sh -c*' STUB_RC=1 \
265+
STUB_ERR='Error from server (Forbidden): pods "rfr-redis-secure-1" is forbidden' run_redis_step "$rt_dir")
266+
case "$rt_out" in *"
267+
rc=0") printf '%s\n' "$rt_out" >&2; echo "the step passed with a forbidden exec" >&2; exit 1 ;; esac
268+
case "$rt_out" in *"15s cap"*) printf '%s\n' "$rt_out" >&2; echo "a forbidden exec was reported as the 15s cap firing" >&2; exit 1 ;; esac
269+
case "$rt_out" in *"rfr-redis-secure-1"*"exit 1"*"Forbidden"*) ;; *) printf '%s\n' "$rt_out" >&2; echo "the message does not carry the exit status and what the exec printed" >&2; exit 1 ;; esac
270+
rm -rf "$rt_dir"
271+
}
272+
273+
@test "a failed sentinel exec reports its own error, not the 15s cap" {
274+
rt_dir=$(mktemp -d)
275+
write_redis_stubs "$rt_dir"
276+
rt_out=$(STUB_FAIL='*exec rfs-redis-secure-0 -c sentinel*' STUB_RC=1 \
277+
STUB_ERR='command terminated with exit code 1' STUB_OUT='NOAUTH Authentication required.' run_redis_step "$rt_dir")
278+
case "$rt_out" in *"
279+
rc=0") printf '%s\n' "$rt_out" >&2; echo "the step passed with a failed sentinel exec" >&2; exit 1 ;; esac
280+
case "$rt_out" in *"15s cap"*) printf '%s\n' "$rt_out" >&2; echo "a refused sentinel command was reported as the 15s cap firing" >&2; exit 1 ;; esac
281+
case "$rt_out" in *"command terminated with exit code 1"*"rfs-redis-secure-0 failed (exit 1): NOAUTH Authentication required."*) ;; *) printf '%s\n' "$rt_out" >&2; echo "the output does not carry what the exec wrote to both streams and its exit status" >&2; exit 1 ;; esac
282+
rm -rf "$rt_dir"
283+
}
284+
285+
@test "a failed Secret read is not reported as a wrong source name" {
286+
# base64 -d exits 0 on empty input, so without pipefail the pipeline takes
287+
# its status and the emptiness check below names the wrong cause.
288+
rt_dir=$(mktemp -d)
289+
write_redis_stubs "$rt_dir"
290+
rt_out=$(STUB_FAIL='*get secret redis-secure.ca-tls -o jsonpath*' STUB_RC=1 \
291+
STUB_ERR='Error from server (ServiceUnavailable): the server is currently unable to handle the request' run_redis_step "$rt_dir")
292+
case "$rt_out" in *"
293+
rc=0") printf '%s\n' "$rt_out" >&2; echo "the step passed with a failed Secret read" >&2; exit 1 ;; esac
294+
case "$rt_out" in *"source name or key is wrong"*) printf '%s\n' "$rt_out" >&2; echo "an API error was reported as a wrong source name" >&2; exit 1 ;; esac
295+
case "$rt_out" in *"ServiceUnavailable"*) ;; *) printf '%s\n' "$rt_out" >&2; echo "the API error is missing from the output" >&2; exit 1 ;; esac
296+
rm -rf "$rt_dir"
297+
}
298+
299+
@test "the private-key check fails when the projection it reads carries a key" {
300+
# The projection is read once above this check. A check that reads it
301+
# again passes when that second read fails, because an empty read holds
302+
# no key material: here the first read returns a key and the second one
303+
# is refused.
304+
rt_dir=$(mktemp -d)
305+
write_redis_stubs "$rt_dir"
306+
rt_out=$(STUB_PROJ_KEY=1 STUB_FAIL='*get secret redis-secure.tenant-ca -o jsonpath*' STUB_FAIL_FROM=2 STUB_RC=1 \
307+
STUB_ERR='Error from server (ServiceUnavailable): the server is currently unable to handle the request' run_redis_step "$rt_dir")
308+
case "$rt_out" in *"
309+
rc=0") printf '%s\n' "$rt_out" >&2; echo "the step passed with key material in the projection" >&2; exit 1 ;; esac
310+
case "$rt_out" in *"projection carries PEM private key material"*) ;; *) printf '%s\n' "$rt_out" >&2; echo "the step did not fail on the key material" >&2; exit 1 ;; esac
311+
rm -rf "$rt_dir"
312+
}
313+
314+
@test "a read that finds the step budget spent does not run and says so" {
315+
rt_dir=$(mktemp -d)
316+
write_redis_stubs "$rt_dir"
317+
rt_out=$(RS_BUDGET=0 run_redis_step "$rt_dir")
318+
case "$rt_out" in *"
319+
rc=0") printf '%s\n' "$rt_out" >&2; echo "the step passed with its budget spent before the first read" >&2; exit 1 ;; esac
320+
case "$rt_out" in *"step budget is spent, so this read did not run: -n tenant-test get secret redis-secure.tenant-ca"*) ;; *) printf '%s\n' "$rt_out" >&2; echo "the first read did not report the spent budget" >&2; exit 1 ;; esac
321+
rm -rf "$rt_dir"
322+
}
323+
324+
@test "a read is clamped to what the step budget has left and names itself" {
325+
# A 2s budget against a read that would hang for 20s: the clamp has to
326+
# end the read at the budget, not at the 30s per-read cap.
327+
rt_dir=$(mktemp -d)
328+
write_redis_stubs "$rt_dir"
329+
rt_start=$(date +%s)
330+
rt_out=$(RS_BUDGET=2 STUB_SLEEP_ON='*get secret redis-secure.tenant-ca -o go-template*' STUB_SLEEP=20 run_redis_step "$rt_dir")
331+
rt_took=$(( $(date +%s) - rt_start ))
332+
case "$rt_out" in *"
333+
rc=0") printf '%s\n' "$rt_out" >&2; echo "the step passed with a hung read" >&2; exit 1 ;; esac
334+
case "$rt_out" in *"ran into its "[12]"s bound: -n tenant-test get secret redis-secure.tenant-ca"*) ;; *) printf '%s\n' "$rt_out" >&2; echo "the hung read did not name itself at the budget's bound" >&2; exit 1 ;; esac
335+
[ "$rt_took" -lt 15 ] || { echo "the step took ${rt_took}s against a 2s budget; the read was not clamped" >&2; exit 1; }
336+
rm -rf "$rt_dir"
337+
}

0 commit comments

Comments
 (0)