Merge #516: the probe refuses with three distinct messages, each naming its own cause (fleetd #500)
CI / contract (push) Successful in 45s
CI / build (push) Successful in 1m46s

This commit was merged in pull request #516.
This commit is contained in:
2026-09-12 06:09:50 +02:00
+78 -12
View File
@@ -65,6 +65,27 @@
# credential the member holds in full. # credential the member holds in full.
# #
set -uo pipefail set -uo pipefail
# `pipefail` is not what catches the parser failure handled below (fleetd #500): in
# `printf '%s' "$POLICY_JSON" | jq -r '...'`, jq is the LAST element of the pipe, so the pipeline's
# own exit status is already jq's status, with or without pipefail. It is kept as insurance for if
# a post-processing stage is ever appended after the parser (e.g. `| tail -n +2`) — at that point
# the parser would sit upstream and pipefail becomes the only thing that still reports its status.
# --- refuse on an interpreter that cannot run this script (fleetd #500) -------------------------
#
# mapfile, used below to parse the policy response, was added in bash 4.0. macOS ships bash 3.2.57
# at /bin/bash, which predates it. This script's own `set -uo pipefail` does not catch a missing
# mapfile: the builtin just fails with "command not found" on stderr, and every line below that
# reads the array it would have filled uses a `:-` default or a slice, neither of which `set -u`
# catches on an unset array. Left unguarded, that chain ends in the "0 known names" refusal further
# down — a claim about the POLICY, for a failure that is actually about the INTERPRETER. So the
# interpreter is checked once, explicitly, before it is asked to do anything mapfile depends on.
if (( ${BASH_VERSINFO[0]} < 4 )); then
echo "refusing to run: this script uses mapfile, which needs bash 4 or newer. This shell is bash" \
"${BASH_VERSION:-<unknown, no \$BASH_VERSION>}. Re-run it under a newer bash, for example:" \
"\"\$(command -v bash)\" \"$0\"" "$@" >&2
exit 3
fi
FLEETD_HOST="${FLEETD_HOST:-http://127.0.0.1:8765}" FLEETD_HOST="${FLEETD_HOST:-http://127.0.0.1:8765}"
POLICY_URL="${FLEETD_HOST%/}/member-credentials" POLICY_URL="${FLEETD_HOST%/}/member-credentials"
@@ -124,16 +145,32 @@ fi
# One parse pass: line 1 = present (true/false/null), line 2 = policy mode (possibly blank), # One parse pass: line 1 = present (true/false/null), line 2 = policy mode (possibly blank),
# lines 3-5 = knownCount/allowedCount/blockedCount, remaining lines = the known[] names. A single # lines 3-5 = knownCount/allowedCount/blockedCount, remaining lines = the known[] names. A single
# pass avoids re-parsing (and re-risking a truthiness bug) five separate times. # pass avoids re-parsing (and re-risking a truthiness bug) five separate times.
#
# This used to feed the parser straight into `mapfile -t _FIELDS < <(producer)`. That form cannot
# see the producer fail: `<` `<(...)` is a process substitution, not a pipeline, so `set -o
# pipefail` does not reach inside it, and mapfile's own exit status reports whether the BUILTIN
# ran, not whether the command substituted into it succeeded — a failing jq or python3 there still
# leaves mapfile at rc=0 with an empty array, read as a parse that genuinely found nothing (fleetd
# #500). Capturing the parser's output with command substitution first, and checking ITS exit
# status, reports the producer's real failure while the fact still exists — before it is handed to
# mapfile at all.
#
# mapfile then reads from that captured string with `<<<` (a herestring), not `< <(...)`: `<<<`
# materialises the whole string in memory first, where `< <(...)` would stream it. That only
# matters for a large producer; this one is a short credential-name policy response, so the
# tradeoff is irrelevant here — noted because it would not be for every producer.
if command -v jq >/dev/null 2>&1; then if command -v jq >/dev/null 2>&1; then
mapfile -t _FIELDS < <(printf '%s' "$POLICY_JSON" | jq -r ' _FIELDS_RAW="$(printf '%s' "$POLICY_JSON" | jq -r '
(.present | tostring), (.present | tostring),
(.policy // ""), (.policy // ""),
(.knownCount // 0 | tostring), (.knownCount // 0 | tostring),
(.allowedCount // 0 | tostring), (.allowedCount // 0 | tostring),
(.blockedCount // 0 | tostring), (.blockedCount // 0 | tostring),
(.known[]? // empty)') (.known[]? // empty)')"
_PARSE_STATUS=$?
_PARSER_NAME="jq"
else else
mapfile -t _FIELDS < <(printf '%s' "$POLICY_JSON" | python3 - <<'PY' _FIELDS_RAW="$(printf '%s' "$POLICY_JSON" | python3 - <<'PY'
import json, sys import json, sys
data = json.load(sys.stdin) data = json.load(sys.stdin)
print(str(data.get("present"))) print(str(data.get("present")))
@@ -144,7 +181,34 @@ print(data.get("blockedCount") if data.get("blockedCount") is not None else 0)
for n in (data.get("known") or []): for n in (data.get("known") or []):
print(n) print(n)
PY PY
) )"
_PARSE_STATUS=$?
_PARSER_NAME="python3"
fi
if [ "$_PARSE_STATUS" -ne 0 ]; then
echo "refusing to run: could not parse the policy fetched from $POLICY_URL — $_PARSER_NAME exited" \
"non-zero (status $_PARSE_STATUS). That is a parser failure, not a claim about the policy" \
"itself; the policy response has not been read." >&2
exit 4
fi
mapfile -t _FIELDS <<< "$_FIELDS_RAW"
# Arity check — the CORRECTNESS fix (fleetd #500). A parser that exits 0 can still return fewer
# than the 5 fixed fields (present, policy mode, 3 counts) that every line below this expects,
# whatever the reason: a producer that printed nothing, malformed JSON that jq/python3 still
# accepted, or a schema change upstream. The slice just below this (`_FIELDS[@]:5`) does not fire
# `set -u` on an unset OR a short array, and every fixed-field read above used a `:-` default, so
# without this check a short `_FIELDS` reaches the "0 known names" guard further down with the
# same look as a policy that genuinely has 0 names. Check the count here, at the one point the
# fact is still present, before the slice consumes it.
if (( ${#_FIELDS[@]} < 5 )); then
echo "refusing to run: the policy parser ($_PARSER_NAME) returned ${#_FIELDS[@]} field(s); at" \
"least 5 are required (present, policy mode, knownCount, allowedCount, blockedCount). The" \
"parse ran but its shape is wrong — this is not a claim about how many names the policy" \
"knows." >&2
exit 5
fi fi
PRESENT="${_FIELDS[0]:-null}" PRESENT="${_FIELDS[0]:-null}"
@@ -164,19 +228,21 @@ case "$KNOWN_COUNT_REPORTED" in
;; ;;
esac esac
# --- guard the denominator explicitly — never proceed on a zero/short count --------------------- # --- guard the denominator explicitly — never proceed on a zero count ---------------------------
# #
# This is the exact trap named in the ticket: an empty (or truncated) NAMES array passes every # This is the exact trap named in the ticket: an empty NAMES array passes every subsequent "is it
# subsequent "is it set" check vacuously and prints a table that LOOKS complete. So this is checked # set" check vacuously and prints a table that LOOKS complete. By this point the interpreter gate,
# before anything else runs, with a message that says why, not just that it failed. # the parser-exit-status check, and the arity check above have already ruled out "the interpreter
# couldn't run mapfile", "the parser failed", and "the parser returned the wrong shape" — so a zero
# count reaching here really does mean the policy itself reports 0 known names, not a swallowed
# failure upstream. That is still checked before anything else runs, with a message that says so.
if [ "${#NAMES[@]}" -eq 0 ] || [ "$KNOWN_COUNT_REPORTED" -eq 0 ]; then if [ "${#NAMES[@]}" -eq 0 ] || [ "$KNOWN_COUNT_REPORTED" -eq 0 ]; then
cat >&2 <<EOF cat >&2 <<EOF
refusing to run: the policy fetched from $POLICY_URL contains 0 known names (present=${PRESENT:-unknown}). refusing to run: the policy fetched from $POLICY_URL contains 0 known names (present=${PRESENT:-unknown}).
Either memberCredentials: is absent/empty on the running daemon (nothing is protected — see fleetd's memberCredentials: is absent or empty on the running daemon — nothing is protected (see fleetd's own
own startup warning), or the response could not be parsed. Either way, checking zero names would startup warning). Checking zero names would print a clean-looking table for a policy that protects
print a clean-looking table for a policy that protects nothing, or for a probe that read nothing. nothing. This is refused rather than reported as a pass.
This is refused rather than reported as a pass.
EOF EOF
exit 1 exit 1
fi fi