@@ -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
303313def 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+
363415def _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