Initial public release — aci-sim v0.16.0

aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric
(per-site APIC + ND/NDO management planes) — for testing ACI automation
(Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates
without real hardware.

Highlights: APIC + NDO REST surface served from one in-memory MIT built
from a declarative topology.yaml; port mode + sandbox (real per-device IPs
on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror
(multi-site templates materialize onto the target sites' APIC stores);
real-APIC fvBD default attributes; file-backed state save/restore; an
`aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite.

History squashed for the public release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:06:36 +10:00
co-authored by Claude Fable 5
commit 7e9a175ce6
117 changed files with 31769 additions and 0 deletions
+530
View File
@@ -0,0 +1,530 @@
"""
NDO state model builder — Phase 6.
`build_ndo_model(topo)` derives a complete NdoState from the same Topology
object used for the APIC MIT builders, so every NDO tenant/VRF/BD/EPG/contract
name *exactly* matches the APIC MIT (cross-plane consistency, DESIGN.md rule).
The state is a plain dataclass — no FastAPI dependency here.
"""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass, field
from aci_sim.topology.schema import Topology
from .patch import _new_site_bd, _new_site_epg, normalize_site, normalize_template
# ---------------------------------------------------------------------------
# Fixed deterministic artefacts (stable across test runs — no datetime.now())
# ---------------------------------------------------------------------------
#: UUID used as the dhcpRelayPolicy reference in every BD's dhcpLabels.
#: The tenantPolicy template exposes this UUID so ndo_bd_view can resolve it.
DHCP_RELAY_UUID: str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
#: UUID for the DHCP option policy exposed in the tenantPolicy template.
DHCP_OPTION_UUID: str = "f9e8d7c6-b5a4-3210-fedc-ba9876543210"
#: Stable template-id for the single tenantPolicy template.
TENANT_POLICY_TEMPLATE_ID: str = "tenantpolicy000000000001" # 24 chars
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _hex_id(seed: str) -> str:
"""Return a 24-char lower-hex deterministic id (Mongo ObjectId style)."""
return hashlib.sha256(seed.encode()).hexdigest()[:24]
# ---------------------------------------------------------------------------
# State container
# ---------------------------------------------------------------------------
@dataclass
class NdoState:
"""All NDO-plane state derived from the topology, plus a mutable POST bucket."""
#: [{id, name, platform, urls}] — from topo.sites
sites: list
#: [{id, name, displayName, siteAssociations}] — one per topo.tenants
tenants: list
#: Summary shape [{id, displayName, name, templates:[{name}]}]
schemas: list
#: schema_id → full schema detail dict (returned by GET /schemas/{id})
schema_details: dict
#: [{templateId, templateType}] — includes one "tenantPolicy" entry
template_summaries: list
#: templateId → full template doc (returned by GET /templates/{id})
tenant_policy_templates: dict
#: {"sites": [{id, siteId, status, connectivityStatus}]}
fabric_connectivity: dict
#: schema_id → [{status, drift}]
policy_states: dict
#: [{id, timestamp, user, action, description, details}] — fixed set
audit_records: list
#: ND-platform local users, ND-shaped `{"items":[{"spec":{...}}]}` — PR-11.
#: `cisco.mso.mso_tenant`'s `lookup_users()`/`lookup_remote_users()` query
#: this (and the sibling `remote_users`) as part of every tenant create.
local_users: dict = field(default_factory=dict)
#: ND-platform remote (AAA/TACACS/LDAP) users, same ND aggregate shape.
#: Seeded with the sandbox's own `admin` login so `mso_tenant`'s implicit
#: `users: [ansible_user]` (defaulted to admin if unset) resolves.
remote_users: dict = field(default_factory=dict)
#: Accumulates schemas POSTed at runtime (mutable — shared with app closures)
extra_schemas: list = field(default_factory=list)
# ---------------------------------------------------------------------------
# Builder
# ---------------------------------------------------------------------------
def build_ndo_model(topo: Topology, sandbox: bool = False) -> NdoState: # noqa: C901
"""Derive an NdoState from *topo* with names matching the APIC MIT.
In sandbox mode each APIC is served on its own ``mgmt_ip`` at :443, so NDO
advertises the portless ``https://<mgmt_ip>`` URL — exactly how real NDO
reports controller URLs, and what autoACI's discovery expects. Otherwise it
advertises ``https://<apic_host>`` (the 127.0.0.1:<port> loopback form).
"""
# ------------------------------------------------------------------
# Sites
# ------------------------------------------------------------------
# PR-10: real NDO registers sites under their fabric name (e.g.
# "LAB1-IT-ACI"), not the short topology-internal site name ("LAB1") —
# verified against a real-gear cisco.mso.mso_tenant run (see
# docs/CONTRACT.md §7 / CHANGELOG 0.2.1): the tenant's `sites` list only
# accepted the fabric-name form, and `mso_tenant` failed with
# "Site 'LAB1-IT-ACI' is not a valid site name" when only the short name
# was exposed here. `Site.fabric_name` (topSystem.fabricDomain) already
# carries this value; fall back to the short `site.name` only if a
# topology omits fabric_name (keeps this builder tolerant of minimal
# topologies used by unit tests that don't set it).
sites: list = []
site_id_map: dict[str, str] = {} # site.name (short) → site.id — internal join key
for site in topo.sites:
apic_url = f"https://{site.mgmt_ip}" if (sandbox and site.mgmt_ip) else f"https://{site.apic_host}"
ndo_site_name = site.fabric_name or site.name
sites.append(
{
"id": site.id,
"name": ndo_site_name,
"platform": "APIC",
"urls": [apic_url],
}
)
site_id_map[site.name] = site.id
# ------------------------------------------------------------------
# Tenants
# ------------------------------------------------------------------
tenants: list = []
for tenant in topo.tenants:
t_id = _hex_id(f"tenant-{tenant.name}")
site_assocs = [
{"siteId": site_id_map[s]}
for s in tenant.sites
if s in site_id_map
]
tenants.append(
{
"id": t_id,
"name": tenant.name,
"displayName": tenant.name,
"siteAssociations": site_assocs,
}
)
# ------------------------------------------------------------------
# Schemas — one per tenant
# ------------------------------------------------------------------
schemas: list = []
schema_details: dict = {}
policy_states: dict = {}
for tenant in topo.tenants:
schema_id = _hex_id(f"schema-{tenant.name}")
tmpl_type = "stretched" if tenant.stretch else "application"
tmpl_name = f"{tenant.name}-template"
# VRFs — name/displayName/vrfRef match APIC fvCtx names
vrfs = [
{
"name": vrf.name,
"displayName": vrf.name,
"vrfRef": f"/vrfs/{vrf.name}",
"vzAnyEnabled": False,
"ipDataPlaneLearning": "enabled",
}
for vrf in tenant.vrfs
]
# BDs — name/vrfRef match APIC fvBD names; every BD carries a DHCP
# relay label so ndo_bd_view can exercise uuid→name resolution.
bds = []
for bd in tenant.bds:
bds.append(
{
"name": bd.name,
"vrfRef": f"/vrfs/{bd.vrf}",
"subnets": [{"ip": s} for s in bd.subnets],
"l2Stretch": bd.l2stretch,
"intersiteBumTraffic": bd.l2stretch,
"l2UnknownUnicast": "flood",
"arpFlood": True,
"unicastRouting": True,
"epMoveDetectMode": "garp",
"dhcpLabels": [{"ref": DHCP_RELAY_UUID}],
}
)
# ANPs / EPGs — names match APIC fvAp / fvAEPg names
anps = []
for ap in tenant.aps:
epgs = []
for epg in ap.epgs:
contract_rels = [
{
"relationshipType": "provider",
"contractRef": f"/contracts/{c}",
}
for c in epg.contracts.provide
] + [
{
"relationshipType": "consumer",
"contractRef": f"/contracts/{c}",
}
for c in epg.contracts.consume
]
epgs.append(
{
"name": epg.name,
"bdRef": f"/bds/{epg.bd}",
"preferredGroup": False,
"proxyArp": False,
"uSegEpg": False,
"intraEpg": "unenforced",
"contractRelationships": contract_rels,
}
)
anps.append({"name": ap.name, "epgs": epgs})
# Contracts — names match APIC vzBrCP names
contracts = []
for contract in tenant.contracts:
filter_rels = [
{"filterRef": f"/filters/{flt.name}"}
for flt in contract.filters
]
contracts.append(
{
"name": contract.name,
"scope": contract.scope,
"filterRelationships": filter_rels,
}
)
# Filters — deduplicated; entries use CONTRACT §7 field names
# (ipProtocol / dFromPort / dToPort / etherType)
seen_filters: set[str] = set()
filters: list = []
for contract in tenant.contracts:
for flt in contract.filters:
if flt.name in seen_filters:
continue
seen_filters.add(flt.name)
entries = [
{
"name": f"{e.proto}-{e.from_port}",
"ipProtocol": e.proto,
"dFromPort": e.from_port,
"dToPort": e.to_port,
"etherType": e.ether_type,
}
for e in flt.entries
]
filters.append({"name": flt.name, "entries": entries})
# External EPGs from L3Out ext_epgs — vrfRef / l3outRef match APIC names
external_epgs: list = []
for l3out in tenant.l3outs:
for ext_epg in l3out.ext_epgs:
external_epgs.append(
{
"name": ext_epg.name,
"vrfRef": f"/vrfs/{l3out.vrf}",
"l3outRef": f"/l3outs/{l3out.name}",
"type": "on-premise",
"subnets": [{"ip": s} for s in ext_epg.subnets],
}
)
# Schema-level site associations (template ↔ site mappings)
schema_sites = [
{"templateName": tmpl_name, "siteId": site_id_map[s]}
for s in tenant.sites
if s in site_id_map
]
template = {
"name": tmpl_name,
"templateType": tmpl_type,
# PR-13: `custom_mso_schema_service_graph.py`'s "service graph
# already exists, add a node to it" branch (invoked the SECOND
# time the module PATCHes the same service-graph name — e.g.
# once per site in a multi-site service-graph bind) reads
# `schema_obj.get('templates')[template_idx]['tenantId']` with
# a bare subscript to resolve the L4-L7 device's owning tenant.
# Same deterministic id `build_ndo_model`'s tenants[] list
# already assigns this tenant (`_hex_id(f"tenant-{name}")`) —
# recomputed here rather than threaded through because it's
# derived purely from the tenant name.
"tenantId": _hex_id(f"tenant-{tenant.name}"),
"vrfs": vrfs,
"bds": bds,
"anps": anps,
"contracts": contracts,
"filters": filters,
"externalEpgs": external_epgs,
}
# PR-12: backfill self-referencing anpRef/epgRef on every template
# anp/epg (real NDO carries these — see aci_sim/ndo/patch.py
# normalize_template's docstring), then mirror each template ANP/EPG
# into every site already associated with this template, matching
# real NDO 4.x's auto-population of sites[].anps[].epgs[] the moment
# a template with sites attached gains an ANP/EPG — otherwise a
# topology.yaml tenant that ships with ANPs/EPGs already configured
# would boot into the same `set_site_anp()`-returns-None crash this
# PR fixes for the PATCH-built case.
normalize_template(template, schema_id)
for site in schema_sites:
if site.get("templateName") != tmpl_name:
continue
# PR-15: mirror each template BD into every site already
# associated with this template too — same rationale as the
# ANP/EPG mirroring below (and `_mirror_template_bd_to_sites` in
# patch.py, which does the equivalent for a runtime PATCH `add`):
# real NDO 4.x auto-creates the site BDDelta shadow the moment a
# template BD is added to a template with sites attached, and
# `mso_schema_site_bd.py` always PATCHes that shadow with `op:
# replace` (never `add`) — a topology.yaml tenant that boots with
# BDs already configured must start with the shadow already
# present, or the FIRST site-BD PATCH 400s with "'bd-X' not found
# at '/sites/{siteId}-{t}/bds/bd-X'" (confirmed on a real hardware
# aci-py create_tenant/create_bd run against a freshly-booted sim).
site.setdefault("bds", [])
for bd in template["bds"]:
bd_ref = "/schemas/{}/templates/{}/bds/{}".format(schema_id, tmpl_name, bd["name"])
site["bds"].append(_new_site_bd(bd_ref))
site.setdefault("anps", [])
for anp in template["anps"]:
site["anps"].append(
{"anpRef": anp["anpRef"], "epgs": [_new_site_epg(epg["epgRef"]) for epg in anp["epgs"]]}
)
# PR-13: mirror each template contract into every site already
# associated with this template too — same rationale as the
# ANP/EPG mirroring above, for the site-local `contracts[]`
# array a raw mso_rest-driven PATCH (e.g. the mso-model role's
# per-fabric service-graph redirect bind) addresses by bare
# contract name (`/sites/{siteId}-{t}/contracts/{c}/
# serviceGraphRelationship`).
site.setdefault("contracts", [])
for contract in template["contracts"]:
contract_ref = "/schemas/{}/templates/{}/contracts/{}".format(schema_id, tmpl_name, contract["name"])
site["contracts"].append({"contractRef": contract_ref})
normalize_site(site)
schema_name = f"{tenant.name}-schema"
schemas.append(
{
"id": schema_id,
"displayName": schema_name,
"name": schema_name,
# Summary shape: only template names (no inner lists)
"templates": [{"name": tmpl_name}],
}
)
schema_details[schema_id] = {
"id": schema_id,
"displayName": schema_name,
"name": schema_name,
"templates": [template],
"sites": schema_sites,
}
policy_states[schema_id] = [{"status": "synced", "drift": False}]
# ------------------------------------------------------------------
# Template summaries
# ------------------------------------------------------------------
# One tenantPolicy template (carries DHCP policies), plus one summary
# entry per schema template so ndo_deployment_status / stretch-analysis
# can enumerate them.
#
# PR-13: `MSOTemplate.__init__`'s name-based lookup
# (`ansible-mso`'s `plugins/module_utils/template.py`) filters
# `templates/summaries` entries by `templateName`+`templateType` (and
# tolerates absent `schemaName`/`schemaId` kwargs — `query_objs()` skips
# `None`-valued filter kwargs). Every summary entry must therefore carry
# a `templateName` field, not just `templateId`/`templateType`, or a
# by-name lookup (`ndo_template`/`ndo_dhcp_relay_policy`'s `template:
# "TenantPol-MS-TN1"` param) never matches anything.
template_summaries: list = [
{
"templateId": TENANT_POLICY_TEMPLATE_ID,
"templateName": "tenantpolicy-default",
"templateType": "tenantPolicy",
}
]
for tenant in topo.tenants:
s_id = _hex_id(f"schema-{tenant.name}")
tmpl_type = "stretched" if tenant.stretch else "application"
template_summaries.append(
{
"templateId": f"{s_id}-{tenant.name}",
"templateName": f"{tenant.name}-template",
"templateType": tmpl_type,
}
)
# ------------------------------------------------------------------
# Tenant policy template detail (DHCP relay + option policies)
# ------------------------------------------------------------------
# PR-13: keyed by templateId, one entry per `GET /templates/{id}` a
# caller might ask for. Every entry mirrors real NDO's generic template
# envelope shape: `{templateId, displayName, templateType,
# tenantPolicyTemplate:{template:{...}, sites:[{siteId}]}}` — the
# `sites` array (not just the inner `template` dict) is required because
# `ndo_template`'s "template already exists" PATCH branch
# (`append_site_config_to_ops`) reads
# `config.get(template_type_container, {}).get("sites", [])` directly.
tenant_policy_templates: dict = {
TENANT_POLICY_TEMPLATE_ID: {
"templateId": TENANT_POLICY_TEMPLATE_ID,
"displayName": "tenantpolicy-default",
"templateType": "tenantPolicy",
"tenantPolicyTemplate": {
"template": {
"dhcpRelayPolicies": [
{"uuid": DHCP_RELAY_UUID, "name": "dhcp-relay-1"},
],
"dhcpOptionPolicies": [
{"uuid": DHCP_OPTION_UUID, "name": "dhcp-option-1"},
],
},
"sites": [],
},
}
}
# ------------------------------------------------------------------
# Fabric connectivity
# ------------------------------------------------------------------
fabric_connectivity: dict = {
"sites": [
{
"id": site.id,
"siteId": site.id,
"status": "up",
"connectivityStatus": "up",
}
for site in topo.sites
]
}
# ------------------------------------------------------------------
# Audit records (fixed timestamps — no Date.now() / datetime.now())
# ------------------------------------------------------------------
audit_records: list = [
{
"id": "audit-001",
"timestamp": "2026-06-01T10:00:00Z",
"user": "admin",
"action": "create",
"description": "Schema Corp-schema created",
"details": "Initial schema creation via NDO",
},
{
"id": "audit-002",
"timestamp": "2026-06-01T10:05:00Z",
"user": "admin",
"action": "deploy",
"description": "Template Corp-template deployed to SiteA, SiteB",
"details": "Full deployment: 1 VRF, 2 BDs, 2 EPGs, 1 contract",
},
{
"id": "audit-003",
"timestamp": "2026-06-02T09:00:00Z",
"user": "operator",
"action": "update",
"description": "BD app-bd updated",
"details": "Changed ARP flood setting to enabled",
},
]
# ------------------------------------------------------------------
# ND local/remote users — PR-11
# ------------------------------------------------------------------
# `cisco.mso.mso_tenant`'s state=present flow always resolves the tenant's
# `users` list (defaulted to ["admin"] when unset) via `lookup_users()`,
# which on ND platforms queries `/nexus/infra/api/aaa/v4/localusers` +
# `/remoteusers` first, falling back to the legacy `/api/config/class/*`
# form when both come back empty (see ansible-mso module_utils/mso.py).
# The sim only serves the legacy `/api/config/class/remoteusers` route
# (see ndo/app.py), so seed a single "admin" local user in ND's
# `{"items":[{"spec":{...}}]}` aggregate shape — `get_user_from_list_of_
# users()` reads `item["spec"]["loginID"]` and needs `id`/`userID` on
# that same spec dict to build the `userAssociations` id list.
local_users: dict = {
"items": [
{
"spec": {
"loginID": "admin",
"loginDomain": None,
"id": "admin-local-0000000000000001",
"userID": "admin-local-0000000000000001",
}
}
]
}
# Remote (AAA/TACACS/LDAP) users start empty: `nd_request()` returns the
# raw JSON body untouched (see module_utils/mso.py `nd_request()`), and
# `get_user_from_list_of_users()` only iterates `body["items"]` when body
# is a dict — so any 200 response with an `"items"` key (even empty)
# short-circuits the "ND Error: Unknown error" 404 failure the real
# playbook run hit. No remote users are configured in the tenants under
# test, only the local "admin" ships above.
remote_users: dict = {"items": []}
return NdoState(
sites=sites,
tenants=tenants,
schemas=schemas,
schema_details=schema_details,
template_summaries=template_summaries,
tenant_policy_templates=tenant_policy_templates,
fabric_connectivity=fabric_connectivity,
policy_states=policy_states,
audit_records=audit_records,
local_users=local_users,
remote_users=remote_users,
)