feat(validation): server-side MO value validation — real-APIC 400 on bad values (v0.23.0)

Closes F10: the sim silently stored out-of-range/malformed scalar values
(e.g. vlan-9999, IP 999.888.777.x) that real APIC/NDO reject server-side.

New aci_sim/rest_aci/validators.py: table-driven RULES {(class,prop) -> validator}
(range/enum/IP·MAC-format/vlan-encap). Enforced in writes.py::_validate_planned
(APIC) and ndo/patch.py::apply_json_patch (NDO) before any store mutation.
Fail-safe default-allow: only registered (class,prop) validated; unknown pass.
Bad values now -> APIC Error 103 Malformed MO body. ~45 rules / ~18 classes.

Constraints sourced from vendored cisco.aci arg-specs + standard ACI ranges,
cross-checked against the real-machine var corpus for zero false-rejects
(regression: full create_tenant MS-TN1 + SF-TN1 chains stay green). +176 tests
(1098 passed). Referential integrity split out as F11 (needs real-APIC study).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zdUTqU9fvCCvF3uVGZsu1
This commit is contained in:
dtzp555-max
2026-07-09 09:59:34 +10:00
co-authored by Claude Opus 4.8
parent ffaf9964c2
commit fe3d13221a
8 changed files with 1353 additions and 29 deletions
+168
View File
@@ -0,0 +1,168 @@
"""F10 — server-side value validation, end-to-end through the APIC write
face (POST /api/mo/{dn}.json -> aci_sim/rest_aci/writes.py::_validate_planned
-> aci_sim/rest_aci/validators.py::RULES).
Complements tests/test_f10_validators.py (which exercises RULES entries
directly) by proving the FULL plumbing: a bad value 400s with the real APIC
error envelope and never lands in the store; the pre-existing 3
fvSubnet/l3extSubnet/vnsRedirectDest ip checks keep working byte-identically
now that they're migrated into the RULES registry
(tests/test_write_validation_and_children.py already covers that regression
directly — this file adds the NEW P1/P2 rules' end-to-end wiring)."""
from __future__ import annotations
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
TENANT = "F10TEST-T1"
@pytest.fixture(scope="module")
def client():
topo = load_topology(TOPO_PATH)
site = topo.sites[0]
store = build_site(topo, site)
state = ApicSiteState(
name=site.name, site=site, topo=topo,
store=store, baseline=copy.deepcopy(store),
)
c = TestClient(make_apic_app(state))
resp = c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200
_post(c, f"uni/tn-{TENANT}", {"fvTenant": {"attributes": {"name": TENANT}}})
return c
def _post(client, dn: str, body: dict):
return client.post(f"/api/mo/{dn}.json", json=body)
def _assert_not_in_store(client, cls: str, dn: str):
q = client.get(f"/api/class/{cls}.json").json()
assert not any(
i[cls]["attributes"]["dn"] == dn for i in q["imdata"]
), f"invalid {cls} must not land in the MIT"
# ---------------------------------------------------------------------------
# The exact F10 probe finding: encap=vlan-9999 / rtrId=999.888.777.x now 400.
# ---------------------------------------------------------------------------
def test_f10_probe_bad_encap_rejected(client):
dn = f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/lifp-IP1/rspathL3OutAtt-[topology/pod-1/paths-101/pathep-[eth1/1]]"
r = _post(
client, dn,
{"l3extRsPathL3OutAtt": {"attributes": {"encap": "vlan-9999", "dn": dn}}},
)
assert r.status_code == 400
err = r.json()["imdata"][0]["error"]["attributes"]
assert "vlan-9999" in err["text"]
# app.py's post_mo handler catches WriteValidationError with code="103"
# (the design doc's "code 107" citation is for a different catch site —
# the GET-query FilterParseError handlers at get_class/get_mo).
assert err["code"] == "103"
_assert_not_in_store(client, "l3extRsPathL3OutAtt", dn)
def test_f10_probe_bad_rtrid_rejected(client):
dn = f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/rsnodeL3OutAtt-[topology/pod-1/node-101]"
r = _post(
client, dn,
{"l3extRsNodeL3OutAtt": {"attributes": {"rtrId": "999.888.777.241", "dn": dn}}},
)
assert r.status_code == 400
err = r.json()["imdata"][0]["error"]["attributes"]
assert "999.888.777.241" in err["text"]
_assert_not_in_store(client, "l3extRsNodeL3OutAtt", dn)
def test_f10_valid_encap_and_rtrid_accepted(client):
dn1 = f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/lifp-IP1/rspathL3OutAtt-[topology/pod-1/paths-101/pathep-[eth1/2]]"
r = _post(
client, dn1,
{"l3extRsPathL3OutAtt": {"attributes": {"encap": "vlan-51", "mtu": "9000", "dn": dn1}}},
)
assert r.status_code == 200
dn2 = f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/rsnodeL3OutAtt-[topology/pod-1/node-102]"
r = _post(
client, dn2,
{"l3extRsNodeL3OutAtt": {"attributes": {"rtrId": "10.100.201.241", "dn": dn2}}},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# All-or-nothing: a bad value nested deep in a subtree must reject the WHOLE
# POST, leaving zero side effects (design §3.2 "preserves the existing
# all-or-nothing guarantee").
# ---------------------------------------------------------------------------
def test_bad_nested_asn_rejects_whole_subtree(client):
peer_dn = (
f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/lifp-IP1/"
f"paths-101/pathep-[eth1/3]]/peerP-[10.100.0.5]"
)
body = {
"bgpPeerP": {
"attributes": {"addr": "10.100.0.5", "dn": peer_dn},
"children": [
{"bgpAsP": {"attributes": {"asn": "0"}}}, # illegal: asn must be >= 1
],
}
}
r = _post(client, peer_dn, body)
assert r.status_code == 400
# The parent bgpPeerP must NOT have landed either — all-or-nothing.
_assert_not_in_store(client, "bgpPeerP", peer_dn)
def test_good_nested_asn_accepted(client):
peer_dn = (
f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/lifp-IP1/"
f"paths-101/pathep-[eth1/4]]/peerP-[10.100.0.6]"
)
body = {
"bgpPeerP": {
"attributes": {"addr": "10.100.0.6", "ttl": "2", "dn": peer_dn},
"children": [
{"bgpAsP": {"attributes": {"asn": "65100"}}},
],
}
}
r = _post(client, peer_dn, body)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# Fail-safe: an unregistered class or property is never validated, even with
# a wild value.
# ---------------------------------------------------------------------------
def test_unregistered_class_prop_passes_through_unvalidated(client):
dn = f"uni/tn-{TENANT}/ap-AP1"
r = _post(client, dn, {"fvAp": {"attributes": {"name": "AP1", "someUnknownProp": "!!not-validated!!", "dn": dn}}})
assert r.status_code == 200
def test_delete_skips_validation_even_with_bad_leftover_shape(client):
dn = f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/lifp-IP1/rspathL3OutAtt-[topology/pod-1/paths-101/pathep-[eth1/9]]"
r = _post(client, dn, {"l3extRsPathL3OutAtt": {"attributes": {"encap": "vlan-51", "dn": dn}}})
assert r.status_code == 200
# A delete carries only dn/status — no encap attr to (mis)validate.
r = _post(client, dn, {"l3extRsPathL3OutAtt": {"attributes": {"dn": dn, "status": "deleted"}}})
assert r.status_code == 200
+279
View File
@@ -0,0 +1,279 @@
"""F10 batch 3 — NDO value validation (aci_sim/ndo/patch.py::NDO_RULES /
_validate_ndo_op), per design doc §3.3.
NDO stores schema JSON (not APIC MOs); values arrive as JSON-Patch op values
rather than (class, prop) MO attrs, so this is validated separately from the
APIC RULES registry (tests/test_f10_validators.py,
tests/test_f10_apic_writeface.py) even though it reuses the same ``fmt``/
``rng`` factories. Design §3.3 scope is deliberately narrow: subnet ``ip``
and static-port VLAN (the real cisco.mso wire field is ``portEncapVlan`` —
see NDO_RULES's own comment in patch.py for why the design doc's "vlan"
placeholder was resolved to that name).
Both the whole-object-add shape (``.../subnets/-`` with a dict value) and
the single-field-replace shape (``.../subnets/0/ip`` with a scalar value)
are covered, since both are legal JSON-Patch forms even though only the
whole-object shape is what the real ansible-mso client sends today (per this
repo's existing PR-11/PR-12 test fixtures)."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.ndo.patch import NDO_RULES, PatchError, apply_json_patch
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
# ---------------------------------------------------------------------------
# Unit level: apply_json_patch directly against a bare doc dict.
# ---------------------------------------------------------------------------
def _bd_doc_with_subnets() -> dict:
return {
"sites": [
{
"siteId": "1",
"templateName": "LAB1",
"bds": [
{
"bdRef": "/schemas/schema-abc/templates/LAB1/bds/bd-App1_LAB1",
"subnets": [],
}
],
}
]
}
def test_ndo_subnet_ip_valid_accepted_whole_object_add():
doc = _bd_doc_with_subnets()
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "192.168.41.1/24", "scope": "public"},
}
]
apply_json_patch(doc, ops) # must not raise
assert doc["sites"][0]["bds"][0]["subnets"] == [{"ip": "192.168.41.1/24", "scope": "public"}]
def test_ndo_subnet_ip_zero_route_accepted():
"""0.0.0.0/0 (external-EPG default route) is a real canonical value —
see design §6 corpus table."""
doc = _bd_doc_with_subnets()
ops = [
{"op": "add", "path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-", "value": {"ip": "0.0.0.0/0"}}
]
apply_json_patch(doc, ops)
def test_ndo_subnet_bad_ip_rejected_whole_object_add():
doc = _bd_doc_with_subnets()
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "999.888.777.17/30", "scope": "public"},
}
]
with pytest.raises(PatchError, match="999.888.777.17/30"):
apply_json_patch(doc, ops)
# Reject must not have mutated the subnets list.
assert doc["sites"][0]["bds"][0]["subnets"] == []
def test_ndo_subnet_ip_single_field_replace_shape():
"""Direct scalar replace on an EXISTING item's field
(.../subnets/0/ip) — the other JSON-Patch shape _validate_ndo_op
supports (container is a dict, last=='ip')."""
doc = _bd_doc_with_subnets()
doc["sites"][0]["bds"][0]["subnets"] = [{"ip": "192.168.41.1/24", "scope": "public"}]
ops = [{"op": "replace", "path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/0/ip", "value": "192.168.41.2/24"}]
apply_json_patch(doc, ops)
assert doc["sites"][0]["bds"][0]["subnets"][0]["ip"] == "192.168.41.2/24"
ops_bad = [{"op": "replace", "path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/0/ip", "value": "999.888.777.2/24"}]
with pytest.raises(PatchError):
apply_json_patch(doc, ops_bad)
# Reject must not have overwritten the previously-good value.
assert doc["sites"][0]["bds"][0]["subnets"][0]["ip"] == "192.168.41.2/24"
def _epg_doc_with_static_ports() -> dict:
return {
"sites": [
{
"siteId": "1",
"templateName": "LAB1",
"anps": [
{
"anpRef": "/schemas/s/templates/LAB1/anps/app-Web_LAB1",
"epgs": [
{
"epgRef": "/schemas/s/templates/LAB1/anps/app-Web_LAB1/epgs/epg-web",
"staticPorts": [],
}
],
}
],
}
]
}
def test_ndo_static_port_vlan_valid_accepted():
doc = _epg_doc_with_static_ports()
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/staticPorts/-",
"value": {
"path": "topology/pod-1/protpaths-101-102/pathep-[ipg-vpc-LegacySW01]",
"portEncapVlan": 100,
"mode": "regular",
"deploymentImmediacy": "immediate",
"type": "vpc",
},
}
]
apply_json_patch(doc, ops)
static_ports = doc["sites"][0]["anps"][0]["epgs"][0]["staticPorts"]
assert len(static_ports) == 1
assert static_ports[0]["portEncapVlan"] == 100
def test_ndo_static_port_vlan_out_of_range_rejected():
"""F10-probe-shaped bad value: vlan-9999 equivalent on the NDO side."""
doc = _epg_doc_with_static_ports()
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/staticPorts/-",
"value": {
"path": "topology/pod-1/protpaths-101-102/pathep-[ipg-vpc-LegacySW01]",
"portEncapVlan": 9999,
"mode": "regular",
},
}
]
with pytest.raises(PatchError, match="9999"):
apply_json_patch(doc, ops)
assert doc["sites"][0]["anps"][0]["epgs"][0]["staticPorts"] == []
def test_ndo_static_port_vlan_string_form_also_validated():
"""cisco.mso's own JSON payload uses a plain int (see the accept test
above) but the sim must not assume that — a numeric STRING is just as
real a wire value elsewhere in this codebase (ACI side is all-string)."""
doc = _epg_doc_with_static_ports()
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/anps/app-Web_LAB1/epgs/epg-web/staticPorts/-",
"value": {"path": "topology/pod-1/pathep-[eth1/1]", "portEncapVlan": "0"},
}
]
with pytest.raises(PatchError):
apply_json_patch(doc, ops)
# ---------------------------------------------------------------------------
# Fail-safe: an unregistered collection/field is never validated.
# ---------------------------------------------------------------------------
def test_ndo_unregistered_collection_field_passes_through():
doc = {"templates": [{"name": "LAB1", "vrfs": []}]}
ops = [
{
"op": "add",
"path": "/templates/LAB1/vrfs/-",
"value": {"name": "vrf1", "someWildField": "!!not validated!!"},
}
]
apply_json_patch(doc, ops) # must not raise — ("vrfs", *) is not in NDO_RULES
assert doc["templates"][0]["vrfs"] == [{"name": "vrf1", "someWildField": "!!not validated!!"}]
def test_ndo_subnet_scope_field_not_validated_only_ip_is():
"""Design §3.3 scope is deliberately narrow (subnet ip + static-port
vlan only) — an arbitrary 'scope' value on a subnet must NOT be
rejected by this batch even though it's semantically wrong, since
("subnets", "scope") has no NDO_RULES entry."""
doc = _bd_doc_with_subnets()
ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "192.168.41.1/24", "scope": "totally-not-a-real-scope"},
}
]
apply_json_patch(doc, ops) # must not raise
def test_ndo_rules_table_scope():
"""Locks the batch-3 scope: exactly the two design-doc-named rules."""
assert set(NDO_RULES.keys()) == {("subnets", "ip"), ("staticPorts", "portEncapVlan")}
# ---------------------------------------------------------------------------
# Integration level: full FastAPI PATCH /mso/api/v1/schemas/{id} round trip
# (mirrors tests/test_pr12_site_local.py's own end-to-end pattern).
# ---------------------------------------------------------------------------
@pytest.fixture()
def topo():
return load_topology(TOPO_PATH)
@pytest.fixture()
def client(topo):
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
def test_schema_patch_bad_subnet_ip_400s_end_to_end(client):
payload = {
"displayName": "F10-TEST",
"templates": [{"name": "LAB1", "displayName": "LAB1", "tenantId": "t1", "bds": []}],
"sites": [{"siteId": "1", "templateName": "LAB1"}],
}
schema_id = client.post("/mso/api/v1/schemas", json=payload).json()["id"]
bd_ops = [
{"op": "add", "path": "/templates/LAB1/bds/-", "value": {"name": "bd-App1_LAB1", "displayName": "bd-App1_LAB1", "subnets": []}}
]
resp = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=bd_ops)
assert resp.status_code == 200
bad_subnet_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "999.888.777.17/30", "scope": "public"},
}
]
resp2 = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=bad_subnet_ops)
assert resp2.status_code == 400
assert "999.888.777.17/30" in resp2.json()["detail"]
good_subnet_ops = [
{
"op": "add",
"path": "/sites/1-LAB1/bds/bd-App1_LAB1/subnets/-",
"value": {"ip": "192.168.41.1/24", "scope": "public"},
}
]
resp3 = client.patch(f"/mso/api/v1/schemas/{schema_id}", json=good_subnet_ops)
assert resp3.status_code == 200
assert resp3.json()["sites"][0]["bds"][0]["subnets"] == [{"ip": "192.168.41.1/24", "scope": "public"}]
+388
View File
@@ -0,0 +1,388 @@
"""F10 — server-side value validation, unit level (aci_sim/rest_aci/validators.py).
Table-driven per the design doc's own zero-false-reject proof methodology
(``_F10_VALIDATION_DESIGN.md`` §6): every canonical value that appears in the
real-hardware-passing corpus (``~/e2e-20260708/vars/{ms,sf,common}/`` +
``logs/ansible/*create_tenant*.yml``) MUST pass; every F10-probe bad value
(``logs/probe/*probe_L2b.yml``) and one or two hand-built out-of-range/format
errors per rule MUST 400 (here: raise ValueError, since these are unit-level
RULES lookups — the FastAPI-level 400 plumbing is exercised separately in
tests/test_f10_apic_writeface.py and tests/test_f10_ndo_validation.py).
This file also locks the fail-safe default-allow contract (design §3.6): an
unregistered (class, prop) pair must never be checked.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from aci_sim.rest_aci.validators import RULES, fmt, mtu, one_of, rng
# ---------------------------------------------------------------------------
# Golden set: canonical values that must ACCEPT. (cls, prop, value) — sourced
# from the design doc's own corpus sample (§6 table) and, where noted, the
# live canonical corpus at ~/e2e-20260708/vars/{ms,sf,common}/ on PI230.
# ---------------------------------------------------------------------------
ACCEPT_CASES: list[tuple[str, str, str]] = [
# encap (vlan-N) — real l3out_vlan/bind_epg_to_static_port/migrate_
# existing_vlan values from ms/sf vars.
("l3extRsPathL3OutAtt", "encap", "vlan-51"),
("l3extRsPathL3OutAtt", "encap", "vlan-100"),
("l3extRsPathL3OutAtt", "encap", "vlan-2501"),
("l3extRsPathL3OutAtt", "encap", "vlan-2611"),
("l3extRsPathL3OutAtt", "encap", "vlan-1"),
("l3extRsPathL3OutAtt", "encap", "vlan-4094"),
("fvRsPathAtt", "encap", "vlan-2502"),
("fvRsPathAtt", "encap", "vlan-2503"),
("fvRsPathAtt", "encap", "vlan-2602"),
("fvRsPathAtt", "encap", "vlan-2603"),
("fvRsPathAtt", "primaryEncap", "vlan-100"),
("fvRsPathAtt", "primaryEncap", "unknown"), # edge: literal unknown allowed here
("fvRsDomAtt", "encap", "vlan-51"),
("fvRsDomAtt", "encap", "unknown"), # edge: literal unknown allowed here
("fvnsEncapBlk", "from", "vlan-1"),
("fvnsEncapBlk", "to", "vlan-4094"),
("fvnsEncapBlk", "from", "vlan-2501"),
# vxlan-N — F10 F2 fix: NOT capped at the vlan-N 4094 ceiling (24-bit
# VNID space instead). 5000 is a legal VNID that the pre-fix shared
# ceiling would have 400-rejected.
("l3extRsPathL3OutAtt", "encap", "vxlan-5000"),
# rtrId — IPv4-only dotted quad (l3out node profile router-id).
("l3extRsNodeL3OutAtt", "rtrId", "10.100.201.241"),
("l3extRsNodeL3OutAtt", "rtrId", "10.100.202.242"),
("l3extRsNodeL3OutAtt", "rtrId", "10.100.211.243"),
("l3extRsNodeL3OutAtt", "rtrId", "10.100.212.241"),
# l3extRsPathL3OutAtt.addr — interface form with /prefix, v4 and v6.
("l3extRsPathL3OutAtt", "addr", "10.100.201.17/30"),
("l3extRsPathL3OutAtt", "addr", "10.100.211.49/30"),
("l3extRsPathL3OutAtt", "addr", "2001:db8::1/64"), # edge: IPv6
# bgpPeerP.addr — bare address, v4 and v6.
("bgpPeerP", "addr", "10.100.0.1"),
("bgpPeerP", "addr", "2001:db8::1"), # edge: IPv6
# bgpPeerP.addr with /prefix — F10 F3 fix: subnet-based BGP peers
# legally carry a /prefix; the old ip_addr (bare-only) check rejected it.
("bgpPeerP", "addr", "10.100.211.0/30"),
("bgpPeerP", "ttl", "1"),
("bgpPeerP", "ttl", "255"),
("bgpPeerP", "ttl", "2"),
("bgpAsP", "asn", "65100"),
("bgpAsP", "asn", "1"),
("bgpAsP", "asn", "4294967295"),
# mtu — literal inherit or [576,9216].
("l3extRsPathL3OutAtt", "mtu", "9000"),
("l3extRsPathL3OutAtt", "mtu", "9216"),
("l3extRsPathL3OutAtt", "mtu", "1492"),
("l3extRsPathL3OutAtt", "mtu", "576"),
("l3extRsPathL3OutAtt", "mtu", "inherit"), # edge: literal inherit
("fvBD", "mtu", "9000"),
("fvBD", "mtu", "inherit"), # edge: literal inherit
# mac — colon and Cisco dot form.
("fvBD", "mac", "00:22:BD:F8:19:FF"), # the sim's own BD create-default
("fvBD", "mac", "00:11:22:33:44:55"),
("fvBD", "mac", "0011.2233.4455"), # edge: dot form
("vnsRedirectDest", "mac", "00:00:0c:9f:f7:01"),
("vnsRedirectDest", "mac", "0000.0c9f.f701"), # edge: dot form
# infraPortBlk — int >= 1.
("infraPortBlk", "fromPort", "1"),
("infraPortBlk", "toPort", "48"),
("infraPortBlk", "fromCard", "1"),
("infraPortBlk", "toCard", "1"),
# vzEntry ports — int in [0,65535] or a named port.
("vzEntry", "dFromPort", "443"),
("vzEntry", "dToPort", "https"), # edge: named
("vzEntry", "sFromPort", "0"),
("vzEntry", "sToPort", "unspecified"), # edge: named
("vzEntry", "dFromPort", "20"),
# ip (migrated, byte-identical semantics) — including the 0.0.0.0/0 and
# 192.168.x/24 forms from create_bd/create_tenant vars.
("fvSubnet", "ip", "192.168.41.1/24"),
("fvSubnet", "ip", "192.168.31.1/24"),
("fvSubnet", "ip", "192.168.250.1/24"),
("l3extSubnet", "ip", "0.0.0.0/0"),
("l3extSubnet", "ip", "10.100.201.17/30"),
("vnsRedirectDest", "ip", "10.100.201.241"),
("vnsRedirectDest", "ip", "2001:db8::1"), # edge: IPv6
# scope / ctrl / aggregate — single token and multi-token (comma) forms.
("fvSubnet", "scope", "public"),
("fvSubnet", "scope", "private,shared"), # edge: token-list
("fvSubnet", "ctrl", "unspecified"),
("fvSubnet", "ctrl", "nd,querier"), # edge: token-list
("l3extSubnet", "scope", "export-rtctrl"),
("l3extSubnet", "scope", "import-security,shared-rtctrl"), # edge: token-list
("l3extSubnet", "aggregate", "export-rtctrl"),
("fvCtx", "pcEnfPref", "enforced"),
("fvCtx", "pcEnfPref", "unenforced"),
("fvCtx", "pcEnfDir", "ingress"),
("fvCtx", "pcEnfDir", "egress"),
("fvBD", "unkMacUcastAct", "proxy"),
("fvBD", "unkMacUcastAct", "flood"),
("fvBD", "multiDstPktAct", "bd-flood"),
("fvBD", "multiDstPktAct", "drop"),
("fvBD", "unkMcastAct", "flood"),
("fvBD", "unkMcastAct", "opt-flood"),
("fvBD", "type", "regular"),
("fvBD", "epMoveDetectMode", ""), # edge: empty-string literal member
("fvBD", "epMoveDetectMode", "garp"),
("vzEntry", "etherT", "ip"),
("vzEntry", "etherT", "unspecified"),
("vzBrCP", "scope", "context"),
("vzBrCP", "scope", "application-profile"),
("fvRsDomAtt", "classPref", "encap"),
("fvRsDomAtt", "instrImedcy", "immediate"),
("fvRsDomAtt", "resImedcy", "lazy"),
# resImedcy "pre-provision" — F10 F1 fix (BLOCKER): the canonical
# corpus's ACTUAL resolution_immediacy value (34/34 in the campaign
# that motivated this batch); the pre-fix enum {immediate, lazy} 400-
# rejected every single EPG-domain binding.
("fvRsDomAtt", "resImedcy", "pre-provision"),
("bgpPeerP", "peerCtrl", "bfd"),
("bgpPeerP", "peerCtrl", "bfd,nh-self"), # edge: token-list
("fvnsVlanInstP", "allocMode", "dynamic"),
("fvnsEncapBlk", "allocMode", "static"),
]
@pytest.mark.parametrize("cls,prop,value", ACCEPT_CASES, ids=[f"{c}.{p}={v!r}" for c, p, v in ACCEPT_CASES])
def test_canonical_value_accepted(cls, prop, value):
validator = RULES[(cls, prop)]
validator(cls, prop, value, {}) # must NOT raise
def test_canonical_corpus_sample_size():
"""Design doc §6 samples ~40 distinct canonical values; this suite
covers at least that many (cls, prop, value) accept-cases."""
assert len(ACCEPT_CASES) >= 40
# ---------------------------------------------------------------------------
# Bad values that must REJECT — the F10 probe values plus 1-2 hand-built
# out-of-range/format errors per rule family.
# ---------------------------------------------------------------------------
REJECT_CASES: list[tuple[str, str, str]] = [
# F10 probe values (the actual empirically-confirmed gap).
("l3extRsPathL3OutAtt", "encap", "vlan-9999"),
("l3extRsNodeL3OutAtt", "rtrId", "999.888.777.241"),
("l3extRsPathL3OutAtt", "addr", "999.888.777.17/30"),
("l3extRsPathL3OutAtt", "addr", "999.888.777.25/30"),
# encap: out of range / malformed.
("l3extRsPathL3OutAtt", "encap", "vlan-0"),
("l3extRsPathL3OutAtt", "encap", "vlan-4095"),
("l3extRsPathL3OutAtt", "encap", "garbage"),
("fvRsPathAtt", "encap", "unknown"), # NOT allowed for this specific prop
("fvRsDomAtt", "encap", "vlan-9999"),
("fvnsEncapBlk", "from", "vlan-4095"),
("fvnsEncapBlk", "to", "vlan-0"),
# vxlan-N: still must reject beyond the 24-bit VNID ceiling (F10 F2
# regression guard — widening vlan-N's cap must not become "no cap").
("l3extRsPathL3OutAtt", "encap", "vxlan-99999999"),
("l3extRsPathL3OutAtt", "encap", "vxlan-0"),
# rtrId: IPv6 not allowed (row says IPv4-only dotted-quad).
("l3extRsNodeL3OutAtt", "rtrId", "2001:db8::1"),
("l3extRsNodeL3OutAtt", "rtrId", "not-an-ip"),
# bgpPeerP.
("bgpPeerP", "addr", "999.888.777.1"),
("bgpPeerP", "addr", "999.888.777.5"), # F10 F3 regression guard: /prefix now
# tolerated but a garbage octet must still 400 (ip_interface catches it too).
("bgpPeerP", "addr", "999.888.777.5/30"), # same, with a /prefix
("bgpPeerP", "ttl", "0"),
("bgpPeerP", "ttl", "256"),
("bgpPeerP", "peerCtrl", "not-a-flag"),
# bgpAsP.
("bgpAsP", "asn", "0"),
("bgpAsP", "asn", "4294967296"),
# mtu.
("l3extRsPathL3OutAtt", "mtu", "99999"),
("l3extRsPathL3OutAtt", "mtu", "500"),
("fvBD", "mtu", "not-a-number"),
# mac.
("fvBD", "mac", "zz:11:22:33:44:55"),
("fvBD", "mac", "00:11:22:33:44"),
("vnsRedirectDest", "mac", "not-a-mac"),
# infraPortBlk.
("infraPortBlk", "fromPort", "0"),
("infraPortBlk", "toCard", "-1"),
# vzEntry ports.
("vzEntry", "dFromPort", "70000"),
("vzEntry", "sToPort", "httpz"),
# enums.
("fvSubnet", "scope", "publicc"),
("fvSubnet", "scope", "private,publicc"),
("fvSubnet", "ctrl", "bogus"),
("l3extSubnet", "scope", "bogus-scope"),
("l3extSubnet", "aggregate", "bogus-agg"),
("fvCtx", "pcEnfPref", "bogus"),
("fvCtx", "pcEnfDir", "bogus"),
("fvBD", "unkMacUcastAct", "bogus"),
("fvBD", "multiDstPktAct", "bogus"),
("fvBD", "type", "weird"),
("fvBD", "epMoveDetectMode", "bogus"),
("vzEntry", "etherT", "ipv7"),
("vzBrCP", "scope", "bogus"),
("fvRsDomAtt", "classPref", "bogus"),
("fvRsDomAtt", "instrImedcy", "bogus"),
("fvnsVlanInstP", "allocMode", "weird"),
# ip (migrated) regression — the exact garbage from the 2026-07-07 J2
# incident that motivated the pre-F10 hardcoded checks.
("fvSubnet", "ip", ".1/24"),
("l3extSubnet", "ip", ".1/24"),
("vnsRedirectDest", "ip", "."),
]
@pytest.mark.parametrize("cls,prop,value", REJECT_CASES, ids=[f"{c}.{p}={v!r}" for c, p, v in REJECT_CASES])
def test_bad_value_rejected(cls, prop, value):
validator = RULES[(cls, prop)]
with pytest.raises(ValueError):
validator(cls, prop, value, {})
# ---------------------------------------------------------------------------
# Corpus enumeration lock (independent-review test-blind-spot fix, F10 F1
# post-mortem): ACCEPT_CASES above hand-picks ONE value per enum prop (e.g.
# resImedcy="lazy"), which is exactly how F1 slipped past review — "lazy" is
# legal but is NOT what the real campaign ever sends (100% "pre-provision").
# A hand-picked accept-case proves the validator accepts *a* legal value, not
# that it accepts the corpus's *actual* values. This test closes that gap
# mechanically: it scans every resolution_immediacy/deployment_immediacy
# value that the real campaign's vars (~/e2e-20260708/vars/{ms,sf,common}/)
# actually rendered into (the rendered artifacts live at
# ~/e2e-20260708/logs/ansible/*.yml — same corpus this module's docstring
# already cites) and asserts EVERY distinct value found is ACCEPTed, with no
# human hand-picking a subset. Skipped (not failed) off PI230 where this
# machine-local e2e corpus doesn't exist.
# ---------------------------------------------------------------------------
_E2E_ANSIBLE_LOGS = Path.home() / "e2e-20260708" / "logs" / "ansible"
_IMMEDIACY_RE = re.compile(r'(resolution_immediacy|deployment_immediacy):\s*"([^"]+)"')
def _enumerate_corpus_immediacy_values() -> dict[str, set[str]]:
"""Mechanically extract every distinct resolution_immediacy /
deployment_immediacy string literal from the rendered aci-model-*.yml /
mso-model-*.yml artifacts under ~/e2e-20260708/logs/ansible/ — these are
exactly what results from rendering ~/e2e-20260708/vars/{ms,sf,common}/
through the campaign's pipeline. Values are read off disk, not typed in
by hand, so a future enum with a real-corpus value this suite doesn't
already know about will surface here instead of staying invisible."""
found: dict[str, set[str]] = {"resolution_immediacy": set(), "deployment_immediacy": set()}
for path in sorted(_E2E_ANSIBLE_LOGS.glob("*.yml")):
text = path.read_text()
for key, value in _IMMEDIACY_RE.findall(text):
found[key].add(value)
return found
@pytest.mark.skipif(
not _E2E_ANSIBLE_LOGS.is_dir(),
reason="~/e2e-20260708/logs/ansible corpus not present on this machine",
)
def test_corpus_resolution_and_deployment_immediacy_values_all_accepted():
found = _enumerate_corpus_immediacy_values()
assert found["resolution_immediacy"], (
"corpus scan found zero resolution_immediacy values — the glob/regex "
"is broken, this is not a real pass"
)
res_imedcy = RULES[("fvRsDomAtt", "resImedcy")]
for value in sorted(found["resolution_immediacy"]):
res_imedcy("fvRsDomAtt", "resImedcy", value, {}) # must NOT raise
assert found["deployment_immediacy"], (
"corpus scan found zero deployment_immediacy values — the glob/regex "
"is broken, this is not a real pass"
)
instr_imedcy = RULES[("fvRsDomAtt", "instrImedcy")]
for value in sorted(found["deployment_immediacy"]):
instr_imedcy("fvRsDomAtt", "instrImedcy", value, {}) # must NOT raise
def test_corpus_resolution_immediacy_is_actually_pre_provision():
"""Pins the specific fact that motivated the F1 fix (not just "some
value accepts") — if this ever flips, F1's own justification is stale
and needs re-checking, not silently trusted."""
found = _enumerate_corpus_immediacy_values()
if not found["resolution_immediacy"]:
pytest.skip("~/e2e-20260708/logs/ansible corpus not present on this machine")
assert found["resolution_immediacy"] == {"pre-provision"}
# ---------------------------------------------------------------------------
# Fail-safe / default-allow contract (design §3.6).
# ---------------------------------------------------------------------------
def test_unregistered_class_prop_pair_has_no_validator():
assert ("fvTenant", "name") not in RULES
assert ("fvAp", "name") not in RULES
assert ("bogusClass", "bogusProp") not in RULES
def test_rules_table_size_matches_design_scope():
"""Sanity lock on the registry shape: P1+P2 (+3 migrated ip rules) should
land in the low-to-mid 40s of (class, prop) entries — see
_F10_VALIDATION_DESIGN.md §2A/§2B row counts (16+17 table ROWS; several
rows cover 2+ props, e.g. infraPortBlk's 4 port/card fields, so the
(class, prop) key count is naturally higher than the 31 "core rules"
row-count figure quoted in the design doc's summary)."""
assert 35 <= len(RULES) <= 55
# ---------------------------------------------------------------------------
# Direct factory-level tests (not routed through RULES) — pin the exact
# behavior of the composable helpers themselves.
# ---------------------------------------------------------------------------
def test_rng_no_upper_bound():
v = rng(1, None)
v("infraPortBlk", "fromPort", "1", {})
v("infraPortBlk", "fromPort", "999999", {}) # no ceiling specified
with pytest.raises(ValueError):
v("infraPortBlk", "fromPort", "0", {})
def test_rng_rejects_non_integer():
v = rng(1, 10)
with pytest.raises(ValueError):
v("x", "y", "not-a-number", {})
def test_one_of_plain_membership():
v = one_of({"a", "b"})
v("x", "y", "a", {})
with pytest.raises(ValueError):
v("x", "y", "c", {})
def test_one_of_token_list_empty_value_is_vacuously_allowed():
"""An empty string with list_sep set has no tokens to check — the
fail-safe bias (design §3.6) treats 'nothing to validate' as pass rather
than risk a false-reject on an omitted/unset multi-valued enum."""
v = one_of({"a", "b"}, list_sep=",")
v("x", "y", "", {}) # must not raise
def test_fmt_unknown_kind_raises_at_construction():
with pytest.raises(ValueError):
fmt("not-a-real-kind")
def test_mtu_helper_directly():
m = mtu()
m("fvBD", "mtu", "inherit", {})
m("fvBD", "mtu", "9216", {})
with pytest.raises(ValueError):
m("fvBD", "mtu", "9217", {})
with pytest.raises(ValueError):
m("fvBD", "mtu", "not-inherit-and-not-a-number", {})
def test_vlan_encap_unknown_only_where_flagged():
strict = fmt("vlan_encap")
lenient = fmt("vlan_encap", allow_unknown=True)
with pytest.raises(ValueError):
strict("x", "y", "unknown", {})
lenient("x", "y", "unknown", {}) # must not raise