#!/usr/bin/env bash
# scripts/checks/d011-02-vip-jumphost.sh -- D-011 item 2: public API VIPs respond,
# on hostname, from the jumphost.
#
# Enumerates PUBLIC endpoints from the keystone catalog DYNAMICALLY (never a hardcoded
# VIP list), reduces to unique scheme://host:port, and probes each root over TLS from
# the jumphost. "Respond on hostname" (D-019: public endpoints are FQDNs resolving via
# corporate DNS) means the probe tests DNS + TLS (vault CA) + the VIP answering.
#
# Healthy-response policy (documented, debatable): an HTTP status in
# {200,201,300,301,302,401,403,404} = the VIP RESPONDS (version-discovery 300 and
# unauthenticated 401/403 are healthy). 5xx = reachable but erroring -> FAIL (an
# acceptance bar should not pass a 5xx-ing API). No HTTP code (curl transport error:
# DNS/conn/TLS) -> FAIL, with the failure class surfaced (a TLS-verify failure is
# retried with -k ONLY to classify reachable-but-cert-broken vs unreachable; it still
# FAILs -- we never pass an unverifiable endpoint).
#
# Exit: 0 PASS (all public VIPs healthy) | 1 FAIL (any unhealthy/unreachable)
# | 2 HOLD (no admin scope, no endpoints, or curl/tooling missing).
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib-validate.sh
. "$HERE/../lib-validate.sh"
ID=d011-02-vip-jumphost; vr_begin "$ID"
vr_need openstack curl python3 || { emit "$ID" "$VR_HOLD" "missing tool"; exit "$VR_HOLD"; }
vr_admin_env || { emit "$ID" "$VR_HOLD" "no admin scope (source ~/admin-openrc)"; exit "$VR_HOLD"; }
if ! vr_json EPJSON openstack endpoint list --interface public -f json; then
vr_err_tail; emit "$ID" "$VR_HOLD" "endpoint list failed"; exit "$VR_HOLD"
fi
jq -e . >/dev/null 2>&1 <<<"$EPJSON" || { emit "$ID" "$VR_HOLD" "endpoint list not JSON"; exit "$VR_HOLD"; }
# unique scheme://host:port -> space-joined list of service names on it
mapfile -t ORIGINS < <(python3 -c '
import sys, json
from urllib.parse import urlparse
try: rows=json.load(sys.stdin)
except Exception: sys.exit(0)
seen={}
for r in rows:
url=r.get("URL") or r.get("url") or ""
svc=r.get("Service Name") or r.get("Service Type") or r.get("service_name") or "?"
p=urlparse(url)
if not p.scheme or not p.hostname: continue
port=p.port or (443 if p.scheme=="https" else 80)
origin="%s://%s:%d" % (p.scheme, p.hostname, port)
seen.setdefault(origin, set()).add(svc)
for o in sorted(seen): print(o, ",".join(sorted(seen[o])))
' <<<"$EPJSON")
[ "${#ORIGINS[@]}" -gt 0 ] || { emit "$ID" "$VR_HOLD" "no public endpoints found in catalog"; exit "$VR_HOLD"; }
CA="${OS_CACERT:-$HOME/vault-init/vault-ca-root.pem}"
HEALTHY_RE='^(200|201|300|301|302|401|403|404)$'
BAD=0; N=0
for line in "${ORIGINS[@]}"; do
origin="${line%% *}"; svcs="${line#* }"; N=$((N+1))
url="$origin/"
CODE="$(curl -sS -o /dev/null -w '%{http_code}' --cacert "$CA" \
--connect-timeout 6 --max-time 12 "$url" 2>/dev/null || true)"
RC=$?
if [ "$RC" -eq 0 ] && [[ "$CODE" =~ $HEALTHY_RE ]]; then
echo " OK $origin http=$CODE [$svcs]"
elif [ "$RC" -eq 0 ] && [[ "$CODE" =~ ^5 ]]; then
echo " BAD $origin http=$CODE (reachable but 5xx) [$svcs]"; BAD=$((BAD+1))
else
# transport error -- classify; retry -k ONLY to distinguish cert-broken from unreachable
KCODE="$(curl -sS -k -o /dev/null -w '%{http_code}' --connect-timeout 6 --max-time 12 "$url" 2>/dev/null || true)"
if [ -n "$KCODE" ] && [[ "$KCODE" =~ ^[0-9]{3}$ ]] && [ "$KCODE" != 000 ]; then
echo " BAD $origin TLS-verify-FAILED (reachable with -k http=$KCODE; cert not valid for hostname) [$svcs]"
else
echo " BAD $origin UNREACHABLE (curl rc=$RC, http=${CODE:-none}) [$svcs]"
fi
BAD=$((BAD+1))
fi
done
if [ "$BAD" -gt 0 ]; then
emit "$ID" "$VR_FAIL" "$BAD/$N public VIP origins unhealthy"; exit "$VR_FAIL"
fi
emit "$ID" "$VR_PASS" "all $N public VIP origins respond healthy over TLS on hostname"; exit "$VR_PASS"