panorama-super-cli 0.2.3__tar.gz → 0.2.4__tar.gz
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.
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/CHANGELOG.md +13 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/PKG-INFO +1 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/duplicates-and-merging.md +21 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/reference/cli.md +1 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/_version.py +1 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/dedup_cmds.py +16 -3
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/dedup.py +17 -5
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/normalize.py +32 -4
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/pyproject.toml +1 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/skills/panorama-super-cli/SKILL.md +2 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_cli.py +30 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_dedup.py +45 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_normalize.py +27 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/uv.lock +1 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/CODEOWNERS +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/dependabot.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/workflows/docs.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/workflows/lint.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/workflows/release.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/workflows/test.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.gitignore +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.pre-commit-config.yaml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.python-version +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/AGENTS.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/CLAUDE.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/LICENSE +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/README.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/contributing/branching.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/contributing/development.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/contributing/release-process.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/getting-started/concepts.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/getting-started/first-run.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/getting-started/install.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/finding-objects.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/live-vs-offline.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/naming.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/output-formats.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/references-and-audit.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/safety.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/using-with-ai-agents.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/index.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/reference/config.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/reference/exit-codes.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/justfile +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/mkdocs.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/__main__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/_plan.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/app.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/auth_cmds.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/find_cmds.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/name_cmds.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/profile_cmds.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/refs_cmds.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/runtime.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/config/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/config/loader.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/config/models.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/apply_live.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/apply_xml.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/changeset.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/models.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/naming.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/parse.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/refs.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/resolve.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/setcmd.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/source.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/output/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/output/errors.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/output/format.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/scripts/sync_agents_md.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/conftest.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/fixtures/nested-device-groups.xml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/fixtures/panorama-config.xml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_apply_xml.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_auth.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_changeset.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_cli_auth.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_hardening.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_live_apply.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_naming.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_nested_dg.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_output.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_parse.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_refs.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_resolve.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_setcmd.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_source.py +0 -0
|
@@ -7,6 +7,19 @@ project will follow [Semantic Versioning](https://semver.org/). While on
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## v0.2.4 — 2026-06-04
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`psc dedup addresses` is now strict by default** (#38). Addresses are
|
|
15
|
+
grouped only when their values are byte-identical, so a host accidentally
|
|
16
|
+
written with a subnet mask (`10.1.1.50/24`) is no longer reported as a
|
|
17
|
+
duplicate of the network `10.1.1.0/24` — merging those would have silently
|
|
18
|
+
changed rule matching. Genuinely identical forms (`10.0.0.10` and
|
|
19
|
+
`10.0.0.10/32`) still group. Pass `--not-strict` for the previous
|
|
20
|
+
host-bit-masking behaviour. The `dedup merge` safety gate now compares exact
|
|
21
|
+
values too, so such a merge is blocked unless `--allow-value-change` is given.
|
|
22
|
+
|
|
10
23
|
## v0.2.3 — 2026-06-04
|
|
11
24
|
|
|
12
25
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: panorama-super-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely.
|
|
5
5
|
Project-URL: Homepage, https://github.com/thomaschristory/panorama-super-cli
|
|
6
6
|
Project-URL: Documentation, https://thomaschristory.github.io/panorama-super-cli/
|
|
@@ -12,6 +12,27 @@ Objects are grouped by **value**, not name. `h-web1`, `web-primary`, and
|
|
|
12
12
|
`svc-https` (both TCP/443) in another. Each row tells you the value and every
|
|
13
13
|
object name + location that defines it.
|
|
14
14
|
|
|
15
|
+
### Strict by default
|
|
16
|
+
|
|
17
|
+
Address matching is **strict**: only byte-identical values are duplicates. A
|
|
18
|
+
host accidentally written with a subnet mask — `web-server = 10.1.1.50/24` —
|
|
19
|
+
is **not** reported as a duplicate of the network `internal = 10.1.1.0/24`,
|
|
20
|
+
even though both mask down to the same `/24`. (`10.0.0.10` and `10.0.0.10/32`
|
|
21
|
+
*are* still the same host, so they group.)
|
|
22
|
+
|
|
23
|
+
Pass `--not-strict` for the looser, fringe behaviour that masks host bits and
|
|
24
|
+
collapses a host-with-mask onto its network:
|
|
25
|
+
|
|
26
|
+
```console
|
|
27
|
+
psc -c panorama.xml dedup addresses --not-strict
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`--not-strict` only widens what's *listed* — it grants no merge power. A pair it
|
|
31
|
+
surfaces (e.g. a host and its network) has different exact values, so
|
|
32
|
+
[`dedup merge`](#the-safety-gate) still refuses it unless you pass
|
|
33
|
+
`--allow-value-change`. Treat `--not-strict` as a discovery aid, then decide
|
|
34
|
+
case by case whether the masked-equal objects really should be merged.
|
|
35
|
+
|
|
15
36
|
## Merge two objects
|
|
16
37
|
|
|
17
38
|
`dedup merge` collapses one object (`--remove`) into another (`--keep`),
|
|
@@ -40,7 +40,7 @@ object by exact name. See [Finding objects](../guides/finding-objects.md).
|
|
|
40
40
|
### dedup
|
|
41
41
|
|
|
42
42
|
```
|
|
43
|
-
psc dedup addresses
|
|
43
|
+
psc dedup addresses [--not-strict]
|
|
44
44
|
psc dedup services
|
|
45
45
|
psc dedup merge --keep NAME --remove NAME [--location LOC]
|
|
46
46
|
[--keep-location LOC] [--remove-location LOC]
|
|
@@ -30,10 +30,23 @@ def _dup_rows(groups: list[DuplicateGroup]) -> list[dict[str, object]]:
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
@app.command("addresses")
|
|
33
|
-
def addresses(
|
|
34
|
-
|
|
33
|
+
def addresses(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
not_strict: bool = typer.Option(
|
|
36
|
+
False,
|
|
37
|
+
"--not-strict",
|
|
38
|
+
help="Also group host objects onto their network (mask host bits). "
|
|
39
|
+
"Default: only byte-identical values are duplicates.",
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""List address objects that share an identical value under different names.
|
|
43
|
+
|
|
44
|
+
By default this is *strict*: a host written with a subnet mask
|
|
45
|
+
(`10.1.1.50/24`) is not treated as a duplicate of the network `10.1.1.0/24`.
|
|
46
|
+
Pass `--not-strict` for the looser, host-bit-masking behaviour.
|
|
47
|
+
"""
|
|
35
48
|
rt: Runtime = ctx.obj
|
|
36
|
-
groups = find_duplicate_addresses(rt.snapshot())
|
|
49
|
+
groups = find_duplicate_addresses(rt.snapshot(), strict=not not_strict)
|
|
37
50
|
if rt.strict and not groups:
|
|
38
51
|
raise PscError("no duplicate addresses", ErrorType.NOT_FOUND)
|
|
39
52
|
render(
|
|
@@ -45,21 +45,30 @@ class DuplicateGroup(BaseModel):
|
|
|
45
45
|
return len(self.members)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def find_duplicate_addresses(snapshot: Snapshot) -> list[DuplicateGroup]:
|
|
49
|
-
"""Address objects sharing
|
|
48
|
+
def find_duplicate_addresses(snapshot: Snapshot, *, strict: bool = True) -> list[DuplicateGroup]:
|
|
49
|
+
"""Address objects sharing the same value, 2+ per bucket.
|
|
50
|
+
|
|
51
|
+
`strict` (default) groups only byte-identical values, so a host written with
|
|
52
|
+
a subnet mask (`10.1.1.50/24`) is *not* a duplicate of its network
|
|
53
|
+
(`10.1.1.0/24`). `strict=False` groups by masked network — the looser,
|
|
54
|
+
fringe behaviour that collapses host-with-mask onto the network.
|
|
55
|
+
"""
|
|
50
56
|
buckets: dict[str, list[ObjectRef]] = {}
|
|
51
57
|
display: dict[str, str] = {}
|
|
52
58
|
for a in snapshot.addresses:
|
|
53
59
|
nv = normalize_address(a)
|
|
54
60
|
if nv is None:
|
|
55
61
|
continue
|
|
56
|
-
key = nv.overlaps_key()
|
|
62
|
+
key = nv.exact_key() if strict else nv.overlaps_key()
|
|
57
63
|
buckets.setdefault(key, []).append(ObjectRef(name=a.name, location=a.location.name))
|
|
58
|
-
|
|
64
|
+
label = (nv.exact or nv.key) if strict else nv.key
|
|
65
|
+
display.setdefault(key, f"{a.type.value} {label}")
|
|
59
66
|
return _to_groups("address", buckets, display)
|
|
60
67
|
|
|
61
68
|
|
|
62
69
|
def find_duplicate_services(snapshot: Snapshot) -> list[DuplicateGroup]:
|
|
70
|
+
# Service dedup is already exact (protocol + normalized port lists); there
|
|
71
|
+
# is no host-bit-masking analogue, so no strict/loose distinction applies.
|
|
63
72
|
buckets: dict[str, list[ObjectRef]] = {}
|
|
64
73
|
display: dict[str, str] = {}
|
|
65
74
|
for s in snapshot.services:
|
|
@@ -93,10 +102,13 @@ def _rewrite_members(before: list[str], drop: str, keep: str) -> list[str]:
|
|
|
93
102
|
|
|
94
103
|
|
|
95
104
|
def _addr_value_key(snapshot: Snapshot, ref: ObjectRef) -> str | None:
|
|
105
|
+
# Exact (host-bit-preserving) key: the merge gate must treat a /24-masked
|
|
106
|
+
# host and the /24 network as *different* values, so it blocks the merge
|
|
107
|
+
# unless --allow-value-change is passed.
|
|
96
108
|
for a in snapshot.addresses:
|
|
97
109
|
if a.name == ref.name and a.location == ref.loc:
|
|
98
110
|
nv = normalize_address(a)
|
|
99
|
-
return nv.
|
|
111
|
+
return nv.exact_key() if nv else None
|
|
100
112
|
return None
|
|
101
113
|
|
|
102
114
|
|
|
@@ -41,11 +41,20 @@ class AddrValue:
|
|
|
41
41
|
range: tuple[int, int] | None = None
|
|
42
42
|
family: int | None = None
|
|
43
43
|
fqdn: str | None = None
|
|
44
|
+
exact: str | None = None
|
|
45
|
+
"""Host-bit-preserving canonical string for ip-netmask (e.g. `10.1.1.50/24`
|
|
46
|
+
stays distinct from `10.1.1.0/24`); `None` means `key` is already exact."""
|
|
44
47
|
|
|
45
48
|
def overlaps_key(self) -> str:
|
|
46
|
-
"""Kind-qualified key
|
|
49
|
+
"""Kind-qualified key grouping by *masked network* — loose: a host
|
|
50
|
+
written with a subnet mask collapses onto its network."""
|
|
47
51
|
return f"{self.kind.value}:{self.key}"
|
|
48
52
|
|
|
53
|
+
def exact_key(self) -> str:
|
|
54
|
+
"""Kind-qualified key grouping only *byte-identical* values — strict:
|
|
55
|
+
host bits are preserved, so `10.1.1.50/24` != `10.1.1.0/24`."""
|
|
56
|
+
return f"{self.kind.value}:{self.exact or self.key}"
|
|
57
|
+
|
|
49
58
|
|
|
50
59
|
def _as_network(value: str) -> IPNetwork | None:
|
|
51
60
|
try:
|
|
@@ -54,6 +63,16 @@ def _as_network(value: str) -> IPNetwork | None:
|
|
|
54
63
|
return None
|
|
55
64
|
|
|
56
65
|
|
|
66
|
+
def _as_interface(value: str) -> ipaddress.IPv4Interface | ipaddress.IPv6Interface | None:
|
|
67
|
+
"""Parse host-preserving: `10.1.1.50/24` keeps its host bits, `10.0.0.10`
|
|
68
|
+
becomes `10.0.0.10/32`. `.network` is the masked form (equals
|
|
69
|
+
`_as_network`), `str(...)` is the exact form. `None` if unparseable."""
|
|
70
|
+
try:
|
|
71
|
+
return ipaddress.ip_interface(value.strip())
|
|
72
|
+
except ValueError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
57
76
|
def _range_bounds(value: str) -> tuple[int, int, int] | None:
|
|
58
77
|
"""`a-b` -> (start_int, end_int, family); None if unparseable."""
|
|
59
78
|
if "-" not in value:
|
|
@@ -75,10 +94,19 @@ def normalize_address(addr: Address) -> AddrValue | None:
|
|
|
75
94
|
"""
|
|
76
95
|
v = addr.value.strip()
|
|
77
96
|
if addr.type is AddressType.IP_NETMASK:
|
|
78
|
-
|
|
79
|
-
|
|
97
|
+
# One parse yields both keys: `.network` is the masked grouping key
|
|
98
|
+
# (loose), `str(iface)` preserves host bits for the exact key (strict).
|
|
99
|
+
iface = _as_interface(v)
|
|
100
|
+
if iface is None:
|
|
80
101
|
return None
|
|
81
|
-
|
|
102
|
+
net = iface.network
|
|
103
|
+
return AddrValue(
|
|
104
|
+
kind=addr.type,
|
|
105
|
+
key=str(net),
|
|
106
|
+
network=net,
|
|
107
|
+
family=net.version,
|
|
108
|
+
exact=str(iface),
|
|
109
|
+
)
|
|
82
110
|
if addr.type is AddressType.IP_RANGE:
|
|
83
111
|
bounds = _range_bounds(v)
|
|
84
112
|
if bounds is None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "panorama-super-cli"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.4"
|
|
4
4
|
description = "Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -78,7 +78,8 @@ like `10.0.0.0/8` would otherwise drown out the host you asked for.
|
|
|
78
78
|
### dedup — duplicates and merging
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
|
-
psc -c cfg.xml -o json dedup addresses #
|
|
81
|
+
psc -c cfg.xml -o json dedup addresses # strict: byte-identical values only
|
|
82
|
+
psc -c cfg.xml -o json dedup addresses --not-strict # also mask host bits (10.1.1.50/24 ~ 10.1.1.0/24)
|
|
82
83
|
psc -c cfg.xml -o json dedup services
|
|
83
84
|
psc -c cfg.xml -o json dedup merge --keep h-web1 --remove web-primary # dry-run plan
|
|
84
85
|
psc -c cfg.xml dedup merge --keep h-web1 --remove web-primary --apply --out fixed.xml
|
|
@@ -64,6 +64,36 @@ def test_strict_not_found_exit_5() -> None:
|
|
|
64
64
|
assert json.loads(cp.stdout)["type"] == "not_found"
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
_HOST_AND_NET_CONFIG = """<?xml version="1.0"?>
|
|
68
|
+
<config version="11.0.0">
|
|
69
|
+
<shared>
|
|
70
|
+
<address>
|
|
71
|
+
<entry name="host-with-mask"><ip-netmask>10.1.1.50/24</ip-netmask></entry>
|
|
72
|
+
<entry name="real-network"><ip-netmask>10.1.1.0/24</ip-netmask></entry>
|
|
73
|
+
</address>
|
|
74
|
+
</shared>
|
|
75
|
+
</config>
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_dedup_addresses_strict_default_finds_no_duplicates(tmp_path: Path) -> None:
|
|
80
|
+
cfg = tmp_path / "cfg.xml"
|
|
81
|
+
cfg.write_text(_HOST_AND_NET_CONFIG)
|
|
82
|
+
cp = run("-c", str(cfg), "-o", "json", "dedup", "addresses")
|
|
83
|
+
assert cp.returncode == 0
|
|
84
|
+
assert json.loads(cp.stdout) == []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_dedup_addresses_not_strict_groups_host_with_network(tmp_path: Path) -> None:
|
|
88
|
+
cfg = tmp_path / "cfg.xml"
|
|
89
|
+
cfg.write_text(_HOST_AND_NET_CONFIG)
|
|
90
|
+
cp = run("-c", str(cfg), "-o", "json", "dedup", "addresses", "--not-strict")
|
|
91
|
+
assert cp.returncode == 0
|
|
92
|
+
groups = json.loads(cp.stdout)
|
|
93
|
+
assert len(groups) == 1
|
|
94
|
+
assert {m["name"] for m in groups[0]["members"]} == {"host-with-mask", "real-network"}
|
|
95
|
+
|
|
96
|
+
|
|
67
97
|
def test_merge_dry_run_exit_0_writes_nothing(tmp_path: Path) -> None:
|
|
68
98
|
out = tmp_path / "x.xml"
|
|
69
99
|
cp = run(
|
|
@@ -6,16 +6,45 @@ from psc.core.dedup import (
|
|
|
6
6
|
find_duplicate_services,
|
|
7
7
|
plan_merge,
|
|
8
8
|
)
|
|
9
|
-
from psc.core.models import Snapshot
|
|
9
|
+
from psc.core.models import Address, AddressType, Snapshot
|
|
10
10
|
from psc.core.refs import ReferenceGraph
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def _host_and_network() -> Snapshot:
|
|
14
|
+
"""A host written with a subnet mask and a real network object: identical
|
|
15
|
+
only after host-bit masking, so a strict dedup must keep them apart."""
|
|
16
|
+
return Snapshot(
|
|
17
|
+
addresses=[
|
|
18
|
+
Address(name="host", type=AddressType.IP_NETMASK, value="10.1.1.50/24"),
|
|
19
|
+
Address(name="net", type=AddressType.IP_NETMASK, value="10.1.1.0/24"),
|
|
20
|
+
]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
13
24
|
def test_duplicate_addresses_grouped_by_value(snapshot: Snapshot) -> None:
|
|
14
25
|
groups = {g.value: {m.name for m in g.members} for g in find_duplicate_addresses(snapshot)}
|
|
15
26
|
assert groups["ip-netmask 10.0.0.10/32"] == {"h-web1", "web-primary", "h-web1-slash"}
|
|
16
27
|
assert groups["ip-netmask 192.168.1.1/32"] == {"edge-dup", "local-only"}
|
|
17
28
|
|
|
18
29
|
|
|
30
|
+
def test_dedup_strict_default_excludes_host_with_mask() -> None:
|
|
31
|
+
# Strict (default): a /24-masked host is NOT a duplicate of the /24 network.
|
|
32
|
+
assert find_duplicate_addresses(_host_and_network()) == []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_dedup_not_strict_groups_host_with_mask() -> None:
|
|
36
|
+
# --not-strict restores the host-bit-masking behaviour for the fringe case.
|
|
37
|
+
groups = find_duplicate_addresses(_host_and_network(), strict=False)
|
|
38
|
+
assert len(groups) == 1
|
|
39
|
+
assert {m.name for m in groups[0].members} == {"host", "net"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_dedup_strict_still_groups_genuine_duplicates(snapshot: Snapshot) -> None:
|
|
43
|
+
# Strict must keep collapsing 10.0.0.10 and 10.0.0.10/32 — genuinely identical.
|
|
44
|
+
groups = {g.value: {m.name for m in g.members} for g in find_duplicate_addresses(snapshot)}
|
|
45
|
+
assert groups["ip-netmask 10.0.0.10/32"] == {"h-web1", "web-primary", "h-web1-slash"}
|
|
46
|
+
|
|
47
|
+
|
|
19
48
|
def test_duplicate_services(snapshot: Snapshot) -> None:
|
|
20
49
|
groups = find_duplicate_services(snapshot)
|
|
21
50
|
members = {m.name for g in groups for m in g.members}
|
|
@@ -51,6 +80,21 @@ def test_merge_blocks_value_mismatch(snapshot: Snapshot) -> None:
|
|
|
51
80
|
assert any("value mismatch" in b for b in cs.blockers)
|
|
52
81
|
|
|
53
82
|
|
|
83
|
+
def test_merge_blocks_host_vs_network_value_mismatch() -> None:
|
|
84
|
+
# The merge gate compares exact values: a /24-masked host and the /24
|
|
85
|
+
# network mean different things, so the merge is blocked without --force.
|
|
86
|
+
snap = _host_and_network()
|
|
87
|
+
graph = ReferenceGraph.build(snap)
|
|
88
|
+
cs = plan_merge(
|
|
89
|
+
snap,
|
|
90
|
+
graph,
|
|
91
|
+
keep=ObjectRef(name="net", location="shared"),
|
|
92
|
+
drop=ObjectRef(name="host", location="shared"),
|
|
93
|
+
)
|
|
94
|
+
assert cs.is_blocked
|
|
95
|
+
assert any("value mismatch" in b for b in cs.blockers)
|
|
96
|
+
|
|
97
|
+
|
|
54
98
|
def test_merge_allows_value_change_when_forced(snapshot: Snapshot) -> None:
|
|
55
99
|
graph = ReferenceGraph.build(snapshot)
|
|
56
100
|
cs = plan_merge(
|
|
@@ -15,6 +15,33 @@ def test_host_and_slash32_normalize_equal() -> None:
|
|
|
15
15
|
assert a.overlaps_key() == b.overlaps_key()
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def test_exact_key_preserves_host_bits() -> None:
|
|
19
|
+
# Same masked network, different host bits: the loose key collapses them,
|
|
20
|
+
# the strict (exact) key keeps them apart.
|
|
21
|
+
host = normalize_address(_addr("10.1.1.50/24"))
|
|
22
|
+
net = normalize_address(_addr("10.1.1.0/24"))
|
|
23
|
+
assert host is not None and net is not None
|
|
24
|
+
assert host.overlaps_key() == net.overlaps_key()
|
|
25
|
+
assert host.exact_key() != net.exact_key()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_exact_key_unifies_host_and_slash32() -> None:
|
|
29
|
+
# A bare host and its /32 are genuinely identical under the strict key too.
|
|
30
|
+
a = normalize_address(_addr("10.0.0.10"))
|
|
31
|
+
b = normalize_address(_addr("10.0.0.10/32"))
|
|
32
|
+
assert a is not None and b is not None
|
|
33
|
+
assert a.exact_key() == b.exact_key()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_exact_key_canonicalizes_ipv6_forms() -> None:
|
|
37
|
+
# Strict must still group genuinely identical objects written differently:
|
|
38
|
+
# `0:0:0:0:0:0:0:1` and `::1` are the same host.
|
|
39
|
+
a = normalize_address(_addr("0:0:0:0:0:0:0:1"))
|
|
40
|
+
b = normalize_address(_addr("::1"))
|
|
41
|
+
assert a is not None and b is not None
|
|
42
|
+
assert a.exact_key() == b.exact_key()
|
|
43
|
+
|
|
44
|
+
|
|
18
45
|
def test_exact_contains_within() -> None:
|
|
19
46
|
host = normalize_address(_addr("10.0.0.10/32"))
|
|
20
47
|
net = normalize_address(_addr("10.0.0.0/24"))
|
|
File without changes
|
|
File without changes
|
{panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/ISSUE_TEMPLATE/feature_request.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/fixtures/nested-device-groups.xml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|