diff --git a/docs/changelog-20260709-netbox-dc-dc-pipeline.md b/docs/changelog-20260709-netbox-dc-dc-pipeline.md new file mode 100644 index 0000000..53a2d68 --- /dev/null +++ b/docs/changelog-20260709-netbox-dc-dc-pipeline.md @@ -0,0 +1,110 @@ +# Changelog 2026-07-09 -- NetBox multi-DC/dual-stack import pipeline (DOCFIX-152) + +No live infrastructure touched -- new script + test harness only. Closes +tooling gap register item #3 in `docs/dc-dc-deployment-workflow.md`. + +## Item + +### 1. DOCFIX-152 -- `netbox/dc-dc-prefixes-import.py` +FILES: `netbox/dc-dc-prefixes-import.py` (new), `netbox/README.md` (new VR1 +section), `tests/dc-dc-prefixes-import/` (new: `run-tests.sh`, +`test_logic.py`, `fake_pynetbox.py`, `stub_syspath/pynetbox.py`). + +WHAT: extends the v1 single-site IPv4-only NetBox import pattern +(`netbox/ipv4-prefixes-import.py`) to VR1's two-DC, dual-stack model per +D-101. Per DC (`--dc dc1`/`--dc dc2`), creates the six-plane IPv4 structure +plus the D-101 family-matrix IPv6 legs (ULA for +metal-admin/metal-internal/data-tenant/storage/replication, GUA for +provider-public). + +**Nothing invented, consistent with this repo's hard rule:** D-101 names +three literals as NetBox-assigned, explicitly "NOT hardcoded in this +decision" -- the org ULA /48, the per-DC GUA carve out of the real ARIN +2602:f3e2::/32 block, and DC2's v4 supernet. All three are REQUIRED +environment variables (`ORG_ULA_48`, `DC_GUA_PREFIX`, and, for dc2 only, +`DC2_V4_SUPERNET`) with NO defaults -- the script validates format and +containment and FAILS LOUD if any is missing, malformed, out of range, or +(for DC2's supernet) overlapping DC1. DC1's six v4 planes ARE hardcoded, +deliberately: D-101 rules DC1 inherits DC0's layout UNCHANGED, so those +CIDRs are the ADOPTED decision text itself, not an inference. + +The exact per-plane subdivision of the ULA /48 and the GUA carve (which /64 +goes to which plane) is this script's OWN PROPOSED scheme, documented in the +module docstring as NOT itself an operator-ratified D-NNN sub-decision -- +flagged for review before trusting real NetBox writes, the same posture +D-102 had before its own ratification pass folded it into D-101. + +FOUND AND FIXED while writing the test harness: the first draft's example +GUA addresses (`2602:f3e2:0:100::/40` style) had host bits set beyond the +/40 boundary (the fourth hextet exceeds bit 40) -- Python's +`ipaddress.ip_network(..., strict=True)` correctly rejected them. Corrected +to `2602:f3e2:1000::/40` / `2602:f3e2:2000::/40` in both the script's own +docstring examples and the test data. + +Also found and fixed a real test-harness bug (not a script bug): the +end-to-end test counted the substring "CREATED" in captured stdout to +verify prefix-creation counts, but the script's own site-creation message +also contains the word "CREATED" (`'... -- CREATED.'` when a new NetBox +site is created), inflating the count by one on first run. Tightened the +assertion to count "CREATED prefix" / "EXISTS prefix" specifically. + +FLAGGED, not fixed (out of scope): `netbox/ipv4-prefixes-import.py`'s +existing IPAM role slugs (`provider`/`metal`/`lbaas-management`/ +`openstack-tenant`) predate the D-052/D-053 six-plane cutover that +`scripts/lib-net.sh` reflects. This new script uses NEW role slugs matching +the CURRENT six-plane names -- a real, pre-existing staleness in the v1 +script, called out in both the new script's docstring and `netbox/README.md` +rather than silently perpetuated or silently fixed mid-step in an unrelated +v1 artifact. + +FLAGGED as a genuine open call, not silently decided: DC1's import may +create prefixes numerically identical to the existing `vr0-dc0` NetBox +site's entries. NetBox permits duplicate global prefixes (soft warning, +not a hard block) but whether to retire the `vr0-dc0` entries once DC1 +supersedes that rehearsal environment, or keep both for historical record, +is an operator IPAM-hygiene decision -- the script prints a note and does +not decide it. + +VERIFIED, not just asserted: +- New harness `tests/dc-dc-prefixes-import/`: 37/37 PASS. Pure-logic tests + for every validator (`validate_ula_48`, `validate_gua_prefix`, + `validate_dc2_supernet`) and carve function (`carve_dc2_v4`, `carve_ula`, + `carve_gua`), plus a full fake-NetBox end-to-end run of `main()` for both + DCs covering: happy path, idempotency (re-run creates nothing, reports + all EXISTS), DC2's supernet is genuinely required (fails loud when unset), + and argparse's `--dc` requirement/choice validation. The fake NetBox + client (`fake_pynetbox.py`) implements only the specific pynetbox calls + this script makes -- not a general mock. +- `bash scripts/repo-lint.sh`: 0 fail, 1 documented legacy WARN. Caught and + fixed a real CRLF regression during authoring: a Python-based find/replace + used during test-data cleanup wrote the file back in Windows text mode + (`\r\n`), which repo-lint's L1 check correctly flagged; fixed by + rewriting the file in binary mode with LF-only line endings. +- `bash scripts/run-tests-all.sh`: full gauntlet 23/45 FAILED -- the SAME 23 + pre-existing environment-gap failures already documented in + `docs/changelog-20260709-dc-selector-convention.md` (jq missing on this + workstation, or live juju/cloud-dependent suites); the new + `dc-dc-prefixes-import` suite itself is clean (37/37), confirming zero + regressions. + +UNVALIDATED regardless of the above: no live NetBox / `pynetbox`-reachable +instance exists this session (operator's workstation, not the +vopenstack-jesse jumphost) to run this end-to-end against a real server. +The fake-NetBox tests confirm the script's OWN logic is internally +consistent; they cannot confirm pynetbox's real API surface matches what +this script assumes (which mirrors the same assumption the existing v1 +scripts already make, uncontested, in this same directory). + +REVERT: `rm -f netbox/dc-dc-prefixes-import.py && rm -rf +tests/dc-dc-prefixes-import && git checkout HEAD~ -- netbox/README.md` +(safe -- no other script calls this one yet). + +## Next actionable step + +Tooling gap register #3 CLOSED (mechanism). Before a real run: the operator +must generate the org ULA /48 (RFC 4193 random allocation) and obtain the +per-DC GUA carve from whoever manages 2602:f3e2::/32 region-0 /36 -- neither +exists yet (this was already true; this script doesn't create that gap, it +surfaces it explicitly via fail-loud env-var requirements instead of a +silent default). Stage 2/3 runbooks (not yet authored) are the first real +consumers of this pipeline's output. diff --git a/docs/dc-dc-deployment-workflow.md b/docs/dc-dc-deployment-workflow.md index 45db3e8..f3b5932 100644 --- a/docs/dc-dc-deployment-workflow.md +++ b/docs/dc-dc-deployment-workflow.md @@ -225,12 +225,20 @@ **UNVALIDATED** -- no `tofu` binary was available to run `scripts/opentofu-validate.sh` this session; run it on a connected machine before trusting any of this. -3. **NetBox multi-DC/dual-stack import pipeline.** `netbox/` has exactly two - scripts (`ipv4-prefixes-import.py`, `ipv6-mark-reserved.py`) -- single-DC v4 - import and v6-reservation-marking only. D-101 requires DC2's supernet, the - org ULA /48, and per-DC GUA carves to be NetBox-authoritative via "the - extended import pipeline" -- that pipeline is not written. Blocks Stage 0's - exit condition (literals must be real, not placeholders) and Stage 2. +3. **NetBox multi-DC/dual-stack import pipeline -- MECHANISM CLOSED + 2026-07-09 (DOCFIX-152), literals still pending.** `netbox/dc-dc-prefixes- + import.py` extends the v1 pattern to VR1's two-DC dual-stack model: + per-DC (`--dc dc1`/`dc2`) creates the six-plane IPv4 (DC1 inherited, + hardcoded per D-101; DC2 via required `DC2_V4_SUPERNET`, no default) plus + the D-101 family-matrix IPv6 legs (ULA/GUA), carved from two REQUIRED + env vars (`ORG_ULA_48`, `DC_GUA_PREFIX`) with no defaults -- fails loud + rather than inventing either. 37/37 tests (`tests/dc-dc-prefixes- + import/`, fake-NetBox end-to-end). UNVALIDATED against a real NetBox (no + instance reachable this session). The literals themselves (org ULA /48, + per-DC GUA carve, DC2 supernet) still don't exist -- that operator/NetBox + assignment step is unchanged by this delivery; this closes the TOOLING + gap, not the DATA gap. Still blocks Stage 2/3 until those literals are + assigned and this script is actually run against a live NetBox. 4. **OPNsense edge deployment/config, and netem WAN-simulation -- (a)-(c) DONE 2026-07-09, (d) is the only real gap left.** Each site (Office1, DC1, DC2) gets its own independent OPNsense edge with a simulated ISP uplink (D-100); @@ -326,4 +334,5 @@ | `opentofu/` (DC2 planes) | NOT DONE -- deliberately deferred pending NetBox CIDR assignment, see `opentofu/README.md` | | Reusable as-is, repo-agnostic (2026-07-09 sweep) | `bundle.yaml`, `phase-01..08` VR0 DC0 runbooks (Stage 5/7 template, once DC-parameterized -- gap #1), `preflight.sh`, `cloud-assert.sh`, `repo-lint.sh`, `run-logged.sh`, `ledger-scan.sh` | | `lib-net.sh` / `lib-hosts.sh` | `$DC` selector mechanism ADDED 2026-07-09 (DOCFIX-151, gap #1 CLOSED) -- `lib_net_select_dc()`/`lib_hosts_select_dc()`, backward compatible, 21/21 tests; Stage 5 runbook still needs to call it | +| NetBox DC-DC pipeline (`netbox/dc-dc-prefixes-import.py`) | MECHANISM BUILT 2026-07-09 (DOCFIX-152, gap #3 tooling half CLOSED) -- 37/37 fake-NetBox tests, UNVALIDATED live; real literals (ULA /48, GUA carve, DC2 supernet) still unassigned | | Environment/skill docs (`environment.md`, README.md) | Still describe VR0 DC0/v1 single-DC scope wholesale -- flagged, not yet rewritten for VR1; not a blocker for Stage 0 but will need doing before/during Stage 1 | diff --git a/docs/session-ledger.md b/docs/session-ledger.md index 1b02c63..2d0b6ff 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -21,8 +21,8 @@ ## Machine-derived (re-seed from `scripts/ledger-scan.sh`; do not hand-edit) -_As of 2026-07-09 scan (post-DOCFIX-151, $DC selector convention closed); -re-run the scan to refresh:_ +_As of 2026-07-09 scan (post-DOCFIX-152, NetBox DC-DC pipeline mechanism +closed); re-run the scan to refresh:_ - **PROPOSED / OPEN decisions:** D-068 (Vault substrate hardening, Roosevelt -- 1.16 ruled out per amendment; off-EOL path OPEN), D-071 (routine update cadence + @@ -37,8 +37,8 @@ - **OPEN security rows:** SEC-001 (rotate at v1 close, re-ruled 2026-07-06), SEC-003 (custodians + second-person unseal -- DEFERRED; keeps d011-06 MANUAL), SEC-004 (flip repo to private at v1 close -- deferral reaffirmed). -- **Next-free numbers:** D = 111, DOCFIX = 151, BUNDLEFIX = 012. - (Seeded from the 2026-07-09 scan, post DOCFIX-151.) +- **Next-free numbers:** D = 111, DOCFIX = 153, BUNDLEFIX = 012. + (Seeded from the 2026-07-09 scan, post DOCFIX-152.) --- @@ -951,6 +951,33 @@ runbook (not yet authored) still needs to actually CALL `lib_net_select_dc "$DC"` / `lib_hosts_select_dc "$DC"` -- the mechanism exists, the call sites don't yet. +- **DONE -- Tooling gap register #3 mechanism CLOSED 2026-07-09 (NetBox + DC-DC pipeline, changelog `docs/changelog-20260709-netbox-dc-dc-pipeline.md`, + DOCFIX-152):** `netbox/dc-dc-prefixes-import.py` extends the v1 single-site + IPv4-only NetBox import to VR1's two-DC dual-stack model (D-101). DC1's + v4 planes hardcoded (D-101: inherited unchanged, this is decision text, + not an inference); DC2's v4 supernet, the org ULA /48, and the per-DC GUA + carve are all REQUIRED env vars with no defaults -- fails loud rather than + inventing any of them. The per-plane subdivision scheme for the ULA/GUA + blocks is flagged in the script's own docstring as a proposed, unratified + convention pending operator review. New harness + `tests/dc-dc-prefixes-import/` (a fake in-memory NetBox client): 37/37 + PASS. Caught and fixed two real authoring bugs: (1) example GUA addresses + in the first draft had host bits set beyond the /40 boundary, caught by + Python's own `ipaddress.ip_network(strict=True)`; (2) the test's own + "count CREATED lines" assertion was inflated by the script's unrelated + site-creation message also containing the word CREATED -- tightened to + match prefix-specific lines. Flagged, not fixed (out of scope): the + existing v1 `ipv4-prefixes-import.py`'s role slugs predate the current + six-plane model in `scripts/lib-net.sh`; this new script uses the CURRENT + slugs instead. repo-lint 0 fail/1 documented warn (also caught and fixed + a CRLF regression introduced by a Python find/replace during test-data + cleanup). Full gauntlet 23/45 FAILED -- confirmed identical pre-existing + environment-gap failures (jq missing / no live juju-cloud access on this + workstation), zero regressions. UNVALIDATED against a real NetBox (none + reachable this session). This closes the TOOLING half of gap #3 only -- + the literals themselves (ULA /48, GUA carve, DC2 supernet) still need + real assignment before this script can be run for real. diff --git a/netbox/README.md b/netbox/README.md index 855fd80..0e8d52e 100644 --- a/netbox/README.md +++ b/netbox/README.md @@ -1,9 +1,76 @@ -# NetBox imports -- v1 scope +# NetBox imports -- v1 (IPv4-only) and VR1 DC-DC (multi-DC, dual-stack) scope -This directory contains the NetBox import scripts required for v1 (IPv4-only) -deployment. Each script is idempotent; re-running is safe. +This directory contains the NetBox import scripts for both the original v1 +(IPv4-only, single-site) deployment and the VR1 DC-DC (two-DC, dual-stack) +buildout. Each script is idempotent; re-running is safe. -## Scripts +## VR1 DC-DC scripts (2026-07-09, closes tooling gap register #3) + +### `dc-dc-prefixes-import.py` + +Extends the v1 single-site IPv4-only pattern to VR1's two-DC, dual-stack +model (D-101, `docs/design-decisions.md`). Per DC (`--dc dc1` or `--dc dc2`): +creates the six-plane IPv4 structure plus the D-101 family-matrix IPv6 legs +(ULA for metal-admin/metal-internal/data-tenant/storage/replication, GUA for +provider-public). + +**Nothing is invented.** D-101 names three literals as NetBox-assigned and +explicitly "NOT hardcoded in this decision": the org ULA /48, the per-DC GUA +carve out of the real ARIN 2602:f3e2::/32 block, and DC2's v4 supernet. All +three are REQUIRED environment variables with no defaults -- +`ORG_ULA_48`, `DC_GUA_PREFIX`, and (for `--dc dc2` only) `DC2_V4_SUPERNET`. +The script validates format/containment and FAILS LOUD if any is missing or +malformed; it never silently substitutes DC1's values for DC2 or invents a +/48. DC1's v4 planes ARE hardcoded here, deliberately -- D-101 rules DC1 +inherits DC0's layout unchanged, so those six CIDRs are the ADOPTED decision +text itself, not an inferred value. + +The exact per-plane subdivision of the ULA /48 and the GUA carve (which /64 +within the /56/whatever goes to which plane) is this script's OWN PROPOSED +scheme -- documented in the script's module docstring -- and has NOT itself +been operator-ratified as a D-NNN sub-decision. Review it before trusting +what this script would write to a real NetBox. + +``` +NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN= \ + ORG_ULA_48=fd00:1234:5678::/48 DC_GUA_PREFIX=2602:f3e2:1000::/40 \ + python3 dc-dc-prefixes-import.py --dc dc1 + +NETBOX_URL=... NETBOX_TOKEN=... \ + ORG_ULA_48=fd00:1234:5678::/48 DC_GUA_PREFIX=2602:f3e2:2000::/40 \ + DC2_V4_SUPERNET=10.13.0.0/19 \ + python3 dc-dc-prefixes-import.py --dc dc2 +``` + +**Role slugs used here are NEW** (`provider-public`/`metal-admin`/ +`metal-internal`/`data-tenant`/`storage`/`replication`, matching the CURRENT +six-plane model in `scripts/lib-net.sh`) -- NOT the same slugs as +`ipv4-prefixes-import.py` below, which predates the D-052/D-053 six-plane +cutover. This staleness in the v1 script is flagged, not fixed, here (out of +scope -- that script is a v1/DC0 artifact this delivery doesn't touch). +These roles must exist in NetBox before running (create out of band; this +script does not create IPAM roles, same assumption the v1 scripts make). + +**Site collision note:** DC1 may create prefixes numerically identical to +the existing `vr0-dc0` site's entries (D-101's inherit-unchanged ruling). +NetBox permits duplicate global prefixes (soft warning, not a hard block), +but whether to retire the `vr0-dc0` entries once DC1 supersedes that +rehearsal environment, or keep both for history, is an operator IPAM-hygiene +call this script does not make for you (it prints a note, does not decide). + +**UNVALIDATED**: no live NetBox / `pynetbox`-reachable instance was +available this session (operator's workstation, not the vopenstack-jesse +jumphost) to run this end-to-end against a real server. Tested via +`tests/dc-dc-prefixes-import/` -- an in-memory fake NetBox client exercising +the full `main()` path for both DCs, idempotency, and every fail-loud case +(37/37 PASS) -- but a real NetBox run has not confirmed the actual API +shape/behavior. + +Run order: after `ipv4-prefixes-import.py`/`ipv6-mark-reserved.py` are no +longer relevant to VR1 (they are v1/DC0-scoped), and before Stage 2/3 of +`docs/dc-dc-deployment-workflow.md` (which cite this pipeline's literals). + +## v1 (IPv4-only, single-site) scripts ### `ipv4-prefixes-import.py` diff --git a/netbox/dc-dc-prefixes-import.py b/netbox/dc-dc-prefixes-import.py new file mode 100644 index 0000000..613e052 --- /dev/null +++ b/netbox/dc-dc-prefixes-import.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +""" +NetBox multi-DC / dual-stack prefix import for VR1 DC-DC (Omega Cloud). + +Closes tooling gap register item #3 in docs/dc-dc-deployment-workflow.md: +extends the v1 (netbox/ipv4-prefixes-import.py) single-site IPv4-only import +to the VR1 two-DC, dual-stack model ratified in D-101 (docs/design-decisions.md). + +Per DC (dc1 or dc2, selected via --dc, mirroring the scripts/lib-net.sh +$DC selector convention, DOCFIX-151), creates the six-plane structure with +the D-101 family matrix: + + Plane v4 v6 family + provider-public (see below) GUA (dual-stack) + metal-admin (see below) ULA (dual-stack, 2026-07-09 amendment) + metal-internal (see below) ULA (dual-stack) + data-tenant (see below) ULA-only + storage (see below) ULA-only + replication (see below) ULA-only + +DC1 v4: INHERITS the DC0 six-plane CIDRs unchanged (D-101 -- "DC1 equals the +validated template"), i.e. the SAME literals as netbox/ipv4-prefixes-import.py, +hardcoded here for that reason (this is not an inferred value; it is the +explicit text of an ADOPTED decision). Scoped to a DISTINCT NetBox site +(vr1-dc1), not the existing vr0-dc0 site. + +DC2 v4: D-101 explicitly states DC2's supernet is "NetBox-assigned... not +hardcoded." No default is provided here -- DC2_V4_SUPERNET is a REQUIRED +environment variable with no fallback. Running with --dc dc2 and no +DC2_V4_SUPERNET set FAILS LOUD rather than guessing or reusing DC1's range. + +IPv6 (both DCs): D-101 names two literals as NetBox-assigned and "NOT +hardcoded in this decision": the org ULA /48, and the per-DC GUA carve out +of the already-real ARIN 2602:f3e2::/32 region-0 /36 (the /32 itself IS a +real literal quoted in D-101's own text -- only its per-DC subdivision is +still open). Both are REQUIRED environment variables with no defaults: +ORG_ULA_48 and DC_GUA_PREFIX. + +IMPORTANT -- what this script does NOT do: it does not invent the org ULA +/48 or the GUA carve. The operator must generate/obtain those (an RFC 4193 +random ULA /48, and the actual per-DC GUA assignment from whoever manages +2602:f3e2::/32) and pass them in. This script only validates their FORMAT +and then applies a DETERMINISTIC, DOCUMENTED SUBDIVISION into per-plane +prefixes (see SUBCARVE SCHEME below). That subdivision scheme is this +script's own proposal -- it is NOT itself a ratified D-NNN sub-decision. +Review it before trusting the values this script would write to a real +NetBox. (Precedent: D-102's MTU sub-policy was folded into D-101 only after +explicit operator ratification; this carve scheme has not had that pass.) + +SUBCARVE SCHEME (proposed, unratified -- review before real use): + - ULA: each DC gets a /56 out of the org /48 (dc1 = ::0/56, dc2 = ::1:00/56, + i.e. the 8 bits immediately after the /48 index the DC -- room for up to + 256 DCs, far beyond VR1's two). Within a DC's /56, each of the three + ULA-family planes (data-tenant, storage, replication) plus the two + dual-stack planes' ULA leg (metal-admin, metal-internal) gets a /64, + indexed in the fixed plane order below (provider-public has no ULA leg, + so its index is skipped -- the /64 offsets are NOT contiguous with the + plane list index, they are assigned only to the five ULA-bearing planes). + - GUA: each DC's already-carved GUA prefix (DC_GUA_PREFIX, e.g. a /40 or + /44 -- length is read from what's actually passed in, not assumed) is + subdivided the same way: provider-public gets the whole thing as a + single /64 (VR1 has no other GUA-bearing plane in the matrix above; + tenant-facing GUA delegation is a separate, later, per-tenant carve out + of this same block -- not this script's job, see netbox/README.md). + +Idempotent: re-running is safe (existing prefixes are detected and skipped +unless --update). NetBox version: 4.x (scope_type/scope_id, not legacy site=). + +Usage: + # DC1 (v4 inherited, only v6 top-level blocks required): + NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN= \\ + ORG_ULA_48=fd00:1234:5678::/48 DC_GUA_PREFIX=2602:f3e2:1000::/40 \\ + python3 dc-dc-prefixes-import.py --dc dc1 + + # DC2 (v4 supernet ALSO required, nothing defaulted): + NETBOX_URL=... NETBOX_TOKEN=... \\ + ORG_ULA_48=fd00:1234:5678::/48 DC_GUA_PREFIX=2602:f3e2:2000::/40 \\ + DC2_V4_SUPERNET=10.13.0.0/19 \\ + python3 dc-dc-prefixes-import.py --dc dc2 + + # Preview only, no writes: + ... python3 dc-dc-prefixes-import.py --dc dc1 --verify-only + +KNOWN PRE-EXISTING STALENESS (flagged, not fixed here): netbox/ipv4-prefixes- +import.py's role slugs (provider/metal/lbaas-management/openstack-tenant) +predate the D-052/D-053 six-plane cutover that scripts/lib-net.sh reflects. +This script uses NEW role slugs matching the CURRENT six-plane names +(provider-public/metal-admin/metal-internal/data-tenant/storage/replication) +since VR1 is being built against the current model, not the v1-era one. +These roles must exist in NetBox before running (create out of band, same +assumption netbox/ipv4-prefixes-import.py already makes for its own roles -- +this script does not create IPAM roles, only prefixes/sites use them). + +UNVALIDATED: no NETBOX_URL/live NetBox and no `pynetbox`-reachable instance +were available this session (workstation, not the vopenstack-jesse jumphost) +to run this end-to-end. Reviewed against the pynetbox API shape used +successfully by the existing v1 scripts in this same directory, not executed. +""" + +from __future__ import annotations + +import argparse +import ipaddress +import os +import sys + +try: + import pynetbox +except ImportError: + sys.stderr.write("ERROR: pynetbox not installed. pip install pynetbox\n") + sys.exit(1) + +# ----------------------------------------------------------------------------- +# DC1/DC2 site identity. Slugs are new -- no prior convention existed in this +# repo (grepped opentofu/*.tf and the buildout design; none found) -- chosen +# to match the D-106 Designate naming already ratified (dc1/dc2, vr1 label). +# ----------------------------------------------------------------------------- + +SITES = { + "dc1": {"slug": "vr1-dc1", "name": "VR1 DC1"}, + "dc2": {"slug": "vr1-dc2", "name": "VR1 DC2"}, +} + +# Fixed plane order -- must match scripts/lib-net.sh's PLANE_NAME exactly +# (CIDR-keyed there; name-keyed here, same six planes, same order). +PLANE_ORDER = [ + "provider-public", + "metal-admin", + "metal-internal", + "data-tenant", + "storage", + "replication", +] + +# D-101 family matrix: which v6 family (if any) each plane carries. +# "gua" = dual-stack v4+GUA; "ula" = dual-stack v4+ULA; "ula-only" = no v4 +# plane object created for the v6 leg (data-tenant/storage/replication ARE +# dual-stack-capable per D-102's Phase-A rollback posture text, but the +# ADOPTED default is IPv6-only for those three -- see D-101/D-102 body). +PLANE_V6_FAMILY = { + "provider-public": "gua", + "metal-admin": "ula", + "metal-internal": "ula", + "data-tenant": "ula-only", + "storage": "ula-only", + "replication": "ula-only", +} + +# DC1 v4: the DC0 six-plane CIDRs, UNCHANGED, per D-101 ("DC1 equals the +# validated template"). Identical to netbox/ipv4-prefixes-import.py's +# historical single-plane set is NOT the comparison here -- these are the +# CURRENT six-plane values from scripts/lib-net.sh's PLANE_CIDRS, which is +# this repo's single source of truth for that layout. +DC1_V4_PREFIXES = { + "provider-public": "10.12.4.0/22", + "metal-admin": "10.12.8.0/22", + "metal-internal": "10.12.12.0/22", + "data-tenant": "10.12.16.0/22", + "storage": "10.12.32.0/22", + "replication": "10.12.36.0/22", +} + +# DC2 v4: NO default. Must come from DC2_V4_SUPERNET (env var), carved into +# six sequential /22s in PLANE_ORDER, exactly mirroring DC1's /22-per-plane +# structure. The supernet must be large enough to hold six non-overlapping +# /22s (a /19 exactly fits six /22s with zero slack; anything larger works +# with room to spare, anything smaller FAILS LOUD). +DC2_MIN_SUPERNET_PREFIXLEN = 19 # a /19 = exactly 8x /22, room for the six planes + + +def die(msg: str, code: int = 1) -> None: + sys.stderr.write(f"ERROR: {msg}\n") + sys.exit(code) + + +def get_nb() -> "pynetbox.api": + url = os.environ.get("NETBOX_URL") + token = os.environ.get("NETBOX_TOKEN") + if not url: + die("NETBOX_URL environment variable not set") + if not token: + die("NETBOX_TOKEN environment variable not set") + nb = pynetbox.api(url, token=token) + try: + _ = nb.status() + except Exception as exc: # noqa: BLE001 + die(f"Could not reach NetBox at {url}: {exc}") + return nb + + +def find_or_create_site(nb, slug: str, name: str): + site = nb.dcim.sites.get(slug=slug) + if site is not None: + return site, False + # Minimal site record -- status defaults to NetBox's own default (active). + created = nb.dcim.sites.create(name=name, slug=slug) + return created, True + + +def find_role(nb, slug: str): + role = nb.ipam.roles.get(slug=slug) + if role is None: + die( + f"IPAM role with slug '{slug}' not found in NetBox. Create it first " + f"(out of band) -- this script does not create roles, matching " + f"netbox/ipv4-prefixes-import.py's existing assumption." + ) + return role + + +# ----------------------------------------------------------------------------- +# Required-literal validation. Each of these dies with a specific, actionable +# message rather than silently defaulting -- the whole point of this pipeline +# closing gap #3 is that these values are NOT invented here. +# ----------------------------------------------------------------------------- + + +def require_env(name: str, why: str) -> str: + val = os.environ.get(name) + if not val: + die(f"{name} environment variable is required and not set -- {why}") + return val + + +def validate_ula_48(cidr: str) -> ipaddress.IPv6Network: + try: + net = ipaddress.ip_network(cidr, strict=True) + except ValueError as exc: + die(f"ORG_ULA_48='{cidr}' is not a valid CIDR: {exc}") + if net.version != 6: + die(f"ORG_ULA_48='{cidr}' is not an IPv6 prefix") + if net.prefixlen != 48: + die(f"ORG_ULA_48='{cidr}' must be a /48 (got /{net.prefixlen})") + # RFC 4193 ULA range is fc00::/7; the locally-assigned form sets the L bit + # (fd00::/8). Reject anything outside fc00::/7 -- a GUA or other range + # here would be a real misconfiguration, not a style nit. + if not net.subnet_of(ipaddress.ip_network("fc00::/7")): + die(f"ORG_ULA_48='{cidr}' is not within the ULA range fc00::/7 (RFC 4193)") + return net + + +def validate_gua_prefix(cidr: str) -> ipaddress.IPv6Network: + try: + net = ipaddress.ip_network(cidr, strict=True) + except ValueError as exc: + die(f"DC_GUA_PREFIX='{cidr}' is not a valid CIDR: {exc}") + if net.version != 6: + die(f"DC_GUA_PREFIX='{cidr}' is not an IPv6 prefix") + parent = ipaddress.ip_network("2602:f3e2::/32") + if not net.subnet_of(parent): + die( + f"DC_GUA_PREFIX='{cidr}' is not within the D-101-cited ARIN block " + f"{parent} -- if this is a genuinely different GUA allocation, " + f"that is itself a design-decision-worthy change (grep D-101 " + f"first), not something this script should silently accept." + ) + if net.prefixlen >= 64: + die(f"DC_GUA_PREFIX='{cidr}' must be shorter than /64 (need room to carve a /64 for provider-public)") + return net + + +def validate_dc2_supernet(cidr: str) -> ipaddress.IPv4Network: + try: + net = ipaddress.ip_network(cidr, strict=True) + except ValueError as exc: + die(f"DC2_V4_SUPERNET='{cidr}' is not a valid CIDR: {exc}") + if net.version != 4: + die(f"DC2_V4_SUPERNET='{cidr}' is not an IPv4 prefix") + if net.prefixlen > DC2_MIN_SUPERNET_PREFIXLEN: + die( + f"DC2_V4_SUPERNET='{cidr}' (/{net.prefixlen}) is too small to hold " + f"six /22 planes -- need at least a /{DC2_MIN_SUPERNET_PREFIXLEN}" + ) + dc1_nets = [ipaddress.ip_network(c) for c in DC1_V4_PREFIXES.values()] + for d1 in dc1_nets: + if net.overlaps(d1): + die( + f"DC2_V4_SUPERNET='{cidr}' overlaps DC1's inherited range {d1} -- " + f"D-101 requires DC2 to be non-overlapping. Choose a different supernet." + ) + return net + + +def carve_dc2_v4(supernet: ipaddress.IPv4Network) -> dict: + subnets = list(supernet.subnets(new_prefix=22)) + if len(subnets) < len(PLANE_ORDER): + die( + f"DC2_V4_SUPERNET yields only {len(subnets)} /22 subnet(s), " + f"need {len(PLANE_ORDER)}" + ) + return {plane: str(subnets[i]) for i, plane in enumerate(PLANE_ORDER)} + + +def carve_ula(org_ula_48: ipaddress.IPv6Network, dc: str) -> dict: + dc_index = 0 if dc == "dc1" else 1 + # /48 -> per-DC /56 (8-bit DC index). See SUBCARVE SCHEME in module docstring. + dc_56_candidates = list(org_ula_48.subnets(new_prefix=56)) + dc_56 = dc_56_candidates[dc_index] + ula_planes = [p for p in PLANE_ORDER if PLANE_V6_FAMILY[p] in ("ula", "ula-only")] + dc_64_candidates = list(dc_56.subnets(new_prefix=64)) + return {plane: str(dc_64_candidates[i]) for i, plane in enumerate(ula_planes)} + + +def carve_gua(dc_gua_prefix: ipaddress.IPv6Network) -> dict: + # Only provider-public carries GUA in the current family matrix. + gua_64_candidates = list(dc_gua_prefix.subnets(new_prefix=64)) + return {"provider-public": str(gua_64_candidates[0])} + + +# ----------------------------------------------------------------------------- +# NetBox writes +# ----------------------------------------------------------------------------- + + +def create_or_report_prefix(nb, cidr: str, role_slug: str, site, description: str, update: bool) -> None: + role = find_role(nb, role_slug) + existing = nb.ipam.prefixes.get(prefix=cidr) + payload = { + "prefix": cidr, + "role": role.id, + "description": description, + "scope_type": "dcim.site", + "scope_id": site.id, + } + if existing is None: + created = nb.ipam.prefixes.create(**payload) + print(f" CREATED prefix {cidr} (id={created.id}) role={role_slug}") + elif update: + existing.update(payload) + print(f" UPDATED prefix {cidr} (id={existing.id}) role={role_slug}") + else: + print(f" EXISTS prefix {cidr} (id={existing.id}) -- skipped (use --update to overwrite)") + + +def verify(nb, site, v4: dict, v6_ula: dict, v6_gua: dict) -> None: + print() + print("=" * 72) + print(f"Verification -- final state for site {site.name} (id={site.id})") + print("=" * 72) + print("\nIPv4 (per plane):") + for plane in PLANE_ORDER: + cidr = v4[plane] + p = nb.ipam.prefixes.get(prefix=cidr) + status = "MISSING" if p is None else f"OK (id={p.id})" + print(f" {plane.ljust(16)} {cidr.ljust(18)} {status}") + print("\nIPv6 ULA (per plane, where applicable):") + for plane, cidr in v6_ula.items(): + p = nb.ipam.prefixes.get(prefix=cidr) + status = "MISSING" if p is None else f"OK (id={p.id})" + print(f" {plane.ljust(16)} {cidr.ljust(24)} {status}") + print("\nIPv6 GUA (per plane, where applicable):") + for plane, cidr in v6_gua.items(): + p = nb.ipam.prefixes.get(prefix=cidr) + status = "MISSING" if p is None else f"OK (id={p.id})" + print(f" {plane.ljust(16)} {cidr.ljust(24)} {status}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + parser.add_argument("--dc", required=True, choices=["dc1", "dc2"], help="Which DC to import (no default -- explicit, measured selection per the $DC convention)") + parser.add_argument("--update", action="store_true", help="Update existing prefixes in place") + parser.add_argument("--verify-only", action="store_true", help="Skip writes; only print verification block") + args = parser.parse_args() + + dc = args.dc + site_cfg = SITES[dc] + + org_ula_48 = validate_ula_48(require_env("ORG_ULA_48", "D-101's org ULA /48 -- not defaulted, must be a real RFC 4193 /48 you generated")) + dc_gua_prefix = validate_gua_prefix(require_env("DC_GUA_PREFIX", f"the {dc} carve out of ARIN 2602:f3e2::/32 region-0 /36 cited in D-101 -- not defaulted")) + + if dc == "dc1": + v4 = dict(DC1_V4_PREFIXES) + else: + dc2_supernet = validate_dc2_supernet(require_env("DC2_V4_SUPERNET", "D-101 explicitly requires DC2's v4 supernet be NetBox-assigned, not hardcoded")) + v4 = carve_dc2_v4(dc2_supernet) + + v6_ula = carve_ula(org_ula_48, dc) + v6_gua = carve_gua(dc_gua_prefix) + + nb = get_nb() + site, site_created = find_or_create_site(nb, site_cfg["slug"], site_cfg["name"]) + print(f"Connected. Site '{site_cfg['name']}' (id={site.id}){' -- CREATED' if site_created else ''}.") + + if dc == "dc1": + print( + "\nNOTE: this may create prefixes identical to the existing 'vr0-dc0' " + "site's entries (D-101: DC1 inherits DC0's v4 layout unchanged). " + "NetBox permits duplicate global prefixes (soft-warn, not a hard " + "block) but this is a real IPAM-hygiene decision for the operator: " + "either retire/delete the vr0-dc0 entries once DC1 supersedes that " + "rehearsal environment, or knowingly keep both for historical " + "record. This script does not decide that for you." + ) + + if not args.verify_only: + print("\nIPv4 prefixes:") + for plane in PLANE_ORDER: + create_or_report_prefix( + nb, v4[plane], plane, site, + f"VR1 {dc.upper()} {plane} (v4; D-101)", args.update, + ) + print("\nIPv6 ULA prefixes:") + for plane, cidr in v6_ula.items(): + create_or_report_prefix( + nb, cidr, plane, site, + f"VR1 {dc.upper()} {plane} (ULA leg; D-101, proposed subcarve -- see script docstring)", + args.update, + ) + print("\nIPv6 GUA prefixes:") + for plane, cidr in v6_gua.items(): + create_or_report_prefix( + nb, cidr, plane, site, + f"VR1 {dc.upper()} {plane} (GUA leg; D-101, proposed subcarve -- see script docstring)", + args.update, + ) + + verify(nb, site, v4, v6_ula, v6_gua) + print("\nDone.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/dc-dc-prefixes-import/fake_pynetbox.py b/tests/dc-dc-prefixes-import/fake_pynetbox.py new file mode 100644 index 0000000..e9e8013 --- /dev/null +++ b/tests/dc-dc-prefixes-import/fake_pynetbox.py @@ -0,0 +1,93 @@ +""" +In-memory stand-in for the `pynetbox` package, used only by this test harness. + +No real NetBox / network access exists this session (workstation, not the +jumphost) -- this fake implements just enough of pynetbox's surface (the +subset netbox/dc-dc-prefixes-import.py actually calls: .status(), +dcim.sites.get/create, ipam.roles.get, ipam.prefixes.get/create, and +Record.update()) to exercise the real script's logic end-to-end without a +live server. It is intentionally NOT a general pynetbox mock -- it models +only the calls this one script makes. +""" + +from __future__ import annotations + + +class Record: + _next_id = 1 + + def __init__(self, **fields): + self.id = Record._next_id + Record._next_id += 1 + for k, v in fields.items(): + setattr(self, k, v) + self._fields = dict(fields) + + def update(self, payload): + for k, v in payload.items(): + setattr(self, k, v) + self._fields.update(payload) + return True + + +class Collection: + def __init__(self): + self._items = [] + + def _matches(self, item, filters): + for k, v in filters.items(): + if getattr(item, k, None) != v: + return False + return True + + def get(self, **filters): + matches = [i for i in self._items if self._matches(i, filters)] + if not matches: + return None + return matches[0] + + def filter(self, **filters): + return [i for i in self._items if self._matches(i, filters)] + + def create(self, **fields): + rec = Record(**fields) + self._items.append(rec) + return rec + + def all(self): + return list(self._items) + + +class _Dcim: + def __init__(self): + self.sites = Collection() + + +class _Ipam: + def __init__(self): + self.roles = Collection() + self.prefixes = Collection() + self.ip_ranges = Collection() + self.vlans = Collection() + self.vlan_groups = Collection() + + +class FakeApi: + """Stands in for pynetbox.api(url, token=...).""" + + def __init__(self, url=None, token=None): + self.url = url + self.token = token + self.dcim = _Dcim() + self.ipam = _Ipam() + + def status(self): + return {"netbox-version": "fake"} + + def preseed_roles(self, slugs): + for slug in slugs: + self.ipam.roles.create(slug=slug, name=slug) + + +def api(url=None, token=None): + return FakeApi(url=url, token=token) diff --git a/tests/dc-dc-prefixes-import/run-tests.sh b/tests/dc-dc-prefixes-import/run-tests.sh new file mode 100644 index 0000000..452b462 --- /dev/null +++ b/tests/dc-dc-prefixes-import/run-tests.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# tests/dc-dc-prefixes-import/run-tests.sh -- harness for +# netbox/dc-dc-prefixes-import.py (tooling gap register #3). +# +# The actual test logic is Python (test_logic.py), since the target script +# is Python -- this wrapper just invokes it and relays the exit code, +# matching this repo's run-tests-all.sh discovery convention (glob for +# tests/*/run-tests.sh) without forcing a bash reimplementation of the +# assertions. +set -uo pipefail +SD="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +command -v python3 >/dev/null 2>&1 || { echo "FAIL: python3 required"; exit 1; } +python3 "$SD/test_logic.py" diff --git a/tests/dc-dc-prefixes-import/stub_syspath/pynetbox.py b/tests/dc-dc-prefixes-import/stub_syspath/pynetbox.py new file mode 100644 index 0000000..dc15e08 --- /dev/null +++ b/tests/dc-dc-prefixes-import/stub_syspath/pynetbox.py @@ -0,0 +1,11 @@ +"""Shim so `import pynetbox` resolves to the test-only fake_pynetbox module. + +Only exists under tests/dc-dc-prefixes-import/stub_syspath/ so it is never on +PYTHONPATH outside this harness -- it must not be importable as a stand-in +for the real pynetbox package anywhere else in the repo. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from fake_pynetbox import FakeApi, api, Record, Collection # noqa: F401 diff --git a/tests/dc-dc-prefixes-import/test_logic.py b/tests/dc-dc-prefixes-import/test_logic.py new file mode 100644 index 0000000..2d8ac26 --- /dev/null +++ b/tests/dc-dc-prefixes-import/test_logic.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +Unit + fake-end-to-end tests for netbox/dc-dc-prefixes-import.py. + +No real NetBox exists this session (workstation, not the jumphost) -- the +end-to-end tests run against tests/dc-dc-prefixes-import/fake_pynetbox.py, +an in-memory stand-in implementing only the calls the target script actually +makes. Pure-logic tests (validators, carve functions) need no NetBox at all. + +Run via tests/dc-dc-prefixes-import/run-tests.sh, which just invokes this +file with python3 and relays its exit code -- matching this repo's bash +harness convention even though the target under test is Python. +""" +from __future__ import annotations + +import contextlib +import importlib.util +import io +import ipaddress +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) +STUB_SYSPATH = os.path.join(HERE, "stub_syspath") +TARGET_PATH = os.path.join(REPO_ROOT, "netbox", "dc-dc-prefixes-import.py") + +# The stub must resolve BEFORE any real pynetbox (there isn't one installed +# on this workstation anyway, but keep the ordering explicit/defensive). +sys.path.insert(0, STUB_SYSPATH) +sys.path.insert(0, HERE) # so fake_pynetbox itself is importable directly + +import pynetbox # noqa: E402 -- resolves to stub_syspath/pynetbox.py +import fake_pynetbox # noqa: E402 + +P = 0 +F = 0 + + +def ok(label: str) -> None: + global P + P += 1 + print(f"PASS: {label}") + + +def no(label: str, detail: str = "") -> None: + global F + F += 1 + print(f"FAIL: {label}" + (f" ({detail})" if detail else "")) + + +def check(cond: bool, label: str, detail: str = "") -> None: + (ok(label) if cond else no(label, detail)) + + +def expect_dies(label: str, fn, *a, **kw) -> None: + try: + fn(*a, **kw) + no(label, "did not raise SystemExit") + except SystemExit: + ok(label) + + +def expect_ok(label: str, fn, *a, **kw): + try: + return fn(*a, **kw) + except SystemExit as exc: + no(label, f"unexpectedly raised SystemExit({exc})") + return None + else: + ok(label) + + +def load_target(): + spec = importlib.util.spec_from_file_location("dc_dc_prefixes_import", TARGET_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +T = load_target() + +# ----------------------------------------------------------------------------- +# validate_ula_48 +# ----------------------------------------------------------------------------- +net = expect_ok("ula48 valid /48 within fc00::/7", T.validate_ula_48, "fd12:3456:789a::/48") +if net is not None: + check((net.prefixlen == 48), "ula48 result prefixlen==48") + +expect_dies("ula48 wrong length /47 rejected", T.validate_ula_48, "fd12:3456:789a::/47") +expect_dies("ula48 wrong length /49 rejected", T.validate_ula_48, "fd12:3456:789a::/49") +expect_dies("ula48 GUA input rejected (not in fc00::/7)", T.validate_ula_48, "2602:f3e2:1234::/48") +expect_dies("ula48 IPv4 input rejected", T.validate_ula_48, "10.0.0.0/8") +expect_dies("ula48 malformed input rejected", T.validate_ula_48, "not-a-cidr") + +# ----------------------------------------------------------------------------- +# validate_gua_prefix +# ----------------------------------------------------------------------------- +gua = expect_ok("gua valid /40 within 2602:f3e2::/32", T.validate_gua_prefix, "2602:f3e2:1000::/40") +if gua is not None: + check(gua.prefixlen == 40, "gua result prefixlen==40") + +expect_dies("gua /64 rejected (no room to carve)", T.validate_gua_prefix, "2602:f3e2:1000::/64") +expect_dies("gua unrelated GUA block rejected", T.validate_gua_prefix, "2001:db8::/40") + +# ----------------------------------------------------------------------------- +# validate_dc2_supernet +# ----------------------------------------------------------------------------- +dc2_super = expect_ok("dc2 supernet valid /19 no overlap", T.validate_dc2_supernet, "10.13.0.0/19") +expect_dies("dc2 supernet too small (/20) rejected", T.validate_dc2_supernet, "10.13.0.0/20") +expect_dies("dc2 supernet overlapping DC1 rejected", T.validate_dc2_supernet, "10.12.0.0/19") +expect_dies("dc2 supernet IPv6 input rejected", T.validate_dc2_supernet, "fd00::/19") + +# ----------------------------------------------------------------------------- +# carve_dc2_v4 +# ----------------------------------------------------------------------------- +if dc2_super is not None: + v4 = T.carve_dc2_v4(dc2_super) + check(set(v4.keys()) == set(T.PLANE_ORDER), "carve_dc2_v4 covers all six planes") + all_22 = all(ipaddress.ip_network(c).prefixlen == 22 for c in v4.values()) + check(all_22, "carve_dc2_v4 all /22") + within = all(ipaddress.ip_network(c).subnet_of(dc2_super) for c in v4.values()) + check(within, "carve_dc2_v4 all within supernet") + distinct = len(set(v4.values())) == len(v4) + check(distinct, "carve_dc2_v4 all distinct") + +# ----------------------------------------------------------------------------- +# carve_ula +# ----------------------------------------------------------------------------- +org_ula = ipaddress.ip_network("fd12:3456:789a::/48") +ula_dc1 = T.carve_ula(org_ula, "dc1") +ula_dc2 = T.carve_ula(org_ula, "dc2") +expected_ula_planes = {p for p in T.PLANE_ORDER if T.PLANE_V6_FAMILY[p] in ("ula", "ula-only")} +check(set(ula_dc1.keys()) == expected_ula_planes, "carve_ula dc1 covers exactly the ULA-bearing planes") +check(set(ula_dc2.keys()) == expected_ula_planes, "carve_ula dc2 covers exactly the ULA-bearing planes") +all_64_dc1 = all(ipaddress.ip_network(c).prefixlen == 64 for c in ula_dc1.values()) +check(all_64_dc1, "carve_ula dc1 all /64") +no_overlap = not any( + ipaddress.ip_network(a).overlaps(ipaddress.ip_network(b)) + for a in ula_dc1.values() for b in ula_dc2.values() +) +check(no_overlap, "carve_ula dc1/dc2 non-overlapping") + +# ----------------------------------------------------------------------------- +# carve_gua +# ----------------------------------------------------------------------------- +if gua is not None: + gua_carved = T.carve_gua(gua) + check(set(gua_carved.keys()) == {"provider-public"}, "carve_gua only provider-public") + g64 = ipaddress.ip_network(gua_carved["provider-public"]) + check(g64.prefixlen == 64, "carve_gua result is /64") + check(g64.subnet_of(gua), "carve_gua result within input prefix") + +# ----------------------------------------------------------------------------- +# require_env +# ----------------------------------------------------------------------------- +os.environ["DC_DC_TEST_VAR"] = "present" +check(T.require_env("DC_DC_TEST_VAR", "test") == "present", "require_env returns set value") +del os.environ["DC_DC_TEST_VAR"] +expect_dies("require_env dies when unset", T.require_env, "DC_DC_TEST_VAR_NOT_SET", "test") + +# ----------------------------------------------------------------------------- +# main() end-to-end against the fake NetBox -- dc1 happy path + idempotency +# ----------------------------------------------------------------------------- + + +@contextlib.contextmanager +def captured_stdout(): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + yield buf + + +def run_main(argv, api_instance): + pynetbox.api = lambda url=None, token=None: api_instance + old_argv = sys.argv + sys.argv = ["dc-dc-prefixes-import.py"] + argv + try: + return T.main() + finally: + sys.argv = old_argv + + +os.environ["NETBOX_URL"] = "https://fake.example/netbox" +os.environ["NETBOX_TOKEN"] = "fake-token" +os.environ["ORG_ULA_48"] = "fd12:3456:789a::/48" + +fake1 = fake_pynetbox.FakeApi() +fake1.preseed_roles(T.PLANE_ORDER) +os.environ["DC_GUA_PREFIX"] = "2602:f3e2:1000::/40" + +with captured_stdout() as out1: + rc1 = run_main(["--dc", "dc1"], fake1) +check(rc1 == 0, "main dc1 first run rc==0", str(rc1)) +created_count = out1.getvalue().count("CREATED prefix") +check(created_count == 12, "main dc1 first run creates 12 prefixes (6 v4 + 5 ula + 1 gua)", str(created_count)) +check("vr0-dc0" in out1.getvalue(), "main dc1 prints the vr0-dc0 collision note") + +with captured_stdout() as out2: + rc2 = run_main(["--dc", "dc1"], fake1) +check(rc2 == 0, "main dc1 second run (idempotent) rc==0") +check(out2.getvalue().count("CREATED prefix") == 0, "main dc1 second run creates nothing new") +check(out2.getvalue().count("EXISTS prefix") == 12, "main dc1 second run reports all 12 as EXISTS") + +# ----------------------------------------------------------------------------- +# main() end-to-end -- dc2 happy path (fresh site, distinct v4/GUA) +# ----------------------------------------------------------------------------- +fake2 = fake_pynetbox.FakeApi() +fake2.preseed_roles(T.PLANE_ORDER) +os.environ["DC_GUA_PREFIX"] = "2602:f3e2:2000::/40" +os.environ["DC2_V4_SUPERNET"] = "10.13.0.0/19" + +with captured_stdout() as out3: + rc3 = run_main(["--dc", "dc2"], fake2) +check(rc3 == 0, "main dc2 run rc==0", str(rc3)) +check(out3.getvalue().count("CREATED prefix") == 12, "main dc2 run creates 12 prefixes", str(out3.getvalue().count("CREATED prefix"))) +dc2_site = fake2.dcim.sites.get(slug="vr1-dc2") +check(dc2_site is not None, "main dc2 creates the vr1-dc2 site") + +# ----------------------------------------------------------------------------- +# main() -- dc2 with DC2_V4_SUPERNET missing FAILS LOUD +# ----------------------------------------------------------------------------- +del os.environ["DC2_V4_SUPERNET"] +fake3 = fake_pynetbox.FakeApi() +fake3.preseed_roles(T.PLANE_ORDER) +try: + run_main(["--dc", "dc2"], fake3) + no("main dc2 without DC2_V4_SUPERNET fails loud", "did not raise SystemExit") +except SystemExit: + ok("main dc2 without DC2_V4_SUPERNET fails loud") + +# ----------------------------------------------------------------------------- +# argparse: missing/invalid --dc +# ----------------------------------------------------------------------------- +fake4 = fake_pynetbox.FakeApi() +fake4.preseed_roles(T.PLANE_ORDER) +os.environ["DC2_V4_SUPERNET"] = "10.13.0.0/19" + +try: + run_main([], fake4) + no("main missing --dc fails loud") +except SystemExit: + ok("main missing --dc fails loud") + +try: + run_main(["--dc", "dc3"], fake4) + no("main invalid --dc choice fails loud") +except SystemExit: + ok("main invalid --dc choice fails loud") + +# ----------------------------------------------------------------------------- +# Summary +# ----------------------------------------------------------------------------- +print() +if F == 0: + print(f"ALL PASS ({P} checks)") + sys.exit(0) +else: + print(f"FAILURES: {F}") + sys.exit(1)