panorama-super-cli 0.2.1__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.1 → panorama_super_cli-0.2.2}/.github/workflows/docs.yml +4 -4
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.github/workflows/lint.yml +4 -4
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.github/workflows/release.yml +2 -2
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.github/workflows/test.yml +2 -2
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/CHANGELOG.md +19 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/PKG-INFO +1 -1
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/_version.py +1 -1
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/cli/app.py +37 -5
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/changeset.py +53 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/dedup.py +14 -13
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/naming.py +15 -1
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/pyproject.toml +1 -1
- panorama_super_cli-0.2.2/tests/test_changeset.py +172 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_cli.py +50 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_dedup.py +19 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_naming.py +19 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/uv.lock +1 -1
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.github/CODEOWNERS +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.github/dependabot.yml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.gitignore +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.pre-commit-config.yaml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/.python-version +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/AGENTS.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/CLAUDE.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/LICENSE +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/README.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/contributing/branching.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/contributing/development.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/contributing/release-process.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/getting-started/concepts.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/getting-started/first-run.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/getting-started/install.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/guides/duplicates-and-merging.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/guides/finding-objects.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/guides/live-vs-offline.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/guides/naming.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/guides/output-formats.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/guides/references-and-audit.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/guides/safety.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/guides/using-with-ai-agents.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/index.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/reference/cli.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/reference/config.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/docs/reference/exit-codes.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/justfile +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/mkdocs.yml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/__main__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/cli/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/cli/_plan.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/cli/auth_cmds.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/cli/dedup_cmds.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/cli/find_cmds.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/cli/name_cmds.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/cli/profile_cmds.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/cli/refs_cmds.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/cli/runtime.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/config/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/config/loader.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/config/models.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/apply_live.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/apply_xml.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/models.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/normalize.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/parse.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/refs.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/resolve.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/setcmd.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/core/source.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/output/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/output/errors.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/psc/output/format.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/scripts/sync_agents_md.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/skills/panorama-super-cli/SKILL.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/conftest.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/fixtures/nested-device-groups.xml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/fixtures/panorama-config.xml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_apply_xml.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_auth.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_cli_auth.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_hardening.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_live_apply.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_nested_dg.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_normalize.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_output.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_parse.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_refs.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.2}/tests/test_resolve.py +0 -0
- {panorama_super_cli-0.2.1 → 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,25 @@ 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
|
+
|
|
10
29
|
## v0.2.1 — 2026-06-04
|
|
11
30
|
|
|
12
31
|
### 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/
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -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"
|
|
@@ -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
|
|
|
@@ -120,3 +126,47 @@ def test_no_source_errors_config() -> None:
|
|
|
120
126
|
cp = run("-o", "json", "find", "ip", "10.0.0.10")
|
|
121
127
|
assert cp.returncode == 9
|
|
122
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(
|
|
File without changes
|
|
File without changes
|
{panorama_super_cli-0.2.1 → 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{panorama_super_cli-0.2.1 → 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
|
|
File without changes
|