#!/usr/bin/env bash
# d063-apply.sh -- apply the D-063 (a-amended) ingress rule set to capi-mgmt-sg.
# Usage: d063-apply.sh [--audit|--apply]   (--audit is DEFAULT and read-only)
# Rule set (D-063 amendment, 2026-07-03): 6443 from each magnum unit /32 (derived LIVE via
# `ip route get $FIP` on every unit -- the same mechanism the adopting measurement used),
# 6443 from $GW/32 (operator path, documented-coarse), 6443 from $HAIRPIN (self/hairpin),
# 22 from $GW/32; THEN remove the wide (0.0.0.0/0 and ::/0) 22/6443 ingress rules by the
# rule IDs measured in this run. Adds are verified present BEFORE any remove (no window
# where the conductor poll is cut off). Re-derive per environment -- never copy rules forward.
# ERROR REGIME: set -uo pipefail; mutations via run() capture-then-test; any add failure
# (other than already-exists) ABORTS before removes.
# Exit: 0 ok | 20 precondition | 21 derivation failed | 24 confirm refused | 25 add failed
#       | 26 remove failed | 27 post-add readback missing a rule
set -uo pipefail
MODE="${1:---audit}"
M="${MODEL:-openstack}"; FIP="${FIP:-10.12.7.222}"; GW="${GW:-10.12.4.1}"
HAIRPIN="${HAIRPIN:-10.20.0.0/24}"; SGNAME="${SGNAME:-capi-mgmt-sg}"
PROJ="${PROJ:-capi-mgmt}"; PROJ_DOMAIN="${PROJ_DOMAIN:-capi}"
case "$MODE" in --audit|--apply) : ;; *) echo "usage: d063-apply.sh [--audit|--apply]"; exit 20;; esac
FAILS=0
run(){ local _c="$1"; shift; local out
  if out=$("$@" </dev/null 2>&1); then [ -n "$out" ] && printf '%s\n' "$out" | sed 's/^/    /'
  else printf '%s\n' "$out" | sed 's/^/    /'; echo "    ^ FAILED: $*"; eval "$_c=\$(( $_c + 1 ))"; fi
  return 0; }
is_id(){ [[ "${1:-}" =~ ^[0-9a-f]{32}$ ]]; }
ERRF=$(mktemp); trap 'rm -f "$ERRF"' EXIT
is_v4(){ [[ "${1:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; }
for v in $(env | awk -F= '/^OS_/{print $1}'); do unset "$v"; done
source "$HOME/admin-openrc"

echo "=== resolve SG ($SGNAME in project $PROJ@$PROJ_DOMAIN) ==="
PID=$(openstack project show "$PROJ" --domain "$PROJ_DOMAIN" -f value -c id </dev/null 2>&1) || true
is_id "$PID" || PID=$(openstack project show "$PROJ" -f value -c id </dev/null 2>&1) || true
is_id "$PID" || { echo "PRECOND: project $PROJ not resolved (raw: $PID)"; exit 20; }
SGID=$(openstack security group list --project "$PID" -f value -c ID -c Name </dev/null 2>&1 | awk -v n="$SGNAME" '$2==n{print $1}') || true
case "$SGID" in *[!0-9a-f-]*|'') echo "PRECOND: SG $SGNAME not resolved in project $PID"; exit 20;; esac
echo "  project=$PID sg=$SGID"

echo "=== current ingress rules (tcp 22/6443) ==="
# DOCFIX-085: no --long (deprecated; warns on stderr) and NEVER merge stderr into a
# structured-output capture -- any warning lands at char 0 and kills the parse.
RULES=$(openstack security group rule list "$SGID" -f json </dev/null 2>"$ERRF") || true
WIDE=$(printf '%s\n' "$RULES" | python3 -c "
import sys,json
try:
    for r in json.load(sys.stdin):
        pr=(r.get('Port Range') or '')
        if r.get('Direction')=='ingress' and r.get('IP Protocol')=='tcp' and pr in ('22:22','6443:6443'):
            rip=r.get('IP Range') or r.get('Remote IP Prefix') or ''
            print(r['ID'], pr, rip or '<any>')
except Exception as e: print('PARSE-ERROR', e)")
printf '%s\n' "${WIDE:-<none>}" | sed 's/^/  /'
grep -q 'PARSE-ERROR' <<<"$WIDE" && { echo "PRECOND: rule list unparsed"; echo "  stdout head:"; printf '%s\n' "$RULES" | head -3 | sed 's/^/    /'; echo "  stderr:"; head -3 "$ERRF" | sed 's/^/    /'; exit 20; }
[ -s "$ERRF" ] && { echo "  (stderr from rule list, informational):"; head -2 "$ERRF" | sed 's/^/    /'; }
WIDE_IDS=$(printf '%s\n' "$WIDE" | awk '$3=="0.0.0.0/0" || $3=="::/0" || $3=="<any>" {print $1}')

echo "=== derive magnum unit sources LIVE (ip route get $FIP per unit) ==="
UNITS=$(juju status magnum -m "$M" --format json 2>/dev/null | python3 -c "
import sys,json
try:
    print(' '.join(json.load(sys.stdin)['applications']['magnum']['units'].keys()))
except Exception: print('')")
[ -n "$UNITS" ] || { echo "DERIVE-FAIL: no magnum units found"; exit 21; }
MAG_SRCS=""
for U in $UNITS; do
  L=$(juju ssh -m "$M" "$U" -- "ip -o route get $FIP" </dev/null 2>&1 | tr -d '\r' | head -1) || true
  S=$(grep -oE 'src [0-9.]+' <<<"$L" | awk '{print $2}') || true
  echo "  $U -> ${S:-<unparsed: $L>}"
  is_v4 "$S" || { echo "DERIVE-FAIL: $U source unparsable -- ABORT before any mutation"; exit 21; }
  MAG_SRCS="$MAG_SRCS $S"
done

echo "=== plan ==="
PLAN_ADD=""
for S in $MAG_SRCS; do PLAN_ADD="$PLAN_ADD 6443:$S/32"; done
PLAN_ADD="$PLAN_ADD 6443:$GW/32 6443:$HAIRPIN 22:$GW/32"
for P in $PLAN_ADD; do echo "  ADD ingress tcp ${P%%:*} from ${P#*:}"; done
if [ -n "$WIDE_IDS" ]; then for I in $WIDE_IDS; do echo "  REMOVE wide rule $I"; done
else echo "  REMOVE: no wide 22/6443 rules found (already tight?)"; fi
[ "$MODE" = "--audit" ] && { echo "AUDIT COMPLETE (no changes)."; exit 0; }

[ -r /dev/tty ] || { echo "REFUSED: --apply needs a terminal"; exit 24; }
printf 'Type d063 to confirm SG mutation on %s: ' "$SGNAME" > /dev/tty
IFS= read -r C < /dev/tty || C=""
[ "$C" = "d063" ] || { echo "REFUSED: confirmation mismatch"; exit 24; }

echo "=== phase 1: adds (idempotent; abort on real failure) ==="
for P in $PLAN_ADD; do
  PORT="${P%%:*}"; SRC="${P#*:}"
  OUT=$(openstack security group rule create --ingress --ethertype IPv4 --protocol tcp \
        --dst-port "$PORT" --remote-ip "$SRC" "$SGID" </dev/null 2>&1) || {
    if grep -qiE 'already exists|409' <<<"$OUT"; then echo "  add $PORT<-$SRC: already present (ok)"; continue
    else printf '%s\n' "$OUT" | sed 's/^/    /'; echo "ABORT: add $PORT<-$SRC failed; NO removes performed"; exit 25; fi; }
  echo "  add $PORT<-$SRC: created"
done
echo "=== phase 1 gate: readback (every planned rule must exist before removes) ==="
RB=$(openstack security group rule list "$SGID" -f json </dev/null 2>"$ERRF") || true
MISS=$(printf '%s\n' "$RB" | python3 -c "
import sys,json
want=set()
for p in '''$PLAN_ADD'''.split():
    port,src=p.split(':',1); want.add((port+':'+port,src))
have=set()
try:
    for r in json.load(sys.stdin):
        if r.get('Direction')=='ingress' and r.get('IP Protocol')=='tcp':
            have.add(((r.get('Port Range') or ''), (r.get('IP Range') or r.get('Remote IP Prefix') or '')))
except Exception as e: print('PARSE-ERROR',e); raise SystemExit
for w in sorted(want-have): print(w[0],w[1])")
[ -z "$MISS" ] || { echo "ABORT: readback missing:"; printf '%s\n' "$MISS" | sed 's/^/  /'; exit 27; }
echo "  all planned rules present"
echo "=== phase 2: remove wide rules (by measured ID) ==="
for I in $WIDE_IDS; do echo "  remove $I"; run FAILS openstack security group rule delete "$I"; done
[ "$FAILS" = 0 ] || { echo "DONE WITH $FAILS REMOVE FAILURES -- wide rules may remain; review"; exit 26; }
echo "=== final state ==="
openstack security group rule list "$SGID" -f value -c ID -c "IP Protocol" -c "Port Range" -c "IP Range" </dev/null 2>&1 | sed 's/^/  /'
echo "D-063 APPLY COMPLETE"
exit 0
