Files
home-network-k3s-impl/conformance/tests/l2-behavioral/ha-control-plane/chainsaw-test.yaml
T
jakman85andClaude Opus 5 6a8953e288 feat(prod): observability for the two failure modes that went unseen, plus OIDC self-healing
Minimal prod delivery, authored directly on main (= prod's live revision d1a6628)
rather than by merging work/prod-standup-campaign. That branch carries the DNS apex
inversion, which would have set prod's BASE_DOMAIN to home.keithjackson.dev; prod
runs external-dns with policy: sync and txtOwnerId: prod, so it would have begun
rewriting apex records dev owns while the non-k3s prod still serves 443 there.
Operator sequencing is "dev migrates first". Nothing under clusters/ is touched
here at all, which is what makes this safe to land now.

Also deliberately EXCLUDED, because prod's live cluster-vars has no CNPG_BACKUP_PATH
key and StrictPostBuildSubstitutions would fail the whole tier: the R2 rework of
platform/velero/ and the CNPG destinationPath changes. Those land as
feat/r2-backup-prod together with the endpoint and bucket knobs.

WHAT THIS FIXES

1. Nothing was watching CNPG. All three prod databases have run since bootstrap with
   ContinuousArchiving=False and firstRecoverabilityPoint=null, and no alert existed
   anywhere. Because archive_mode=on and the archive command fails, Postgres refuses
   to recycle WAL: keycloak-db holds every segment ever written (257 MiB on a 5 GiB
   volume, ~6.8 MiB/h) — roughly 29 days to a disk-full PANIC on the cluster's SSO
   database. Found by hand, 38h late. CNPGWALArchiveBacklog would have fired within
   the hour, and evaluates to 3 firing series on prod today.
   Rules key on cnpg_collector_* rather than cnpg_pg_stat_archiver_*, because
   nextcloud-db's exporter cannot log in to its own database (permission denied for
   database "nextcloud", both clusters) so the pg_stat_archiver family does not exist
   for it — the obvious metric would have been blind to one database in three.

2. No PVC outside namespace=monitoring had any fill alert. New rules are partitioned
   by namespace so they cannot double-fire with MonitoringVolumeFillingUp, pinned to
   job="kubelet" (the apiserver copy of the same series reports namespace=default),
   and guarded by inodes > 0 — block-mode PVCs such as unifi-rootdisk report
   available=0 forever and would otherwise have fired critical permanently.

3. VPNTunnelDown could never clear on a cluster with no gateway node; it has been
   firing critical on prod since 2026-09-05. Now guarded so prod self-arms the moment
   a node carries the egress-gateway label, with dev's behaviour unchanged today.

4. The Keycloak realm import fails OPEN: an unresolved placeholder is stored
   LITERALLY and the import still reports success, then sits at Done=True and never
   re-runs. On 2026-09-06 all 12 prod OIDC clients held the literal
   $(env:..._CLIENT_SECRET) string and every SSO login failed, while every signal was
   green. The CronJob is the level-triggered correction; two alerts make its silence
   observable. Repaired live already — this stops it recurring.

Also: the R-NET-04 conformance scan descended into nested agent worktrees under
.claude/, counting 4 egress policies where 1 is authored.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 00:59:22 -05:00

380 lines
25 KiB
YAML

# =============================================================================
# L2 BEHAVIORAL — the cluster under test IS an HA k3s control plane (R-PLAT-01) [RUNNABLE]
# Catalog: docs/14 L2 row ("real runtime behavior, probes against a cluster")
# Spec: docs/00 R-PLAT-01 ("The platform MUST run on k3s across 3 nodes with
# production-grade HA control plane in prod; dev/test MAY run fewer nodes but MUST
# exercise the same multi-node topology.")
# docs/01 "Kubernetes distro | **k3s** (HA, embedded etcd) | R-PLAT-01/02"
# docs/02 table: "Control plane | HA embedded etcd (k3s server on all 3) | HA
# embedded etcd (k3s server on all 3)"
# "Node count | 3 VMs (mirror prod topology) | 3 physical nodes"
# docs/02 §"k3s topology decisions": "HA control plane with **embedded etcd** across
# all 3 nodes (no external datastore)"; "Nodes are both control-plane and workload
# (3-node converged cluster)".
#
# WHY L2 — AND WHAT L0 ALREADY DID
# --------------------------------
# tests/l0-static/ha-control-plane-as-code (same requirement label) proves the AUTHORED
# half offline: the repo builds a 3-server embedded-etcd control plane in both environments,
# one founder, no external datastore, a leader-elected control-plane-only VIP in the serving
# cert's SANs. It cannot prove that any of it is TRUE OF A RUNNING CLUSTER — an inventory
# entry is not a node, and a templated DaemonSet is not a floating VIP. That is this test:
# every assertion below is read from the cluster the suite is pointed at.
#
# ENVIRONMENT-AGNOSTIC (R-ENV-02): no address, hostname, node count or range is written
# here. The expected node count comes from the cluster's OWN declared identity
# (flux-system/cluster-vars CLUSTER_ENV, via a test-level chainsaw binding — the same
# ConfigMap Flux substitutes from); the control-plane VIP is read out of the kube-vip
# DaemonSet the cluster is actually running; the server addresses come from the Node
# objects; and the LoadBalancer pool the VIP must stay outside of is read from cluster-vars
# too. R-PLAT-01 itself distinguishes prod from dev/test ("3 nodes ... in prod; dev/test MAY
# run fewer"), so the assertion is necessarily environment-AWARE — but it takes the
# environment from the cluster, never from a flag or a literal, which is precisely the
# mechanism the tests/README.md convention prescribes.
#
# GIVEN the cluster this suite is pointed at,
# WHEN its Nodes, its kube-vip DaemonSet and its kube-apiserver endpoints are inspected,
# THEN DISTRO: every node's kubelet is a k3s build (`+k3s`) — the distro really is k3s;
# SERVERS: every Ready node is BOTH control-plane AND etcd (the converged 3-node
# topology docs/02 fixes — no agent-only node, no control-plane-only node),
# the count is ODD and quorum-capable (tolerates >= 1 loss), and it is
# EXACTLY three when the cluster declares itself prod;
# ETCD: every one of those nodes is a REGISTERED embedded-etcd member (k3s stamps
# `etcd.k3s.cattle.io/node-name` on a node that joined the etcd cluster), so
# the datastore is embedded and replicated across all of them;
# VIP: kube-vip is Ready on EVERY control-plane node, is control-plane-only
# (svc_enable=false — it must not contend with Cilium LB-IPAM for ARP), and
# the address it floats is neither a node address, nor the ingress Gateway
# VIP, nor inside the Cilium LoadBalancer pool;
# ENDPOINT: the kube-apiserver answers `/readyz` THROUGH THAT VIP over TLS that
# validates against the cluster CA for the VIP itself — i.e. the VIP really
# fronts the API and the serving cert really carries it;
# EVERY SERVER SERVES: the apiserver also answers `/readyz` on EVERY server's own
# address, again with a CA-valid cert for that address. This is the assertion
# that separates a real HA control plane from one server plus two joiners:
# if only the founder ran an apiserver, the other addresses would refuse.
#
# THE PROBE RUNS IN-CLUSTER, DELIBERATELY. An earlier draft dialled the VIP and the node
# addresses from the RUNNER. That works from a host on the node LAN (verified against prod)
# and FAILS against dev, whose nodes sit behind a NAT the runner reaches only via a
# forwarded port — i.e. it would have made the test a property of where it was launched
# from. A Job on the cluster is on the node network in both environments, so the probe is
# the same probe everywhere. The Job is created imperatively (kubectl heredoc) rather than
# as a fixture file because its address list is DISCOVERED from the cluster at run time —
# writing it into a fixture is exactly the per-environment literal R-ENV-02 forbids.
#
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this test as claiming them):
# - FAILOVER / SURVIVAL. Nothing here removes a node. That etcd keeps quorum, that the VIP
# migrates, and that the API stays reachable when a server dies is an L3 fault-injection
# claim and is proven NOWHERE in this suite yet (tests/l3-fault-injection/node-loss
# drains a node for R-STATE-02's data-availability claim and asserts nothing about the
# control plane). This is the largest remaining R-PLAT-01 gap.
# - THE NEXT environment. That the repo would build an HA control plane again is the L0
# half (tests/l0-static/ha-control-plane-as-code).
# - "PRODUCTION-GRADE" as a capacity/performance judgement (operator's, docs/02 + §2 of
# infra/PROD-PROVISIONING.md).
# - THAT THE RUNNING cilium-agents ACTUALLY DIAL THIS VIP. As of 2026-09-06 the repository
# authors `K3S_API_HOST` = the control-plane VIP in both environments (asserted at L0 by
# tests/l0-static/ha-control-plane-as-code, on both sides of the [parity] contract), but
# this test does not read the agents' live `KUBERNETES_SERVICE_HOST`. Until the operator
# applies that change the LIVE agents still carry the PRIMARY NODE's address — measured
# 2026-09-06: all three prod agents on 192.168.2.10 while kube-vip held 192.168.2.9. An
# in-cluster assertion on the agents' endpoint belongs here and is NOT yet written; it is
# the obvious follow-up once the change is applied and would catch drift the L0 check
# cannot see (a hand-edited DaemonSet, a Helm value overridden out of band).
# - etcd health beyond membership registration (raft leader, DB size, alarms) — k3s exposes
# it only on the node-local etcd metrics port, which is not reachable through the
# Kubernetes API this test is allowed to use.
#
# CLEANUP: the probe Job lives in chainsaw's per-test namespace and is deleted with it
# (config/chainsaw-config.yaml skipDelete: false). Everything else is read-only: this test
# creates nothing outside its own namespace and mutates no platform object.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: ha-control-plane
labels:
conformance.home.k3s/R-PLAT-01: "true"
annotations:
conformance.home.k3s/requirements: "R-PLAT-01"
conformance.home.k3s/layer: "L2"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
Given the cluster under test, when its Nodes, kube-vip DaemonSet and apiserver
endpoints are probed, then every node is a k3s server that is both control-plane and a
registered embedded-etcd member, the count is odd and quorum-capable (exactly three
when the cluster declares itself prod), kube-vip floats a control-plane-only VIP that
collides with no allocator, and the apiserver answers /readyz through that VIP AND on
every server's own address with a CA-valid cert.
spec:
timeouts:
apply: 60s
assert: 2m
exec: 3m
# -- R-ENV-02: the environment's identity and its address allocations are READ FROM THE
# CLUSTER UNDER TEST. flux-system/cluster-vars is the ConfigMap Flux itself substitutes
# from, so these are the same values the platform was rendered with. Test-level, so
# they are in scope for every step (step outputs/bindings are step-scoped).
bindings:
- name: clusterEnv
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.CLUSTER_ENV)
- name: gatewayVip
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.GATEWAY_VIP)
- name: lbPoolStart
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.CILIUM_LB_POOL_START)
- name: lbPoolStop
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.CILIUM_LB_POOL_STOP)
steps:
# -----------------------------------------------------------------------------------
- name: then-the-node-set-is-a-quorum-capable-converged-k3s-control-plane
description: >-
Read the Node objects: k3s on every node, every node both control-plane and a
registered embedded-etcd member, an odd quorum-capable count, exactly three in prod.
try:
- script:
env:
- name: CLUSTER_ENV
value: ($clusterEnv)
content: |
set -eu
fail() { echo "FAIL: $*" >&2; exit 1; }
total="$(kubectl get nodes -o name | grep -c . || true)"
[ "$total" -ge 1 ] || fail "the cluster reports NO nodes at all"
# --- k3s, on every node (R-PLAT-01 names the distro; docs/01 ADR-0001) -----
# A non-k3s kubelet here would mean the platform is not running on k3s.
notk3s="$(kubectl get nodes \
-o jsonpath='{range .items[*]}{.metadata.name}{"="}{.status.nodeInfo.kubeletVersion}{"\n"}{end}' \
| grep -v '+k3s' | grep -c . || true)"
[ "$notk3s" = "0" ] \
|| fail "$notk3s node(s) do not run a k3s kubelet — R-PLAT-01 requires the platform to run on k3s"
# --- every node is BOTH control-plane AND etcd (docs/02 converged topology) -
# Counted by label EXISTENCE, so it does not matter whether k3s writes "true"
# or "". A control-plane node without the etcd role would be a server with an
# external/absent datastore; an etcd node without control-plane would be an
# isolated datastore member. Both are excluded by requiring all three counts
# to be equal.
cp="$(kubectl get nodes -l node-role.kubernetes.io/control-plane -o name | grep -c . || true)"
etcd="$(kubectl get nodes -l node-role.kubernetes.io/etcd -o name | grep -c . || true)"
echo "nodes_total=$total control_plane=$cp etcd=$etcd cluster_env=${CLUSTER_ENV:-<unset>}"
[ "$cp" = "$total" ] \
|| fail "$cp of $total nodes carry node-role.kubernetes.io/control-plane — docs/02 fixes a converged cluster where every node is a control-plane member ('k3s server on all 3')"
[ "$etcd" = "$total" ] \
|| fail "$etcd of $total nodes carry node-role.kubernetes.io/etcd — docs/02 requires embedded etcd 'across all 3 nodes (no external datastore)'"
# --- every node is a REGISTERED embedded-etcd member -----------------------
# k3s stamps etcd.k3s.cattle.io/node-name on a node that actually joined the
# embedded-etcd cluster. The role label alone is a label; this annotation is
# the datastore's own record of membership.
members="$(kubectl get nodes \
-o jsonpath='{range .items[*]}{.metadata.annotations.etcd\.k3s\.cattle\.io/node-name}{"\n"}{end}' \
| grep -c . || true)"
[ "$members" = "$total" ] \
|| fail "$members of $total nodes are registered embedded-etcd members (etcd.k3s.cattle.io/node-name) — the datastore is not replicated across every server"
# --- Ready ---------------------------------------------------------------
notready="$(kubectl get nodes \
-o jsonpath='{range .items[*]}{.metadata.name}{"="}{range .status.conditions[?(@.type=="Ready")]}{.status}{end}{"\n"}{end}' \
| grep -v '=True$' | grep -c . || true)"
[ "$notready" = "0" ] \
|| fail "$notready node(s) are not Ready — a control plane missing a member is not the HA topology under test"
# --- quorum arithmetic ---------------------------------------------------
# Embedded etcd needs floor(N/2)+1 members to commit. Fault tolerance is
# N - quorum; 'production-grade HA' must survive at least one loss, which
# rules out 1 and 2 members. An EVEN count buys no extra tolerance and adds
# a tie, so it is also rejected.
quorum=$(( total / 2 + 1 ))
tolerance=$(( total - quorum ))
echo "quorum=$quorum tolerance=$tolerance"
[ "$tolerance" -ge 1 ] \
|| fail "$total etcd member(s) give quorum $quorum and tolerate $tolerance loss(es) — an HA control plane must survive losing a server"
[ $(( total % 2 )) -eq 1 ] \
|| fail "$total etcd members is EVEN: quorum is the same as for $(( total - 1 )) members, so the extra member adds a tie instead of tolerance"
# --- the count R-PLAT-01 itself fixes, per environment --------------------
# The environment is the cluster's OWN CLUSTER_ENV (cluster-vars), never a
# flag: 'k3s across 3 nodes ... in prod; dev/test MAY run fewer nodes but MUST
# exercise the same multi-node topology'.
case "${CLUSTER_ENV:-}" in
prod)
[ "$total" = "3" ] \
|| fail "this cluster declares CLUSTER_ENV=prod and has $total nodes — R-PLAT-01 requires 'k3s across 3 nodes ... in prod' (docs/02: '3 physical nodes')" ;;
"")
fail "flux-system/cluster-vars has no CLUSTER_ENV — the cluster does not declare which environment it is, so R-PLAT-01's prod clause has no referent (and no literal may be substituted for it)" ;;
*)
[ "$total" -ge 2 ] \
|| fail "this cluster declares CLUSTER_ENV=${CLUSTER_ENV} and has $total node(s) — dev/test may run fewer nodes than prod but MUST 'exercise the same multi-node topology'" ;;
esac
echo "HA_TOPOLOGY_OK"
check:
(contains($stdout, 'HA_TOPOLOGY_OK')): true
# -----------------------------------------------------------------------------------
- name: then-a-control-plane-only-vip-is-floated-on-every-server
description: >-
Read the running kube-vip DaemonSet: Ready on every control-plane node,
control-plane-only, and floating an address that collides with no other allocator.
try:
- script:
env:
- name: GATEWAY_VIP
value: ($gatewayVip)
- name: LB_POOL_START
value: ($lbPoolStart)
- name: LB_POOL_STOP
value: ($lbPoolStop)
content: |
set -eu
fail() { echo "FAIL: $*" >&2; exit 1; }
# dotted quad -> integer, for range comparison without ipcalc/python.
n() { echo "$1" | awk -F. '{printf "%d", ($1*16777216)+($2*65536)+($3*256)+$4}'; }
# The DaemonSet is found by the label roles/kube_vip stamps on it, not by a
# hardcoded name in a namespace this test guessed.
ds="$(kubectl -n kube-system get ds -l app.kubernetes.io/name=kube-vip-ds -o name | head -1)"
[ -n "$ds" ] \
|| fail "no kube-vip DaemonSet is running in kube-system — the kube-apiserver has no floating endpoint, so losing the founder takes the API endpoint with it"
desired="$(kubectl -n kube-system get "$ds" -o jsonpath='{.status.desiredNumberScheduled}')"
ready="$(kubectl -n kube-system get "$ds" -o jsonpath='{.status.numberReady}')"
cp="$(kubectl get nodes -l node-role.kubernetes.io/control-plane -o name | grep -c . || true)"
vip="$(kubectl -n kube-system get "$ds" \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="address")].value}')"
cp_enable="$(kubectl -n kube-system get "$ds" \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="cp_enable")].value}')"
svc_enable="$(kubectl -n kube-system get "$ds" \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="svc_enable")].value}')"
echo "kube_vip=$ds ready=$ready/$desired control_plane_nodes=$cp vip=$vip cp_enable=$cp_enable svc_enable=$svc_enable"
[ -n "$vip" ] \
|| fail "the kube-vip DaemonSet announces no address — nothing is floated"
[ "$cp_enable" = "true" ] \
|| fail "kube-vip runs with cp_enable=$cp_enable — control-plane mode is off, so it floats no API VIP"
[ "$svc_enable" = "false" ] \
|| fail "kube-vip runs with svc_enable=$svc_enable — it would also serve Service LoadBalancers and contend with Cilium LB-IPAM for ARP on the same addresses (ADR-0002 gives Service LB to Cilium)"
[ "$desired" = "$cp" ] \
|| fail "kube-vip is scheduled on $desired node(s) but there are $cp control-plane nodes — the VIP must be able to move to ANY server, or its failover set is smaller than the control plane"
[ "$ready" = "$desired" ] \
|| fail "kube-vip is Ready on $ready of $desired nodes — a VIP holder that is not running cannot take the address over"
# --- the VIP collides with nothing --------------------------------------
node_ips="$(kubectl get nodes \
-o jsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}' \
| grep -c "^${vip}$" || true)"
[ "$node_ips" = "0" ] \
|| fail "the floated VIP $vip is ALSO a node's own InternalIP — a VIP that is a node's address cannot float off that node"
[ "$vip" != "${GATEWAY_VIP:-}" ] \
|| fail "the control-plane VIP and the ingress Gateway VIP are the same address ($vip) — kube-vip and Cilium would both ARP for it"
if [ -n "${LB_POOL_START:-}" ] && [ -n "${LB_POOL_STOP:-}" ]; then
v="$(n "$vip")"; lo="$(n "$LB_POOL_START")"; hi="$(n "$LB_POOL_STOP")"
[ "$lo" -le "$hi" ] \
|| fail "the cluster's Cilium LB pool ${LB_POOL_START}-${LB_POOL_STOP} is inverted, so the VIP cannot be proven to be outside it"
if [ "$v" -ge "$lo" ] && [ "$v" -le "$hi" ]; then
fail "the control-plane VIP $vip lies INSIDE this cluster's Cilium LoadBalancer pool ${LB_POOL_START}-${LB_POOL_STOP} — LB-IPAM could hand the kube-API's VIP to a Service"
fi
else
fail "flux-system/cluster-vars declares no CILIUM_LB_POOL_START/STOP — the separation between the control-plane VIP and the Service-LB allocator cannot be checked, and no literal range may be substituted for it (R-ENV-02)"
fi
echo "HA_VIP_CONTROL_PLANE_ONLY_OK"
check:
(contains($stdout, 'HA_VIP_CONTROL_PLANE_ONLY_OK')): true
# -----------------------------------------------------------------------------------
- name: then-the-apiserver-answers-through-the-vip-and-on-every-server
description: >-
From inside the cluster, probe /readyz on the floated VIP and on every server's own
address, validating TLS against the cluster CA for the address dialled.
try:
- script:
# The address list is DISCOVERED here (Nodes + the kube-vip DaemonSet), so the
# Job cannot be a static fixture — writing the addresses down is the
# per-environment literal R-ENV-02 forbids. Hence the imperative heredoc, the
# same pattern tests/l3-fault-injection/dr-restore uses for run-time-shaped
# fixtures. The Job lives in chainsaw's per-test namespace and is torn down with
# it; nothing outside that namespace is created or changed.
content: |
set -eu
ns="$NAMESPACE"
fail() { echo "FAIL: $*" >&2; exit 1; }
ds="$(kubectl -n kube-system get ds -l app.kubernetes.io/name=kube-vip-ds -o name | head -1)"
vip="$(kubectl -n kube-system get "$ds" \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="address")].value}')"
servers="$(kubectl get nodes -l node-role.kubernetes.io/control-plane \
-o jsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{" "}{end}')"
[ -n "$vip" ] || fail "no floated VIP to probe"
[ -n "$servers" ] || fail "no control-plane node addresses to probe"
echo "probing vip=$vip servers=$servers"
# The probe pod authenticates with its own ServiceAccount token and validates
# TLS against the cluster CA, both from the projected SA volume. /readyz is
# authorized for any authenticated identity via the built-in
# system:public-info-viewer role — anonymous access is NOT relied on (verified:
# an unauthenticated /readyz on this platform answers 401).
kubectl -n "$ns" apply -f - <<EOF
apiVersion: batch/v1
kind: Job
metadata:
name: ha-api-endpoint-probe
spec:
backoffLimit: 1
activeDeadlineSeconds: 150
template:
spec:
restartPolicy: Never
containers:
- name: probe
image: curlimages/curl:8.11.1
env:
- name: VIP
value: "${vip}"
- name: SERVERS
value: "${servers}"
command:
- sh
- -c
- |
set -u
SA=/var/run/secrets/kubernetes.io/serviceaccount
probe() { # \$1 = address to dial
curl -sS --max-time 15 \\
--cacert "\$SA/ca.crt" \\
-H "Authorization: Bearer \$(cat \$SA/token)" \\
"https://\$1:6443/readyz"
}
rc=0
out="\$(probe "\$VIP" 2>&1)" || rc=1
echo "vip \$VIP -> [\$out]"
if [ "\$rc" != "0" ] || [ "\$out" != "ok" ]; then
echo "FAIL: the kube-apiserver did not answer /readyz through the floated VIP \$VIP with a CA-valid cert for that address — the VIP does not front the API (or it is missing from the serving cert's SANs)"
exit 1
fi
for s in \$SERVERS; do
rc=0
out="\$(probe "\$s" 2>&1)" || rc=1
echo "server \$s -> [\$out]"
if [ "\$rc" != "0" ] || [ "\$out" != "ok" ]; then
echo "FAIL: server \$s does not answer /readyz with a CA-valid cert for its own address — it is not running a kube-apiserver, so the control plane is not HA across every node"
exit 1
fi
done
echo "HA_API_ENDPOINT_OK"
EOF
# Bounded wait; on failure surface the probe's own output before failing.
if ! kubectl -n "$ns" wait --for=condition=complete --timeout=140s job/ha-api-endpoint-probe; then
kubectl -n "$ns" logs job/ha-api-endpoint-probe --tail=40 || true
fail "the in-cluster API-endpoint probe did not complete — see its log above"
fi
logs="$(kubectl -n "$ns" logs job/ha-api-endpoint-probe --tail=40)"
echo "$logs"
case "$logs" in
*HA_API_ENDPOINT_OK*) echo "HA_API_ENDPOINT_REACHABLE_OK" ;;
*) fail "the probe completed without reporting success" ;;
esac
check:
(contains($stdout, 'HA_API_ENDPOINT_REACHABLE_OK')): true