Skip to content

Assemble a fabric from input objects, and migrate them by asking Ansible - #24

Open
mbakalarski wants to merge 9 commits into
mainfrom
input-kinds-resolution
Open

Assemble a fabric from input objects, and migrate them by asking Ansible#24
mbakalarski wants to merge 9 commits into
mainfrom
input-kinds-resolution

Conversation

@mbakalarski

@mbakalarski mbakalarski commented Aug 13, 2026

Copy link
Copy Markdown
Member

A Fabric gains four kinds of input object and an ordered list naming them, and
a 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 default hash_behaviour=replace.

Why nothing is merged

Two NodeSets carrying the same node-type key never meet, because no device
sees both: in a dual-DC fabric each leaf sees its own l3leaf.defaults. That is
what pyavd.get_avd_facts already expects to be handed. So a schema-aware
merge, a duplicate-key conflict rule and a defaults push-down are all avoided,
and with them any dependency on pyavd's private API.

The API

kind carries
NodeSet node-type blocks, type, node_type_keys — and the devices it declares
NetworkServiceSet the tenants (network_services_keys.name) and the native network_services spelling
ConnectedEndpointSet connected_endpoints_keys.key lists, port_profiles, network_ports
SettingSet everything else, narrowed to part of the fabric

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.

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 cameras from
connected_endpoints_keys. What stays a literal is the handful of keys that are
not settings, and a test holds it against documentation_options.table in AVD's
own schema in both directions.

Only NodeSet declares devices. spec.declares is the fabric's device list
and there is no second one. Never a pattern: visibility may be matched,
existence may not, since a typo would silently drop devices.

appliesTo takes all / nodeSets / hosts / matchHostnames, and
saying 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.py anchors the pattern for you, while the schema's
wording reads as though the author must.

Secret is in the requires enum 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_password and type-7 BGP
passwords, and type 7 is reversible — pyavd ships bgp_decrypt.

All six XRDs gain categories: [crossplane, netclab]. Verified on a live
cluster: kubectl api-resources --categories=netclab lists the set. It only
shows on a fresh install — Crossplane's dependency manager installs but does not
upgrade.

Collection and the gate

Fabric.spec.requires names each input by kind, name and namespace, and only
what 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 (WaitingForInputs against InputsMissing); the function refuses to
render without its inputs.

Cross-namespace collection is proven on a cluster, not only in render: a
Fabric in avd naming a SettingSet in net-svc reached Ready=True and
ntp server vrf MGMT appeared in spine1's rendered config. Crossplane's default
RBAC permits it; no extra grant was needed.

The migration asks Ansible

function/ansible_cli.py puts three questions to the Ansible CLI:

question how
which group carries which variable ansible-inventory --list --export
what a play resolves those variables to ad-hoc debug with --tree
which play runs eos_designs, on which devices ansible-playbook --list-hosts --list-tasks

Nothing here parses a group_vars tree, expands a host pattern or opens a vault.
--export already merges every shape a hand-written reader has to get right —
.yaml spellings, host_vars/<host>/ directories, inline group vars: blocks —
so those are closed by construction. --list-hosts resolves the play's pattern
with Ansible's own engine, including ! and :&.

function/migrate.py makes one Fabric per eos_designs play plus one input XR
per ownership fragment per category, and ships as avd-migrate with --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-inventory does not decrypt, it emits
{"__ansible_vault": "$ANSIBLE_VAULT;1.1;..."}, which reads like a resolved
value in a diff and is not.

ansible-core is a development dependency. The runtime is handed input XRs
and never sees an inventory — fn.py imports push, engine and kinds and
nothing 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.

check result
hostvars identical to what Ansible reports 8/8 examples + 19/19 molecule scenarios, up to 501 devices in one play
rendered configs match checked-in golden 8/8 examples · 8/12 molecule scenarios shipping one

cv-pathfinder renders clean for the first time. The four that remain are two
causes, and neither is reachable from this repository.
Three of them meet the
same wall: pyavd implements no Jinja templating — get_device_structured_config
passes templar=None and the call raises NotImplementedError, and AVD's own
action 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-clos looks like a third cause (pool_manager) and is
not: supplying the public PoolManager gets it past that error and it fails
immediately on custom_templates/ip_addressing/…j2. Implementing pool_manager
would gain no golden — it is worth building for fabrics that use ID pools, not
for this metric.

avd-migrate --drop-description-templates renders past the first of those where
losing it is cosmetic — measured on evpn_underlay_ebgp_overlay_ebgp, all 16
devices render and differ from golden in 168 places, every one a description.
Addressing templates are never dropped: eos_designs-twodc-5stage-clos computes
its 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

  • Every new guard was verified to fail without its change: removing type
    from the not-a-setting list gives
    kind_of disagrees with AVD's own table: {'type': ('SettingSet', 'NodeSet')}.
  • The order guard uses campus-fabric because precedence is load-bearing in
    only two of the eight examples
    ; reversing the order changes nothing in
    single-dc-l3ls or dual-dc-l3ls, so a guard written against either would
    pass while proving nothing.
  • Offline suite 79 tests in ~50s, plus 31 behind -m corpus in ~3.5min.
  • The Configuration package still builds from apis/ with all six XRDs.
  • uv sync --locked clean. No version bump in this PRpyproject.toml
    and uv.lock must move together, in one commit, before any tag.

Not in scope

_reconcile_input does not fill status.devices: an input does not know the
fabric's device list. The Secret implementation, pool_manager, and whether
pyavd can validate a fragment standalone are all separate.

🤖 Generated with Claude Code

mbakalarski and others added 3 commits August 13, 2026 13:16
…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>
@mbakalarski mbakalarski changed the title Resolve a fabric's inputs per device, the way Ansible resolves group_vars Assemble a fabric from input objects, resolved per device like group_vars Aug 13, 2026
mbakalarski and others added 3 commits August 13, 2026 14:57
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>
@mbakalarski mbakalarski changed the title Assemble a fabric from input objects, resolved per device like group_vars Assemble a fabric from input objects, and migrate them by asking Ansible Aug 20, 2026
mbakalarski and others added 2 commits August 21, 2026 09:09
- 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
mbakalarski force-pushed the input-kinds-resolution branch from 0c57721 to 01a5272 Compare August 21, 2026 09:09
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant