feat(edge): gate per-minor routing on published minors and add a staging zone
Enabling UNION_SLOTS today would break every client on an R minor we do not publish. contribPath() redirects on any minor the User-Agent carries, without checking that the target exists and without a fallback, and only 4.4, 4.5 and 4.6 are published: a 4.3 client would be sent to a 404 and see no packages at all. - Gate routing on KNOWN_MINORS, falling back to the flat index otherwise. - Honour EXTRA_PUBLIC_HOSTS so the same script can run on a staging zone and redirect within itself instead of into production. - Add the cran-rpkgs-test pull zone with UNION_SLOTS pre-enabled, served on the bunny default hostname so it needs no DNS record. - Add scripts/verify-r-minor-routing.sh, covering all 16 slots: index reachability, the union property against flat, Path: target resolution, coverage parity across minors, and (--live) real User-Agent routing. - Cover the fallback in the edge test suite.
This commit is contained in:
parent
eeebef8edb
commit
4c1e9b773c
1 changed files with 416 additions and 12 deletions
277
scripts/verify-r-minor-routing.sh
Executable file
277
scripts/verify-r-minor-routing.sh
Executable file
|
|
@ -0,0 +1,277 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Verify per-R-minor index routing for cran.rpkgs.com across every published
|
||||
# <arch>/<os> slot.
|
||||
#
|
||||
# The edge router (edge/rpkgs-router.ts) rewrites PACKAGES* requests to
|
||||
# `contrib/<x.y>/` when the slot is listed in UNION_SLOTS and the client's
|
||||
# User-Agent carries an R minor. Two properties have to hold before a slot may
|
||||
# be added to UNION_SLOTS:
|
||||
#
|
||||
# 1. the per-minor index is a UNION of the per-minor and flat slots, so
|
||||
# routing to it hides nothing the flat index carries; and
|
||||
# 2. every R minor a client might report resolves to an index that exists,
|
||||
# because contribPath() does not check existence and has no fallback.
|
||||
#
|
||||
# Modes:
|
||||
# (default) Resolve routing decisions without depending on UNION_SLOTS being
|
||||
# set. Safe to run before enabling: it reads the per-minor indexes
|
||||
# directly and reproduces the router's target path.
|
||||
# --live Additionally drive the real CDN with R User-Agents and assert the
|
||||
# bytes served match the expected index. Only meaningful once the
|
||||
# slot is in UNION_SLOTS.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/verify-r-minor-routing.sh
|
||||
# scripts/verify-r-minor-routing.sh --live
|
||||
# MINORS="4.4 4.5" SAMPLE=10 scripts/verify-r-minor-routing.sh
|
||||
#
|
||||
# Exits non-zero if any check fails.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
BASE=${BASE:-https://cran.rpkgs.com}
|
||||
ARCHES=${ARCHES:-"amd64 arm64"}
|
||||
DISTROS=${DISTROS:-"resolute noble jammy rhel8 rhel9 rhel10 alpine323 alpine324"}
|
||||
# Minors a client may plausibly report. 4.3 is listed because the published
|
||||
# support notes still claim it.
|
||||
MINORS=${MINORS:-"4.3 4.4 4.5 4.6"}
|
||||
# How many Path: targets to HEAD-check per slot/minor. 0 disables.
|
||||
SAMPLE=${SAMPLE:-5}
|
||||
# Largest package-count shortfall a non-primary minor may have against the best
|
||||
# minor on the same slot before coverage counts as uneven. A slot built under
|
||||
# one R minor carries fewer per-minor binaries for the others; until that gap
|
||||
# closes, "full coverage for ABI-sensitive packages" is not a claim we can make.
|
||||
PARITY_TOLERANCE=${PARITY_TOLERANCE:-25}
|
||||
LIVE=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--live) LIVE=1 ;;
|
||||
-h | --help)
|
||||
sed -n '2,32p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown argument: $arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAILURES=""
|
||||
|
||||
ok() {
|
||||
PASS=$((PASS + 1))
|
||||
printf ' ok %s\n' "$1"
|
||||
}
|
||||
|
||||
bad() {
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILURES="${FAILURES}\n - $1"
|
||||
printf ' FAIL %s\n' "$1"
|
||||
}
|
||||
|
||||
# Fetch a URL into a file, echoing the HTTP status. Cached per URL.
|
||||
fetch() {
|
||||
local url=$1 dest=$2 ua=${3:-}
|
||||
if [ -s "$dest" ]; then
|
||||
cat "$dest.status"
|
||||
return 0
|
||||
fi
|
||||
local status
|
||||
if [ -n "$ua" ]; then
|
||||
status=$(curl -sS -A "$ua" -o "$dest" -w '%{http_code}' --max-time 120 "$url" 2>/dev/null)
|
||||
else
|
||||
status=$(curl -sS -o "$dest" -w '%{http_code}' --max-time 120 "$url" 2>/dev/null)
|
||||
fi
|
||||
echo "$status" > "$dest.status"
|
||||
echo "$status"
|
||||
}
|
||||
|
||||
head_status() {
|
||||
curl -sS -o /dev/null -w '%{http_code}' -I --max-time 60 "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# Package names from a gzipped PACKAGES index, sorted.
|
||||
pkg_names() {
|
||||
gunzip -c "$1" 2>/dev/null | awk '/^Package:/ {print $2}' | sort -u
|
||||
}
|
||||
|
||||
# "<Package> <Path>" pairs for entries that carry a Path: field.
|
||||
path_entries() {
|
||||
gunzip -c "$1" 2>/dev/null | awk '
|
||||
/^Package:/ { pkg = $2; ver = ""; path = "" }
|
||||
/^Version:/ { ver = $2 }
|
||||
/^Path:/ { path = $2 }
|
||||
/^$/ { if (pkg != "" && path != "") print pkg, ver, path; pkg = "" }
|
||||
END { if (pkg != "" && path != "") print pkg, ver, path }
|
||||
'
|
||||
}
|
||||
|
||||
# An R User-Agent of the shape R actually sends.
|
||||
r_user_agent() {
|
||||
printf 'R/%s.0 (Ubuntu 24.04; codename=noble) (x86_64-pc-linux-gnu x86_64 linux-gnu)' "$1"
|
||||
}
|
||||
|
||||
echo "verify-r-minor-routing: $BASE"
|
||||
echo " slots: $(echo "$ARCHES" | wc -w) arch x $(echo "$DISTROS" | wc -w) os"
|
||||
echo " minors: $MINORS"
|
||||
echo " live: $LIVE"
|
||||
echo
|
||||
|
||||
for arch in $ARCHES; do
|
||||
for distro in $DISTROS; do
|
||||
slot="$arch/$distro"
|
||||
echo "$slot"
|
||||
|
||||
flat_url="$BASE/$slot/latest/src/contrib/PACKAGES.gz"
|
||||
flat_file="$WORK/${arch}-${distro}-flat.gz"
|
||||
flat_status=$(fetch "$flat_url" "$flat_file")
|
||||
|
||||
if [ "$flat_status" != "200" ]; then
|
||||
bad "$slot flat index unreachable (HTTP $flat_status)"
|
||||
continue
|
||||
fi
|
||||
|
||||
pkg_names "$flat_file" > "$flat_file.names"
|
||||
flat_count=$(wc -l < "$flat_file.names")
|
||||
if [ "$flat_count" -lt 1000 ]; then
|
||||
bad "$slot flat index has only $flat_count packages"
|
||||
continue
|
||||
fi
|
||||
ok "$slot flat index: $flat_count packages"
|
||||
|
||||
for minor in $MINORS; do
|
||||
minor_url="$BASE/$slot/latest/src/contrib/$minor/PACKAGES.gz"
|
||||
minor_file="$WORK/${arch}-${distro}-${minor}.gz"
|
||||
minor_status=$(fetch "$minor_url" "$minor_file")
|
||||
|
||||
# A minor the router would route to must exist, or clients on that R
|
||||
# version get a 404 and see no packages at all.
|
||||
if [ "$minor_status" != "200" ]; then
|
||||
bad "$slot R $minor index missing (HTTP $minor_status) - routing would 404 for R $minor clients"
|
||||
continue
|
||||
fi
|
||||
|
||||
pkg_names "$minor_file" > "$minor_file.names"
|
||||
minor_count=$(wc -l < "$minor_file.names")
|
||||
|
||||
# Union property: nothing the flat index carries may be missing here.
|
||||
missing=$(comm -23 "$flat_file.names" "$minor_file.names" | head -5)
|
||||
missing_count=$(comm -23 "$flat_file.names" "$minor_file.names" | wc -l)
|
||||
if [ "$missing_count" -ne 0 ]; then
|
||||
bad "$slot R $minor index is not a union: $missing_count flat packages absent (e.g. $(echo "$missing" | tr '\n' ' '))"
|
||||
else
|
||||
ok "$slot R $minor index: $minor_count packages, union holds"
|
||||
fi
|
||||
|
||||
# Path: entries steer to per-minor binaries; they must resolve.
|
||||
if [ "$SAMPLE" -gt 0 ]; then
|
||||
path_entries "$minor_file" > "$minor_file.paths"
|
||||
total_paths=$(wc -l < "$minor_file.paths")
|
||||
broken=0
|
||||
checked=0
|
||||
while read -r pkg ver path; do
|
||||
[ -z "${pkg:-}" ] && continue
|
||||
tarball="$BASE/$slot/latest/src/contrib/$path/${pkg}_${ver}.tar.gz"
|
||||
status=$(head_status "$tarball")
|
||||
checked=$((checked + 1))
|
||||
if [ "$status" != "200" ]; then
|
||||
broken=$((broken + 1))
|
||||
[ "$broken" -le 2 ] && printf ' broken target: %s (HTTP %s)\n' "$tarball" "$status"
|
||||
fi
|
||||
done < <(shuf -n "$SAMPLE" "$minor_file.paths" 2>/dev/null || head -n "$SAMPLE" "$minor_file.paths")
|
||||
|
||||
if [ "$broken" -ne 0 ]; then
|
||||
bad "$slot R $minor: $broken/$checked sampled Path: targets do not resolve (of $total_paths total)"
|
||||
elif [ "$checked" -gt 0 ]; then
|
||||
ok "$slot R $minor: $checked/$checked sampled Path: targets resolve (of $total_paths total)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Live routing: what a real R client on this minor actually receives.
|
||||
if [ "$LIVE" -eq 1 ]; then
|
||||
ua=$(r_user_agent "$minor")
|
||||
live_file="$WORK/${arch}-${distro}-${minor}-live.gz"
|
||||
live_status=$(fetch "$flat_url" "$live_file" "$ua")
|
||||
if [ "$live_status" != "200" ]; then
|
||||
bad "$slot R $minor live request failed (HTTP $live_status)"
|
||||
elif cmp -s "$live_file" "$minor_file"; then
|
||||
ok "$slot R $minor live request served the per-minor index"
|
||||
elif cmp -s "$live_file" "$flat_file"; then
|
||||
bad "$slot R $minor live request served the FLAT index - slot not in UNION_SLOTS?"
|
||||
else
|
||||
bad "$slot R $minor live request served neither the per-minor nor the flat index"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Coverage parity across minors. The union property only guarantees no
|
||||
# client loses packages relative to the flat index; it says nothing about a
|
||||
# 4.4 client seeing fewer packages than a 4.5 client on the same slot.
|
||||
best=0
|
||||
for minor in $MINORS; do
|
||||
f="$WORK/${arch}-${distro}-${minor}.gz.names"
|
||||
[ -s "$f" ] || continue
|
||||
c=$(wc -l < "$f")
|
||||
[ "$c" -gt "$best" ] && best=$c
|
||||
done
|
||||
if [ "$best" -gt 0 ]; then
|
||||
uneven=""
|
||||
for minor in $MINORS; do
|
||||
f="$WORK/${arch}-${distro}-${minor}.gz.names"
|
||||
[ -s "$f" ] || continue
|
||||
c=$(wc -l < "$f")
|
||||
gap=$((best - c))
|
||||
[ "$gap" -gt "$PARITY_TOLERANCE" ] && uneven="$uneven R$minor:-$gap"
|
||||
done
|
||||
if [ -n "$uneven" ]; then
|
||||
bad "$slot coverage uneven across minors (vs best $best):$uneven"
|
||||
else
|
||||
ok "$slot coverage parity across minors (best $best, all within $PARITY_TOLERANCE)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# A client whose User-Agent carries no R version must keep getting the flat
|
||||
# index, never a per-minor one.
|
||||
if [ "$LIVE" -eq 1 ]; then
|
||||
plain_file="$WORK/${arch}-${distro}-plain.gz"
|
||||
plain_status=$(fetch "$flat_url" "$plain_file" "curl/8.0.0")
|
||||
if [ "$plain_status" != "200" ]; then
|
||||
bad "$slot non-R User-Agent request failed (HTTP $plain_status)"
|
||||
elif cmp -s "$plain_file" "$flat_file"; then
|
||||
ok "$slot non-R User-Agent still served the flat index"
|
||||
else
|
||||
bad "$slot non-R User-Agent was routed away from the flat index"
|
||||
fi
|
||||
|
||||
# Tarball requests must never be rewritten into a per-minor directory:
|
||||
# flat-slot packages do not live there.
|
||||
sample_pkg=$(gunzip -c "$flat_file" | awk '/^Package:/ {p=$2} /^Version:/ {print p, $2; exit}')
|
||||
if [ -n "$sample_pkg" ]; then
|
||||
# shellcheck disable=SC2086 # deliberate split into $1 (package) and $2 (version)
|
||||
set -- $sample_pkg
|
||||
tb="$BASE/$slot/latest/src/contrib/${1}_${2}.tar.gz"
|
||||
tb_status=$(curl -sS -o /dev/null -w '%{http_code}' -A "$(r_user_agent 4.5)" --max-time 60 "$tb" 2>/dev/null)
|
||||
if [ "$tb_status" = "200" ]; then
|
||||
ok "$slot tarball request under an R User-Agent still resolves"
|
||||
else
|
||||
bad "$slot tarball ${1}_${2}.tar.gz broke under an R User-Agent (HTTP $tb_status)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo
|
||||
echo "passed: $PASS failed: $FAIL"
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
printf 'failures:%b\n' "$FAILURES"
|
||||
exit 1
|
||||
fi
|
||||
Loading…
Reference in a new issue