myxce/.claude/skills/xce-test-resources/run-resource-verify.sh
Rongsong Shen 1364664769
feat(resource-value-verification): verdict aggregation, reporting, cleanup (task 7.1)
aggregate_verdict()/build_verdict_record() combine the three per-layer
check results into a single per-resource comparison, self-contained and
invokable with synthetic records (task 8.2). Uses ASCII Unit Separator
(\x1f) as the record delimiter after discovering bash's IFS tab-splitting
silently collapses adjacent empty fields even with IFS set to only a tab
(bug-1581), which corrupted UNREACHABLE-record field alignment.

Emits one machine-parseable VERDICT line (resources/layers_checked/
mismatches/unreachable/rejected/result) plus the terminal PASS/FAIL oracle
line and matching exit code. Cleanup is the same trap-based mechanism task
5.1 registered for the abort path - logs always preserved regardless of
--keep-running.

Round-2 fix: main()'s live-data wiring (build_verdict_records_from_tsv())
derived a layer's status from array-key existence rather than the actual
recorded outcome - since tasks 6.3/6.4 always set the key (even on a
genuine timeout, just with an empty value), every real UNREACHABLE was
silently misclassified as MISMATCH. Fixed to check SCHEDULER_CHECK_RESULTS/
APISERVER_CHECK_RESULTS string content instead (bug-1582). Live-confirmed
by killing the scheduler mid-run: VERDICT now correctly reports
unreachable=9 mismatches=0, not the reverse.

SKILL.md rewritten to describe the now-complete, functional tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:18:01 +00:00

1551 lines
79 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# run-resource-verify.sh -- driver for /xce-test-resources: end-to-end
# resource-value propagation verification (inject a synthetic worker
# resource value via xce_test's resupdate, confirm it is visible at the
# controller, live scheduler, and Go apiserver).
#
# resource-value-verification spec, task 5.1+6.1+6.2+6.3+6.4+7.1 SCOPE (+
# bug-1579 follow-up fix): this file implements the FULL skill -- deployment
# (task 5.1), worker join + resource injection (task 6.1), the
# controller-side per-layer verification check (task 6.2), the
# scheduler-side per-layer verification check (task 6.3), the apiserver-side
# per-layer verification check (task 6.4), AND the final verdict aggregation
# / VERDICT line / PASS-FAIL oracle / cleanup-on-every-path (task 7.1) --
# stand up a fresh controller + scheduler
# (-vvv) + apiserver (debug logging) deployment via xce-deploy-ha's
# deploy-ha.sh + generated start-all.sh, confirm all three components are
# actually up, join a SYNTHETIC worker (no real worker daemon process)
# through xce_test's worker context, run resupdate for one or more
# resource=value pairs, then (a) query the controller (xce_test
# client-context `resval`) for every ACCEPTED pair and report
# MATCH/MISMATCH against the injected value, (b) poll the live scheduler
# daemon's own log file (task 4.1's trace line) for the same ACCEPTED pairs,
# within a bounded wait, and report MATCH/MISMATCH/UNREACHABLE distinctly
# from the controller-side result, and (c) poll the live apiserver's own log
# file (task 4.2's trace line) for the same ACCEPTED pairs, within a bounded
# wait, and report MATCH/MISMATCH/UNREACHABLE distinctly from both the
# controller-side and scheduler-side results, then clean up (trap-based,
# covering a mid-deployment abort, not just a completed run).
#
# bug-1579 follow-up fix (this revision): the controller-side check (a) above
# now runs NESTED inside inject_resources()'s own xce_test worker session --
# via a `!` shell-escape line placed between `resupdate` and `quit` -- rather
# than as a wholly separate xce_test process run AFTER that session's own
# `quit` has already disconnected the synthetic worker. See the
# internal_controller_check() function and the top-of-file bug-1579 note
# below for why this matters and exactly how it works; DEFAULT_RESOURCE_ARGS
# now includes `cpuf` again as proof the fix is real (see that note).
# See .kiro/specs/resource-value-verification/{requirements,design}.md,
# Requirements 2.1, 2.2, 3.1, 3.2, 4.1, 4.2, 4.3, 5.3, 6.1, 6.2, 6.3, 6.4,
# 6.5 for these tasks; the full skill covers 3.1-6.5 and is now complete.
#
# Task 7.1 (this revision): adds the final aggregation step. Combines the
# controller/scheduler/apiserver per-layer results (tasks 6.2-6.4) plus the
# injection accept/reject outcome (tasks 2.1/2.2/6.1) into a single overall
# verdict, via a SELF-CONTAINED comparison/aggregation function
# (aggregate_verdict(), see its own header comment below) that takes plain
# TSV "records" as arguments -- no live command, no file I/O, no global
# deployment state -- so it is directly callable with hand-written synthetic
# records for testing (this task's own sanity-check invocations, and task
# 8.2's dedicated harness, which depends on this exact function/record
# format) as well as from main()'s live-run path. Emits exactly one
# machine-parseable VERDICT line (xce-test-ha's convention) plus the
# standard terminal PASS/FAIL oracle line; exit code is 0 for an overall
# PASS, 1 for an overall FAIL -- this is now the script's REAL exit code,
# not the deployment-only placeholder described in the "Exit 0/Non-zero"
# note below the Usage block (kept for historical/task-5.1-6.4 context, see
# the correction directly beneath it). The bottom of this file is also now
# guarded so it can be `source`d (e.g. by task 8.2's harness) without
# executing main()/internal_controller_check() as a side effect -- see the
# `[[ "${BASH_SOURCE[0]}" == "${0}" ]]` guard at the very end.
#
# Task 6.1 deliberate deviation from design.md: the default (no-args)
# resource set is Requirement 1's gap-resource set (r15m, pg, ls, it, swp,
# io, tmp) plus a couple of already-supported NUMERIC names for regression
# coverage (mem, cpuf) -- NOT "type"/"model" as design.md's illustrative text
# suggests. bug-1574 (xce_test can SEGFAULT, non-deterministically, on
# `resupdate -n type/-n model ...` -- uninitialized-memory UB in
# set_resval()'s Value_u_svalue fallback path) makes including a string-typed
# resource in this script's own default live-testing path a real crash risk,
# not a theoretical one. Revisit once bug-1574 is fixed.
#
# Task 6.2 remediation (bug-1579, original workaround): the
# regression-coverage name was originally "cpuf", but cpuf is one of
# worker.c's N_HOST_ATTRS (add_hostattrs_resvals()) -- the controller RESETS
# it to baseline the moment the synthetic worker disconnects
# (mark_worker_unavail() -> apply_worker_dead_mutation() ->
# reset_hostattrs_unknown() + add_hostattrs_resvals()), and
# inject_resources()'s xce_test session used to end with `quit` (disconnecting
# the worker) before a separate, later xce_test process ran the controller
# query -- so cpuf always reported a false MISMATCH (observed=0.000000).
# Task 6.2's review swapped it for "r1m" (confirmed via
# resval_typeresolve.c's resinfo[] table to be a genuinely pre-existing
# ("already-supported") resource name, and via
# src/worker/src/resource_sync.c to be a periodic/dynamic load-index
# resource, not in worker.c's N_HOST_ATTRS list) as a workaround, not a fix --
# the underlying disconnect-timing problem remained live for any *other*
# host-attrs name (including a user-supplied custom resource=value
# argument).
#
# bug-1579 REAL FIX (this revision): xce_test's `!<shell-command>` escape
# (preprocess_and_run(), test.c ~line 950-998) is handled in the single
# top-level REPL loop (test.c main(), the sole call site) BEFORE any
# context-specific command dispatch -- so it works identically in ANY
# context, including `worker`, WITHOUT calling leave_ctx()/disconnecting.
# inject_resources() now inserts a `!` line between `resupdate` and `quit`
# that invokes this very script recursively, in a hidden
# `--internal-controller-check` mode (see internal_controller_check() below),
# from INSIDE the still-open worker session. That nested invocation runs its
# own client-context xce_test session and queries `resval` for every
# requested resource name WHILE the synthetic worker is still connected --
# so a host-attrs resource is never reset before it's checked. The nested
# check writes raw observed values to $TOP/logs/controller-check-raw.tsv;
# query_controller_resvals() (task 6.2's original function, still called at
# the same point in main(), after inject_resources() returns) now reads that
# file instead of running its own live query -- a SEQUENCING change, not a
# logic change: the MATCH/MISMATCH comparison, CONTROLLER_OBSERVED/
# CONTROLLER_CHECK_RESULTS population, and controller-check-results.tsv
# format are all unchanged. DEFAULT_RESOURCE_ARGS now restores "cpuf"
# alongside "mem" (design.md's original "a couple of already-supported
# names for regression coverage" intent) -- this is the proof the fix is
# real: cpuf now correctly MATCHes at the controller layer instead of
# false-MISMATCHing. The alternative considered during task 6.2's review
# (keep the worker session connected across all per-layer checks by
# reordering calls) was already ruled out there as not viable without this
# `!`-escape mechanism -- xce_test's REPL has no command path from the
# `worker` context back to `client` without leave_ctx() (`..`/cmd_up), which
# disconnects exactly like `quit` does. See bug-1579 for the full history.
#
# Must be invoked under an environment with `nix` on PATH (same as
# xce-deploy-ha / xce-test-ha) -- this script shells out to `nix develop`
# itself for the steps that need it (openssl, xceadmin), so it does not need
# to be pre-wrapped by the caller.
#
# Usage:
# run-resource-verify.sh [--keep-running] [--dir DIR] [resource=value ...]
#
# --keep-running Skip PROCESS teardown in cleanup -- the deployment
# (controller/scheduler/apiserver) is left running
# under DIR for further inspection. Log files under
# DIR are ALWAYS preserved regardless of this flag --
# it only controls whether processes are killed (see
# cleanup(), and design.md's explicit divergence from
# xce-deploy-ha's own cleanup.sh, which wipes logs).
# --dir DIR Scratch/deploy directory. Default: a fresh directory
# generated via `mktemp -d`, following xce-deploy-ha's
# "every invocation is a fresh, self-contained deploy"
# convention. $XCE_RESVERIFY_TOP overrides the default
# when --dir is not given (same precedence pattern as
# xce-test-ha's $XCE_HA_TOP).
# resource=value One or more resources to inject via resupdate, e.g.
# `r15m=1.5 pg=3`. May repeat. When none are given,
# defaults to Requirement 1's gap-resource set plus a
# couple of already-numeric names for regression
# coverage (see the top-of-file bug-1574 note for why
# the default deliberately excludes type/model).
#
# --- SUPERSEDED by task 7.1 (kept for historical context -- this described
# the script's exit code BEFORE the final aggregation step existed) ---
# Exit 0 == this task's scope succeeded (deployment stood up, all three
# components confirmed up, worker joined, resupdate ran for every requested
# resource -- accepted AND rejected outcomes are both a successful run of
# THIS script's scope, see inject_resources() -- and the controller-side
# check (task 6.2), scheduler-side check (task 6.3), and apiserver-side check
# (task 6.4) each ran for every accepted resource -- a MATCH, a MISMATCH, or
# (scheduler-side/apiserver-side only) an UNREACHABLE/timeout are ALL a
# successful run of this script's own scope, see
# query_controller_resvals()/query_scheduler_resvals()/
# query_apiserver_resvals(); only a structural failure to query the
# controller, or to read the injection manifest for the scheduler/apiserver
# checks, is fatal here -- cleaned up per --keep-running).
# Non-zero == deployment failed at some step, the worker failed to join,
# resupdate produced no observable accept/reject outcome for some name (a
# structural injection failure, distinct from a legitimate rejection), or the
# controller-side check itself could not run (e.g. the client context failed
# to connect). Once task 7.1 lands, the exit code instead reflects the full
# injection/verification PASS/FAIL verdict -- tasks 6.2/6.3/6.4 deliberately
# do NOT fail the run on a controller-, scheduler-, or apiserver-layer
# MISMATCH/UNREACHABLE yet (see ANY_CONTROLLER_MISMATCH/
# ANY_SCHEDULER_MISMATCH/ANY_SCHEDULER_UNREACHABLE/ANY_APISERVER_MISMATCH/
# ANY_APISERVER_UNREACHABLE, groundwork for task 7.1's aggregation).
# --- CURRENT (task 7.1, this revision) ---
# Exit 0 == overall verdict PASS: every requested resource name was accepted
# (Requirement 1.2's rejection path never fired) AND every accepted
# resource's controller/scheduler/apiserver observed value matched its
# injected value (see aggregate_verdict() below) -- exactly what the
# script's single VERDICT line's `result=PASS` reports.
# Non-zero (1) == overall verdict FAIL: at least one requested resource name
# was rejected, OR at least one accepted resource's value did not match (or
# was UNREACHABLE/timed out) at some layer -- exactly what `result=FAIL`
# reports, with every specific (resource, layer) failure named in the
# FAILURE lines aggregate_verdict() logs immediately above the VERDICT line.
# A structural failure before aggregation is reached (deployment failure,
# worker-join failure, a resupdate with no observable accept/reject outcome,
# a malformed `--dir`/argument, etc.) still exits non-zero via fail()/usage()
# (1, or 2 for a usage/argument error) -- unchanged from before this task.
#
set -u
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$SELF_DIR/../../.." && pwd)"
# Absolute path to this script itself -- used by inject_resources()'s `!`
# shell-escape (bug-1579 fix, see the top-of-file note) to invoke this same
# script recursively in `--internal-controller-check` mode, from INSIDE the
# xce_test worker session's `system()` call, while that session's connection
# to the controller is still open (before its `quit`).
SELF_SCRIPT="$SELF_DIR/$(basename "${BASH_SOURCE[0]}")"
DEPLOY_HA="$REPO/src/tools/deploy-ha.sh"
BUILD_DIR="${XCE_RESVERIFY_BUILD_DIR:-$REPO/src/xce.build}"
BINARY="${XCE_RESVERIFY_BIN:-$BUILD_DIR/controller/xce_controller}"
SCHED_BIN="${XCE_RESVERIFY_SCHED_BIN:-$BUILD_DIR/scheduler/xce_scheduler}"
APISERVER_BIN="${XCE_RESVERIFY_APISERVER_BIN:-/tmp/apiserver}"
# xce_test -- drives injection (task 6.1) via its worker-context `join`/
# `resupdate` commands (same binary xce-test-ha's run-ha-soak.sh uses, same
# build dir as $BINARY since both come from the controller meson target).
XT="${XCE_RESVERIFY_XT:-$BUILD_DIR/controller/xce_test}"
# Single-node cluster, deliberately -- this skill verifies resource-value
# PROPAGATION (controller -> scheduler -> apiserver), not raft/HA failover
# behavior (that's xce-test-ha's job). A single node still elects itself
# CTRL_PRIMARY immediately and satisfies deploy-ha.sh's health-check, with
# no election-timing flakiness to budget for. Override for multi-node via
# $XCE_RESVERIFY_PEERS (comma-separated host:port list).
PEERS="${XCE_RESVERIFY_PEERS:-localhost:9991}"
KEEP_RUNNING=0
TOP=""
RESOURCE_ARGS=() # task 6.1 extension point: resource=value pairs
log() { echo "[xce-test-resources] $*"; }
fail() { echo "[xce-test-resources] FAIL: $*" >&2; exit 1; }
usage() {
cat <<EOF
Usage: $0 [--keep-running] [--dir DIR] [resource=value ...]
--keep-running leave controller/scheduler/apiserver processes running
after this run instead of tearing them down. Logs are
always preserved either way.
--dir DIR scratch/deploy directory (default: fresh mktemp -d, or
\$XCE_RESVERIFY_TOP).
resource=value inject this resource via resupdate (may repeat). Default
when none given: r15m=1.5 pg=2.5 ls=3.5 it=4.5 swp=5.5
io=6.5 tmp=7.5 mem=8.5 cpuf=9.5 (numeric-only -- see
bug-1574 note at top of file).
EOF
}
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
--keep-running)
KEEP_RUNNING=1
shift
;;
--dir)
[ $# -ge 2 ] || fail "--dir requires an argument"
TOP="$2"
shift 2
;;
--dir=*)
TOP="${1#--dir=}"
shift
;;
-h|--help)
usage
exit 0
;;
*=*)
# resource=value pair -- task 6.1's concern; just collected here.
RESOURCE_ARGS+=("$1")
shift
;;
*)
echo "[xce-test-resources] Unknown argument: $1" >&2
usage
exit 2
;;
esac
done
}
resolve_top() {
if [ -z "$TOP" ]; then
TOP="${XCE_RESVERIFY_TOP:-$(mktemp -d /tmp/xce-resverify.XXXXXX)}"
fi
# Resolve a relative --dir/$XCE_RESVERIFY_TOP against cwd, so it is
# unambiguous to deploy-ha.sh and every generated per-component script
# (same pattern as xce-test-ha's run-ha-soak.sh; no realpath dependency
# since busybox realpath in this environment lacks -m).
case "$TOP" in
/*) : ;;
*) TOP="$PWD/$TOP" ;;
esac
mkdir -p "$TOP"
}
# ── Cleanup: process teardown only, logs always preserved ──────────────────
# Deliberately does NOT invoke xce-deploy-ha's own generated cleanup.sh --
# that script wipes $TOP/logs/*.log, which this feature's design explicitly
# diverges from (a FAIL verdict's underlying scheduler/apiserver trace-log
# evidence must stay inspectable after the run). Every pkill pattern below is
# scoped to a $TOP-unique argv substring, not a bare binary/process name --
# xce-test-ha's bug-1436 postmortem: an unscoped `pkill -9 -x xce_controller`
# can kill a DIFFERENT concurrent run's live cluster sharing the same
# machine. $TOP itself (mktemp-unique per invocation, or an operator-chosen
# --dir) is what makes each of these patterns safe to run concurrently with
# an unrelated xce-deploy-ha/xce-test-ha/xce-test-resources invocation.
cleanup() {
if [ "$KEEP_RUNNING" -eq 1 ]; then
log "--keep-running requested -- leaving controller/scheduler/apiserver up under $TOP (logs also preserved)"
return 0
fi
log "cleaning up processes under $TOP (log files under $TOP/logs are preserved)"
# One combined pattern (alternation), not four separate pkill
# invocations -- fewer fork/exec round trips for a cleanup path that
# may run from a signal trap, plus retry-until-verified below so a
# slow/racy single kill attempt can't leave a process behind silently.
local pattern="--workdir $TOP/controller| -l $TOP/logs|--logfile $TOP/logs/scheduler.log| -d $TOP/apiserver"
local attempt=0
while [ "$attempt" -lt 10 ]; do
attempt=$((attempt + 1))
if ! pgrep -f -- "$pattern" >/dev/null 2>&1; then
break
fi
pkill -9 -f -- "$pattern" 2>/dev/null
sleep 0.2
done
if pgrep -f -- "$pattern" >/dev/null 2>&1; then
log "warning: some processes under $TOP may still be running after cleanup retries"
fi
return 0
}
# Standard robust trap idiom: on_exit (registered for EXIT) always runs
# cleanup and preserves whatever exit code the script was already leaving
# with. INT/TERM are trapped SEPARATELY and translated into an explicit
# `exit`, because a bare `trap cleanup INT TERM` (with no exit inside the
# handler) would only interrupt whatever foreground command was running and
# then RESUME the script at the next statement -- silently defeating a
# "kill the script mid-deployment" abort instead of actually stopping it.
# Chaining through `exit N` here re-triggers the EXIT trap, so cleanup only
# ever runs once.
on_exit() {
local rc=$?
cleanup
exit "$rc"
}
register_trap() {
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
}
precondition_checks() {
[ -x "$BINARY" ] || fail "controller binary missing: $BINARY (run /xce-build controller)"
[ -x "$SCHED_BIN" ] || fail "scheduler binary missing: $SCHED_BIN (run /xce-build scheduler) -- required, this skill verifies the scheduler layer"
[ -x "$APISERVER_BIN" ] || fail "apiserver binary missing: $APISERVER_BIN (cd src/frontend && make build-server, or the CGO-flags workaround -- see tasks.md's Implementation Notes) -- required, this skill verifies the apiserver layer"
[ -x "$XT" ] || fail "xce_test binary missing: $XT (run /xce-build controller) -- required to join a synthetic worker and run resupdate"
command -v nix >/dev/null 2>&1 || fail "nix not found on PATH -- required to resolve the deploy-environment bash and invoke deploy-ha.sh"
}
# ── Step 1: deploy (xce-deploy-ha's deploy-ha.sh) ───────────────────────────
deploy() {
log "resolving deploy-environment bash via nix develop"
BASH_BIN="$(nix develop "$REPO/src" --command which bash)"
[ -n "$BASH_BIN" ] || fail "deployment failed at step deploy-ha.sh: could not resolve bash inside nix develop"
log "deploying controller+scheduler+apiserver into $TOP (peers=$PEERS)"
if ! nix develop "$REPO/src" --command "$BASH_BIN" "$DEPLOY_HA" "$BINARY" "$TOP" "$PEERS"; then
fail "deployment failed at step deploy-ha.sh (see output above)"
fi
[ -x "$TOP/scripts/start-all.sh" ] || fail "deployment failed at step deploy-ha.sh: $TOP/scripts/start-all.sh was not generated"
[ -x "$TOP/scripts/start-scheduler.sh" ] || fail "deployment failed at step deploy-ha.sh: $TOP/scripts/start-scheduler.sh was not generated (scheduler binary not found alongside controller build?)"
[ -x "$TOP/scripts/start-apiserver.sh" ] || fail "deployment failed at step deploy-ha.sh: $TOP/scripts/start-apiserver.sh was not generated (no apiserver binary at $APISERVER_BIN?)"
}
# ── Step 2: start-all.sh -- controller -> health-check -> scheduler/apiserver
start_all() {
# deploy-ha.sh's Step 3 also generates + start-all.sh also launches a
# REAL worker daemon (xcecd_virtual) whenever one happens to exist
# alongside $BINARY in the build dir (its own pre-existing, opt-in-by-
# presence worker/scheduler convention, predating this feature -- see
# task 3.1's notes). This skill's whole point (Requirement 2.1) is that
# NO real worker daemon process is needed -- task 6.1 injects via a
# SYNTHETIC xce_test-joined worker instead -- and a real worker sitting
# in the background for this run's whole duration is worse than just
# off-contract: it periodically re-reports its OWN resources (~every
# 15s), and a resource's SECOND periodic report is bug-1572 (open,
# unfixed scheduler double-free/crash) -- a real background worker is a
# live crash risk to this run, not just noise. start-worker.sh reads
# $XCE_WORKER_BIN itself (falling back to the real xcecd_virtual path)
# -- point it at a real no-op binary so start-all.sh's worker step exits
# immediately instead of leaving a daemon running; deploy-ha.sh itself
# is untouched (out of boundary), this only affects what THIS skill's
# own start-all.sh invocation actually launches.
export XCE_WORKER_BIN="/bin/true"
log "invoking start-all.sh (controller, scheduler at -vvv, apiserver with debug logging; real worker daemon suppressed via XCE_WORKER_BIN=/bin/true -- see comment above)"
if ! "$BASH_BIN" "$TOP/scripts/start-all.sh"; then
fail "deployment failed at step start-all.sh (nonzero exit)"
fi
}
# ── Step 3: confirm all three components are actually up ───────────────────
# start-all.sh's own internal health-check only gates on the CONTROLLER
# electing a primary before it goes on to background scheduler/apiserver --
# it does not itself wait for the scheduler to finish joining the cluster or
# for the apiserver to finish registering with the controller before
# returning. This skill's own "deployment succeeded" signal (distinct from
# start-all.sh's) requires all three, so poll each with a short interval up
# to a generous bounded wait (this codebase's established convention for
# this kind of check -- see xce-test-ha and design.md's Implementation
# Notes for the xce-test-resources skill).
wait_for_log() {
local desc="$1" pattern="$2" timeout="$3"
shift 3
local waited=0
while [ "$waited" -lt "$timeout" ]; do
if grep -qE -- "$pattern" "$@" 2>/dev/null; then
log "confirmed: $desc"
return 0
fi
sleep 1
waited=$((waited + 1))
done
return 1
}
confirm_deployment_up() {
log "waiting for controller, scheduler, and apiserver to confirm up (bounded wait per component)"
wait_for_log "controller primary elected" "CTRL_PRIMARY" 30 $TOP/logs/ctrl-*.log \
|| fail "deployment failed at step controller: no primary election observed in $TOP/logs/ctrl-*.log within 30s"
wait_for_log "scheduler joined cluster" "sched_init: joined cluster" 30 "$TOP/logs/scheduler.log" \
|| fail "deployment failed at step scheduler: '$TOP/logs/scheduler.log' never logged joining the cluster within 30s"
wait_for_log "apiserver backend connection registered" "Backend connection registered" 30 "$TOP/logs/apiserver.log" \
|| fail "deployment failed at step apiserver: '$TOP/logs/apiserver.log' never logged a registered backend connection within 30s"
log "all three components confirmed up under $TOP"
}
# ── Step 4: join a synthetic worker + inject resource values (task 6.1) ────
# Requirement 1's gap-resource set (numeric) plus a couple of already-numeric
# names for regression coverage -- see the top-of-file bug-1574 note for why
# this deliberately excludes type/model (design.md's own illustrative text
# names them, but xce_test can SEGFAULT on a string-typed resupdate, so this
# is a documented, deliberate deviation, not an oversight). The two
# regression-coverage names are "mem" and "cpuf", matching design.md's
# original "a couple of already-supported names" intent -- "cpuf" is a
# worker.c N_HOST_ATTRS name and is restored here (bug-1579's real fix, see
# the top-of-file note) as live proof that the controller-side check no
# longer false-MISMATCHes a host-attrs resource: the nested `!`-escape
# controller check (internal_controller_check()) runs while the synthetic
# worker's connection is still open, so cpuf is never reset to baseline
# before it's checked.
DEFAULT_RESOURCE_ARGS=(
r15m=1.5 pg=2.5 ls=3.5 it=4.5 swp=5.5 io=6.5 tmp=7.5
mem=8.5 cpuf=9.5
)
# Fixed synthetic worker identity for this run -- tags every resupdate'd
# value (Requirement 2.2), exactly like `set myname <host>` + `worker` +
# `join` in xce-test-ha's run-ha-soak.sh (identical established pattern).
# A fresh $TOP per invocation means no cross-run collision risk.
WORKER_HOST="resverify-worker"
declare -A INJECTED_VALUES=() # resource name -> injected value, as sent
declare -A INJECTED_RESULTS=() # resource name -> "accepted" | "rejected: <reason>" | "rejected: no result line observed"
# Run a batch of xce_test commands (stdin script). Two things this MUST do,
# both hard-won (see cerebrum.md / buglog.json):
# - ASAN_OPTIONS=detect_leaks=0: xce_test is an ASAN build with small
# pre-existing intentional REPL-lifetime leaks; its default leak-detect
# exit path can swallow buffered stdout on a piped/redirected run,
# losing the very accept/reject lines this function parses.
# - wrapped in `nix develop` (like deploy()'s own BASH_BIN resolution
# above): unlike $BINARY/$SCHED_BIN/$APISERVER_BIN (started via
# generated scripts whose binaries carry a baked RPATH), xce_test's own
# runtime linkage has not been confirmed independent of the nix
# devShell's library path -- wrap defensively, mirroring deploy()'s own
# "shell out to nix develop for the steps that need it" posture.
xtest() {
nix develop "$REPO/src" --command env ASAN_OPTIONS=detect_leaks=0 timeout 60 "$XT" 2>&1
}
# inject_resources [resource=value ...]
# No args -> DEFAULT_RESOURCE_ARGS. Joins one synthetic worker (no real
# worker daemon process anywhere in this flow -- Requirement 2.1) and runs
# ONE resupdate invocation carrying every pair, so task 2.2's per-name
# accept/reject accounting covers the whole batch in a single RPC round
# trip. Populates INJECTED_VALUES/INJECTED_RESULTS (and $TOP/logs/
# injection-results.tsv) for tasks 6.2-6.4's extension points to consume.
inject_resources() {
local pairs=("$@")
local pair name value resupdate_line out result
if [ "${#pairs[@]}" -eq 0 ]; then
pairs=("${DEFAULT_RESOURCE_ARGS[@]}")
fi
resupdate_line=""
for pair in "${pairs[@]}"; do
name="${pair%%=*}"
value="${pair#*=}"
if [ -z "$name" ] || [ "$name" = "$pair" ] || [ -z "$value" ]; then
fail "malformed resource argument (expected resource=value): $pair"
fi
INJECTED_VALUES["$name"]="$value"
resupdate_line="$resupdate_line -n $name -v $value"
done
log "joining synthetic worker '$WORKER_HOST' (masters=$PEERS) and injecting: ${pairs[*]}"
# bug-1579 fix: run the controller-side check (internal_controller_check(),
# below) via a `!` shell-escape BEFORE `quit` -- while this session's
# worker connection is still open -- instead of in a separate xce_test
# process run after this session has already disconnected. `%q`-quote
# every interpolated value defensively (none of TOP/PEERS/WORKER_HOST/
# resource names are expected to need it, but this line is re-parsed by
# a shell via xce_test's system() call, so quote anyway).
local ctrl_check_cmd
ctrl_check_cmd="bash $(printf '%q' "$SELF_SCRIPT") --internal-controller-check $(printf '%q' "$TOP") $(printf '%q' "$PEERS") $(printf '%q' "$WORKER_HOST")"
for pair in "${pairs[@]}"; do
name="${pair%%=*}"
ctrl_check_cmd="$ctrl_check_cmd $(printf '%q' "$name")"
done
local script
script="$(printf 'set masters %s\nset myname %s\nworker\njoin\nresupdate%s\n!%s\nquit\n' \
"$PEERS" "$WORKER_HOST" "$resupdate_line" "$ctrl_check_cmd")"
out="$(printf '%s' "$script" | xtest)"
printf '%s\n' "$out" | grep -Fxq -- "host join ok" \
|| fail "worker join failed -- no synthetic worker daemon process exists so this is the whole injection path; xce_test output:
$out"
for pair in "${pairs[@]}"; do
name="${pair%%=*}"
if printf '%s\n' "$out" | grep -Fxq -- "${name}: accepted"; then
result="accepted"
elif printf '%s\n' "$out" | grep -Fxq -- "${name}: rejected: unknown resource"; then
result="rejected: unknown resource"
elif printf '%s\n' "$out" | grep -Fxq -- "${name}: rejected: value mismatch"; then
result="rejected: value mismatch"
else
result="rejected: no result line observed"
fi
INJECTED_RESULTS["$name"]="$result"
log "resupdate: $name=${INJECTED_VALUES[$name]} -> $result"
done
# Manifest for post-run inspection and for tasks 6.2-6.4 to consume
# (this same script process already has INJECTED_VALUES/INJECTED_RESULTS
# in memory; the file is a durable, human-inspectable mirror of the
# same data, alongside the always-preserved logs/ convention).
mkdir -p "$TOP/logs"
{
for pair in "${pairs[@]}"; do
name="${pair%%=*}"
printf '%s\t%s\t%s\n' "$name" "${INJECTED_VALUES[$name]}" "${INJECTED_RESULTS[$name]}"
done
} > "$TOP/logs/injection-results.tsv"
# This flow never starts a real worker daemon (xcecd/xcecd_virtual) --
# structurally guaranteed by construction (the only process this
# function starts is xce_test itself, and only its worker CONTEXT, not
# a separate daemon binary). Confirm explicitly rather than just assert
# it in a comment, since Requirement 2.1/6.4's whole point is "no real
# worker daemon process required".
if pgrep -f -- 'xcecd_virtual|/xcecd( |$)' >/dev/null 2>&1; then
log "note: a process matching xcecd/xcecd_virtual is running on this machine -- NOT started by this script (it never invokes those binaries), so unrelated to this run; investigate separately if unexpected"
fi
local failed=0
for pair in "${pairs[@]}"; do
name="${pair%%=*}"
case "${INJECTED_RESULTS[$name]}" in
"rejected: no result line observed")
log "FAIL: '$name' produced no observable accept/reject outcome -- structural injection failure, not a legitimate rejection"
failed=1
;;
esac
done
[ "$failed" -eq 0 ] || fail "injection did not produce a definitive accept/reject outcome for every requested resource (see above)"
}
# ── Step 5: controller-side verification check (task 6.2 + bug-1579 fix) ───
# Requirement 3.1/3.2: for every SUCCESSFULLY-injected (accepted) resource/
# worker pair, query the controller via xce_test's client-context `resval`
# (the same xcecli_resval() call cmd_resupdate's own task 2.2 follow-up
# verification already uses internally -- a READ query, not a mutation, low
# risk) and compare the returned value against what was injected. A REJECTED
# resource was never actually applied at the controller in the first place
# (task 2.2's own follow-up query inside resupdate already confirmed that at
# injection time) -- there is nothing to verify for it here, so
# query_controller_resvals() only REPORTS on names marked "accepted" in
# $TOP/logs/injection-results.tsv.
#
# bug-1579 fix: the live `resval` query itself no longer happens here. It now
# happens EARLIER, nested inside inject_resources()'s own xce_test worker
# session via a `!` shell-escape (see internal_controller_check() below and
# the top-of-file bug-1579 note) -- for EVERY requested name, accepted or
# not, since accept/reject isn't known yet at that point in the sequence.
# query_controller_resvals() reads that nested query's raw results back from
# $TOP/logs/controller-check-raw.tsv and reports only on the names
# $TOP/logs/injection-results.tsv (available only now, after
# inject_resources() has returned and parsed the whole session's output)
# marks "accepted" -- a pure sequencing change: the MATCH/MISMATCH
# comparison, CONTROLLER_OBSERVED/CONTROLLER_CHECK_RESULTS population, and
# controller-check-results.tsv format below are all unchanged from task 6.2.
# internal_controller_check TOP PEERS WORKER_HOST NAME...
# NOT a normal in-process function call -- invoked as a FRESH
# `bash $SELF_SCRIPT --internal-controller-check ...` process via
# inject_resources()'s `!` shell-escape line, from INSIDE the xce_test
# worker-context session's system() call, while the injecting synthetic
# worker's connection to the controller is STILL OPEN (i.e. before that same
# session's `quit`). This is what makes the controller-side check immune to
# the worker-disconnect host-attrs reset (worker.c's mark_worker_unavail()
# -> apply_worker_dead_mutation() -> reset_hostattrs_unknown() +
# add_hostattrs_resvals(), N_HOST_ATTRS) -- the worker never disconnects
# before this check runs.
#
# Queries resval for EVERY given name, not just ones that will turn out to be
# "accepted" -- cmd_resupdate()'s own accept/reject determination has already
# run inside THIS SAME xce_test process by this point and been printed to
# its stdout, but that text isn't available to this freshly-spawned process;
# accept/reject filtering happens later, in query_controller_resvals(), once
# the parent script has parsed the whole session's captured output. Writes
# RAW observed values (blank if not found) to
# $TOP/logs/controller-check-raw.tsv (name\tobserved) for
# query_controller_resvals() to read once inject_resources() returns.
#
# On a connection failure, records $TOP/logs/controller-check-error.txt
# instead (query_controller_resvals() fails the run on this, same failure
# semantics as task 6.2's original inline check) and deliberately still
# returns 0 either way: a non-zero exit here becomes this xce_test session's
# g_last_status and, in non-interactive/batch mode, ABORTS its REPL loop
# before `quit` runs (preprocess_and_run()'s shell-escape handling) --
# undesirable when the failure can instead be surfaced through the raw-file/
# error-file contract and let this session reach `quit` normally.
internal_controller_check() {
local top="$1" peers="$2" worker_host="$3"
shift 3
local names=("$@")
mkdir -p "$top/logs"
local raw_tsv="$top/logs/controller-check-raw.tsv"
local err_txt="$top/logs/controller-check-error.txt"
rm -f "$err_txt"
: > "$raw_tsv"
[ "${#names[@]}" -gt 0 ] || return 0
local cmds="" name
for name in "${names[@]}"; do
cmds="${cmds}resval -n $name -m $worker_host"$'\n'
done
local script out
script="$(printf 'set masters %s\nclient\n%squit\n' "$peers" "$cmds")"
out="$(printf '%s' "$script" | xtest)"
if printf '%s\n' "$out" | grep -Fq -- "enter_ctx: invalid"; then
printf '%s\n' "$out" > "$err_txt"
return 0
fi
# Same sequential state-machine parse query_controller_resvals() used to
# do inline pre-fix -- see its own historical comment (still applicable
# here) for why (bug-1576's short-name substring hazard, no trailing
# marker after the last query).
local -A parsed_values=()
local line cur_name=""
while IFS= read -r line; do
case "$line" in
"resource "*"@${worker_host}:")
cur_name="${line#resource }"
cur_name="${cur_name%@${worker_host}:}"
;;
"value: "*)
if [ -n "$cur_name" ]; then
parsed_values["$cur_name"]="${line#value: }"
fi
cur_name=""
;;
esac
done <<< "$out"
for name in "${names[@]}"; do
printf '%s\t%s\n' "$name" "${parsed_values[$name]:-}" >> "$raw_tsv"
done
return 0
}
declare -A CONTROLLER_OBSERVED=() # resource name -> raw value string as rendered by cmd_resval (only set for accepted names actually queried)
declare -A CONTROLLER_CHECK_RESULTS=() # resource name -> "MATCH" | "MISMATCH: injected=<X> observed=<Y>" | "MISMATCH: controller returned no value ..."
ANY_CONTROLLER_MISMATCH=0 # groundwork for task 7.1's verdict aggregation; this task's own exit code does NOT depend on this -- see the top-of-file Exit code note and main()
# controller_values_match INJECTED OBSERVED
# Pure comparison helper (Requirement 3.2 / design.md's Error Handling "Layer
# mismatch" convention: report both values on a mismatch). Deliberately
# factored out of query_controller_resvals() so it can be exercised directly
# with synthetic inputs, independent of any live controller query -- this
# task's validation approach exercises exactly this (see SKILL.md / task
# notes). Returns 0 (bash "true"/success) on a match, 1 on a mismatch.
#
# Numeric comparison, not string equality: cmd_resval's own controller-side
# rendering always prints fixed 6-decimal %f notation (e.g. an injected
# "1.5" comes back rendered as "1.500000"; a bytes-valued resource such as
# `mem`/`tmp` additionally appends a human-readable "(X.XX UNIT)" suffix
# after the raw float, already stripped by the caller before this function
# ever sees it) -- a literal string compare would falsely MISMATCH on every
# numeric resource. Mirrors task 4.1's identical scheduler trace-log
# parse-as-float lesson (see tasks.md's Implementation Notes). Falls back to
# exact string equality for non-numeric (string-typed, e.g. type/model)
# values -- this script's own default resource set excludes those
# (bug-1574), but the comparison helper itself stays correct for either
# kind, since a future caller (or a synthetic test invocation) may pass one.
controller_values_match() {
local injected="$1" observed="$2"
if [[ "$injected" =~ ^-?[0-9]+([.][0-9]+)?$ ]] && [[ "$observed" =~ ^-?[0-9]+([.][0-9]+)?$ ]]; then
awk -v a="$injected" -v b="$observed" \
'BEGIN { d = a - b; if (d < 0) d = -d; exit !(d < 0.0005) }'
else
[ "$injected" = "$observed" ]
fi
}
# query_controller_resvals -- reads $TOP/logs/injection-results.tsv (task
# 6.1's durable, order-preserving mirror of INJECTED_VALUES/INJECTED_RESULTS
# -- bash associative arrays have no defined iteration order, the TSV does)
# and, for every name marked "accepted", issues one `resval -n <name> -m
# <host>` query in the xce_test client context, batched into a single
# xtest() invocation (same "one script, one process" precedent as
# inject_resources()'s single combined resupdate call). Populates
# CONTROLLER_OBSERVED/CONTROLLER_CHECK_RESULTS and
# $TOP/logs/controller-check-results.tsv (name\tinjected\tobserved\tresult).
query_controller_resvals() {
local tsv="$TOP/logs/injection-results.tsv"
[ -f "$tsv" ] || fail "controller check: $tsv not found -- inject_resources() must run first"
local name value result accepted_names=() skipped_names=()
while IFS=$'\t' read -r name value result; do
if [ "$result" = "accepted" ]; then
accepted_names+=("$name")
else
skipped_names+=("$name")
fi
done < "$tsv"
if [ "${#skipped_names[@]}" -gt 0 ]; then
log "controller check: skipping ${#skipped_names[@]} non-accepted resource(s) -- never actually applied at the controller, nothing to verify there: ${skipped_names[*]}"
fi
if [ "${#accepted_names[@]}" -eq 0 ]; then
log "controller check: no accepted resources to verify"
return 0
fi
# The live `resval` query already ran EARLIER, nested inside
# inject_resources()'s own xce_test worker session via a `!` shell
# escape (bug-1579 fix -- see internal_controller_check() above), while
# the synthetic worker's connection to the controller was still open.
# This function just reads back what that nested query already
# collected -- see the "Step 5" comment block above.
local err_txt="$TOP/logs/controller-check-error.txt"
if [ -f "$err_txt" ]; then
fail "controller check failed -- xce_test client context could not connect (masters=$PEERS); xce_test output:
$(cat "$err_txt")"
fi
local raw_tsv="$TOP/logs/controller-check-raw.tsv"
[ -f "$raw_tsv" ] || fail "controller check: $raw_tsv not found -- inject_resources()'s nested controller-check (internal_controller_check(), invoked via its xce_test session's \`!\` shell escape) did not run or did not complete"
log "controller check: reporting on ${#accepted_names[@]} accepted resource(s) (queried during injection, see internal_controller_check()): ${accepted_names[*]}"
local -A parsed_values=()
while IFS=$'\t' read -r name value; do
[ -n "$name" ] || continue
parsed_values["$name"]="$value"
done < "$raw_tsv"
mkdir -p "$TOP/logs"
local results_tsv="$TOP/logs/controller-check-results.tsv"
: > "$results_tsv"
local injected raw observed result_str
for name in "${accepted_names[@]}"; do
injected="${INJECTED_VALUES[$name]}"
raw="${parsed_values[$name]:-}"
if [ -z "$raw" ]; then
observed=""
result_str="MISMATCH: controller returned no value (injected=$injected observed=<none>)"
ANY_CONTROLLER_MISMATCH=1
else
# First whitespace-delimited token of the rendered value --
# strips a bytes-valued resource's trailing "(X.XX UNIT)"
# suffix (e.g. "8.500000 (8.50 B)" -> "8.500000"); a no-op for
# every other rendering (plain %f, %s, or true/false).
observed="$(awk '{print $1}' <<< "$raw")"
if controller_values_match "$injected" "$observed"; then
result_str="MATCH"
else
result_str="MISMATCH: injected=$injected observed=$observed"
ANY_CONTROLLER_MISMATCH=1
fi
fi
CONTROLLER_OBSERVED["$name"]="$observed"
CONTROLLER_CHECK_RESULTS["$name"]="$result_str"
log "controller check: $name -> $result_str"
printf '%s\t%s\t%s\t%s\n' "$name" "$injected" "$observed" "$result_str" >> "$results_tsv"
done
}
# ── Step 6: scheduler-side verification check (task 6.3) ───────────────────
# Requirement 4.1/4.2/4.3: for every SUCCESSFULLY-injected (accepted)
# resource/worker pair, poll (short interval, bounded generous total wait --
# design.md's "poll with a short interval up to a generous timeout, not
# tight polling that could flake") the live scheduler daemon's own log file
# for task 4.1's trace line -- "resval applied host=<H> resource=<R>
# value=<V>" at DEBUG3 (`-vvv`, already how start-all.sh starts the
# scheduler, per task 3.1) -- and compare the parsed value against the
# injected one, reusing controller_values_match() (numeric-with-tolerance vs.
# exact-string, same rule for either layer). A resource whose trace line
# never appears within the bounded wait is reported as "unreachable" /
# timeout, explicitly distinct from a value MISMATCH (design.md Error
# Handling -- "Layer unreachable" is its own category, not folded into
# mismatch). No record/replay/REPL round-trip -- a plain tail/grep of a live
# log file, per the System Flows sequence diagram. A REJECTED resource was
# never actually applied anywhere (task 2.2's own follow-up query inside
# resupdate already confirmed that at injection time) -- nothing to verify
# for it here either, same skip rule query_controller_resvals() (task 6.2)
# already established.
# Poll interval / bounded total wait, shared across the WHOLE batch of
# still-pending resources per iteration (see poll_scheduler_log below), not
# a separate full bounded wait PER resource -- so N accepted resources costs
# at most one ~15s bounded wait in the worst case (all N stuck), not N of
# them stacked sequentially; in the common case (scheduler already applied
# everything by the time this runs, since it fires after inject_resources()
# AND query_controller_resvals()'s own extra RPC round trip) it costs one
# fast pass with no sleep at all.
SCHED_POLL_INTERVAL="0.5"
SCHED_POLL_MAX_ITERS=30 # 30 x 0.5s = 15s total bounded wait
# poll_scheduler_log SCHED_LOG_FILE HOST NAME...
# Core bounded-wait poller, deliberately factored out of
# query_scheduler_resvals() so it can be exercised directly (e.g. against a
# name/host combination that was never actually injected, to prove the
# "unreachable" path is genuinely reachable) independent of the
# accepted-injection bookkeeping -- same "pure/self-contained, independently
# testable" spirit as controller_values_match(). Each iteration takes ONE
# full pass over SCHED_LOG_FILE (a single awk invocation building a
# name->value map for every "resval applied host=<H> resource=..." line
# seen for HOST, last occurrence per name wins) and checks every STILL-
# PENDING name against that map, rather than a separate grep-with-its-own-
# timeout per name run in sequence -- by the time a later name in the batch
# is checked, an earlier name's line has almost certainly already appeared
# in the same log-file read. Prints "name<TAB>value" to stdout for every
# name that WAS observed within the bounded wait; a name with no
# corresponding output line was NOT observed -- the caller's job to report
# that distinctly as "unreachable", not silently as a mismatch with an empty
# value.
poll_scheduler_log() {
local sched_log="$1" host="$2"
shift 2
local pending=("$@")
local -A found=()
local iter=0
while [ "$iter" -lt "$SCHED_POLL_MAX_ITERS" ] && [ "${#pending[@]}" -gt 0 ]; do
iter=$((iter + 1))
local name val
while IFS=$'\t' read -r name val; do
[ -n "$name" ] || continue
found["$name"]="$val"
done < <(awk -v needle="resval applied host=${host} resource=" '
index($0, needle) > 0 {
line = $0
sub(/^.*resource=/, "", line)
sep = index(line, " value=")
if (sep > 0) {
seen[substr(line, 1, sep - 1)] = substr(line, sep + 7)
}
}
END { for (n in seen) print n "\t" seen[n] }
' "$sched_log" 2>/dev/null)
local still_pending=()
for name in "${pending[@]}"; do
if [ -z "${found[$name]+x}" ]; then
still_pending+=("$name")
fi
done
pending=("${still_pending[@]}")
if [ "${#pending[@]}" -gt 0 ] && [ "$iter" -lt "$SCHED_POLL_MAX_ITERS" ]; then
sleep "$SCHED_POLL_INTERVAL"
fi
done
local n
for n in "${!found[@]}"; do
printf '%s\t%s\n' "$n" "${found[$n]}"
done
}
declare -A SCHEDULER_OBSERVED=() # resource name -> raw applied value string from the scheduler's trace log (only set for accepted names actually observed)
declare -A SCHEDULER_CHECK_RESULTS=() # resource name -> "MATCH" | "MISMATCH: injected=<X> observed=<Y>" | "UNREACHABLE: ..."
ANY_SCHEDULER_MISMATCH=0 # groundwork for task 7.1's verdict aggregation, mirrors ANY_CONTROLLER_MISMATCH; this task's own exit code does NOT depend on it
ANY_SCHEDULER_UNREACHABLE=0 # groundwork for task 7.1; kept distinct from ANY_SCHEDULER_MISMATCH per Requirement 4.2/4.3 and design.md's "Layer unreachable" being its own error category
# query_scheduler_resvals -- reads $TOP/logs/injection-results.tsv (same
# durable, order-preserving source query_controller_resvals() (task 6.2)
# reads) and, for every name marked "accepted", polls $TOP/logs/
# scheduler.log via poll_scheduler_log() above. Populates
# SCHEDULER_OBSERVED/SCHEDULER_CHECK_RESULTS and $TOP/logs/
# scheduler-check-results.tsv (name\tinjected\tobserved\tresult), following
# the exact same pattern query_controller_resvals() established for its own
# layer.
query_scheduler_resvals() {
local tsv="$TOP/logs/injection-results.tsv"
[ -f "$tsv" ] || fail "scheduler check: $tsv not found -- inject_resources() must run first"
local name value result accepted_names=() skipped_names=()
while IFS=$'\t' read -r name value result; do
if [ "$result" = "accepted" ]; then
accepted_names+=("$name")
else
skipped_names+=("$name")
fi
done < "$tsv"
if [ "${#skipped_names[@]}" -gt 0 ]; then
log "scheduler check: skipping ${#skipped_names[@]} non-accepted resource(s) -- never actually applied at the scheduler, nothing to verify there: ${skipped_names[*]}"
fi
if [ "${#accepted_names[@]}" -eq 0 ]; then
log "scheduler check: no accepted resources to verify"
return 0
fi
local sched_log="$TOP/logs/scheduler.log"
local total_wait
total_wait="$(awk -v i="$SCHED_POLL_INTERVAL" -v n="$SCHED_POLL_MAX_ITERS" 'BEGIN{printf "%.1f", i*n}')"
log "scheduler check: polling $sched_log for ${#accepted_names[@]} accepted resource(s), bounded wait up to ${total_wait}s total: ${accepted_names[*]}"
local -A observed_map=()
local n v
while IFS=$'\t' read -r n v; do
[ -n "$n" ] || continue
observed_map["$n"]="$v"
done < <(poll_scheduler_log "$sched_log" "$WORKER_HOST" "${accepted_names[@]}")
mkdir -p "$TOP/logs"
local results_tsv="$TOP/logs/scheduler-check-results.tsv"
: > "$results_tsv"
local injected observed result_str
for name in "${accepted_names[@]}"; do
injected="${INJECTED_VALUES[$name]}"
if [ -z "${observed_map[$name]+x}" ]; then
observed=""
result_str="UNREACHABLE: no scheduler trace-log line observed within bounded wait (injected=$injected observed=<none>)"
ANY_SCHEDULER_UNREACHABLE=1
else
observed="${observed_map[$name]}"
if controller_values_match "$injected" "$observed"; then
result_str="MATCH"
else
result_str="MISMATCH: injected=$injected observed=$observed"
ANY_SCHEDULER_MISMATCH=1
fi
fi
SCHEDULER_OBSERVED["$name"]="$observed"
SCHEDULER_CHECK_RESULTS["$name"]="$result_str"
log "scheduler check: $name -> $result_str"
printf '%s\t%s\t%s\t%s\n' "$name" "$injected" "$observed" "$result_str" >> "$results_tsv"
done
}
# ── Step 7: apiserver-side verification check (task 6.4) ───────────────────
# Requirement 5.1/5.2/5.4: for every SUCCESSFULLY-injected (accepted)
# resource/worker pair, poll (short interval, bounded generous total wait --
# same batched-polling design as query_scheduler_resvals()/task 6.3 above)
# the live apiserver's own log file for task 4.2's trace line -- a zap
# message "resource-location stored" with fields resource/host/value, e.g.
# `resource-location stored {"resource": "r15m", "host": "resverify-worker",
# "value": 1.5}` -- and compare the parsed value against the injected one,
# reusing controller_values_match() (numeric-with-tolerance vs. exact-string,
# same rule for every layer). A resource whose trace line never appears
# within the bounded wait is reported as "unreachable" / timeout, explicitly
# distinct from a value MISMATCH (design.md Error Handling -- "Layer
# unreachable" is its own category, not folded into mismatch). No xcecli
# query, no REST call -- a plain tail/grep of a live log file, per the System
# Flows sequence diagram. A REJECTED resource was never actually applied
# anywhere -- nothing to verify for it here either, same skip rule
# query_controller_resvals()/query_scheduler_resvals() already established.
# Poll interval / bounded total wait -- identical shape to
# SCHED_POLL_INTERVAL/SCHED_POLL_MAX_ITERS above (task 6.3), shared across
# the whole batch of still-pending resources per iteration (see
# poll_apiserver_log below), not a separate bounded wait per resource.
APISERVER_POLL_INTERVAL="0.5"
APISERVER_POLL_MAX_ITERS=30 # 30 x 0.5s = 15s total bounded wait
# poll_apiserver_log APISERVER_LOG_FILE HOST NAME...
# Core bounded-wait poller for the apiserver layer, mirroring
# poll_scheduler_log() above (task 6.3) in shape: factored out of
# query_apiserver_resvals() so it can be exercised directly (e.g. against a
# name/host combination that was never actually injected, to prove the
# "unreachable" path is genuinely reachable) independent of the
# accepted-injection bookkeeping. Each iteration takes ONE full pass over
# APISERVER_LOG_FILE (a single awk invocation building a name->value map for
# every "resource-location stored" line seen for HOST, last occurrence per
# name wins) and checks every STILL-PENDING name against that map, rather
# than a separate grep-with-its-own-timeout per name. Prints "name<TAB>value"
# to stdout for every name that WAS observed within the bounded wait; a name
# with no corresponding output line was NOT observed -- the caller's job to
# report that distinctly as "unreachable", not silently as a mismatch with an
# empty value.
#
# Line format (task 4.2, handler.go's OnResourceLocationUpdated, zap console
# encoding -- confirmed live against this script's own apiserver.log output):
# the message is followed by a trailing JSON object holding the structured
# fields, in a FIXED field order (resource, host, value) -- "value" renders
# unquoted for numeric/boolean resources (float64/bool zap fields, this
# script's default set) and double-quoted for string-typed resources
# (type/model, excluded from the default set per bug-1574). Parsed below via
# index()/substr()/sub() -- not a JSON library -- mirroring
# poll_scheduler_log()'s own non-gawk-specific approach for its fixed-format
# trace line; the trailing quote pair (if present) is stripped so the
# numeric-vs-string branch in controller_values_match() sees the same shape
# either way.
poll_apiserver_log() {
local api_log="$1" host="$2"
shift 2
local pending=("$@")
local -A found=()
local iter=0
while [ "$iter" -lt "$APISERVER_POLL_MAX_ITERS" ] && [ "${#pending[@]}" -gt 0 ]; do
iter=$((iter + 1))
local name val
while IFS=$'\t' read -r name val; do
[ -n "$name" ] || continue
found["$name"]="$val"
done < <(awk -v host="$host" '
index($0, "resource-location stored") > 0 && index($0, "\"host\": \"" host "\"") > 0 {
line = $0
rtag = "\"resource\": \""
rstart = index(line, rtag)
if (rstart == 0) next
rest = substr(line, rstart + length(rtag))
rend = index(rest, "\"")
if (rend == 0) next
name = substr(rest, 1, rend - 1)
vtag = "\"value\": "
vstart = index(line, vtag)
if (vstart == 0) next
vraw = substr(line, vstart + length(vtag))
sub(/}[ \r]*$/, "", vraw)
if (length(vraw) >= 2 && substr(vraw, 1, 1) == "\"" && substr(vraw, length(vraw), 1) == "\"") {
vraw = substr(vraw, 2, length(vraw) - 2)
}
seen[name] = vraw
}
END { for (n in seen) print n "\t" seen[n] }
' "$api_log" 2>/dev/null)
local still_pending=()
for name in "${pending[@]}"; do
if [ -z "${found[$name]+x}" ]; then
still_pending+=("$name")
fi
done
pending=("${still_pending[@]}")
if [ "${#pending[@]}" -gt 0 ] && [ "$iter" -lt "$APISERVER_POLL_MAX_ITERS" ]; then
sleep "$APISERVER_POLL_INTERVAL"
fi
done
local n
for n in "${!found[@]}"; do
printf '%s\t%s\n' "$n" "${found[$n]}"
done
}
declare -A APISERVER_OBSERVED=() # resource name -> raw stored value string from the apiserver's trace log (only set for accepted names actually observed)
declare -A APISERVER_CHECK_RESULTS=() # resource name -> "MATCH" | "MISMATCH: injected=<X> observed=<Y>" | "UNREACHABLE: ..."
ANY_APISERVER_MISMATCH=0 # groundwork for task 7.1's verdict aggregation, mirrors ANY_CONTROLLER_MISMATCH/ANY_SCHEDULER_MISMATCH; this task's own exit code does NOT depend on it
ANY_APISERVER_UNREACHABLE=0 # groundwork for task 7.1, mirrors ANY_SCHEDULER_UNREACHABLE; kept distinct from ANY_APISERVER_MISMATCH per Requirement 5.2/5.4 and design.md's "Layer unreachable" being its own error category
# query_apiserver_resvals -- reads $TOP/logs/injection-results.tsv (same
# durable, order-preserving source query_controller_resvals()/
# query_scheduler_resvals() read) and, for every name marked "accepted",
# polls $TOP/logs/apiserver.log via poll_apiserver_log() above. Populates
# APISERVER_OBSERVED/APISERVER_CHECK_RESULTS and $TOP/logs/
# apiserver-check-results.tsv (name\tinjected\tobserved\tresult), following
# the exact same pattern query_scheduler_resvals() (task 6.3) established for
# its own layer.
query_apiserver_resvals() {
local tsv="$TOP/logs/injection-results.tsv"
[ -f "$tsv" ] || fail "apiserver check: $tsv not found -- inject_resources() must run first"
local name value result accepted_names=() skipped_names=()
while IFS=$'\t' read -r name value result; do
if [ "$result" = "accepted" ]; then
accepted_names+=("$name")
else
skipped_names+=("$name")
fi
done < "$tsv"
if [ "${#skipped_names[@]}" -gt 0 ]; then
log "apiserver check: skipping ${#skipped_names[@]} non-accepted resource(s) -- never actually applied at the apiserver, nothing to verify there: ${skipped_names[*]}"
fi
if [ "${#accepted_names[@]}" -eq 0 ]; then
log "apiserver check: no accepted resources to verify"
return 0
fi
local api_log="$TOP/logs/apiserver.log"
local total_wait
total_wait="$(awk -v i="$APISERVER_POLL_INTERVAL" -v n="$APISERVER_POLL_MAX_ITERS" 'BEGIN{printf "%.1f", i*n}')"
log "apiserver check: polling $api_log for ${#accepted_names[@]} accepted resource(s), bounded wait up to ${total_wait}s total: ${accepted_names[*]}"
local -A observed_map=()
local n v
while IFS=$'\t' read -r n v; do
[ -n "$n" ] || continue
observed_map["$n"]="$v"
done < <(poll_apiserver_log "$api_log" "$WORKER_HOST" "${accepted_names[@]}")
mkdir -p "$TOP/logs"
local results_tsv="$TOP/logs/apiserver-check-results.tsv"
: > "$results_tsv"
local injected observed result_str
for name in "${accepted_names[@]}"; do
injected="${INJECTED_VALUES[$name]}"
if [ -z "${observed_map[$name]+x}" ]; then
observed=""
result_str="UNREACHABLE: no apiserver trace-log line observed within bounded wait (injected=$injected observed=<none>)"
ANY_APISERVER_UNREACHABLE=1
else
observed="${observed_map[$name]}"
if controller_values_match "$injected" "$observed"; then
result_str="MATCH"
else
result_str="MISMATCH: injected=$injected observed=$observed"
ANY_APISERVER_MISMATCH=1
fi
fi
APISERVER_OBSERVED["$name"]="$observed"
APISERVER_CHECK_RESULTS["$name"]="$result_str"
log "apiserver check: $name -> $result_str"
printf '%s\t%s\t%s\t%s\n' "$name" "$injected" "$observed" "$result_str" >> "$results_tsv"
done
}
# ── Step 8: verdict aggregation, VERDICT line, PASS/FAIL oracle (task 7.1) ──
# Requirement 6.2/6.3/6.4: combine the controller/scheduler/apiserver
# per-layer results (tasks 6.2-6.4) plus the injection accept/reject outcome
# (tasks 2.1/2.2/6.1) into a single overall verdict. Deliberately
# implemented as a SELF-CONTAINED comparison/aggregation function --
# aggregate_verdict() below takes an array of plain TSV "records" (one per
# checked resource, see the format comment on aggregate_verdict() itself)
# and does its own MATCH/MISMATCH/UNREACHABLE classification via
# controller_values_match() (task 6.2's own pure comparison helper, reused
# here rather than re-parsing/re-trusting each layer's already-rendered
# CHECK_RESULTS string) -- it runs no live command, reads no file, and
# touches no global deployment state, so it is directly callable with
# hand-written SYNTHETIC records for testing (this task's own sanity-check
# invocations during implementation, and task 8.2's dedicated harness, which
# depends on this exact function/record format). main() below builds the
# record array from the ALREADY-POPULATED INJECTED_VALUES/INJECTED_RESULTS
# (task 6.1) and CONTROLLER_OBSERVED/SCHEDULER_OBSERVED/APISERVER_OBSERVED
# (tasks 6.2-6.4) associative arrays for the live-run path; a synthetic
# caller builds the same shape by hand, with no deployment involved at all.
#
# Per design.md's Error Handling, a REJECTED resource name (Requirement 1.2)
# is a run-level failure in its own right -- reported as its own failure
# category ("REJECTED", distinct from a per-layer MISMATCH/UNREACHABLE) and
# always drives the overall verdict to FAIL, independent of the (nonexistent
# -- a rejected name is never actually applied anywhere, see
# query_controller_resvals()/query_scheduler_resvals()/
# query_apiserver_resvals()'s shared skip rule) per-layer results.
# Record format (9 fields, separated by VERDICT_FIELD_SEP -- ASCII Unit
# Separator 0x1F, deliberately NOT a tab, see that constant's own comment
# below), one record string per checked resource:
# resource<SEP>injected<SEP>injection_status<SEP>
# ctrl_reachable<SEP>ctrl_observed<SEP>
# sched_reachable<SEP>sched_observed<SEP>
# api_reachable<SEP>api_observed
#
# resource -- resource name.
# injected -- the value that was (or would have been) injected.
# injection_status -- "accepted" or "rejected: <reason>"
# (INJECTED_RESULTS' own vocabulary, tasks 2.2/6.1)
# -- anything other than the literal string
# "accepted" is treated as a rejection, independent
# of the reason text.
# *_reachable -- "1" if that layer produced an observed value
# within scope/bounded wait, "0" if it did not
# (timeout, or simply never checked). Ignored when
# injection_status != "accepted" -- nothing was ever
# applied anywhere for a rejected name, so no layer
# is "checked" for it at all (matches tasks
# 6.2-6.4's own skip rule).
# *_observed -- observed value string for that layer, meaningful
# only when the paired *_reachable flag is "1".
#
# A record for a rejected resource still needs all 9 fields present
# (positional read) -- pass "0"/"" for each of the 6 layer fields by
# convention (see build_verdict_record() below); their content is ignored.
#
# VERDICT_FIELD_SEP: ASCII Unit Separator (0x1F), NOT a tab. Found live
# (this task's own synthetic sanity-check run, scenario (c) below) that a
# plain `IFS=$'\t' read` -- the convention every OTHER TSV in this file
# uses -- silently COLLAPSES a genuinely-empty field: bash treats tab (like
# space/newline) as "IFS whitespace" for splitting purposes even when IFS is
# set to ONLY a tab character, so two adjacent delimiters (an empty
# *_observed field, which legitimately happens both for an UNREACHABLE
# layer and for a reachable-but-empty-value MISMATCH, e.g. controller "no
# value" -- see query_controller_resvals()) collapse into one, shifting
# every field after it and silently corrupting the record. None of this
# file's other TSVs (injection-results.tsv, *-check-results.tsv) ever carry
# a genuinely-empty field, which is why this gotcha never surfaced before
# aggregate_verdict()'s record format, the first place in this script where
# an empty field is a legitimate, expected value. 0x1F is not "IFS
# whitespace" and is never expected to appear in a resource name, value, or
# status string, so it splits/preserves empty fields correctly with a plain
# `IFS=$VERDICT_FIELD_SEP read`.
VERDICT_FIELD_SEP=$'\x1f'
build_verdict_record() {
# build_verdict_record NAME INJECTED INJ_STATUS CTRL_OK CTRL_OBS SCHED_OK SCHED_OBS API_OK API_OBS
# Thin formatting helper so main()'s live-run wiring and any hand-built
# synthetic record share one place that defines field order/count -- not
# itself part of the "self-contained comparison step" (aggregate_verdict()
# is that step); a caller may also just printf/construct the
# VERDICT_FIELD_SEP-joined string directly, this exists purely for
# readability at call sites.
local sep="$VERDICT_FIELD_SEP"
printf '%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s' \
"$1" "$sep" "$2" "$sep" "$3" "$sep" "$4" "$sep" "$5" "$sep" \
"$6" "$sep" "$7" "$sep" "$8" "$sep" "$9"
}
# aggregate_verdict RECORD...
# THE self-contained comparison/aggregation step (Requirement 6.2/6.3/6.4).
# Pure function over its arguments: no network I/O, no file reads, no
# subprocess -- every classification decision is either a direct field
# comparison or a call to controller_values_match() (also pure). Safe and
# deterministic to call with hand-built synthetic records, live-run-derived
# records, or a mix, with no live deployment required either way.
#
# Resets and repopulates these globals on every call (so repeated calls in
# the same shell/test session -- e.g. one per synthetic scenario -- never
# see a stale result from a previous invocation):
# AGG_RESOURCE_STATUS["<name>"] = "PASS" | "FAIL"
# AGG_FAILURE_LINES[] = one human-readable "resource=... layer=...
# status=... ..." string per failing
# (resource, layer) pair or rejected name
# AGG_TOTAL_RESOURCES = count of records passed in
# AGG_MISMATCHES = count of (resource, layer) pairs
# classified MISMATCH
# AGG_UNREACHABLE = count of (resource, layer) pairs
# classified UNREACHABLE
# AGG_REJECTED = count of records whose injection_status
# was not "accepted"
# AGG_RESULT = "PASS" | "FAIL"
#
# Prints one FAILURE line per failing (resource, layer) pair or rejected
# name (naming the specific resource + layer, per Requirement 6.3), then
# exactly one VERDICT line, then exactly one terminal PASS/FAIL oracle line
# -- xce-test-ha's machine-parseable convention, see SKILL.md.
#
# Returns (exit code) 0 when AGG_RESULT=PASS, 1 when AGG_RESULT=FAIL -- so a
# caller (main(), or a synthetic test) can branch directly on the call
# itself: `if aggregate_verdict "${records[@]}"; then ... fi`.
declare -A AGG_RESOURCE_STATUS=()
AGG_FAILURE_LINES=()
AGG_TOTAL_RESOURCES=0
AGG_MISMATCHES=0
AGG_UNREACHABLE=0
AGG_REJECTED=0
AGG_RESULT="PASS"
aggregate_verdict() {
AGG_RESOURCE_STATUS=()
AGG_FAILURE_LINES=()
AGG_TOTAL_RESOURCES=0
AGG_MISMATCHES=0
AGG_UNREACHABLE=0
AGG_REJECTED=0
AGG_RESULT="PASS"
local rec resource injected inj_status
local ctrl_ok ctrl_obs sched_ok sched_obs api_ok api_obs
local layer ok obs resource_failed
for rec in "$@"; do
IFS="$VERDICT_FIELD_SEP" read -r resource injected inj_status \
ctrl_ok ctrl_obs sched_ok sched_obs api_ok api_obs <<< "$rec"
AGG_TOTAL_RESOURCES=$((AGG_TOTAL_RESOURCES + 1))
resource_failed=0
if [ "$inj_status" != "accepted" ]; then
AGG_REJECTED=$((AGG_REJECTED + 1))
resource_failed=1
AGG_FAILURE_LINES+=("resource=$resource layer=injection status=REJECTED detail=\"$inj_status\"")
else
for layer in controller scheduler apiserver; do
case "$layer" in
controller) ok="$ctrl_ok"; obs="$ctrl_obs" ;;
scheduler) ok="$sched_ok"; obs="$sched_obs" ;;
apiserver) ok="$api_ok"; obs="$api_obs" ;;
esac
if [ "$ok" != "1" ]; then
AGG_UNREACHABLE=$((AGG_UNREACHABLE + 1))
resource_failed=1
AGG_FAILURE_LINES+=("resource=$resource layer=$layer status=UNREACHABLE injected=$injected observed=<none>")
elif ! controller_values_match "$injected" "$obs"; then
AGG_MISMATCHES=$((AGG_MISMATCHES + 1))
resource_failed=1
AGG_FAILURE_LINES+=("resource=$resource layer=$layer status=MISMATCH injected=$injected observed=$obs")
fi
done
fi
if [ "$resource_failed" -eq 1 ]; then
AGG_RESOURCE_STATUS["$resource"]="FAIL"
AGG_RESULT="FAIL"
else
AGG_RESOURCE_STATUS["$resource"]="PASS"
fi
done
if [ "$AGG_TOTAL_RESOURCES" -eq 0 ]; then
# No resources at all is not a legitimate PASS -- nothing was
# actually verified. Structural/degenerate case, not expected from
# main()'s own live-run wiring (inject_resources() already fail()s
# the run before this point if RESOURCE_ARGS somehow resolved empty
# -- DEFAULT_RESOURCE_ARGS is always non-empty), but a synthetic
# caller could legitimately probe this edge, so classify it
# explicitly rather than silently reporting PASS on zero input.
AGG_RESULT="FAIL"
AGG_FAILURE_LINES+=("no resources were checked -- nothing to verify")
fi
local line
for line in "${AGG_FAILURE_LINES[@]}"; do
log "FAILURE: $line"
done
log "VERDICT resources=$AGG_TOTAL_RESOURCES layers_checked=3 mismatches=$AGG_MISMATCHES unreachable=$AGG_UNREACHABLE rejected=$AGG_REJECTED result=$AGG_RESULT"
if [ "$AGG_RESULT" = "PASS" ]; then
log "PASS: $AGG_TOTAL_RESOURCES resource(s), 3 layers checked, zero mismatches, zero unreachable, zero rejected"
return 0
else
log "FAIL: $AGG_TOTAL_RESOURCES resource(s), 3 layers checked, $AGG_MISMATCHES mismatch(es), $AGG_UNREACHABLE unreachable, $AGG_REJECTED rejected -- see FAILURE lines above"
return 1
fi
}
# build_verdict_records_from_tsv TSV_PATH
# Round-2 remediation of the round-1 REJECTED verdict (see the sched_ok/
# api_ok derivation comment inside for the actual bug/fix). This is main()'s
# own record-building loop (Task 7.1), extracted verbatim from main() into a
# standalone function so it is independently callable -- the round-1
# reviewer's exact finding was that a hand-built-synthetic-record test of
# aggregate_verdict() alone (task 8.2's harness) could never have caught this
# bug, because the bug lived entirely in main()'s data-marshaling code
# (reading $TOP/logs/injection-results.tsv plus the CONTROLLER_OBSERVED/
# SCHEDULER_OBSERVED/SCHEDULER_CHECK_RESULTS/APISERVER_OBSERVED/
# APISERVER_CHECK_RESULTS globals into aggregate_verdict()-shaped records),
# which a bypass-and-call-aggregate_verdict()-directly test never exercises.
# Extracting it here means a synthetic test can source this file, hand-
# populate the same globals + a synthetic TSV, call this function directly,
# and inspect its output -- exercising the EXACT SAME code main() itself
# runs, not a reimplementation of it. No behavior change from the inline
# version: same reads, same globals, same build_verdict_record() calls.
#
# Populates the global VERDICT_RECORDS array (reset on every call, mirroring
# aggregate_verdict()'s own reset-globals-on-every-call convention) with one
# build_verdict_record()-shaped record per line of TSV_PATH. Build one record
# per INJECTED name, in injection order -- read back from
# $TOP/logs/injection-results.tsv, the same durable, order-preserving source
# query_controller_resvals()/query_scheduler_resvals()/
# query_apiserver_resvals() already read (bash associative arrays have no
# defined iteration order, the TSV does). ANY_CONTROLLER_MISMATCH/
# ANY_SCHEDULER_MISMATCH/ANY_SCHEDULER_UNREACHABLE/ANY_APISERVER_MISMATCH/
# ANY_APISERVER_UNREACHABLE (set by the three query_*_resvals() calls) are
# NOT consulted here -- aggregate_verdict() does its own independent
# MATCH/MISMATCH/UNREACHABLE classification straight from the *_OBSERVED
# values via controller_values_match(), the same pure comparison helper each
# query_*_resvals() already uses, so a synthetic caller and this live-run
# path share identical classification logic.
VERDICT_RECORDS=()
build_verdict_records_from_tsv() {
local verdict_tsv="$1"
[ -f "$verdict_tsv" ] || fail "verdict aggregation: $verdict_tsv not found -- inject_resources() must run first"
VERDICT_RECORDS=()
local vname vvalue vstatus
local ctrl_ok ctrl_obs sched_ok sched_obs api_ok api_obs
while IFS=$'\t' read -r vname vvalue vstatus; do
[ -n "$vname" ] || continue
if [ "$vstatus" = "accepted" ]; then
# ctrl_ok: derived from CONTROLLER_OBSERVED array-key existence.
# This is fine as-is (round-2 review confirmed no change needed)
# -- query_controller_resvals()/internal_controller_check() has
# no separate UNREACHABLE concept at all; a query failure there
# aborts the whole run via fail() rather than leaving a
# placeholder entry, so "no observed value" legitimately means
# MISMATCH for this layer specifically, and array-key existence
# is an accurate proxy for "was queried".
if [ -n "${CONTROLLER_OBSERVED[$vname]+x}" ]; then
ctrl_ok=1; ctrl_obs="${CONTROLLER_OBSERVED[$vname]}"
else
ctrl_ok=0; ctrl_obs=""
fi
# sched_ok/api_ok: round-2 fix (remediation of the round-1
# REJECTED verdict). query_scheduler_resvals()/
# query_apiserver_resvals() (tasks 6.3/6.4) unconditionally set
# SCHEDULER_OBSERVED[$vname]/APISERVER_OBSERVED[$vname] for
# EVERY accepted+checked name, including on a genuine
# UNREACHABLE/timeout -- where the value is just set to "" (see
# the `observed=""` branch in both functions). So deriving
# sched_ok/api_ok from *_OBSERVED array-key existence (the old,
# rejected approach) was ALWAYS 1 for a checked name -- it could
# never see the genuine UNREACHABLE status, and aggregate_verdict
# fell through to comparing the injected value against an empty
# string, misreporting UNREACHABLE as MISMATCH. Fix: derive
# sched_ok/api_ok from SCHEDULER_CHECK_RESULTS[$vname]/
# APISERVER_CHECK_RESULTS[$vname] instead -- both are always
# populated (same unconditional-set guarantee, task 6.3/6.4) with
# one of exactly "MATCH", "MISMATCH: injected=... observed=...",
# or "UNREACHABLE: ..." (see the declare -A comments above each
# array), so a leading "UNREACHABLE:" is the correct, exact
# signal -- not the presence/absence of the OBSERVED entry.
# The `__UNCHECKED__` fallback below is defensive-only (not
# expected to trigger given query_scheduler_resvals()/
# query_apiserver_resvals() iterate the exact same accepted-name
# list this loop reads from the same injection-results.tsv, so
# CHECK_RESULTS is always populated by the time main() reaches
# this loop) -- but if it ever DID trigger, treating "never
# checked" as ok=1/empty-observed would silently reproduce this
# exact bug class, so it's treated as unreachable instead.
case "${SCHEDULER_CHECK_RESULTS[$vname]-__UNCHECKED__}" in
UNREACHABLE:*|__UNCHECKED__) sched_ok=0; sched_obs="" ;;
*) sched_ok=1; sched_obs="${SCHEDULER_OBSERVED[$vname]-}" ;;
esac
case "${APISERVER_CHECK_RESULTS[$vname]-__UNCHECKED__}" in
UNREACHABLE:*|__UNCHECKED__) api_ok=0; api_obs="" ;;
*) api_ok=1; api_obs="${APISERVER_OBSERVED[$vname]-}" ;;
esac
else
ctrl_ok=0; ctrl_obs=""
sched_ok=0; sched_obs=""
api_ok=0; api_obs=""
fi
VERDICT_RECORDS+=("$(build_verdict_record "$vname" "$vvalue" "$vstatus" \
"$ctrl_ok" "$ctrl_obs" "$sched_ok" "$sched_obs" "$api_ok" "$api_obs")")
done < "$verdict_tsv"
}
main() {
parse_args "$@"
resolve_top
# Trap registered before the FIRST process is started (deploy() below
# only generates scripts/certs/config; start_all() is what actually
# starts backgrounded processes) -- this covers a mid-deployment abort
# (e.g. apiserver fails to start after controller+scheduler are already
# up), not just a completed PASS/FAIL run. See Requirement 6.5 and
# design.md's Error Handling / Deployment failure entry.
register_trap
precondition_checks
deploy
start_all
confirm_deployment_up
inject_resources "${RESOURCE_ARGS[@]}"
query_controller_resvals
query_scheduler_resvals
query_apiserver_resvals
# --- Task 7.1: aggregate the controller/scheduler/apiserver per-layer
# results plus the injection accept/reject outcome into one VERDICT
# line + terminal PASS/FAIL oracle line (aggregate_verdict() above).
# build_verdict_records_from_tsv() (round-2 extraction, see its own
# header comment) does the data-marshaling; main() is just a thin
# caller. ---
build_verdict_records_from_tsv "$TOP/logs/injection-results.tsv"
log "aggregating verdict across ${#VERDICT_RECORDS[@]} resource(s) under $TOP; cleanup (trap-based, same mechanism as a mid-deployment abort) fires on exit below regardless of PASS/FAIL, unless --keep-running was passed -- logs under $TOP/logs are preserved either way"
if aggregate_verdict "${VERDICT_RECORDS[@]}"; then
exit 0
else
exit 1
fi
}
# Hidden internal entry point (bug-1579 fix): inject_resources()'s `!`
# shell-escape line invokes this same script as `bash "$SELF_SCRIPT"
# --internal-controller-check TOP PEERS WORKER_HOST NAME...` from inside its
# own xce_test worker session. Intercepted here, BEFORE main()/parse_args()
# (which has no knowledge of this flag and would otherwise reject it as an
# "Unknown argument") -- runs only internal_controller_check(), none of the
# normal deploy/inject/trap flow.
#
# Guarded so this file can be `source`d (e.g. a synthetic test harness that
# wants aggregate_verdict()/controller_values_match()/build_verdict_record()
# in scope, task 8.2's use case) without executing main() or
# internal_controller_check() as a side effect -- ${BASH_SOURCE[0]} (this
# file's own path) only equals ${0} when the script is the thing actually
# being run (directly executed, or `bash run-resource-verify.sh ...`,
# including the `--internal-controller-check` recursive invocation above,
# which is a fresh `bash "$SELF_SCRIPT" ...` process, not a source), not
# when it's sourced into another shell/script.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
if [ "${1:-}" = "--internal-controller-check" ]; then
shift
internal_controller_check "$@"
exit $?
fi
main "$@"
fi