Assemble a fabric from input objects, and migrate them by asking Ansible - #24
Open
mbakalarski wants to merge 9 commits into
Open
Assemble a fabric from input objects, and migrate them by asking Ansible#24mbakalarski wants to merge 9 commits into
mbakalarski wants to merge 9 commits into
Conversation
…vars A Fabric will name several input XRs, each carrying a fragment of the eos_designs document plus the devices that see it. This lands the resolution: per device, layer the inputs that apply, in order, with dict.update(). Nothing is merged. Two NodeSets carrying the same node-type key never meet, because no device sees both -- a dual-DC fabric's leaves each see their own l3leaf.defaults. That is what pyavd.get_avd_facts already expects to be handed; engine.render_fabric_design flattens it today by giving every device the same document, and xr.fabric_design_from_inputs exists to squeeze many hostvars back into one. Neither is needed on this path, so MergeOnSchema, a duplicate-key conflict rule and the defaults push-down are all avoided -- and with them any dependency on pyavd's private API. Two things that look like one are kept apart: which devices an input *declares* (the union of these is the fabric's device list, and there is no second list) and which devices *see* it (appliesTo). They coincide in simple topologies and diverge in a 5-stage CLOS, where a DC's super_spine block names four devices but is visible to all sixteen of that DC. Measured against AVD's own corpus rather than argued: the hostvars this produces are byte-identical to faithfully reproduced Ansible across all 8 bundled examples and every molecule scenario with an inventory of its own -- 25 inventories, up to 501 devices, in five seconds because nothing renders. The test is stricter than a render comparison on purpose, so a divergence cannot hide until it matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolution equivalence already proves the model: matching hostvars render identically, because it is the same function on the same input. So this adds nothing there, and that is not its job. It guards the pair (this path, this pyavd) -- an AVD upgrade that changes output slips past equivalence and fails here. That is what test_xr_fold does today through the fold, and this is its successor: the fold reaches 6 of the 8 examples, this reaches 7. campus-fabric is among the two the fold defers, with the reason "aaa_settings.radius differs by role; no node-scoped equivalent" -- there is no equivalent to find when nothing is folded, so it simply renders. Both nets run in parallel for now; removing the older one is a separate change, so the swap is visible in a diff rather than taken on trust. cv-pathfinder is deferred: its credentials are ansible-vault, and credentials cannot live in an XR spec. It carries XPASS semantics, so it will report itself the day that is fixed. Examples only -- the molecule scenarios need AVD features this path does not carry yet. Costs 5s: the offline suite goes 61 tests in 10.7s to 69 in 15.7s. Checked that the test can actually fail, by perturbing a golden value and watching it go red rather than by trusting that it would. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four XRDs -- NodeSet, NetworkServices, ConnectedEndpoints, Settings -- each carrying a fragment of the eos_designs document in an open spec.design, plus spec.appliesTo saying which devices see it. Fabric gains spec.requires, an ordered list naming its inputs. The kinds separate ownership, not content: RBAC is granted per kind, and eos_designs' top-level key names come from its own content, so no schema could partition them anyway. Only NodeSet declares devices. spec.declares is the fabric's device list and there is no second one -- a node named in a block the fabric does not declare is not a device. Never a pattern, either: visibility may be matched, existence may not, since a typo would silently drop devices. appliesTo takes all / nodeSets / hosts / matchHostnames. The pattern form copies AVD's own hostname matching from default_node_types -- and from the code, not the description: shared_utils/node_type.py anchors the pattern for you, while the schema's wording reads as though the author must. Copying the wording would have made the same pattern mean two things. A pattern matching nothing is an error rather than an empty set; kinds.unmatched_patterns() surfaces it, because a pattern is silent about matching nothing and the render is pushed as a full config replacement. Secret is in the requires enum from this first version. It is not implemented yet, but the mechanism it enables -- a Secret layered like any other input -- needs no other schema footprint, and adding the enum value after release would be a schema change to a published API. Credentials are not hypothetical here: the bundled examples carry sha512_password and type-7 BGP passwords, and type 7 is reversible, pyavd ships bgp_decrypt. All six XRDs gain categories [crossplane, netclab]. function-avd had them nowhere while netclab-xp carries them on all twelve, so `kubectl get netclab` returned nothing in the avd namespace. This lands the fix for Fabric and Device as well -- though it will only show on a fresh install, since Crossplane's dependency manager installs but does not upgrade. test_apis_consistency guards what a build cannot: that the XRDs and the kinds fn.py reconciles are the same set, that every XRD carries categories, and that each defaultCompositionRef resolves to a Composition for that kind. Checked it can fail, by dropping a categories block and watching it go red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Fabric asks Crossplane for each entry in spec.requires by kind, name and namespace, layers what comes back, and renders per device. A Fabric with no requires takes the released path unchanged: one document handed to every device. The gate is the point. Requirements are answered on the *next* reconcile, so the first one always arrives with nothing at all -- rendering then would push a fabric short of its inputs, as a full config replacement, with no Delete. So an unresolved requires composes nothing. The proto settles a question this design could not answer before: "not yet" and "never" are distinguishable. Crossplane sends an empty Resources for a requirement it looked for and did not find, and omits the key entirely when it has not fetched yet. Both gate, but they are different states and the condition says which -- WaitingForInputs against InputsMissing. Two refusals rather than a silent render, both because the alternative reaches a device. A matchHostnames pattern that matches nothing is fatal: a pattern is silent about matching nothing, so it cannot be allowed to be. A Secret named in requires is fatal too -- it is in the enum so the mechanism can land without a schema change, but rendering a fabric whose credentials are quietly absent is worse than not rendering. Values replaced by a later input are reported as a warning, never an error: the order is declared by whoever wrote requires, so an override is intentional. First tests in this repo to drive RunFunction. They cover both gate states, both refusals, that resolved inputs compose devices, that each input kind reconciles and reports its own keys, and that a fabric with only spec.design still composes -- the last one guarding the refactor that put both paths through render_structured_configs, since v0.1.6 is published and netclab-xp pins it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects in scripts/kind-up.sh, both found by running it rather than reading it, and both invisible to CI because CI does not run this script. The xpkg build passed no --examples-root. That is not "no examples": it means everything under examples/, including examples/lab/topology.yaml, which is helm values with no `kind`. So the build died with "Object 'Kind' is missing" -- the same failure that killed the v0.1.4 release. CI and the release workflow have named examples/fabric explicitly since, in four places, with comments citing that release. This script was the build path that never got the fix, so a local bring-up has been broken since the topology was committed in #19. And it installed two named XRDs while the package root ships whatever is under apis/, so a cluster built by this script no longer matched the package it was built from -- with the input kinds missing exactly where a Fabric that names them is being tested. It applies apis/*/ now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five changes to the input-kind model, all found by generating the XRs the migration produces and reading them. Renamed to a Set family while renaming is still free: NetworkServiceSet, ConnectedEndpointSet, SettingSet. The three plural kinds declared plurals identical to their own lower-cased kind, so nothing on the CLI told one object from all of them. Kubernetes kinds are singular, and its own plural kind -- core/v1 Endpoints -- is the one it superseded with EndpointSlice. These XRDs are unpublished; after a tag this would be a breaking change with no in-place path. classify learned the native network_services spelling. AVD 6.x reads it as well as the dynamic keys named by network_services_keys, so a document written the new way became a SettingSet, carrying its BGP and OSPF passwords into the kind that owns fabric-wide settings. Latent: no XR in AVD's corpus changes kind. appliesTo on a NodeSet now defaults to what it declares. Defaulting to the whole fabric made every migrated NodeSet name itself, 26 of 26 across the examples, because Ansible has no unscoped group_vars file. The widened case stays expressible and is still needed by a 5-stage CLOS. A group's vars become one input per category, merged as Ansible merges them and split after. cv-pathfinder's group_vars/WAN/ is four files the author had already separated; merged and classified whole, its single tenants key decided the kind for 22. 517 of 1117 XRs mixed categories, now none. The device list is the play's hosts, not the inventory's. cv-pathfinder's inventory holds cloudvision so cv_deploy can reach the CloudVision API server; it is not a switch, and a declared device with no node type fails the whole fabric's render. All 25 inventories name their hosts in a playbook, so this reads what AVD acts on rather than guessing. Gated on measuring peers: the 16 references to dropped hosts are all in the negative fixtures, where AVD itself runs each case without them. Suite 101 green. Each guard was verified to fail without its change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- function/ansible_cli.py replaces our own reader of inventories, group_vars and host_vars: ansible-inventory --export, an ad-hoc debug run, and ansible-playbook --list-hosts --list-tasks. - function/migrate.py emits one Fabric per play plus one input XR per ownership fragment per category, and refuses to emit unless its layering matches Ansible. - Values are what the play produced, not what the file says -- vault included. - classify reads its vocabulary from pyavd's public schema, not a literal. - avd-migrate reports what it cannot carry; --drop-description-templates never touches an ip_addressing template. - Gone with the fold path: xr.py, ansible_inputs.py, verify_xr.py, verify_example.py, verify_kinds.py and two test files. - Hostvars byte-identical to Ansible: 8 examples, 19 molecule scenarios. Against AVD's golden: 8/8 examples (was 7), 8/12 molecule (was 5); the four left are upstream. - 79 offline tests green, 31 more behind -m corpus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The node-ID pool lives in a ConfigMap the Fabric composes; spec.nodeIdPool.seedConfigMapName seeds it from a fabric running elsewhere. - function/avd_compat.py: AVD's v2.x spine addressing as an AvdIpAddressing subclass, where pyavd implements no Jinja templating. - avd-migrate reports what it cannot carry: pool assignments, play-level vars. Fabric names are unique per play now. - Fix: the Fabric XRD required spec.design, which avd-migrate never emits. - Fix: uppercase hostnames could not be composed. - Fix: scripts/kind-up.sh installed one XRD out of six. - e2e: all eight bundled AVD examples migrate, apply and render AVD's golden on a cluster -- every device, one namespace each. - tests/test_e2e_node_id_pool.py is strict-xfail: twodc is 26/26 offline and 12 differences on a cluster. Not diagnosed. - 91 offline tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mbakalarski
force-pushed
the
input-kinds-resolution
branch
from
August 21, 2026 09:09
0c57721 to
01a5272
Compare
- function/nulls.py: an API server prunes a null map value out of an open field, so it travels as avd.netclab.dev/null and is restored after layering. That was the cause of twodc's 12 service_profile differences -- decoder off, exactly those 12 return. - avd-migrate --emit-pool-seed carries the node-ID assignments as a ConfigMap the emitted Fabric seeds from. Without them twodc does not render at all: its v2.x addressing derives addresses from ids, so a fresh set collides. - avd-migrate --compat-ip-addressing points ip_addressing at function.avd_compat where the pinned templates are the ones it transcribes; any other template is left alone and stays reported. - Emitted manifests name no namespace, so `kubectl -n <ns> apply -f` decides. --namespace pins them. - The composed pool carries avd.netclab.dev/artifact=node-id-pool. The fabric label is on all 27 ConfigMaps it composes, so the pool could not be selected. - examples/fabric/inputs: seven of AVD's bundled inventories plus twodc, as input XRs and a Fabric using spec.requires -- four of the six kinds had no example in the package. cv-pathfinder is left out; it is the only one that would publish a value AVD keeps in a vault. - tests/test_e2e_node_id_pool.py drops its strict-xfail. One mark hid two failures: the nulls, and a pool lookup that was reading a device render. - README rewritten, 393 -> 201 lines: it said two composite kinds, marked two examples deferred that now render golden, and carried a sentence left mangled by the fold's deletion. fn.py's docstring said two kinds as well; there are six. - 97 offline tests green, 12 e2e green on a cluster built from scratch, and the shipped twodc example renders AVD's golden on 26 of 26 devices from a namespace it does not name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A
Fabricgains four kinds of input object and an ordered list naming them, anda migration that turns an AVD Ansible inventory into exactly that — by asking
Ansible what the inventory means rather than reproducing it.
Each input carries a fragment of the eos_designs document plus the devices that
see it; per device, the inputs that apply are layered in order with
dict.update()— Ansible's defaulthash_behaviour=replace.Why nothing is merged
Two
NodeSets carrying the same node-type key never meet, because no devicesees both: in a dual-DC fabric each leaf sees its own
l3leaf.defaults. That iswhat
pyavd.get_avd_factsalready expects to be handed. So a schema-awaremerge, a duplicate-key conflict rule and a
defaultspush-down are all avoided,and with them any dependency on pyavd's private API.
The API
NodeSettype,node_type_keys— and the devices it declaresNetworkServiceSetnetwork_services_keys.name) and the nativenetwork_servicesspellingConnectedEndpointSetconnected_endpoints_keys.keylists,port_profiles,network_portsSettingSetThe kinds separate ownership, not content: RBAC is granted per kind, and
eos_designs' top-level key names come from its own content, so no schema could
partition them anyway.
Which kind a key belongs to is read from pyavd's public schema, not from a
literal — the literal it replaced was already short, missing
camerasfromconnected_endpoints_keys. What stays a literal is the handful of keys that arenot settings, and a test holds it against
documentation_options.tablein AVD'sown schema in both directions.
Only
NodeSetdeclares devices.spec.declaresis the fabric's device listand there is no second one. Never a pattern: visibility may be matched,
existence may not, since a typo would silently drop devices.
appliesTotakesall/nodeSets/hosts/matchHostnames, andsaying nothing is the common case — the whole fabric, except on a
NodeSet,where it means what that NodeSet declares. The pattern form copies AVD's own
hostname matching from the code rather than the description:
shared_utils/node_type.pyanchors the pattern for you, while the schema'swording reads as though the author must.
Secretis in therequiresenum from this first version, unimplemented.The mechanism needs no other schema footprint, and adding the enum value after
release would be a schema change to a published API. Credentials are not
hypothetical: the bundled examples carry
sha512_passwordand type-7 BGPpasswords, and type 7 is reversible — pyavd ships
bgp_decrypt.All six XRDs gain
categories: [crossplane, netclab]. Verified on a livecluster:
kubectl api-resources --categories=netclablists the set. It onlyshows on a fresh install — Crossplane's dependency manager installs but does not
upgrade.
Collection and the gate
Fabric.spec.requiresnames each input by kind, name and namespace, and onlywhat it names takes part in the render — so the rendered document is a function
of the Fabric, not of whatever else exists in the namespace. That matters
because the render is pushed to a device as a full configuration replacement.
"Not yet fetched" and "does not exist" are distinguishable and reported
differently (
WaitingForInputsagainstInputsMissing); the function refuses torender without its inputs.
Cross-namespace collection is proven on a cluster, not only in
render: aFabricinavdnaming aSettingSetinnet-svcreachedReady=Trueandntp server vrf MGMTappeared in spine1's rendered config. Crossplane's defaultRBAC permits it; no extra grant was needed.
The migration asks Ansible
function/ansible_cli.pyputs three questions to the Ansible CLI:ansible-inventory --list --exportdebugwith--treeansible-playbook --list-hosts --list-tasksNothing here parses a
group_varstree, expands a host pattern or opens a vault.--exportalready merges every shape a hand-written reader has to get right —.yamlspellings,host_vars/<host>/directories, inline groupvars:blocks —so those are closed by construction.
--list-hostsresolves the play's patternwith Ansible's own engine, including
!and:&.function/migrate.pymakes oneFabricper eos_designs play plus one input XRper ownership fragment per category, and ships as
avd-migratewith--emit.The one rule the Ansible CLI does not print is group order (depth, then
name). The migration layers the fragments with it and refuses to emit
anything unless the result equals what Ansible reports. A wrong order cannot
reach an XR.
Values are what the play produced, not what the file says — an XR has no
templating engine, no vault and no playbook directory. The trigger is not "does
this look like Jinja" but "did the play produce something else", which needs no
pattern and catches vault too:
ansible-inventorydoes not decrypt, it emits{"__ansible_vault": "$ANSIBLE_VAULT;1.1;..."}, which reads like a resolvedvalue in a diff and is not.
ansible-coreis a development dependency. The runtime is handed input XRsand never sees an inventory —
fn.pyimportspush,engineandkindsandnothing else — so it reaches neither the image nor the published package.
Evidence
Measured against AVD's own corpus rather than argued, with Ansible itself as
the reference.
cv-pathfinderrenders clean for the first time. The four that remain are twocauses, and neither is reachable from this repository. Three of them meet the
same wall: pyavd implements no Jinja templating —
get_device_structured_configpasses
templar=Noneand the call raisesNotImplementedError, and AVD's ownaction plugin reaches into pyavd's internal API to hand in Ansible's templar.
The fourth loads a custom Python module at render time.
eos_designs-twodc-5stage-closlooks like a third cause (pool_manager) and isnot: supplying the public
PoolManagergets it past that error and it failsimmediately on
custom_templates/ip_addressing/…j2. Implementingpool_managerwould gain no golden — it is worth building for fabrics that use ID pools, not
for this metric.
avd-migrate --drop-description-templatesrenders past the first of those wherelosing it is cosmetic — measured on
evpn_underlay_ebgp_overlay_ebgp, all 16devices render and differ from golden in 168 places, every one a
description.Addressing templates are never dropped:
eos_designs-twodc-5stage-closcomputesits P2P uplink IPs that way, and one flag over both would have silently emitted a
different network.
What this replaces
An earlier implementation — a hand-written reader of inventories, group_vars and
host_vars, a fold squeezing many hostvars into one document, and their
verification harnesses — is deleted along with it (
xr.py,ansible_inputs.py,verify_xr.py,verify_example.py,verify_kinds.py, two test files).Its equivalence test compared two of our own readers against each other, so a
source neither read vanished from both sides and the comparison agreed. Four
gaps sat behind that green result and surfaced only when a render disagreed with
AVD's own golden. Real Ansible on one side is what closed them.
Checked, not assumed
typefrom the not-a-setting list gives
kind_of disagrees with AVD's own table: {'type': ('SettingSet', 'NodeSet')}.campus-fabricbecause precedence is load-bearing inonly two of the eight examples; reversing the order changes nothing in
single-dc-l3lsordual-dc-l3ls, so a guard written against either wouldpass while proving nothing.
-m corpusin ~3.5min.apis/with all six XRDs.uv sync --lockedclean. No version bump in this PR —pyproject.tomland
uv.lockmust move together, in one commit, before any tag.Not in scope
_reconcile_inputdoes not fillstatus.devices: an input does not know thefabric's device list. The
Secretimplementation,pool_manager, and whetherpyavd can validate a fragment standalone are all separate.
🤖 Generated with Claude Code