Newer
Older
openstack-caracal-ipv4 / scripts / checks / d011-02-vip-jumphost.sh
#!/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"