panorama-super-cli 0.2.3__tar.gz → 0.2.5__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.5}/CHANGELOG.md +24 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/PKG-INFO +1 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/duplicates-and-merging.md +21 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/reference/cli.md +1 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/_version.py +1 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/dedup_cmds.py +16 -3
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/find_cmds.py +3 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/dedup.py +17 -5
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/normalize.py +32 -4
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/output/format.py +11 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/pyproject.toml +1 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/skills/panorama-super-cli/SKILL.md +2 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_cli.py +38 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_dedup.py +45 -1
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_normalize.py +27 -0
- panorama_super_cli-0.2.5/tests/test_output.py +65 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/uv.lock +577 -577
- panorama_super_cli-0.2.3/tests/test_output.py +0 -35
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/CODEOWNERS +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/dependabot.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/workflows/docs.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/workflows/lint.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/workflows/release.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/workflows/test.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.gitignore +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.pre-commit-config.yaml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.python-version +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/AGENTS.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/CLAUDE.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/LICENSE +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/README.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/contributing/branching.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/contributing/development.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/contributing/release-process.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/getting-started/concepts.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/getting-started/first-run.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/getting-started/install.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/finding-objects.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/live-vs-offline.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/naming.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/output-formats.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/references-and-audit.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/safety.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/using-with-ai-agents.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/index.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/reference/config.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/reference/exit-codes.md +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/justfile +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/mkdocs.yml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/__main__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/_plan.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/app.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/auth_cmds.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/name_cmds.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/profile_cmds.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/refs_cmds.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/runtime.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/config/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/config/loader.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/config/models.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/apply_live.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/apply_xml.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/changeset.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/models.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/naming.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/parse.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/refs.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/resolve.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/setcmd.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/source.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/output/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/output/errors.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/scripts/sync_agents_md.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/__init__.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/conftest.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/fixtures/nested-device-groups.xml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/fixtures/panorama-config.xml +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_apply_xml.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_auth.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_changeset.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_cli_auth.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_hardening.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_live_apply.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_naming.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_nested_dg.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_parse.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_refs.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_resolve.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_setcmd.py +0 -0
- {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_source.py +0 -0
|
@@ -7,6 +7,30 @@ project will follow [Semantic Versioning](https://semver.org/). While on
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## v0.2.5 — 2026-06-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`psc find ip` table output now separates each target's matches with a
|
|
15
|
+
horizontal rule** (#43). Resolving several targets at once (notably with
|
|
16
|
+
`--file`) previously printed every match as one undifferentiated block, so it
|
|
17
|
+
was hard to see where one target's matches ended and the next began. The
|
|
18
|
+
table now draws a rule between per-target blocks. Machine output formats
|
|
19
|
+
(json/jsonl/yaml/csv/set) are unchanged.
|
|
20
|
+
|
|
21
|
+
## v0.2.4 — 2026-06-04
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **`psc dedup addresses` is now strict by default** (#38). Addresses are
|
|
26
|
+
grouped only when their values are byte-identical, so a host accidentally
|
|
27
|
+
written with a subnet mask (`10.1.1.50/24`) is no longer reported as a
|
|
28
|
+
duplicate of the network `10.1.1.0/24` — merging those would have silently
|
|
29
|
+
changed rule matching. Genuinely identical forms (`10.0.0.10` and
|
|
30
|
+
`10.0.0.10/32`) still group. Pass `--not-strict` for the previous
|
|
31
|
+
host-bit-masking behaviour. The `dedup merge` safety gate now compares exact
|
|
32
|
+
values too, so such a merge is blocked unless `--allow-value-change` is given.
|
|
33
|
+
|
|
10
34
|
## v0.2.3 — 2026-06-04
|
|
11
35
|
|
|
12
36
|
### 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.5
|
|
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(
|
|
@@ -78,7 +78,9 @@ def ip(
|
|
|
78
78
|
raise PscError("no matching objects", ErrorType.NOT_FOUND)
|
|
79
79
|
|
|
80
80
|
model = results if len(results) != 1 else results[0]
|
|
81
|
-
|
|
81
|
+
# Group the table by query so multi-target output (e.g. --file) draws a rule
|
|
82
|
+
# between each target's matches instead of one undifferentiated block.
|
|
83
|
+
render(rt.stdout, rt.output, model=model, rows=rows, table_title="find ip", group_by="query")
|
|
82
84
|
|
|
83
85
|
|
|
84
86
|
@app.command("object")
|
|
@@ -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:
|
|
@@ -104,12 +104,17 @@ def render(
|
|
|
104
104
|
rows: list[dict[str, Any]] | None = None,
|
|
105
105
|
set_lines: list[str] | None = None,
|
|
106
106
|
table_title: str | None = None,
|
|
107
|
+
group_by: str | None = None,
|
|
107
108
|
) -> None:
|
|
108
109
|
"""Print `model`/`rows`/`set_lines` in `fmt` to `console`.
|
|
109
110
|
|
|
110
111
|
The console must be the *stdout* console; errors go elsewhere. Table output
|
|
111
112
|
is the only format that uses rich styling — every other format is plain text
|
|
112
113
|
so pipes and captures stay clean.
|
|
114
|
+
|
|
115
|
+
`group_by` is a table-only hint: a column name whose changing value marks a
|
|
116
|
+
new block. A horizontal rule is drawn between blocks so multi-target output
|
|
117
|
+
(e.g. `find ip --file`) is easy to scan. It does not affect machine formats.
|
|
113
118
|
"""
|
|
114
119
|
# `soft_wrap=True` is critical: without it rich wraps long lines to the
|
|
115
120
|
# console width and injects newlines *inside* JSON string values, producing
|
|
@@ -135,8 +140,13 @@ def render(
|
|
|
135
140
|
table = Table(title=table_title, header_style="bold cyan")
|
|
136
141
|
for col in rows[0]:
|
|
137
142
|
table.add_column(col)
|
|
138
|
-
|
|
143
|
+
prev: Any = None
|
|
144
|
+
for i, row in enumerate(rows):
|
|
145
|
+
if group_by and i > 0 and row.get(group_by) != prev:
|
|
146
|
+
table.add_section()
|
|
139
147
|
table.add_row(*[_cell(row.get(col)) for col in rows[0]])
|
|
148
|
+
if group_by:
|
|
149
|
+
prev = row.get(group_by)
|
|
140
150
|
console.print(table)
|
|
141
151
|
else:
|
|
142
152
|
console.print(_json(model), markup=False, highlight=False)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "panorama-super-cli"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.5"
|
|
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,44 @@ def test_strict_not_found_exit_5() -> None:
|
|
|
64
64
|
assert json.loads(cp.stdout)["type"] == "not_found"
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def test_find_ip_table_separates_multiple_targets() -> None:
|
|
68
|
+
# Issue #43: multi-target table output must draw a rule between each
|
|
69
|
+
# target's matches. The interior divider uses box-drawing '├'.
|
|
70
|
+
cp = run("-c", str(FIXTURE), "-o", "table", "find", "ip", "10.0.0.10", "10.0.0.99")
|
|
71
|
+
assert cp.returncode == 0
|
|
72
|
+
assert "├" in cp.stdout
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_HOST_AND_NET_CONFIG = """<?xml version="1.0"?>
|
|
76
|
+
<config version="11.0.0">
|
|
77
|
+
<shared>
|
|
78
|
+
<address>
|
|
79
|
+
<entry name="host-with-mask"><ip-netmask>10.1.1.50/24</ip-netmask></entry>
|
|
80
|
+
<entry name="real-network"><ip-netmask>10.1.1.0/24</ip-netmask></entry>
|
|
81
|
+
</address>
|
|
82
|
+
</shared>
|
|
83
|
+
</config>
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_dedup_addresses_strict_default_finds_no_duplicates(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")
|
|
91
|
+
assert cp.returncode == 0
|
|
92
|
+
assert json.loads(cp.stdout) == []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_dedup_addresses_not_strict_groups_host_with_network(tmp_path: Path) -> None:
|
|
96
|
+
cfg = tmp_path / "cfg.xml"
|
|
97
|
+
cfg.write_text(_HOST_AND_NET_CONFIG)
|
|
98
|
+
cp = run("-c", str(cfg), "-o", "json", "dedup", "addresses", "--not-strict")
|
|
99
|
+
assert cp.returncode == 0
|
|
100
|
+
groups = json.loads(cp.stdout)
|
|
101
|
+
assert len(groups) == 1
|
|
102
|
+
assert {m["name"] for m in groups[0]["members"]} == {"host-with-mask", "real-network"}
|
|
103
|
+
|
|
104
|
+
|
|
67
105
|
def test_merge_dry_run_exit_0_writes_nothing(tmp_path: Path) -> None:
|
|
68
106
|
out = tmp_path / "x.xml"
|
|
69
107
|
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"))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from psc.core.models import SHARED, Location
|
|
9
|
+
from psc.core.refs import Reference, Target
|
|
10
|
+
from psc.output.format import OutputFormat, render, to_jsonable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_location_serializes_as_name() -> None:
|
|
14
|
+
assert to_jsonable(SHARED) == "shared"
|
|
15
|
+
assert to_jsonable(Location.dg("DG1")) == "DG1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_location_roundtrips_from_string() -> None:
|
|
19
|
+
loc = Location.model_validate("DG1")
|
|
20
|
+
assert loc.device_group == "DG1"
|
|
21
|
+
assert Location.model_validate("shared").is_shared
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_dataclass_is_jsonable() -> None:
|
|
25
|
+
ref = Reference(
|
|
26
|
+
target_name="x",
|
|
27
|
+
namespace="address",
|
|
28
|
+
referrer_kind="security-rule",
|
|
29
|
+
referrer_name="r",
|
|
30
|
+
referrer_location=SHARED,
|
|
31
|
+
field="source",
|
|
32
|
+
resolved=Target("address", "x", SHARED),
|
|
33
|
+
)
|
|
34
|
+
payload = to_jsonable(ref)
|
|
35
|
+
# round-trips through json without error
|
|
36
|
+
json.dumps(payload)
|
|
37
|
+
assert payload["resolved"]["location"] == "shared"
|
|
38
|
+
assert payload["referrer_location"] == "shared"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _render_table(rows: list[dict[str, object]], **kw: object) -> str:
|
|
42
|
+
buf = io.StringIO()
|
|
43
|
+
console = Console(file=buf, width=120, force_terminal=False, color_system=None)
|
|
44
|
+
render(console, OutputFormat.TABLE, rows=rows, table_title="t", **kw)
|
|
45
|
+
return buf.getvalue()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_table_group_by_inserts_divider_between_groups() -> None:
|
|
49
|
+
rows = [
|
|
50
|
+
{"query": "10.0.0.1", "object": "a"},
|
|
51
|
+
{"query": "10.0.0.1", "object": "b"},
|
|
52
|
+
{"query": "10.0.0.2", "object": "c"},
|
|
53
|
+
]
|
|
54
|
+
plain = _render_table(rows)
|
|
55
|
+
grouped = _render_table(rows, group_by="query")
|
|
56
|
+
# Grouping draws one extra horizontal rule between the two query blocks.
|
|
57
|
+
assert grouped.count("\n") == plain.count("\n") + 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_table_group_by_single_group_no_extra_divider() -> None:
|
|
61
|
+
rows = [
|
|
62
|
+
{"query": "10.0.0.1", "object": "a"},
|
|
63
|
+
{"query": "10.0.0.1", "object": "b"},
|
|
64
|
+
]
|
|
65
|
+
assert _render_table(rows, group_by="query") == _render_table(rows)
|