Skip to content

Commit 01a5272

Browse files
mbakalarskiclaude
andcommitted
Keep a fabric's node IDs on the cluster, and check the whole chain there
- 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>
1 parent b784f4d commit 01a5272

12 files changed

Lines changed: 1258 additions & 16 deletions

apis/fabric/xrd.yaml

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,35 @@ spec:
8888
required:
8989
- kind
9090
- name
91+
nodeIdPool:
92+
type: object
93+
description: >-
94+
Where this fabric's node IDs come from when the design sets
95+
fabric_numbering.node_id.algorithm to pool_manager. AVD then
96+
hands out IDs instead of reading them off each node, and
97+
keeps the assignments in a file. There is no such file in a
98+
cluster, so the Fabric composes a ConfigMap and reads it back
99+
on the next reconcile. Nothing here is needed for a fabric
100+
that numbers its nodes itself.
101+
properties:
102+
seedConfigMapName:
103+
type: string
104+
description: >-
105+
A ConfigMap holding assignments to start from, read once
106+
when this fabric has no pool of its own yet. It exists
107+
for a fabric that was already running elsewhere: AVD
108+
generated its IDs and they are in a file, and a fabric
109+
that starts over assigns different ones - which reaches
110+
every device as a full configuration replacement. Not
111+
needed for a new fabric, and ignored once the composed
112+
pool exists, so it seeds rather than overrides.
113+
minLength: 1
114+
seedKey:
115+
type: string
116+
description: >-
117+
Key within that ConfigMap. Defaults to the key the
118+
composed pool uses.
119+
minLength: 1
91120
push:
92121
type: object
93122
description: >-
@@ -126,9 +155,14 @@ spec:
126155
description: cEOS serves a self-signed cert; default true.
127156
required:
128157
- credentialsSecretName
158+
# `design` is NOT required: a fabric may be assembled entirely from
159+
# the inputs named in `spec.requires`, which is what `avd-migrate`
160+
# emits and what `fn.py` has always accepted ("design or requires").
161+
# Requiring it here made every migrated Fabric unapplyable, and no
162+
# offline test could see it -- they drive RunFunction directly and
163+
# never meet the API server's schema.
129164
required:
130165
- fabricName
131-
- design
132166
status:
133167
type: object
134168
properties:

function/avd_compat.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""AVD behaviours pyavd cannot reach, written as the classes AVD asks for.
2+
3+
pyavd implements **no Jinja templating**: `get_device_structured_config` passes
4+
`templar=None` and the call raises `NotImplementedError`, and no public entry
5+
point accepts a templar. So a design pinning a `.j2` path cannot render here,
6+
wherever the file is carried.
7+
8+
The same schema blocks — `node_type_keys[].ip_addressing` and
9+
`.interface_descriptions` — take `python_module` / `python_class_name` instead,
10+
and **that route pyavd supports**: `load_python_class` imports the module by
11+
dotted path and checks it against the public base class. A module shipped inside
12+
this package is importable by dotted path, so pointing a design at
13+
`function.avd_compat` loads no arbitrary code — it loads ours.
14+
15+
This is not a general answer. It reproduces one specific, published scheme.
16+
Anyone whose fabric uses a template of their own writes their own class and
17+
builds their own function image on this one.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import ipaddress
23+
24+
from pyavd.api.ip_addressing import AvdIpAddressing
25+
26+
27+
class AvdIpAddressingV2Spine(AvdIpAddressing):
28+
"""AVD v2.x spine-to-super-spine P2P addressing.
29+
30+
A transcription of the two templates `eos_designs-twodc-5stage-clos` pins,
31+
which say what they are: *"In AVD v2.x the spine to super-spine links used
32+
this special IP addressing scheme. This file may still be used by older
33+
inventories."*
34+
35+
⚠ The comment describes where they came from, not that they are inert. The
36+
scheme divides the uplink pool by `max_uplink_switches` so that adding a
37+
spine does not move existing addresses — which is why that fabric's golden
38+
puts a spine's two super-spine uplinks 64 apart rather than adjacent. AVD's
39+
native algorithm packs them contiguously and the two are not interchangeable.
40+
41+
Everything else falls through to `AvdIpAddressing`, so a fabric selecting
42+
this class changes only its P2P uplinks.
43+
"""
44+
45+
def _v2_p2p(self, uplink_switch_index: int, last: int) -> str:
46+
pool = ipaddress.ip_network(self._uplink_ipv4_pool, strict=False)
47+
offset = (self._id - 1) % self._max_parallel_uplinks
48+
index = (
49+
(pool.num_addresses // self._max_uplink_switches) * int(uplink_switch_index)
50+
+ ((self._id - 1) * self._max_parallel_uplinks + offset) * 2
51+
+ last
52+
)
53+
return str(pool.network_address + index)
54+
55+
def p2p_uplinks_ip(self, uplink_switch_index: int) -> str:
56+
return self._v2_p2p(uplink_switch_index, 1)
57+
58+
def p2p_uplinks_peer_ip(self, uplink_switch_index: int) -> str:
59+
return self._v2_p2p(uplink_switch_index, 0)

function/engine.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,19 +85,27 @@ def validate_all(all_inputs: dict[str, dict]) -> dict[str, list]:
8585

8686

8787
def render_structured_configs(
88-
all_inputs: dict[str, dict], *, validate: bool = True
88+
all_inputs: dict[str, dict], *, validate: bool = True, pool_manager: object = None
8989
) -> dict[str, dict]:
9090
"""Run facts + per-device structured config for the whole fabric.
9191
9292
``get_avd_facts`` is fabric-wide (needs every device at once); the structured
9393
config is then derived per device from those shared facts.
94+
95+
``pool_manager`` is required only by a fabric setting
96+
``fabric_numbering.node_id.algorithm: pool_manager``, which asks AVD to
97+
assign node IDs from a pool instead of reading them off each node. ⚠ The
98+
pool is **a file** (`pyavd.api.pool_manager.PoolManager(output_dir)`), and
99+
the assignments have to survive between runs or every device is renumbered —
100+
so nothing composes one yet, and a Fabric that asks for it fails with AVD's
101+
own message until a Fabric has somewhere to keep it.
94102
"""
95103
if validate:
96104
violations = validate_all(all_inputs)
97105
if violations:
98106
raise InputValidationError(violations)
99107

100-
avd_facts = pyavd.get_avd_facts(all_inputs)
108+
avd_facts = pyavd.get_avd_facts(all_inputs, pool_manager=pool_manager)
101109
return {
102110
hostname: pyavd.get_device_structured_config(
103111
hostname, inputs, avd_facts=avd_facts

function/fn.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import hashlib
20+
import re
2021
from datetime import datetime, timezone
2122

2223
import pyavd
@@ -25,7 +26,7 @@
2526
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
2627
from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1
2728

28-
from . import push
29+
from . import pools, push
2930
from .engine import (
3031
InputValidationError,
3132
device_roles_from_design,
@@ -64,6 +65,19 @@ def _normalize_numbers(obj):
6465
return obj
6566

6667

68+
def _dns_name(hostname: str) -> str:
69+
"""A hostname as a Kubernetes object name may spell it.
70+
71+
AVD hostnames are free text and two of its eight bundled examples --
72+
`campus-fabric` and `l2ls-fabric` -- write them entirely in capitals. A
73+
composed resource named after one is rejected outright: *"invalid name
74+
... Must be a valid RFC 1123 subdomain name"*, so the whole fabric fails to
75+
compose. The hostname itself is untouched; only the object's name is spelled
76+
this way.
77+
"""
78+
return re.sub(r"[^a-z0-9-]+", "-", hostname.lower()).strip("-") or "device"
79+
80+
6781
def _now() -> str:
6882
return datetime.now(timezone.utc).isoformat(timespec="seconds")
6983

@@ -166,8 +180,47 @@ def _reconcile_fabric(
166180
document["fabric_name"] = fabric_name
167181
all_inputs = {host: document for host in hostnames_from_design(document)}
168182

183+
# A fabric that asks for pool-assigned node IDs needs its assignments
184+
# back before it renders, or every device is renumbered on every pass.
185+
keeps_a_pool = pools.wanted_by(all_inputs)
186+
pool = ""
169187
try:
170-
structured_configs = render_structured_configs(all_inputs)
188+
if keeps_a_pool:
189+
previous = pools.observed_pool(req.observed.resources)
190+
if not previous.strip():
191+
# Only before this fabric has a pool of its own. Once it has,
192+
# the seed is history and must not override it.
193+
state, seeded = pools.seed(req, rsp, spec, namespace)
194+
if state == "pending":
195+
# The normal first pass, exactly as for the named inputs.
196+
response.set_conditions(
197+
rsp,
198+
resource.Condition(
199+
typ="InputsResolved",
200+
status="False",
201+
reason="WaitingForSeed",
202+
message="waiting for the node-ID pool seed ConfigMap",
203+
),
204+
)
205+
response.normal(rsp, "waiting for the node-ID pool seed")
206+
return
207+
if state == "missing":
208+
response.fatal(
209+
rsp,
210+
"spec.nodeIdPool.seedConfigMapName names a ConfigMap that "
211+
"does not exist; rendering without it would renumber every "
212+
"device",
213+
)
214+
return
215+
previous = seeded
216+
with pools.pool_manager(all_inputs, previous) as (manager, pool_file):
217+
structured_configs = render_structured_configs(
218+
all_inputs, pool_manager=manager
219+
)
220+
manager.save_updated_pools()
221+
pool = pool_file.read_text() if pool_file.is_file() else previous
222+
else:
223+
structured_configs = render_structured_configs(all_inputs)
171224
except InputValidationError as err:
172225
resource.update_status(
173226
rsp.desired.composite,
@@ -179,6 +232,15 @@ def _reconcile_fabric(
179232
response.fatal(rsp, f"AVD render failed: {type(err).__name__}: {err}")
180233
return
181234

235+
if keeps_a_pool:
236+
# Composed after the render, so a failed render never overwrites a
237+
# good pool with a partial one.
238+
resource.update(
239+
rsp.desired.resources[pools.RESOURCE_NAME],
240+
pools.configmap(xr_name, namespace, fabric_name, pool),
241+
)
242+
rsp.desired.resources[pools.RESOURCE_NAME].ready = fnv1.READY_TRUE
243+
182244
push_spec = spec.get("push") or {}
183245
if push_spec and not push_spec.get("credentialsSecretName"):
184246
response.fatal(rsp, "spec.push.credentialsSecretName is required when push is set")
@@ -197,6 +259,20 @@ def _reconcile_fabric(
197259
host: device_roles_from_design(hostvars).get(host) or hostvars.get("type", "")
198260
for host, hostvars in all_inputs.items()
199261
}
262+
# Two hostnames that differ only in case, or only in a character a
263+
# Kubernetes name cannot carry, would compose a single Device between
264+
# them -- one config silently standing in for two switches.
265+
spelled: dict[str, str] = {}
266+
for hostname in sorted(structured_configs):
267+
clash = spelled.setdefault(_dns_name(hostname), hostname)
268+
if clash != hostname:
269+
response.fatal(
270+
rsp,
271+
f"devices {clash!r} and {hostname!r} both need the object name "
272+
f"{_dns_name(hostname)!r}; rename one",
273+
)
274+
return
275+
200276
observed_devices = req.observed.resources # keyed by composition-resource-name (hostname)
201277
devices = []
202278
for hostname, structured_config in structured_configs.items():
@@ -207,7 +283,7 @@ def _reconcile_fabric(
207283
"apiVersion": API_VERSION,
208284
"kind": "Device",
209285
"metadata": {
210-
"name": resource.child_name(xr_name, hostname),
286+
"name": resource.child_name(xr_name, _dns_name(hostname)),
211287
"namespace": namespace,
212288
"labels": {
213289
"avd.netclab.dev/fabric": fabric_name,

function/migrate.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,20 @@ def _inputs(fragments: list[Fragment], devices: frozenset[str],
294294
return [inp for _, inp in planned]
295295

296296

297-
def _fabric_name(root: Path, play: Play, many: bool) -> str:
297+
def _fabric_name(root: Path, play: Play, many: bool, taken: set[str]) -> str:
298+
"""A name per play, and never the same one twice.
299+
300+
⚠ The pattern does not always tell two plays apart:
301+
`eos_designs-twodc-5stage-clos` runs eos_designs twice over the same
302+
`hosts: TWODC_5STAGE_CLOS`, so naming by pattern gave both fabrics one name —
303+
and `--emit` wrote one file, the second silently overwriting the first.
304+
"""
298305
if not many:
299-
return slug(root.name)
300-
return slug(f"{root.name}-{play.pattern}") or slug(f"{root.name}-{play.index}")
306+
return _unique(slug(root.name), taken)
307+
base = slug(f"{root.name}-{play.pattern}") or slug(root.name)
308+
if base in taken:
309+
base = slug(f"{base}-{play.playbook.removesuffix('.yml')}-{play.index}")
310+
return _unique(base, taken)
301311

302312

303313
def migrate(root: Path, collections: Path | None = None, inventory: Path | None = None,
@@ -325,6 +335,7 @@ def migrate(root: Path, collections: Path | None = None, inventory: Path | None
325335

326336
vocabulary = Vocabulary.default()
327337
fabrics: list[Fabric] = []
338+
named: set[str] = set()
328339
for play in found:
329340
devices = frozenset(play.hosts)
330341
if not devices:
@@ -347,21 +358,78 @@ def migrate(root: Path, collections: Path | None = None, inventory: Path | None
347358
)
348359

349360
fabric = Fabric(
350-
name=_fabric_name(root, play, len(found) > 1),
361+
name=_fabric_name(root, play, len(found) > 1, named),
351362
devices=tuple(sorted(devices)),
352363
inputs=_inputs(fragments, devices, vocabulary),
353364
play=play,
354365
)
355366
_fabric_name_of(fabric, inv.hostvars)
367+
_report_play_vars(fabric, root, play)
356368
# Only now, with the translation proven faithful. Dropping anything
357369
# before the comparison above would weaken the one gate this module has.
358370
_report_unsupported(fabric, drop_descriptions)
359371
fabrics.append(fabric)
360372
return fabrics
361373

362374

375+
def _pooled_ids(design: dict) -> dict:
376+
node_id = (design.get("fabric_numbering") or {}).get("node_id")
377+
if isinstance(node_id, dict) and node_id.get("algorithm") == "pool_manager":
378+
return node_id
379+
return {}
380+
381+
382+
def _report_play_vars(fabric: Fabric, root: Path, play: Play) -> None:
383+
"""Variables set on the play itself, which no input XR carries.
384+
385+
⚠ A source `ansible-inventory` cannot see, and one that changes the render:
386+
`eos_designs-twodc-5stage-clos` runs eos_designs twice over the same hosts
387+
and the second play sets `avd_digital_twin_mode: true`, producing a
388+
different config into a different golden directory. Migrated without it, the
389+
two fabrics come out identical and one of them is wrong.
390+
391+
**Reported, not carried.** Play vars outrank group and host vars, but the
392+
oracle this migration checks itself against is per-host hostvars, which do
393+
not include them -- so carrying them would silently weaken the one gate this
394+
module has. Whoever migrates such a play adds the keys to an input by hand.
395+
"""
396+
import yaml as _yaml
397+
398+
playbook = root / play.playbook
399+
try:
400+
document = _yaml.safe_load(playbook.read_text())
401+
except (OSError, _yaml.YAMLError):
402+
return
403+
if not isinstance(document, list) or play.index > len(document):
404+
return
405+
variables = document[play.index - 1].get("vars") if isinstance(
406+
document[play.index - 1], dict) else None
407+
if isinstance(variables, dict) and variables:
408+
fabric.notes.append(
409+
f"{play.playbook} play #{play.index} sets {len(variables)} variable(s) on the "
410+
f"play itself ({', '.join(sorted(variables))}); play vars outrank group and "
411+
f"host vars and are NOT carried into any input"
412+
)
413+
414+
363415
def _report_unsupported(fabric: Fabric, drop_descriptions: bool) -> None:
364416
"""Note -- and optionally drop -- what pyavd will not honour."""
417+
# State, not settings. An inventory already running `pool_manager` keeps its
418+
# node IDs in a file AVD generated; this translates the *setting* and leaves
419+
# the *assignments* behind. Applied to a fabric that is already deployed,
420+
# that renumbers every device -- and a render reaches a switch as a full
421+
# configuration replacement.
422+
for inp in fabric.inputs:
423+
pooled = _pooled_ids(inp.design)
424+
if pooled:
425+
where = pooled.get("pools_file") or "<root_dir>/intended/data/<fabric>-ids.yml"
426+
fabric.notes.append(
427+
f"node IDs come from a pool; its assignments live in {where} and do "
428+
f"NOT travel with this migration. Seed them into the fabric "
429+
f"(spec.nodeIdPool.seedConfigMapName) or every device is renumbered"
430+
)
431+
break
432+
365433
if drop_descriptions:
366434
dropped = [
367435
f"{inp.name}.{path}"

0 commit comments

Comments
 (0)