diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7037da8..012d5ab 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2172,6 +2172,20 @@ provision time, not runtime-poked). Full record + fix candidates: `docs/audit/ovn-central-cert-reeval-20260803.md` CORRECTION 2026-08-04 and the VOID banner in `ovn-central-cert-remediation-plan-20260803.md`. + **>>> RESOLVED + WIRED 2026-08-04 -- fix PROVEN live, delivered for the redeploy. <<<** The fix + is NOT a binding change (three binding configs refuted live -- the charm derives the CN from the + metal-internal address regardless) and the app STAYS on metal-internal (D-052-correct for its + OVSDB/certificates data type). PROVEN end-to-end (controlled single-unit test): an `/etc/hosts` + reverse entry for the metal-internal address made `get_hostname` resolve -> `common_name` + published -> vault issued `ovn-central_0.server.cert` -> `/etc/ovn/{cert_host,key_host,ovn-central.crt}` + written; the two control units without it stayed broken. WIRED: `scripts/dc-node-etchosts.sh` + (harness `tests/dc-node-etchosts` 9/9) renders a per-DC `cloudinit-userdata` that adds each node's + metal-internal address -> hostname to `/etc/hosts` at provision (CIDR pinned from lib-net: dc0 + 10.12.12.0/22, dc1 10.12.72.0/22), applied as gated **Step 1.2b** of + `runbooks/phase-01-bundle-deploy.md` (after add-model, before deploy). It is a NEW mechanism + borrowing D-008's SHAPE, not D-008 itself. The DELIVERY is verify-at-provision on the redeploy; + the rendered content + cert-issuance chain are proven. Full record: the reeval doc's RESOLUTION + 2026-08-04 section. **>>> SESSION CLOSE 2026-08-03 (GA-R4 bookend). Stage 5 remains OPEN; this is a session bookend, NOT a stage close. <<<** DEPLOY IS MID-STAGE-5: bundle deployed + mostly converged (9 machines started, mysql ONLINE, vault init+unseal+root-CA, ~25 units active, 0 error), diff --git a/docs/audit/ovn-central-cert-reeval-20260803.md b/docs/audit/ovn-central-cert-reeval-20260803.md index aeedc6f..4ff9ff4 100644 --- a/docs/audit/ovn-central-cert-reeval-20260803.md +++ b/docs/audit/ovn-central-cert-reeval-20260803.md @@ -153,6 +153,41 @@ redeploy (step 3 redeploys onto the SAME MAAS; fresh containers fail identically otherwise), and it must be verified at PROVISION time (what MAAS hands a NEW container), not just runtime-poked. +## >>> RESOLUTION 2026-08-04 (PROVEN live, then wired for redeploy). <<< +**Root cause (final, measured):** charm-ovn-central (rev 311) computes its TLS `common_name` as +`get_hostname(local_address())`, and `local_address()` is the unit's metal-internal address +(10.12.12.x) -- the DELIBERATELY-ISOLATED D-052 plane with no gateway and no reachable DNS +resolver. `get_hostname()` -> None -> empty CN -> vault issues no server cert. rdns_mode=2 and +the MAAS forward+reverse records exist at the region BIND; the reverse is simply UNREACHABLE +from the isolated plane. The charm has NO config override for the CN (measured: config.yaml). + +**Binding approach REFUTED, live (three configs):** `''`->metal-admin, `certificates`->metal-admin, +and `''`->metal-admin+bounce all left the CN empty -- the charm derives the CN from the +metal-internal address regardless of which endpoint is rebound. Also `juju`/MAAS default-route +selection is non-deterministic and not tied to the default binding (Juju charm-guide: a binding +"is not a mechanism that alters or verifies routing"; LP#1781856). So the app STAYS on +metal-internal (D-052-correct for its OVSDB/certificates data type); the fix does NOT move it. + +**Fix PROVEN end-to-end, live 2026-08-04 (controlled single-unit test):** an `/etc/hosts` entry +`10.12.12.122 ` on ovn-central/0 made the charm's own `get_hostname("10.12.12.122")` +resolve, the charm published `common_name`, vault issued `ovn-central_0.server.cert`, and the +charm wrote `/etc/ovn/{cert_host,key_host,ovn-central.crt}` -- while control units /1 and /2 +(no entry) stayed broken. The entry is the sole cause of the fix. OVN imposes NO CN-content +rule on the server/DB cert and vault signs any non-empty CN, so the name only needs to be +non-empty and stable. + +**Wired for the redeploy (verify-at-provision):** `scripts/dc-node-etchosts.sh` renders a +model-wide `cloudinit-userdata` (harness `tests/dc-node-etchosts` 9/9) that, at machine +provision, adds each node's metal-internal address -> hostname to `/etc/hosts`, scoped to THIS +DC's metal-internal CIDR pinned from `lib-net.sh` (dc0 10.12.12.0/22, dc1 10.12.72.0/22). The +rendered runcmd was executed live and correctly added the metal-internal entry, skipped the +metal-admin address, and made `get_hostname` resolve. Applied as gated Step 1.2b in +`runbooks/phase-01-bundle-deploy.md` (after add-model, before deploy). **This is a NEW mechanism +that BORROWS D-008's static-/etc/hosts SHAPE but is NOT D-008** (D-008's /etc/hosts is FQDN->VIP +for os-public-hostname, unset in VR1; no /etc/hosts script existed before this). The DELIVERY +(does juju apply it to fresh containers) is only verifiable at the redeploy; the rendered +content and the cert-issuance chain are proven. + ## Residual UNKNOWNs (before executing) - `rdns_mode`/`managed` for 10.12.12.0/22 in MAAS (needs the MAAS API; not read this session -- secrets rule kept the reviewer off the API key). Decides whether PTRs can simply be added. diff --git a/docs/changelog-20260804-ovn-central-cert-fix.md b/docs/changelog-20260804-ovn-central-cert-fix.md new file mode 100644 index 0000000..3a2db3b --- /dev/null +++ b/docs/changelog-20260804-ovn-central-cert-fix.md @@ -0,0 +1,50 @@ +# Changelog -- 2026-08-04: ovn-central cert root-caused, fix PROVEN, and WIRED for redeploy + +Session scope: finish re-evaluating the ovn-central "awaiting server certificate data" failure +(the prior diagnosis was wrong), prove the real fix on the live dc0 model, and wire it into the +deploy for the clean redeploy. Stage 5 remains OPEN; nothing here opens/closes a stage. Live +mutations were reversible tests, all reverted (model left at its captured before-state). + +## Item 1 -- root cause corrected (measured), binding approach refuted live +The committed rdns_mode/binding framing was wrong. MEASURED: `rdns_mode=2` on all planes and the +PTR exists at the region BIND; the metal-internal plane is ISOLATED (no gateway/route to the +resolver), so `get_hostname(metal-internal-addr)` returns None -> empty cert CN. Three binding +configs tested live (`''`->metal-admin; `certificates`->metal-admin; `''`->metal-admin+bounce) -- +all left the CN empty, because charm-ovn-central derives the CN from its metal-internal address +regardless of which endpoint is rebound. So the app STAYS on metal-internal (D-052-correct for its +OVSDB/certificates data type). Records corrected: CORRECTION/RESOLUTION blocks in +`docs/audit/ovn-central-cert-reeval-20260803.md`, VOID banner in the remediation-plan doc, sweep F1 +correction, CURRENT-STATE. + +## Item 2 -- fix PROVEN end-to-end (controlled live test) +An `/etc/hosts` entry `10.12.12.122 ` on ovn-central/0 made the charm's own +`get_hostname("10.12.12.122")` resolve, the charm published `common_name`, vault issued +`ovn-central_0.server.cert`, and `/etc/ovn/{cert_host,key_host,ovn-central.crt}` were written -- +while control units /1 and /2 (no entry) stayed broken. The entry is the sole cause of the fix. +OVN imposes no CN-content rule (2026-08-04 research) and vault signs any non-empty CN, so the name +only needs to be non-empty and stable. + +## Item 3 -- NEW: scripts/dc-node-etchosts.sh + tests/dc-node-etchosts (harness 9/9) +Renders a per-DC `cloudinit-userdata` (modes render/apply/check) that, at machine provision, adds +each node's metal-internal address -> `.maas ` to `/etc/hosts`, scoped to THIS +DC's metal-internal CIDR **derived from `lib-net.sh`** (dc0 10.12.12.0/22, dc1 10.12.72.0/22 -- +never typed; the harness caught an author-guessed dc1 CIDR). Deterministic + idempotent (no +race-prone DNS guard, per review); a python3 CIDR-membership test handles the /22 exactly. The +rendered runcmd was EXECUTED live and correctly added only the metal-internal entry (skipping the +metal-admin address) and made `get_hostname` resolve. Harness grades: valid cloud-init YAML, +per-DC CIDR, runcmd behaviour (scoping + idempotency + non-empty name), refuse on bad site/mode. +- Changed: NEW `scripts/dc-node-etchosts.sh`, NEW `tests/dc-node-etchosts/run-tests.sh`. +- Verified: harness **9/9 ALL PASS**; render parses as cloud-init YAML; live runcmd behaviour + confirmed on ovn-central/0 then cleaned up. +- **Revert:** delete both files and Step 1.2b (below); no live state depends on it (unapplied). + +## Item 4 -- runbook wiring: Step 1.2b (gated, pre-deploy) +`runbooks/phase-01-bundle-deploy.md` gains **Step 1.2b** between add-model (1.2) and deploy (1.3): +`dc-node-etchosts.sh apply ` + `check ` gate. VR1 multi-space DCs only (the script REFUSES +a site with no metal-internal plane, so VR0 is a no-op). Notes the DELIVERY is verify-at-provision. +- **Revert:** remove the Step 1.2b block. + +## NOTE -- this is a NEW mechanism, not a D-008 extension +It borrows D-008's static-`/etc/hosts` SHAPE, but D-008's `/etc/hosts` is FQDN->VIP for +`os-public-hostname` (unset in VR1) and no `/etc/hosts` script existed before this. A future +session grepping D-008 for this will not find it -- it is `dc-node-etchosts.sh`. diff --git a/runbooks/phase-01-bundle-deploy.md b/runbooks/phase-01-bundle-deploy.md index be6c1f9..73c35ca 100644 --- a/runbooks/phase-01-bundle-deploy.md +++ b/runbooks/phase-01-bundle-deploy.md @@ -175,6 +175,36 @@ D-019 is superseded). Only the two benign R11 warnings (L34 `name`, L55 `variables`). +## Step 1.2b -- metal-internal /etc/hosts cloudinit (VR1 multi-space DCs ONLY; MUST precede Step 1.3) +VR1's D-052 three-plane isolation puts the OVN NB/SB DB + certificates on the **isolated** +metal-internal plane (no gateway, no route to the region resolver). charm-ovn-central derives +its TLS common_name from `get_hostname(its metal-internal address)`; that reverse lookup has +no reachable resolver on the isolated plane, so the CN comes out EMPTY, vault issues no server +cert, and the OVN cluster never forms (6641/6642 never listen). PROVEN live 2026-08-04 (a +controlled single-unit test: an `/etc/hosts` reverse entry -> CN populated -> vault issued the +per-unit server cert -> `/etc/ovn/{cert_host,key_host,ovn-central.crt}` written; the two +control units without it stayed broken). Full record: `docs/audit/ovn-central-cert-reeval-20260803.md`. +This sets a model-wide `cloudinit-userdata` that, at machine provision, gives each node a LOCAL +`/etc/hosts` reverse mapping for its metal-internal address. It BORROWS D-008's shape but is a +NEW mechanism (D-008's /etc/hosts is FQDN->VIP for os-public-hostname, unset here). It MUST be +set AFTER `add-model` (Step 1.2) and BEFORE `juju deploy` (Step 1.3) -- it only affects machines +created afterwards. SKIP for VR0 (single flat `metal`, no isolated plane -- the script REFUSES +when a site has no metal-internal plane). Runs where the juju client is (the DC rack for VR1, D-138). + +**RUN -- (DC rack, D-138)** +```bash +( { + DC="${DC:?export DC=vr1-dc0|vr1-dc1 first}" + JUJU_MODEL="${DC_MODEL:?export DC_MODEL first}" bash scripts/dc-node-etchosts.sh apply "$DC" \ + && JUJU_MODEL="${DC_MODEL}" bash scripts/dc-node-etchosts.sh check "$DC" +} ) +``` +**GATE:** `check` exits 0 -- the model's `cloudinit-userdata` carries this DC's metal-internal +CIDR (from `lib-net.sh`) and the `/etc/hosts` runcmd. VERIFY-AT-PROVISION: after the first +machines come up in Step 1.3/1.4, confirm on one unit that `/etc/hosts` carries its +`10.12..x ` line and `get_hostname()` resolves; this is the only +point the DELIVERY (vs. the rendered content) is provable. + ## Step 1.3 -- Deploy (VIP-guarded) Re-run the VIP guard inline (the dry-run never echoes vip values), then deploy only if the guard exits 0. diff --git a/scripts/dc-node-etchosts.sh b/scripts/dc-node-etchosts.sh new file mode 100644 index 0000000..d327df1 --- /dev/null +++ b/scripts/dc-node-etchosts.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# scripts/dc-node-etchosts.sh -- per-DC cloudinit-userdata that gives every node a +# LOCAL /etc/hosts reverse mapping for its metal-internal address, so a charm that +# derives its TLS common_name from get_hostname(local_address) can resolve it even +# though the metal-internal plane is deliberately ISOLATED (no gateway, no route to +# the region resolver -- D-052). PROVEN CAUSE + FIX (2026-08-04, live): +# * charm-ovn-central (rev 311) computes cert CN = get_hostname(its metal-internal +# address); that address has NO reachable reverse-DNS resolver on the isolated +# plane -> get_hostname() returns None -> empty common_name -> vault issues no +# server cert -> ovn-central blocks "'certificates' awaiting server certificate +# data" and the OVN NB/SB cluster never forms (6641/6642 never listen). +# * MEASURED on the live dc0 model with a controlled test: adding +# ` ` to /etc/hosts on ONE unit made get_hostname +# resolve, the charm published common_name, vault issued the per-unit server +# cert, and /etc/ovn/{cert_host,key_host,ovn-central.crt} were written -- while +# the two control units without the entry stayed broken. The entry is the sole +# cause of the fix. +# +# This is a NEW mechanism. It BORROWS the SHAPE of D-008's static-/etc/hosts +# bootstrap but is NOT that: D-008's /etc/hosts is FQDN->VIP for os-public-hostname +# (unset in VR1); this supplies each node's OWN metal-internal address->name reverse +# mapping. rdns_mode=2 and the MAAS forward record already exist; only the reverse +# is unreachable from the isolated plane, which the local files entry supplies. +# OVN does not check the server-cert CN content and vault signs any non-empty CN +# (2026-08-04 research), so the name value only has to be non-empty and stable. +# +# DELIVERY: juju model-config cloudinit-userdata (model-wide, applied at machine +# provision). It MUST be set BEFORE `juju deploy` (a gated pre-deploy step in +# runbooks/phase-01-bundle-deploy.md / dc-dc-phase4). Scoped to the metal-internal +# CIDR (pinned per DC from lib-net.sh) so metal-admin et al. keep their DNS reverse +# untouched -- blast radius is exactly the plane that lacks reverse DNS. +# +# Usage: dc-node-etchosts.sh +# render print the cloudinit-userdata YAML (pure; no juju) -- what the harness grades +# apply set it on the DC's juju model (juju client; run on the DC rack, D-138) +# check read it back and verify the runcmd + this DC's metal-internal CIDR are present +# env: JUJU_MODEL (default: derived , e.g. vr1-dc0) ; MODEL_ARG override +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODE="${1:-}"; SITE="${2:-}" +case "$MODE" in render|apply|check) ;; *) + echo "usage: dc-node-etchosts.sh e.g. dc-node-etchosts.sh render vr1-dc0" >&2; exit 2 ;; +esac +[ -n "$SITE" ] || { echo "REFUSE: required" >&2; exit 2; } + +# --- derive THIS DC's metal-internal CIDR from lib-net (never typed) --- +# shellcheck source=/dev/null +. "$HERE/lib-net.sh" +lib_net_select_dc "$SITE" || { echo "REFUSE: lib_net_select_dc $SITE failed" >&2; exit 2; } +MI_CIDR="" +for c in "${PLANE_CIDRS[@]}"; do + [ "${PLANE_NAME[$c]:-}" = "metal-internal" ] && { MI_CIDR="$c"; break; } +done +[ -n "$MI_CIDR" ] || { echo "REFUSE: no metal-internal plane in lib-net for $SITE" >&2; exit 2; } + +MODEL="${JUJU_MODEL:-$SITE}" + +# --- render the cloudinit-userdata YAML --- +# Built with printf, one line per statement, the CIDR passed as a %s ARGUMENT (the +# repo's heredoc-whitespace rule). The runcmd is deterministic + idempotent: for each +# of the node's own IPv4 addresses that lies IN this DC's metal-internal CIDR, append +# ` .maas ` to /etc/hosts unless already present. The CIDR +# membership test uses python3 (present on every Ubuntu node image) so a /22 is handled +# exactly, not by fragile string-prefix matching. +render() { + printf '%s\n' '#cloud-config' + printf '%s\n' '# GENERATED by scripts/dc-node-etchosts.sh -- do not hand-edit the deployed value.' + printf '%s\n' '# Gives each node a local /etc/hosts reverse mapping for its metal-internal' + printf '%s\n' '# address so cert-CN-from-get_hostname resolves on the isolated plane (D-052).' + printf '%s\n' 'runcmd:' + printf '%s\n' '- - /bin/bash' + printf '%s\n' ' - -c' + printf '%s\n' " - 'for ip in \$(hostname -I); do if python3 -c \"import ipaddress,sys; sys.exit(0 if ipaddress.ip_address(sys.argv[1]) in ipaddress.ip_network(sys.argv[2]) else 1)\" \"\$ip\" \"${MI_CIDR}\" 2>/dev/null; then grep -qw \"\$ip\" /etc/hosts || echo \"\$ip \$(hostname).maas \$(hostname)\" >> /etc/hosts; fi; done'" +} + +case "$MODE" in + render) + render + ;; + apply) + command -v juju >/dev/null 2>&1 || { echo "REFUSE: no juju client on this host (run apply on the DC rack, D-138)" >&2; exit 2; } + UD="$(render)" + echo "== applying cloudinit-userdata to model '$MODEL' (metal-internal ${MI_CIDR}) ==" + printf '%s\n' "$UD" + juju model-config -m "$MODEL" "cloudinit-userdata=$UD" || { echo "FAIL: juju model-config set failed" >&2; exit 1; } + echo "OK: set. VERIFY at provision time -- this affects machines created AFTER this point only." + ;; + check) + command -v juju >/dev/null 2>&1 || { echo "REFUSE: no juju client on this host" >&2; exit 2; } + GOT="$(juju model-config -m "$MODEL" cloudinit-userdata 2>/dev/null)" + fail=0 + printf '%s' "$GOT" | grep -q "$MI_CIDR" || { echo "FAIL: model cloudinit-userdata missing this DC's metal-internal CIDR $MI_CIDR"; fail=1; } + printf '%s' "$GOT" | grep -q '/etc/hosts' || { echo "FAIL: model cloudinit-userdata has no /etc/hosts runcmd"; fail=1; } + printf '%s' "$GOT" | grep -q 'ip_network' || { echo "FAIL: model cloudinit-userdata missing the CIDR-membership guard"; fail=1; } + [ "$fail" -eq 0 ] && { echo "OK: model '$MODEL' cloudinit-userdata carries the metal-internal /etc/hosts mapping for $MI_CIDR"; exit 0; } || exit 1 + ;; +esac diff --git a/tests/HARNESS-MANIFEST b/tests/HARNESS-MANIFEST index 0559953..c8e6ae7 100644 --- a/tests/HARNESS-MANIFEST +++ b/tests/HARNESS-MANIFEST @@ -27,6 +27,7 @@ dc-egress-check dc-mirror dc-node-carve +dc-node-etchosts dc-node-v6-carve dc-node-v6-verify dc-plane-ipam diff --git a/tests/dc-node-etchosts/run-tests.sh b/tests/dc-node-etchosts/run-tests.sh new file mode 100644 index 0000000..63ca41f --- /dev/null +++ b/tests/dc-node-etchosts/run-tests.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# tests/dc-node-etchosts/run-tests.sh -- offline harness for scripts/dc-node-etchosts.sh +# +# No live juju. `render` is pure (sources lib-net). We grade three things: +# 1. render emits VALID cloud-init YAML with THIS DC's metal-internal CIDR (per-DC, +# derived from lib-net -- dc0 != dc1). A malformed cloudinit-userdata would break +# EVERY machine's provisioning, so YAML validity is a hard gate. +# 2. the embedded runcmd shell logic actually WORKS: it adds an /etc/hosts entry for +# an address IN the metal-internal CIDR, SKIPS one outside it (blast-radius), and +# is IDEMPOTENT. We run the real rendered command against a temp hosts file + a +# stub `hostname`, so behaviour is proven, not just "it parsed". +# 3. every assertion can FAIL (wrong CIDR, unscoped, non-idempotent, refuse). +# ASCII + LF. Exit 0 all pass | 1 any fail. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +SCRIPT="$REPO/scripts/dc-node-etchosts.sh" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +PASS=0; FAIL=0 +ok(){ echo " PASS $1"; PASS=$((PASS+1)); } +no(){ echo " FAIL $1"; FAIL=$((FAIL+1)); } + +# --- extract the runcmd shell script from a render, rewired to a temp hosts file --- +runcmd_of() { # -> prints the /bin/bash -c script with /etc/hosts -> $TMP/hosts + bash "$SCRIPT" render "$1" | python3 -c " +import sys, yaml +d = yaml.safe_load(sys.stdin) +cmd = d['runcmd'][0][2] +print(cmd.replace('/etc/hosts', '$TMP/hosts')) +" +} + +# T1 render dc0 parses as cloud-init YAML with the runcmd in the /bin/bash -c list form +if bash "$SCRIPT" render vr1-dc0 | python3 -c " +import sys, yaml +d = yaml.safe_load(sys.stdin) +rc = d['runcmd'][0] +assert isinstance(rc, list) and rc[0:2]==['/bin/bash','-c'], 'runcmd not /bin/bash -c list form' +assert d.get('runcmd'), 'no runcmd' +" 2>/dev/null; then ok "T1 render vr1-dc0 is valid cloud-init YAML (/bin/bash -c runcmd)"; else no "T1 render vr1-dc0 valid YAML"; fi + +# T2 dc0 carries dc0's metal-internal CIDR +bash "$SCRIPT" render vr1-dc0 | grep -q '10.12.12.0/22' && ok "T2 dc0 render pins metal-internal 10.12.12.0/22" || no "T2 dc0 CIDR" + +# T3 PER-DC: dc1's metal-internal is 10.12.72.0/22 (lib-net; 10.12.68 is dc1 metal-ADMIN), +# NOT dc0's 10.12.12.0/22 -- proves the CIDR is derived from lib-net per site, keyed by +# PLANE_NAME=="metal-internal", not hardcoded or confused with metal-admin. +out1="$(bash "$SCRIPT" render vr1-dc1 2>/dev/null)" +if grep -q '10.12.72.0/22' <<<"$out1" && ! grep -q '10.12.12.0/22' <<<"$out1" && ! grep -q '10.12.68.0/22' <<<"$out1"; then + ok "T3 dc1 render pins metal-internal 10.12.72.0/22 (not dc0's, not dc1 metal-admin)" +else no "T3 dc1 per-DC CIDR (got: $(grep -oE '10\.12\.[0-9]+\.0/22' <<<"$out1" | tr '\n' ' '))"; fi + +# T4 BEHAVIOUR: runcmd adds a metal-internal address, SKIPS a metal-admin address. +mkstubhost() { # + printf '#!/usr/bin/env bash\ncase "$1" in -I) echo "%s";; *) echo "node-x";; esac\n' "$1" > "$TMP/bin/hostname" + chmod +x "$TMP/bin/hostname" +} +mkdir -p "$TMP/bin"; : > "$TMP/hosts" +mkstubhost "10.12.12.50 10.12.8.60" +PATH="$TMP/bin:$PATH" bash -c "$(runcmd_of vr1-dc0)" +if grep -q '^10.12.12.50 ' "$TMP/hosts" && ! grep -q '10.12.8.60' "$TMP/hosts"; then + ok "T4 runcmd adds metal-internal 10.12.12.50, SKIPS metal-admin 10.12.8.60 (blast radius)" +else no "T4 scoping (hosts: $(cat "$TMP/hosts" | tr '\n' '|'))"; fi + +# T5 IDEMPOTENT: running again does not duplicate the entry +PATH="$TMP/bin:$PATH" bash -c "$(runcmd_of vr1-dc0)" +n="$(grep -c '^10.12.12.50 ' "$TMP/hosts")" +[ "$n" = "1" ] && ok "T5 runcmd is idempotent (one entry after two runs)" || no "T5 idempotent (count=$n)" + +# T6 the entry maps the address to a NON-EMPTY name (the CN must be non-empty) +grep -qE '^10.12.12.50 [^ ]+' "$TMP/hosts" && ok "T6 entry carries a non-empty hostname (valid CN source)" || no "T6 name present" + +# T7 FAILING DIRECTION: an address OUTSIDE metal-internal alone yields NO entry. +: > "$TMP/hosts"; mkstubhost "10.12.8.99 10.12.4.99" +PATH="$TMP/bin:$PATH" bash -c "$(runcmd_of vr1-dc0)" +[ ! -s "$TMP/hosts" ] && ok "T7 no metal-internal address -> no entry (the guard can decline)" || no "T7 guard (hosts: $(cat "$TMP/hosts"))" + +# T8 REFUSE on an unknown site +bash "$SCRIPT" render vr1-dc9 >/dev/null 2>&1; rc=$? +[ "$rc" -eq 2 ] && ok "T8 render REFUSES an unknown site (rc=2)" || no "T8 refuse unknown site (rc=$rc)" + +# T9 REFUSE on a bad mode +bash "$SCRIPT" frobnicate vr1-dc0 >/dev/null 2>&1; rc=$? +[ "$rc" -eq 2 ] && ok "T9 REFUSES an unknown mode (rc=2)" || no "T9 refuse mode (rc=$rc)" + +echo +echo "RESULT: PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] && { echo "ALL PASS"; exit 0; } || exit 1