panorama-super-cli 0.2.0__tar.gz → 0.2.2__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.0 → panorama_super_cli-0.2.2}/.github/workflows/docs.yml +4 -4
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/workflows/lint.yml +4 -4
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/workflows/release.yml +2 -2
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/workflows/test.yml +2 -2
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/CHANGELOG.md +30 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/PKG-INFO +2 -2
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/README.md +1 -1
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/_version.py +1 -1
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/app.py +37 -5
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/find_cmds.py +6 -1
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/changeset.py +53 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/dedup.py +14 -13
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/naming.py +15 -1
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/resolve.py +15 -6
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/pyproject.toml +1 -1
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/skills/panorama-super-cli/SKILL.md +3 -0
- panorama_super_cli-0.2.2/tests/test_changeset.py +172 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_cli.py +68 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_dedup.py +19 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_naming.py +19 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_resolve.py +30 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/uv.lock +1 -1
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/CODEOWNERS +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/dependabot.yml +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.gitignore +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.pre-commit-config.yaml +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.python-version +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/AGENTS.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/CLAUDE.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/LICENSE +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/contributing/branching.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/contributing/development.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/contributing/release-process.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/getting-started/concepts.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/getting-started/first-run.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/getting-started/install.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/duplicates-and-merging.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/finding-objects.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/live-vs-offline.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/naming.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/output-formats.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/references-and-audit.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/safety.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/using-with-ai-agents.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/index.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/reference/cli.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/reference/config.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/reference/exit-codes.md +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/justfile +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/mkdocs.yml +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/__init__.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/__main__.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/__init__.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/_plan.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/auth_cmds.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/dedup_cmds.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/name_cmds.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/profile_cmds.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/refs_cmds.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/runtime.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/config/__init__.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/config/loader.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/config/models.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/__init__.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/apply_live.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/apply_xml.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/models.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/normalize.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/parse.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/refs.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/setcmd.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/source.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/output/__init__.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/output/errors.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/output/format.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/scripts/sync_agents_md.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/__init__.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/conftest.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/fixtures/nested-device-groups.xml +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/fixtures/panorama-config.xml +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_apply_xml.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_auth.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_cli_auth.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_hardening.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_live_apply.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_nested_dg.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_normalize.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_output.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_parse.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_refs.py +0 -0
- {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_setcmd.py +0 -0
|
@@ -23,9 +23,9 @@ jobs:
|
|
|
23
23
|
build:
|
|
24
24
|
runs-on: ubuntu-latest
|
|
25
25
|
steps:
|
|
26
|
-
- uses: actions/checkout@
|
|
26
|
+
- uses: actions/checkout@v6
|
|
27
27
|
- name: Install uv
|
|
28
|
-
uses: astral-sh/setup-uv@
|
|
28
|
+
uses: astral-sh/setup-uv@v7
|
|
29
29
|
with:
|
|
30
30
|
enable-cache: true
|
|
31
31
|
- name: Install Python
|
|
@@ -36,7 +36,7 @@ jobs:
|
|
|
36
36
|
run: uv run mkdocs build --strict
|
|
37
37
|
- name: Upload Pages artifact
|
|
38
38
|
if: github.ref_type == 'tag'
|
|
39
|
-
uses: actions/upload-pages-artifact@
|
|
39
|
+
uses: actions/upload-pages-artifact@v5
|
|
40
40
|
with:
|
|
41
41
|
path: site
|
|
42
42
|
|
|
@@ -49,4 +49,4 @@ jobs:
|
|
|
49
49
|
url: ${{ steps.deployment.outputs.page_url }}
|
|
50
50
|
steps:
|
|
51
51
|
- id: deployment
|
|
52
|
-
uses: actions/deploy-pages@
|
|
52
|
+
uses: actions/deploy-pages@v5
|
|
@@ -9,9 +9,9 @@ jobs:
|
|
|
9
9
|
lint:
|
|
10
10
|
runs-on: ubuntu-latest
|
|
11
11
|
steps:
|
|
12
|
-
- uses: actions/checkout@
|
|
12
|
+
- uses: actions/checkout@v6
|
|
13
13
|
- name: Install uv
|
|
14
|
-
uses: astral-sh/setup-uv@
|
|
14
|
+
uses: astral-sh/setup-uv@v7
|
|
15
15
|
with:
|
|
16
16
|
enable-cache: true
|
|
17
17
|
- name: Install Python
|
|
@@ -28,9 +28,9 @@ jobs:
|
|
|
28
28
|
agents-md-fresh:
|
|
29
29
|
runs-on: ubuntu-latest
|
|
30
30
|
steps:
|
|
31
|
-
- uses: actions/checkout@
|
|
31
|
+
- uses: actions/checkout@v6
|
|
32
32
|
- name: Install uv
|
|
33
|
-
uses: astral-sh/setup-uv@
|
|
33
|
+
uses: astral-sh/setup-uv@v7
|
|
34
34
|
with:
|
|
35
35
|
enable-cache: true
|
|
36
36
|
- name: Install Python
|
|
@@ -25,7 +25,7 @@ jobs:
|
|
|
25
25
|
# no-environment form.
|
|
26
26
|
steps:
|
|
27
27
|
- name: Checkout (full history for changelog notes)
|
|
28
|
-
uses: actions/checkout@
|
|
28
|
+
uses: actions/checkout@v6
|
|
29
29
|
with:
|
|
30
30
|
fetch-depth: 0
|
|
31
31
|
|
|
@@ -40,7 +40,7 @@ jobs:
|
|
|
40
40
|
echo "Tag $GITHUB_REF_NAME ($GITHUB_SHA) is on main."
|
|
41
41
|
|
|
42
42
|
- name: Install uv
|
|
43
|
-
uses: astral-sh/setup-uv@
|
|
43
|
+
uses: astral-sh/setup-uv@v7
|
|
44
44
|
with:
|
|
45
45
|
enable-cache: true
|
|
46
46
|
|
|
@@ -14,9 +14,9 @@ jobs:
|
|
|
14
14
|
os: [ubuntu-latest, macos-latest]
|
|
15
15
|
python: ["3.12", "3.13"]
|
|
16
16
|
steps:
|
|
17
|
-
- uses: actions/checkout@
|
|
17
|
+
- uses: actions/checkout@v6
|
|
18
18
|
- name: Install uv
|
|
19
|
-
uses: astral-sh/setup-uv@
|
|
19
|
+
uses: astral-sh/setup-uv@v7
|
|
20
20
|
with:
|
|
21
21
|
enable-cache: true
|
|
22
22
|
- name: Install Python
|
|
@@ -7,6 +7,36 @@ project will follow [Semantic Versioning](https://semver.org/). While on
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## v0.2.2 — 2026-06-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Repoint-before-delete no longer skipped silently.** A merge or rename whose
|
|
15
|
+
only safe repoint is a reference the appliers can't express as a flat member
|
|
16
|
+
rewrite — a NAT source/destination *translation* field, or a rule edit with no
|
|
17
|
+
rulebase — is now refused with a `blocker` (exit 6) on both the offline and
|
|
18
|
+
live paths, naming the object and the unmappable referrer. Previously the edit
|
|
19
|
+
was dropped while the delete/rename still ran, leaving a dangling reference.
|
|
20
|
+
Advisory unmappable edits with no accompanying teardown still pass with a
|
|
21
|
+
warning. (#28)
|
|
22
|
+
- **`psc` with no arguments (and unknown commands) print help, not a traceback.**
|
|
23
|
+
Typer 0.16+ vendors its own Click, so the exceptions the entry point raised
|
|
24
|
+
were not the `click.*` the wrapper caught and escaped as a traceback ending in
|
|
25
|
+
`NoArgsIsHelpError`. The wrapper now handles both the vendored and the real
|
|
26
|
+
Click hierarchies, resolving the module defensively so psc still imports on
|
|
27
|
+
older Typer. No-args/`--help` exit 0; usage errors exit 2. (#31)
|
|
28
|
+
|
|
29
|
+
## v0.2.1 — 2026-06-04
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **`find ip --exact` / `-e`.** Restrict `find ip` to objects whose value
|
|
34
|
+
equals the target exactly, dropping the broader (`contains`) and narrower
|
|
35
|
+
(`within`) matches that a host query normally also surfaces. Netmask and
|
|
36
|
+
bare-host forms still canonicalize equal, so `10.0.0.10` and `10.0.0.10/32`
|
|
37
|
+
remain exact matches of each other. Address-groups are reported only when
|
|
38
|
+
they carry an exact match. (#30)
|
|
39
|
+
|
|
10
40
|
## v0.2.0 — 2026-06-04
|
|
11
41
|
|
|
12
42
|
### 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.2
|
|
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/
|
|
@@ -81,7 +81,7 @@ pip install panorama-super-cli
|
|
|
81
81
|
|
|
82
82
|
| Area | Commands (v0.1) |
|
|
83
83
|
| --- | --- |
|
|
84
|
-
| **Find / resolve** | `psc find ip <ip>`, `find ip -f ips.txt`, `find object <name>` |
|
|
84
|
+
| **Find / resolve** | `psc find ip <ip>`, `find ip -e <ip>` (exact only), `find ip -f ips.txt`, `find object <name>` |
|
|
85
85
|
| **Duplicates** | `psc dedup addresses`, `dedup services`, `dedup merge` |
|
|
86
86
|
| **Naming** | `psc name suggest`, `name lint`, `name apply` (opt-in templates) |
|
|
87
87
|
| **References** | `psc refs <object>` (where-used), `refs unused` |
|
|
@@ -50,7 +50,7 @@ pip install panorama-super-cli
|
|
|
50
50
|
|
|
51
51
|
| Area | Commands (v0.1) |
|
|
52
52
|
| --- | --- |
|
|
53
|
-
| **Find / resolve** | `psc find ip <ip>`, `find ip -f ips.txt`, `find object <name>` |
|
|
53
|
+
| **Find / resolve** | `psc find ip <ip>`, `find ip -e <ip>` (exact only), `find ip -f ips.txt`, `find object <name>` |
|
|
54
54
|
| **Duplicates** | `psc dedup addresses`, `dedup services`, `dedup merge` |
|
|
55
55
|
| **Naming** | `psc name suggest`, `name lint`, `name apply` (opt-in templates) |
|
|
56
56
|
| **References** | `psc refs <object>` (where-used), `refs unused` |
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
from types import ModuleType
|
|
6
7
|
|
|
7
8
|
import click
|
|
8
9
|
import typer
|
|
@@ -15,6 +16,28 @@ from psc.config.loader import load_config
|
|
|
15
16
|
from psc.output.errors import PscError
|
|
16
17
|
from psc.output.format import OutputFormat
|
|
17
18
|
|
|
19
|
+
|
|
20
|
+
def _click_exception_module() -> ModuleType:
|
|
21
|
+
"""The Click `exceptions` module whose flavour matches what `app(...)` raises.
|
|
22
|
+
|
|
23
|
+
Typer >=0.16 vendors its own Click under `typer._click`, so the exceptions
|
|
24
|
+
`app(standalone_mode=False)` raises are not subclasses of the real `click.*`
|
|
25
|
+
(issue #31). Older Typer has no `typer._click` at all — importing it at
|
|
26
|
+
module top level would crash psc on *import*, breaking every command, which
|
|
27
|
+
is strictly worse than the bug we're fixing. Resolve defensively: prefer the
|
|
28
|
+
vendored module, fall back to the real Click (which older Typer raises).
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
from typer._click import exceptions as vendored # noqa: PLC0415 — conditional
|
|
32
|
+
except ImportError:
|
|
33
|
+
from click import exceptions as real # noqa: PLC0415 — fallback for older Typer
|
|
34
|
+
|
|
35
|
+
return real
|
|
36
|
+
return vendored
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_typer_click_exc = _click_exception_module()
|
|
40
|
+
|
|
18
41
|
app = typer.Typer(
|
|
19
42
|
add_completion=True,
|
|
20
43
|
no_args_is_help=True,
|
|
@@ -125,18 +148,27 @@ def _emit_error(err: PscError) -> None:
|
|
|
125
148
|
|
|
126
149
|
|
|
127
150
|
def main() -> None:
|
|
151
|
+
# Typer 0.26 vendors its own Click (`typer._click`), so the exceptions
|
|
152
|
+
# `app(standalone_mode=False)` raises are NOT subclasses of the real
|
|
153
|
+
# `click.*`. Catch both flavours, or a no-args/`--help`/bad-command run
|
|
154
|
+
# escapes uncaught and prints a traceback instead of help (#31).
|
|
128
155
|
try:
|
|
129
156
|
app(standalone_mode=False)
|
|
130
157
|
except PscError as err:
|
|
131
158
|
_emit_error(err)
|
|
132
159
|
raise SystemExit(err.exit_code) from None
|
|
133
|
-
except click.
|
|
134
|
-
err.show()
|
|
135
|
-
raise SystemExit(2) from None
|
|
136
|
-
except (click.exceptions.Abort, KeyboardInterrupt):
|
|
160
|
+
except (KeyboardInterrupt, click.exceptions.Abort, _typer_click_exc.Abort):
|
|
137
161
|
Console(stderr=True).print("aborted")
|
|
138
162
|
raise SystemExit(130) from None
|
|
139
|
-
except click.exceptions.Exit as err:
|
|
163
|
+
except (click.exceptions.Exit, _typer_click_exc.Exit) as err:
|
|
164
|
+
raise SystemExit(err.exit_code) from None
|
|
165
|
+
except (click.ClickException, _typer_click_exc.ClickException) as err:
|
|
166
|
+
# `no_args_is_help` (and `--help`) already printed the help text; the
|
|
167
|
+
# raise is just a stop signal, so exit 0 rather than rendering it as an
|
|
168
|
+
# error. Genuine usage errors still print and exit non-zero.
|
|
169
|
+
if err.__class__.__name__ == "NoArgsIsHelpError":
|
|
170
|
+
raise SystemExit(0) from None
|
|
171
|
+
err.show()
|
|
140
172
|
raise SystemExit(err.exit_code) from None
|
|
141
173
|
|
|
142
174
|
|
|
@@ -21,11 +21,16 @@ def ip(
|
|
|
21
21
|
file: Path | None = typer.Option(
|
|
22
22
|
None, "--file", "-f", help="Read targets from a file (one per line; # comments)."
|
|
23
23
|
),
|
|
24
|
+
exact: bool = typer.Option(
|
|
25
|
+
False, "--exact", "-e", help="Only exact matches (drop contains/within)."
|
|
26
|
+
),
|
|
24
27
|
) -> None:
|
|
25
28
|
"""Find which address objects/groups match an IP (or a whole list).
|
|
26
29
|
|
|
27
30
|
Reports exact matches, broader objects that *contain* the target, and
|
|
28
31
|
narrower objects *within* it, plus the address-groups that carry them.
|
|
32
|
+
With --exact, only objects equal to the target are reported (netmask and
|
|
33
|
+
bare-host forms still count as equal, e.g. 10.0.0.10 == 10.0.0.10/32).
|
|
29
34
|
"""
|
|
30
35
|
rt: Runtime = ctx.obj
|
|
31
36
|
snap = rt.snapshot()
|
|
@@ -43,7 +48,7 @@ def ip(
|
|
|
43
48
|
"provide one or more IP/CIDR/range/FQDN targets, or --file", ErrorType.VALIDATION
|
|
44
49
|
)
|
|
45
50
|
|
|
46
|
-
results = find_ips(snap, items, rt.scope())
|
|
51
|
+
results = find_ips(snap, items, rt.scope(), exact=exact)
|
|
47
52
|
rows: list[dict[str, object]] = []
|
|
48
53
|
for res in results:
|
|
49
54
|
if not res.matches:
|
|
@@ -125,3 +125,56 @@ class ChangeSet(BaseModel):
|
|
|
125
125
|
out += [r.summary for r in self.renames]
|
|
126
126
|
out += [d.summary for d in self.deletes]
|
|
127
127
|
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def reference_edit_is_mappable(edit: ReferenceEdit) -> bool:
|
|
131
|
+
"""Whether both appliers can express `edit` as a flat member-field rewrite.
|
|
132
|
+
|
|
133
|
+
The single source of truth behind `apply_xml._referrer_field_element` and
|
|
134
|
+
`apply_live._referrer_field_xpath`: a member field they know how to address
|
|
135
|
+
(a group's `static`/`members`, a security-rule field, a NAT src/dst list).
|
|
136
|
+
A NAT *translation* field is nested with no flat list, and a rule edit with
|
|
137
|
+
no `rulebase` can't be located — both are unmappable, and an applier silently
|
|
138
|
+
skips them. The planner uses this to refuse such a skip when the same plan
|
|
139
|
+
tears the target down, instead of leaving a dangling reference (#28).
|
|
140
|
+
"""
|
|
141
|
+
kind = edit.referrer_kind
|
|
142
|
+
if kind in ("address-group", "service-group"):
|
|
143
|
+
return True
|
|
144
|
+
if kind == "security-rule":
|
|
145
|
+
return edit.rulebase is not None
|
|
146
|
+
if kind == "nat-rule":
|
|
147
|
+
return edit.rulebase is not None and edit.field in ("source", "destination")
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def gate_unmappable_reference_edits(cs: ChangeSet) -> None:
|
|
152
|
+
"""Promote unsafe unmappable reference edits to blockers, in place.
|
|
153
|
+
|
|
154
|
+
An unmappable edit (see `reference_edit_is_mappable`) is silently skipped at
|
|
155
|
+
apply time. If the plan also deletes or renames an object, that skip drops
|
|
156
|
+
the repoint that kept the teardown safe — a dangling reference, the exact
|
|
157
|
+
failure repoint-before-delete exists to prevent. Make it a hard blocker. With
|
|
158
|
+
no teardown the edit is advisory only, so it stays a warning.
|
|
159
|
+
|
|
160
|
+
Clearing the ops is the planner's job (a blocked plan carries zero ops); this
|
|
161
|
+
only classifies.
|
|
162
|
+
"""
|
|
163
|
+
unmappable = [e for e in cs.reference_edits if not reference_edit_is_mappable(e)]
|
|
164
|
+
if not unmappable:
|
|
165
|
+
return
|
|
166
|
+
torn_down = [d.name for d in cs.deletes] + [r.old_name for r in cs.renames]
|
|
167
|
+
for e in unmappable:
|
|
168
|
+
referrer = f"{e.referrer_kind} '{e.referrer_name}'@{e.referrer_location} {e.field}"
|
|
169
|
+
if torn_down:
|
|
170
|
+
targets = ", ".join(f"'{t}'" for t in torn_down)
|
|
171
|
+
cs.blockers.append(
|
|
172
|
+
f"cannot repoint {referrer} automatically (no flat member list to rewrite); "
|
|
173
|
+
f"removing/renaming {targets} would leave a dangling reference — edit that "
|
|
174
|
+
"field manually, then re-run"
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
cs.warnings.append(
|
|
178
|
+
f"{referrer} can't be repointed automatically (no flat member list); "
|
|
179
|
+
"review and edit it by hand"
|
|
180
|
+
)
|
|
@@ -14,7 +14,13 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
from pydantic import BaseModel, Field
|
|
16
16
|
|
|
17
|
-
from psc.core.changeset import
|
|
17
|
+
from psc.core.changeset import (
|
|
18
|
+
ChangeSet,
|
|
19
|
+
ObjectDelete,
|
|
20
|
+
ObjectKind,
|
|
21
|
+
ReferenceEdit,
|
|
22
|
+
gate_unmappable_reference_edits,
|
|
23
|
+
)
|
|
18
24
|
from psc.core.models import Location, Snapshot
|
|
19
25
|
from psc.core.normalize import normalize_address, service_key
|
|
20
26
|
from psc.core.refs import Reference, ReferenceGraph
|
|
@@ -155,23 +161,18 @@ def plan_merge(
|
|
|
155
161
|
cs.warnings.append(
|
|
156
162
|
f"{ref.referrer_kind} '{ref.referrer_name}' {ref.field} would be emptied"
|
|
157
163
|
)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
f"{ref.field}; psc can't safely rewrite NAT translation offline — "
|
|
165
|
-
"edit that field manually, then re-run the merge"
|
|
166
|
-
)
|
|
164
|
+
|
|
165
|
+
cs.deletes.append(ObjectDelete(kind=ObjectKind.ADDRESS, name=drop.name, location=drop.location))
|
|
166
|
+
# Refuse any repoint the appliers would silently skip (e.g. a NAT translation
|
|
167
|
+
# field) now that the delete is in the plan — a skipped repoint + delete is a
|
|
168
|
+
# dangling reference. Mirrors `plan_rename`; offline and live share the gate.
|
|
169
|
+
gate_unmappable_reference_edits(cs)
|
|
167
170
|
|
|
168
171
|
if cs.blockers:
|
|
169
172
|
# Invariant: a blocked plan carries zero ops, so no consumer can execute
|
|
170
173
|
# a partial rewrite by iterating ops without checking `is_blocked`.
|
|
171
174
|
cs.reference_edits.clear()
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
cs.deletes.append(ObjectDelete(kind=ObjectKind.ADDRESS, name=drop.name, location=drop.location))
|
|
175
|
+
cs.deletes.clear()
|
|
175
176
|
return cs
|
|
176
177
|
|
|
177
178
|
|
|
@@ -15,7 +15,13 @@ import re
|
|
|
15
15
|
|
|
16
16
|
from pydantic import BaseModel
|
|
17
17
|
|
|
18
|
-
from psc.core.changeset import
|
|
18
|
+
from psc.core.changeset import (
|
|
19
|
+
ChangeSet,
|
|
20
|
+
ObjectKind,
|
|
21
|
+
ObjectRename,
|
|
22
|
+
ReferenceEdit,
|
|
23
|
+
gate_unmappable_reference_edits,
|
|
24
|
+
)
|
|
19
25
|
from psc.core.dedup import field_members
|
|
20
26
|
from psc.core.models import Address, AddressType, Location, Service, Snapshot
|
|
21
27
|
from psc.core.refs import ReferenceGraph
|
|
@@ -182,4 +188,12 @@ def plan_rename(
|
|
|
182
188
|
cs.renames.append(
|
|
183
189
|
ObjectRename(kind=kind, location=location_name, old_name=old_name, new_name=new_name)
|
|
184
190
|
)
|
|
191
|
+
# Refuse any repoint the appliers would silently skip (e.g. a NAT translation
|
|
192
|
+
# field): skipping it while renaming the object away leaves a dangling
|
|
193
|
+
# reference. Shared gate keeps offline and live identical (#28).
|
|
194
|
+
gate_unmappable_reference_edits(cs)
|
|
195
|
+
if cs.blockers:
|
|
196
|
+
# Invariant: a blocked plan carries zero ops (see `plan_merge`).
|
|
197
|
+
cs.reference_edits.clear()
|
|
198
|
+
cs.renames.clear()
|
|
185
199
|
return cs
|
|
@@ -58,9 +58,17 @@ def _visible_names(snapshot: Snapshot, scope: Location | None) -> set[str] | Non
|
|
|
58
58
|
return {loc.name for loc in snapshot.ancestors(scope)}
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def find_ip(
|
|
61
|
+
def find_ip(
|
|
62
|
+
snapshot: Snapshot, raw: str, scope: Location | None = None, *, exact: bool = False
|
|
63
|
+
) -> FindResult:
|
|
62
64
|
"""Find every address object/group matching `raw` within `scope` (which
|
|
63
|
-
includes the scoped device-group's ancestors and `shared`).
|
|
65
|
+
includes the scoped device-group's ancestors and `shared`).
|
|
66
|
+
|
|
67
|
+
With `exact=True`, only objects whose value equals the query exactly are
|
|
68
|
+
kept — broader (`CONTAINS`) and narrower (`WITHIN`) matches are dropped.
|
|
69
|
+
Netmask and bare-host forms still canonicalize equal (`10.0.0.10` ==
|
|
70
|
+
`10.0.0.10/32`), so those remain exact.
|
|
71
|
+
"""
|
|
64
72
|
query = parse_query(raw)
|
|
65
73
|
visible = _visible_names(snapshot, scope)
|
|
66
74
|
matched: list[tuple[Address, MatchKind]] = []
|
|
@@ -71,8 +79,9 @@ def find_ip(snapshot: Snapshot, raw: str, scope: Location | None = None) -> Find
|
|
|
71
79
|
if nv is None:
|
|
72
80
|
continue
|
|
73
81
|
mk = match(query, nv)
|
|
74
|
-
if mk is not
|
|
75
|
-
|
|
82
|
+
if mk is None or (exact and mk is not MatchKind.EXACT):
|
|
83
|
+
continue
|
|
84
|
+
matched.append((addr, mk))
|
|
76
85
|
|
|
77
86
|
matches = [
|
|
78
87
|
AddressMatch(
|
|
@@ -124,9 +133,9 @@ def find_ip(snapshot: Snapshot, raw: str, scope: Location | None = None) -> Find
|
|
|
124
133
|
|
|
125
134
|
|
|
126
135
|
def find_ips(
|
|
127
|
-
snapshot: Snapshot, raws: list[str], scope: Location | None = None
|
|
136
|
+
snapshot: Snapshot, raws: list[str], scope: Location | None = None, *, exact: bool = False
|
|
128
137
|
) -> list[FindResult]:
|
|
129
|
-
return [find_ip(snapshot, r, scope) for r in raws]
|
|
138
|
+
return [find_ip(snapshot, r, scope, exact=exact) for r in raws]
|
|
130
139
|
|
|
131
140
|
|
|
132
141
|
class ObjectHit(BaseModel):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "panorama-super-cli"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
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"
|
|
@@ -50,6 +50,7 @@ psc -c panorama.xml dedup merge --keep h-web1 --remove web-primary --apply --out
|
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
52
|
psc -c cfg.xml -o json find ip 10.0.0.10 # exact/contains/within + groups
|
|
53
|
+
psc -c cfg.xml -o json find ip -e 10.0.0.10 # exact only (10.0.0.10 == /32)
|
|
53
54
|
psc -c cfg.xml -o json find ip 10.0.0.0/24 # everything inside the /24
|
|
54
55
|
psc -c cfg.xml -o json find ip -f ips.txt # a whole list (array result)
|
|
55
56
|
psc -c cfg.xml -o json find object grp-web # locate by exact name
|
|
@@ -57,6 +58,8 @@ psc -c cfg.xml -o json find object grp-web # locate by exact name
|
|
|
57
58
|
|
|
58
59
|
`exists: true` means there's an exact-match object. `matches[].match` is one of
|
|
59
60
|
`exact` (equal), `contains` (object is broader), `within` (object is narrower).
|
|
61
|
+
Pass `--exact`/`-e` to keep only `exact` matches — handy when a broad object
|
|
62
|
+
like `10.0.0.0/8` would otherwise drown out the host you asked for.
|
|
60
63
|
|
|
61
64
|
### dedup — duplicates and merging
|
|
62
65
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Tests for the shared unmappable-reference gate (issue #28).
|
|
2
|
+
|
|
3
|
+
The gate is the single source of truth for "can both appliers express this
|
|
4
|
+
reference edit as a flat member rewrite?" When the answer is no *and* the same
|
|
5
|
+
plan tears the target down (delete/rename), the silently-skipped repoint would
|
|
6
|
+
leave a dangling reference — so it must become a hard blocker, not a warning.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from psc.core.apply_live import plan_xapi_ops
|
|
16
|
+
from psc.core.apply_xml import apply_changeset
|
|
17
|
+
from psc.core.changeset import (
|
|
18
|
+
ChangeSet,
|
|
19
|
+
ObjectDelete,
|
|
20
|
+
ObjectKind,
|
|
21
|
+
ObjectRename,
|
|
22
|
+
ReferenceEdit,
|
|
23
|
+
gate_unmappable_reference_edits,
|
|
24
|
+
reference_edit_is_mappable,
|
|
25
|
+
)
|
|
26
|
+
from psc.core.models import Snapshot
|
|
27
|
+
from psc.core.naming import plan_rename
|
|
28
|
+
from psc.core.refs import ReferenceGraph
|
|
29
|
+
from psc.output.errors import ErrorType, PscError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _edit(**kw: object) -> ReferenceEdit:
|
|
33
|
+
base: dict[str, object] = {
|
|
34
|
+
"referrer_kind": "address-group",
|
|
35
|
+
"referrer_name": "g",
|
|
36
|
+
"referrer_location": "shared",
|
|
37
|
+
"field": "static",
|
|
38
|
+
}
|
|
39
|
+
base.update(kw)
|
|
40
|
+
return ReferenceEdit(**base) # type: ignore[arg-type]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_flat_member_fields_are_mappable() -> None:
|
|
44
|
+
assert reference_edit_is_mappable(_edit(referrer_kind="address-group", field="static"))
|
|
45
|
+
assert reference_edit_is_mappable(_edit(referrer_kind="service-group", field="members"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_rule_field_is_mappable_only_with_rulebase() -> None:
|
|
49
|
+
assert reference_edit_is_mappable(
|
|
50
|
+
_edit(referrer_kind="security-rule", field="destination", rulebase="pre")
|
|
51
|
+
)
|
|
52
|
+
assert reference_edit_is_mappable(
|
|
53
|
+
_edit(referrer_kind="nat-rule", field="source", rulebase="pre")
|
|
54
|
+
)
|
|
55
|
+
# trigger 2: a rule edit with no rulebase can't be addressed by either applier
|
|
56
|
+
assert not reference_edit_is_mappable(
|
|
57
|
+
_edit(referrer_kind="security-rule", field="destination", rulebase=None)
|
|
58
|
+
)
|
|
59
|
+
assert not reference_edit_is_mappable(
|
|
60
|
+
_edit(referrer_kind="nat-rule", field="source", rulebase=None)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_nat_translation_and_nonmember_fields_are_unmappable() -> None:
|
|
65
|
+
# trigger 1: NAT translation fields are nested, not flat member lists
|
|
66
|
+
assert not reference_edit_is_mappable(
|
|
67
|
+
_edit(referrer_kind="nat-rule", field="source-translation", rulebase="pre")
|
|
68
|
+
)
|
|
69
|
+
assert not reference_edit_is_mappable(
|
|
70
|
+
_edit(referrer_kind="nat-rule", field="destination-translation", rulebase="pre")
|
|
71
|
+
)
|
|
72
|
+
# a nat-rule `service` is a scalar, not a member list either
|
|
73
|
+
assert not reference_edit_is_mappable(
|
|
74
|
+
_edit(referrer_kind="nat-rule", field="service", rulebase="pre")
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_gate_blocks_unmappable_edit_when_plan_deletes_target() -> None:
|
|
79
|
+
cs = ChangeSet(
|
|
80
|
+
title="merge",
|
|
81
|
+
reference_edits=[
|
|
82
|
+
_edit(
|
|
83
|
+
referrer_kind="nat-rule",
|
|
84
|
+
referrer_name="nat-web",
|
|
85
|
+
referrer_location="shared",
|
|
86
|
+
field="source-translation",
|
|
87
|
+
rulebase="pre",
|
|
88
|
+
)
|
|
89
|
+
],
|
|
90
|
+
deletes=[ObjectDelete(kind=ObjectKind.ADDRESS, name="net-10", location="shared")],
|
|
91
|
+
)
|
|
92
|
+
gate_unmappable_reference_edits(cs)
|
|
93
|
+
assert cs.is_blocked
|
|
94
|
+
blocker = " ".join(cs.blockers)
|
|
95
|
+
assert "net-10" in blocker # names the torn-down object
|
|
96
|
+
assert "nat-web" in blocker # names the unmappable referrer
|
|
97
|
+
assert "source-translation" in blocker
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_gate_blocks_unmappable_edit_when_plan_renames_target() -> None:
|
|
101
|
+
cs = ChangeSet(
|
|
102
|
+
title="rename",
|
|
103
|
+
reference_edits=[
|
|
104
|
+
_edit(
|
|
105
|
+
referrer_kind="security-rule",
|
|
106
|
+
referrer_name="r1",
|
|
107
|
+
referrer_location="shared",
|
|
108
|
+
field="destination",
|
|
109
|
+
rulebase=None,
|
|
110
|
+
)
|
|
111
|
+
],
|
|
112
|
+
renames=[
|
|
113
|
+
ObjectRename(
|
|
114
|
+
kind=ObjectKind.ADDRESS, location="shared", old_name="net-10", new_name="N-10"
|
|
115
|
+
)
|
|
116
|
+
],
|
|
117
|
+
)
|
|
118
|
+
gate_unmappable_reference_edits(cs)
|
|
119
|
+
assert cs.is_blocked
|
|
120
|
+
assert any("net-10" in b and "r1" in b for b in cs.blockers)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_gate_warns_but_passes_when_no_teardown() -> None:
|
|
124
|
+
cs = ChangeSet(
|
|
125
|
+
title="advisory",
|
|
126
|
+
reference_edits=[
|
|
127
|
+
_edit(
|
|
128
|
+
referrer_kind="nat-rule",
|
|
129
|
+
referrer_name="nat-web",
|
|
130
|
+
referrer_location="shared",
|
|
131
|
+
field="source-translation",
|
|
132
|
+
rulebase="pre",
|
|
133
|
+
)
|
|
134
|
+
],
|
|
135
|
+
)
|
|
136
|
+
gate_unmappable_reference_edits(cs)
|
|
137
|
+
assert not cs.is_blocked
|
|
138
|
+
assert any("nat-web" in w for w in cs.warnings)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_gate_noop_when_all_edits_mappable() -> None:
|
|
142
|
+
cs = ChangeSet(
|
|
143
|
+
title="clean",
|
|
144
|
+
reference_edits=[_edit(referrer_kind="address-group", field="static")],
|
|
145
|
+
deletes=[ObjectDelete(kind=ObjectKind.ADDRESS, name="a", location="shared")],
|
|
146
|
+
)
|
|
147
|
+
gate_unmappable_reference_edits(cs)
|
|
148
|
+
assert not cs.is_blocked
|
|
149
|
+
assert not cs.warnings
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_engine_blocked_plan_refused_on_both_paths(snapshot: Snapshot, fixture_path: Path) -> None:
|
|
153
|
+
"""The gate-produced blocker is honored identically offline and live: both
|
|
154
|
+
appliers refuse with CONFLICT (exit 6) before mutating anything (#28)."""
|
|
155
|
+
graph = ReferenceGraph.build(snapshot)
|
|
156
|
+
cs = plan_rename(
|
|
157
|
+
snapshot,
|
|
158
|
+
graph,
|
|
159
|
+
kind=ObjectKind.ADDRESS,
|
|
160
|
+
location_name="shared",
|
|
161
|
+
old_name="net-10",
|
|
162
|
+
new_name="N-10.0.0.0_24",
|
|
163
|
+
)
|
|
164
|
+
assert cs.is_blocked
|
|
165
|
+
|
|
166
|
+
with pytest.raises(PscError) as offline:
|
|
167
|
+
apply_changeset(fixture_path.read_text(), cs)
|
|
168
|
+
assert offline.value.error_type is ErrorType.CONFLICT
|
|
169
|
+
|
|
170
|
+
with pytest.raises(PscError) as live:
|
|
171
|
+
plan_xapi_ops(cs)
|
|
172
|
+
assert live.value.error_type is ErrorType.CONFLICT
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import builtins
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
10
|
+
import click
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from psc.cli import app
|
|
14
|
+
|
|
9
15
|
FIXTURE = Path(__file__).parent / "fixtures" / "panorama-config.xml"
|
|
10
16
|
|
|
11
17
|
|
|
@@ -34,6 +40,24 @@ def test_find_ip_json_contract() -> None:
|
|
|
34
40
|
assert {m["name"] for m in data["matches"]} >= {"h-web1", "web-primary"}
|
|
35
41
|
|
|
36
42
|
|
|
43
|
+
def test_find_ip_exact_flag_json() -> None:
|
|
44
|
+
cp = run("-c", str(FIXTURE), "-o", "json", "find", "ip", "--exact", "10.0.0.10")
|
|
45
|
+
assert cp.returncode == 0
|
|
46
|
+
data = json.loads(cp.stdout)
|
|
47
|
+
assert data["exists"] is True
|
|
48
|
+
matches = data["matches"]
|
|
49
|
+
assert all(m["match"] == "exact" for m in matches)
|
|
50
|
+
names = {m["name"] for m in matches}
|
|
51
|
+
assert {"h-web1", "web-primary"} <= names
|
|
52
|
+
assert "net-10" not in names # the /24 is CONTAINS, dropped under --exact
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_find_ip_exact_short_flag() -> None:
|
|
56
|
+
cp = run("-c", str(FIXTURE), "-o", "json", "find", "ip", "-e", "10.0.0.10")
|
|
57
|
+
assert cp.returncode == 0
|
|
58
|
+
assert all(m["match"] == "exact" for m in json.loads(cp.stdout)["matches"])
|
|
59
|
+
|
|
60
|
+
|
|
37
61
|
def test_strict_not_found_exit_5() -> None:
|
|
38
62
|
cp = run("-c", str(FIXTURE), "--strict", "-o", "json", "find", "ip", "203.0.113.9")
|
|
39
63
|
assert cp.returncode == 5
|
|
@@ -102,3 +126,47 @@ def test_no_source_errors_config() -> None:
|
|
|
102
126
|
cp = run("-o", "json", "find", "ip", "10.0.0.10")
|
|
103
127
|
assert cp.returncode == 9
|
|
104
128
|
assert json.loads(cp.stdout)["type"] == "config"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_no_args_prints_help_without_traceback() -> None:
|
|
132
|
+
# Typer's no_args_is_help raises a *vendored* click NoArgsIsHelpError that
|
|
133
|
+
# the main() wrapper (standalone_mode=False) must swallow cleanly (#31).
|
|
134
|
+
cp = run()
|
|
135
|
+
combined = cp.stdout + cp.stderr
|
|
136
|
+
assert cp.returncode == 0
|
|
137
|
+
assert "Usage:" in combined
|
|
138
|
+
assert "Traceback" not in combined
|
|
139
|
+
assert "NoArgsIsHelpError" not in combined
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_unknown_command_usage_error_exit_2() -> None:
|
|
143
|
+
cp = run("no-such-command")
|
|
144
|
+
combined = cp.stdout + cp.stderr
|
|
145
|
+
assert cp.returncode == 2
|
|
146
|
+
assert "Traceback" not in combined
|
|
147
|
+
assert "No such command" in combined
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_click_exception_module_resolves_with_required_attrs() -> None:
|
|
151
|
+
# main() reads ClickException/Exit/Abort off the resolved module, so it must
|
|
152
|
+
# expose them whichever Typer flavour is installed.
|
|
153
|
+
mod = app._click_exception_module()
|
|
154
|
+
for attr in ("ClickException", "Exit", "Abort"):
|
|
155
|
+
assert hasattr(mod, attr)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_click_exception_module_falls_back_when_typer_unvendored(
|
|
159
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
160
|
+
) -> None:
|
|
161
|
+
# Older Typer (<0.16) has no `typer._click`; a top-level import of it would
|
|
162
|
+
# crash psc at import time — worse than #31. Resolution must degrade to the
|
|
163
|
+
# real Click instead of raising.
|
|
164
|
+
real_import = builtins.__import__
|
|
165
|
+
|
|
166
|
+
def fake_import(name: str, *args: object, **kwargs: object) -> object:
|
|
167
|
+
if name.startswith("typer._click"):
|
|
168
|
+
raise ImportError("simulated Typer without vendored Click")
|
|
169
|
+
return real_import(name, *args, **kwargs) # type: ignore[arg-type]
|
|
170
|
+
|
|
171
|
+
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
172
|
+
assert app._click_exception_module() is click.exceptions
|
|
@@ -72,3 +72,22 @@ def test_merge_missing_object_blocks(snapshot: Snapshot) -> None:
|
|
|
72
72
|
drop=ObjectRef(name="web-primary", location="shared"),
|
|
73
73
|
)
|
|
74
74
|
assert cs.is_blocked
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_merge_blocks_when_repoint_hits_nat_translation(snapshot: Snapshot) -> None:
|
|
78
|
+
"""net-10 is referenced by nat-web's source-translation, which has no flat
|
|
79
|
+
member list — repointing it away can't be expressed offline or live, so a
|
|
80
|
+
merge that would delete net-10 must be blocked, not silently skipped (#28).
|
|
81
|
+
"""
|
|
82
|
+
graph = ReferenceGraph.build(snapshot)
|
|
83
|
+
cs = plan_merge(
|
|
84
|
+
snapshot,
|
|
85
|
+
graph,
|
|
86
|
+
keep=ObjectRef(name="h-web1", location="shared"),
|
|
87
|
+
drop=ObjectRef(name="net-10", location="shared"),
|
|
88
|
+
allow_value_change=True,
|
|
89
|
+
)
|
|
90
|
+
assert cs.is_blocked
|
|
91
|
+
assert any("net-10" in b and "nat-web" in b for b in cs.blockers)
|
|
92
|
+
# a blocked plan carries zero executable ops
|
|
93
|
+
assert cs.op_count == 0
|
|
@@ -64,6 +64,25 @@ def test_rename_blocks_on_existing_name(snapshot: Snapshot) -> None:
|
|
|
64
64
|
assert cs.is_blocked
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def test_rename_blocks_when_repoint_hits_nat_translation(snapshot: Snapshot) -> None:
|
|
68
|
+
"""net-10 is referenced by nat-web's source-translation (a nested field with
|
|
69
|
+
no flat member list). The rename can repoint the security-rule destination
|
|
70
|
+
but not the translation field — so applying it would delete the old name out
|
|
71
|
+
from under a dangling reference. Block it (#28)."""
|
|
72
|
+
graph = ReferenceGraph.build(snapshot)
|
|
73
|
+
cs = plan_rename(
|
|
74
|
+
snapshot,
|
|
75
|
+
graph,
|
|
76
|
+
kind=ObjectKind.ADDRESS,
|
|
77
|
+
location_name="shared",
|
|
78
|
+
old_name="net-10",
|
|
79
|
+
new_name="N-10.0.0.0_24",
|
|
80
|
+
)
|
|
81
|
+
assert cs.is_blocked
|
|
82
|
+
assert any("net-10" in b and "nat-web" in b for b in cs.blockers)
|
|
83
|
+
assert cs.op_count == 0
|
|
84
|
+
|
|
85
|
+
|
|
67
86
|
def test_rename_blocks_shared_dg_shadow() -> None:
|
|
68
87
|
# Renaming a shared object to a name a DG already defines is refused.
|
|
69
88
|
snap = Snapshot(
|
|
@@ -22,6 +22,36 @@ def test_find_ip_reports_groups(snapshot: Snapshot) -> None:
|
|
|
22
22
|
assert set(grp["grp-web"].via) == {"h-web1", "web-primary"}
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def test_find_ip_exact_flag_drops_contains_and_within(snapshot: Snapshot) -> None:
|
|
26
|
+
# Without --exact, the host matches its own objects (EXACT) and the
|
|
27
|
+
# enclosing /24 (CONTAINS). With exact=True only the EXACT ones survive.
|
|
28
|
+
res = find_ip(snapshot, "10.0.0.10", exact=True)
|
|
29
|
+
assert res.exists is True
|
|
30
|
+
kinds = {m.match for m in res.matches}
|
|
31
|
+
assert kinds == {MatchKind.EXACT}
|
|
32
|
+
names = {m.name for m in res.matches}
|
|
33
|
+
# /32 and bare-host forms normalize equal, so all three are EXACT...
|
|
34
|
+
assert {"h-web1", "web-primary", "h-web1-slash"} <= names
|
|
35
|
+
# ...and the broader /24 (CONTAINS) is excluded.
|
|
36
|
+
assert "net-10" not in names
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_find_ip_exact_flag_slash32_equals_bare(snapshot: Snapshot) -> None:
|
|
40
|
+
# 10.0.0.10/32 and 10.0.0.10 are the same host; both query forms must hit
|
|
41
|
+
# both object forms under --exact.
|
|
42
|
+
for q in ("10.0.0.10", "10.0.0.10/32"):
|
|
43
|
+
names = {m.name for m in find_ip(snapshot, q, exact=True).matches}
|
|
44
|
+
assert {"h-web1", "h-web1-slash"} <= names, q
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_find_ip_exact_flag_excludes_groups_with_only_broader(snapshot: Snapshot) -> None:
|
|
48
|
+
# A CIDR query that exactly matches no object yields nothing under --exact.
|
|
49
|
+
res = find_ip(snapshot, "10.0.0.0/8", exact=True)
|
|
50
|
+
assert res.exists is False
|
|
51
|
+
assert res.matches == []
|
|
52
|
+
assert res.groups == []
|
|
53
|
+
|
|
54
|
+
|
|
25
55
|
def test_find_ip_no_match(snapshot: Snapshot) -> None:
|
|
26
56
|
res = find_ip(snapshot, "203.0.113.1")
|
|
27
57
|
assert res.exists is False
|
|
File without changes
|
|
File without changes
|
{panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.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
|
{panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/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
|