packet-tracer-skill 0.3.0 → 0.3.2
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 +116 -0
- package/README.md +55 -9
- package/SKILL.md +214 -2
- package/package.json +2 -1
- package/scripts/build_sample_catalog.py +42 -0
- package/scripts/generate_pkt.py +4408 -187
- package/scripts/intent_parser.py +31 -4
- package/scripts/lab_coherence.py +455 -0
- package/scripts/pkt_editor.py +117 -35
- package/scripts/pkt_transformer.py +99 -1
- package/scripts/sample_catalog.py +65 -12
- package/scripts/session_log.py +325 -0
- package/scripts/usage_ledger.py +230 -218
package/scripts/pkt_editor.py
CHANGED
|
@@ -5,6 +5,7 @@ import hashlib
|
|
|
5
5
|
import json
|
|
6
6
|
import re
|
|
7
7
|
from collections import Counter
|
|
8
|
+
from collections.abc import Callable
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
import xml.etree.ElementTree as ET
|
|
10
11
|
from functools import lru_cache
|
|
@@ -706,6 +707,10 @@ def _append_unique_config_lines(parent: ET.Element | None, lines: list[str]) ->
|
|
|
706
707
|
_WORD_VALUED_SETTINGS = (
|
|
707
708
|
"switchport port-security violation",
|
|
708
709
|
"switchport mode",
|
|
710
|
+
# An interface has one description. Without this, `description Satis` and
|
|
711
|
+
# `description VLAN40 standby` read as different settings and both survive
|
|
712
|
+
# a merge, where the device keeps only the last.
|
|
713
|
+
"description",
|
|
709
714
|
"duplex",
|
|
710
715
|
"speed",
|
|
711
716
|
)
|
|
@@ -880,6 +885,49 @@ def _find_link_by_devices(root: ET.Element, left_name: str, right_name: str) ->
|
|
|
880
885
|
return None
|
|
881
886
|
|
|
882
887
|
|
|
888
|
+
@lru_cache(maxsize=1)
|
|
889
|
+
def _modern_link_prototype_xml() -> str | None:
|
|
890
|
+
"""A cable copied from a lab the installed Packet Tracer wrote itself.
|
|
891
|
+
|
|
892
|
+
A new link is cloned from one the file already has. When the file has none,
|
|
893
|
+
the fallback was a cable from the bundled `FTP.pkt`, and that sample is old
|
|
894
|
+
enough to predate the fields a 9.x cable carries: it refers to its devices
|
|
895
|
+
by index rather than by `save-ref-id`, and has no `FUNCTIONAL`,
|
|
896
|
+
`GEO_VIEW_COLOR` or `IS_MANAGED_IN_RACK_VIEW` at all.
|
|
897
|
+
|
|
898
|
+
Measured: adding one link to `minimal`, which has cables to copy, takes it
|
|
899
|
+
from four links to five and it opens. Adding one link to `wireless_home`,
|
|
900
|
+
which has none, produces a file Packet Tracer refuses -- and that is why
|
|
901
|
+
both wireless labs ship uncabled. Same writer, same ports, same devices;
|
|
902
|
+
the only difference is which cable was cloned.
|
|
903
|
+
|
|
904
|
+
The compatibility donor is the right source because it is already the file
|
|
905
|
+
this skill trusts for the installed version, so its cables are the shape
|
|
906
|
+
this Packet Tracer writes.
|
|
907
|
+
"""
|
|
908
|
+
from packet_tracer_env import get_packet_tracer_compatibility_donor
|
|
909
|
+
|
|
910
|
+
donor = get_packet_tracer_compatibility_donor()
|
|
911
|
+
if donor is None:
|
|
912
|
+
return None
|
|
913
|
+
try:
|
|
914
|
+
root = decode_pkt_to_root(donor)
|
|
915
|
+
except Exception: # pragma: no cover - a donor that no longer decodes
|
|
916
|
+
return None
|
|
917
|
+
prototype = _first_link_prototype(root)
|
|
918
|
+
if prototype is None:
|
|
919
|
+
return None
|
|
920
|
+
return ET.tostring(prototype, encoding="unicode")
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _fallback_link_prototype() -> ET.Element | None:
|
|
924
|
+
xml = _modern_link_prototype_xml()
|
|
925
|
+
if xml is not None:
|
|
926
|
+
return ET.fromstring(xml)
|
|
927
|
+
prototype_root = load_sample_root(resolve_sample_path(FTP_SAMPLE))
|
|
928
|
+
return prototype_root.find(".//LINKS/LINK")
|
|
929
|
+
|
|
930
|
+
|
|
883
931
|
def _first_link_prototype(root: ET.Element) -> ET.Element | None:
|
|
884
932
|
for link in root.findall(".//LINKS/LINK"):
|
|
885
933
|
cable = link.find("./CABLE")
|
|
@@ -1356,8 +1404,16 @@ def _ensure_link(
|
|
|
1356
1404
|
right_port: str,
|
|
1357
1405
|
media: str,
|
|
1358
1406
|
port_mem_map: dict[tuple[str, str], str] | None = None,
|
|
1407
|
+
allow_parallel: bool = False,
|
|
1359
1408
|
) -> None:
|
|
1360
|
-
|
|
1409
|
+
# Every caller but one wants at most one cable between a pair of devices, so
|
|
1410
|
+
# a second call re-points the first cable instead of adding one. An
|
|
1411
|
+
# EtherChannel is the exception: it is only a bundle if two cables run
|
|
1412
|
+
# between the same two switches, and asking for the second one used to move
|
|
1413
|
+
# the first. The new-link branch below is what a parallel cable needs -- it
|
|
1414
|
+
# is the branch that drops the saving session's memory pointers, which is
|
|
1415
|
+
# what makes Packet Tracer accept a link it did not write itself.
|
|
1416
|
+
existing = None if allow_parallel else _find_link_by_devices(root, left_name, right_name)
|
|
1361
1417
|
devices = {device.findtext("./ENGINE/NAME", default=""): device for device in root.findall(".//DEVICES/DEVICE")}
|
|
1362
1418
|
index_refs, save_refs = _device_refs(root)
|
|
1363
1419
|
left_device = devices.get(left_name)
|
|
@@ -1387,8 +1443,7 @@ def _ensure_link(
|
|
|
1387
1443
|
if prototype is None:
|
|
1388
1444
|
prototype = _first_link_prototype(root)
|
|
1389
1445
|
if prototype is None:
|
|
1390
|
-
|
|
1391
|
-
prototype = prototype_root.find(".//LINKS/LINK")
|
|
1446
|
+
prototype = _fallback_link_prototype()
|
|
1392
1447
|
link = copy.deepcopy(prototype)
|
|
1393
1448
|
if link is None:
|
|
1394
1449
|
return
|
|
@@ -1550,13 +1605,13 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
|
1550
1605
|
for target in _config_targets(device):
|
|
1551
1606
|
_append_unique_config_lines(target, ["ip dhcp snooping", f"ip dhcp snooping vlan {operation['vlan']}"])
|
|
1552
1607
|
if operation.get("trust_port"):
|
|
1553
|
-
|
|
1608
|
+
_set_config_block(target, f"interface {operation['trust_port']}", [" ip dhcp snooping trust"])
|
|
1554
1609
|
return
|
|
1555
1610
|
elif operation["op"] == "set_dai":
|
|
1556
1611
|
for target in _config_targets(device):
|
|
1557
1612
|
_append_unique_config_lines(target, ["ip dhcp snooping", f"ip dhcp snooping vlan {operation['vlan']}", f"ip arp inspection vlan {operation['vlan']}"])
|
|
1558
1613
|
if operation.get("trust_port"):
|
|
1559
|
-
|
|
1614
|
+
_set_config_block(target, f"interface {operation['trust_port']}", [" ip arp inspection trust", " ip dhcp snooping trust"])
|
|
1560
1615
|
return
|
|
1561
1616
|
elif operation["op"] == "set_port_security":
|
|
1562
1617
|
body = [" switchport mode access", " switchport port-security"]
|
|
@@ -1565,7 +1620,7 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
|
1565
1620
|
if operation.get("violation"):
|
|
1566
1621
|
body.append(f" switchport port-security violation {operation['violation']}")
|
|
1567
1622
|
for target in _config_targets(device):
|
|
1568
|
-
|
|
1623
|
+
_set_config_block(target, f"interface {operation['port']}", body)
|
|
1569
1624
|
return
|
|
1570
1625
|
elif operation["op"] == "set_lldp":
|
|
1571
1626
|
for target in _config_targets(device):
|
|
@@ -1573,7 +1628,7 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
|
1573
1628
|
return
|
|
1574
1629
|
elif operation["op"] == "set_rep":
|
|
1575
1630
|
for target in _config_targets(device):
|
|
1576
|
-
|
|
1631
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" rep segment {operation['segment']}"])
|
|
1577
1632
|
return
|
|
1578
1633
|
elif operation["op"] == "set_span":
|
|
1579
1634
|
for target in _config_targets(device):
|
|
@@ -1592,7 +1647,7 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
|
1592
1647
|
mode = str(operation.get("mode") or "auto")
|
|
1593
1648
|
for target in _config_targets(device):
|
|
1594
1649
|
_append_unique_config_lines(target, global_lines)
|
|
1595
|
-
|
|
1650
|
+
_set_config_block(
|
|
1596
1651
|
target,
|
|
1597
1652
|
f"interface {operation['interface']}",
|
|
1598
1653
|
[f" authentication port-control {mode}", " dot1x pae authenticator"],
|
|
@@ -1612,7 +1667,7 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
|
1612
1667
|
f"policy-map {operation['policy_map']}",
|
|
1613
1668
|
[f" class {operation['class_map']}", f" {action}"],
|
|
1614
1669
|
)
|
|
1615
|
-
|
|
1670
|
+
_set_config_block(
|
|
1616
1671
|
target,
|
|
1617
1672
|
f"interface {operation['interface']}",
|
|
1618
1673
|
[f" service-policy {operation['direction']} {operation['policy_map']}"],
|
|
@@ -1631,8 +1686,8 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
|
1631
1686
|
interfaces = [str(interface) for interface in operation.get("interfaces", []) if str(interface).strip()]
|
|
1632
1687
|
for target in _config_targets(device):
|
|
1633
1688
|
for interface_name in interfaces:
|
|
1634
|
-
|
|
1635
|
-
|
|
1689
|
+
_set_config_block(target, f"interface {interface_name}", [f" channel-group {channel} mode {mode}"])
|
|
1690
|
+
_set_config_block(target, f"interface Port-channel{channel}", [" no shutdown"])
|
|
1636
1691
|
return
|
|
1637
1692
|
elif operation["op"] == "set_vtp":
|
|
1638
1693
|
lines = [f"vtp domain {operation['domain']}", f"vtp mode {operation['mode']}"]
|
|
@@ -1643,7 +1698,7 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
|
1643
1698
|
return
|
|
1644
1699
|
elif operation["op"] == "set_dtp":
|
|
1645
1700
|
for target in _config_targets(device):
|
|
1646
|
-
|
|
1701
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" switchport mode {operation['mode']}"])
|
|
1647
1702
|
return
|
|
1648
1703
|
else:
|
|
1649
1704
|
return
|
|
@@ -1668,7 +1723,7 @@ def _apply_router_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
|
1668
1723
|
def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) -> None:
|
|
1669
1724
|
if operation["op"] == "set_subinterface":
|
|
1670
1725
|
for target in _config_targets(device):
|
|
1671
|
-
|
|
1726
|
+
_set_config_block(
|
|
1672
1727
|
target,
|
|
1673
1728
|
f"interface {operation['subinterface']}",
|
|
1674
1729
|
[
|
|
@@ -1699,7 +1754,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1699
1754
|
return
|
|
1700
1755
|
elif operation["op"] == "apply_acl":
|
|
1701
1756
|
for target in _config_targets(device):
|
|
1702
|
-
|
|
1757
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" ip access-group {operation['acl_name']} {operation['direction']}"])
|
|
1703
1758
|
return
|
|
1704
1759
|
elif operation["op"] == "enable_ipv6_unicast_routing":
|
|
1705
1760
|
for target in _config_targets(device):
|
|
@@ -1708,7 +1763,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1708
1763
|
elif operation["op"] == "set_ipv6_address":
|
|
1709
1764
|
for target in _config_targets(device):
|
|
1710
1765
|
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1711
|
-
|
|
1766
|
+
_set_config_block(
|
|
1712
1767
|
target,
|
|
1713
1768
|
f"interface {operation['interface']}",
|
|
1714
1769
|
[f" ipv6 address {operation['address']}/{operation['prefix']}", " no shutdown"],
|
|
@@ -1720,7 +1775,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1720
1775
|
body.insert(1, f" ipv6 nd prefix {operation['prefix']}/{operation['prefix_len']}")
|
|
1721
1776
|
for target in _config_targets(device):
|
|
1722
1777
|
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1723
|
-
|
|
1778
|
+
_set_config_block(target, f"interface {operation['interface']}", body)
|
|
1724
1779
|
return
|
|
1725
1780
|
elif operation["op"] == "set_dhcpv6_pool":
|
|
1726
1781
|
pool_body = [f" address prefix {operation['prefix']}/{operation['prefix_len']}"]
|
|
@@ -1732,26 +1787,26 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1732
1787
|
for target in _config_targets(device):
|
|
1733
1788
|
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1734
1789
|
_append_config_block(target, f"ipv6 dhcp pool {operation['name']}", pool_body)
|
|
1735
|
-
|
|
1790
|
+
_set_config_block(target, f"interface {operation['interface']}", interface_body)
|
|
1736
1791
|
return
|
|
1737
1792
|
elif operation["op"] == "set_ospfv3_interface":
|
|
1738
1793
|
process_id = operation["process_id"]
|
|
1739
1794
|
for target in _config_targets(device):
|
|
1740
1795
|
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1741
|
-
|
|
1796
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" ipv6 ospf {process_id} area {operation['area']}", " no shutdown"])
|
|
1742
1797
|
_append_config_block(target, f"ipv6 router ospf {process_id}", [])
|
|
1743
1798
|
return
|
|
1744
1799
|
elif operation["op"] == "set_eigrp_ipv6_interface":
|
|
1745
1800
|
asn = operation["asn"]
|
|
1746
1801
|
for target in _config_targets(device):
|
|
1747
1802
|
_append_unique_config_lines(target, ["ipv6 unicast-routing", "no ipv6 cef"])
|
|
1748
|
-
|
|
1803
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" ipv6 eigrp {asn}", " no shutdown"])
|
|
1749
1804
|
_append_config_block(target, f"ipv6 router eigrp {asn}", [" no shutdown"])
|
|
1750
1805
|
return
|
|
1751
1806
|
elif operation["op"] == "set_ripng_interface":
|
|
1752
1807
|
for target in _config_targets(device):
|
|
1753
1808
|
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1754
|
-
|
|
1809
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" ipv6 rip {operation['process_name']} enable", " no shutdown"])
|
|
1755
1810
|
return
|
|
1756
1811
|
elif operation["op"] == "set_hsrp_ipv6":
|
|
1757
1812
|
# A standby group with no virtual address configures nothing, and the
|
|
@@ -1767,7 +1822,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1767
1822
|
body.append(f" standby {operation['group']} preempt")
|
|
1768
1823
|
for target in _config_targets(device):
|
|
1769
1824
|
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1770
|
-
|
|
1825
|
+
_set_config_block(target, f"interface {operation['interface']}", body)
|
|
1771
1826
|
return
|
|
1772
1827
|
elif operation["op"] == "set_ospfv2_network":
|
|
1773
1828
|
for target in _config_targets(device):
|
|
@@ -1797,11 +1852,11 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1797
1852
|
return
|
|
1798
1853
|
elif operation["op"] == "set_dhcp_relay":
|
|
1799
1854
|
for target in _config_targets(device):
|
|
1800
|
-
|
|
1855
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" ip helper-address {operation['helper']}"])
|
|
1801
1856
|
return
|
|
1802
1857
|
elif operation["op"] == "set_nat_interface":
|
|
1803
1858
|
for target in _config_targets(device):
|
|
1804
|
-
|
|
1859
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" ip nat {operation['role']}"])
|
|
1805
1860
|
return
|
|
1806
1861
|
elif operation["op"] == "set_nat_static":
|
|
1807
1862
|
for target in _config_targets(device):
|
|
@@ -1849,7 +1904,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1849
1904
|
for target in _config_targets(device):
|
|
1850
1905
|
_append_unique_config_lines(target, global_lines)
|
|
1851
1906
|
if operation.get("interface") and operation.get("direction"):
|
|
1852
|
-
|
|
1907
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" ip flow {operation['direction']}"])
|
|
1853
1908
|
return
|
|
1854
1909
|
elif operation["op"] == "set_gre_tunnel":
|
|
1855
1910
|
body = []
|
|
@@ -1864,7 +1919,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1864
1919
|
]
|
|
1865
1920
|
)
|
|
1866
1921
|
for target in _config_targets(device):
|
|
1867
|
-
|
|
1922
|
+
_set_config_block(target, f"interface {operation['interface']}", body)
|
|
1868
1923
|
return
|
|
1869
1924
|
elif operation["op"] == "set_ppp_interface":
|
|
1870
1925
|
body = [" encapsulation ppp"]
|
|
@@ -1872,7 +1927,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1872
1927
|
body.append(f" ppp authentication {operation['authentication']}")
|
|
1873
1928
|
body.append(" no shutdown")
|
|
1874
1929
|
for target in _config_targets(device):
|
|
1875
|
-
|
|
1930
|
+
_set_config_block(target, f"interface {operation['interface']}", body)
|
|
1876
1931
|
return
|
|
1877
1932
|
elif operation["op"] == "set_ipsec_transform_set":
|
|
1878
1933
|
for target in _config_targets(device):
|
|
@@ -1893,17 +1948,17 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1893
1948
|
],
|
|
1894
1949
|
)
|
|
1895
1950
|
if operation.get("interface"):
|
|
1896
|
-
|
|
1951
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" crypto map {operation['map_name']}"])
|
|
1897
1952
|
return
|
|
1898
1953
|
elif operation["op"] == "set_cbac_inspect":
|
|
1899
1954
|
for target in _config_targets(device):
|
|
1900
1955
|
_append_unique_config_lines(target, [f"ip inspect name {operation['name']} {operation['protocol']}"])
|
|
1901
|
-
|
|
1956
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" ip inspect {operation['name']} {operation['direction']}"])
|
|
1902
1957
|
return
|
|
1903
1958
|
elif operation["op"] == "set_zfw_zone_interface":
|
|
1904
1959
|
for target in _config_targets(device):
|
|
1905
1960
|
_append_config_block(target, f"zone security {operation['zone']}", [])
|
|
1906
|
-
|
|
1961
|
+
_set_config_block(target, f"interface {operation['interface']}", [f" zone-member security {operation['zone']}"])
|
|
1907
1962
|
return
|
|
1908
1963
|
elif operation["op"] == "set_zfw_zone_pair":
|
|
1909
1964
|
for target in _config_targets(device):
|
|
@@ -1968,7 +2023,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
|
|
|
1968
2023
|
def _apply_management_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
1969
2024
|
if operation["op"] == "set_management_vlan":
|
|
1970
2025
|
for target in _config_targets(device):
|
|
1971
|
-
|
|
2026
|
+
_set_config_block(
|
|
1972
2027
|
target,
|
|
1973
2028
|
f"interface Vlan{operation['vlan']}",
|
|
1974
2029
|
[f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}", " no shutdown"],
|
|
@@ -2135,10 +2190,23 @@ def _apply_wireless_op(device: ET.Element, operation: dict[str, object]) -> None
|
|
|
2135
2190
|
if node.find("CHANNEL") is not None:
|
|
2136
2191
|
_ensure_text(node, "CHANNEL", str(operation["channel"]))
|
|
2137
2192
|
if operation.get("passphrase"):
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2193
|
+
# The key goes in `WEP_PROCESS`, whatever the security is.
|
|
2194
|
+
#
|
|
2195
|
+
# Those field names are legacy: a working WPA2 home router
|
|
2196
|
+
# keeps `WEP_PROCESS/KEY` with `WEP_PROCESS/ENCRYPTION` set to
|
|
2197
|
+
# the encryption type, and carries no `WPA_PASSPHRASE` at all.
|
|
2198
|
+
# Measured on `hr-guest`, which associates and pings; the same
|
|
2199
|
+
# shape the client side already used. Choosing the field by
|
|
2200
|
+
# authentication type -- `WEP_KEY` for WEP, `WPA_PASSPHRASE`
|
|
2201
|
+
# otherwise -- put the passphrase somewhere Packet Tracer does
|
|
2202
|
+
# not read, so the access point ran WPA2 with no key while its
|
|
2203
|
+
# clients had one, and nothing associated. The lab opened and
|
|
2204
|
+
# every field looked right on both sides.
|
|
2205
|
+
process = node.find("WEP_PROCESS")
|
|
2206
|
+
if process is None:
|
|
2207
|
+
process = ET.SubElement(node, "WEP_PROCESS")
|
|
2208
|
+
_ensure_text(process, "KEY", str(operation["passphrase"]))
|
|
2209
|
+
_ensure_text(process, "ENCRYPTION", str(operation["encrypt_type"]))
|
|
2142
2210
|
for profile in _profile_nodes(engine):
|
|
2143
2211
|
_ensure_text(profile, "NAME", str(operation["ssid"]))
|
|
2144
2212
|
_ensure_text(profile, "SSID", str(operation["ssid"]))
|
|
@@ -2391,9 +2459,23 @@ def apply_edit_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
|
|
|
2391
2459
|
return apply_plan_operations(root, plan)
|
|
2392
2460
|
|
|
2393
2461
|
|
|
2394
|
-
def edit_pkt_file(
|
|
2462
|
+
def edit_pkt_file(
|
|
2463
|
+
pkt_path: str | Path,
|
|
2464
|
+
plan: IntentPlan,
|
|
2465
|
+
output_path: str | Path,
|
|
2466
|
+
xml_out_path: str | Path | None = None,
|
|
2467
|
+
repair: Callable[[ET.Element], object] | None = None,
|
|
2468
|
+
) -> Path:
|
|
2395
2469
|
root = decode_pkt_to_root(pkt_path)
|
|
2396
2470
|
updated = apply_plan_operations(root, plan)
|
|
2471
|
+
# An edit names ports the way a plan does -- from the request, not from the
|
|
2472
|
+
# file -- so it can write the same configuration that took SW2 off the
|
|
2473
|
+
# network: a channel-group on a port whose peer does not bundle. The
|
|
2474
|
+
# generation pipelines repair that after the fact and the edit path had no
|
|
2475
|
+
# such step. The caller passes the repair in; importing it here would close
|
|
2476
|
+
# a loop, since the module that owns it already imports this one.
|
|
2477
|
+
if repair is not None:
|
|
2478
|
+
repair(updated)
|
|
2397
2479
|
xml_bytes = serialize_pkt_xml(updated)
|
|
2398
2480
|
output_path = Path(output_path)
|
|
2399
2481
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -718,6 +718,82 @@ UNSLOTTED_MULTIPORT_TYPES = {
|
|
|
718
718
|
"MerakiServer",
|
|
719
719
|
}
|
|
720
720
|
|
|
721
|
+
# A home router numbers its LAN ports from one and gives the WAN socket a name
|
|
722
|
+
# of its own. Counting the sockets and numbering them all is how a cable landed
|
|
723
|
+
# on `GigabitEthernet 5` of a router with four LAN ports: five copper gigabit
|
|
724
|
+
# PORT nodes, and the fifth is `Internet`. Packet Tracer refused the file, and
|
|
725
|
+
# every check before it passed -- the device lists no interfaces in its
|
|
726
|
+
# configuration, so nothing else could contradict the count.
|
|
727
|
+
WIRELESS_ROUTER_TYPES = {"WirelessRouter", "WirelessRouterNewGeneration"}
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def wireless_router_port_names(device: ET.Element) -> list[str]:
|
|
731
|
+
"""The interface names a home router really answers to, LAN ports first.
|
|
732
|
+
|
|
733
|
+
A home router writes no interfaces into its configuration, so
|
|
734
|
+
`donor_interface_names` comes back empty and every shape check abstains --
|
|
735
|
+
which left the port index as the only thing anyone checked. That accepted
|
|
736
|
+
`FastEthernet0/1` on a device whose sockets are `Ethernet 1` .. `4`: right
|
|
737
|
+
index, wrong name, and Packet Tracer refuses a lab that names an interface
|
|
738
|
+
a device does not have.
|
|
739
|
+
|
|
740
|
+
The names are positional and set by the model. Measured over every home
|
|
741
|
+
router in the labs on this machine -- sixteen cables in all, and not one on
|
|
742
|
+
any other name:
|
|
743
|
+
|
|
744
|
+
WirelessRouter `Ethernet 1` .. `4`, `Internet`
|
|
745
|
+
WirelessRouterNewGeneration `GigabitEthernet 1` .. `4`, `Internet`
|
|
746
|
+
|
|
747
|
+
Both carry five copper ports and the fifth is the uplink, which is why
|
|
748
|
+
counting sockets and numbering them all put a cable on a port that is not
|
|
749
|
+
there. Note the older model's ports report `eCopperFastEthernet` and are
|
|
750
|
+
still named plain `Ethernet`, so the family cannot be read off the socket:
|
|
751
|
+
the device type is the discriminator.
|
|
752
|
+
|
|
753
|
+
Confirmed against the live devices rather than the files alone. Dropped into
|
|
754
|
+
an empty Packet Tracer and read back:
|
|
755
|
+
|
|
756
|
+
Linksys-WRT300N Vlan1, Internet, Ethernet 1 .. 4, Wireless
|
|
757
|
+
HomeRouter-PT-AC Vlan1, Internet, GigabitEthernet 1 .. 4,
|
|
758
|
+
Wireless 1 .. 6, Wireless0/0
|
|
759
|
+
|
|
760
|
+
Worth doing: the builder's own device table gives `Ethernet 1` .. `4` for
|
|
761
|
+
the AC model, which the device itself contradicts. `pt_inspect_ports` is no
|
|
762
|
+
help either -- it answers at the IOS layer, where the LAN sockets are
|
|
763
|
+
bridged into `Vlan1` and do not appear at all.
|
|
764
|
+
|
|
765
|
+
The wireless sockets follow from the same PORT list: the access-point
|
|
766
|
+
radios are `Wireless 1` .. `N`, or bare `Wireless` when there is only one,
|
|
767
|
+
and a host radio is `Wireless0/0`. They are listed so a wireless link is
|
|
768
|
+
never mistaken for a cable on a port that is not there.
|
|
769
|
+
|
|
770
|
+
`Internet` comes after the LAN ports so a repair prefers a LAN port and
|
|
771
|
+
only reaches for the uplink when the LAN ports are taken.
|
|
772
|
+
"""
|
|
773
|
+
raw_type = (device.findtext("./ENGINE/TYPE") or "").strip()
|
|
774
|
+
if normalize_device_type(raw_type) not in WIRELESS_ROUTER_TYPES:
|
|
775
|
+
return []
|
|
776
|
+
prefix = "GigabitEthernet" if raw_type == "WirelessRouterNewGeneration" else "Ethernet"
|
|
777
|
+
copper = access_radios = host_radios = 0
|
|
778
|
+
for node in device.findall(".//PORT"):
|
|
779
|
+
port_type = (node.findtext("TYPE") or "").strip()
|
|
780
|
+
if PORT_TYPE_FAMILIES.get(port_type) in {"FastEthernet", "GigabitEthernet"}:
|
|
781
|
+
copper += 1
|
|
782
|
+
elif port_type.startswith("eAccessPointWireless"):
|
|
783
|
+
access_radios += 1
|
|
784
|
+
elif port_type.startswith("eHostWireless"):
|
|
785
|
+
host_radios += 1
|
|
786
|
+
# One of the copper sockets is the uplink, which is why counting them and
|
|
787
|
+
# numbering them all put a cable on a port that is not there.
|
|
788
|
+
names = [f"{prefix} {index}" for index in range(1, max(copper - 1, 0) + 1)]
|
|
789
|
+
names.append("Internet")
|
|
790
|
+
if access_radios == 1:
|
|
791
|
+
names.append("Wireless")
|
|
792
|
+
else:
|
|
793
|
+
names += [f"Wireless {index}" for index in range(1, access_radios + 1)]
|
|
794
|
+
names += ["Wireless0/0"] * min(host_radios, 1)
|
|
795
|
+
return names
|
|
796
|
+
|
|
721
797
|
|
|
722
798
|
def donor_interface_names(device: ET.Element) -> list[str]:
|
|
723
799
|
"""The interfaces a donor device really has, in the order it lists them.
|
|
@@ -779,7 +855,7 @@ def _name_contradicts_device_shape(device: ET.Element, canonical: str, kind: str
|
|
|
779
855
|
`1/1`, ... `9/1`. Both have one slash, so depth cannot tell them apart,
|
|
780
856
|
and the generator's `FastEthernet0/{index}` asked such a switch for
|
|
781
857
|
`FastEthernet0/2`. Measured: that single link is why a lab built from
|
|
782
|
-
|
|
858
|
+
the saved serial-WAN lab was refused; the same file with the uplink on
|
|
783
859
|
`FastEthernet2/1` opens.
|
|
784
860
|
|
|
785
861
|
Two names are required before concluding anything about the second axis, so
|
|
@@ -827,6 +903,16 @@ def port_exists(device: ET.Element, port_name: str) -> bool:
|
|
|
827
903
|
if "channel" in lowered or lowered.startswith("vlan") or "." in canonical:
|
|
828
904
|
return False
|
|
829
905
|
|
|
906
|
+
# A home router's sockets are named positionally and are the same on every
|
|
907
|
+
# unit of a model, so they can be answered exactly rather than guessed at.
|
|
908
|
+
# This has to come before the branches below: `Ethernet 1` matches neither
|
|
909
|
+
# modelled prefix and used to fall through to the permissive branch, where a
|
|
910
|
+
# device with no configured interfaces is given the benefit of the doubt --
|
|
911
|
+
# so `Ethernet 99` passed too.
|
|
912
|
+
if device_type in WIRELESS_ROUTER_TYPES:
|
|
913
|
+
known = wireless_router_port_names(device)
|
|
914
|
+
return canonical in known if known else True
|
|
915
|
+
|
|
830
916
|
# Serial is modelled now, so it gets a real answer: a router with no serial
|
|
831
917
|
# card cannot carry `Serial0/0/0`, and letting that through produced a WAN
|
|
832
918
|
# lab whose PPP configuration sat on an interface that was never cabled.
|
|
@@ -845,7 +931,19 @@ def port_exists(device: ET.Element, port_name: str) -> bool:
|
|
|
845
931
|
# point, `RS 232` on a laptop, the `Switch` pass-through on an IP phone.
|
|
846
932
|
# Reporting those as missing made real links look invalid, which is the
|
|
847
933
|
# damaging direction: a legitimate link gets dropped.
|
|
934
|
+
#
|
|
935
|
+
# Permissive, but not blind. A device that lists its own interfaces has told
|
|
936
|
+
# us what it has, and a name absent from that list is not one of them. An
|
|
937
|
+
# ASA 5506-X whose interfaces are `GigabitEthernet1/1` .. `1/8` answered yes
|
|
938
|
+
# to `Ethernet0/0` -- the name fits neither modelled prefix, so it fell
|
|
939
|
+
# straight through to this branch, the port repair saw nothing to fix, and
|
|
940
|
+
# Packet Tracer refused the lab. Devices with no interfaces in their
|
|
941
|
+
# configuration keep the benefit of the doubt, which is what leaves access
|
|
942
|
+
# points, phones and laptops working.
|
|
848
943
|
if not canonical.startswith(("FastEthernet", "GigabitEthernet")):
|
|
944
|
+
named = donor_interface_names(device)
|
|
945
|
+
if named and canonical not in named:
|
|
946
|
+
return False
|
|
849
947
|
return True
|
|
850
948
|
|
|
851
949
|
for kind, count in port_capacity(device).items():
|
|
@@ -21,6 +21,11 @@ SCRIPT_DIR = Path(__file__).resolve().parent
|
|
|
21
21
|
SKILL_ROOT = SCRIPT_DIR.parent
|
|
22
22
|
DEFAULT_CATALOG_JSON = SKILL_ROOT / "references" / "packettracer-sample-catalog.json"
|
|
23
23
|
DEFAULT_CATALOG_MD = SKILL_ROOT / "references" / "packettracer-sample-catalog.md"
|
|
24
|
+
# Labs the user happens to have on disk are catalogued for donor ranking but
|
|
25
|
+
# never written to the two files above: those are committed, and a lab from
|
|
26
|
+
# `C:/Users/<name>/Downloads` carries that person's name in its path, its
|
|
27
|
+
# filename and its device labels. Kept beside them, and git-ignored.
|
|
28
|
+
LOCAL_CATALOG_JSON = SKILL_ROOT / "references" / "local-donor-catalog.json"
|
|
24
29
|
DEFAULT_CURATED_DONOR_REGISTRY = SKILL_ROOT / "references" / "curated-donor-registry.json"
|
|
25
30
|
|
|
26
31
|
CAPABILITY_KEYWORDS = {
|
|
@@ -1112,25 +1117,51 @@ def enrich_catalog_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
|
1112
1117
|
return enriched
|
|
1113
1118
|
|
|
1114
1119
|
|
|
1120
|
+
def _catalog_item_path(item: dict, saves_root: Path | None) -> str:
|
|
1121
|
+
"""Where a catalogued lab actually lives.
|
|
1122
|
+
|
|
1123
|
+
Entries from the Packet Tracer installation are stored relative to its
|
|
1124
|
+
saves root and joined back to it here. A lab the user saved somewhere else
|
|
1125
|
+
carries its own absolute path, and joining that to the saves root produced
|
|
1126
|
+
a path no file was at -- so a donor built for this skill could be
|
|
1127
|
+
catalogued and still never be read.
|
|
1128
|
+
|
|
1129
|
+
The committed catalogue drops `path` on purpose, to stay independent of
|
|
1130
|
+
whose machine wrote it. `source_path` survives instead, and only entries
|
|
1131
|
+
outside the installation carry one: those are machine-specific by nature.
|
|
1132
|
+
"""
|
|
1133
|
+
for key in ("path", "source_path"):
|
|
1134
|
+
own_path = str(item.get(key) or "")
|
|
1135
|
+
if own_path and Path(own_path).is_absolute():
|
|
1136
|
+
return own_path
|
|
1137
|
+
relative_path = item["relative_path"]
|
|
1138
|
+
if saves_root is None:
|
|
1139
|
+
return own_path or relative_path
|
|
1140
|
+
return str(saves_root / relative_path)
|
|
1141
|
+
|
|
1142
|
+
|
|
1115
1143
|
@lru_cache(maxsize=8)
|
|
1116
1144
|
def _load_catalog_cached(path_str: str) -> tuple[SampleDescriptor, ...]:
|
|
1117
1145
|
raw_items = json.loads(Path(path_str).read_text(encoding="utf-8"))
|
|
1118
1146
|
items = enrich_catalog_items(raw_items)
|
|
1119
1147
|
saves_root = get_packet_tracer_saves_root()
|
|
1120
1148
|
return tuple(
|
|
1121
|
-
_descriptor_from_item(
|
|
1122
|
-
{
|
|
1123
|
-
**item,
|
|
1124
|
-
"path": str((saves_root / item["relative_path"]) if saves_root is not None else item.get("path", item["relative_path"])),
|
|
1125
|
-
}
|
|
1126
|
-
)
|
|
1149
|
+
_descriptor_from_item({**item, "path": _catalog_item_path(item, saves_root)})
|
|
1127
1150
|
for item in items
|
|
1128
1151
|
if "error" not in item
|
|
1129
1152
|
)
|
|
1130
1153
|
|
|
1131
1154
|
|
|
1132
1155
|
def load_catalog(path: Path | None = None) -> list[SampleDescriptor]:
|
|
1133
|
-
|
|
1156
|
+
"""Both catalogues, so splitting the file did not shrink donor choice.
|
|
1157
|
+
|
|
1158
|
+
The installed samples are committed; the labs found on this machine are
|
|
1159
|
+
not. Ranking has always seen one list and still does.
|
|
1160
|
+
"""
|
|
1161
|
+
catalog = list(_load_catalog_cached(str(path or DEFAULT_CATALOG_JSON)))
|
|
1162
|
+
if path is None and LOCAL_CATALOG_JSON.is_file():
|
|
1163
|
+
catalog.extend(_load_catalog_cached(str(LOCAL_CATALOG_JSON)))
|
|
1164
|
+
return catalog
|
|
1134
1165
|
|
|
1135
1166
|
|
|
1136
1167
|
def _summarize_pkt(path: Path, relative_path: str, origin: str, prototype_eligible: bool) -> dict[str, Any]:
|
|
@@ -1348,17 +1379,39 @@ def extract_reference_patterns(samples: list[SampleDescriptor]) -> list[Referenc
|
|
|
1348
1379
|
return patterns
|
|
1349
1380
|
|
|
1350
1381
|
|
|
1351
|
-
def write_catalog_outputs(
|
|
1382
|
+
def write_catalog_outputs(
|
|
1383
|
+
items: list[dict[str, Any]],
|
|
1384
|
+
json_path: Path | None = None,
|
|
1385
|
+
md_path: Path | None = None,
|
|
1386
|
+
local_json_path: Path | None = None,
|
|
1387
|
+
) -> None:
|
|
1388
|
+
"""Write the installed-sample catalogue, and the local one beside it.
|
|
1389
|
+
|
|
1390
|
+
`_local_donor_items` strips `path` from its entries so the committed
|
|
1391
|
+
catalogue would not depend on whose machine built it -- but that intent
|
|
1392
|
+
lived only there, and this writer, which is what actually persists the
|
|
1393
|
+
file, did not share it. So a rebuild on a machine with saved labs staged
|
|
1394
|
+
350 entries naming the user and their classmates, each with an absolute
|
|
1395
|
+
path under their home directory, into a file bound for a public
|
|
1396
|
+
repository. Provenance is already on every entry; the split reads it
|
|
1397
|
+
rather than trusting the caller to have filtered first.
|
|
1398
|
+
"""
|
|
1352
1399
|
enriched = enrich_catalog_items(items)
|
|
1353
|
-
|
|
1400
|
+
installed: list[dict[str, Any]] = []
|
|
1401
|
+
local: list[dict[str, Any]] = []
|
|
1354
1402
|
for item in enriched:
|
|
1355
1403
|
saved = dict(item)
|
|
1356
1404
|
saved.pop("path", None)
|
|
1357
|
-
|
|
1358
|
-
(json_path or DEFAULT_CATALOG_JSON).write_text(json.dumps(
|
|
1405
|
+
(installed if saved.get("origin") == "cisco-local" else local).append(saved)
|
|
1406
|
+
(json_path or DEFAULT_CATALOG_JSON).write_text(json.dumps(installed, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
1407
|
+
local_path = local_json_path or LOCAL_CATALOG_JSON
|
|
1408
|
+
if local:
|
|
1409
|
+
local_path.write_text(json.dumps(local, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
1410
|
+
elif local_path.exists():
|
|
1411
|
+
local_path.unlink()
|
|
1359
1412
|
root_label = "<PACKET_TRACER_SAVES_ROOT>"
|
|
1360
1413
|
lines = ["# Packet Tracer Installed Sample Catalog", "", f"Source root: `{root_label}`", ""]
|
|
1361
|
-
for item in
|
|
1414
|
+
for item in installed:
|
|
1362
1415
|
if "error" in item:
|
|
1363
1416
|
lines.append(f"- `{item['relative_path']}`")
|
|
1364
1417
|
lines.append(f" decode error: `{item['error']}`")
|