diff --git a/docs/v1-redeploy-changelog.md b/docs/v1-redeploy-changelog.md index becccec..be46b22 100644 --- a/docs/v1-redeploy-changelog.md +++ b/docs/v1-redeploy-changelog.md @@ -1535,3 +1535,26 @@ under state filters, cut on mapped-v6 colons, regex vs || line tails -- all caught, none live). Next-free: D-071, DOCFIX-084, BUNDLEFIX-009 (unchanged; D-063 amendment reuses its number). + +### 2026-07-03 (addendum 5) -- DOCFIX-084: scripts/d063-apply.sh (D-063 a-amended applier) + +DOCFIX-084 -- scripts/d063-apply.sh + tests/d063-apply/. + WHAT: repo-deliverable applier for the D-063 (a-amended) rule set on capi-mgmt-sg. + Audit-by-default; --apply gated by typing d063 at /dev/tty. Magnum unit /32s derived LIVE + per unit via `ip route get $FIP` (the same mechanism the adopting measurement used -- + PATTERN-1, enumerates units, Roosevelt-safe); wide 0.0.0.0/0 and ::/0 rules for 22/6443 + removed by rule IDs MEASURED in the same run. Safety ordering: all adds created AND read + back present (exit 27 if any missing) BEFORE any remove -- no window where the conductor + poll is cut off. Duplicate adds (409) are idempotent-present. Derivation failure aborts + with zero mutations (exit 21). set -uo pipefail + run() capture-then-test throughout. + WHY: D-063 amendment mandates per-environment re-derivation, never rule copy-forward; a + harnessed script is the reusable form. ACCEPT: tests/d063-apply 7/7 incl. ordering + assertion (last create strictly before first delete), zero-mutation proofs on + confirm-mismatch and derive-fail, and a readback-gate case. Harness lesson: two fixture + bugs (non-hex mock IDs rejected by the tightened is_id; a 409 mock that lied about rule + existence) were both caught BY THE SCRIPT'S OWN GATES -- the mock world must be truthful. + REVERT: delete the added /32 and hairpin rules by readback and recreate the two wide rules + (openstack security group rule create --ingress --protocol tcp --dst-port {22,6443} + --remote-ip 0.0.0.0/0 ); or git checkout both files at this commit's parent. + +Next-free: D-071, DOCFIX-085, BUNDLEFIX-009. diff --git a/scripts/d063-apply.sh b/scripts/d063-apply.sh new file mode 100644 index 0000000..a6e0824 --- /dev/null +++ b/scripts/d063-apply.sh @@ -0,0 +1,114 @@ +#!/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=$("$@" &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}$ ]]; } +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 &1) || true +is_id "$PID" || PID=$(openstack project show "$PROJ" -f value -c id &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 &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) ===" +RULES=$(openstack security group rule list "$SGID" --long -f json &1) || 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 '') +except Exception as e: print('PARSE-ERROR', e)") +printf '%s\n' "${WIDE:-}" | sed 's/^/ /' +grep -q 'PARSE-ERROR' <<<"$WIDE" && { echo "PRECOND: rule list unparsed"; exit 20; } +WIDE_IDS=$(printf '%s\n' "$WIDE" | awk '$3=="0.0.0.0/0" || $3=="::/0" || $3=="" {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" &1 | tr -d '\r' | head -1) || true + S=$(grep -oE 'src [0-9.]+' <<<"$L" | awk '{print $2}') || true + echo " $U -> ${S:-}" + 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" &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" --long -f json &1) || 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" --long -f value -c ID -c "IP Protocol" -c "Port Range" -c "IP Range" &1 | sed 's/^/ /' +echo "D-063 APPLY COMPLETE" +exit 0 diff --git a/tests/d063-apply/run-tests.sh b/tests/d063-apply/run-tests.sh new file mode 100644 index 0000000..08f4b6e --- /dev/null +++ b/tests/d063-apply/run-tests.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# tests/d063-apply/run-tests.sh -- mock harness for scripts/d063-apply.sh +# Branches: audit=0 (zero mutations) | apply-happy=0 (adds strictly BEFORE removes; 2-unit +# derivation) | duplicate-add=0 | confirm-mismatch=24 | derive-fail=21 (zero mutations) | +# readback-miss=27 (zero removes) | missing-sg=20 +set -u +SD="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; SCRIPT="$SD/../../scripts/d063-apply.sh" +W=$(mktemp -d); trap 'rm -rf "$W"' EXIT +mkdir -p "$W/bin"; touch "$W/admin-openrc" +cat > "$W/ptyrun.py" <<'PTY' +import pty, os, sys +cmd = sys.argv[1:-1]; feed = sys.argv[-1] +pid, fd = pty.fork() +if pid == 0: + os.execvp(cmd[0], cmd) +os.write(fd, (feed + "\n").encode()) +out = b"" +while True: + try: + d = os.read(fd, 4096) + except OSError: + break + if not d: + break + out += d +_, st = os.waitpid(pid, 0) +sys.stdout.write(out.decode(errors="replace")) +sys.exit(os.waitstatus_to_exitcode(st)) +PTY +cat > "$W/bin/juju" <<'JM' +#!/usr/bin/env bash +S="${MOCK_SCEN:-happy}" +case "$*" in + *"status magnum"*) echo '{"applications":{"magnum":{"units":{"magnum/0":{},"magnum/1":{}}}}}';; + *"ssh"*magnum/0*) + if [ "$S" = derivefail ]; then echo "RTNETLINK answers: no route"; else echo "10.12.7.222 dev eth0 src 10.12.4.154 uid 1000 \\ cache"; fi;; + *"ssh"*magnum/1*) echo "10.12.7.222 dev eth0 src 10.12.4.155 uid 1000 \\ cache";; +esac +JM +cat > "$W/bin/openstack" <<'OM' +#!/usr/bin/env bash +S="${MOCK_SCEN:-happy}"; ST="${MOCK_STATE:?}"; mkdir -p "$ST" +case "$*" in + "project show"*) python3 -c 'print("c"*32)';; + "security group list"*) + if [ "$S" = missingsg ]; then :; else printf '%s capi-mgmt-sg\n' "$(python3 -c "import uuid;print(uuid.UUID(bytes=b's'*16))")"; fi;; + "security group rule list"*) + python3 - "$ST" <<'PY' +import json,sys,os,glob +st=sys.argv[1]; scen=os.environ.get("MOCK_SCEN","happy") +rules=[{"ID":"wide22","Direction":"ingress","IP Protocol":"tcp","Port Range":"22:22","IP Range":"0.0.0.0/0"}, + {"ID":"wide6443","Direction":"ingress","IP Protocol":"tcp","Port Range":"6443:6443","IP Range":"0.0.0.0/0"}, + {"ID":"egr","Direction":"egress","IP Protocol":None,"Port Range":"","IP Range":""}] +if scen=="dupadd": # truthful 409 world: the duplicate rule genuinely pre-exists + rules.append({"ID":"pre154","Direction":"ingress","IP Protocol":"tcp","Port Range":"6443:6443","IP Range":"10.12.4.154/32"}) +adds=sorted(glob.glob(st+"/add-*")) +if scen=="readbackmiss" and adds: adds=adds[:-1] +for i,a in enumerate(adds): + port,src=open(a).read().split() + rules.append({"ID":"new%d"%i,"Direction":"ingress","IP Protocol":"tcp","Port Range":port+":"+port,"IP Range":src}) +deleted=set(open(st+"/deleted").read().split()) if os.path.exists(st+"/deleted") else set() +print(json.dumps([r for r in rules if r["ID"] not in deleted])) +PY + ;; + "security group rule create"*) + PORT=""; SRC=""; prev="" + for a in "$@"; do [ "$prev" = "--dst-port" ] && PORT="$a"; [ "$prev" = "--remote-ip" ] && SRC="$a"; prev="$a"; done + if [ "$S" = dupadd ] && [ "$SRC" = "10.12.4.154/32" ] && [ ! -f "$ST/dupfired" ]; then + touch "$ST/dupfired"; echo "SecurityGroupRuleExists: Security group rule already exists. (HTTP 409)"; exit 1; fi + N=$(ls "$ST"/add-* 2>/dev/null | wc -l); printf '%s %s\n' "$PORT" "$SRC" > "$ST/add-$(printf '%03d' "$N")" + echo "MUT create $PORT $SRC" >> "$ST/mutlog";; + "security group rule delete"*) + echo "${@: -1}" >> "$ST/deleted"; echo "MUT delete ${@: -1}" >> "$ST/mutlog";; +esac +OM +chmod +x "$W/bin/"* +P=0; F=0 +chk(){ if [ "$2" = "$3" ]; then echo "PASS: $1 (exit $2)"; P=$((P+1)); else echo "FAIL: $1 (exit $2, want $3)"; F=$((F+1)); fi; } +muts(){ cat "$1/mutlog" 2>/dev/null | wc -l; } +export HOME="$W" PATH="$W/bin:$PATH" +ST="$W/s1"; MOCK_STATE="$ST" bash "$SCRIPT" --audit >/dev/null 2>&1; RC=$? +[ "$RC" = 0 ] && [ "$(muts "$ST")" = 0 ] && { echo "PASS: audit (exit 0, 0 mutations)"; P=$((P+1)); } || { echo "FAIL: audit (exit $RC, muts $(muts "$ST"))"; F=$((F+1)); } +ST="$W/s2"; MOCK_STATE="$ST" python3 "$W/ptyrun.py" bash "$SCRIPT" --apply d063 >"$W/happy.out" 2>&1; RC=$? +LASTC=$(grep -n 'MUT create' "$ST/mutlog" | tail -1 | cut -d: -f1); FIRSTD=$(grep -n 'MUT delete' "$ST/mutlog" | head -1 | cut -d: -f1) +if [ "$RC" = 0 ] && [ -n "$FIRSTD" ] && [ "$LASTC" -lt "$FIRSTD" ] && grep -q '10.12.4.155/32' "$ST/mutlog"; then + echo "PASS: apply-happy (exit 0, adds before removes, both units derived)"; P=$((P+1)) +else echo "FAIL: apply-happy (exit $RC, lastC=$LASTC firstD=$FIRSTD)"; F=$((F+1)); fi +ST="$W/s3"; MOCK_STATE="$ST" MOCK_SCEN=dupadd python3 "$W/ptyrun.py" bash "$SCRIPT" --apply d063 >/dev/null 2>&1; chk duplicate-add $? 0 +ST="$W/s4"; MOCK_STATE="$ST" python3 "$W/ptyrun.py" bash "$SCRIPT" --apply WRONG >/dev/null 2>&1; RC=$? +[ "$RC" = 24 ] && [ "$(muts "$ST")" = 0 ] && { echo "PASS: confirm-mismatch (exit 24, 0 mutations)"; P=$((P+1)); } || { echo "FAIL: confirm-mismatch (exit $RC)"; F=$((F+1)); } +ST="$W/s5"; MOCK_STATE="$ST" MOCK_SCEN=derivefail python3 "$W/ptyrun.py" bash "$SCRIPT" --apply d063 >/dev/null 2>&1; RC=$? +[ "$RC" = 21 ] && [ "$(muts "$ST")" = 0 ] && { echo "PASS: derive-fail (exit 21, 0 mutations)"; P=$((P+1)); } || { echo "FAIL: derive-fail (exit $RC, muts $(muts "$ST"))"; F=$((F+1)); } +ST="$W/s6"; MOCK_STATE="$ST" MOCK_SCEN=readbackmiss python3 "$W/ptyrun.py" bash "$SCRIPT" --apply d063 >/dev/null 2>&1; RC=$? +DELS=$(grep -c 'MUT delete' "$ST/mutlog" 2>/dev/null || true) +[ "$RC" = 27 ] && [ "${DELS:-0}" = 0 ] && { echo "PASS: readback-miss (exit 27, 0 removes)"; P=$((P+1)); } || { echo "FAIL: readback-miss (exit $RC, dels $DELS)"; F=$((F+1)); } +ST="$W/s7"; MOCK_STATE="$ST" MOCK_SCEN=missingsg bash "$SCRIPT" --audit >/dev/null 2>&1; chk missing-sg $? 20 +echo; [ "$F" = 0 ] && { echo "ALL PASS ($P/7)"; exit 0; } || { echo "FAILURES: $F"; exit 1; }