packet-tracer-skill 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +424 -42
- package/README.md +535 -250
- package/SKILL.md +337 -262
- package/bin/packet-tracer-skill.js +29 -2
- package/docs/automation-controller-proof.md +35 -0
- package/docs/curated-donor-registry.md +11 -0
- package/docs/generate-ready-pilot-design.md +30 -0
- package/docs/github-launch-ops-0.2.3.md +37 -0
- package/docs/github-metadata.md +6 -4
- package/docs/hero-demo-plan.md +1 -1
- package/docs/home-iot-donor-proof.md +4 -4
- package/docs/industrial-programming-proof.md +48 -0
- package/docs/ipv4-routing-management-proof.md +37 -0
- package/docs/l2-resiliency-bgp-proof.md +60 -0
- package/docs/l2-security-qos-proof.md +59 -0
- package/docs/packet-tracer-feature-gap-atlas.md +174 -17
- package/docs/post-launch-follow-up.md +9 -5
- package/docs/proof-readiness-dashboard.md +69 -0
- package/docs/publish-preview-roadmap.md +6 -5
- package/docs/release-checklist.md +27 -13
- package/docs/release-notes-0.2.2.md +1 -1
- package/docs/release-notes-0.2.3.md +59 -0
- package/docs/release-notes-0.2.4.md +20 -0
- package/docs/runtime-truth.md +33 -8
- package/docs/security-edge-deepening-proof.md +65 -0
- package/docs/voice-collaboration-proof.md +38 -0
- package/docs/wan-security-donor-proof.md +20 -3
- package/examples/README.md +98 -69
- package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
- package/examples/gallery.md +94 -6
- package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
- package/examples/index.json +932 -4
- package/examples/local-sample-evidence.json +24 -0
- package/examples/proof-cards.json +117 -0
- package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
- package/package.json +60 -44
- package/pytest.ini +9 -0
- package/references/packettracer-feature-atlas.json +67 -17
- package/references/packettracer-sample-catalog.json +45287 -4525
- package/references/packettracer-sample-catalog.md +599 -259
- package/references/proof-readiness-candidates.json +352 -0
- package/scripts/build_examples_index.py +228 -35
- package/scripts/build_sample_catalog.py +24 -44
- package/scripts/corpus_runner.py +430 -0
- package/scripts/coverage_matrix.py +1842 -1319
- package/scripts/donor_cache.py +354 -0
- package/scripts/donor_diagnostics.py +3 -1
- package/scripts/feature_atlas.py +65 -1
- package/scripts/generate_pkt.py +8762 -4070
- package/scripts/intent_parser.py +2242 -1138
- package/scripts/local_donors.py +340 -0
- package/scripts/packet_tracer_env.py +846 -391
- package/scripts/pkt_annotate.py +218 -0
- package/scripts/pkt_codec.py +420 -181
- package/scripts/pkt_editor.py +2405 -1226
- package/scripts/pkt_transformer.py +1072 -727
- package/scripts/pkt_verify.py +461 -0
- package/scripts/remote_search.py +197 -21
- package/scripts/runtime_doctor.py +80 -29
- package/scripts/sample_catalog.py +1372 -1195
- package/scripts/twofish_diagnostics.py +48 -31
- package/scripts/usage_ledger.py +218 -0
- package/scripts/vendor/README.md +44 -37
- package/scripts/vendor/twofish_pure.py +321 -0
- package/scripts/workspace_repair.py +548 -508
- package/templates/pt900/donors/README.md +15 -0
|
@@ -1,508 +1,548 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import copy
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
import xml.etree.ElementTree as ET
|
|
6
|
-
|
|
7
|
-
PHYSICAL_HOME_PATH = ["Intercity", "Home City", "Corporate Office", "Main Wiring Closet"]
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass
|
|
11
|
-
class WorkspaceValidationResult:
|
|
12
|
-
workspace_mode: str
|
|
13
|
-
logical_status: str
|
|
14
|
-
physical_status: str
|
|
15
|
-
blocking_issues: list[str]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@dataclass
|
|
19
|
-
class DonorCoherenceResult:
|
|
20
|
-
device_metadata_status: str
|
|
21
|
-
scenario_status: str
|
|
22
|
-
physical_runtime_status: str
|
|
23
|
-
visual_runtime_status: str
|
|
24
|
-
blocking_issues: list[str]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _make_workspace_node(name: str, node_type: int, x_pos: int, y_pos: int, width: int, height: int, path: str = "") -> ET.Element:
|
|
28
|
-
node = ET.Element("NODE")
|
|
29
|
-
for tag, value in [
|
|
30
|
-
("X", str(x_pos)),
|
|
31
|
-
("Y", str(y_pos)),
|
|
32
|
-
("TYPE", str(node_type)),
|
|
33
|
-
]:
|
|
34
|
-
child = ET.SubElement(node, tag)
|
|
35
|
-
child.text = value
|
|
36
|
-
name_node = ET.SubElement(node, "NAME")
|
|
37
|
-
name_node.set("translate", "true")
|
|
38
|
-
name_node.text = name
|
|
39
|
-
for tag, value in [
|
|
40
|
-
("SX", "1"),
|
|
41
|
-
("SY", "1"),
|
|
42
|
-
("W", str(width)),
|
|
43
|
-
("H", str(height)),
|
|
44
|
-
("PATH", path),
|
|
45
|
-
]:
|
|
46
|
-
child = ET.SubElement(node, tag)
|
|
47
|
-
child.text = value
|
|
48
|
-
ET.SubElement(node, "CHILDREN")
|
|
49
|
-
for tag, value in [
|
|
50
|
-
("MANUAL_SCALING", "false"),
|
|
51
|
-
("SCALED_PIXMAP_WIDTH", "-842150451"),
|
|
52
|
-
("SCALED_PIXMAP_HEIGHT", "-842150451"),
|
|
53
|
-
("INIT_WIDTH", str(width)),
|
|
54
|
-
("INIT_HEIGHT", str(height)),
|
|
55
|
-
("INIT_SX", "1"),
|
|
56
|
-
("INIT_SY", "1"),
|
|
57
|
-
]:
|
|
58
|
-
child = ET.SubElement(node, tag)
|
|
59
|
-
child.text = value
|
|
60
|
-
return node
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _build_minimal_physical_workspace() -> tuple[ET.Element, ET.Element, ET.Element]:
|
|
64
|
-
physical = ET.Element("PHYSICALWORKSPACE")
|
|
65
|
-
homerack = ET.SubElement(physical, "HOMERACK")
|
|
66
|
-
homerack.set("translate", "true")
|
|
67
|
-
homerack.text = ",".join(PHYSICAL_HOME_PATH)
|
|
68
|
-
|
|
69
|
-
intercity = _make_workspace_node("Intercity", 0, 0, 0, 20000, 12416, "../art/Background/gGeoViewInterCity.png")
|
|
70
|
-
city = _make_workspace_node("Home City", 1, 200, 200, 2000, 1238, "../art/Background/gGeoViewCity.png")
|
|
71
|
-
building = _make_workspace_node("Corporate Office", 2, 100, 100, 200, 125, "../art/Background/gGeoViewBuilding.png")
|
|
72
|
-
closet = _make_workspace_node("Main Wiring Closet", 3, 50, 50, 20, 20, "")
|
|
73
|
-
table = _make_workspace_node("Table", 5, 0, 0, 0, 0, "")
|
|
74
|
-
rack = _make_workspace_node("Rack", 4, 0, 0, 0, 0, "")
|
|
75
|
-
|
|
76
|
-
intercity.find("./CHILDREN").append(city)
|
|
77
|
-
city.find("./CHILDREN").append(building)
|
|
78
|
-
building.find("./CHILDREN").append(closet)
|
|
79
|
-
closet.find("./CHILDREN").extend([table, rack])
|
|
80
|
-
physical.append(intercity)
|
|
81
|
-
return physical, table, rack
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _closet_node_from_physical_root(physical_root: ET.Element) -> ET.Element | None:
|
|
85
|
-
path = "./NODE/CHILDREN/NODE/CHILDREN/NODE/CHILDREN/NODE"
|
|
86
|
-
closet = physical_root.find(path)
|
|
87
|
-
if closet is not None and closet.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
|
|
88
|
-
return closet
|
|
89
|
-
for node in physical_root.findall(".//NODE"):
|
|
90
|
-
if node.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
|
|
91
|
-
return node
|
|
92
|
-
return None
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _container_node(closet: ET.Element, container_name: str, node_type: int) -> ET.Element:
|
|
96
|
-
children = closet.find("./CHILDREN")
|
|
97
|
-
if children is None:
|
|
98
|
-
children = ET.SubElement(closet, "CHILDREN")
|
|
99
|
-
for node in children.findall("./NODE"):
|
|
100
|
-
if node.findtext("./NAME", default="") == container_name:
|
|
101
|
-
target = node.find("./TYPE")
|
|
102
|
-
if target is not None:
|
|
103
|
-
target.text = str(node_type)
|
|
104
|
-
return node
|
|
105
|
-
node = _make_workspace_node(container_name, node_type, 0, 0, 0, 0, "")
|
|
106
|
-
children.append(node)
|
|
107
|
-
return node
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _leaf_template(container: ET.Element, default_name: str) -> ET.Element:
|
|
111
|
-
for node in container.findall("./CHILDREN/NODE"):
|
|
112
|
-
if node.findtext("./TYPE", default="") == "6":
|
|
113
|
-
return copy.deepcopy(node)
|
|
114
|
-
return _make_workspace_node(default_name, 6, 0, 0, 0, 0, "")
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _clone_leaf(template: ET.Element, name: str, index: int) -> ET.Element:
|
|
118
|
-
leaf = copy.deepcopy(template)
|
|
119
|
-
for child in list(leaf):
|
|
120
|
-
if child.tag == "CHILDREN":
|
|
121
|
-
child.clear()
|
|
122
|
-
name_node = leaf.find("./NAME")
|
|
123
|
-
if name_node is None:
|
|
124
|
-
name_node = ET.SubElement(leaf, "NAME")
|
|
125
|
-
name_node.set("translate", "true")
|
|
126
|
-
name_node.text = name
|
|
127
|
-
x_node = leaf.find("./X")
|
|
128
|
-
y_node = leaf.find("./Y")
|
|
129
|
-
if x_node is None:
|
|
130
|
-
x_node = ET.SubElement(leaf, "X")
|
|
131
|
-
if y_node is None:
|
|
132
|
-
y_node = ET.SubElement(leaf, "Y")
|
|
133
|
-
x_node.text = str((index % 8) * 8)
|
|
134
|
-
y_node.text = str((index // 8) * 8)
|
|
135
|
-
return leaf
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def _physical_leaf_path(device_name: str, container_name: str) -> str:
|
|
139
|
-
return ",".join([*PHYSICAL_HOME_PATH, container_name, device_name])
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _device_physical_container(device: ET.Element) -> str:
|
|
143
|
-
device_type = device.findtext("./ENGINE/TYPE", default="")
|
|
144
|
-
if device_type in {"Router", "Switch", "MultiLayerSwitch", "Server", "WirelessRouter", "WirelessLanController", "LightWeightAccessPoint"}:
|
|
145
|
-
return "Rack"
|
|
146
|
-
return "Table"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def sanitize_generated_physical_workspace(root: ET.Element) -> None:
|
|
150
|
-
devices_parent = root.find(".//DEVICES")
|
|
151
|
-
if devices_parent is None:
|
|
152
|
-
return
|
|
153
|
-
version = root.findtext("./VERSION", default="")
|
|
154
|
-
if version.startswith("9.") and any(device.find("./WORKSPACE/PHYSICAL_CPUR") is not None for device in devices_parent.findall("./DEVICE")):
|
|
155
|
-
return
|
|
156
|
-
|
|
157
|
-
physical_root = root.find("./PHYSICALWORKSPACE")
|
|
158
|
-
if physical_root is None:
|
|
159
|
-
physical_root, _, _ = _build_minimal_physical_workspace()
|
|
160
|
-
insert_at = 1 if root.find("./VERSION") is not None else 0
|
|
161
|
-
root.insert(insert_at, physical_root)
|
|
162
|
-
|
|
163
|
-
closet = _closet_node_from_physical_root(physical_root)
|
|
164
|
-
if closet is None:
|
|
165
|
-
replacement_root, _, _ = _build_minimal_physical_workspace()
|
|
166
|
-
existing = root.find("./PHYSICALWORKSPACE")
|
|
167
|
-
if existing is not None:
|
|
168
|
-
root.remove(existing)
|
|
169
|
-
insert_at = 1 if root.find("./VERSION") is not None else 0
|
|
170
|
-
root.insert(insert_at, replacement_root)
|
|
171
|
-
physical_root = replacement_root
|
|
172
|
-
closet = _closet_node_from_physical_root(physical_root)
|
|
173
|
-
if closet is None:
|
|
174
|
-
return
|
|
175
|
-
|
|
176
|
-
table = _container_node(closet, "Table", 5)
|
|
177
|
-
rack = _container_node(closet, "Rack", 4)
|
|
178
|
-
table_template = _leaf_template(table, "PC0")
|
|
179
|
-
rack_template = _leaf_template(rack, "Server0")
|
|
180
|
-
table_children = table.find("./CHILDREN")
|
|
181
|
-
rack_children = rack.find("./CHILDREN")
|
|
182
|
-
assert table_children is not None and rack_children is not None
|
|
183
|
-
table_children.clear()
|
|
184
|
-
rack_children.clear()
|
|
185
|
-
|
|
186
|
-
for index, device in enumerate(devices_parent.findall("./DEVICE"), start=1):
|
|
187
|
-
name = device.findtext("./ENGINE/NAME", default=f"Device{index}")
|
|
188
|
-
container_name = _device_physical_container(device)
|
|
189
|
-
template = rack_template if container_name == "Rack" else table_template
|
|
190
|
-
leaf = _clone_leaf(template, name, index - 1)
|
|
191
|
-
if container_name == "Rack":
|
|
192
|
-
rack_children.append(leaf)
|
|
193
|
-
else:
|
|
194
|
-
table_children.append(leaf)
|
|
195
|
-
|
|
196
|
-
workspace = device.find("./WORKSPACE")
|
|
197
|
-
if workspace is None:
|
|
198
|
-
workspace = ET.SubElement(device, "WORKSPACE")
|
|
199
|
-
logical = workspace.find("./LOGICAL")
|
|
200
|
-
logical_copy = ET.fromstring(ET.tostring(logical, encoding="unicode")) if logical is not None else ET.Element("LOGICAL")
|
|
201
|
-
for child in list(workspace):
|
|
202
|
-
workspace.remove(child)
|
|
203
|
-
workspace.append(logical_copy)
|
|
204
|
-
physical = ET.SubElement(workspace, "PHYSICAL")
|
|
205
|
-
physical.set("translate", "true")
|
|
206
|
-
physical.text = _physical_leaf_path(name, container_name)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def _collect_physical_paths(root: ET.Element) -> set[str]:
|
|
210
|
-
physical_root = root.find("./PHYSICALWORKSPACE")
|
|
211
|
-
if physical_root is None:
|
|
212
|
-
return set()
|
|
213
|
-
paths: set[str] = set()
|
|
214
|
-
for top in physical_root.findall("./NODE"):
|
|
215
|
-
_walk_physical_node(top, [], paths)
|
|
216
|
-
return paths
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def _walk_physical_node(node: ET.Element, ancestors: list[str], sink: set[str]) -> None:
|
|
220
|
-
name = node.findtext("./NAME", default="")
|
|
221
|
-
if not name:
|
|
222
|
-
return
|
|
223
|
-
current = [*ancestors, name]
|
|
224
|
-
sink.add(",".join(current))
|
|
225
|
-
for child in node.findall("./CHILDREN/NODE"):
|
|
226
|
-
_walk_physical_node(child, current, sink)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def inspect_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
|
|
230
|
-
issues: list[str] = []
|
|
231
|
-
if root.tag != "PACKETTRACER5":
|
|
232
|
-
issues.append("Root element must be PACKETTRACER5")
|
|
233
|
-
if root.find("VERSION") is None:
|
|
234
|
-
issues.append("Packet Tracer XML is missing VERSION")
|
|
235
|
-
|
|
236
|
-
devices_parent = root.find(".//DEVICES")
|
|
237
|
-
links_parent = root.find(".//LINKS")
|
|
238
|
-
if devices_parent is None or links_parent is None:
|
|
239
|
-
issues.append("Packet Tracer XML is missing DEVICES or LINKS container")
|
|
240
|
-
return WorkspaceValidationResult("unknown", "invalid", "invalid", issues)
|
|
241
|
-
|
|
242
|
-
devices = devices_parent.findall("./DEVICE")
|
|
243
|
-
links = links_parent.findall("./LINK")
|
|
244
|
-
if not devices:
|
|
245
|
-
issues.append("No devices remain in generated Packet Tracer XML")
|
|
246
|
-
|
|
247
|
-
logical_mem_addrs: list[str] = []
|
|
248
|
-
save_ref_ids: list[str] = []
|
|
249
|
-
for device in devices:
|
|
250
|
-
if device.find("./WORKSPACE/LOGICAL/X") is None or device.find("./WORKSPACE/LOGICAL/Y") is None:
|
|
251
|
-
issues.append("Device is missing logical workspace coordinates")
|
|
252
|
-
mem_addr = device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")
|
|
253
|
-
if not mem_addr:
|
|
254
|
-
issues.append("Device is missing logical workspace MEM_ADDR")
|
|
255
|
-
else:
|
|
256
|
-
logical_mem_addrs.append(mem_addr)
|
|
257
|
-
save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
258
|
-
if save_ref:
|
|
259
|
-
save_ref_ids.append(save_ref)
|
|
260
|
-
|
|
261
|
-
device_count = len(devices)
|
|
262
|
-
for link in links:
|
|
263
|
-
cable = link.find("./CABLE")
|
|
264
|
-
if cable is None:
|
|
265
|
-
issues.append("Link is missing CABLE node")
|
|
266
|
-
continue
|
|
267
|
-
from_idx = cable.findtext("FROM", default="")
|
|
268
|
-
to_idx = cable.findtext("TO", default="")
|
|
269
|
-
if not from_idx or not to_idx:
|
|
270
|
-
issues.append("Link is missing FROM or TO index")
|
|
271
|
-
continue
|
|
272
|
-
if from_idx.isdigit() and to_idx.isdigit():
|
|
273
|
-
if int(from_idx) >= device_count or int(to_idx) >= device_count:
|
|
274
|
-
issues.append("Link references a device index outside the DEVICES list")
|
|
275
|
-
elif save_ref_ids:
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
issues.append(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
"
|
|
407
|
-
"
|
|
408
|
-
"
|
|
409
|
-
"
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
"
|
|
413
|
-
"
|
|
414
|
-
"
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
|
|
7
|
+
PHYSICAL_HOME_PATH = ["Intercity", "Home City", "Corporate Office", "Main Wiring Closet"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class WorkspaceValidationResult:
|
|
12
|
+
workspace_mode: str
|
|
13
|
+
logical_status: str
|
|
14
|
+
physical_status: str
|
|
15
|
+
blocking_issues: list[str]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DonorCoherenceResult:
|
|
20
|
+
device_metadata_status: str
|
|
21
|
+
scenario_status: str
|
|
22
|
+
physical_runtime_status: str
|
|
23
|
+
visual_runtime_status: str
|
|
24
|
+
blocking_issues: list[str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _make_workspace_node(name: str, node_type: int, x_pos: int, y_pos: int, width: int, height: int, path: str = "") -> ET.Element:
|
|
28
|
+
node = ET.Element("NODE")
|
|
29
|
+
for tag, value in [
|
|
30
|
+
("X", str(x_pos)),
|
|
31
|
+
("Y", str(y_pos)),
|
|
32
|
+
("TYPE", str(node_type)),
|
|
33
|
+
]:
|
|
34
|
+
child = ET.SubElement(node, tag)
|
|
35
|
+
child.text = value
|
|
36
|
+
name_node = ET.SubElement(node, "NAME")
|
|
37
|
+
name_node.set("translate", "true")
|
|
38
|
+
name_node.text = name
|
|
39
|
+
for tag, value in [
|
|
40
|
+
("SX", "1"),
|
|
41
|
+
("SY", "1"),
|
|
42
|
+
("W", str(width)),
|
|
43
|
+
("H", str(height)),
|
|
44
|
+
("PATH", path),
|
|
45
|
+
]:
|
|
46
|
+
child = ET.SubElement(node, tag)
|
|
47
|
+
child.text = value
|
|
48
|
+
ET.SubElement(node, "CHILDREN")
|
|
49
|
+
for tag, value in [
|
|
50
|
+
("MANUAL_SCALING", "false"),
|
|
51
|
+
("SCALED_PIXMAP_WIDTH", "-842150451"),
|
|
52
|
+
("SCALED_PIXMAP_HEIGHT", "-842150451"),
|
|
53
|
+
("INIT_WIDTH", str(width)),
|
|
54
|
+
("INIT_HEIGHT", str(height)),
|
|
55
|
+
("INIT_SX", "1"),
|
|
56
|
+
("INIT_SY", "1"),
|
|
57
|
+
]:
|
|
58
|
+
child = ET.SubElement(node, tag)
|
|
59
|
+
child.text = value
|
|
60
|
+
return node
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_minimal_physical_workspace() -> tuple[ET.Element, ET.Element, ET.Element]:
|
|
64
|
+
physical = ET.Element("PHYSICALWORKSPACE")
|
|
65
|
+
homerack = ET.SubElement(physical, "HOMERACK")
|
|
66
|
+
homerack.set("translate", "true")
|
|
67
|
+
homerack.text = ",".join(PHYSICAL_HOME_PATH)
|
|
68
|
+
|
|
69
|
+
intercity = _make_workspace_node("Intercity", 0, 0, 0, 20000, 12416, "../art/Background/gGeoViewInterCity.png")
|
|
70
|
+
city = _make_workspace_node("Home City", 1, 200, 200, 2000, 1238, "../art/Background/gGeoViewCity.png")
|
|
71
|
+
building = _make_workspace_node("Corporate Office", 2, 100, 100, 200, 125, "../art/Background/gGeoViewBuilding.png")
|
|
72
|
+
closet = _make_workspace_node("Main Wiring Closet", 3, 50, 50, 20, 20, "")
|
|
73
|
+
table = _make_workspace_node("Table", 5, 0, 0, 0, 0, "")
|
|
74
|
+
rack = _make_workspace_node("Rack", 4, 0, 0, 0, 0, "")
|
|
75
|
+
|
|
76
|
+
intercity.find("./CHILDREN").append(city)
|
|
77
|
+
city.find("./CHILDREN").append(building)
|
|
78
|
+
building.find("./CHILDREN").append(closet)
|
|
79
|
+
closet.find("./CHILDREN").extend([table, rack])
|
|
80
|
+
physical.append(intercity)
|
|
81
|
+
return physical, table, rack
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _closet_node_from_physical_root(physical_root: ET.Element) -> ET.Element | None:
|
|
85
|
+
path = "./NODE/CHILDREN/NODE/CHILDREN/NODE/CHILDREN/NODE"
|
|
86
|
+
closet = physical_root.find(path)
|
|
87
|
+
if closet is not None and closet.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
|
|
88
|
+
return closet
|
|
89
|
+
for node in physical_root.findall(".//NODE"):
|
|
90
|
+
if node.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
|
|
91
|
+
return node
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _container_node(closet: ET.Element, container_name: str, node_type: int) -> ET.Element:
|
|
96
|
+
children = closet.find("./CHILDREN")
|
|
97
|
+
if children is None:
|
|
98
|
+
children = ET.SubElement(closet, "CHILDREN")
|
|
99
|
+
for node in children.findall("./NODE"):
|
|
100
|
+
if node.findtext("./NAME", default="") == container_name:
|
|
101
|
+
target = node.find("./TYPE")
|
|
102
|
+
if target is not None:
|
|
103
|
+
target.text = str(node_type)
|
|
104
|
+
return node
|
|
105
|
+
node = _make_workspace_node(container_name, node_type, 0, 0, 0, 0, "")
|
|
106
|
+
children.append(node)
|
|
107
|
+
return node
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _leaf_template(container: ET.Element, default_name: str) -> ET.Element:
|
|
111
|
+
for node in container.findall("./CHILDREN/NODE"):
|
|
112
|
+
if node.findtext("./TYPE", default="") == "6":
|
|
113
|
+
return copy.deepcopy(node)
|
|
114
|
+
return _make_workspace_node(default_name, 6, 0, 0, 0, 0, "")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _clone_leaf(template: ET.Element, name: str, index: int) -> ET.Element:
|
|
118
|
+
leaf = copy.deepcopy(template)
|
|
119
|
+
for child in list(leaf):
|
|
120
|
+
if child.tag == "CHILDREN":
|
|
121
|
+
child.clear()
|
|
122
|
+
name_node = leaf.find("./NAME")
|
|
123
|
+
if name_node is None:
|
|
124
|
+
name_node = ET.SubElement(leaf, "NAME")
|
|
125
|
+
name_node.set("translate", "true")
|
|
126
|
+
name_node.text = name
|
|
127
|
+
x_node = leaf.find("./X")
|
|
128
|
+
y_node = leaf.find("./Y")
|
|
129
|
+
if x_node is None:
|
|
130
|
+
x_node = ET.SubElement(leaf, "X")
|
|
131
|
+
if y_node is None:
|
|
132
|
+
y_node = ET.SubElement(leaf, "Y")
|
|
133
|
+
x_node.text = str((index % 8) * 8)
|
|
134
|
+
y_node.text = str((index // 8) * 8)
|
|
135
|
+
return leaf
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _physical_leaf_path(device_name: str, container_name: str) -> str:
|
|
139
|
+
return ",".join([*PHYSICAL_HOME_PATH, container_name, device_name])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _device_physical_container(device: ET.Element) -> str:
|
|
143
|
+
device_type = device.findtext("./ENGINE/TYPE", default="")
|
|
144
|
+
if device_type in {"Router", "Switch", "MultiLayerSwitch", "Server", "WirelessRouter", "WirelessLanController", "LightWeightAccessPoint"}:
|
|
145
|
+
return "Rack"
|
|
146
|
+
return "Table"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def sanitize_generated_physical_workspace(root: ET.Element) -> None:
|
|
150
|
+
devices_parent = root.find(".//DEVICES")
|
|
151
|
+
if devices_parent is None:
|
|
152
|
+
return
|
|
153
|
+
version = root.findtext("./VERSION", default="")
|
|
154
|
+
if version.startswith("9.") and any(device.find("./WORKSPACE/PHYSICAL_CPUR") is not None for device in devices_parent.findall("./DEVICE")):
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
physical_root = root.find("./PHYSICALWORKSPACE")
|
|
158
|
+
if physical_root is None:
|
|
159
|
+
physical_root, _, _ = _build_minimal_physical_workspace()
|
|
160
|
+
insert_at = 1 if root.find("./VERSION") is not None else 0
|
|
161
|
+
root.insert(insert_at, physical_root)
|
|
162
|
+
|
|
163
|
+
closet = _closet_node_from_physical_root(physical_root)
|
|
164
|
+
if closet is None:
|
|
165
|
+
replacement_root, _, _ = _build_minimal_physical_workspace()
|
|
166
|
+
existing = root.find("./PHYSICALWORKSPACE")
|
|
167
|
+
if existing is not None:
|
|
168
|
+
root.remove(existing)
|
|
169
|
+
insert_at = 1 if root.find("./VERSION") is not None else 0
|
|
170
|
+
root.insert(insert_at, replacement_root)
|
|
171
|
+
physical_root = replacement_root
|
|
172
|
+
closet = _closet_node_from_physical_root(physical_root)
|
|
173
|
+
if closet is None:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
table = _container_node(closet, "Table", 5)
|
|
177
|
+
rack = _container_node(closet, "Rack", 4)
|
|
178
|
+
table_template = _leaf_template(table, "PC0")
|
|
179
|
+
rack_template = _leaf_template(rack, "Server0")
|
|
180
|
+
table_children = table.find("./CHILDREN")
|
|
181
|
+
rack_children = rack.find("./CHILDREN")
|
|
182
|
+
assert table_children is not None and rack_children is not None
|
|
183
|
+
table_children.clear()
|
|
184
|
+
rack_children.clear()
|
|
185
|
+
|
|
186
|
+
for index, device in enumerate(devices_parent.findall("./DEVICE"), start=1):
|
|
187
|
+
name = device.findtext("./ENGINE/NAME", default=f"Device{index}")
|
|
188
|
+
container_name = _device_physical_container(device)
|
|
189
|
+
template = rack_template if container_name == "Rack" else table_template
|
|
190
|
+
leaf = _clone_leaf(template, name, index - 1)
|
|
191
|
+
if container_name == "Rack":
|
|
192
|
+
rack_children.append(leaf)
|
|
193
|
+
else:
|
|
194
|
+
table_children.append(leaf)
|
|
195
|
+
|
|
196
|
+
workspace = device.find("./WORKSPACE")
|
|
197
|
+
if workspace is None:
|
|
198
|
+
workspace = ET.SubElement(device, "WORKSPACE")
|
|
199
|
+
logical = workspace.find("./LOGICAL")
|
|
200
|
+
logical_copy = ET.fromstring(ET.tostring(logical, encoding="unicode")) if logical is not None else ET.Element("LOGICAL")
|
|
201
|
+
for child in list(workspace):
|
|
202
|
+
workspace.remove(child)
|
|
203
|
+
workspace.append(logical_copy)
|
|
204
|
+
physical = ET.SubElement(workspace, "PHYSICAL")
|
|
205
|
+
physical.set("translate", "true")
|
|
206
|
+
physical.text = _physical_leaf_path(name, container_name)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _collect_physical_paths(root: ET.Element) -> set[str]:
|
|
210
|
+
physical_root = root.find("./PHYSICALWORKSPACE")
|
|
211
|
+
if physical_root is None:
|
|
212
|
+
return set()
|
|
213
|
+
paths: set[str] = set()
|
|
214
|
+
for top in physical_root.findall("./NODE"):
|
|
215
|
+
_walk_physical_node(top, [], paths)
|
|
216
|
+
return paths
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _walk_physical_node(node: ET.Element, ancestors: list[str], sink: set[str]) -> None:
|
|
220
|
+
name = node.findtext("./NAME", default="")
|
|
221
|
+
if not name:
|
|
222
|
+
return
|
|
223
|
+
current = [*ancestors, name]
|
|
224
|
+
sink.add(",".join(current))
|
|
225
|
+
for child in node.findall("./CHILDREN/NODE"):
|
|
226
|
+
_walk_physical_node(child, current, sink)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def inspect_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
|
|
230
|
+
issues: list[str] = []
|
|
231
|
+
if root.tag != "PACKETTRACER5":
|
|
232
|
+
issues.append("Root element must be PACKETTRACER5")
|
|
233
|
+
if root.find("VERSION") is None:
|
|
234
|
+
issues.append("Packet Tracer XML is missing VERSION")
|
|
235
|
+
|
|
236
|
+
devices_parent = root.find(".//DEVICES")
|
|
237
|
+
links_parent = root.find(".//LINKS")
|
|
238
|
+
if devices_parent is None or links_parent is None:
|
|
239
|
+
issues.append("Packet Tracer XML is missing DEVICES or LINKS container")
|
|
240
|
+
return WorkspaceValidationResult("unknown", "invalid", "invalid", issues)
|
|
241
|
+
|
|
242
|
+
devices = devices_parent.findall("./DEVICE")
|
|
243
|
+
links = links_parent.findall("./LINK")
|
|
244
|
+
if not devices:
|
|
245
|
+
issues.append("No devices remain in generated Packet Tracer XML")
|
|
246
|
+
|
|
247
|
+
logical_mem_addrs: list[str] = []
|
|
248
|
+
save_ref_ids: list[str] = []
|
|
249
|
+
for device in devices:
|
|
250
|
+
if device.find("./WORKSPACE/LOGICAL/X") is None or device.find("./WORKSPACE/LOGICAL/Y") is None:
|
|
251
|
+
issues.append("Device is missing logical workspace coordinates")
|
|
252
|
+
mem_addr = device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")
|
|
253
|
+
if not mem_addr:
|
|
254
|
+
issues.append("Device is missing logical workspace MEM_ADDR")
|
|
255
|
+
else:
|
|
256
|
+
logical_mem_addrs.append(mem_addr)
|
|
257
|
+
save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
258
|
+
if save_ref:
|
|
259
|
+
save_ref_ids.append(save_ref)
|
|
260
|
+
|
|
261
|
+
device_count = len(devices)
|
|
262
|
+
for link in links:
|
|
263
|
+
cable = link.find("./CABLE")
|
|
264
|
+
if cable is None:
|
|
265
|
+
issues.append("Link is missing CABLE node")
|
|
266
|
+
continue
|
|
267
|
+
from_idx = cable.findtext("FROM", default="")
|
|
268
|
+
to_idx = cable.findtext("TO", default="")
|
|
269
|
+
if not from_idx or not to_idx:
|
|
270
|
+
issues.append("Link is missing FROM or TO index")
|
|
271
|
+
continue
|
|
272
|
+
if from_idx.isdigit() and to_idx.isdigit():
|
|
273
|
+
if int(from_idx) >= device_count or int(to_idx) >= device_count:
|
|
274
|
+
issues.append("Link references a device index outside the DEVICES list")
|
|
275
|
+
elif save_ref_ids:
|
|
276
|
+
missing = [ref for ref in (from_idx, to_idx) if ref not in save_ref_ids]
|
|
277
|
+
if missing:
|
|
278
|
+
# Naming the dangling reference matters: this message is what a
|
|
279
|
+
# failed generation reports, and "values not present" gave no
|
|
280
|
+
# way to tell which device went missing without re-running the
|
|
281
|
+
# whole plan under a debugger.
|
|
282
|
+
issues.append(
|
|
283
|
+
"Link references device SAVE_REF_ID values not present in DEVICES "
|
|
284
|
+
f"({', '.join(missing)})"
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
issues.append("Link FROM or TO index is not numeric")
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
ports = [port.text or "" for port in cable.findall("PORT")]
|
|
291
|
+
if len(ports) < 2 or not ports[0] or not ports[1]:
|
|
292
|
+
issues.append("Link is missing endpoint port names")
|
|
293
|
+
|
|
294
|
+
# A link may legitimately carry no device MEM_ADDR references. They are
|
|
295
|
+
# runtime pointers from the session that saved the file — in working
|
|
296
|
+
# donors they resolve to nothing — and a link built without them opens
|
|
297
|
+
# in Packet Tracer, while one built with invented values does not.
|
|
298
|
+
# Requiring them here made the repo reject files Packet Tracer accepts.
|
|
299
|
+
from_device_mem = cable.findtext("FROM_DEVICE_MEM_ADDR", default="")
|
|
300
|
+
to_device_mem = cable.findtext("TO_DEVICE_MEM_ADDR", default="")
|
|
301
|
+
if bool(from_device_mem) != bool(to_device_mem):
|
|
302
|
+
issues.append("Link has a device MEM_ADDR reference on only one end")
|
|
303
|
+
elif from_device_mem not in logical_mem_addrs or to_device_mem not in logical_mem_addrs:
|
|
304
|
+
issues.append("Link device MEM_ADDR references do not match device workspace records")
|
|
305
|
+
|
|
306
|
+
physical_paths = _collect_physical_paths(root)
|
|
307
|
+
if not physical_paths:
|
|
308
|
+
issues.append("Packet Tracer XML is missing PHYSICALWORKSPACE tree")
|
|
309
|
+
all_physical_values = [device.findtext("./WORKSPACE/PHYSICAL", default="") for device in devices]
|
|
310
|
+
legacy_uuid_physical = any("{" in value and "}" in value for value in all_physical_values if value)
|
|
311
|
+
for device in devices:
|
|
312
|
+
physical = device.findtext("./WORKSPACE/PHYSICAL", default="")
|
|
313
|
+
if not physical:
|
|
314
|
+
issues.append("Device is missing physical workspace path")
|
|
315
|
+
continue
|
|
316
|
+
if not legacy_uuid_physical and physical not in physical_paths:
|
|
317
|
+
issues.append(f"Device physical path does not exist in PHYSICALWORKSPACE: {physical}")
|
|
318
|
+
|
|
319
|
+
if all(device.find("./WORKSPACE/PHYSICAL_CPUR") is None for device in devices):
|
|
320
|
+
workspace_mode = "logical_only_safe"
|
|
321
|
+
elif legacy_uuid_physical:
|
|
322
|
+
workspace_mode = "legacy_uuid_physical"
|
|
323
|
+
else:
|
|
324
|
+
workspace_mode = "mixed_physical"
|
|
325
|
+
logical_status = "ok" if not any("logical" in issue.lower() or "mem_addr" in issue.lower() for issue in issues) else "invalid"
|
|
326
|
+
physical_status = "ok" if not any("physical" in issue.lower() for issue in issues) else "invalid"
|
|
327
|
+
return WorkspaceValidationResult(workspace_mode, logical_status, physical_status, issues)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def validate_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
|
|
331
|
+
result = inspect_workspace_integrity(root)
|
|
332
|
+
if result.blocking_issues:
|
|
333
|
+
raise ValueError("; ".join(result.blocking_issues))
|
|
334
|
+
return result
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _section_xml(root: ET.Element, path: str) -> str:
|
|
338
|
+
node = root.find(path)
|
|
339
|
+
if node is None:
|
|
340
|
+
return ""
|
|
341
|
+
return ET.tostring(node, encoding="unicode")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _section_content_score(node: ET.Element | None) -> int:
|
|
345
|
+
if node is None:
|
|
346
|
+
return 0
|
|
347
|
+
score = len(node.attrib)
|
|
348
|
+
if (node.text or "").strip():
|
|
349
|
+
score += 1
|
|
350
|
+
score += sum(1 for _ in node.iter()) - 1
|
|
351
|
+
return score
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _section_content_score_many(nodes: list[ET.Element]) -> int:
|
|
355
|
+
return sum(_section_content_score(node) for node in nodes)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _device_by_save_ref(root: ET.Element) -> dict[str, ET.Element]:
|
|
359
|
+
devices: dict[str, ET.Element] = {}
|
|
360
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
361
|
+
save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="").strip()
|
|
362
|
+
if save_ref:
|
|
363
|
+
devices[save_ref] = device
|
|
364
|
+
return devices
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _device_leaf_uuid(device: ET.Element) -> str:
|
|
368
|
+
physical = device.findtext("./WORKSPACE/PHYSICAL", default="").strip()
|
|
369
|
+
if not physical:
|
|
370
|
+
return ""
|
|
371
|
+
token = physical.split(",")[-1].strip()
|
|
372
|
+
if token.startswith("{") and token.endswith("}"):
|
|
373
|
+
return token
|
|
374
|
+
return ""
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _physical_leaf_index(root: ET.Element) -> dict[str, ET.Element]:
|
|
378
|
+
nodes: dict[str, ET.Element] = {}
|
|
379
|
+
for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
|
|
380
|
+
uuid = node.findtext("./UUID_STR", default="").strip()
|
|
381
|
+
if uuid:
|
|
382
|
+
nodes[uuid] = node
|
|
383
|
+
return nodes
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _config_hostname_hits(device: ET.Element, old_name: str) -> bool:
|
|
387
|
+
target = f"hostname {old_name}"
|
|
388
|
+
for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
389
|
+
if (line.text or "").strip() == target:
|
|
390
|
+
return True
|
|
391
|
+
for line in device.findall("./ENGINE/STARTUPCONFIG/LINE"):
|
|
392
|
+
if (line.text or "").strip() == target:
|
|
393
|
+
return True
|
|
394
|
+
for line in device.findall(".//FILE_CONTENT/CONFIG/LINE"):
|
|
395
|
+
if (line.text or "").strip() == target:
|
|
396
|
+
return True
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def inspect_donor_coherence(donor_root: ET.Element, generated_root: ET.Element) -> DonorCoherenceResult:
|
|
401
|
+
issues: list[str] = []
|
|
402
|
+
donor_devices = _device_by_save_ref(donor_root)
|
|
403
|
+
generated_devices = _device_by_save_ref(generated_root)
|
|
404
|
+
|
|
405
|
+
scenario_sections = {
|
|
406
|
+
"SCENARIOSET": _section_xml(generated_root, "./SCENARIOSET"),
|
|
407
|
+
"COMMAND_LOGS": _section_xml(generated_root, "./COMMAND_LOGS"),
|
|
408
|
+
"CEPS": _section_xml(generated_root, "./CEPS"),
|
|
409
|
+
"FILTERS": _section_xml(generated_root, "./FILTERS"),
|
|
410
|
+
}
|
|
411
|
+
physical_sections = {
|
|
412
|
+
"PHYSICALWORKSPACE": _section_xml(generated_root, "./PHYSICALWORKSPACE"),
|
|
413
|
+
"GEOVIEW_GRAPHICSITEMS": _section_xml(generated_root, "./GEOVIEW_GRAPHICSITEMS"),
|
|
414
|
+
"CLUSTERS": _section_xml(generated_root, "./CLUSTERS"),
|
|
415
|
+
}
|
|
416
|
+
global_visual_paths = {
|
|
417
|
+
"FILTERS": "./FILTERS",
|
|
418
|
+
"CLUSTERS": "./CLUSTERS",
|
|
419
|
+
"GEOVIEW_GRAPHICSITEMS": "./GEOVIEW_GRAPHICSITEMS",
|
|
420
|
+
"RECTANGLES": "./RECTANGLES",
|
|
421
|
+
"ELLIPSES": "./ELLIPSES",
|
|
422
|
+
"POLYGONS": "./POLYGONS",
|
|
423
|
+
}
|
|
424
|
+
preserved_scenario_paths = {
|
|
425
|
+
"SCENARIOSET": "./SCENARIOSET",
|
|
426
|
+
"COMMAND_LOGS": "./COMMAND_LOGS",
|
|
427
|
+
"CEPS": "./CEPS",
|
|
428
|
+
}
|
|
429
|
+
for section_name, path in preserved_scenario_paths.items():
|
|
430
|
+
donor_score = _section_content_score(donor_root.find(path))
|
|
431
|
+
generated_score = _section_content_score(generated_root.find(path))
|
|
432
|
+
if donor_score > 0 and generated_score == 0:
|
|
433
|
+
issues.append(f"Generated file unexpectedly emptied donor runtime section {section_name}")
|
|
434
|
+
for section_name, path in global_visual_paths.items():
|
|
435
|
+
donor_score = _section_content_score(donor_root.find(path))
|
|
436
|
+
generated_score = _section_content_score(generated_root.find(path))
|
|
437
|
+
if donor_score > 0 and generated_score == 0:
|
|
438
|
+
issues.append(f"Generated file unexpectedly emptied donor visual section {section_name}")
|
|
439
|
+
|
|
440
|
+
donor_notes_score = _section_content_score_many(donor_root.findall("./PHYSICALWORKSPACE//NOTES"))
|
|
441
|
+
generated_notes_score = _section_content_score_many(generated_root.findall("./PHYSICALWORKSPACE//NOTES"))
|
|
442
|
+
if donor_notes_score > 0 and generated_notes_score == 0:
|
|
443
|
+
issues.append("Generated file unexpectedly emptied donor visual section PHYSICALWORKSPACE/NOTES")
|
|
444
|
+
|
|
445
|
+
for tag in ["ANSWER_TREE_SELECTED", "PHYSICALALIGN", "HIDEPHYSICAL", "CABLE_POPUP_IN_PHYSICAL"]:
|
|
446
|
+
donor_value = donor_root.findtext(f"./{tag}", default="").strip()
|
|
447
|
+
generated_value = generated_root.findtext(f"./{tag}", default="").strip()
|
|
448
|
+
if donor_value != generated_value:
|
|
449
|
+
issues.append(f"Generated file changed donor physical view state {tag} from {donor_value} to {generated_value}")
|
|
450
|
+
|
|
451
|
+
# A pruned device's *name* is often reused: the donor's PC1 goes and the
|
|
452
|
+
# generated lab has a PC1 of its own, because that is what was asked for.
|
|
453
|
+
# The checks below are substring searches, so the reused name matched and a
|
|
454
|
+
# perfectly good donor was rejected -- which is why a VoIP lab carrying the
|
|
455
|
+
# analog phone a prompt asked for could never be used.
|
|
456
|
+
generated_names = {
|
|
457
|
+
device.findtext("./ENGINE/NAME", default="").strip()
|
|
458
|
+
for device in generated_devices.values()
|
|
459
|
+
}
|
|
460
|
+
pruned_ids = sorted(set(donor_devices) - set(generated_devices))
|
|
461
|
+
for save_ref in pruned_ids:
|
|
462
|
+
donor_device = donor_devices[save_ref]
|
|
463
|
+
donor_name = donor_device.findtext("./ENGINE/NAME", default="").strip()
|
|
464
|
+
if donor_name in generated_names:
|
|
465
|
+
donor_name = ""
|
|
466
|
+
original_uuid = donor_device.findtext(".//ORIGINAL_DEVICE_UUID", default="").strip()
|
|
467
|
+
physical_uuid = _device_leaf_uuid(donor_device)
|
|
468
|
+
for section_name, text in scenario_sections.items():
|
|
469
|
+
for label, value in [("save-ref-id", save_ref), ("device-name", donor_name), ("original-uuid", original_uuid)]:
|
|
470
|
+
if _leftover_hit(label, value, text):
|
|
471
|
+
issues.append(f"Pruned device {donor_name} still appears in {section_name} via {label}")
|
|
472
|
+
for section_name, text in physical_sections.items():
|
|
473
|
+
for label, value in [("save-ref-id", save_ref), ("device-name", donor_name), ("original-uuid", original_uuid), ("physical-leaf", physical_uuid)]:
|
|
474
|
+
if _leftover_hit(label, value, text):
|
|
475
|
+
issues.append(f"Pruned device {donor_name} still appears in {section_name} via {label}")
|
|
476
|
+
|
|
477
|
+
physical_index = _physical_leaf_index(generated_root)
|
|
478
|
+
for save_ref in sorted(set(donor_devices) & set(generated_devices)):
|
|
479
|
+
donor_device = donor_devices[save_ref]
|
|
480
|
+
generated_device = generated_devices[save_ref]
|
|
481
|
+
donor_name = donor_device.findtext("./ENGINE/NAME", default="").strip()
|
|
482
|
+
generated_name = generated_device.findtext("./ENGINE/NAME", default="").strip()
|
|
483
|
+
sys_name = generated_device.findtext("./ENGINE/SYS_NAME", default="").strip()
|
|
484
|
+
if donor_name != generated_name and sys_name == donor_name:
|
|
485
|
+
issues.append(f"Renamed device {generated_name} still keeps donor SYS_NAME {donor_name}")
|
|
486
|
+
if donor_name != generated_name and _config_hostname_hits(generated_device, donor_name):
|
|
487
|
+
issues.append(f"Renamed device {generated_name} still keeps donor hostname {donor_name} in config")
|
|
488
|
+
leaf_uuid = _device_leaf_uuid(generated_device)
|
|
489
|
+
if leaf_uuid:
|
|
490
|
+
leaf = physical_index.get(leaf_uuid)
|
|
491
|
+
if leaf is None:
|
|
492
|
+
issues.append(f"Generated device {generated_name} references missing physical leaf {leaf_uuid}")
|
|
493
|
+
else:
|
|
494
|
+
leaf_name = leaf.findtext("./NAME", default="").strip()
|
|
495
|
+
if leaf_name != generated_name:
|
|
496
|
+
issues.append(f"Generated device {generated_name} physical leaf name is {leaf_name}")
|
|
497
|
+
|
|
498
|
+
device_issues = [issue for issue in issues if "SYS_NAME" in issue or "hostname" in issue]
|
|
499
|
+
scenario_issues = [issue for issue in issues if any(section in issue for section in ["SCENARIOSET", "COMMAND_LOGS", "CEPS"])]
|
|
500
|
+
physical_issues = [issue for issue in issues if any(section in issue for section in ["PHYSICALWORKSPACE", "physical leaf"])]
|
|
501
|
+
visual_issues = [
|
|
502
|
+
issue
|
|
503
|
+
for issue in issues
|
|
504
|
+
if any(
|
|
505
|
+
section in issue
|
|
506
|
+
for section in [
|
|
507
|
+
"FILTERS",
|
|
508
|
+
"CLUSTERS",
|
|
509
|
+
"GEOVIEW_GRAPHICSITEMS",
|
|
510
|
+
"RECTANGLES",
|
|
511
|
+
"ELLIPSES",
|
|
512
|
+
"POLYGONS",
|
|
513
|
+
"PHYSICALWORKSPACE/NOTES",
|
|
514
|
+
"physical view state",
|
|
515
|
+
]
|
|
516
|
+
)
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
return DonorCoherenceResult(
|
|
520
|
+
device_metadata_status="ok" if not device_issues else "invalid",
|
|
521
|
+
scenario_status="ok" if not scenario_issues else "invalid",
|
|
522
|
+
physical_runtime_status="ok" if not physical_issues else "invalid",
|
|
523
|
+
visual_runtime_status="ok" if not visual_issues else "invalid",
|
|
524
|
+
blocking_issues=issues,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _leftover_hit(label: str, value: str, text: str) -> bool:
|
|
529
|
+
"""Whether `value` really names a leftover in a serialised section.
|
|
530
|
+
|
|
531
|
+
A device name has to match a whole element, not a substring. Measured on a
|
|
532
|
+
donor that carries both `Switch1` and `Multilayer Switch1`: pruning
|
|
533
|
+
`Switch1` was reported as a leftover because the string still occurs inside
|
|
534
|
+
the multilayer switch's name, and the donor was rejected for a device that
|
|
535
|
+
had gone. Ids are unique tokens, so they keep the plain search.
|
|
536
|
+
"""
|
|
537
|
+
if not value:
|
|
538
|
+
return False
|
|
539
|
+
if label != "device-name":
|
|
540
|
+
return value in text
|
|
541
|
+
return f">{value}<" in text
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def validate_donor_coherence(donor_root: ET.Element, generated_root: ET.Element) -> DonorCoherenceResult:
|
|
545
|
+
result = inspect_donor_coherence(donor_root, generated_root)
|
|
546
|
+
if result.blocking_issues:
|
|
547
|
+
raise ValueError("; ".join(result.blocking_issues))
|
|
548
|
+
return result
|