Newer
Older
openstack-caracal-dc-dc / netbox / dc-dc-prefixes-import.py
#!/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 (dc0 or dc1, 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

NAMING (D-117, 2026-07-13): VR1's DCs are **VR1 DC0** and **VR1 DC1**, matching
the NetBox apex, which already holds both site records. This script previously
called them dc1/dc2 -- an off-by-one against the apex that would have bound
VR1 DC0's prefixes to the site vr1-dc1 (i.e. the OTHER DC), silently, because
the script also used to write by default. Both defects are fixed; see D-117.
EVERY "DCn" below is REGION-QUALIFIED, because "DC0" is ambiguous across VR0
and VR1 and that ambiguity is exactly what produced the bug.

VR1 DC0 v4: INHERITS the **VR0 DC0** six-plane CIDRs unchanged (D-101 -- "the
first DC 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 the
DISTINCT NetBox site **vr1-dc0** -- NOT the existing **vr0-dc0** site, which is
the rehearsal environment those literals came from.

VR1 DC1 v4: D-101 explicitly states the second DC's supernet is "NetBox-assigned
... not hardcoded." No default is provided here -- DC1_V4_SUPERNET is a REQUIRED
environment variable with no fallback. Running with --dc dc1 and no
DC1_V4_SUPERNET set FAILS LOUD rather than guessing or reusing VR1 DC0's range.
(This env var was DC1_V4_SUPERNET before D-117; the old name is REJECTED with a
pointer rather than silently ignored.)

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 (D-111 -- aligned to the DEPLOYED VR0-DC0/Willamette NN
mnemonic; see the D-111 decision record + docs/dc-dc-netbox-buildout-scope.md
section 5.1. Supersedes the original contiguous-plane-index proposal):
  - Net-byte (NN) offsets match the live cloud: provider :10 (+ API VIP :11),
    metal :20/:21, data :30, storage :40, replication :50. Each plane gets a
    /60 container + a /64 active, mirroring the deployed template.
  - GUA: provider-public only (D-101 family matrix). Out of DC_GUA_PREFIX (the
    per-DC site /48, e.g. 2602:f3e2:f02::/48 for VR1 DC0): /60 + /64 active at :10,
    API-VIP /64 at :11.
  - ULA: the five internal planes, out of a per-DC /56 of the org ULA /48. The
    /56 index is the GUA site nibble (VR1 DC0 -> ...:02xx, VR1 DC1 -> ...:03xx) so the
    4th hextet reads DC.NN in BOTH families. metal-admin (:20) and metal-internal
    (:21) SHARE the metal /60 (at :20), exactly as provider (:10) / API-VIP (:11)
    share the provider /60. data-tenant :30, storage :40, replication :50 each
    get their own /60 + /64.
  - Addresses are computed by direct index arithmetic (_sub_at), NEVER
    list(...subnets()) enumeration -- the DOCFIX-181 no-hang property is preserved.

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:
    # DRY-RUN IS THE DEFAULT (D-117). Nothing is written without --commit.

    # VR1 DC0 (v4 inherited from VR0 DC0; only v6 top-level blocks required):
    NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN=<token> \\
        ORG_ULA_48=fd50:840e:74e2::/48 DC_GUA_PREFIX=2602:f3e2:f02::/48 \\
        python3 dc-dc-prefixes-import.py --dc dc0            # preview
    ... python3 dc-dc-prefixes-import.py --dc dc0 --commit   # write

    # VR1 DC1 (v4 supernet ALSO required, nothing defaulted):
    NETBOX_URL=... NETBOX_TOKEN=... \\
        ORG_ULA_48=fd50:840e:74e2::/48 DC_GUA_PREFIX=2602:f3e2:f03::/48 \\
        DC1_V4_SUPERNET=10.12.64.0/19 \\
        python3 dc-dc-prefixes-import.py --dc dc1 --commit

    # Verification block only (no plan, no writes):
    ... python3 dc-dc-prefixes-import.py --dc dc0 --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)

# -----------------------------------------------------------------------------
# VR1 DC site identity.
#
# HISTORY (kept deliberately -- this is the bug's own confession): the original
# comment here read "Slugs are new -- no prior convention existed in this repo
# (grepped opentofu/*.tf and the buildout design; none found)". It grepped the
# REPO. The convention lived in the APEX (NetBox), which is precisely where
# D-010/D-103 say naming authority lives. That is how the off-by-one got in.
# -----------------------------------------------------------------------------

# D-117: these slugs are the APEX's, verified live against netbox.baldurkeep.com
# on 2026-07-13 (vr1-dc0 id=43 -> 2602:f3e2:f02::/48; vr1-dc1 id=44 -> f03::/48).
# They are NOT a repo-local invention. Do not "fix" them to dc1/dc2 -- that is the
# exact off-by-one D-117 closed. Prior art for naming lives in the APEX, not here.
SITES = {
    "dc0": {"slug": "vr1-dc0", "name": "VR1 DC0"},
    "dc1": {"slug": "vr1-dc1", "name": "VR1 DC1"},
}

# 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.
VR0_DC0_TEMPLATE_V4 = {
    "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",
}

# VR1 DC1 (the SECOND DC) v4: NO default. From DC1_V4_SUPERNET (env), 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 holds exactly eight /22s -- six used, two spare; anything
# smaller than /19 FAILS LOUD).
DC1_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


class _PlannedSite:
    """A site that does not exist yet, in a run that is not allowed to create it.

    D-117: a dry run against a FRESH NetBox must still be able to show the whole
    plan -- that is the entire point of previewing against an empty apex. Bailing
    out with "no site id" would make --dry-run useless exactly when it matters
    most. Nothing is ever written from one of these: id is None, and every write
    path returns before touching the API."""
    def __init__(self, slug: str, name: str):
        self.id = None
        self.slug = slug
        self.name = name


def find_or_create_site(nb, slug: str, name: str, commit: bool):
    """D-117: a site is a WRITE. Without --commit we return a _PlannedSite stub
    instead of creating one, so the rest of the plan can still be printed."""
    site = nb.dcim.sites.get(slug=slug)
    if site is not None:
        return site, False
    if not commit:
        return _PlannedSite(slug, name), 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_dc1_supernet(cidr: str) -> ipaddress.IPv4Network:
    try:
        net = ipaddress.ip_network(cidr, strict=True)
    except ValueError as exc:
        die(f"DC1_V4_SUPERNET='{cidr}' is not a valid CIDR: {exc}")
    if net.version != 4:
        die(f"DC1_V4_SUPERNET='{cidr}' is not an IPv4 prefix")
    if net.prefixlen > DC1_MIN_SUPERNET_PREFIXLEN:
        die(
            f"DC1_V4_SUPERNET='{cidr}' (/{net.prefixlen}) is too small to hold "
            f"six /22 planes -- need at least a /{DC1_MIN_SUPERNET_PREFIXLEN}"
        )
    tmpl_nets = [ipaddress.ip_network(c) for c in VR0_DC0_TEMPLATE_V4.values()]
    for d1 in tmpl_nets:
        if net.overlaps(d1):
            die(
                f"DC1_V4_SUPERNET='{cidr}' overlaps VR1 DC0's inherited range {d1} -- "
                f"D-101 requires VR1 DC1 to be non-overlapping. Choose a different supernet."
            )
    return net


def carve_dc1_v4(supernet: ipaddress.IPv4Network) -> dict:
    subnets = list(supernet.subnets(new_prefix=22))
    if len(subnets) < len(PLANE_ORDER):
        die(
            f"DC1_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)}


# D-111 alignment: lay the v6 legs out on the DEPLOYED VR0-DC0/Willamette NN
# net-byte mnemonic (provider :10 [+VIP :11], metal :20/:21, data :30, storage
# :40, replication :50), /60-per-plane container + /64 active, mirroring the
# live template. Replaces the earlier contiguous-plane-index subcarve. See the
# D-111 decision record + docs/dc-dc-netbox-buildout-scope.md section 5.1.
#
# Per-DC ULA /56 index aligned to the GUA site nibble (VR1 DC0 -> ...:02xx, VR1 DC1 ->
# ...:03xx) so the 4th hextet reads DC.NN in BOTH families.
DC_V6_INDEX = {"dc0": 0x02, "dc1": 0x03}


def _sub_at(base: ipaddress.IPv6Network, new_prefixlen: int, index: int) -> ipaddress.IPv6Network:
    """The index-th subnet of `base` at new_prefixlen, computed by direct
    arithmetic -- NOT list(base.subnets(...)) enumeration, which would
    materialize up to millions of objects and hang (DOCFIX-181)."""
    if new_prefixlen < base.prefixlen:
        die(f"internal: new_prefixlen /{new_prefixlen} shorter than base {base}")
    net_int = int(base.network_address) + index * (1 << (128 - new_prefixlen))
    return ipaddress.ip_network((net_int, new_prefixlen))


def carve_v6(dc: str, org_ula_48: ipaddress.IPv6Network, dc_gua_prefix: ipaddress.IPv6Network) -> list:
    """Return [(cidr, role_slug, kind)] for this DC's v6 legs on the deployed NN
    layout. provider-public = GUA out of DC_GUA_PREFIX; the five internal planes
    = ULA out of a per-DC /56 of the org ULA /48 (D-101 family matrix)."""
    ula = _sub_at(org_ula_48, 56, DC_V6_INDEX[dc])
    recs = [
        (str(_sub_at(dc_gua_prefix, 60, 0x10 >> 4)), "provider-public", "GUA /60"),
        (str(_sub_at(dc_gua_prefix, 64, 0x10)),      "provider-public", "GUA /64 active"),
        (str(_sub_at(dc_gua_prefix, 64, 0x11)),      "provider-public", "GUA VIP /64"),
        (str(_sub_at(ula, 60, 0x20 >> 4)),           "metal-admin",     "ULA metal /60 (admin+internal)"),
        (str(_sub_at(ula, 64, 0x20)),                "metal-admin",     "ULA /64 active"),
        (str(_sub_at(ula, 64, 0x21)),                "metal-internal",  "ULA /64 active (in metal /60)"),
    ]
    for plane, nn in (("data-tenant", 0x30), ("storage", 0x40), ("replication", 0x50)):
        recs.append((str(_sub_at(ula, 60, nn >> 4)), plane, "ULA /60"))
        recs.append((str(_sub_at(ula, 64, nn)),      plane, "ULA /64 active"))
    return recs


# -----------------------------------------------------------------------------
# NetBox writes
# -----------------------------------------------------------------------------


def create_or_report_prefix(nb, cidr: str, role_slug: str, site, description: str, update: bool, commit: bool) -> None:
    """D-117: DRY BY DEFAULT. Without commit=True this writes NOTHING -- it only
    prints the plan. This tool used to write by default, which is how an
    off-by-one site binding could have landed in the IPAM apex unannounced."""
    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:
        if not commit:
            print(f"  [dry-run] would CREATE prefix {cidr} role={role_slug} -> site {site.name}")
            return
        created = nb.ipam.prefixes.create(**payload)
        print(f"  CREATED prefix {cidr} (id={created.id}) role={role_slug}")
    elif update:
        if not commit:
            print(f"  [dry-run] would UPDATE prefix {cidr} (id={existing.id}) role={role_slug} -> site {site.name}")
            return
        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_recs: list) -> 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 (D-101 family matrix / D-111 NN layout):")
    for cidr, role_slug, kind in v6_recs:
        p = nb.ipam.prefixes.get(prefix=cidr)
        status = "MISSING" if p is None else f"OK (id={p.id})"
        print(f"  {role_slug.ljust(16)} {cidr.ljust(26)} {kind.ljust(24)} {status}")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
    parser.add_argument("--dc", required=True, choices=["dc0", "dc1"], help="Which VR1 DC to import (D-117: dc0|dc1, matching the NetBox apex. The old dc1|dc2 labels were off by one -- see D-117)")
    parser.add_argument("--update", action="store_true", help="Update existing prefixes in place (requires --commit)")
    parser.add_argument("--commit", action="store_true", help="WRITE to NetBox. Default is a DRY RUN that writes nothing (D-117 -- this tool used to write by default)")
    parser.add_argument("--verify-only", action="store_true", help="Skip the plan entirely; only print the verification block")
    args = parser.parse_args()

    # D-117: reject the pre-rename env var by NAME rather than ignoring it. An
    # operator with muscle memory would otherwise get "required env missing" and
    # have no idea the variable was renamed under them.
    if os.environ.get("DC2_V4_SUPERNET") and not os.environ.get("DC1_V4_SUPERNET"):
        die("DC2_V4_SUPERNET is set, but that name was RETIRED by D-117. VR1's "
            "second DC is now 'dc1' (not 'dc2'), so the variable is DC1_V4_SUPERNET. "
            "Re-export it under the new name -- refusing to guess.")

    dc = args.dc
    commit = args.commit
    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 == "dc0":
        v4 = dict(VR0_DC0_TEMPLATE_V4)
    else:
        second_supernet = validate_dc1_supernet(require_env("DC1_V4_SUPERNET", "D-101 explicitly requires the SECOND DC's v4 supernet be NetBox-assigned, not hardcoded (D-115 assigns 10.12.64.0/19)"))
        v4 = carve_dc1_v4(second_supernet)

    v6_recs = carve_v6(dc, org_ula_48, dc_gua_prefix)

    nb = get_nb()
    site, site_created = find_or_create_site(nb, site_cfg["slug"], site_cfg["name"], commit)
    if isinstance(site, _PlannedSite):
        print(f"[dry-run] would CREATE site '{site.name}' ({site.slug}) -- it does not exist yet.")
    else:
        print(f"Connected. Site '{site_cfg['name']}' (id={site.id}){' -- CREATED' if site_created else ''}.")

    if dc == "dc0":
        print(
            "\nNOTE: this may create prefixes identical to the existing 'vr0-dc0' "
            "site's entries (D-101: VR1 DC0 inherits VR0 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 VR1 DC0 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("\n*** DRY RUN -- nothing will be written. Re-run with --commit to apply. ***"
              if not commit else "\n*** COMMITTING to NetBox. ***")
        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, commit,
            )
        print("\nIPv6 prefixes (D-101 family matrix on the D-111 NN layout):")
        for cidr, role_slug, kind in v6_recs:
            create_or_report_prefix(
                nb, cidr, role_slug, site,
                f"VR1 {dc.upper()} {role_slug} ({kind}; D-101/D-111)",
                args.update, commit,
            )

    verify(nb, site, v4, v6_recs)
    print("\nDone.")
    return 0


if __name__ == "__main__":
    sys.exit(main())