Newer
Older
openstack-caracal-dc-dc / tests / dc-edge-wan-import / test_logic.py
#!/usr/bin/env python3
"""
Behavioral tests for netbox/dc-edge-wan-import.py -- drives the REAL main()/preflight
against an in-memory fake NB (tests/dc-edge-wan-import/fake_netbox.py) injected at the
get_nb() seam. No live NetBox. Invoked by run-tests.sh alongside the static greps.

The properties pinned here map to this repo's documented IPAM bug classes:
  * HALF-WRITE (roles-aggregates-import.py): a later target's missing precondition
    must abort with ZERO creates, never "wrote dc0 then died".
  * SWAPPED SCOPE (the D-117 wrong-target near-miss): the vr1-dc0 /24 must bind
    vr1-dc0's id and the vr1-dc1 /24 must bind vr1-dc1's id -- not shared or swapped.
  * ALREADY-PRESENT = SKIP (idempotent), distinct from missing-infra = die.
  * DRY BY DEFAULT: no --commit writes NOTHING.
  * UPSTREAM-WRITE guard: --commit to a non-sandbox host refuses without the flag.
"""
from __future__ import annotations

import contextlib
import faulthandler
import importlib.util
import inspect
import io
import os
import sys

faulthandler.dump_traceback_later(30, exit=True)

HERE = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(os.path.dirname(HERE))
TARGET_PATH = os.path.join(REPO_ROOT, "netbox", "dc-edge-wan-import.py")
sys.path.insert(0, HERE)

import fake_netbox  # noqa: E402

P = 0
F = 0


def ok(label):
    global P
    P += 1
    print(f"PASS: {label}")


def no(label, detail=""):
    global F
    F += 1
    print(f"FAIL: {label}" + (f" ({detail})" if detail else ""))


def check(cond, label, detail=""):
    ok(label) if cond else no(label, detail)


def load_target():
    spec = importlib.util.spec_from_file_location("dc_edge_wan_import", TARGET_PATH)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


T = load_target()


@contextlib.contextmanager
def captured_stdout():
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        yield buf


def run_main(argv, fake):
    T.get_nb = lambda base=None, token=None: fake
    old = sys.argv
    sys.argv = ["dc-edge-wan-import.py"] + argv
    try:
        return T.main()
    finally:
        sys.argv = old


def run_dies(label, argv, fake):
    # swallow stdout AND stderr -- die() prints "FAIL: ..." to stderr by design, and
    # for an EXPECTED death that is not a test failure, just noise on the gauntlet.
    try:
        with contextlib.redirect_stderr(io.StringIO()), captured_stdout():
            run_main(argv, fake)
        no(label, "did not raise SystemExit")
    except SystemExit:
        ok(label)


# Full-precondition fixture builders (role + container + both DC sites present).
ROLE = {"slug": "edge", "name": "Edge", "id": 1}
CONTAINER = {"prefix": "172.30.0.0/16", "id": 10}
SITE_DC0 = {"slug": "vr1-dc0", "name": "VR1 DC0", "id": 20}
SITE_DC1 = {"slug": "vr1-dc1", "name": "VR1 DC1", "id": 21}


def full_fake(prefixes_extra=()):
    return fake_netbox.FakeNB(
        roles=[ROLE],
        prefixes=[CONTAINER, *prefixes_extra],
        sites=[SITE_DC0, SITE_DC1],
    )


os.environ["NETBOX_URL"] = "http://10.10.1.10:8000"   # a known sandbox (guard passes)
os.environ["NETBOX_TOKEN"] = "fake-token"

# -----------------------------------------------------------------------------
# 1. DRY RUN is the default and writes NOTHING.
# -----------------------------------------------------------------------------
fk = full_fake()
with captured_stdout() as out:
    rc = run_main([], fk)
check(rc == 0, "dry run rc==0", str(rc))
check(out.getvalue().count("would CREATE") == 2, "dry run PLANS both /24s",
      str(out.getvalue().count("would CREATE")))
check(out.getvalue().count("CREATED ") == 0, "dry run CREATES nothing (no CREATED line)")
check("DRY RUN" in out.getvalue(), "dry run says so, loudly")
check(len(fk.creates) == 0, "dry run left the NetBox untouched (0 writes)", str(len(fk.creates)))

# -----------------------------------------------------------------------------
# 2. --commit writes exactly the two /24s, correctly scoped (NO swap).
# -----------------------------------------------------------------------------
fk = full_fake()
with captured_stdout() as out:
    rc = run_main(["--commit"], fk)
check(rc == 0, "commit rc==0", str(rc))
check(len(fk.creates) == 2, "commit created exactly 2 prefixes", str(len(fk.creates)))
check(all(path == "ipam/prefixes" for path, _ in fk.creates),
      "commit wrote only ipam/prefixes (no stray sites/roles/container)")
by_prefix = {pl["prefix"]: pl for _, pl in fk.creates}
check(by_prefix.get("172.30.2.0/24", {}).get("scope_id") == 20,
      "SCOPE: 172.30.2.0/24 binds vr1-dc0's id (20)", str(by_prefix.get("172.30.2.0/24")))
check(by_prefix.get("172.30.3.0/24", {}).get("scope_id") == 21,
      "SCOPE: 172.30.3.0/24 binds vr1-dc1's id (21) -- not swapped",
      str(by_prefix.get("172.30.3.0/24")))
check(all(pl.get("scope_type") == "dcim.site" for pl in by_prefix.values()),
      "SCOPE: both /24s are dcim.site-scoped (matches office1-wan)")
check(all(pl.get("role") == 1 for pl in by_prefix.values()), "both /24s carry the edge role id")
check(all(pl.get("status") == "active" for pl in by_prefix.values()), "both /24s are status active")

# -----------------------------------------------------------------------------
# 3. Already-present = idempotent SKIP (both present -> 0 creates, exit clean).
# -----------------------------------------------------------------------------
fk = full_fake(prefixes_extra=[{"prefix": "172.30.2.0/24", "id": 30},
                               {"prefix": "172.30.3.0/24", "id": 31}])
with captured_stdout() as out:
    rc = run_main(["--commit"], fk)
check(rc == 0, "both-present idempotent run rc==0")
check(out.getvalue().count("EXISTS") == 2, "both-present run reports both as EXISTS")
check(len(fk.creates) == 0, "both-present run creates nothing new")

# -----------------------------------------------------------------------------
# 4. Partial-present: dc0 already there, dc1 absent -> creates ONLY dc1.
# -----------------------------------------------------------------------------
fk = full_fake(prefixes_extra=[{"prefix": "172.30.2.0/24", "id": 30}])
with captured_stdout():
    rc = run_main(["--commit"], fk)
check(rc == 0, "partial-present run rc==0")
check(len(fk.creates) == 1 and fk.creates[0][1]["prefix"] == "172.30.3.0/24",
      "partial-present run creates ONLY the missing 172.30.3.0/24",
      str([pl["prefix"] for _, pl in fk.creates]))

# -----------------------------------------------------------------------------
# 5. HALF-WRITE GUARD -- a LATER target's missing site aborts with ZERO creates.
#    role + container + vr1-dc0 present, but vr1-dc1 SITE absent.
# -----------------------------------------------------------------------------
fk = fake_netbox.FakeNB(roles=[ROLE], prefixes=[CONTAINER], sites=[SITE_DC0])
run_dies("half-write: missing vr1-dc1 site is REJECTED before any write", ["--commit"], fk)
check(len(fk.creates) == 0, "half-write: the rejected run wrote NOTHING (vr1-dc0 not created)")

# -----------------------------------------------------------------------------
# 6-8. Missing infra = die (each precondition), each writing nothing.
# -----------------------------------------------------------------------------
fk = fake_netbox.FakeNB(roles=[], prefixes=[CONTAINER], sites=[SITE_DC0, SITE_DC1])
run_dies("missing edge role is REJECTED", ["--commit"], fk)
check(len(fk.creates) == 0, "missing-role run wrote nothing")

fk = fake_netbox.FakeNB(roles=[ROLE], prefixes=[], sites=[SITE_DC0, SITE_DC1])
run_dies("missing 172.30.0.0/16 container is REJECTED", ["--commit"], fk)
check(len(fk.creates) == 0, "missing-container run wrote nothing")

fk = fake_netbox.FakeNB(roles=[ROLE], prefixes=[CONTAINER], sites=[SITE_DC1])
run_dies("missing vr1-dc0 site is REJECTED", ["--commit"], fk)
check(len(fk.creates) == 0, "missing-dc0-site run wrote nothing")

# -----------------------------------------------------------------------------
# 9. /24 OUTSIDE the container is rejected by the local subnet preflight.
# -----------------------------------------------------------------------------
_saved = T.TARGETS
T.TARGETS = [("10.0.0.0/24", "vr1-dc0", "bogus -- not inside 172.30.0.0/16")]
try:
    run_dies("a /24 outside the Edge container is REJECTED", ["--commit"], full_fake())
finally:
    T.TARGETS = _saved

# -----------------------------------------------------------------------------
# 10. Missing NETBOX_URL/TOKEN fails loud (does not guess a target).
# -----------------------------------------------------------------------------
_u = os.environ.pop("NETBOX_URL", None)
run_dies("missing NETBOX_URL fails loud", [], full_fake())
os.environ["NETBOX_URL"] = _u

# -----------------------------------------------------------------------------
# 11. UPSTREAM-WRITE guard: --commit to a non-sandbox host refuses without the flag,
#     proceeds with it. Sandbox needs no flag (covered above).
# -----------------------------------------------------------------------------
os.environ["NETBOX_URL"] = "https://netbox.baldurkeep.com"   # the production apex
fk = full_fake()
run_dies("upstream --commit WITHOUT --yes-write-upstream is REFUSED", ["--commit"], fk)
check(len(fk.creates) == 0, "the refused upstream --commit wrote NOTHING")

fk = full_fake()
with captured_stdout():
    rc = run_main(["--commit", "--yes-write-upstream"], fk)
check(rc == 0 and len(fk.creates) == 2,
      "upstream --commit --yes-write-upstream IS allowed and writes both /24s",
      str((rc, len(fk.creates))))
os.environ["NETBOX_URL"] = "http://10.10.1.10:8000"

# -----------------------------------------------------------------------------
# 12. Structural pins (a changed constant a behavioral test alone would miss).
# -----------------------------------------------------------------------------
check(T.ROLE_SLUG == "edge", "ROLE_SLUG is 'edge'")
check(T.CONTAINER == "172.30.0.0/16", "CONTAINER is 172.30.0.0/16")
check(T.STATUS == "active", "STATUS is active (like office1-wan .1/24)")
check([c for c, _, _ in T.TARGETS] == ["172.30.2.0/24", "172.30.3.0/24"],
      "TARGETS pins exactly the two DC /24s .2 and .3")
check([s for _, s, _ in T.TARGETS] == ["vr1-dc0", "vr1-dc1"],
      "TARGETS scopes .2->vr1-dc0 and .3->vr1-dc1 (no swap in the source)")
check(T.SANDBOX_HOSTS == {"localhost", "127.0.0.1", "10.10.1.10"},
      "SANDBOX_HOSTS matches the sibling importers")
check("get_nb" in dir(T) and callable(T.get_nb), "get_nb() injection seam exists")
_src = inspect.getsource(T)
check('User-Agent"' in _src and "curl/8.5.0" in _src, "UA-aware (WAF-safe User-Agent set)")

print()
if F == 0:
    print(f"test_logic: ALL PASS ({P} checks)")
    sys.exit(0)
print(f"test_logic: {F} FAIL of {P + F}")
sys.exit(1)