Skip to content

Commit 264cb7c

Browse files
mbakalarskiclaude
andcommitted
Collect the inputs a Fabric names, and refuse to render without them
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>
1 parent b259c23 commit 264cb7c

2 files changed

Lines changed: 345 additions & 6 deletions

File tree

function/fn.py

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,17 @@
2929
from .engine import (
3030
InputValidationError,
3131
device_roles_from_design,
32-
render_fabric_design,
32+
hostnames_from_design,
33+
render_structured_configs,
34+
)
35+
from .kinds import (
36+
KINDS,
37+
Input,
38+
hosts_in_blocks,
39+
overwrites,
40+
resolve,
41+
unmatched_patterns,
3342
)
34-
from .kinds import KINDS, hosts_in_blocks
3543

3644
API_VERSION = "avd.netclab.dev/v1alpha1"
3745

@@ -142,12 +150,24 @@ def _reconcile_fabric(
142150
namespace = meta.get("namespace", "default")
143151
xr_name = meta.get("name") or (fabric_name or "fabric").lower()
144152

145-
if not fabric_name or not design:
146-
response.fatal(rsp, "spec.fabricName and spec.design are required")
153+
requires = spec.get("requires") or []
154+
if not fabric_name or not (design or requires):
155+
response.fatal(rsp, "spec.fabricName and spec.design or spec.requires are required")
147156
return
148157

158+
if requires:
159+
all_inputs = self._collect(req, rsp, observed, requires, design, fabric_name)
160+
if all_inputs is None:
161+
return # gated -- _collect reported why
162+
else:
163+
# The released path: one fabric-wide document handed to every device,
164+
# with AVD resolving roles from the node-type blocks.
165+
document = dict(design)
166+
document["fabric_name"] = fabric_name
167+
all_inputs = {host: document for host in hostnames_from_design(document)}
168+
149169
try:
150-
structured_configs = render_fabric_design(design, fabric_name)
170+
structured_configs = render_structured_configs(all_inputs)
151171
except InputValidationError as err:
152172
resource.update_status(
153173
rsp.desired.composite,
@@ -171,7 +191,12 @@ def _reconcile_fabric(
171191
"urlTemplate", "https://{hostname}.{namespace}.svc/command-api"
172192
)
173193

174-
roles = device_roles_from_design(design)
194+
# Roles come from each device's own view: with inputs, a leaf in DC1 sees
195+
# DC1's node-type block and nothing of DC2's.
196+
roles = {
197+
host: device_roles_from_design(hostvars).get(host) or hostvars.get("type", "")
198+
for host, hostvars in all_inputs.items()
199+
}
175200
observed_devices = req.observed.resources # keyed by composition-resource-name (hostname)
176201
devices = []
177202
for hostname, structured_config in structured_configs.items():
@@ -227,6 +252,139 @@ def _reconcile_fabric(
227252
rsp, f"Composed {len(structured_configs)} Device(s) for fabric {fabric_name}"
228253
)
229254

255+
# -- Collecting the inputs a Fabric names ---------------------------------
256+
257+
def _collect( # noqa: PLR0913
258+
self,
259+
req: fnv1.RunFunctionRequest,
260+
rsp: fnv1.RunFunctionResponse,
261+
observed: dict,
262+
requires: list[dict],
263+
design: dict,
264+
fabric_name: str,
265+
) -> dict[str, dict] | None:
266+
"""Ask Crossplane for the named inputs; layer them once they arrive.
267+
268+
Returns per-device inputs, or ``None`` when the fabric must not render --
269+
the gate. That gate is not only a guard against a slow operator:
270+
requirements are answered on the *next* reconcile, so the first one
271+
always arrives with nothing at all, and rendering then would push a
272+
fabric short of its inputs as a full config replacement.
273+
"""
274+
namespace = (observed.get("metadata") or {}).get("namespace", "default")
275+
276+
# State the requirements on every reconcile. Crossplane fetches what the
277+
# latest response asked for, so leaving them out once drops the inputs.
278+
keys: list[tuple[str, dict]] = []
279+
for index, entry in enumerate(requires):
280+
kind = entry["kind"]
281+
key = f"{index:03d}-{kind.lower()}-{entry['name']}"
282+
keys.append((key, entry))
283+
response.require_resources(
284+
rsp,
285+
name=key,
286+
api_version="v1" if kind == "Secret" else API_VERSION,
287+
kind=kind,
288+
match_name=entry["name"],
289+
namespace=entry.get("namespace", namespace),
290+
)
291+
292+
pending: list[str] = []
293+
absent: list[str] = []
294+
inputs: list[Input] = []
295+
for key, entry in keys:
296+
named = f"{entry['kind']}/{entry.get('namespace', namespace)}/{entry['name']}"
297+
if entry["kind"] == "Secret":
298+
# In the API from the first version so the mechanism can land
299+
# without a schema change, but not implemented. Refuse rather
300+
# than render a fabric whose credentials are silently absent.
301+
response.fatal(rsp, f"Secret inputs are not implemented yet: {named}")
302+
return None
303+
if key not in req.required_resources:
304+
pending.append(named)
305+
continue
306+
items = req.required_resources[key].items
307+
if not items:
308+
# Crossplane looked and found nothing. The proto distinguishes
309+
# this from "not fetched yet" by sending an empty Resources, and
310+
# that is what lets a Fabric tell "waiting" from "missing" --
311+
# the one thing this design was previously unable to do.
312+
absent.append(named)
313+
continue
314+
inputs.append(
315+
Input.from_xr(_normalize_numbers(resource.struct_to_dict(items[0].resource)))
316+
)
317+
318+
if pending or absent:
319+
detail = []
320+
if absent:
321+
detail.append(f"not found: {', '.join(absent)}")
322+
if pending:
323+
detail.append(f"not fetched yet: {', '.join(pending)}")
324+
message = "; ".join(detail)
325+
response.set_conditions(
326+
rsp,
327+
resource.Condition(
328+
typ="InputsResolved",
329+
status="False",
330+
reason="InputsMissing" if absent else "WaitingForInputs",
331+
message=message[:400],
332+
),
333+
)
334+
resource.update_status(
335+
rsp.desired.composite,
336+
{"fabricName": fabric_name, "validation": {"ok": False, "message": message}},
337+
)
338+
# Missing is a real problem; not-fetched-yet is the normal first pass.
339+
report = response.warning if absent else response.normal
340+
report(rsp, f"fabric {fabric_name} is waiting on inputs -- {message}")
341+
return None
342+
343+
# The Fabric's own design is the first input: fabric-wide, seen by every
344+
# device, and declaring whatever devices its own blocks name so a Fabric
345+
# that carries both a design and a requires list still has its devices.
346+
document = dict(design)
347+
document["fabric_name"] = fabric_name
348+
inputs.insert(
349+
0,
350+
Input(
351+
name="fabric",
352+
kind="Settings",
353+
design=document,
354+
all_devices=True,
355+
declares=sorted(hosts_in_blocks(document)),
356+
),
357+
)
358+
359+
if stray := unmatched_patterns(inputs):
360+
listed = ", ".join(f"{name}: {pattern!r}" for name, pattern in stray)
361+
response.fatal(
362+
rsp,
363+
f"appliesTo.matchHostnames matched no device ({listed}) -- "
364+
f"a pattern that matches nothing is silent, so it is refused",
365+
)
366+
return None
367+
368+
if replaced := overwrites(inputs):
369+
shown = ", ".join(f"{key} on {host} ({first} -> {second})"
370+
for host, key, first, second in replaced[:5])
371+
response.warning(
372+
rsp,
373+
f"{len(replaced)} value(s) replaced by a later input: {shown}"
374+
+ (" ..." if len(replaced) > 5 else ""),
375+
)
376+
377+
response.set_conditions(
378+
rsp,
379+
resource.Condition(
380+
typ="InputsResolved",
381+
status="True",
382+
reason="AllInputsResolved",
383+
message=f"{len(inputs)} input(s)",
384+
),
385+
)
386+
return resolve(inputs)
387+
230388
# -- Device: validate + render one device's config -----------------------
231389

232390
def _reconcile_device(

tests/test_fabric_collect.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""A Fabric collects the inputs it names, and refuses to render without them.
2+
3+
Offline -- drives RunFunction directly, with no cluster and no Crossplane. The
4+
gate is the most safety-critical piece in the collect path: requirements are
5+
answered on the *next* reconcile, so the first one always arrives with nothing,
6+
and a fabric rendered short of its inputs would be pushed to devices as a full
7+
config replacement.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import asyncio
13+
14+
import pytest
15+
from crossplane.function import resource
16+
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
17+
18+
from function.fn import FunctionRunner
19+
20+
API = "avd.netclab.dev/v1alpha1"
21+
22+
23+
def _run(req: fnv1.RunFunctionRequest) -> fnv1.RunFunctionResponse:
24+
return asyncio.run(FunctionRunner().RunFunction(req, None))
25+
26+
27+
def _fabric(requires: list[dict], design: dict | None = None) -> dict:
28+
return {
29+
"apiVersion": API,
30+
"kind": "Fabric",
31+
"metadata": {"name": "fabric", "namespace": "avd"},
32+
"spec": {"fabricName": "FABRIC", "design": design or {}, "requires": requires},
33+
}
34+
35+
36+
def _input_xr(kind: str, name: str, spec: dict) -> dict:
37+
return {
38+
"apiVersion": API,
39+
"kind": kind,
40+
"metadata": {"name": name, "namespace": "avd"},
41+
"spec": spec,
42+
}
43+
44+
45+
def _request(xr: dict, required: dict[str, list[dict]] | None = None) -> fnv1.RunFunctionRequest:
46+
req = fnv1.RunFunctionRequest()
47+
req.observed.composite.resource.CopyFrom(resource.dict_to_struct(xr))
48+
for key, objects in (required or {}).items():
49+
# An empty list is Crossplane saying "I looked and found nothing", which
50+
# the proto distinguishes from a key that is absent entirely.
51+
entry = req.required_resources[key]
52+
for obj in objects:
53+
entry.items.add().resource.CopyFrom(resource.dict_to_struct(obj))
54+
return req
55+
56+
57+
def _condition(rsp: fnv1.RunFunctionResponse, typ: str):
58+
return next((c for c in rsp.conditions if c.type == typ), None)
59+
60+
61+
# A spine rather than a leaf, only because a leaf defaults to being a VTEP and
62+
# would drag in the VXLAN pools -- this fixture is about the collect path, not
63+
# about exercising AVD.
64+
SPINES = _input_xr(
65+
"NodeSet",
66+
"spines",
67+
{
68+
# `type` rides in the same input: AVD needs it (or default_node_types)
69+
# to know what the device is, and it applies to whoever sees this input.
70+
"design": {
71+
"type": "spine",
72+
"spine": {
73+
"defaults": {"loopback_ipv4_pool": "10.255.0.0/27"},
74+
"nodes": [{"name": "spine1", "id": 1, "bgp_as": 65100}],
75+
},
76+
}
77+
},
78+
)
79+
80+
81+
def test_first_reconcile_asks_and_renders_nothing() -> None:
82+
"""Requirements are answered next time round, so the first pass is empty.
83+
84+
This is the case the gate exists for: not a slow operator, but the protocol.
85+
"""
86+
rsp = _run(_request(_fabric([{"kind": "NodeSet", "name": "spines"}])))
87+
88+
assert set(rsp.requirements.resources) == {"000-nodeset-spines"}
89+
selector = rsp.requirements.resources["000-nodeset-spines"]
90+
assert (selector.kind, selector.match_name, selector.namespace) == ("NodeSet", "spines", "avd")
91+
92+
assert not rsp.desired.resources, "nothing may be composed before the inputs arrive"
93+
condition = _condition(rsp, "InputsResolved")
94+
assert condition.reason == "WaitingForInputs"
95+
96+
97+
def test_missing_input_is_distinguished_from_not_yet_fetched() -> None:
98+
"""An empty Resources means Crossplane looked and found nothing."""
99+
rsp = _run(
100+
_request(
101+
_fabric([{"kind": "NodeSet", "name": "spines"}]),
102+
required={"000-nodeset-spines": []},
103+
)
104+
)
105+
106+
assert not rsp.desired.resources
107+
assert _condition(rsp, "InputsResolved").reason == "InputsMissing"
108+
assert "not found" in _condition(rsp, "InputsResolved").message
109+
110+
111+
def test_resolved_inputs_compose_devices() -> None:
112+
rsp = _run(
113+
_request(
114+
_fabric([{"kind": "NodeSet", "name": "spines"}]),
115+
required={"000-nodeset-spines": [SPINES]},
116+
)
117+
)
118+
119+
assert _condition(rsp, "InputsResolved").status == fnv1.STATUS_CONDITION_TRUE
120+
assert set(rsp.desired.resources) == {"spine1"}
121+
122+
123+
def test_design_without_requires_still_composes() -> None:
124+
"""The released path is untouched: one document, handed to every device.
125+
126+
Guards the refactor that put both paths through render_structured_configs --
127+
v0.1.6 is published and netclab-xp pins it, so this must keep working with no
128+
inputs in sight.
129+
"""
130+
rsp = _run(_request(_fabric(requires=[], design=SPINES["spec"]["design"])))
131+
132+
assert not any(r.severity == fnv1.SEVERITY_FATAL for r in rsp.results)
133+
assert set(rsp.desired.resources) == {"spine1"}
134+
assert not rsp.requirements.resources, "a fabric with no requires asks for nothing"
135+
136+
137+
def test_secret_input_is_refused_until_implemented() -> None:
138+
"""It is in the enum so the mechanism can land without a schema change.
139+
140+
Rendering a fabric whose credentials are silently absent would push a config
141+
without them, so refusing is the only safe placeholder.
142+
"""
143+
rsp = _run(_request(_fabric([{"kind": "Secret", "name": "creds"}])))
144+
145+
assert any(r.severity == fnv1.SEVERITY_FATAL for r in rsp.results)
146+
assert not rsp.desired.resources
147+
148+
149+
def test_pattern_matching_no_device_is_refused() -> None:
150+
"""A pattern is silent about matching nothing, so it cannot be allowed to."""
151+
settings = _input_xr(
152+
"Settings",
153+
"typo",
154+
# The fabric holds only spine1, so this pattern matches nothing.
155+
{"design": {"ntp_settings": {}}, "appliesTo": {"matchHostnames": ["leaf.*"]}},
156+
)
157+
rsp = _run(
158+
_request(
159+
_fabric(
160+
[
161+
{"kind": "NodeSet", "name": "spines"},
162+
{"kind": "Settings", "name": "typo"},
163+
]
164+
),
165+
required={"000-nodeset-spines": [SPINES], "001-settings-typo": [settings]},
166+
)
167+
)
168+
169+
fatal = [r for r in rsp.results if r.severity == fnv1.SEVERITY_FATAL]
170+
assert fatal and "matched no device" in fatal[0].message
171+
assert not rsp.desired.resources
172+
173+
174+
@pytest.mark.parametrize("kind", ["NodeSet", "NetworkServices", "ConnectedEndpoints", "Settings"])
175+
def test_input_kinds_reconcile_and_report_their_keys(kind: str) -> None:
176+
"""Each input reports its own shape on its own object, composing nothing."""
177+
rsp = _run(_request(_input_xr(kind, "an-input", {"design": {"ntp_settings": {}, "type": "x"}})))
178+
179+
assert not any(r.severity == fnv1.SEVERITY_FATAL for r in rsp.results)
180+
status = resource.struct_to_dict(rsp.desired.composite.resource).get("status", {})
181+
assert status["keys"] == ["ntp_settings", "type"]

0 commit comments

Comments
 (0)