Files
cozystack/hack/e2e-post-install-prep.sh
Aleksei Sviridkin 735486fc36 fix(e2e): say when a post-install-prep wait attempt hit its bound
When the outer timeout cut a `kubectl wait` attempt, nothing was
printed: `timeout` is silent and kubectl dies on the signal. The
timeout line then read "last kubectl error: none", which says the
client had nothing to report rather than that it was killed
mid-request. The attempt's exit status now names that case.

The errors the loop prints also carry the script's prefix. The first
NotFound shows up on every green run, since the release appears about
a minute in, and without the prefix it reads like a failure when the
log is scanned after one.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-10-01 02:19:17 +03:00

374 lines
17 KiB
Bash
Executable File

#!/bin/sh
# Runs LINSTOR pool + StorageClass + MetalLB pool configuration as soon as
# their respective prerequisites are reachable. Designed to run in the
# background during the platform HR reconcile wait, so its wall-clock cost
# overlaps with the wait instead of compounding it.
#
# Each LINSTOR prerequisite below sits at the end of a multi-hop reconcile
# chain: cozystack-operator -> platform HR -> linstor HR -> piraeus-operator
# -> cert-manager issues the controller TLS -> linstor-controller Deployment
# -> controller pod -> DB migration. On a loaded CI runner that chain has been
# observed to take 7-9 min end to end, and the operator alone needs ~70s just
# to emit the linstor HR. The earlier per-step "object exists" budgets
# (timeout 60 / timeout 300) were anchored to this script's start, so they
# raced that reconcile latency; when one lost (linstor HR appeared at ~+70s
# against a 60s budget) `set -e` aborted the whole script and the install
# failed. So every wait below tolerates a not-yet-created object without
# aborting, and each gets its own budget, started when the link before it
# completed rather than when this script did -- the same shape the LINSTOR
# node and MetalLB CRD waits further down already have.
#
# One budget shared by the whole chain instead fails the install whenever the
# chain merely runs long, because the head of it eats the window and the tail
# inherits the remainder. Both halves were observed inside a single 15m window
# on one run: the linstor HR went Ready 452s in, and the Deployment needed a
# further ~520s because the controller's DB migration lost its connection to
# the apiserver and retried four times before succeeding. Neither figure is
# anomalous and neither exceeds the budget on its own; only their sum did, and
# the install failed naming the Deployment as though it were broken.
#
# What a per-link budget buys is not speed but a guarantee: every link gets its
# whole allowance no matter what the links ahead of it spent, so the tail of the
# chain can no longer be failed by the head merely being slow. Detecting a link
# that is genuinely stuck does not get faster, and for the second link it gets
# slower, because its budget starts when the first one finished: a Deployment
# that never converges is reported later by however long the HelmRelease took.
# A first link that never becomes Ready is reported at the same moment as
# before, since its budget still starts with the script.
#
# The cost is the ceiling: two waits at LINK_BUDGET each put the worst case at
# twice that figure rather than once, ahead of the 300s node wait, the 600s
# StorageProfile wait in container mode and the 300s MetalLB wait below, plus
# the bound on each call that does work, and still well inside the timeout the
# job carries.
set -eu
# The QEMU lane gives every satellite a private /dev/vdc and lets LINSTOR
# create the zpool. Container nodes share the runner kernel, where zpools are
# global, so hack/e2e-container-up.sh creates one node-distinct pool ahead of
# time and the satellite must register that existing pool instead.
validate_linstor_storage_mode() {
case "${COZY_LINSTOR_DRBD_ENABLED:-true}" in
true | false) return 0 ;;
*)
echo "[post-install-prep] COZY_LINSTOR_DRBD_ENABLED must be true or false, got '${COZY_LINSTOR_DRBD_ENABLED}'" >&2
return 1
;;
esac
}
# KUBECTL_BOUND prefixes the cluster calls in the helpers below with a wall-clock
# bound. The main body sets it; it stays empty when unit tests source the
# helpers, because `timeout` execs a binary and would bypass the kubectl shell
# function those tests substitute, reaching whatever cluster the host points at.
KUBECTL_BOUND=
create_linstor_storage_pool() {
e2e_linstor_node=$1
case "${COZY_LINSTOR_DRBD_ENABLED:-true}" in
true)
$KUBECTL_BOUND kubectl exec -n cozy-linstor deploy/linstor-controller -- \
linstor physical-storage create-device-pool zfs "$e2e_linstor_node" /dev/vdc \
--pool-name data --storage-pool data
;;
false)
$KUBECTL_BOUND kubectl exec -n cozy-linstor deploy/linstor-controller -- \
linstor storage-pool create zfs "$e2e_linstor_node" data "data-$e2e_linstor_node"
;;
*) return 2 ;;
esac
}
render_linstor_storageclasses() {
cat <<'EOF'
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: linstor.csi.linbit.com
parameters:
linstor.csi.linbit.com/storagePool: "data"
linstor.csi.linbit.com/layerList: "storage"
linstor.csi.linbit.com/allowRemoteVolumeAccess: "false"
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
EOF
if [ "${COZY_LINSTOR_DRBD_ENABLED:-true}" = false ]; then
return 0
fi
cat <<'EOF'
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: replicated
provisioner: linstor.csi.linbit.com
parameters:
linstor.csi.linbit.com/storagePool: "data"
linstor.csi.linbit.com/autoPlace: "3"
linstor.csi.linbit.com/layerList: "drbd storage"
linstor.csi.linbit.com/allowRemoteVolumeAccess: "true"
property.linstor.csi.linbit.com/DrbdOptions/auto-quorum: suspend-io
property.linstor.csi.linbit.com/DrbdOptions/Resource/on-no-data-accessible: suspend-io
property.linstor.csi.linbit.com/DrbdOptions/Resource/on-suspended-primary-outdated: force-secondary
property.linstor.csi.linbit.com/DrbdOptions/Net/rr-conflict: retry-connect
volumeBindingMode: Immediate
allowVolumeExpansion: true
EOF
}
# CDI infers ReadWriteMany/Block first for the LINSTOR provisioner because it
# assumes a DRBD-capable StorageClass. The container lane deliberately exposes
# only LINSTOR's local storage layer, and linstor-csi rejects RWX without DRBD.
# Pin the one class used by that lane to the RWO/Block combination phase-0
# proved before any DataVolume can consume the inferred default.
patch_local_cdi_storage_profile() {
$KUBECTL_BOUND kubectl patch storageprofile local --request-timeout=60s --type merge \
-p '{"spec":{"claimPropertySets":[{"accessModes":["ReadWriteOnce"],"volumeMode":"Block"}]}}'
}
# Unit tests source the pure helpers above without reaching a cluster.
if [ "${E2E_POST_INSTALL_PREP_LIB:-false}" = true ]; then
return 0 2>/dev/null || exit 0
fi
if ! validate_linstor_storage_mode; then
exit 2
fi
# Every call that does work carries a bound, not only the waits: the caller
# blocks in `wait` on this script, so one call that never returns holds the
# install open until the job's own ceiling instead of failing the step. A
# deadline checked between loop iterations does not cover the call inside the
# iteration. Plain requests also carry a --request-timeout strictly inside the
# outer bound, so kubectl gets to name the reason before the kill lands; exec
# streams get the outer bound alone. When that bound fires, `timeout` prints
# nothing and kubectl dies on the signal before it can, so the wrapper says
# which call it was: otherwise the log ends on the step's announcement line.
# Only 124 is named. 137 is also what `kubectl exec` returns when the remote
# command is OOM-killed, so reading it as this bound would name the wrong cause.
CALL_BOUND=300
bounded() {
bounded_rc=0
timeout -k 5 "$CALL_BOUND" "$@" || bounded_rc=$?
if [ "$bounded_rc" -eq 124 ]; then
echo "[post-install-prep] killed after ${CALL_BOUND}s: $*" >&2
fi
return "$bounded_rc"
}
KUBECTL_BOUND=bounded
# Per-link budget in seconds, applied by wait_for_linstor to each link separately.
LINK_BUDGET=900
# wait_for_linstor <description> <kubectl-wait-args...>
# Polls `kubectl wait` until it succeeds or this link's budget elapses. The
# name says linstor because the timeout diagnostics below read cozy-linstor
# unconditionally: the wait itself is generic over its kubectl arguments, but
# what it dumps on the way out is not, and a caller elsewhere would get the
# wrong namespace's pods presented as evidence.
# kubectl wait exits non-zero immediately when the object does not exist yet,
# so the loop tolerates "not created yet" without the set -e cliff that a bare
# `kubectl wait` would trigger on a NotFound. The per-attempt timeout shrinks
# to the budget remaining, so the final attempt can consume the rest of it.
# The outer `timeout` sits above that: --timeout covers only the watch, not the
# GET that resolves the object first, which has no deadline of its own.
# An expired credential or an RBAC denial exits the same way, and only the
# text tells them apart, so stderr is kept and printed whenever it changes:
# once for a NotFound repeated every poll, and again when the cause changes.
# It is printed rather than matched to fail fast, because apiserver restarts
# during install produce the same connection and auth errors transiently.
wait_for_linstor() {
desc=$1
shift
echo "[post-install-prep] waiting for ${desc}"
deadline=$(( $(date +%s) + LINK_BUDGET ))
last_err=
while :; do
remaining=$(( deadline - $(date +%s) ))
if [ "$remaining" -le 0 ]; then
echo "[post-install-prep] timed out after ${LINK_BUDGET}s waiting for ${desc}; last kubectl error: ${last_err:-none}" >&2
# The description names the object waited on, not the link of the chain
# that held it up, and those differ: the same "Deployment not Available"
# has been reached with the controller pod crash-looping its migration
# init container and with that pod unschedulable because cert-manager had
# not issued its client TLS Secret yet. The pod list separates the two,
# and the namespace events carry the reason a volume or an image failed.
# `kubectl events` rather than `kubectl get events`: it orders by when an
# event was last seen, so a condition that has been repeating for minutes
# -- which is what a stuck mount looks like -- stays inside the window
# instead of being pushed out of it by newer one-off events. It also has
# no --sort-by, so it cannot fail the whole read the way a sort key does
# when it is absent from any single item; .lastTimestamp is unset on an
# Event written through events.k8s.io/v1, and that failure prints nothing
# at all. Every read here keeps its stderr, because a diagnostic that fails
# silently is indistinguishable from a namespace with nothing to report
# -- the very distinction being drawn here. Each is bounded, since the
# caller is blocked in `wait` and a wedged apiserver would otherwise hold
# the install open until the job's own timeout; the client budget is
# strictly the smaller of the two, so kubectl gets to name the reason
# before the outer kill takes it away.
# The release list comes first: a linstor HelmRelease that never goes
# Ready can be held by a dependency further up the chain rather than
# failing itself, and each release's Ready message names what it waits
# on, so the list points at the release that is actually stuck, which
# the cozy-linstor pods cannot.
timeout -k 5 30 kubectl get hr -A \
--request-timeout=10s 2>&1 | tail -n 300 >&2
timeout -k 5 30 kubectl get pods -n cozy-linstor -o wide \
--request-timeout=10s 2>&1 | tail -n 30 >&2
timeout -k 5 30 kubectl events -n cozy-linstor \
--request-timeout=10s 2>&1 | tail -n 30 >&2
return 1
fi
attempt_rc=0
{ attempt_err=$(timeout -k 5 $(( remaining + 30 )) \
kubectl wait "$@" --timeout="${remaining}s" 2>&1 >&3); } 3>&1 || attempt_rc=$?
if [ "$attempt_rc" -eq 0 ]; then
return 0
fi
# A bound that fires leaves no text: `timeout` prints nothing and kubectl
# dies on the signal. Not a remote exit code here, so 137 is the -k kill.
case $attempt_rc in
124 | 137) attempt_err="attempt cut by its outer bound after $(( remaining + 30 ))s" ;;
esac
if [ -n "$attempt_err" ] && [ "$attempt_err" != "$last_err" ]; then
printf '%s\n' "$attempt_err" | sed 's/^/[post-install-prep] /' >&2
last_err=$attempt_err
fi
sleep 5
done
}
# controller_reachable: true only while the linstor-controller Service has at
# least one *ready* endpoint. The linstor CLI below dials that Service
# (linstor+ssl://linstor-controller:3371), and a ClusterIP is routable only once
# its Service has a ready backend -- otherwise the dial fails with
# "[Errno 113] No route to host" (EHOSTUNREACH). In the core Endpoints object the
# ready set is .subsets[].addresses (peers not yet ready sit in
# .subsets[].notReadyAddresses), so a non-empty addresses list is exactly
# ">=1 ready backend". Missing object / API error -> empty -> not reachable.
controller_reachable() {
[ -n "$(timeout -k 5 30 kubectl get endpoints linstor-controller -n cozy-linstor \
--request-timeout=10s -o jsonpath='{.subsets[*].addresses[*].ip}' 2>/dev/null)" ]
}
# wait_for_object <budget-seconds> <description> <kubectl-get-args...>
# Polls until `kubectl get` finds the objects. NotFound is the ordinary state
# while polling, so each attempt's stderr is dropped; on the way out the read
# runs once more with stderr kept, so a timeout says which object is missing
# instead of ending on the "waiting for" line.
wait_for_object() {
budget=$1
desc=$2
shift 2
echo "[post-install-prep] waiting for ${desc}"
deadline=$(( $(date +%s) + budget ))
until timeout -k 5 30 kubectl get "$@" --request-timeout=10s >/dev/null 2>&1; do
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "[post-install-prep] timed out after ${budget}s waiting for ${desc}" >&2
timeout -k 5 30 kubectl get "$@" --request-timeout=10s 2>&1 | tail -n 30 >&2
return 1
fi
sleep 2
done
}
wait_for_linstor "linstor HelmRelease to be Ready" \
helmrelease/linstor -n cozy-linstor --for=condition=Ready
wait_for_linstor "linstor-controller Deployment to be Available" \
deployment/linstor-controller -n cozy-linstor --for=condition=available
# Wait for 3 satellites to register Online, but gate every probe on a ready
# controller Service endpoint. The "Deployment Available" check above is a
# one-shot gate that goes stale: the controller carries
# reloader.stakater.com/auto=true and mounts the cert-manager-issued API-TLS
# Secret (see packages/system/linstor/templates/cluster.yaml). When cert-manager
# (re)writes that Secret during bring-up, reloader rolls the single-replica
# controller, dropping its Service to zero ready endpoints *after* Available
# already passed. A blind CLI dial into that window is what surfaced as the
# tolerated "[Errno 113] No route to host" churn. Probing only while the Service
# is routable removes that churn at the root -- a mid-loop reload re-waits for
# the endpoint instead of erroring -- while "Online == 3" stays the real
# satellite-convergence assertion. The dedicated 300s budget is preserved from
# the previous `timeout 300`; `set -e` is disabled inside an until-condition, so
# a not-yet-reachable controller does not abort the script.
echo "[post-install-prep] waiting for linstor-controller endpoint + 3 LINSTOR nodes Online"
node_deadline=$(( $(date +%s) + 300 ))
until controller_reachable \
&& [ "$(timeout -k 5 30 kubectl exec -n cozy-linstor deploy/linstor-controller -- linstor node list 2>/dev/null | grep -c Online)" -eq 3 ]; do
if [ "$(date +%s)" -ge "$node_deadline" ]; then
echo "[post-install-prep] timed out waiting for linstor-controller endpoint + 3 LINSTOR nodes Online" >&2
timeout -k 5 30 kubectl get endpoints linstor-controller -n cozy-linstor -o wide \
--request-timeout=10s 2>&1 | tail -n 30 >&2
timeout -k 5 30 kubectl get pods -n cozy-linstor -o wide \
--request-timeout=10s 2>&1 | tail -n 30 >&2
exit 1
fi
sleep 2
done
echo "[post-install-prep] creating LINSTOR storage pools (parallel across nodes)"
# Read on its own rather than piped into awk, which would hide its status: a
# listing that failed or timed out reads as "no pools yet", and the creates it
# triggers then fail on the pools that do exist, naming the wrong reason.
pool_list=$($KUBECTL_BOUND kubectl exec -n cozy-linstor deploy/linstor-controller -- linstor sp l -s data --pastable)
created_pools=$(printf '%s\n' "$pool_list" | awk '$2 == "data" {printf " " $4} END{printf " "}')
pids=""
for node in srv1 srv2 srv3; do
case $created_pools in
*" $node "*) echo " pool 'data' already exists on $node"; continue;;
esac
create_linstor_storage_pool "$node" &
pids="$pids $!"
done
for pid in $pids; do
wait "$pid"
done
echo "[post-install-prep] applying StorageClasses"
render_linstor_storageclasses | $KUBECTL_BOUND kubectl apply --request-timeout=60s -f -
if [ "${COZY_LINSTOR_DRBD_ENABLED:-true}" = false ]; then
wait_for_object 600 "CDI StorageProfile/local" storageprofile local
echo "[post-install-prep] forcing CDI StorageProfile/local to RWO/Block"
patch_local_cdi_storage_profile
fi
# The CRDs come from the metallb release, so its state is what separates CRDs
# that are late from an operator that never got as far as installing them.
if ! wait_for_object 300 "MetalLB CRDs" crd ipaddresspools.metallb.io l2advertisements.metallb.io; then
timeout -k 5 30 kubectl get hr -n cozy-metallb \
--request-timeout=10s 2>&1 | tail -n 30 >&2
exit 1
fi
echo "[post-install-prep] applying MetalLB IPAddressPool"
$KUBECTL_BOUND kubectl apply --request-timeout=60s -f - <<'EOF'
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: cozystack
namespace: cozy-metallb
spec:
ipAddressPools: [cozystack]
---
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: cozystack
namespace: cozy-metallb
spec:
addresses: [192.168.123.200-192.168.123.250]
autoAssign: true
avoidBuggyIPs: false
EOF
echo "[post-install-prep] done"