mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
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:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user