Newer
Older
openstack-caracal-dc-dc / scripts / dc-node-v6-verify.sh
#!/usr/bin/env bash
# scripts/dc-node-v6-verify.sh <plan|node|bridges> <site> [spec] -- gate G19:
# do the DC nodes actually BRING UP their IPv6 statics, and does a plane carry
# IPv6 between two nodes? site = vr1-dc0 | vr1-dc1. Governing: D-139 (GUA carve),
# D-101 (v6 unless v4 is necessary), D-134 (node octets).
#
#   plan <site> [--profile P]  on a host holding the maas CLI. DERIVES the
#     expectation from live MAAS and prints one SPEC per machine. NO prefix
#     literal exists in this file: D-139 retires the VR1 ULA /48 for GUA, so a
#     baked table would be right today and wrong on carve day.
#   node <site> <spec>         ON the node: bash -s -- node <site> <spec>.
#     Read-only, no root; touches only ip and ping.
#   bridges <site>             on the RACK. DIAGNOSTIC, never a verdict: prints
#     each plane bridge's multicast_snooping/multicast_querier so a red `node`
#     can be triaged in one step. Exits 0 (reported) or 3 (could not read).
#
# WHAT A GREEN DOES NOT MEAN: this asserts the NIC against MAAS, not against
# D-139's ruled GUA table. MAAS-vs-ruling is dc-node-v6-carve.py check and
# tests/d139-gua-carve. `plan` prints the derived prefixes, so a reader can see
# which carve is being asserted.
#
# SPEC := entry[,entry...];  entry := iface=subnet_cidr=addr=peer=peerhost
#   peer '-' means no second machine on that plane -- that plane REFUSES.
#
# EXIT: 0 PASS | 1 FAIL | 2 bad args | 3 REFUSE (could not evaluate -- NEVER a
# pass). ASCII + LF.
set -uo pipefail

MODE="${1:-}"; SITE="${2:-}"
case "$MODE" in plan|node|bridges) ;; *)
  echo "usage: dc-node-v6-verify.sh <plan|node|bridges> <site> [spec]" >&2; exit 2 ;; esac
case "$SITE" in vr1-dc0|vr1-dc1) ;; *)
  echo "FAIL: unknown site '$SITE' (expected vr1-dc0 | vr1-dc1)" >&2; exit 2 ;; esac

# TEST SEAM. EMPTY in every production invocation, so the sysfs paths below are
# byte-identical to the absolute ones. Precedent: DC_SNAP_PROXY_SYSROOT.
SYSROOT="${DC_NODE_V6_SYSROOT:-}"

FAILED=0; REFUSED=0
ok(){      echo "  OK      $1"; }
no(){      echo "  FAIL    $1"; FAILED=1; }
refuse(){  echo "  REFUSE  $1"; REFUSED=1; }
verdict(){ # verdict <label>
  if [ "$FAILED" -ne 0 ]; then echo "dc-node-v6-verify $1 ($SITE): FAIL"; exit 1; fi
  if [ "$REFUSED" -ne 0 ]; then
    echo "dc-node-v6-verify $1 ($SITE): REFUSE -- could not evaluate; this is NOT a pass"; exit 3
  fi
  echo "dc-node-v6-verify $1 ($SITE): PASS"; exit 0
}

# ---------------------------------------------------------------------------
# plan -- MAAS is the AUTHORITY FOR THE EXPECTATION, never for the verdict.
# ---------------------------------------------------------------------------
do_plan() {
  local profile="${MAAS_PROFILE:-admin}" src="built-in default" a
  [ -n "${MAAS_PROFILE:-}" ] && src="MAAS_PROFILE env"
  shift 2 || true
  while [ $# -gt 0 ]; do
    case "$1" in
      --profile) profile="${2:-}"; src="--profile"; shift 2 || exit 2 ;;
      *) echo "FAIL: unknown plan option '$1'" >&2; exit 2 ;;
    esac
  done
  [ -n "$profile" ] || { echo "FAIL: --profile needs a value" >&2; exit 2; }
  echo "dc-node-v6-verify plan ($SITE) -- maas profile '$profile' (from $src)"
  command -v maas >/dev/null 2>&1 || {
    refuse "no 'maas' CLI on this host -- a MISSING TOOL is not an empty MAAS; run from the D-128 Plane-2 host"
    verdict plan; }
  command -v python3 >/dev/null 2>&1 || { refuse "no python3 -- cannot parse the MAAS answer"; verdict plan; }
  local js
  js="$(maas "$profile" machines read 2>&1)" || {
    refuse "'maas $profile machines read' failed -- ${js:0:200}"; verdict plan; }
  printf '%s' "$js" | python3 -c "$PLAN_PY" "openstack-$SITE" "$SITE"
  exit $?
}

# Emits the OK/FAIL/REFUSE lines, the SPEC block and the final verdict itself,
# so plan has exactly one exit contract. Grouping and peer choice are why this
# is python and not jq.
PLAN_PY='
import json, sys
tag, site = sys.argv[1], sys.argv[2]
failed = refused = 0
def ok(m): print("  OK      " + m)
def no(m):
    global failed; failed = 1; print("  FAIL    " + m)
def rf(m):
    global refused; refused = 1; print("  REFUSE  " + m)
try:
    machines = json.load(sys.stdin)
except Exception as e:
    rf("unparseable JSON from machines read: %s" % e); machines = None
if machines is not None:
    sel = [m for m in machines if tag in (m.get("tag_names") or [])]
    if not sel:
        rf("no machine carries the tag %s -- refusing a heuristic fallback; the "
           "wrong region or an uncarved DC both look like this" % tag)
    else:
        ok("tag %s selects %d machine(s)" % (tag, len(sel)))
        links = {}
        for m in sel:
            h = m["hostname"]
            for i in m.get("interface_set", []) or []:
                for l in i.get("links", []) or []:
                    ip = l.get("ip_address"); sub = l.get("subnet") or {}
                    cidr = sub.get("cidr") or ""
                    if ip and ":" in str(ip) and ":" in cidr:
                        links.setdefault((h, cidr), []).append((i["name"], str(ip)))
        dup = sorted(k for k, v in links.items() if len(v) > 1)
        if dup:
            for h, c in dup[:6]:
                no("%s holds %d v6 links on plane %s (%s) -- the expected address "
                   "is AMBIGUOUS; resolve in MAAS before gating"
                   % (h, len(links[(h, c)]), c, links[(h, c)]))
        else:
            ok("no machine holds two v6 links on one plane")
        sets = {}
        for m in sel:
            sets[m["hostname"]] = frozenset(c for (h, c) in links if h == m["hostname"])
        empty = sorted(h for h, s in sets.items() if not s)
        for h in empty[:6]:
            no("%s carries NO v6 link in MAAS -- nothing to assert on its NICs" % h)
        nonempty = {h: s for h, s in sets.items() if s}
        if nonempty:
            modal = max(set(nonempty.values()), key=lambda s: list(nonempty.values()).count(s))
            odd = sorted(h for h, s in nonempty.items() if s != modal)
            if odd:
                for h in odd[:6]:
                    no("%s v6 plane set DIFFERS from the other machines: missing %s / extra %s"
                       % (h, sorted(modal - nonempty[h]) or "-", sorted(nonempty[h] - modal) or "-"))
            else:
                ok("v6 plane set is IDENTICAL across %d machine(s): %d plane(s)" % (len(nonempty), len(modal)))
            for c in sorted(modal):
                print("            %s" % c)
            for c in sorted(modal):
                holders = sorted(h for h in nonempty if c in nonempty[h])
                if len(holders) < 2:
                    rf("plane %s has only %d machine(s) with a v6 static -- assertion (2), "
                       "does the plane CARRY v6, cannot be evaluated for it" % (c, len(holders)))
        print("== SPEC (arg 3 to `node`; one line per machine) ==")
        for h in sorted(nonempty):
            ents = []
            for c in sorted(nonempty[h]):
                iface, addr = links[(h, c)][0]
                peers = sorted((links[(g, c)][0][1], g) for g in nonempty if c in nonempty[g] and g != h)
                p, ph = peers[0] if peers else ("-", "-")
                ents.append("%s=%s=%s=%s=%s" % (iface, c, addr, p, ph))
            print("%s  %s" % (h, ",".join(ents)))
print("dc-node-v6-verify plan (%s): %s" % (site, "FAIL" if failed else
      ("REFUSE -- could not evaluate; this is NOT a pass" if refused else "PASS")))
sys.exit(1 if failed else (3 if refused else 0))
'

# ---------------------------------------------------------------------------
# node -- the assertion MAAS cannot make: a held link is not a carried address.
# ---------------------------------------------------------------------------
NEIGH_UP="REACHABLE STALE DELAY PROBE PERMANENT NOARP"
NEIGH_DOWN="FAILED INCOMPLETE"

do_node() {
  local spec="${3:-}" e iface cidr addr peer peerhost plen want link addrs line state prc
  [ -n "$spec" ] || { echo "FAIL: node needs a SPEC (get one from: dc-node-v6-verify.sh plan $SITE)" >&2; exit 2; }
  echo "dc-node-v6-verify node ($SITE) -- on $(uname -n 2>/dev/null || echo '?')"
  command -v ip >/dev/null 2>&1 || { refuse "no 'ip' on this node -- cannot evaluate any address"; verdict node; }
  local have_ping=1; command -v ping >/dev/null 2>&1 || have_ping=0

  local IFS_SAVE="$IFS"; local -a ENTRIES
  IFS=',' read -ra ENTRIES <<<"$spec"; IFS="$IFS_SAVE"
  for e in "${ENTRIES[@]}"; do
    IFS='=' read -r iface cidr addr peer peerhost <<<"$e"
    if [ -z "${peerhost:-}" ] || [ -z "$addr" ] || [ -z "$cidr" ] || [ -z "$iface" ]; then
      echo "FAIL: malformed SPEC entry '$e' (want iface=cidr=addr=peer=peerhost)" >&2; exit 2
    fi
    plen="${cidr##*/}"; want="$addr/$plen"

    # (1a) the link itself. An address on a carrier-less NIC carries nothing.
    link="$(ip -o link show dev "$iface" 2>/dev/null)"
    if [ -z "$link" ]; then
      no "$iface: interface ABSENT on this node, but MAAS holds $want on it"; continue
    elif ! grep -q 'LOWER_UP' <<<"$link"; then
      no "$iface ($cidr): no LOWER_UP -- no carrier, so $want cannot be carried"; continue
    fi

    # (1b) THE address, and ONLY it. An extra global is the stale-prefix
    # signature D-139's ULA->GUA move will produce if the old link is not removed.
    addrs="$(ip -6 -o addr show dev "$iface" scope global 2>/dev/null)"
    line="$(awk -v w="$want" '$4==w' <<<"$addrs")"
    if [ -z "$line" ]; then
      no "$iface ($cidr): MAAS holds $want but the NIC does not carry it -- a held link is not a carried address"
    elif grep -qw 'dadfailed' <<<"$line"; then
      no "$iface: $want is DADFAILED -- duplicate address on $cidr"
    elif grep -qw 'tentative' <<<"$line"; then
      no "$iface: $want is still TENTATIVE -- DAD has not completed, the address is not usable"
    else
      local extra
      extra="$(awk -v w="$want" '$4!=w && $4!=""{print $4}' <<<"$addrs")"
      if [ -n "$extra" ]; then
        no "$iface ($cidr): carries $want AND unexpected global(s) $(tr '\n' ' ' <<<"$extra")-- MAAS holds exactly one. A stale prefix left behind by a re-carve is this shape; so is an HA VIP, which is NOT a defect -- see the D-139 VIP row note in docs/audit/g19-ipv6-plane-gate-build-20260801.txt"
      else
        ok "$iface ($cidr): $want up, global, DAD complete, sole global on the NIC"
      fi
    fi

    # (2) the PLANE, not the node. ping first: it is what populates the cache
    # that the next assertion reads.
    if [ "$peer" = "-" ]; then
      refuse "$iface ($cidr): no second machine holds a v6 static -- the data path cannot be evaluated from here"
      continue
    fi
    if [ "$have_ping" -eq 0 ]; then
      refuse "$iface ($cidr): no 'ping' on this node -- reachability to $peer is UNKNOWN, not absent"; continue
    fi
    ping -6 -c 3 -W 2 -n -I "$iface" "$peer" >/dev/null 2>&1; prc=$?
    state="$(ip -6 neigh show to "$peer" dev "$iface" 2>/dev/null | awk 'NF{print $NF}' | tail -1)"
    [ -n "$state" ] || state="ABSENT"
    if [ "$prc" -gt 1 ]; then
      refuse "$iface ($cidr): ping to $peer ($peerhost) exited $prc -- the probe could not run; reachability UNKNOWN"
    elif grep -qw "$state" <<<"$NEIGH_UP"; then
      if [ "$prc" -eq 0 ]; then ok "$iface ($cidr): $peer ($peerhost) replies, neighbour $state"
      else no "$iface ($cidr): NO reply from $peer ($peerhost) but the neighbour RESOLVED ($state) -- ND worked, so this is NOT the multicast-ND class; look at filtering or the peer's stack"; fi
    elif grep -qw "$state" <<<"$NEIGH_DOWN" || [ "$state" = "ABSENT" ]; then
      if [ "$prc" -eq 0 ]; then no "$iface ($cidr): $peer ($peerhost) replied YET the neighbour is $state -- CONTRADICTION; one of the two readings is lying, do not close a gate on it"
      else no "$iface ($cidr): no reply from $peer ($peerhost) and the neighbour is $state -- Neighbour Discovery did not resolve; run 'dc-node-v6-verify.sh bridges $SITE' on the rack before blaming routing"; fi
    else
      refuse "$iface ($cidr): neighbour state '$state' for $peer is not one this gate recognises -- refusing to interpret it"
    fi
  done
  verdict node
}

# ---------------------------------------------------------------------------
# bridges -- DIAGNOSTIC. Reports; asserts nothing about Linux behaviour. The v6
# planes carry gateway_ip=None (D-139), so peers are on-link and ND -- which is
# multicast -- IS the resolution mechanism. Exit 0 reported | 3 could not read.
# ---------------------------------------------------------------------------
do_bridges() {
  local nets n br s q missing=0 seen=0
  echo "dc-node-v6-verify bridges ($SITE) -- DIAGNOSTIC ONLY, no verdict"
  command -v virsh >/dev/null 2>&1 || {
    echo "  REFUSE  no 'virsh' on this host -- run on the DC rack"; exit 3; }
  nets="$(virsh -c qemu:///system net-list --all --name 2>/dev/null | grep -E "^${SITE}-" )"
  if [ -z "$nets" ]; then
    echo "  REFUSE  no libvirt network named ${SITE}-* -- the plane bridges are UNKNOWN, not absent"; exit 3
  fi
  while read -r n; do
    [ -n "$n" ] || continue
    seen=$((seen+1))
    br="$(virsh -c qemu:///system net-dumpxml "$n" 2>/dev/null | sed -n "s/.*bridge name='\([^']*\)'.*/\1/p")"
    if [ -z "$br" ]; then
      echo "  REFUSE  $n did not resolve to a bridge"; missing=1; continue
    fi
    s="$(cat "${SYSROOT}/sys/class/net/${br}/bridge/multicast_snooping" 2>/dev/null)"
    q="$(cat "${SYSROOT}/sys/class/net/${br}/bridge/multicast_querier" 2>/dev/null)"
    if [ -z "$s" ] || [ -z "$q" ]; then
      echo "  REFUSE  $n ($br): multicast state unreadable under ${SYSROOT}/sys/class/net/${br}/bridge/"; missing=1; continue
    fi
    printf '  READ    %-28s %-10s multicast_snooping=%s multicast_querier=%s%s\n' \
      "$n" "$br" "$s" "$q" "$([ "$s" = 1 ] && [ "$q" = 0 ] && echo '  <-- SUSPECT')"
  done <<<"$nets"
  echo "  NOTE    snooping=1 with querier=0 is REPORTED as suspect, not diagnosed: IPv6"
  echo "          Neighbour Discovery is multicast, and no Linux behaviour is asserted here."
  [ "$missing" -eq 0 ] || { echo "dc-node-v6-verify bridges ($SITE): REFUSE -- $seen network(s) seen, at least one unreadable"; exit 3; }
  echo "dc-node-v6-verify bridges ($SITE): reported $seen bridge(s)"; exit 0
}

case "$MODE" in
  plan)    do_plan "$@" ;;
  node)    do_node "$@" ;;
  bridges) do_bridges ;;
esac