packet-tracer-skill 0.1.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/LICENSE +21 -0
- package/LICENSES/LICENSE.Twofish-BSD-3-Clause.txt +29 -0
- package/README.md +687 -0
- package/SKILL.md +221 -0
- package/bin/packet-tracer-skill.js +635 -0
- package/examples/blueprint_minimal.json +46 -0
- package/package.json +42 -0
- package/references/packettracer-sample-catalog.json +21410 -0
- package/references/packettracer-sample-catalog.md +1124 -0
- package/references/pkt-format.md +57 -0
- package/references/xml-skeleton-notes.md +44 -0
- package/requirements-dev.txt +1 -0
- package/requirements.txt +6 -0
- package/scripts/build_sample_catalog.py +65 -0
- package/scripts/donor_diagnostics.py +35 -0
- package/scripts/generate_pkt.py +1264 -0
- package/scripts/install_skill.py +71 -0
- package/scripts/intent_parser.py +712 -0
- package/scripts/packet_tracer_env.py +278 -0
- package/scripts/pkt_builder.py +15 -0
- package/scripts/pkt_codec.py +181 -0
- package/scripts/pkt_editor.py +752 -0
- package/scripts/pkt_transformer.py +541 -0
- package/scripts/sample_catalog.py +385 -0
- package/scripts/sample_selector.py +156 -0
- package/scripts/setup.ps1 +26 -0
- package/scripts/twofish_diagnostics.py +91 -0
- package/scripts/vendor/README.md +46 -0
- package/scripts/vendor/twofish.py +81 -0
- package/scripts/workspace_repair.py +441 -0
- package/templates/pt900/base_empty.xml +21 -0
- package/templates/pt900/device_library/pc.xml +20 -0
- package/templates/pt900/device_library/printer.xml +432 -0
- package/templates/pt900/device_library/router.xml +16 -0
- package/templates/pt900/device_library/switch.xml +38 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
|
|
8
|
+
from intent_parser import IntentPlan
|
|
9
|
+
from packet_tracer_env import resolve_sample_path
|
|
10
|
+
from pkt_codec import decode_pkt_modern, encode_pkt_modern
|
|
11
|
+
from pkt_transformer import _device_type, _port_address_for_name, apply_cable_type, apply_host_ip, load_sample_root
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
FTP_SAMPLE = r"01 Networking\FTP\FTP.pkt"
|
|
15
|
+
SERVER_SAMPLE = r"01 Networking\DNS\Multilevel_DNS.pkt"
|
|
16
|
+
WIRELESS_SAMPLE = r"01 Networking\DHCP\dhcp_reservation.pkt"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def decode_pkt_to_root(pkt_path: str | Path) -> ET.Element:
|
|
20
|
+
return ET.fromstring(decode_pkt_modern(Path(pkt_path).read_bytes()))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def inventory_devices(root: ET.Element) -> list[dict[str, str]]:
|
|
24
|
+
devices: list[dict[str, str]] = []
|
|
25
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
26
|
+
devices.append(
|
|
27
|
+
{
|
|
28
|
+
"name": device.findtext("./ENGINE/NAME", default=""),
|
|
29
|
+
"type": _device_type(device),
|
|
30
|
+
"model": device.find("./ENGINE/TYPE").get("model", "") if device.find("./ENGINE/TYPE") is not None else "",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
return devices
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def inventory_links(root: ET.Element) -> list[dict[str, object]]:
|
|
37
|
+
devices = root.findall(".//DEVICES/DEVICE")
|
|
38
|
+
index_to_name = {str(index): device.findtext("./ENGINE/NAME", default="") for index, device in enumerate(devices)}
|
|
39
|
+
save_ref_to_name = {
|
|
40
|
+
device.findtext("./ENGINE/SAVE_REF_ID", default=""): device.findtext("./ENGINE/NAME", default="")
|
|
41
|
+
for device in devices
|
|
42
|
+
if device.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
43
|
+
}
|
|
44
|
+
result: list[dict[str, object]] = []
|
|
45
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
46
|
+
cable = link.find("./CABLE")
|
|
47
|
+
if cable is None:
|
|
48
|
+
continue
|
|
49
|
+
ports = cable.findall("PORT")
|
|
50
|
+
from_ref = cable.findtext("FROM", default="")
|
|
51
|
+
to_ref = cable.findtext("TO", default="")
|
|
52
|
+
result.append(
|
|
53
|
+
{
|
|
54
|
+
"from": save_ref_to_name.get(from_ref, index_to_name.get(from_ref, "")),
|
|
55
|
+
"to": save_ref_to_name.get(to_ref, index_to_name.get(to_ref, "")),
|
|
56
|
+
"ports": [port.text or "" for port in ports],
|
|
57
|
+
"media": cable.findtext("TYPE", default=""),
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def inventory_services(root: ET.Element) -> dict[str, list[str]]:
|
|
64
|
+
result: dict[str, list[str]] = {}
|
|
65
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
66
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
67
|
+
engine = device.find("./ENGINE")
|
|
68
|
+
if engine is None:
|
|
69
|
+
continue
|
|
70
|
+
enabled: list[str] = []
|
|
71
|
+
for tag, enabled_tag in [
|
|
72
|
+
("HTTP_SERVER", "ENABLED"),
|
|
73
|
+
("HTTPS_SERVER", "HTTPSENABLED"),
|
|
74
|
+
("DNS_SERVER", "ENABLED"),
|
|
75
|
+
("DHCP_SERVER", "ENABLED"),
|
|
76
|
+
("TFTP_SERVER", "ENABLED"),
|
|
77
|
+
("NTP_SERVER", "ENABLED"),
|
|
78
|
+
]:
|
|
79
|
+
node = engine.find(tag)
|
|
80
|
+
if node is not None and node.findtext(enabled_tag, default="0") in {"1", "true", "True"}:
|
|
81
|
+
enabled.append(tag.lower())
|
|
82
|
+
if enabled:
|
|
83
|
+
result[name] = enabled
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def inventory_wireless(root: ET.Element) -> dict[str, dict[str, str]]:
|
|
88
|
+
result: dict[str, dict[str, str]] = {}
|
|
89
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
90
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
91
|
+
engine = device.find("./ENGINE")
|
|
92
|
+
if engine is None:
|
|
93
|
+
continue
|
|
94
|
+
if engine.find("WIRELESS_SERVER") is not None:
|
|
95
|
+
common = engine.find("./WIRELESS_SERVER/WIRELESS_COMMON")
|
|
96
|
+
result[name] = {
|
|
97
|
+
"mode": "ap",
|
|
98
|
+
"ssid": common.findtext("SSID", default="") if common is not None else "",
|
|
99
|
+
}
|
|
100
|
+
if engine.find("WIRELESS_CLIENT") is not None:
|
|
101
|
+
common = engine.find("./WIRELESS_CLIENT/WIRELESS_COMMON")
|
|
102
|
+
result[name] = {
|
|
103
|
+
"mode": "client",
|
|
104
|
+
"ssid": common.findtext("SSID", default="") if common is not None else "",
|
|
105
|
+
}
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def inventory_vlans(root: ET.Element) -> dict[str, list[dict[str, str]]]:
|
|
110
|
+
result: dict[str, list[dict[str, str]]] = {}
|
|
111
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
112
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
113
|
+
vlans: list[dict[str, str]] = []
|
|
114
|
+
for vlan in device.findall(".//VLANS/VLAN"):
|
|
115
|
+
vlan_id = vlan.get("number") or vlan.findtext("ID", default="")
|
|
116
|
+
vlan_name = vlan.get("name") or vlan.findtext("NAME", default="")
|
|
117
|
+
if vlan_id:
|
|
118
|
+
vlans.append({"id": vlan_id, "name": vlan_name})
|
|
119
|
+
if vlans:
|
|
120
|
+
result[name] = vlans
|
|
121
|
+
continue
|
|
122
|
+
running = "\n".join(line.text or "" for line in device.findall(".//LINE"))
|
|
123
|
+
inferred: list[dict[str, str]] = []
|
|
124
|
+
for match in re.finditer(r"(?mi)^vlan\s+(\d+)\s*$", running):
|
|
125
|
+
vlan_id = match.group(1)
|
|
126
|
+
name_match = re.search(rf"(?mi)^vlan\s+{re.escape(vlan_id)}\s*$\n^\s*name\s+(.+?)\s*$", running)
|
|
127
|
+
inferred.append({"id": vlan_id, "name": name_match.group(1).strip() if name_match else ""})
|
|
128
|
+
if inferred:
|
|
129
|
+
unique: dict[str, dict[str, str]] = {entry["id"]: entry for entry in inferred}
|
|
130
|
+
result[name] = list(unique.values())
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def inventory_dhcp_pools(root: ET.Element) -> dict[str, list[str]]:
|
|
135
|
+
pools: dict[str, list[str]] = {}
|
|
136
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
137
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
138
|
+
device_pools: list[str] = []
|
|
139
|
+
for pool in device.findall(".//DHCP_SERVER/POOLS/POOL/NAME"):
|
|
140
|
+
if pool.text:
|
|
141
|
+
device_pools.append(pool.text)
|
|
142
|
+
running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
143
|
+
for match in re.findall(r"ip dhcp pool\s+([A-Za-z0-9_-]+)", running):
|
|
144
|
+
if match not in device_pools:
|
|
145
|
+
device_pools.append(match)
|
|
146
|
+
if device_pools:
|
|
147
|
+
pools[name] = device_pools
|
|
148
|
+
return pools
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def inventory_acl_names(root: ET.Element) -> dict[str, list[str]]:
|
|
152
|
+
result: dict[str, list[str]] = {}
|
|
153
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
154
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
155
|
+
running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
156
|
+
matches = re.findall(r"ip access-list\s+(?:standard|extended)\s+([A-Za-z0-9_-]+)", running)
|
|
157
|
+
if matches:
|
|
158
|
+
result[name] = sorted(dict.fromkeys(matches))
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def inventory_topology_summary(root: ET.Element) -> dict[str, object]:
|
|
163
|
+
devices = inventory_devices(root)
|
|
164
|
+
counts: dict[str, int] = {}
|
|
165
|
+
for device in devices:
|
|
166
|
+
counts[device["type"]] = counts.get(device["type"], 0) + 1
|
|
167
|
+
return {
|
|
168
|
+
"device_counts": counts,
|
|
169
|
+
"link_count": len(inventory_links(root)),
|
|
170
|
+
"has_wireless": bool(inventory_wireless(root)),
|
|
171
|
+
"has_services": bool(inventory_services(root)),
|
|
172
|
+
"has_vlans": bool(inventory_vlans(root)),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def inventory_root(root: ET.Element) -> dict[str, object]:
|
|
177
|
+
return {
|
|
178
|
+
"devices": inventory_devices(root),
|
|
179
|
+
"links": inventory_links(root),
|
|
180
|
+
"services": inventory_services(root),
|
|
181
|
+
"wireless": inventory_wireless(root),
|
|
182
|
+
"vlans": inventory_vlans(root),
|
|
183
|
+
"dhcp_pools": inventory_dhcp_pools(root),
|
|
184
|
+
"acl_names": inventory_acl_names(root),
|
|
185
|
+
"topology_summary": inventory_topology_summary(root),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _find_device(root: ET.Element, name: str) -> ET.Element | None:
|
|
190
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
191
|
+
if device.findtext("./ENGINE/NAME", default="") == name:
|
|
192
|
+
return device
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _ensure_text(parent: ET.Element, tag: str, value: str) -> ET.Element:
|
|
197
|
+
node = parent.find(tag)
|
|
198
|
+
if node is None:
|
|
199
|
+
node = ET.SubElement(parent, tag)
|
|
200
|
+
node.text = value
|
|
201
|
+
return node
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _replace_lines(target: ET.Element, lines: list[str]) -> None:
|
|
205
|
+
target.clear()
|
|
206
|
+
for line in lines:
|
|
207
|
+
node = ET.SubElement(target, "LINE")
|
|
208
|
+
node.text = line
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _append_unique_config_lines(parent: ET.Element | None, lines: list[str]) -> None:
|
|
212
|
+
if parent is None:
|
|
213
|
+
return
|
|
214
|
+
existing = [line.text or "" for line in parent.findall("./LINE")]
|
|
215
|
+
merged = list(existing)
|
|
216
|
+
for line in lines:
|
|
217
|
+
if line not in merged:
|
|
218
|
+
merged.append(line)
|
|
219
|
+
_replace_lines(parent, merged)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _append_config_block(parent: ET.Element | None, header: str, body: list[str]) -> None:
|
|
223
|
+
if parent is None:
|
|
224
|
+
return
|
|
225
|
+
existing = [line.text or "" for line in parent.findall("./LINE")]
|
|
226
|
+
block = [header, *body]
|
|
227
|
+
for index in range(0, max(len(existing) - len(block) + 1, 0)):
|
|
228
|
+
if existing[index : index + len(block)] == block:
|
|
229
|
+
return
|
|
230
|
+
_replace_lines(parent, [*existing, *block])
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _device_index_map(root: ET.Element) -> dict[str, int]:
|
|
234
|
+
return {device.findtext("./ENGINE/NAME", default=""): index for index, device in enumerate(root.findall(".//DEVICES/DEVICE"))}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _device_refs(root: ET.Element) -> tuple[dict[str, str], dict[str, str]]:
|
|
238
|
+
index_map = _device_index_map(root)
|
|
239
|
+
index_refs = {name: str(index) for name, index in index_map.items()}
|
|
240
|
+
save_refs = {}
|
|
241
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
242
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
243
|
+
save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
244
|
+
if name and save_ref:
|
|
245
|
+
save_refs[name] = save_ref
|
|
246
|
+
return index_refs, save_refs
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _find_link_by_devices(root: ET.Element, left_name: str, right_name: str) -> ET.Element | None:
|
|
250
|
+
index_refs, save_refs = _device_refs(root)
|
|
251
|
+
left_candidates = {index_refs.get(left_name, ""), save_refs.get(left_name, "")}
|
|
252
|
+
right_candidates = {index_refs.get(right_name, ""), save_refs.get(right_name, "")}
|
|
253
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
254
|
+
cable = link.find("./CABLE")
|
|
255
|
+
if cable is None:
|
|
256
|
+
continue
|
|
257
|
+
from_idx = cable.findtext("FROM", default="")
|
|
258
|
+
to_idx = cable.findtext("TO", default="")
|
|
259
|
+
if from_idx in left_candidates and to_idx in right_candidates:
|
|
260
|
+
return link
|
|
261
|
+
if from_idx in right_candidates and to_idx in left_candidates:
|
|
262
|
+
return link
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _remove_links_for_device(root: ET.Element, device_name: str) -> None:
|
|
267
|
+
index_refs, save_refs = _device_refs(root)
|
|
268
|
+
refs = {index_refs.get(device_name, ""), save_refs.get(device_name, "")}
|
|
269
|
+
links_parent = root.find(".//LINKS")
|
|
270
|
+
if links_parent is None:
|
|
271
|
+
return
|
|
272
|
+
for link in list(links_parent.findall("./LINK")):
|
|
273
|
+
cable = link.find("./CABLE")
|
|
274
|
+
if cable is None:
|
|
275
|
+
continue
|
|
276
|
+
if cable.findtext("FROM", default="") in refs or cable.findtext("TO", default="") in refs:
|
|
277
|
+
links_parent.remove(link)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _find_parent_of_node(root: ET.Element, target: ET.Element) -> ET.Element | None:
|
|
281
|
+
for parent in root.iter():
|
|
282
|
+
for child in list(parent):
|
|
283
|
+
if child is target:
|
|
284
|
+
return parent
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _remove_physical_leaf(root: ET.Element, device: ET.Element) -> None:
|
|
289
|
+
physical_path = device.findtext("./WORKSPACE/PHYSICAL", default="")
|
|
290
|
+
if not physical_path:
|
|
291
|
+
return
|
|
292
|
+
tokens = [token.strip() for token in physical_path.split(",") if token.strip()]
|
|
293
|
+
if not tokens:
|
|
294
|
+
return
|
|
295
|
+
leaf_token = tokens[-1]
|
|
296
|
+
for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
|
|
297
|
+
uuid = node.findtext("UUID_STR", default="").strip()
|
|
298
|
+
if uuid == leaf_token:
|
|
299
|
+
parent = _find_parent_of_node(root, node)
|
|
300
|
+
if parent is not None:
|
|
301
|
+
parent.remove(node)
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _prune_device(root: ET.Element, device_name: str) -> None:
|
|
306
|
+
device = _find_device(root, device_name)
|
|
307
|
+
if device is None:
|
|
308
|
+
return
|
|
309
|
+
_remove_links_for_device(root, device_name)
|
|
310
|
+
_remove_physical_leaf(root, device)
|
|
311
|
+
parent = _find_parent_of_node(root, device)
|
|
312
|
+
if parent is not None:
|
|
313
|
+
parent.remove(device)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _remove_link(root: ET.Element, left_name: str, right_name: str) -> None:
|
|
317
|
+
links_parent = root.find(".//LINKS")
|
|
318
|
+
if links_parent is None:
|
|
319
|
+
return
|
|
320
|
+
link = _find_link_by_devices(root, left_name, right_name)
|
|
321
|
+
if link is not None:
|
|
322
|
+
links_parent.remove(link)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _ensure_link(root: ET.Element, left_name: str, left_port: str, right_name: str, right_port: str, media: str) -> None:
|
|
326
|
+
existing = _find_link_by_devices(root, left_name, right_name)
|
|
327
|
+
devices = {device.findtext("./ENGINE/NAME", default=""): device for device in root.findall(".//DEVICES/DEVICE")}
|
|
328
|
+
index_refs, save_refs = _device_refs(root)
|
|
329
|
+
left_device = devices.get(left_name)
|
|
330
|
+
right_device = devices.get(right_name)
|
|
331
|
+
if left_device is None or right_device is None:
|
|
332
|
+
return
|
|
333
|
+
link = existing
|
|
334
|
+
if link is None:
|
|
335
|
+
left_type = _device_type(left_device)
|
|
336
|
+
right_type = _device_type(right_device)
|
|
337
|
+
prototype = None
|
|
338
|
+
for candidate in root.findall(".//LINKS/LINK"):
|
|
339
|
+
cable = candidate.find("./CABLE")
|
|
340
|
+
if cable is None:
|
|
341
|
+
continue
|
|
342
|
+
from_ref = cable.findtext("FROM", default="")
|
|
343
|
+
to_ref = cable.findtext("TO", default="")
|
|
344
|
+
from_name = next((name for name, ref in save_refs.items() if ref == from_ref), next((name for name, ref in index_refs.items() if ref == from_ref), ""))
|
|
345
|
+
to_name = next((name for name, ref in save_refs.items() if ref == to_ref), next((name for name, ref in index_refs.items() if ref == to_ref), ""))
|
|
346
|
+
if not from_name or not to_name:
|
|
347
|
+
continue
|
|
348
|
+
from_type = _device_type(devices[from_name])
|
|
349
|
+
to_type = _device_type(devices[to_name])
|
|
350
|
+
if {from_type, to_type} == {left_type, right_type}:
|
|
351
|
+
prototype = candidate
|
|
352
|
+
break
|
|
353
|
+
if prototype is None:
|
|
354
|
+
prototype_root = load_sample_root(resolve_sample_path(FTP_SAMPLE))
|
|
355
|
+
prototype = prototype_root.find(".//LINKS/LINK")
|
|
356
|
+
link = copy.deepcopy(prototype)
|
|
357
|
+
if link is None:
|
|
358
|
+
return
|
|
359
|
+
links_parent = root.find(".//LINKS")
|
|
360
|
+
if links_parent is None:
|
|
361
|
+
return
|
|
362
|
+
links_parent.append(link)
|
|
363
|
+
cable = link.find("./CABLE")
|
|
364
|
+
if cable is None:
|
|
365
|
+
return
|
|
366
|
+
_ensure_text(cable, "FROM", save_refs.get(left_name, index_refs[left_name]))
|
|
367
|
+
_ensure_text(cable, "TO", save_refs.get(right_name, index_refs[right_name]))
|
|
368
|
+
ports = cable.findall("PORT")
|
|
369
|
+
if len(ports) < 2:
|
|
370
|
+
while len(ports) < 2:
|
|
371
|
+
ports.append(ET.SubElement(cable, "PORT"))
|
|
372
|
+
ports[0].text = left_port
|
|
373
|
+
ports[1].text = right_port
|
|
374
|
+
for node_name, value in [
|
|
375
|
+
("FROM_DEVICE_MEM_ADDR", left_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")),
|
|
376
|
+
("TO_DEVICE_MEM_ADDR", right_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")),
|
|
377
|
+
("FROM_PORT_MEM_ADDR", _port_address_for_name(left_device, left_port) or ""),
|
|
378
|
+
("TO_PORT_MEM_ADDR", _port_address_for_name(right_device, right_port) or ""),
|
|
379
|
+
]:
|
|
380
|
+
_ensure_text(cable, node_name, value)
|
|
381
|
+
apply_cable_type(cable, media)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _prefix_to_mask(prefix: int) -> str:
|
|
385
|
+
bits = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
|
|
386
|
+
return ".".join(str((bits >> shift) & 0xFF) for shift in (24, 16, 8, 0))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _set_device_name(root: ET.Element, device: ET.Element, new_name: str) -> None:
|
|
390
|
+
old_name = device.findtext("./ENGINE/NAME", default="")
|
|
391
|
+
node = device.find("./ENGINE/NAME")
|
|
392
|
+
if node is not None:
|
|
393
|
+
node.text = new_name
|
|
394
|
+
sys_name = device.find("./ENGINE/SYS_NAME")
|
|
395
|
+
if sys_name is not None and (sys_name.text or "").strip() == old_name:
|
|
396
|
+
sys_name.text = new_name
|
|
397
|
+
|
|
398
|
+
for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
399
|
+
text = line.text or ""
|
|
400
|
+
if old_name and text == f"hostname {old_name}":
|
|
401
|
+
line.text = f"hostname {new_name}"
|
|
402
|
+
for line in device.findall("./ENGINE/STARTUPCONFIG/LINE"):
|
|
403
|
+
text = line.text or ""
|
|
404
|
+
if old_name and text == f"hostname {old_name}":
|
|
405
|
+
line.text = f"hostname {new_name}"
|
|
406
|
+
for line in device.findall(".//FILE_CONTENT/CONFIG/LINE"):
|
|
407
|
+
text = line.text or ""
|
|
408
|
+
if old_name and text == f"hostname {old_name}":
|
|
409
|
+
line.text = f"hostname {new_name}"
|
|
410
|
+
|
|
411
|
+
physical = device.findtext("./WORKSPACE/PHYSICAL", default="")
|
|
412
|
+
leaf_uuid = physical.split(",")[-1].strip() if physical else ""
|
|
413
|
+
if leaf_uuid:
|
|
414
|
+
for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
|
|
415
|
+
uuid = node.findtext("UUID_STR", default="").strip()
|
|
416
|
+
if uuid == leaf_uuid:
|
|
417
|
+
leaf_name = node.find("./NAME")
|
|
418
|
+
if leaf_name is not None:
|
|
419
|
+
leaf_name.text = new_name
|
|
420
|
+
break
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _set_device_position(device: ET.Element, x: int, y: int) -> None:
|
|
424
|
+
workspace = device.find("./WORKSPACE/LOGICAL")
|
|
425
|
+
if workspace is None:
|
|
426
|
+
return
|
|
427
|
+
_ensure_text(workspace, "X", str(x))
|
|
428
|
+
_ensure_text(workspace, "Y", str(y))
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _config_targets(device: ET.Element) -> list[ET.Element]:
|
|
432
|
+
targets: list[ET.Element] = []
|
|
433
|
+
for path in ["./ENGINE/RUNNINGCONFIG", "./ENGINE/STARTUPCONFIG"]:
|
|
434
|
+
node = device.find(path)
|
|
435
|
+
if node is not None:
|
|
436
|
+
targets.append(node)
|
|
437
|
+
for node in device.findall(".//FILE_CONTENT/CONFIG"):
|
|
438
|
+
targets.append(node)
|
|
439
|
+
return targets
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _ensure_vlan_state(device: ET.Element, vlan_id: int, vlan_name: str) -> None:
|
|
443
|
+
vlans = device.find(".//VLANS")
|
|
444
|
+
if vlans is None:
|
|
445
|
+
engine = device.find("./ENGINE")
|
|
446
|
+
if engine is None:
|
|
447
|
+
return
|
|
448
|
+
vlans = ET.SubElement(engine, "VLANS")
|
|
449
|
+
existing = next((node for node in vlans.findall("./VLAN") if node.get("number") == str(vlan_id)), None)
|
|
450
|
+
if existing is None:
|
|
451
|
+
existing = ET.SubElement(vlans, "VLAN")
|
|
452
|
+
existing.set("number", str(vlan_id))
|
|
453
|
+
existing.set("rspan", "0")
|
|
454
|
+
existing.set("name", vlan_name)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
458
|
+
if operation["op"] == "set_vlan":
|
|
459
|
+
lines = [f"vlan {operation['vlan']}", f" name {operation['name']}"]
|
|
460
|
+
for target in _config_targets(device):
|
|
461
|
+
_append_unique_config_lines(target, lines)
|
|
462
|
+
_ensure_vlan_state(device, int(operation["vlan"]), str(operation["name"]))
|
|
463
|
+
return
|
|
464
|
+
elif operation["op"] == "set_access_port":
|
|
465
|
+
for target in _config_targets(device):
|
|
466
|
+
_append_config_block(
|
|
467
|
+
target,
|
|
468
|
+
f"interface {operation['port']}",
|
|
469
|
+
[" switchport mode access", f" switchport access vlan {operation['vlan']}"],
|
|
470
|
+
)
|
|
471
|
+
return
|
|
472
|
+
elif operation["op"] == "set_trunk_port":
|
|
473
|
+
allowed = ",".join(str(vlan) for vlan in operation["allowed"])
|
|
474
|
+
body = [" switchport mode trunk", f" switchport trunk allowed vlan {allowed}"]
|
|
475
|
+
if operation.get("native"):
|
|
476
|
+
body.append(f" switchport trunk native vlan {operation['native']}")
|
|
477
|
+
for target in _config_targets(device):
|
|
478
|
+
_append_config_block(target, f"interface {operation['port']}", body)
|
|
479
|
+
return
|
|
480
|
+
else:
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _apply_router_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
485
|
+
if operation["op"] == "set_subinterface":
|
|
486
|
+
for target in _config_targets(device):
|
|
487
|
+
_append_config_block(
|
|
488
|
+
target,
|
|
489
|
+
f"interface {operation['subinterface']}",
|
|
490
|
+
[
|
|
491
|
+
f" encapsulation dot1Q {operation['vlan']}",
|
|
492
|
+
f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}",
|
|
493
|
+
" no shutdown",
|
|
494
|
+
],
|
|
495
|
+
)
|
|
496
|
+
return
|
|
497
|
+
elif operation["op"] == "set_router_dhcp_pool":
|
|
498
|
+
lines = [
|
|
499
|
+
f"ip dhcp pool {operation['name']}",
|
|
500
|
+
f" network {operation['network']} {_prefix_to_mask(int(operation['prefix']))}",
|
|
501
|
+
f" default-router {operation['gateway']}",
|
|
502
|
+
]
|
|
503
|
+
if operation.get("dns"):
|
|
504
|
+
lines.append(f" dns-server {operation['dns']}")
|
|
505
|
+
elif operation["op"] == "set_acl":
|
|
506
|
+
lines = [f"ip access-list {operation['acl_kind']} {operation['acl_name']}"]
|
|
507
|
+
elif operation["op"] == "add_acl_rule":
|
|
508
|
+
acl_kind = str(operation.get("acl_kind") or "standard")
|
|
509
|
+
for target in _config_targets(device):
|
|
510
|
+
_append_config_block(
|
|
511
|
+
target,
|
|
512
|
+
f"ip access-list {acl_kind} {operation['acl_name']}",
|
|
513
|
+
[f" {operation['action']} {operation['source']} {operation['destination']}"] if operation.get("destination") else [f" {operation['action']} {operation['source']}"],
|
|
514
|
+
)
|
|
515
|
+
return
|
|
516
|
+
elif operation["op"] == "apply_acl":
|
|
517
|
+
for target in _config_targets(device):
|
|
518
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" ip access-group {operation['acl_name']} {operation['direction']}"])
|
|
519
|
+
return
|
|
520
|
+
else:
|
|
521
|
+
return
|
|
522
|
+
for target in _config_targets(device):
|
|
523
|
+
_append_unique_config_lines(target, lines)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _apply_management_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
527
|
+
if operation["op"] == "set_management_vlan":
|
|
528
|
+
for target in _config_targets(device):
|
|
529
|
+
_append_config_block(
|
|
530
|
+
target,
|
|
531
|
+
f"interface Vlan{operation['vlan']}",
|
|
532
|
+
[f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}", " no shutdown"],
|
|
533
|
+
)
|
|
534
|
+
_append_unique_config_lines(target, [f"ip default-gateway {operation['gateway']}"])
|
|
535
|
+
return
|
|
536
|
+
elif operation["op"] == "enable_telnet":
|
|
537
|
+
for target in _config_targets(device):
|
|
538
|
+
_append_unique_config_lines(target, [f"username {operation['username']} secret {operation['password']}", f"enable secret {operation['password']}"])
|
|
539
|
+
_append_config_block(target, "line vty 0 4", [" login local", " transport input telnet"])
|
|
540
|
+
return
|
|
541
|
+
else:
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _set_enabled_service(engine: ET.Element, service_name: str) -> None:
|
|
546
|
+
mapping = {
|
|
547
|
+
"dns": ("DNS_SERVER", "ENABLED"),
|
|
548
|
+
"http": ("HTTP_SERVER", "ENABLED"),
|
|
549
|
+
"https": ("HTTPS_SERVER", "HTTPSENABLED"),
|
|
550
|
+
"ftp": ("FTP_SERVER", "ENABLED"),
|
|
551
|
+
"tftp": ("TFTP_SERVER", "ENABLED"),
|
|
552
|
+
"ntp": ("NTP_SERVER", "ENABLED"),
|
|
553
|
+
}
|
|
554
|
+
tag, enabled_tag = mapping[service_name]
|
|
555
|
+
node = engine.find(tag)
|
|
556
|
+
if node is None:
|
|
557
|
+
sample_root = load_sample_root(resolve_sample_path(SERVER_SAMPLE))
|
|
558
|
+
prototype = sample_root.find(f".//DEVICES/DEVICE[ENGINE/TYPE='Server']/ENGINE/{tag}")
|
|
559
|
+
node = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, tag)
|
|
560
|
+
if node not in list(engine):
|
|
561
|
+
engine.append(node)
|
|
562
|
+
_ensure_text(node, enabled_tag, "1")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _apply_server_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
566
|
+
engine = device.find("./ENGINE")
|
|
567
|
+
if engine is None:
|
|
568
|
+
return
|
|
569
|
+
if operation["op"] == "set_server_dns_record":
|
|
570
|
+
dns_server = engine.find("DNS_SERVER")
|
|
571
|
+
if dns_server is None:
|
|
572
|
+
sample_root = load_sample_root(resolve_sample_path(SERVER_SAMPLE))
|
|
573
|
+
prototype = sample_root.find(".//DEVICES/DEVICE[ENGINE/TYPE='Server']/ENGINE/DNS_SERVER")
|
|
574
|
+
dns_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "DNS_SERVER")
|
|
575
|
+
engine.append(dns_server)
|
|
576
|
+
_ensure_text(dns_server, "ENABLED", "1")
|
|
577
|
+
database = dns_server.find("NAMESERVER-DATABASE")
|
|
578
|
+
if database is None:
|
|
579
|
+
database = ET.SubElement(dns_server, "NAMESERVER-DATABASE")
|
|
580
|
+
record = ET.SubElement(database, "RESOURCE-RECORD")
|
|
581
|
+
if operation["record_type"] == "A":
|
|
582
|
+
_ensure_text(record, "TYPE", "A-REC")
|
|
583
|
+
_ensure_text(record, "NAME", str(operation["name"]))
|
|
584
|
+
_ensure_text(record, "TTL", "86400")
|
|
585
|
+
_ensure_text(record, "IPADDRESS", str(operation["value"]))
|
|
586
|
+
else:
|
|
587
|
+
_ensure_text(record, "TYPE", "CNAME")
|
|
588
|
+
_ensure_text(record, "NAME", str(operation["name"]))
|
|
589
|
+
_ensure_text(record, "TTL", "86400")
|
|
590
|
+
_ensure_text(record, "SERVER-NAME", str(operation["value"]))
|
|
591
|
+
elif operation["op"] == "set_server_dhcp_pool":
|
|
592
|
+
dhcp_server = engine.find("DHCP_SERVER")
|
|
593
|
+
if dhcp_server is None:
|
|
594
|
+
sample_root = load_sample_root(resolve_sample_path(SERVER_SAMPLE))
|
|
595
|
+
prototype = sample_root.find(".//DEVICES/DEVICE[ENGINE/TYPE='Server']/ENGINE/DHCP_SERVER")
|
|
596
|
+
dhcp_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "DHCP_SERVER")
|
|
597
|
+
engine.append(dhcp_server)
|
|
598
|
+
_ensure_text(dhcp_server, "ENABLED", "1")
|
|
599
|
+
pools = dhcp_server.find("POOLS")
|
|
600
|
+
if pools is None:
|
|
601
|
+
pools = ET.SubElement(dhcp_server, "POOLS")
|
|
602
|
+
pool = ET.SubElement(pools, "POOL")
|
|
603
|
+
_ensure_text(pool, "NAME", str(operation["name"]))
|
|
604
|
+
_ensure_text(pool, "NETWORK", str(operation["network"]))
|
|
605
|
+
_ensure_text(pool, "MASK", _prefix_to_mask(int(operation["prefix"])))
|
|
606
|
+
_ensure_text(pool, "DEFAULT_ROUTER", str(operation["gateway"]))
|
|
607
|
+
_ensure_text(pool, "START_IP", str(operation.get("start") or operation["network"]))
|
|
608
|
+
_ensure_text(pool, "END_IP", str(operation.get("start") or operation["network"]))
|
|
609
|
+
_ensure_text(pool, "DNS_SERVER", str(operation.get("dns") or "0.0.0.0"))
|
|
610
|
+
_ensure_text(pool, "MAX_USERS", str(operation.get("max_users") or 0))
|
|
611
|
+
elif operation["op"] == "enable_server_service":
|
|
612
|
+
_set_enabled_service(engine, str(operation["service"]))
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _wireless_common_nodes(engine: ET.Element) -> list[ET.Element]:
|
|
616
|
+
nodes: list[ET.Element] = []
|
|
617
|
+
for path in [
|
|
618
|
+
"./WIRELESS_SERVER/WIRELESS_COMMON",
|
|
619
|
+
"./WIRELESS_CLIENT/WIRELESS_COMMON",
|
|
620
|
+
"./WLC/WLANS/WLAN_CONFIG",
|
|
621
|
+
]:
|
|
622
|
+
node = engine.find(path)
|
|
623
|
+
if node is not None:
|
|
624
|
+
nodes.append(node)
|
|
625
|
+
return nodes
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _profile_nodes(engine: ET.Element) -> list[ET.Element]:
|
|
629
|
+
return engine.findall("./WIRELESS_CLIENT/PROFILES/WIRELESS_PROFILE") + engine.findall("./WIRELESS_CLIENT/CURRENT_PROFILE/WIRELESS_PROFILE")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _apply_wireless_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
633
|
+
engine = device.find("./ENGINE")
|
|
634
|
+
if engine is None:
|
|
635
|
+
return
|
|
636
|
+
if operation["op"] == "set_wireless_ssid":
|
|
637
|
+
for node in _wireless_common_nodes(engine):
|
|
638
|
+
_ensure_text(node, "SSID", str(operation["ssid"]))
|
|
639
|
+
_ensure_text(node, "AUTHEN_TYPE", str(operation["auth_type"]))
|
|
640
|
+
_ensure_text(node, "ENCRYPT_TYPE", str(operation["encrypt_type"]))
|
|
641
|
+
if node.find("STANDARD_CHANNEL") is not None:
|
|
642
|
+
_ensure_text(node, "STANDARD_CHANNEL", str(operation["channel"]))
|
|
643
|
+
if node.find("CHANNEL") is not None:
|
|
644
|
+
_ensure_text(node, "CHANNEL", str(operation["channel"]))
|
|
645
|
+
if operation.get("passphrase"):
|
|
646
|
+
if node.find("WEP_KEY") is not None:
|
|
647
|
+
_ensure_text(node, "WEP_KEY", str(operation["passphrase"]))
|
|
648
|
+
if node.find("WPA_PASSPHRASE") is not None:
|
|
649
|
+
_ensure_text(node, "WPA_PASSPHRASE", str(operation["passphrase"]))
|
|
650
|
+
for profile in _profile_nodes(engine):
|
|
651
|
+
_ensure_text(profile, "NAME", str(operation["ssid"]))
|
|
652
|
+
_ensure_text(profile, "SSID", str(operation["ssid"]))
|
|
653
|
+
_ensure_text(profile, "AUTHEN_TYPE", str(operation["auth_type"]))
|
|
654
|
+
_ensure_text(profile, "ENCRYPT_TYPE", str(operation["encrypt_type"]))
|
|
655
|
+
_ensure_text(profile, "CHANNEL", str(operation["channel"]))
|
|
656
|
+
if profile.find("WEP_KEY") is not None:
|
|
657
|
+
_ensure_text(profile, "WEP_KEY", str(operation.get("passphrase") or ""))
|
|
658
|
+
elif operation["op"] == "associate_wireless_client":
|
|
659
|
+
for node in _wireless_common_nodes(engine):
|
|
660
|
+
_ensure_text(node, "SSID", str(operation["ssid"]))
|
|
661
|
+
for profile in _profile_nodes(engine):
|
|
662
|
+
_ensure_text(profile, "NAME", str(operation["ssid"]))
|
|
663
|
+
_ensure_text(profile, "SSID", str(operation["ssid"]))
|
|
664
|
+
_ensure_text(profile, "DHCP_ENABLED", "1" if operation.get("ip_mode", "dhcp") == "dhcp" else "0")
|
|
665
|
+
for port in device.findall(".//PORT"):
|
|
666
|
+
if port.findtext("TYPE", default="").startswith("eHostWireless"):
|
|
667
|
+
_ensure_text(port, "PORT_DHCP_ENABLE", "true" if operation.get("ip_mode", "dhcp") == "dhcp" else "false")
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _apply_end_device_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
671
|
+
if operation["op"] == "set_host_ip":
|
|
672
|
+
apply_host_ip(device, operation)
|
|
673
|
+
elif operation["op"] == "set_host_dhcp":
|
|
674
|
+
for port in device.findall(".//PORT"):
|
|
675
|
+
if port.find("PORT_DHCP_ENABLE") is not None:
|
|
676
|
+
_ensure_text(port, "PORT_DHCP_ENABLE", "true")
|
|
677
|
+
for profile in _profile_nodes(device.find("./ENGINE") or ET.Element("ENGINE")):
|
|
678
|
+
_ensure_text(profile, "DHCP_ENABLED", "1")
|
|
679
|
+
elif operation["op"] == "set_host_dns":
|
|
680
|
+
apply_host_ip(device, operation)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def apply_plan_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
|
|
684
|
+
updated = copy.deepcopy(root)
|
|
685
|
+
acl_kind_map: dict[str, str] = {}
|
|
686
|
+
acl_device_map: dict[str, str] = {}
|
|
687
|
+
for operation in plan.router_ops:
|
|
688
|
+
if operation["op"] == "set_acl":
|
|
689
|
+
acl_kind_map[str(operation["acl_name"])] = str(operation["acl_kind"])
|
|
690
|
+
acl_device_map[str(operation["acl_name"])] = str(operation["device"])
|
|
691
|
+
for operation in plan.router_ops:
|
|
692
|
+
if operation["op"] == "add_acl_rule" and operation.get("acl_name") in acl_kind_map:
|
|
693
|
+
operation["acl_kind"] = acl_kind_map[str(operation["acl_name"])]
|
|
694
|
+
if operation["op"] == "add_acl_rule" and operation.get("acl_name") in acl_device_map:
|
|
695
|
+
operation["device"] = acl_device_map[str(operation["acl_name"])]
|
|
696
|
+
for operation in plan.edit_operations:
|
|
697
|
+
if operation["op"] == "prune_device":
|
|
698
|
+
_prune_device(updated, str(operation["device"]))
|
|
699
|
+
continue
|
|
700
|
+
if operation["op"] == "remove_link":
|
|
701
|
+
_remove_link(updated, str(operation["a"]["dev"]), str(operation["b"]["dev"]))
|
|
702
|
+
continue
|
|
703
|
+
if operation["op"] == "set_link":
|
|
704
|
+
_ensure_link(
|
|
705
|
+
updated,
|
|
706
|
+
str(operation["a"]["dev"]),
|
|
707
|
+
str(operation["a"]["port"]),
|
|
708
|
+
str(operation["b"]["dev"]),
|
|
709
|
+
str(operation["b"]["port"]),
|
|
710
|
+
str(operation.get("media", "copper")),
|
|
711
|
+
)
|
|
712
|
+
continue
|
|
713
|
+
device = _find_device(updated, str(operation["device"]))
|
|
714
|
+
if device is None:
|
|
715
|
+
continue
|
|
716
|
+
if operation["op"] == "rename_device":
|
|
717
|
+
_set_device_name(updated, device, str(operation["new_name"]))
|
|
718
|
+
elif operation["op"] == "reflow_layout":
|
|
719
|
+
_set_device_position(device, int(operation["x"]), int(operation["y"]))
|
|
720
|
+
|
|
721
|
+
for bucket, handler in [
|
|
722
|
+
(plan.switch_ops, _apply_switch_op),
|
|
723
|
+
(plan.router_ops, _apply_router_op),
|
|
724
|
+
(plan.server_ops, _apply_server_op),
|
|
725
|
+
(plan.wireless_ops, _apply_wireless_op),
|
|
726
|
+
(plan.end_device_ops, _apply_end_device_op),
|
|
727
|
+
(plan.management_ops, _apply_management_op),
|
|
728
|
+
]:
|
|
729
|
+
for operation in bucket:
|
|
730
|
+
device = _find_device(updated, str(operation["device"]))
|
|
731
|
+
if device is None:
|
|
732
|
+
continue
|
|
733
|
+
handler(device, operation)
|
|
734
|
+
return updated
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def apply_edit_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
|
|
738
|
+
return apply_plan_operations(root, plan)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def edit_pkt_file(pkt_path: str | Path, plan: IntentPlan, output_path: str | Path, xml_out_path: str | Path | None = None) -> Path:
|
|
742
|
+
root = decode_pkt_to_root(pkt_path)
|
|
743
|
+
updated = apply_plan_operations(root, plan)
|
|
744
|
+
xml_bytes = ET.tostring(updated, encoding="utf-8", xml_declaration=False)
|
|
745
|
+
output_path = Path(output_path)
|
|
746
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
747
|
+
output_path.write_bytes(encode_pkt_modern(xml_bytes))
|
|
748
|
+
if xml_out_path is not None:
|
|
749
|
+
xml_path = Path(xml_out_path)
|
|
750
|
+
xml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
751
|
+
xml_path.write_bytes(xml_bytes)
|
|
752
|
+
return output_path
|