"""Server-side value validators for the aci-sim APIC write face (F10). aci-sim is a faithful ACI management-plane MODEL: it stores/associates MOs correctly but historically performed no APIC-style server-side attribute validation. A real APIC 400-rejects out-of-range/malformed values (e.g. ``l3extRsPathL3OutAtt encap=vlan-9999``, ``l3extRsNodeL3OutAtt rtrId=999.888.777.241``) even when the posting client (``cisco.aci.aci_rest`` raw MO bodies, in this campaign) does zero client-side validation of its own. This module is the table-driven rule catalog that closes that gap — see the RULES table below and its section comments for the full rule set, sourcing, and false-reject-risk analysis. Do not add rules here without a comment documenting that row's sourcing. Architecture (design §3.1): - Small composable validator *factories* (``rng``, ``one_of``, ``fmt``, ``all_of``) each return a ``Validator`` callable with signature ``(cls: str, prop: str, value, ctx) -> None`` that raises ``ValueError`` with a human-readable reason on an invalid value. - ``RULES`` maps ``(class, prop) -> Validator``. A ``(class, prop)`` pair with NO entry is intentionally unvalidated — see "Fail-safe" below. Fail-safe / false-reject policy (design §3.6, load-bearing): **default-allow**. Only pairs present in RULES are checked; every other (class, prop) passes through untouched. The bias is hard toward "never reject a legal value" — every rule here is proven against the canonical corpus (design §6) before being added, and any rule that would reject a canonical value must be loosened to admit it (never loosened to admit a known-bad value instead). """ from __future__ import annotations import ipaddress import re from typing import Callable #: A validator inspects one (class, prop, value) triple and either returns #: None (value is legal) or raises ValueError(reason) (value is illegal). #: ``ctx`` is reserved for cross-field checks (Priority-3 in the design doc, #: e.g. fvnsEncapBlk from<=to) — NOT implemented in this batch (P1+P2 only); #: it is threaded through now so a later batch can add cross-field rules #: without changing the RULES calling convention. Validator = Callable[[str, str, object, object], None] # --------------------------------------------------------------------------- # Factories # --------------------------------------------------------------------------- def rng(lo: int, hi: int | None = None, *, transform: Callable[[object], object] | None = None) -> Validator: """Integer range check: ``lo <= int(value) <= hi``. *hi=None* means "no upper bound" (used for the few design-doc rules that only specify a floor, e.g. infraPortBlk ``fromPort/toPort/fromCard/ toCard`` >= 1). *transform* is applied to the raw value before the int() conversion (e.g. stripping a "vlan-" prefix) — not currently used by any RULES entry below (that parsing lives in the ``vlan_encap`` fmt kind instead) but kept per the design §3.1 factory signature for forward use. """ def _validator(cls: str, prop: str, value: object, ctx: object = None) -> None: raw = value if transform is None else transform(value) try: n = int(raw) # type: ignore[arg-type] except (TypeError, ValueError): raise ValueError(f"not an integer, got {value!r}") from None if n < lo or (hi is not None and n > hi): bound = f"[{lo},{hi}]" if hi is not None else f">= {lo}" raise ValueError(f"must be {bound}, got {n}") return _validator def one_of(allowed: set[str] | frozenset[str], *, list_sep: str | None = None) -> Validator: """Enum check. Plain membership by default; with *list_sep* the MO's string value is a token-list (real APIC stores multi-valued enum props like ``scope``/``ctrl``/``aggregate``/``peerCtrl`` as a single comma-joined string — confirmed against ``cisco.aci`` module source, e.g. ``aci_bd_subnet.py``/``aci_l3out_extsubnet.py`` doing ``",".join(sorted(scope))``) — split on *list_sep*, and every non-empty token must be a member of *allowed*. An empty value (no tokens at all) is treated as vacuously valid — nothing to check, and rejecting it would risk a false-reject on an unset/omitted enum (design §3.6 bias). """ allowed_set = set(allowed) def _validator(cls: str, prop: str, value: object, ctx: object = None) -> None: if not isinstance(value, str): raise ValueError(f"expected a string, got {type(value).__name__}") if list_sep is None: if value not in allowed_set: raise ValueError(f"must be one of {sorted(allowed_set)}, got {value!r}") return tokens = [t.strip() for t in value.split(list_sep) if t.strip()] for token in tokens: if token not in allowed_set: raise ValueError( f"token {token!r} not in allowed set {sorted(allowed_set)} (value={value!r})" ) return _validator _VLAN_ENCAP_RE = re.compile(r"^(vlan|vxlan)-(\d+)$") _VLAN_MIN, _VLAN_MAX = 1, 4094 # NOT 4096 — see design doc §7 point 2: # cisco.aci docs say "1 and 4096" but real APIC reserves 4095/4096; 4094 is # the real server ceiling and comfortably admits every canonical value # (max observed: 2611). _VXLAN_MIN, _VXLAN_MAX = 1, 16777215 # F10 fix (independent review): the # vlan-N 4094 ceiling does NOT apply to vxlan-N — VXLAN VNID is a 24-bit # field (RFC 7348), max 2**24-1 = 16777215. The original single shared # ceiling 400-rejected every legal vxlan-N value above 4094, a 100% # false-reject on this prefix. Fail-safe bias (module docstring / design # §3.6): take the widest legal range rather than guess a tighter one. _MAC_COLON_RE = re.compile(r"^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$") _MAC_DOT_RE = re.compile(r"^[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}$") #: Named L4 ports real APIC/`cisco.aci` accept in place of a numeric port, #: sourced verbatim from the vendored cisco.aci collection's #: `FILTER_PORT_MAPPING` (module_utils/constants.py) values, plus the #: literal "unspecified". _NAMED_PORTS = {"https", "smtp", "http", "dns", "pop3", "rtsp", "ftpData", "unspecified"} def _check_vlan_encap(value: object, *, allow_unknown: bool) -> None: if not isinstance(value, str): raise ValueError(f"expected a string, got {type(value).__name__}") if allow_unknown and value == "unknown": return m = _VLAN_ENCAP_RE.match(value) if not m: suffix = " or literal 'unknown'" if allow_unknown else "" raise ValueError(f"expected 'vlan-N' or 'vxlan-N'{suffix}, got {value!r}") prefix, n_str = m.group(1), m.group(2) n = int(n_str) lo, hi = (_VLAN_MIN, _VLAN_MAX) if prefix == "vlan" else (_VXLAN_MIN, _VXLAN_MAX) if not (lo <= n <= hi): raise ValueError(f"{prefix} id must be in [{lo},{hi}], got {n}") def _check_ipv4_iface(value: object) -> None: """``a.b.c.d[/prefix]`` OR the IPv6 equivalent — real APIC uses the same attribute (``ip``/``addr``) for either family on these props. Uses the stdlib ``ipaddress`` module (never a regex) per design §3.1/§7 point 3. Reason text is byte-identical to the pre-F10 hardcoded fvSubnet/ l3extSubnet ip check this replaces (design §3.2: "no behavior change, one code path") — do not append the raw ``ipaddress`` exception text here, existing regression tests + callers depend on the exact string.""" if not isinstance(value, str): raise ValueError(f"expected a string, got {type(value).__name__}") try: ipaddress.ip_interface(value) except ValueError: raise ValueError("not a valid address[/prefix]") from None def _check_ip_addr(value: object) -> None: """A bare IPv4 or IPv6 address, no ``/prefix`` (e.g. bgpPeerP.addr, vnsRedirectDest.ip). Reason text is byte-identical to the pre-F10 hardcoded vnsRedirectDest ip check this replaces — see ``_check_ipv4_iface`` docstring.""" if not isinstance(value, str): raise ValueError(f"expected a string, got {type(value).__name__}") try: ipaddress.ip_address(value) except ValueError: raise ValueError("not a valid IP address") from None def _check_ipv4_addr(value: object) -> None: """IPv4-only dotted-quad (design table: l3extRsNodeL3OutAtt.rtrId is explicitly "valid IPv4 dotted-quad (octet <=255)", not a dual-family field like the iface/addr checks above).""" if not isinstance(value, str): raise ValueError(f"expected a string, got {type(value).__name__}") try: ipaddress.IPv4Address(value) except ValueError as exc: raise ValueError(f"not a valid IPv4 address: {exc}") from None def _check_mac(value: object) -> None: """48-bit MAC, colon form (``HH:HH:HH:HH:HH:HH``) or Cisco dot form (``HHHH.HHHH.HHHH``) — design §7 point 4.""" if not isinstance(value, str): raise ValueError(f"expected a string, got {type(value).__name__}") if _MAC_COLON_RE.match(value) or _MAC_DOT_RE.match(value): return raise ValueError(f"not a valid MAC address (colon or dot form), got {value!r}") def _check_port(value: object) -> None: """int in [0,65535], or one of the named ports APIC accepts.""" if isinstance(value, str) and value in _NAMED_PORTS: return try: n = int(value) # type: ignore[arg-type] except (TypeError, ValueError): raise ValueError( f"not an integer or a named port {sorted(_NAMED_PORTS)}, got {value!r}" ) from None if not (0 <= n <= 65535): raise ValueError(f"port must be in [0,65535], got {n}") _FMT_KINDS: dict[str, Callable[..., None]] = { "ipv4_iface": _check_ipv4_iface, "ip_addr": _check_ip_addr, "ipv4_addr": _check_ipv4_addr, "mac": _check_mac, "port": _check_port, } def fmt(kind: str, **opts: object) -> Validator: """Format-check factory. *kind* selects the checker: - ``vlan_encap``: ``vlan-N`` (N in [1,4094]) or ``vxlan-N`` (N in [1,16777215], 24-bit VNID — NOT the vlan-N ceiling, see ``_VXLAN_MIN``/``_VXLAN_MAX``); pass ``allow_unknown=True`` for the props whose design-table rule text explicitly says "or literal unknown" (``fvRsPathAtt.primaryEncap``, ``fvRsDomAtt.encap``) — every other encap prop does NOT accept "unknown" per its own row text (design §7 point 1: "needs a per-rule unknown-ok flag"). - ``ipv4_iface``: address[/prefix], v4 or v6, via ``ipaddress.ip_interface``. - ``ip_addr``: bare address, v4 or v6, via ``ipaddress.ip_address``. - ``ipv4_addr``: v4-only dotted-quad, via ``ipaddress.IPv4Address``. - ``mac``: 48-bit MAC, colon or dot form. - ``port``: int in [0,65535] or a named L4 port. """ if kind == "vlan_encap": allow_unknown = bool(opts.get("allow_unknown", False)) def _validator(cls: str, prop: str, value: object, ctx: object = None) -> None: _check_vlan_encap(value, allow_unknown=allow_unknown) return _validator checker = _FMT_KINDS.get(kind) if checker is None: raise ValueError(f"unknown fmt kind {kind!r}") def _validator(cls: str, prop: str, value: object, ctx: object = None) -> None: checker(value) return _validator def mtu() -> Validator: """Literal ``inherit`` or int in [576,9216] — l3extRsPathL3OutAtt.mtu / fvBD.mtu (design table row 2A "mtu").""" _range = rng(576, 9216) def _validator(cls: str, prop: str, value: object, ctx: object = None) -> None: if isinstance(value, str) and value == "inherit": return _range(cls, prop, value, ctx) return _validator def all_of(*validators: Validator) -> Validator: """Compose several validators on one prop; all must pass.""" def _validator(cls: str, prop: str, value: object, ctx: object = None) -> None: for v in validators: v(cls, prop, value, ctx) return _validator # --------------------------------------------------------------------------- # RULES — (class, prop) -> Validator # # Every entry below falls into one of two source categories: §2A # (Priority 1 — server-only format/range) or §2B (Priority 2 — server-only # enums), per the rule-source notes in this module's docstring. Cross-field / # cardinality rules (§2C, e.g. infraPortBlk fromPort<=toPort, fvnsEncapBlk # from<=to) are OUT OF SCOPE for this batch (P1+P2 only) — deliberately not # implemented here. # --------------------------------------------------------------------------- RULES: dict[tuple[str, str], Validator] = { # -- §2A Priority 1: server-only format/range -------------------------- # encap (vlan-N N in [1,4094] / vxlan-N N in [1,16777215] — see # _VXLAN_MIN/_VXLAN_MAX) — "unknown" only where the row text says so. ("l3extRsPathL3OutAtt", "encap"): fmt("vlan_encap"), ("fvRsPathAtt", "encap"): fmt("vlan_encap"), ("fvRsPathAtt", "primaryEncap"): fmt("vlan_encap", allow_unknown=True), ("fvRsDomAtt", "encap"): fmt("vlan_encap", allow_unknown=True), # fvnsEncapBlk from/to: same vlan-N format+range; from<=to cross-check is # §2C (out of scope this batch). ("fvnsEncapBlk", "from"): fmt("vlan_encap"), ("fvnsEncapBlk", "to"): fmt("vlan_encap"), # rtrId: IPv4-only dotted-quad. ("l3extRsNodeL3OutAtt", "rtrId"): fmt("ipv4_addr"), # l3extRsPathL3OutAtt.addr: iface form (a.b.c.d[/p]), v4 or v6. ("l3extRsPathL3OutAtt", "addr"): fmt("ipv4_iface"), # bgpPeerP.addr: iface form (a.b.c.d[/p]), v4 or v6 — F10 fix # (independent review): real ACI subnet-based BGP peers legally carry a # peer addr with an optional /prefix (e.g. "10.100.211.0/30"); the # original bare-address-only ip_addr check 400-rejected those. Widened # to ipv4_iface (ip_interface, same as l3extRsPathL3OutAtt.addr) which # still accepts a bare address (defaults to /32 or /128) so the # existing no-prefix canonical values keep passing too. ("bgpPeerP", "addr"): fmt("ipv4_iface"), ("bgpPeerP", "ttl"): rng(1, 255), ("bgpAsP", "asn"): rng(1, 4294967295), ("l3extRsPathL3OutAtt", "mtu"): mtu(), ("fvBD", "mtu"): mtu(), ("fvBD", "mac"): fmt("mac"), ("vnsRedirectDest", "mac"): fmt("mac"), # infraPortBlk: int >= 1 (no stated upper bound; le-cross-check is §2C). ("infraPortBlk", "fromPort"): rng(1, None), ("infraPortBlk", "toPort"): rng(1, None), ("infraPortBlk", "fromCard"): rng(1, None), ("infraPortBlk", "toCard"): rng(1, None), # vzEntry ports: int in [0,65535] or a named port. ("vzEntry", "dFromPort"): fmt("port"), ("vzEntry", "dToPort"): fmt("port"), ("vzEntry", "sFromPort"): fmt("port"), ("vzEntry", "sToPort"): fmt("port"), # -- §2A Priority 1 (continued): ip already-validated (migrated, byte- # identical semantics to the pre-F10 hardcoded branches in writes.py) -- ("fvSubnet", "ip"): fmt("ipv4_iface"), ("l3extSubnet", "ip"): fmt("ipv4_iface"), ("vnsRedirectDest", "ip"): fmt("ip_addr"), # -- §2B Priority 2: server-only enums ---------------------------------- ("fvSubnet", "scope"): one_of({"private", "public", "shared"}, list_sep=","), ("fvSubnet", "ctrl"): one_of( {"nd", "no-default-gateway", "querier", "unspecified"}, list_sep="," ), ("l3extSubnet", "scope"): one_of( { "import-security", "export-rtctrl", "import-rtctrl", "shared-rtctrl", "shared-security", }, list_sep=",", ), ("l3extSubnet", "aggregate"): one_of( {"export-rtctrl", "import-rtctrl", "shared-rtctrl"}, list_sep="," ), ("fvCtx", "pcEnfPref"): one_of({"enforced", "unenforced"}), ("fvCtx", "pcEnfDir"): one_of({"ingress", "egress"}), ("fvBD", "unkMacUcastAct"): one_of({"proxy", "flood"}), ("fvBD", "multiDstPktAct"): one_of({"bd-flood", "drop", "encap-flood"}), ("fvBD", "unkMcastAct"): one_of({"flood", "opt-flood"}), ("fvBD", "type"): one_of({"regular", "fc"}), ("fvBD", "epMoveDetectMode"): one_of({"", "garp"}), ("vzEntry", "etherT"): one_of( { "arp", "fcoe", "ip", "ipv4", "ipv6", "mac_security", "mpls_ucast", "trill", "unspecified", } ), ("vzBrCP", "scope"): one_of({"context", "global", "tenant", "application-profile"}), ("fvRsDomAtt", "classPref"): one_of({"encap", "useg"}), ("fvRsDomAtt", "instrImedcy"): one_of({"immediate", "lazy"}), # resImedcy (Resolution Immediacy) — F10 fix (independent review): real # ACI's legal set is {immediate, lazy, pre-provision}; the original # rule dropped "pre-provision", which is the value 100% of the # canonical corpus's EPG-domain bindings actually use (every # bind_epg_to_*_domain / migrate_existing_vlan* render), so the bug # 400-rejected every one of them. Do NOT add "pre-provision" to # instrImedcy (Deployment Immediacy) above — that enum really is just # {immediate, lazy} per its own design-table row and 100% of the # corpus uses "immediate". ("fvRsDomAtt", "resImedcy"): one_of({"immediate", "lazy", "pre-provision"}), ("bgpPeerP", "peerCtrl"): one_of( {"bfd", "dis-conn-check", "nh-self", "allow-self-as", "send-com", "send-ext-com"}, list_sep=",", ), ("fvnsVlanInstP", "allocMode"): one_of({"dynamic", "static", "inherit"}), ("fvnsEncapBlk", "allocMode"): one_of({"dynamic", "static", "inherit"}), }