packet-tracer-skill 0.2.2 → 0.2.3
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 +32 -1
- package/README.md +221 -51
- 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/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 +172 -15
- package/docs/release-checklist.md +13 -8
- package/docs/release-notes-0.2.2.md +1 -1
- package/docs/release-notes-0.2.3.md +59 -0
- 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/package.json +10 -1
- package/references/packettracer-feature-atlas.json +67 -17
- package/scripts/coverage_matrix.py +505 -12
- package/scripts/feature_atlas.py +65 -1
- package/scripts/generate_pkt.py +161 -3
- package/scripts/intent_parser.py +521 -2
- package/scripts/pkt_editor.py +477 -0
- package/scripts/remote_search.py +197 -21
- package/scripts/sample_catalog.py +57 -2
package/scripts/remote_search.py
CHANGED
|
@@ -14,6 +14,16 @@ import zipfile
|
|
|
14
14
|
from intent_parser import IntentPlan
|
|
15
15
|
|
|
16
16
|
GITHUB_SEARCH_API = "https://api.github.com/search/repositories"
|
|
17
|
+
PERMISSIVE_LICENSES = {
|
|
18
|
+
"APACHE-2.0",
|
|
19
|
+
"BSD-2-CLAUSE",
|
|
20
|
+
"BSD-3-CLAUSE",
|
|
21
|
+
"CC0-1.0",
|
|
22
|
+
"ISC",
|
|
23
|
+
"MIT",
|
|
24
|
+
"UNLICENSE",
|
|
25
|
+
}
|
|
26
|
+
REMOTE_AUDIT_FILENAME = "remote-sample-audit.json"
|
|
17
27
|
|
|
18
28
|
|
|
19
29
|
@dataclass
|
|
@@ -26,6 +36,25 @@ class RemoteSearchCandidate:
|
|
|
26
36
|
import_status: str
|
|
27
37
|
default_branch: str | None = None
|
|
28
38
|
archive_url: str | None = None
|
|
39
|
+
candidate_promotion_status: str = "reference_only"
|
|
40
|
+
imported_file_count: int = 0
|
|
41
|
+
pkt_file_count: int = 0
|
|
42
|
+
pka_file_count: int = 0
|
|
43
|
+
readme_file_count: int = 0
|
|
44
|
+
license_file_count: int = 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_permissive_license(license_or_permission: str | None) -> bool:
|
|
48
|
+
value = (license_or_permission or "").strip().upper()
|
|
49
|
+
return value in PERMISSIVE_LICENSES
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def candidate_promotion_status(license_or_permission: str | None) -> str:
|
|
53
|
+
return "validated_curated" if is_permissive_license(license_or_permission) else "reference_only"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def candidate_is_curated_eligible(candidate: RemoteSearchCandidate) -> bool:
|
|
57
|
+
return candidate.candidate_promotion_status == "validated_curated"
|
|
29
58
|
|
|
30
59
|
|
|
31
60
|
def build_remote_search_queries(plan: IntentPlan) -> list[str]:
|
|
@@ -91,6 +120,9 @@ def search_remote_candidates(
|
|
|
91
120
|
import_status="discovered",
|
|
92
121
|
default_branch=item.get("default_branch"),
|
|
93
122
|
archive_url=item.get("archive_url"),
|
|
123
|
+
candidate_promotion_status=candidate_promotion_status(
|
|
124
|
+
str((item.get("license") or {}).get("spdx_id") or "unknown")
|
|
125
|
+
),
|
|
94
126
|
)
|
|
95
127
|
)
|
|
96
128
|
if len(candidates) >= max_results:
|
|
@@ -98,19 +130,66 @@ def search_remote_candidates(
|
|
|
98
130
|
return candidates
|
|
99
131
|
|
|
100
132
|
|
|
133
|
+
def _allowed_archive_member(filename: str) -> bool:
|
|
134
|
+
lower_name = filename.lower()
|
|
135
|
+
return (
|
|
136
|
+
lower_name.endswith(".pkt")
|
|
137
|
+
or lower_name.endswith(".pka")
|
|
138
|
+
or lower_name.endswith("readme.md")
|
|
139
|
+
or lower_name.endswith("license")
|
|
140
|
+
or lower_name.endswith("license.md")
|
|
141
|
+
or lower_name.endswith("license.txt")
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _increment_file_counters(candidate: RemoteSearchCandidate, target: Path) -> None:
|
|
146
|
+
lower_name = target.name.lower()
|
|
147
|
+
candidate.imported_file_count += 1
|
|
148
|
+
if lower_name.endswith(".pkt"):
|
|
149
|
+
candidate.pkt_file_count += 1
|
|
150
|
+
elif lower_name.endswith(".pka"):
|
|
151
|
+
candidate.pka_file_count += 1
|
|
152
|
+
elif lower_name == "readme.md":
|
|
153
|
+
candidate.readme_file_count += 1
|
|
154
|
+
elif lower_name in {"license", "license.md", "license.txt"}:
|
|
155
|
+
candidate.license_file_count += 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _clear_import_destination(dest_root: Path) -> None:
|
|
159
|
+
if not dest_root.exists():
|
|
160
|
+
return
|
|
161
|
+
for child in dest_root.iterdir():
|
|
162
|
+
if child.is_dir():
|
|
163
|
+
for nested in child.rglob("*"):
|
|
164
|
+
if nested.is_file():
|
|
165
|
+
nested.unlink()
|
|
166
|
+
for nested_dir in sorted([item for item in child.rglob("*") if item.is_dir()], reverse=True):
|
|
167
|
+
nested_dir.rmdir()
|
|
168
|
+
child.rmdir()
|
|
169
|
+
else:
|
|
170
|
+
child.unlink()
|
|
171
|
+
|
|
172
|
+
|
|
101
173
|
def auto_import_remote_candidates(
|
|
102
174
|
candidates: list[RemoteSearchCandidate],
|
|
103
175
|
import_cache_root: Path,
|
|
104
176
|
*,
|
|
105
177
|
max_results: int = 3,
|
|
178
|
+
dry_run: bool = False,
|
|
106
179
|
) -> list[RemoteSearchCandidate]:
|
|
107
180
|
imported: list[RemoteSearchCandidate] = []
|
|
108
|
-
|
|
181
|
+
if not dry_run:
|
|
182
|
+
import_cache_root.mkdir(parents=True, exist_ok=True)
|
|
109
183
|
for candidate in candidates[:max_results]:
|
|
184
|
+
candidate.candidate_promotion_status = candidate_promotion_status(candidate.license_or_permission)
|
|
110
185
|
repo_name = candidate.repo_url.rstrip("/").split("/")[-1]
|
|
111
186
|
owner_name = candidate.repo_url.rstrip("/").split("/")[-2] if "/" in candidate.repo_url.rstrip("/") else "repo"
|
|
112
187
|
dest_root = import_cache_root / f"{owner_name}_{repo_name}"
|
|
113
188
|
archive_url = candidate.archive_url
|
|
189
|
+
if dry_run:
|
|
190
|
+
candidate.import_status = "dry_run"
|
|
191
|
+
imported.append(candidate)
|
|
192
|
+
continue
|
|
114
193
|
if not archive_url:
|
|
115
194
|
imported.append(candidate)
|
|
116
195
|
continue
|
|
@@ -124,39 +203,26 @@ def auto_import_remote_candidates(
|
|
|
124
203
|
continue
|
|
125
204
|
try:
|
|
126
205
|
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
|
|
127
|
-
|
|
128
|
-
for child in dest_root.iterdir():
|
|
129
|
-
if child.is_dir():
|
|
130
|
-
for nested in child.rglob("*"):
|
|
131
|
-
if nested.is_file():
|
|
132
|
-
nested.unlink()
|
|
133
|
-
for nested_dir in sorted([item for item in child.rglob("*") if item.is_dir()], reverse=True):
|
|
134
|
-
nested_dir.rmdir()
|
|
135
|
-
child.rmdir()
|
|
136
|
-
else:
|
|
137
|
-
child.unlink()
|
|
206
|
+
_clear_import_destination(dest_root)
|
|
138
207
|
dest_root.mkdir(parents=True, exist_ok=True)
|
|
139
208
|
for member in archive.infolist():
|
|
140
|
-
|
|
141
|
-
if not (
|
|
142
|
-
lower_name.endswith(".pkt")
|
|
143
|
-
or lower_name.endswith(".pka")
|
|
144
|
-
or lower_name.endswith("readme.md")
|
|
145
|
-
or lower_name.endswith("license")
|
|
146
|
-
or lower_name.endswith("license.md")
|
|
147
|
-
or lower_name.endswith("license.txt")
|
|
148
|
-
):
|
|
209
|
+
if not _allowed_archive_member(member.filename):
|
|
149
210
|
continue
|
|
150
211
|
cleaned = re.sub(r"^[^/]+/", "", member.filename).strip("/")
|
|
151
212
|
if not cleaned:
|
|
152
213
|
continue
|
|
153
214
|
target = dest_root / cleaned
|
|
215
|
+
try:
|
|
216
|
+
target.resolve().relative_to(dest_root.resolve())
|
|
217
|
+
except ValueError:
|
|
218
|
+
continue
|
|
154
219
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
155
220
|
if member.is_dir():
|
|
156
221
|
target.mkdir(parents=True, exist_ok=True)
|
|
157
222
|
continue
|
|
158
223
|
with archive.open(member) as source, target.open("wb") as sink:
|
|
159
224
|
sink.write(source.read())
|
|
225
|
+
_increment_file_counters(candidate, target)
|
|
160
226
|
candidate.path = str(dest_root)
|
|
161
227
|
candidate.import_status = "imported"
|
|
162
228
|
except zipfile.BadZipFile:
|
|
@@ -165,5 +231,115 @@ def auto_import_remote_candidates(
|
|
|
165
231
|
return imported
|
|
166
232
|
|
|
167
233
|
|
|
234
|
+
def _decode_audit_for_root(root: Path, license_or_permission: str) -> dict[str, object]:
|
|
235
|
+
pkt_paths = sorted(root.rglob("*.pkt")) if root.exists() else []
|
|
236
|
+
decode_success_count = 0
|
|
237
|
+
decode_failure_count = 0
|
|
238
|
+
detected_features: set[str] = set()
|
|
239
|
+
detected_families: set[str] = set()
|
|
240
|
+
for pkt_path in pkt_paths:
|
|
241
|
+
try:
|
|
242
|
+
from sample_catalog import summarize_pkt_descriptor
|
|
243
|
+
|
|
244
|
+
descriptor = summarize_pkt_descriptor(
|
|
245
|
+
pkt_path,
|
|
246
|
+
str(pkt_path.relative_to(root)),
|
|
247
|
+
origin="external-reference",
|
|
248
|
+
prototype_eligible=False,
|
|
249
|
+
trust_level="reference-only",
|
|
250
|
+
role="reference",
|
|
251
|
+
license_or_permission=license_or_permission,
|
|
252
|
+
promotion_status="reference_only",
|
|
253
|
+
donor_eligible=False,
|
|
254
|
+
)
|
|
255
|
+
except Exception:
|
|
256
|
+
decode_failure_count += 1
|
|
257
|
+
continue
|
|
258
|
+
decode_success_count += 1
|
|
259
|
+
detected_features.update(descriptor.capability_tags)
|
|
260
|
+
detected_features.update(descriptor.validated_edit_capabilities)
|
|
261
|
+
detected_families.update(descriptor.archetype_tags)
|
|
262
|
+
detected_families.update(descriptor.device_families)
|
|
263
|
+
detected_families.update(descriptor.topology_tags)
|
|
264
|
+
return {
|
|
265
|
+
"decode_status": "not_run" if not pkt_paths else ("ok" if decode_failure_count == 0 else "partial_or_failed"),
|
|
266
|
+
"decode_success_count": decode_success_count,
|
|
267
|
+
"decode_failure_count": decode_failure_count,
|
|
268
|
+
"detected_feature_tags": sorted(detected_features),
|
|
269
|
+
"detected_feature_families": sorted(detected_families),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def build_remote_sample_audit(
|
|
274
|
+
candidates: list[RemoteSearchCandidate],
|
|
275
|
+
import_cache_root: Path,
|
|
276
|
+
) -> dict[str, object]:
|
|
277
|
+
entries: list[dict[str, object]] = []
|
|
278
|
+
license_counts: dict[str, int] = {}
|
|
279
|
+
for candidate in candidates:
|
|
280
|
+
license_value = candidate.license_or_permission or "unknown"
|
|
281
|
+
license_counts[license_value] = license_counts.get(license_value, 0) + 1
|
|
282
|
+
entry = asdict(candidate)
|
|
283
|
+
if candidate.path:
|
|
284
|
+
entry.update(_decode_audit_for_root(Path(candidate.path), candidate.license_or_permission))
|
|
285
|
+
else:
|
|
286
|
+
entry.update(
|
|
287
|
+
{
|
|
288
|
+
"decode_status": "not_run",
|
|
289
|
+
"decode_success_count": 0,
|
|
290
|
+
"decode_failure_count": 0,
|
|
291
|
+
"detected_feature_tags": [],
|
|
292
|
+
"detected_feature_families": [],
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
if candidate.candidate_promotion_status == "validated_curated" and int(entry.get("decode_success_count", 0)) > 0:
|
|
296
|
+
entry["validation_promotion_status"] = "validated_curated"
|
|
297
|
+
else:
|
|
298
|
+
entry["validation_promotion_status"] = "reference_only"
|
|
299
|
+
entries.append(entry)
|
|
300
|
+
return {
|
|
301
|
+
"audit_version": "1.0",
|
|
302
|
+
"cache_root": str(import_cache_root),
|
|
303
|
+
"raw_pkt_policy": "remote Packet Tracer files stay local/cache-only and are not committed or packed",
|
|
304
|
+
"candidate_count": len(candidates),
|
|
305
|
+
"imported_repo_count": sum(1 for candidate in candidates if candidate.import_status == "imported"),
|
|
306
|
+
"dry_run_repo_count": sum(1 for candidate in candidates if candidate.import_status == "dry_run"),
|
|
307
|
+
"pkt_file_count": sum(candidate.pkt_file_count for candidate in candidates),
|
|
308
|
+
"pka_file_count": sum(candidate.pka_file_count for candidate in candidates),
|
|
309
|
+
"decode_success_count": sum(int(entry.get("decode_success_count", 0)) for entry in entries),
|
|
310
|
+
"decode_failure_count": sum(int(entry.get("decode_failure_count", 0)) for entry in entries),
|
|
311
|
+
"license_counts": license_counts,
|
|
312
|
+
"promotion_status_counts": {
|
|
313
|
+
status: sum(1 for candidate in candidates if candidate.candidate_promotion_status == status)
|
|
314
|
+
for status in sorted({candidate.candidate_promotion_status for candidate in candidates})
|
|
315
|
+
},
|
|
316
|
+
"validation_promotion_status_counts": {
|
|
317
|
+
status: sum(1 for entry in entries if entry.get("validation_promotion_status") == status)
|
|
318
|
+
for status in sorted({str(entry.get("validation_promotion_status")) for entry in entries})
|
|
319
|
+
},
|
|
320
|
+
"top_gaps_filled_by_imported_samples": sorted(
|
|
321
|
+
{
|
|
322
|
+
feature
|
|
323
|
+
for entry in entries
|
|
324
|
+
for feature in entry.get("detected_feature_tags", [])
|
|
325
|
+
if isinstance(feature, str)
|
|
326
|
+
}
|
|
327
|
+
)[:25],
|
|
328
|
+
"candidates": entries,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def write_remote_sample_audit(
|
|
333
|
+
candidates: list[RemoteSearchCandidate],
|
|
334
|
+
import_cache_root: Path,
|
|
335
|
+
audit_path: Path | None = None,
|
|
336
|
+
) -> dict[str, object]:
|
|
337
|
+
payload = build_remote_sample_audit(candidates, import_cache_root)
|
|
338
|
+
target = audit_path or (import_cache_root / REMOTE_AUDIT_FILENAME)
|
|
339
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
340
|
+
target.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
341
|
+
return payload
|
|
342
|
+
|
|
343
|
+
|
|
168
344
|
def asdict_list(candidates: list[RemoteSearchCandidate]) -> list[dict[str, object]]:
|
|
169
345
|
return [asdict(candidate) for candidate in candidates]
|
|
@@ -33,13 +33,25 @@ CAPABILITY_KEYWORDS = {
|
|
|
33
33
|
"isatap": ["isatap"],
|
|
34
34
|
"dhcp_snooping": ["dhcp snooping", "option_82", "trusted_untrusted"],
|
|
35
35
|
"ospf": ["ospf"],
|
|
36
|
+
"ospfv2": ["ospf", "ospfv2", "single-area ospf"],
|
|
36
37
|
"ospfv3": ["ospfv3", "ipv6_ospf"],
|
|
37
38
|
"eigrp": ["eigrp"],
|
|
39
|
+
"eigrp_ipv4": ["eigrp", "ipv4 eigrp"],
|
|
38
40
|
"eigrp_ipv6": ["ipv6_eigrp"],
|
|
39
41
|
"rip": ["rip"],
|
|
42
|
+
"ripv2": ["ripv2", "rip"],
|
|
40
43
|
"ripng": ["ripng", "ipv6 rip"],
|
|
41
44
|
"hsrp": ["hsrp"],
|
|
42
45
|
"nat": ["nat"],
|
|
46
|
+
"nat_static": ["static nat"],
|
|
47
|
+
"nat_dynamic": ["dynamic nat"],
|
|
48
|
+
"pat": ["pat", "overload"],
|
|
49
|
+
"static_route": ["static route", "ip route"],
|
|
50
|
+
"default_route": ["default route"],
|
|
51
|
+
"dhcp_relay": ["dhcp relay", "helper-address"],
|
|
52
|
+
"ssh_ios": ["ssh"],
|
|
53
|
+
"ntp_ios": ["ntp"],
|
|
54
|
+
"syslog_ios": ["syslog"],
|
|
43
55
|
"acl": ["acl", "access-list"],
|
|
44
56
|
"dai": ["dai", "dynamic arp inspection"],
|
|
45
57
|
"dot1x": ["dot1x", "802.1x", "port-based nac"],
|
|
@@ -47,9 +59,17 @@ CAPABILITY_KEYWORDS = {
|
|
|
47
59
|
"rep": ["rep_"],
|
|
48
60
|
"snmp": ["snmp"],
|
|
49
61
|
"netflow": ["netflow"],
|
|
50
|
-
"span": ["
|
|
62
|
+
"span": ["rspan", "monitor session", "span session"],
|
|
51
63
|
"qos": ["qos"],
|
|
52
64
|
"port_security": ["port security", "port-security"],
|
|
65
|
+
"bgp": ["bgp"],
|
|
66
|
+
"stp": ["stp", "spanning tree", "spanning-tree"],
|
|
67
|
+
"rstp": ["rstp", "rapid-pvst"],
|
|
68
|
+
"etherchannel": ["etherchannel", "port-channel", "channel-group"],
|
|
69
|
+
"lacp": ["lacp"],
|
|
70
|
+
"pagp": ["pagp"],
|
|
71
|
+
"vtp": ["vtp"],
|
|
72
|
+
"dtp": ["dtp"],
|
|
53
73
|
"vpn": ["vpn", "ipsec", "gre"],
|
|
54
74
|
"ipsec": ["ipsec", "ike"],
|
|
55
75
|
"gre": ["gre"],
|
|
@@ -138,6 +158,26 @@ REPORT_ONLY_CAPABILITIES = {
|
|
|
138
158
|
"span",
|
|
139
159
|
"qos",
|
|
140
160
|
"port_security",
|
|
161
|
+
"bgp",
|
|
162
|
+
"stp",
|
|
163
|
+
"rstp",
|
|
164
|
+
"etherchannel",
|
|
165
|
+
"lacp",
|
|
166
|
+
"pagp",
|
|
167
|
+
"vtp",
|
|
168
|
+
"dtp",
|
|
169
|
+
"ospfv2",
|
|
170
|
+
"eigrp_ipv4",
|
|
171
|
+
"ripv2",
|
|
172
|
+
"static_route",
|
|
173
|
+
"default_route",
|
|
174
|
+
"dhcp_relay",
|
|
175
|
+
"nat_static",
|
|
176
|
+
"nat_dynamic",
|
|
177
|
+
"pat",
|
|
178
|
+
"ssh_ios",
|
|
179
|
+
"ntp_ios",
|
|
180
|
+
"syslog_ios",
|
|
141
181
|
"asa_acl_nat",
|
|
142
182
|
"asa_service_policy",
|
|
143
183
|
"clientless_vpn",
|
|
@@ -422,8 +462,12 @@ def infer_preferred_roles(item: dict[str, Any]) -> list[str]:
|
|
|
422
462
|
roles.append("preferred_vlan")
|
|
423
463
|
if "dhcp_pool" in tags:
|
|
424
464
|
roles.append("preferred_dhcp")
|
|
425
|
-
if {"ospf", "eigrp", "rip"} & tags:
|
|
465
|
+
if {"ospf", "eigrp", "rip", "bgp", "ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route"} & tags:
|
|
426
466
|
roles.append("preferred_routing")
|
|
467
|
+
if {"dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"} & tags:
|
|
468
|
+
roles.append("preferred_ipv4_management")
|
|
469
|
+
if {"stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"} & tags:
|
|
470
|
+
roles.append("preferred_l2_resiliency")
|
|
427
471
|
if {"nat", "acl", "vpn"} & tags:
|
|
428
472
|
roles.append("preferred_security")
|
|
429
473
|
if {"vpn", "ipsec", "gre", "ppp", "multilayer_switching"} & tags:
|
|
@@ -481,6 +525,9 @@ def infer_device_families(item: dict[str, Any]) -> list[str]:
|
|
|
481
525
|
"Tablet": "end devices",
|
|
482
526
|
"Smartphone": "end devices",
|
|
483
527
|
"Printer": "end devices",
|
|
528
|
+
"IpPhone": "end devices",
|
|
529
|
+
"HomeVoip": "end devices",
|
|
530
|
+
"AnalogPhone": "end devices",
|
|
484
531
|
"LightWeightAccessPoint": "access points",
|
|
485
532
|
"WirelessRouter": "home/wireless routers",
|
|
486
533
|
"WirelessRouterNewGeneration": "home/wireless routers",
|
|
@@ -572,6 +619,10 @@ def infer_runtime_features(item: dict[str, Any]) -> list[str]:
|
|
|
572
619
|
features.add("tunnel_runtime")
|
|
573
620
|
if tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
|
|
574
621
|
features.add("ipv6_runtime")
|
|
622
|
+
if tags & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
|
|
623
|
+
features.add("l2_resiliency_runtime")
|
|
624
|
+
if tags & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
|
|
625
|
+
features.add("ipv4_routing_management_runtime")
|
|
575
626
|
if families & {"multilayer switches"} or tags & {"multilayer_switching"}:
|
|
576
627
|
features.add("multilayer_runtime")
|
|
577
628
|
if item.get("workspace_validation"):
|
|
@@ -617,6 +668,10 @@ def infer_archetype_tags(item: dict[str, Any]) -> list[str]:
|
|
|
617
668
|
tags.add("IPv6/routing")
|
|
618
669
|
if capability_tags & {"dhcp_snooping", "dai", "dot1x", "lldp", "rep", "snmp", "netflow", "span", "qos", "port_security"}:
|
|
619
670
|
tags.add("L2 security/monitoring")
|
|
671
|
+
if capability_tags & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
|
|
672
|
+
tags.add("L2 resiliency/routing")
|
|
673
|
+
if capability_tags & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
|
|
674
|
+
tags.add("IPv4 routing/management")
|
|
620
675
|
if capability_tags & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"}:
|
|
621
676
|
tags.add("advanced wireless")
|
|
622
677
|
if capability_tags & {"network_controller", "python_programming", "javascript_programming", "blockly_programming", "tcp_udp_app", "vm_iox"} or "network controllers" in families:
|