diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a2441bb..5da1f01 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1947,6 +1947,74 @@ it was NOT retried in an altered shape** -- so `~/vr1-dc0-creds/` contents and the rack's maas-profile state were not enumerated, and SEC-028's distribution claim is carried from the ledger rather than re-measured. + **>>> D-139 RULING A (v6-ONLY ON FIVE PLANES) IS NOT ACHIEVABLE AT THE CURRENT CHARM + REVISIONS. RULING B (FULL GUA) IS UNAFFECTED. <<<** Capture + `docs/audit/v6-only-charm-viability-20260801.txt` (355 lines, rescued from `/tmp` -- see the + process note below). Researched from the CHARM ARTIFACTS (the charms were downloaded and + read), not from reasoning about them, per the method that inverted the `prefer-ipv6` premise + on 2026-07-31. **THE LOAD-BEARING CLAIM WAS RE-VERIFIED INDEPENDENTLY from the main session + against the shipped `ceph-osd` rev 953 artifact**, quoted here because it decides the + ruling: `hooks/utils.py:203-217` `get_host_ip()` returns `get_ipv6_addr()[0]` when + `prefer-ipv6` is set, and otherwise runs `socket.inet_aton(hostname)` -> on failure + `dns.resolver.query(hostname, 'A')`, an **IPv4-ONLY lookup**, under the charm's own comment + *"This may throw an NXDOMAIN exception; in which case things are badly broken so just let it + kill the hook"*. **So `ceph-osd` breaks on a v6-only plane in BOTH directions:** with + `prefer-ipv6: false` a v6 `private-address` fails `inet_aton` and then NXDOMAINs an `IN A` + lookup; with it TRUE it reaches `get_ipv6_addr()` called BARE (also `ceph_hooks.py:547`, + i.e. WITHOUT `dynamic_only=False`), which returns only DYNAMIC/SLAAC addresses -- and every + VR1 node holds a MAAS **static** v6. That second half is **LP #2061836**, `Fix Committed` on + `charm-ceph-mon` 2024-06-14 and **`New`, never fixed, on `charm-ceph-osd`** -- confirmed + absent from rev 953 here. The first half is **LP #2109798** (`New`, 2025-05-01), whose + reporter's only workaround is, verbatim, *"forcing the ipv4 cidr for the + ceph-cluster-network and ceph-public-network options"* -- i.e. do not do v6. + **THREE CHARM FAMILIES BREAK, IN THREE DIFFERENT WAYS** (the ceph one above verified here; + **the other two are reported from the capture and NOT independently re-verified by the main + session -- treat them as strong, sourced observations, not as measured-here facts**): + **(i) `metal-internal` is the worst** -- `metal-internal` appears **273 times** in + `bundle.yaml` across ~44 applications (counted here), and `mysql-innodb-cluster` plus + `mysql-router` x13 build bare `user:pw@addr` URIs that are invalid for a v6 literal without + brackets, while `hacluster` x8 needs a corosync `ip_version` that only a raising option + supplies. **LP #2111852 is `Fix Committed` but NOT RELEASED** -- `hacluster` 2.4/stable rev + 166 (released 2026-06-29) still hard-writes `ip_version: ipv4` in `templates/corosync.conf`. + **(ii) `data-tenant`** -- OVN documents the encap column as *"The IPv4 address of the + encapsulation tunnel endpoint"* (`ovn-sb.xml:565`) and every `ovn-encap-ip` in OVN 24.03's + entire test suite is IPv4. **(iii) `storage`/`replication`** are the LEAST-BAD of the five, + not clean. + **CONSEQUENCE FOR D-139:** Ruling A's five-plane v6-only scope is **BLOCKED by upstream charm + defects, not by anything in this deployment**, and two of the three are open unfixed + Launchpad bugs whose only public reporters are operators attempting the same shape. + **Ruling B (full GUA) is ORTHOGONAL and STANDS** -- GUA-versus-ULA is independent of whether + a plane keeps v4, so the carve tool built this session remains valid and a GUA DUAL-STACK + carve is achievable today. **This needs an operator ruling and is NOT actioned.** + **THREE ITEMS OWED, RECORDED SO THEY ARE NOT LOST.** (a) **The foundational measurement is + still not taken: does `network-get` return a v6 address on a v6-only bound space?** Whatever + survives of the storage/replication verdict rests on it. (b) **D-139's execution clause + "remove the v4 subnets from the five v6-only planes LAST, after each is proven" is + UNSATISFIABLE as written** -- per-plane conversion is atomic, so there is no state in which a + plane is proven v6-only while still holding v4. That clause must be rewritten whatever is + ruled. (c) **`PREFER_IPV6_CHARMS` omits `rabbitmq-server`**, which the bundle DOES deploy + (both counted here) -- the only mismatch across all 33 bundle pairs, and invariant 9a would + emit a factually false message on it. DOCFIX/BUNDLEFIX material, not numbered. + **PROCESS FAILURE, OWNED: this research was written to `/tmp/v6research/FINDINGS.txt` and + would have been LOST.** The research agent was scoped read-only and given NO capture path in + the repo -- my error in writing the mandate, not the agent's. It was rescued to + `docs/audit/` and sha256-verified identical (355 lines, ASCII). **Standing consequence: every + agent mandate must name a repo path for its findings**, because "the transcript" is not a + surface and this repo's top-tier failure mode is exactly this. + **D-139 APEX CARVE TOOL BUILT, DRY-RUN ONLY, NOTHING PUSHED.** `netbox/d139-gua-carve.py` + (232 lines) + `tests/d139-gua-carve/` (65 cases, incl. an offline stub apex); + `tests/HARNESS-MANIFEST` 94 -> 95. Live dry run against the apex + (`docs/audit/d139-carve-dryrun-20260801.txt`): **CREATE 22 | EXISTS 6 untouched | + RETIRE-REPORT 18**, and it surfaces **52 DEPENDENT ip-addresses living inside the retiring + ULA `/64`s** (26 per DC, in `:220`/`:221` and `:320`/`:321`) that a later delete would + orphan -- exactly the kind of thing a report-only pass exists to find. **Proof no write + occurred: apex prefix count 139 before and 139 after, and none of the 22 CREATE rows exists + in the apex.** Five mutations run, each turning a NAMED test red, tool restored + sha256-identical. Harness 65/65; gauntlet ALL GREEN (95); repo-lint 0 fail. + **MEASURED IN PASSING AND NOT ACTED ON (hard rule 1): VR0 DC0 and Willamette -- the two sites + D-139 cites as the standard it conforms to -- each also carry a `role=Cloud` `/56`, a + `:e0::/60` VPN and `:f0::/60`+`/64` OOB. VR1 DC0/DC1 carry NONE of these**, holding only the + `/48` container. D-139's table does not rule them, so the tool does not create them. - Project: Omega Cloud, VR1 DC-DC rehearsal -- a two-DC + Office1-headend virtual rehearsal on KVM (vcloud host), rehearsing the future bare-metal Roosevelt deployment (D-100, `docs/design-decisions.md:1946`). diff --git a/docs/audit/d139-carve-dryrun-20260801.txt b/docs/audit/d139-carve-dryrun-20260801.txt new file mode 100644 index 0000000..c04818a --- /dev/null +++ b/docs/audit/d139-carve-dryrun-20260801.txt @@ -0,0 +1,97 @@ +d139-carve-dryrun-20260801.txt +=============================== +READ-ONLY dry run of netbox/d139-gua-carve.py against the VR1 WORKING apex +(office1-netbox http://10.10.1.10:8000, DOCFIX-195 -- NOT baldurkeep). Host: +vcloud. Date: 2026-08-01T00:08:57Z. NO write of any kind was made: the apex prefix +count was 139 before and 139 after, and none of the 22 CREATE rows exists. +Governing decision: D-139 ruling B. Retirement is REPORTED, never executed. + +TOTALS ACROSS BOTH DCs: CREATE 22 | EXISTS 6 (untouched) | RETIRE-REPORT 18 +ULA rows, holding 52 dependent ip-addresses that a later delete would orphan. + +STANDING NOTE: commit 633da1c (same day, concurrent session) REFUTED D-139 +ruling B's stated deciding reason (glibc 2.35 does not implement RFC 6724's +precedence table) and FLAGGED the ruling for operator reconsideration. D-139's +text is UNCHANGED, so this dry run reflects the ruling as it stands -- but the +--commit push is gated on that reconsideration, not merely on operator approval. + +MEASURED STRUCTURAL ASYMMETRY, reported not acted on (outside D-139 table): +VR0 DC0 and Willamette -- the two sites D-139 cites as the org standard -- +each carry a role=Cloud /56 (e02::/56, 102::/56), a :e0::/60 VPN and a +:f0::/60+/64 OOB. VR1 DC0/DC1 carry NONE of these; they hold only the /48 +container. D-139 does not rule them, so this tool does not create them. + +======================================================================= +== d139-gua-carve vr1-dc0 (DRY RUN) == + apex: http://10.10.1.10:8000 site: VR1 DC0 (id=8) + apex prefixes scoped to vr1-dc0: 21 (read this run) + CREATE 11 | EXISTS 3 | RETIRE-REPORT 9 + + CREATE: + 2602:f3e2:f02:20::/60 role=metal-admin VR1 DC0 metal-admin (GUA /60; D-139) + 2602:f3e2:f02:20::/64 role=metal-admin VR1 DC0 metal-admin (GUA /64; D-139) + 2602:f3e2:f02:21::/64 role=metal-internal VR1 DC0 metal-internal (GUA /64; D-139) + 2602:f3e2:f02:30::/60 role=data-tenant VR1 DC0 data-tenant (GUA /60; D-139) + 2602:f3e2:f02:30::/64 role=data-tenant VR1 DC0 data-tenant (GUA /64; D-139) + 2602:f3e2:f02:40::/60 role=storage VR1 DC0 storage (GUA /60; D-139) + 2602:f3e2:f02:40::/64 role=storage VR1 DC0 storage (GUA /64; D-139) + 2602:f3e2:f02:50::/60 role=replication VR1 DC0 replication (GUA /60; D-139) + 2602:f3e2:f02:50::/64 role=replication VR1 DC0 replication (GUA /64; D-139) + 2602:f3e2:f02:80::/60 role=lbaas-mgmt VR1 DC0 lb-mgmt (GUA /60; D-139) + 2602:f3e2:f02:80::/64 role=lbaas-mgmt VR1 DC0 lb-mgmt (GUA /64; D-139) + + EXISTS (idempotent -- left untouched): + 2602:f3e2:f02:10::/60 role=provider-public provider-public + 2602:f3e2:f02:10::/64 role=provider-public provider-public + 2602:f3e2:f02:11::/64 role=provider-public provider-public VIP + + RETIRE -- REPORTED ONLY, this tool never deletes: + fd50:840e:74e2:220::/60 role=metal-admin VR1 DC0 metal-admin (ULA metal /60 (admin+internal); D-101/D-111) + fd50:840e:74e2:220::/64 role=metal-admin VR1 DC0 metal-admin (ULA /64 active; D-101/D-111) + fd50:840e:74e2:221::/64 role=metal-internal VR1 DC0 metal-internal (ULA /64 active (in metal /60); D-101/D-111) + fd50:840e:74e2:230::/60 role=data-tenant VR1 DC0 data-tenant (ULA /60; D-101/D-111) + fd50:840e:74e2:230::/64 role=data-tenant VR1 DC0 data-tenant (ULA /64 active; D-101/D-111) + fd50:840e:74e2:240::/60 role=storage VR1 DC0 storage (ULA /60; D-101/D-111) + fd50:840e:74e2:240::/64 role=storage VR1 DC0 storage (ULA /64 active; D-101/D-111) + fd50:840e:74e2:250::/60 role=replication VR1 DC0 replication (ULA /60; D-101/D-111) + fd50:840e:74e2:250::/64 role=replication VR1 DC0 replication (ULA /64 active; D-101/D-111) + dependent objects inside those prefixes: 26 ip-address(es), 0 ip-range(s) + +DRY RUN -- nothing was written. Re-run with --commit to apply. + +== d139-gua-carve vr1-dc1 (DRY RUN) == + apex: http://10.10.1.10:8000 site: VR1 DC1 (id=9) + apex prefixes scoped to vr1-dc1: 21 (read this run) + CREATE 11 | EXISTS 3 | RETIRE-REPORT 9 + + CREATE: + 2602:f3e2:f03:20::/60 role=metal-admin VR1 DC1 metal-admin (GUA /60; D-139) + 2602:f3e2:f03:20::/64 role=metal-admin VR1 DC1 metal-admin (GUA /64; D-139) + 2602:f3e2:f03:21::/64 role=metal-internal VR1 DC1 metal-internal (GUA /64; D-139) + 2602:f3e2:f03:30::/60 role=data-tenant VR1 DC1 data-tenant (GUA /60; D-139) + 2602:f3e2:f03:30::/64 role=data-tenant VR1 DC1 data-tenant (GUA /64; D-139) + 2602:f3e2:f03:40::/60 role=storage VR1 DC1 storage (GUA /60; D-139) + 2602:f3e2:f03:40::/64 role=storage VR1 DC1 storage (GUA /64; D-139) + 2602:f3e2:f03:50::/60 role=replication VR1 DC1 replication (GUA /60; D-139) + 2602:f3e2:f03:50::/64 role=replication VR1 DC1 replication (GUA /64; D-139) + 2602:f3e2:f03:80::/60 role=lbaas-mgmt VR1 DC1 lb-mgmt (GUA /60; D-139) + 2602:f3e2:f03:80::/64 role=lbaas-mgmt VR1 DC1 lb-mgmt (GUA /64; D-139) + + EXISTS (idempotent -- left untouched): + 2602:f3e2:f03:10::/60 role=provider-public provider-public + 2602:f3e2:f03:10::/64 role=provider-public provider-public + 2602:f3e2:f03:11::/64 role=provider-public provider-public VIP + + RETIRE -- REPORTED ONLY, this tool never deletes: + fd50:840e:74e2:320::/60 role=metal-admin VR1 DC1 metal-admin (ULA metal /60 (admin+internal); D-101/D-111) + fd50:840e:74e2:320::/64 role=metal-admin VR1 DC1 metal-admin (ULA /64 active; D-101/D-111) + fd50:840e:74e2:321::/64 role=metal-internal VR1 DC1 metal-internal (ULA /64 active (in metal /60); D-101/D-111) + fd50:840e:74e2:330::/60 role=data-tenant VR1 DC1 data-tenant (ULA /60; D-101/D-111) + fd50:840e:74e2:330::/64 role=data-tenant VR1 DC1 data-tenant (ULA /64 active; D-101/D-111) + fd50:840e:74e2:340::/60 role=storage VR1 DC1 storage (ULA /60; D-101/D-111) + fd50:840e:74e2:340::/64 role=storage VR1 DC1 storage (ULA /64 active; D-101/D-111) + fd50:840e:74e2:350::/60 role=replication VR1 DC1 replication (ULA /60; D-101/D-111) + fd50:840e:74e2:350::/64 role=replication VR1 DC1 replication (ULA /64 active; D-101/D-111) + dependent objects inside those prefixes: 26 ip-address(es), 0 ip-range(s) + +DRY RUN -- nothing was written. Re-run with --commit to apply. diff --git a/docs/audit/v6-only-charm-viability-20260801.txt b/docs/audit/v6-only-charm-viability-20260801.txt new file mode 100644 index 0000000..a7c1b4d --- /dev/null +++ b/docs/audit/v6-only-charm-viability-20260801.txt @@ -0,0 +1,355 @@ +STAGE 5 / D-139 -- WHAT THE CHARMS DO ON A v6-ONLY PLANE +Research 2026-08-01, read from Charmhub ARTIFACTS (base ubuntu@22.04, amd64) + vendor docs. +Staged /tmp/v6research (NOT in the repo). No cloud mutation, no repo write. + +ARTIFACT PROVENANCE (charm, channel, revision, sha256 prefix of the .charm) + ceph-mon squid/stable rev 491 4fec8dc10971ac7b + ceph-osd squid/stable rev 953 60699796f134a2d0 + ceph-radosgw squid/stable rev 600 cede6d47c0590213 + mysql-innodb-cluster 8.0/stable rev 164 457225589673d835 + mysql-router 8.0/stable rev 1154 eb3f21bed93a4490 + ovn-central 24.03/stable rev 311 0946d7d6e6ed84d1 + ovn-chassis 24.03/stable rev 396 ec5cad529e813466 + rabbitmq-server 3.9/stable rev 295 25dba936d5647463 + hacluster 2.4/stable rev 166 ce38ccaa2d4e56bf +Acquired via api.charmhub.io (juju is NOT installed on vcloud), base/arch pinned in the query. + +TOPOLOGY FACTS USED (from the repo, not inferred) + bundle.yaml default-base ubuntu@22.04/stable; ALL 56 apps default-bind ('') to metal-admin. + ALL 56 apps bind at least one endpoint into one of the five v6-only planes. + VR1 nodes have 6 flat per-plane NICs: enp1s0 metal-admin(PXE), enp2s0 provider-public, + enp3s0 metal-internal, enp4s0 data-tenant, enp5s0 storage, enp6s0 replication + (docs/CURRENT-STATE.md:1941-1943, :762-764). + Node v6 is MAAS mode=STATIC (scripts/dc-node-v6-carve.py:180-181), no SLAAC, no RA + (all six v6 subnets gateway_ip=None, D-139). + ceph-mon / mysql-innodb-cluster / ovn-central / rabbitmq-server / ceph-radosgw run in LXD + (bundle to: lxd:0..2). ceph-osd and nova-compute run on METAL. + +========================================================================= +1. THE OPTION'S ADDRESS LOOKUP IS ANCHORED TO private-address, NOT THE SPACE +========================================================================= +charmhelpers/contrib/network/ip.py:340-351 @sniff_iface injects + kwargs['iface'] = get_iface_from_addr(unit_get('private-address')) +into get_ipv6_addr(). private-address = the DEFAULT binding = metal-admin = enp1s0/eth0. +So prefer-ipv6 NEVER reads the bound space's address. Confirms and extends the 2026-07-31 +capture's Finding 5. + +TWO DISTINCT FAILURE MODES, both already evidenced in this repo: + (a) LXD units: container eth0 (metal-admin, stays DUAL-STACK under D-139) gets ONE family + from juju's addrs[0] -- today v4 (D-139's own measurement). No global v6 on eth0 => + Exception: Interface 'eth0' does not have a scope global non-temporary ipv6 address. + ALREADY OBSERVED LIVE 2026-07-31 (docs/CURRENT-STATE.md:1332; D-101 ruling note (b)). + (b) METAL units with STATIC v6: ceph-osd and hacluster call get_ipv6_addr() WITHOUT + dynamic_only=False. ip.py:397-411 then requires the line to match + inet6 (.+)/[0-9]+ scope global.* dynamic.* + AND the address to endswith the EUI-64 MAC suffix. A MAAS mode=STATIC address is + neither => addrs empty => ip.py:416-419 raises the SAME exception. + ceph-mon uses get_ipv6_addr(dynamic_only=False) and is NOT exposed to (b). + ceph-osd hooks/ceph_hooks.py:547 dynamic_ipv6_address = get_ipv6_addr()[0] + ceph-osd hooks/utils.py:205 return get_ipv6_addr()[0] + hacluster hooks/utils.py:454 return utils.get_ipv6_addr(exc_list=excludes)[0] + hacluster hooks/utils.py:214 ipv6_addr = utils.get_ipv6_addr(iface=iface)[0] + ceph-mon src/ceph_hooks.py:210 local_addresses = get_ipv6_addr(dynamic_only=False) + ceph-mon src/utils.py:132 return get_ipv6_addr(dynamic_only=False)[0] + +========================================================================= +2. CEPH +========================================================================= +VENDOR (docs.ceph.com/en/squid/rados/configuration/network-config-ref/): + ms_bind_ipv4 type bool default: true + ms_bind_ipv6 type bool default: false +So a v6-only cluster needs BOTH flipped. Charm emits BOTH -- ONLY under prefer-ipv6. + +ceph-mon src/ceph_hooks.py:207-236 + if config('prefer-ipv6'): + cephcontext['ms_bind_ipv4'] = False + cephcontext['ms_bind_ipv6'] = True + local_addresses = get_ipv6_addr(dynamic_only=False) + public_addr = get_public_addr(); cluster_addr = get_cluster_addr() + if public_addr not in local_addresses: -> WARNING, public_addr = local_addresses[0] + if cluster_addr not in local_addresses: -> WARNING, cluster_addr = local_addresses[0] + else: + cephcontext['public_addr'] = get_public_addr() + cephcontext['cluster_addr'] = get_cluster_addr() +templates/ceph.conf:26-31 (ceph-osd templates/ceph.conf:18-23) render exactly: + {% if ms_bind_ipv6 %}ms_bind_ipv6 = true{%- endif %} + {%- if ms_bind_ipv4 == false %}ms_bind_ipv4 = false{% endif %} + +ceph-osd hooks/ceph_hooks.py:544-551 -- CRUDER, and this is the decisive asymmetry: + if config('prefer-ipv6'): + cephcontext['ms_bind_ipv4'] = False + cephcontext['ms_bind_ipv6'] = True + dynamic_ipv6_address = get_ipv6_addr()[0] # dynamic_only=True <-- BUG + if not public_network: cephcontext['public_addr'] = dynamic_ipv6_address + if not cluster_network: cephcontext['cluster_addr'] = dynamic_ipv6_address + -> ONE address used for BOTH public and cluster, taken from the metal-admin NIC. + -> raises on static addressing before it even gets that far. + +Bundle sets NEITHER ceph-public-network NOR ceph-cluster-network (measured from bundle.yaml: +ceph-mon options = expected-osd-count/monitor-count/source; ceph-osd = osd-devices/source). +So both charms fall through to network_get_primary_address('public'|'cluster') -- +utils.py get_public_addr()/get_cluster_addr() -- i.e. SPACE-AWARE, and correct on v6-only +storage/replication. That path is only reached when prefer-ipv6 is FALSE. + +NET: prefer-ipv6 FALSE + v6-only storage/replication + public addr / cluster addr = correct per-space v6 (space-aware, unbracketed) + BUT ms_bind_ipv6 absent => Ceph defaults ms_bind_ipv6=false / ms_bind_ipv4=true + => daemons will not bind the v6 address. BROKEN. + prefer-ipv6 TRUE + ms_bind_* correct, but addresses are taken from metal-admin, collapsing + storage+replication onto the admin plane; and on ceph-osd it raises first. + BROKEN DIFFERENTLY. + => NEITHER SETTING OF prefer-ipv6 GIVES A CORRECT CEPH ON THIS TOPOLOGY. + +THE SURGICAL PATH THAT EXISTS (verified option name, verified render path): + `config-flags` IS declared by BOTH ceph-mon and ceph-osd (config.yaml). + charmhelpers/contrib/storage/linux/ceph.py:2303-2339 CephConfContext; permitted sections + ceph-mon src/ceph_hooks.py:249 = ['global','mds','mon']; + ceph-osd hooks/ceph_hooks.py:569 = ['global','osd']. + templates/ceph.conf renders the user [global] block AFTER the ms_bind conditionals. + So config-flags="{'global': {'ms_bind_ipv6': 'true', 'ms_bind_ipv4': 'false'}}" sets the + two flags WITHOUT prefer-ipv6, leaving the space-aware address path intact. + PARSER PATH VERIFIED, not assumed: charmhelpers/contrib/openstack/utils.py:1967-1974 + config_flags_parser takes the YAML branch when a colon precedes any '=' (here colon=9, + equals=-1) -> yaml.safe_load -> nested dict -> ordered() accepts it -> CephConfContext's + isinstance(conf, dict) passes. RENDERED the actual template with that context; output + ends with, in [global]: ms_bind_ipv6 = true / ms_bind_ipv4 = false. + STILL NOT PROVEN LIVE, and config-flags is documented by the charm itself as a stop-gap. + + SETTLED IN PASSING: `{% if ceph_public_network is string %}` with ', '.join([]) = '' + DOES render bare `public network = ` and `cluster network = ` lines. This happens with + IPv4 too (the bundle has never set ceph-public-network/ceph-cluster-network), so it is + PRE-EXISTING and family-independent -- it does not change the v6 verdict either way. + +mon host IS bracketed: ceph-mon src/ceph_hooks.py:413/423 format_ipv6_addr(addr) or addr + (charmhelpers ip.py:232-241 returns "[%s]"). +public addr / cluster addr are emitted UNBRACKETED on the charm's own IPv6 path -- so +upstream treats a bare v6 as acceptable there. NOT independently verified against Ceph's +parser. + +========================================================================= +3. MYSQL-INNODB-CLUSTER (and mysql-router, 13 instances, unscoped by the tuple) +========================================================================= +Verified myself, quoted: + mysql_innodb_cluster.py:590-595 + _script = ("dba.configure_instance('{user}:{pw}@{addr}')\n".format(...addr=address)) + mysql_router.py:527-530 + "--bootstrap", "{}:{}@{}".format(self.db_router_user, self.db_router_password, + self.cluster_address) +Both bare -- a v6 literal makes an unparseable MySQL Shell / mysqlrouter URI; the correct +form is user:pw@[addr]. Seven URI sites total in mysql-innodb-cluster +(:591, :685, :722, :824, :889, :1138, :1693). +NEITHER charm ever calls format_ipv6_addr / is_ipv6 (zero hits). +First failure is unconditional: initialize_cluster -> configure_instance -> logs ERROR and +returns (:596-602); leader flag never set; unit parks at +"Instance not yet configured for clustering" (:1515-1519). +prefer-ipv6 does NOT help -- it is consumed only in bundled libs, never in charm code, and +fixes none of the URI sites. +NOT a Group Replication bracketing bug: the charm writes no group_replication_* variables +(zero hits for group_replication_local_address / group_replication_group_seeds / 33061); +mysqlsh derives them. group_replication_ip_allowlist IS charm-built (:627-637) as a bare +comma list, which is the correct format. +mysql-router --conf-bind-address 127.0.0.1 (mysql_router.py:59-64) is deliberate subordinate +loopback, NOT a plane address -- not a break. +NOT ESTABLISHED: whether juju's v6 rendering matches mysqld's for @'host' grant matching +(skip_name_resolve=ON, mysqld.cnf:105); whether juju/charms.reactive accept ':' in the +leader-settings key / flag name built by .replace(".","-") (:49-56). + +========================================================================= +4. OVN -- data-tenant AND metal-internal, and NOT in the tuple +========================================================================= +Neither ovn-central nor ovn-chassis declares prefer-ipv6 or any IPv6 option (grep -i ipv6 +on config.yaml = 0 hits in both). +ROOT DEFECT, verified in this environment: + hooks/relations/ovsdb/lib/ovsdb.py:33-47 _format_addr brackets IPv6, and is NOT idempotent: + ipaddress.ip_address('[fd00::1]') -> ValueError (idempotent for IPv4) + :184-185 publish_cluster_local_addr publishes the ALREADY-BRACKETED string + :82-87 _remote_addrs re-parses it and `except ValueError: continue` -- silently drops it. + All four copies of the lib are byte-identical (md5 7ea671df11a1d964e793c0ab3e554167). +CONSEQUENCE on a v6-only metal-internal / data-tenant, ALL SILENT: + chassis lib/charms/ovn_charm.py:1124-1146 -- sb_conn is '' so `if sb_conn:` is False and + ovn-encap-type / ovn-encap-ip / system-id / ovn-remote are NEVER WRITTEN AT ALL. + central templates/ussuri/ovn-central -- --db-nb/sb-cluster-remote-addr= empty on non-leaders; + ovsdb-tool join-cluster called with no remotes. + No hook error, no blocked status: readiness gates test bound-address raw, and the Jinja + Environment is built without StrictUndefined. +Latent, only after a fix: get_data_ip() (ovn_charm.py:962-973) would write + external-ids:ovn-encap-ip=[fd00::1] -- bracketed. +Independent: the Geneve NOTRACK workaround is iptables-only (ovn_charm.py:1401-1402, +no ip6tables anywhere) and is inert on a v6 tunnel plane. + +========================================================================= +5. HACLUSTER (8 subordinates, hanode/peer-availability all on metal-internal) +========================================================================= +VENDOR (corosync.conf(5), jammy): + "If IPv6 networking is used, the nodeid field in nodelist must be specified." + "corosync resolves ringX_addr names/IP addresses using getaddrinfo(3) with respect of + totem.ip_version setting." + ip_version default if unspecified = ipv6-4 for knet/udpu -- but the charm ALWAYS writes it. +hooks/utils.py:245-250 + if config('prefer-ipv6'): ip_version = 'ipv6' ... else: ip_version = 'ipv4' +hooks/utils.py:271-272 + if config('prefer-ipv6'): conf['nodeid'] = get_corosync_id(local_unit()) +templates/corosync.conf:33-37 renders nodeid / ip_version. +=> prefer-ipv6 FALSE writes `ip_version: ipv4` AND omits nodeid: corosync cannot form over v6. +=> prefer-ipv6 TRUE supplies both, but routes through hooks/utils.py:454 + utils.get_ipv6_addr(exc_list=excludes)[0] -- dynamic_only=True -- failure mode (b). +hacluster imports the v6-safe get_host_ip (charmhelpers network/ip.py:478-494, is_ip-based), +so :472/:484/:486 do NOT crash on a v6 literal. +corosync_transport default 'unicast'. + +========================================================================= +6. RABBITMQ-SERVER -- AND A DEFECT IN THE REPO'S "MEASURED AUTHORITY" +========================================================================= +rabbitmq-server@3.9/stable rev 295 DOES declare prefer-ipv6 (read from the ARTIFACT's +config.yaml), and it is ABSENT from scripts/provider-bundle-check.py PREFER_IPV6_CHARMS, +whose comment at :57 calls that tuple "the measured authority for 9a/9b". +Invariant 9a (:379-384) FAILS any app setting prefer-ipv6 whose charm is not in the tuple, +with the message "its charm does NOT declare that option -- juju rejects the whole bundle". +For rabbitmq-server that message is FACTUALLY WRONG. +Full sweep of all 33 unique charm@channel pairs in bundle.yaml against Charmhub config-yaml: +rabbitmq-server is the ONLY mismatch; the other 32 agree with the tuple; no tuple entry is +unused. (Sweep used default-release, so its revisions are not base-pinned; but for the 9 +charms I also read as jammy/amd64 ARTIFACTS the declaration answer is identical.) +What the option does there: hooks/rabbitmq_context.py:267-270 + if config('prefer-ipv6'): + context['settings']['RABBITMQ_SERVER_START_ARGS'] = "'-proto_dist inet6_tcp'" +i.e. the Erlang distribution family for CLUSTERING. Matters at HA scale-up (num_units 1 today). + +========================================================================= +7. GENERIC METAL-INTERNAL SURFACE (all 56 apps) +========================================================================= +With prefer-ipv6 FALSE, get_relation_ip() (charmhelpers ip.py:605-642) falls through to +network_get_primary_address(interface) -- SPACE-AWARE. On a v6-only space juju has only a v6 +address to return, so charms get v6 by construction. NOT MEASURED in this cloud; owed. +Downstream bracketing is NOT uniformly handled. Example measured: + charmhelpers/contrib/openstack/templates/haproxy.cfg:88/90 + server {{ unit }} {{ address }}:{{ ports[1] }} check + -- unbracketed. Reached only with >1 peer (HAProxyContext returns {} with no peers), i.e. + at dc-ha-scaleup, not initial deploy. Whether HAProxy parses an unbracketed v6:port is + NOT ESTABLISHED. + +========================================================================= +8. UPSTREAM STATUS (Launchpad, opened via api.launchpad.net with all 11 + statuses passed) -- CORROBORATES THE ARTIFACT READS +========================================================================= +LP #2061836 "charm only accepts dynamic ipv6 addresses" (2024-04-16, Federico Bosi) + charm-ceph-mon = FIX COMMITTED (2024-06-14); charm-ceph-osd = NEW, never fixed. + Reporter: "I tried ... replacing all the get_ipv6_addr() calls with + get_ipv6_addr(dynamic_only=False) ... The machines have a single global static ipv6 + address. Everything works correctly." + => the dynamic_only asymmetry I derived from the artifact is ALREADY REPORTED, and the + one-word fix is merged on mon and NOT on osd. Master still shows the bare + get_ipv6_addr() in charm-ceph-osd/hooks/ceph_hooks.py. DO NOT FILE A NEW BUG. + +LP #2056337 "No config flag available to disable OSDs binding to ipv4 addresses" + (2024-03-06) FIX COMMITTED both charms; Gerrit 911592 MERGED 2024-04-12 + "Don't bind to ipv4 if prefer-ipv6 is true." -- this is the ms_bind_ipv4=false emission + I read in the template. Upstream root cause tracker.ceph.com/issues/52867 is still NEW + after ~5 years (created 2021-10-07). + +LP #2109798 "If IPv6 address is set in the mon relation, unit fall in error state" + charm-ceph-osd, NEW/unassigned, 2025-05-01. OSError: illegal IP address string passed to + inet_aton, then dns.resolver.NoNameservers ... IN A -> mon-relation-changed hook error. + *** VERIFIED IN THE rev 953 ARTIFACT, and it MATTERS: *** + hooks/ceph_hooks.py:716-728 get_mon_hosts() + addr = relation_get('ceph-public-address',...) or get_host_ip( + relation_get('private-address',...)) + hooks/utils.py:203-216 get_host_ip() (the LOCAL one, imported at ceph_hooks.py:89) + socket.inet_aton(hostname) ... except socket.error: + answers = dns.resolver.query(hostname, 'A') + On a v6-only storage plane, private-address IS v6. Whenever ceph-public-address has not + yet been published, the `or` fallback fires and the hook dies. + => ceph-osd has an UNFIXED v6 crash path that prefer-ipv6=FALSE does NOT avoid. + This WEAKENS the "storage/replication are salvageable" verdict: config-flags fixes + ms_bind_*, it does NOT fix this. + (format_ipv6_addr(addr) or addr at :728 does correctly bracket the mon host.) + Reporter's only workaround, verbatim: "force ceph-mon and ceph-osd not to use IPv6 + addresse using the prefer-ipv6=false and forcing the ipv4 cidr for the + ceph-cluster-network and ceph-public-network options." + +LP #2111852 charm-hacluster (2025-05-27, FIX COMMITTED) -- exactly the corosync defect I + read: "under the totem section of corosync.conf this setting is present: ip_version: + ipv4 And in the section nodelist as ring0_addr an IPv6 addresse is used". Fires at the + DEFAULT prefer-ipv6=false. + *** BUT: FIX COMMITTED IS NOT FIX RELEASED, AND IT IS NOT IN THE ARTIFACT. *** + hacluster 2.4/stable rev 166, released 2026-06-29, STILL has (grep for ip_version = + the complete set of references at that revision): + hooks/utils.py:246 ip_version = 'ipv6' + hooks/utils.py:249 ip_version = 'ipv4' + hooks/utils.py:257 / :289 'ip_version': ip_version + templates/corosync.conf:36-37 + i.e. the charm this cloud would deploy still hard-writes ip_version: ipv4. + +LP #1966135 ufw rejects bracketed IPv6 from cms-client-bound-address, charm-ovn-central + TRIAGED/HIGH since 2022-03-23, charm-ovn-chassis NEW since 2024-03-27. Unfixed 4 years. + +========================================================================= +9. IS THE MIXED SHAPE RUN BY ANYONE? -- SEARCH PERIMETER STATED +========================================================================= +NOTHING FOUND, and here is the instrument with its positive control: + openstack-discuss HyperKitty q=prefer-ipv6 -> 0 (control: q=ceph -> 2483) + openstack-dev + openstack-operators: 168 monthly archives, 111 MB, 2013-2019, grepped + prefer-ipv6 -> 0 in all 168; ipv6 AND (charm|juju|maas) -> 0 lines + (control: 99 files contain ipv6 / 4982 lines; 41 contain juju) + opendev codesearch ipv6 in tests/bundles/ -> NO MATCHES (control: ceph-osd -> 35 repos) + opendev codesearch ipv6 in tests/tests.yaml -> NO MATCHES (control: bundle -> 88 repos) + zuul: ipv6 in charm .zuul.yaml -> ZERO charm-* repos + opendev codesearch: prefer-ipv6 in ANY .rst -> no matches (documented only in config.yaml) + charm-guide + charm-deployment-guide: grep -ci ipv6 = 0 on every page; the network-SPACES + page does not mention IPv6, dual-stack or address family at all + discourse.ubuntu.com / charmhub: two unanswered operator threads only + (charmhub 5265 2021-10-27, Canonical reply defers to the MAAS forum; + charmhub 17777 2025-05-16 "juju pick ipv6 for lxd deployments", ZERO replies) +VERDICT: zero IPv6 test bundles, zero IPv6 gate jobs, zero IPv6 documentation across the +entire OpenStack-charms ecosystem. Two operators (Bosi 2024, Marchand 2025) are visibly the +only people publicly exercising these code paths, and both did it by filing bugs. + +========================================================================= +10. CEPH VENDOR DOCS -- SQUID SAYS LESS THAN PACIFIC, NOT MORE +========================================================================= +squid doc/rados/configuration/msgr2.rst:86-91 + * ms_bind_ipv4 [default: true] controls whether a daemon binds to an IPv4 address + * ms_bind_ipv6 [default: false] controls whether a daemon binds to an IPv6 address +PACIFIC network-config-ref.rst:217-237 carried the explicit instruction, DELETED in quincy: + "Binding to IPv4 is enabled by default, so if you just add the option to bind to IPv6 + you'll actually put yourself into dual stack mode. If you want just IPv6, then disable + IPv4 and enable IPv6." +PACIFIC msgr2.rst:93-95, also DELETED in quincy: + ".. note:: ... dual-stack support is not yet supported as of Quincy v17.2.0." +Byte counts confirm deletion rather than replacement: network-config-ref.rst 17102 (pacific) +vs 13853 (quincy/reef/squid), "Dual Stack" 3/0/0/0. NO replacement statement was added. +So: BOTH flags required for v6-only is still correct; squid simply stopped saying it. +squid gives NO IPv6 CIDR example for public_network/cluster_network and no bracket guidance +for public_addr. + +========================================================================= +11. OVN / OVS -- VENDOR +========================================================================= +ovn-sb.xml:565 (branch-24.03, identical on main): "The IPv4 address of the encapsulation + tunnel endpoint." <- the SB schema documents the column as IPv4. +OVS vswitchd/vswitch.xml: geneve = "An Ethernet over Geneve ... IPv4/IPv6 tunnel"; + options:remote_ip = "An IPv4 or IPv6 address (not a DNS name)". No bracket form. +OVN controller/encaps.c passes encap->ip straight to smap_add(&options,"remote_ip",...) with + no parse/validation/bracket handling. OVN NEWS v2.6.0 (2016-09-27): "Added support for + IPv6 tunnels". +TEST COVERAGE: every ovn-encap-ip / encap-ip value in OVN 24.03's tests (ovn.at 1.6 MB / + 137 encap matches, system-ovn.at 186, ovn-controller.at 29, multinode.at) is IPv4. + NO test sets an IPv6 encap IP. +OVSDB remote syntax IS confirmed: Documentation/ref/ovsdb.7.rst:715-721 + "To specify an IPv6 address, wrap it in square brackets, e.g. ssl:[::1]:6640. Passive + IP-based connection methods by default listen for IPv4 connections only; use [::] as the + address to accept both IPv4 and IPv6 connections, e.g. pssl:6640:[::]" + ^ NOTE the passive default: an IPv6-only SB/NB listener needs pssl:6641:[::]. + +========================================================================= +12. MYSQL VENDOR +========================================================================= +dev.mysql.com/doc/refman/8.0/en/group-replication-ipv6.html: + "When specified with a port number, an IPv6 address must be specified in square brackets, + for example: group_replication_local_address= "[2001:db8:...:7348]:33061"" + group_replication_group_seeds uses the same bracketed form. + group_replication_ip_allowlist takes BARE addresses, no brackets (8.0.22+; before that + group_replication_ip_whitelist) -- which is what the charm builds, correctly. +So the server side is fine and the charm's allowlist is fine; the break is the mysqlsh / +mysqlrouter URI concatenation, which is upstream of Group Replication ever being reached. diff --git a/docs/changelog-20260731-snap-proxy-apply-ipv6.md b/docs/changelog-20260731-snap-proxy-apply-ipv6.md index 2e6a437..b23bb27 100644 --- a/docs/changelog-20260731-snap-proxy-apply-ipv6.md +++ b/docs/changelog-20260731-snap-proxy-apply-ipv6.md @@ -550,3 +550,116 @@ **Revert.** `git rm docs/audit/stage5-juju-client-blocker-20260801.txt` and drop the CURRENT-STATE paragraph. Nothing live was changed, so there is nothing else to undo. + +--- + +## Item 13 -- BUILD: the D-139 apex carve tool, DRY-RUN ONLY (nothing pushed) + +**What.** `netbox/d139-gua-carve.py` (232 lines) + `tests/d139-gua-carve/run-tests.sh` (151) + +`tests/d139-gua-carve/stub-apex.py` (100, an offline NetBox stand-in) + +`docs/audit/d139-carve-dryrun-20260801.txt` (97) + `tests/HARNESS-MANIFEST` 94 -> 95. + +**Why.** D-139 ruling B execution step 1. **Dry-run is the DEFAULT and the write path is +explicitly opted into**; nothing was written to NetBox in this session. + +**Live dry run, both DCs: `CREATE 22 | EXISTS 6 (untouched) | RETIRE-REPORT 18`.** The +`f0X:10::/60`, `f0X:10::/64` and `f0X:11::/64` provider-public rows already exist and are +correctly skipped rather than recreated. + +**THE REPORT-ONLY PASS EARNED ITS KEEP: it surfaced 52 DEPENDENT ip-addresses** living inside +the retiring ULA `/64`s -- 26 per DC, in `:220`/`:221` and `:320`/`:321` -- which a later +delete would ORPHAN. Those are the v6 VIP legs. 0 dependent ip-ranges. Retirement is +REPORTED, never executed, so this is a finding rather than an incident. + +**Proof no write occurred, asserted on CONTENT rather than on the tool's own say-so:** apex +prefix count **139 before and 139 after**, and none of the 22 CREATE rows exists in the apex. + +**Mutation-proven, 5 mutations, each turning a NAMED test red; tool restored and +sha256-verified byte-identical after each:** storage hextet `0x40`->`0x41` (T07/T08 red, and +the `/60`-alignment guard REFUSEs); `UA = "curl/8.5.0"` removed (T15 red, plus 42 others -- +the stub 403s exactly as the real apex does); the `SANDBOX_HOSTS` refusal neutered (T04 red, +"did not refuse the baldurkeep upstream"); the retire predicate widened from the ULA `/48` to +a GUA `/36` (T10 red, all 9 rows); read-back `gone = []` (T17 red). + +**Credential hygiene held:** only `vr1-netbox-sandbox.env` was sourced, never printed, and the +tool hard-REFUSES any host outside `{localhost, 127.0.0.1, 10.10.1.10}`, naming DOCFIX-195 +when it does. The two NetBox instances stayed separate throughout. + +**Charter compliance, since it was a graded requirement:** 232 lines, of which a **12-line +docstring** and **5 comment lines in the entire file** (longest block 2 lines). Zero +re-argued rationale, zero worked examples, zero alternatives-considered. The length is code -- +refusals, conflict detection, read-back, the write path -- not prose. **One soft criterion +missed and declared: `main()` is ~90 lines, so "one screen per function" does not hold there.** + +Harness 65/65; gauntlet ALL GREEN (95); repo-lint 0 fail. + +**Name deviation, deliberate:** `d139-gua-carve.py`, not `-import.py`. The D-NNN family in +`netbox/` is 3/3 without the suffix (`d115-office-carve.py`, `d120-compose-bands.py`, +`d124-transit-seed.py`); `-import` belongs to the `dc-*` family. + +**Revert.** `rm netbox/d139-gua-carve.py; rm -rf tests/d139-gua-carve` then +`bash scripts/run-tests-all.sh --record-manifest`. Nothing in the cloud or the apex changes +either way. + +--- + +## Item 14 -- D-139 RULING A IS NOT ACHIEVABLE AT THESE CHARM REVISIONS. Ruling B unaffected. + +**What.** `docs/audit/v6-only-charm-viability-20260801.txt` (355 lines) + +`docs/CURRENT-STATE.md`. No artifact changed, nothing actioned. + +**Method, and it is the one this repo learned the hard way:** the charms were DOWNLOADED and +READ. `prefer-ipv6` was researched the same way on 2026-07-31 and the research inverted its +own premise; the same discipline is why this result is trustworthy rather than plausible. + +**RE-VERIFIED INDEPENDENTLY by the main session** against the shipped `ceph-osd` rev 953 +artifact, because it is the claim that decides the ruling -- `hooks/utils.py:203-217`: +`get_host_ip()` returns `get_ipv6_addr()[0]` when `prefer-ipv6` is set, else runs +`socket.inet_aton(hostname)` and on failure `dns.resolver.query(hostname, 'A')` -- an +IPv4-ONLY lookup -- under the charm's OWN comment *"...just let it kill the hook"*. + +**So `ceph-osd` fails on a v6-only plane in BOTH directions**, which is what makes it a block +rather than a config question: `prefer-ipv6: false` -> `inet_aton` raises on a v6 literal -> +NXDOMAIN on an `IN A` lookup (LP #2109798, `New`, whose reporter's only workaround is +"forcing the ipv4 cidr"); `prefer-ipv6: true` -> bare `get_ipv6_addr()` (also +`ceph_hooks.py:547`, WITHOUT `dynamic_only=False`) which returns DYNAMIC/SLAAC addresses only, +while every VR1 node holds a MAAS **static** v6. That is LP #2061836 -- `Fix Committed` on +`charm-ceph-mon`, **`New` and never fixed on `charm-ceph-osd`**, confirmed absent from rev 953. + +**The other two families are reported from the capture and NOT re-verified here** -- recorded +as strong sourced observations, not as measured-here facts: `metal-internal`'s mysql/mysql- +router bare `user:pw@addr` URIs (invalid for an unbracketed v6 literal) and `hacluster`'s +corosync `ip_version: ipv4`, hard-written in 2.4/stable rev 166 released 2026-06-29 even +though LP #2111852 is `Fix Committed` (**Fix Committed is not Fix Released** -- a distinction +this repo has been bitten by before); and OVN documenting the encap column as *"The IPv4 +address of the encapsulation tunnel endpoint"* with zero v6 encap values anywhere in OVN +24.03's test suite. + +**Two things counted locally to size it:** `metal-internal` appears **273 times** in +`bundle.yaml` across ~44 applications, so it is the most pervasive binding in the deployment; +and **`rabbitmq-server` is deployed by the bundle but ABSENT from `PREFER_IPV6_CHARMS`** -- +the only mismatch across all 33 bundle pairs, on which invariant 9a would emit a factually +false message. + +**CONSEQUENCE, stated as a decision rather than actioned:** ruling A's five-plane v6-only +scope is blocked by UPSTREAM CHARM DEFECTS, not by anything in this deployment. **Ruling B is +ORTHOGONAL and STANDS** -- GUA-versus-ULA is independent of whether a plane keeps v4 -- so item +13's tool is unaffected and a GUA DUAL-STACK carve is achievable today. + +**THREE ITEMS OWED.** (a) The foundational measurement is STILL NOT TAKEN: **does +`network-get` return a v6 address on a v6-only bound space?** Whatever survives of the +storage/replication verdict rests on it. (b) **D-139's "remove the v4 subnets LAST, after each +is proven" is UNSATISFIABLE as written** -- per-plane conversion is atomic, so no state exists +in which a plane is proven v6-only while still holding v4. That clause needs rewriting under +any ruling. (c) The `rabbitmq-server` mismatch above. + +**PROCESS FAILURE, OWNED, AND THE MOST REUSABLE PART.** This research was written to +`/tmp/v6research/FINDINGS.txt` and **would have been lost at the session boundary**. The +agent was scoped read-only and given NO repo path for its findings -- **my error in writing +the mandate, not the agent's**. Rescued and sha256-verified identical. **Standing consequence: +every agent mandate must NAME A REPO PATH for its findings.** A read-only mandate still +produces a durable artifact; "read-only" constrains what it may CHANGE, not where it may +RECORD. + +**Revert.** `git rm docs/audit/v6-only-charm-viability-20260801.txt` and drop the +CURRENT-STATE paragraph. Nothing was actioned, so there is nothing else to undo. diff --git a/netbox/d139-gua-carve.py b/netbox/d139-gua-carve.py new file mode 100644 index 0000000..ff82b43 --- /dev/null +++ b/netbox/d139-gua-carve.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +""" +Push the D-139 ruling-B IPv6 plane carve into the VR1 IPAM apex and REPORT the ULA +rows it retires. Prefixes only: no addresses, no MAAS, and NO deletion ever. + + . ~/vr1-office1-creds/vr1-netbox-sandbox.env # the WORKING VR1 apex (DOCFIX-195) + python3 netbox/d139-gua-carve.py --dc vr1-dc0 # DRY RUN (the default) + python3 netbox/d139-gua-carve.py --dc vr1-dc0 --commit # writes, then reads back + +Retirement is REPORTED ONLY -- D-139 sequences the ULA/v4 removals LAST, after each +plane is proven, and each is separately gated. + +Exit: 0 ok | 1 write/read-back error | 2 REFUSE (could not evaluate). +""" +import argparse +import ipaddress +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +UA = "curl/8.5.0" # the apex 403s other UAs and it reads as an auth failure -- platform-traps.md +SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.10"} + +# --- D-139 ruling B (FLAGGED for operator reconsideration at 633da1c -- its stated +# deciding reason was refuted by measurement; the table is D-139 as it stands today) -- +DC_GUA = {"vr1-dc0": "2602:f3e2:f02::/48", "vr1-dc1": "2602:f3e2:f03::/48"} +# (4th hextet, apex role slug, D-139 plane label, owns a /60 parent) +CARVE = [ + (0x10, "provider-public", "provider-public", True), + (0x11, "provider-public", "provider-public VIP", False), + (0x20, "metal-admin", "metal-admin", True), + (0x21, "metal-internal", "metal-internal", False), + (0x30, "data-tenant", "data-tenant", True), + (0x40, "storage", "storage", True), + (0x50, "replication", "replication", True), + (0x80, "lbaas-mgmt", "lb-mgmt", True), +] +RETIRED_ULA_48 = "fd50:840e:74e2::/48" # retired FOR VR1; still a valid org aggregate +STATUS = "active" # measured on all 44 VR1 plane rows, incl. the ULA rows being replaced + + +def die(msg): + print("REFUSE: %s" % msg, file=sys.stderr) + sys.exit(2) + + +class NB: + def __init__(self, url, token): + self.url, self.token = url.rstrip("/"), token + + def _req(self, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + r = urllib.request.Request( + self.url + "/api" + path, data=data, method=method, + headers={"Authorization": "Token " + self.token, "User-Agent": UA, + "Accept": "application/json", "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(r, timeout=45) as resp: + return json.loads(resp.read() or "null") + except urllib.error.HTTPError as e: + raise RuntimeError("%s %s -> %d %r" % (method, path, e.code, e.read()[:300])) + + def get_all(self, path): + out, nxt = [], path + "?limit=500" + while nxt: + d = self._req("GET", nxt) + out += d.get("results", []) + n = d.get("next") + nxt = n.split("/api", 1)[1] if n else None + return out + + def post(self, path, body): + return self._req("POST", path, body) + + +def scope_slug(p): + """The live API returns scope.slug ('vr1-dc0') and scope.name ('VR1 DC0'); repo dumps + normalise name to the slug. Matching name alone found ZERO live (dc-plane-apex-import).""" + sc = p.get("scope") or {} + return sc.get("slug") or sc.get("name") or "" + + +def sub_at(base48, nn, plen): + """Direct arithmetic, never list(subnets()) -- the DOCFIX-181 no-hang property.""" + if nn % 0x10 and plen == 60: + die("internal: /60 parent at hextet %#x is not /60-aligned" % nn) + return ipaddress.ip_network((int(base48.network_address) + (nn << 64), plen)) + + +def build_plan(dc, site, prefixes): + base48 = ipaddress.ip_network(DC_GUA[dc]) + by_cidr = {} + for p in prefixes: + by_cidr.setdefault(str(ipaddress.ip_network(p["prefix"])), []).append(p) + + create, exists, conflict = [], [], [] + for nn, role, label, parent in CARVE: + for plen in ((60, 64) if parent else (64,)): + cidr = str(sub_at(base48, nn, plen)) + rows = by_cidr.get(cidr, []) + mine = [p for p in rows + if scope_slug(p) == dc and ((p.get("role") or {}).get("slug")) == role] + if mine: + exists.append((cidr, role, label)) + elif rows: + conflict.append((cidr, role, [(scope_slug(p) or "-", + (p.get("role") or {}).get("slug") or "-") + for p in rows])) + else: + create.append({"prefix": cidr, "status": STATUS, "role": role, + "scope_type": "dcim.site", "scope_id": site["id"], + "description": "%s %s (GUA /%d; D-139)" + % (site["name"], label, plen)}) + return create, exists, conflict + + +def build_retire(dc, prefixes, addrs, ranges): + ula = ipaddress.ip_network(RETIRED_ULA_48) + scoped = [p for p in prefixes if scope_slug(p) == dc] + if not scoped: + die("the apex returned ZERO prefixes scoped to %s. A filtered or failed read looks " + "exactly like a virgin DC -- refusing to call every plane 'missing' on that " + "evidence (MEMORY: instrument currency before negatives)." % dc) + retire = [] + for p in scoped: + net = ipaddress.ip_network(p["prefix"]) + if net.version == 6 and net.subnet_of(ula): + retire.append(p) + nets = [ipaddress.ip_network(p["prefix"]) for p in retire] + dep_a = sum(1 for a in addrs + if any(ipaddress.ip_interface(a["address"]).ip in n for n in nets)) + dep_r = sum(1 for r in ranges + if any(ipaddress.ip_interface(r["start_address"]).ip in n for n in nets)) + return sorted(retire, key=lambda p: ipaddress.ip_network(p["prefix"])), dep_a, dep_r, len(scoped) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + ap.add_argument("--dc", required=True, choices=sorted(DC_GUA)) + ap.add_argument("--commit", action="store_true", + help="WRITE the CREATE set. Default is a DRY RUN that writes nothing.") + a = ap.parse_args() + + url, token = os.environ.get("NETBOX_URL", ""), os.environ.get("NETBOX_TOKEN", "") + if not url or not token: + die("set NETBOX_URL and NETBOX_TOKEN from the env (never argv -- it would land in " + "shell history): . ~/vr1-office1-creds/vr1-netbox-sandbox.env") + host = urllib.parse.urlsplit(url).hostname or "" + if host not in SANDBOX_HOSTS: + die("'%s' is not the VR1 working apex. office1-netbox (10.10.1.10) takes ALL VR1 " + "reads and writes; netbox.baldurkeep.com is the v1 REFERENCE and stays " + "untouched (DOCFIX-195)." % host) + + nb = NB(url, token) + try: + sites = nb.get_all("/dcim/sites/") + roles = nb.get_all("/ipam/roles/") + prefixes = nb.get_all("/ipam/prefixes/") + addrs = nb.get_all("/ipam/ip-addresses/") + ranges = nb.get_all("/ipam/ip-ranges/") + except RuntimeError as e: + die("apex read failed, so NOTHING here is evaluable: %s -- a 403 is usually the " + "User-Agent trap (this tool pins %s), not a bad token." % (e, UA)) + + site = [s for s in sites if s["slug"] == a.dc] + if len(site) != 1: + die("expected exactly 1 apex site with slug %s, found %d" % (a.dc, len(site))) + site = site[0] + + role_ids = {r["slug"]: r["id"] for r in roles} + missing = sorted({r for _n, r, _l, _p in CARVE if r not in role_ids}) + if missing: + die("apex IPAM role(s) %s do not exist. This tool does not create roles " + "(netbox/roles-aggregates-import.py does); refusing to half-carve a DC." % missing) + + create, exists, conflict = build_plan(a.dc, site, prefixes) + retire, dep_a, dep_r, n_scoped = build_retire(a.dc, prefixes, addrs, ranges) + + print("== d139-gua-carve %s %s ==" % (a.dc, "--commit" if a.commit else "(DRY RUN)")) + print(" apex: %s site: %s (id=%d)" % (url, site["name"], site["id"])) + print(" apex prefixes scoped to %s: %d (read this run)" % (a.dc, n_scoped)) + print(" CREATE %d | EXISTS %d | RETIRE-REPORT %d" % (len(create), len(exists), len(retire))) + + if conflict: + for cidr, role, rows in conflict: + print(" [CONFLICT] %s wants role=%s scope=%s but the apex holds %s" + % (cidr, role, a.dc, rows), file=sys.stderr) + die("%d D-139 prefix(es) exist under a DIFFERENT site or role. That is an " + "unrecognised state, not a no-op -- resolve it by hand." % len(conflict)) + + for title, rows in ( + ("CREATE", [(c["prefix"], c["role"], c["description"]) for c in create]), + ("EXISTS (idempotent -- left untouched)", exists), + ("RETIRE -- REPORTED ONLY, this tool never deletes", + [(p["prefix"], (p.get("role") or {}).get("slug"), p.get("description", "")) + for p in retire])): + print("\n %s:" % title) + for cidr, role, note in rows: + print(" %-24s role=%-16s %s" % (cidr, role, note)) + print(" dependent objects inside those prefixes: %d ip-address(es), %d ip-range(s)" + % (dep_a, dep_r)) + + if not a.commit: + print("\nDRY RUN -- nothing was written. Re-run with --commit to apply.") + return 0 + + errs = 0 + for c in create: + body = dict(c, role=role_ids[c["role"]]) + try: + nb.post("/ipam/prefixes/", body) + except RuntimeError as e: + print(" [ERR] %s: %s" % (c["prefix"], e), file=sys.stderr) + errs += 1 + # READ BACK: a POST that reports success but did not land is a class this repo has + # been bitten by (opnsense-plugins.sh apply always silently dry-ran). + now = {str(ipaddress.ip_network(p["prefix"])) for p in nb.get_all("/ipam/prefixes/") + if scope_slug(p) == a.dc} + gone = [c["prefix"] for c in create if c["prefix"] not in now] + for g in gone: + print(" [MISSING AFTER WRITE] %s" % g, file=sys.stderr) + print("\n READ-BACK: %d/%d present" % (len(create) - len(gone), len(create))) + errs += len(gone) + print("RESULT: errors=%d" % errs) + return 1 if errs else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/HARNESS-MANIFEST b/tests/HARNESS-MANIFEST index 4248d1c..4439b82 100644 --- a/tests/HARNESS-MANIFEST +++ b/tests/HARNESS-MANIFEST @@ -13,6 +13,7 @@ d115-office-carve d120-compose-bands d124-transit-seed +d139-gua-carve dc-cache-proxy dc-dc-ceph-disk-budget dc-dc-dr-drill diff --git a/tests/d139-gua-carve/run-tests.sh b/tests/d139-gua-carve/run-tests.sh new file mode 100755 index 0000000..23c6981 --- /dev/null +++ b/tests/d139-gua-carve/run-tests.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# tests/d139-gua-carve/run-tests.sh +# +# Harness for netbox/d139-gua-carve.py. OFFLINE -- every apex call goes to +# stub-apex.py on 127.0.0.1, never to 10.10.1.10 and never to baldurkeep. +# The values are the point: every CIDR below is D-139 ruling B verbatim, and a +# "tidy-up" that shifts one silently renumbers a plane. +# ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +S="$(cd "$HERE/../../netbox" && pwd)/d139-gua-carve.py" +TMP="$(mktemp -d)"; PORT=""; STUB_PID="" +pass=0; fail=0 +ok() { pass=$((pass+1)); } +bad() { fail=$((fail+1)); echo " FAIL: $1"; } +cleanup() { [ -n "$STUB_PID" ] && kill "$STUB_PID" 2>/dev/null; rm -rf "$TMP"; } +trap cleanup EXIT + +command -v python3 >/dev/null 2>&1 || { echo "FAIL: python3 required"; exit 1; } + +start_stub() { # $1 = STUB_MODE; sets PORT, truncates the write log + [ -n "$STUB_PID" ] && { kill "$STUB_PID" 2>/dev/null; wait "$STUB_PID" 2>/dev/null; } + : > "$TMP/writes.log" + STUB_MODE="$1" STUB_WRITELOG="$TMP/writes.log" python3 "$HERE/stub-apex.py" >"$TMP/port" 2>"$TMP/stub.err" & + STUB_PID=$! + for _ in $(seq 1 50); do PORT="$(cat "$TMP/port" 2>/dev/null)"; [ -n "$PORT" ] && break; sleep 0.1; done + [ -n "$PORT" ] || { echo "FAIL: stub apex did not start"; exit 1; } +} +run() { # $@ -> tool args; captures stdout+stderr in $OUT, rc in $RC + OUT="$(NETBOX_URL="http://127.0.0.1:$PORT" NETBOX_TOKEN=stub python3 "$S" "$@" 2>&1)"; RC=$? +} +has() { printf '%s' "$OUT" | grep -qF "$1"; } + +python3 -c "import ast;ast.parse(open('$S').read())" 2>/dev/null && ok || bad "T01 does not parse" +python3 "$S" --help >/dev/null 2>&1 && ok || bad "T02 --help -> 0" + +# T03 no credentials -> REFUSE(2). "Could not look" must never read as "nothing there". +NETBOX_URL= NETBOX_TOKEN= python3 "$S" --dc vr1-dc0 >/dev/null 2>&1 +[ $? -eq 2 ] && ok || bad "T03 missing NETBOX_URL/TOKEN must REFUSE with rc=2" + +# T04 the two-instance hazard: the v1 upstream is READ-ONLY for VR1 (DOCFIX-195). +o="$(NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN=x python3 "$S" --dc vr1-dc0 --commit 2>&1)" +printf '%s' "$o" | grep -q "DOCFIX-195" && ok || bad "T04 did not refuse the baldurkeep upstream" + +start_stub normal + +# T05/T06 dry run is the DEFAULT and it writes NOTHING. T06 is the assertion that +# proves it: the stub logs every non-GET, and that log must still be empty. +run --dc vr1-dc0 +[ "$RC" -eq 0 ] && has "DRY RUN -- nothing was written" && ok || bad "T05 dry run rc=0 + announcement" +[ ! -s "$TMP/writes.log" ] && ok || bad "T06 dry run issued a WRITE to the apex: $(cat "$TMP/writes.log")" + +# T07 counts, measured against the fixture (11 new + 3 pre-existing + 9 retiring per DC). +has "CREATE 11 | EXISTS 3 | RETIRE-REPORT 9" && ok || bad "T07 dc0 plan counts changed" + +# T08 the D-139 ruling B dc0 carve, verbatim, with the role each prefix binds to. +while read -r cidr role; do + printf '%s' "$OUT" | grep -q "^ $cidr *role=$role" && ok || bad "T08 dc0 CREATE missing $cidr role=$role" +done <<'EOF' +2602:f3e2:f02:20::/60 metal-admin +2602:f3e2:f02:20::/64 metal-admin +2602:f3e2:f02:21::/64 metal-internal +2602:f3e2:f02:30::/60 data-tenant +2602:f3e2:f02:30::/64 data-tenant +2602:f3e2:f02:40::/60 storage +2602:f3e2:f02:40::/64 storage +2602:f3e2:f02:50::/60 replication +2602:f3e2:f02:50::/64 replication +2602:f3e2:f02:80::/60 lbaas-mgmt +2602:f3e2:f02:80::/64 lbaas-mgmt +EOF + +# T09 IDEMPOTENCE. provider-public :10/:11 are already built; they must be reported +# EXISTS and must not appear in the CREATE block. +c="$(printf '%s' "$OUT" | sed -n '/^ CREATE:/,/^$/p')" +for cidr in "2602:f3e2:f02:10::/60" "2602:f3e2:f02:10::/64" "2602:f3e2:f02:11::/64"; do + printf '%s' "$c" | grep -qF "$cidr" && bad "T09 would re-create existing $cidr" || ok + printf '%s' "$OUT" | sed -n '/^ EXISTS/,/^$/p' | grep -qF "$cidr" && ok || bad "T09 $cidr not reported EXISTS" +done + +# T10 RETIRE is a REPORT. It names the nine dc0 ULA rows, says it deletes nothing, +# and counts what would be orphaned (13 statics in each of :220 and :221). +has "RETIRE -- REPORTED ONLY, this tool never deletes" && ok || bad "T10 retire section is not marked report-only" +r="$(printf '%s' "$OUT" | sed -n '/^ RETIRE/,$p')" +for cidr in "fd50:840e:74e2:220::/60" "fd50:840e:74e2:220::/64" "fd50:840e:74e2:221::/64" \ + "fd50:840e:74e2:230::/60" "fd50:840e:74e2:230::/64" "fd50:840e:74e2:240::/60" \ + "fd50:840e:74e2:240::/64" "fd50:840e:74e2:250::/60" "fd50:840e:74e2:250::/64"; do + printf '%s' "$r" | grep -qF "$cidr" && ok || bad "T10 retire list is missing $cidr" +done +printf '%s' "$r" | grep -qF "26 ip-address(es), 0 ip-range(s)" && ok \ + || bad "T10 dependent-object count wrong -- the orphaned statics are the retirement's real cost" + +# T11 SCOPE of the retirement. D-139 sequences the v4 removals LAST and separately; +# the org ULA aggregate and the global fd00::/8 are not VR1 planes. +printf '%s' "$r" | grep -q "10\.12\." && bad "T11 a v4 plane was swept into the retirement" || ok +printf '%s' "$r" | grep -qF "fd50:840e:74e2::/48" && bad "T11 swept the org ULA AGGREGATE" || ok +printf '%s' "$r" | grep -qF "fd00::/8" && bad "T11 swept the global fd00::/8" || ok +printf '%s' "$r" | grep -qF "fd50:840e:74e2:320::" && bad "T11 dc0 run listed a dc1 ULA row" || ok + +# T12 dc1 -- the other half of ruling B, same shape, different /48. +run --dc vr1-dc1 +[ "$RC" -eq 0 ] && has "CREATE 11 | EXISTS 3 | RETIRE-REPORT 9" && ok || bad "T12 dc1 plan counts changed" +for cidr in "2602:f3e2:f03:20::/60" "2602:f3e2:f03:20::/64" "2602:f3e2:f03:21::/64" \ + "2602:f3e2:f03:30::/60" "2602:f3e2:f03:30::/64" "2602:f3e2:f03:40::/60" \ + "2602:f3e2:f03:40::/64" "2602:f3e2:f03:50::/60" "2602:f3e2:f03:50::/64" \ + "2602:f3e2:f03:80::/60" "2602:f3e2:f03:80::/64"; do + printf '%s' "$OUT" | sed -n '/^ CREATE:/,/^$/p' | grep -qF "$cidr" && ok || bad "T12 dc1 CREATE missing $cidr" +done +printf '%s' "$OUT" | grep -qF "2602:f3e2:f02:" && bad "T12 dc1 run leaked a dc0 prefix" || ok +[ ! -s "$TMP/writes.log" ] && ok || bad "T12 dc1 dry run issued a WRITE" + +# T13 a missing IPAM role REFUSES rather than half-carving the DC. +start_stub no-role +run --dc vr1-dc0 +[ "$RC" -eq 2 ] && has "lbaas-mgmt" && ok || bad "T13 missing role must REFUSE(2) and name the role" +[ ! -s "$TMP/writes.log" ] && ok || bad "T13 wrote to the apex despite refusing" + +# T14 a D-139 prefix already present under a DIFFERENT site/role is an UNRECOGNISED +# state. Skipping it as "exists" would silently accept a mis-bound datacenter. +start_stub conflict +run --dc vr1-dc0 +[ "$RC" -eq 2 ] && has "CONFLICT" && ok || bad "T14 mis-bound prefix must REFUSE(2), not read as EXISTS" + +# T15 the User-Agent pin is LOAD-BEARING, proven by mutating a COPY: the stub 403s +# any other UA, exactly as the apex does. Without the pin this tool cannot read at all. +start_stub normal +sed 's|^UA = "curl/8.5.0"|UA = "python-urllib/3"|' "$S" > "$TMP/mutant.py" +cmp -s "$S" "$TMP/mutant.py" && bad "T15 UA line not found -- the pin may have been renamed" || ok +OUT="$(NETBOX_URL="http://127.0.0.1:$PORT" NETBOX_TOKEN=stub python3 "$TMP/mutant.py" --dc vr1-dc0 2>&1)"; RC=$? +[ "$RC" -eq 2 ] && has "User-Agent trap" && ok || bad "T15 a wrong-UA build did not REFUSE with the UA hint" + +# T16 an empty scoped read is NOT a virgin DC. 22 confident CREATEs off a failed +# query is the exact shape of this repo's recorded negative-misread class. +grep -q "instrument currency before negatives" "$S" && ok || bad "T16 lost the zero-scoped-read refusal" +grep -q 'if not scoped:' "$S" && ok || bad "T16 zero-scoped-read guard is gone" + +# T17 the READ-BACK is not decoration. The stub answers every POST 201 but never +# adds the row -- the opnsense-plugins.sh class, where apply reported success and +# had silently dry-run. --commit must catch all 11 and exit 1. (127.0.0.1 only.) +start_stub normal +run --dc vr1-dc0 --commit +[ "$RC" -eq 1 ] && ok || bad "T17 --commit rc=$RC after a write that did not land (want 1)" +has "MISSING AFTER WRITE" && ok || bad "T17 read-back did not notice the writes never landed" +has "READ-BACK: 0/11 present" && ok || bad "T17 read-back tally wrong" +[ "$(wc -l < "$TMP/writes.log")" = "11" ] && ok || bad "T17 posted $(wc -l < "$TMP/writes.log") rows, want 11" +[ "$(grep -c '^POST /api/ipam/prefixes/$' "$TMP/writes.log")" = "11" ] && ok || bad "T17 wrote to an endpoint other than ipam/prefixes" + +echo +total=$((pass+fail)) +if [ "$fail" -eq 0 ]; then echo "d139-gua-carve: $pass/$total PASS"; exit 0; fi +echo "d139-gua-carve: $fail/$total FAIL"; exit 1 diff --git a/tests/d139-gua-carve/stub-apex.py b/tests/d139-gua-carve/stub-apex.py new file mode 100644 index 0000000..b6df9ac --- /dev/null +++ b/tests/d139-gua-carve/stub-apex.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Offline stand-in for the VR1 apex. Binds 127.0.0.1:0 and prints the port on stdout +(two other agents share this jumphost -- a fixed port is a flaky-test generator). + +Fixture rows mirror the state MEASURED live 2026-08-01 (docs/audit/d139-carve-dryrun- +20260801.txt). STUB_MODE: normal | no-role | conflict. Every non-GET is appended to +$STUB_WRITELOG -- the harness asserts that file stays EMPTY through a dry run. +""" +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +MODE = os.environ.get("STUB_MODE", "normal") +WRITELOG = os.environ.get("STUB_WRITELOG", "/dev/null") + +SITES = [{"id": 8, "slug": "vr1-dc0", "name": "VR1 DC0"}, + {"id": 9, "slug": "vr1-dc1", "name": "VR1 DC1"}] +ROLES = [{"id": i, "slug": s} for i, s in enumerate( + ["dc", "provider-public", "metal-admin", "metal-internal", "data-tenant", + "storage", "replication", "lbaas-mgmt", "edge", "transit"], start=1) + if not (MODE == "no-role" and s == "lbaas-mgmt")] + + +def row(cidr, dc, role, desc): + site = next((s for s in SITES if s["slug"] == dc), None) + return {"prefix": cidr, "status": {"value": "active"}, + "role": {"slug": role} if role else None, + "scope": {"slug": site["slug"], "name": site["name"]} if site else None, + "description": desc} + + +PREFIXES = [{"prefix": "fd50:840e:74e2::/48", "role": None, "scope": None, + "description": "org ULA /48 -- an aggregate, NOT a VR1 plane"}, + {"prefix": "fd00::/8", "role": {"slug": "private"}, "scope": None, + "description": "Unique Local Addresses (ULA)"}] +for dc, g, u in (("vr1-dc0", "2602:f3e2:f02", "fd50:840e:74e2:2"), + ("vr1-dc1", "2602:f3e2:f03", "fd50:840e:74e2:3")): + n = dc[-1] + PREFIXES.append(row("%s::/48" % g, dc, "dc", "VR1 Datacenter %s" % n)) + PREFIXES += [row("%s:10::/60" % g, dc, "provider-public", "VR1 DC%s provider-public (GUA /60; D-101/D-111)" % n), + row("%s:10::/64" % g, dc, "provider-public", "VR1 DC%s provider-public (GUA /64 active; D-101/D-111)" % n), + row("%s:11::/64" % g, dc, "provider-public", "VR1 DC%s provider-public (GUA VIP /64; D-101/D-111)" % n)] + for nn, role in (("20", "metal-admin"), ("30", "data-tenant"), + ("40", "storage"), ("50", "replication")): + PREFIXES.append(row("%s%s::/60" % (u, nn), dc, role, "VR1 DC%s %s (ULA /60; D-101/D-111)" % (n, role))) + PREFIXES.append(row("%s%s::/64" % (u, nn), dc, role, "VR1 DC%s %s (ULA /64 active; D-101/D-111)" % (n, role))) + PREFIXES.append(row("%s21::/64" % u, dc, "metal-internal", "VR1 DC%s metal-internal (ULA /64 active; D-101/D-111)" % n)) + v4 = "10.12.%d" % (4 if dc == "vr1-dc0" else 64) + PREFIXES.append(row("%s.0/22" % v4, dc, "provider-public", "VR1 DC%s provider-public (v4; D-101)" % n)) + PREFIXES.append(row("172.30.%s.0/24" % (int(n) + 2), dc, "edge", "%s simulated-ISP edge WAN (D-115)" % dc)) + PREFIXES.append(row("172.31.0.%d/30" % (int(n) * 4), dc, "transit", "office1<->dc%s transit (D-124)" % n)) +if MODE == "conflict": + PREFIXES.append(row("2602:f3e2:f02:20::/64", "vr1-dc1", "storage", "mis-bound")) + +ADDRESSES = [{"address": "%s::%d/64" % (p, o), "description": "VIP"} + for p in ("fd50:840e:74e2:220", "fd50:840e:74e2:221", + "fd50:840e:74e2:320", "fd50:840e:74e2:321", + "2602:f3e2:f02:11", "2602:f3e2:f03:11") + for o in range(50, 63)] + [{"address": "10.10.1.10/24", "description": "netbox"}] +RANGES = [{"start_address": "10.12.4.4/22", "end_address": "10.12.4.49/22", + "description": "D-134 utility band"}] + +BODIES = {"/api/dcim/sites/": SITES, "/api/ipam/roles/": ROLES, + "/api/ipam/prefixes/": PREFIXES, "/api/ipam/ip-addresses/": ADDRESSES, + "/api/ipam/ip-ranges/": RANGES} + + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, code, obj): + b = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(b))) + self.end_headers() + self.wfile.write(b) + + def do_GET(self): + if self.headers.get("User-Agent") != "curl/8.5.0": + return self._send(403, {"detail": "WAF: rejected User-Agent"}) + body = BODIES.get(self.path.split("?")[0]) + if body is None: + return self._send(404, {"detail": "no such endpoint"}) + self._send(200, {"count": len(body), "next": None, "results": body}) + + def _write(self): + with open(WRITELOG, "a") as fh: + fh.write("%s %s\n" % (self.command, self.path)) + self._send(201, {"id": 999}) + + do_POST = do_PATCH = do_PUT = do_DELETE = _write + + +srv = HTTPServer(("127.0.0.1", 0), H) +sys.stdout.write("%d\n" % srv.server_address[1]) +sys.stdout.flush() +srv.serve_forever()