Skip to content

Assemble a fabric from input objects, resolved per device like group_vars - #24

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

Assemble a fabric from input objects, resolved per device like group_vars#24
mbakalarski wants to merge 5 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.
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 —
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 here, so a schema-aware merge, a duplicate-key
conflict rule and the defaults push-down are all avoided, and with them any
dependency on pyavd's private API.

The API

kind carries
NodeSet one node-type block's share — <role>.defaults, .nodes[], .node_groups[]
NetworkServices the tenants (network_services_keys.name)
ConnectedEndpoints connected_endpoints_keys.key lists, port_profiles, network_ports
Settings 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.

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, 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. 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: 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. Fabric and Device are fixed here too,
though it will only show on a fresh install — Crossplane's dependency manager
installs but does not upgrade.

Evidence

Measured against AVD's own corpus rather than argued.

check result
hostvars identical to faithfully reproduced Ansible 25/25 inventories — 8 bundled examples + every molecule scenario with an inventory of its own, up to 501 devices
rendered configs match checked-in golden 7/8 examples; cv-pathfinder deferred

avd-verify-kinds and avd-verify-kinds --render run both from the CLI.

Three tests, doing different jobs

test_resolves_identically_to_ansible is the guard on the model, and it is
stricter than a render comparison on purpose: it fails on a hostvar difference
even where AVD would render the same config, so a divergence cannot hide until
it matters. Five seconds for the whole corpus, because nothing renders.

test_render_reproduces_golden adds nothing there — matching hostvars render
identically. It guards pyavd changing under us. That is what test_xr_fold
does today through the fold, and this is its successor: the fold reaches 6 of 8
examples, this reaches 7, campus-fabric being among the fold's deferrals.
Both nets run in parallel deliberately; removing the older one is a separate
change, so the swap shows up in a diff rather than being taken on trust.

test_apis_consistency guards what a build cannot and a cluster would find one
release later: 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, not assumed

  • Both new tests can fail — verified by perturbing a golden value, and by
    dropping a categories block, rather than by trusting they would.
  • Offline suite: 61 tests in 10.7s → 84 in 15.0s.
  • The Configuration package still builds from apis/, now carrying all six
    XRDs and Compositions.
  • uv sync --locked clean; no version bump in this PR.

Not in scope

_reconcile_input does not fill status.devices: an input does not know the
fabric's device list, so appliesTo only resolves where inputs are collected —
the Fabric will fill it. Collection itself (requirements.resources), the gate
that stops a render before its inputs arrive, and the Secret implementation
are the next steps.

🤖 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 2 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>
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