panorama-super-cli 0.2.1__tar.gz → 0.2.3__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.3}/.github/workflows/docs.yml +4 -4
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/workflows/lint.yml +4 -4
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/workflows/release.yml +2 -2
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/workflows/test.yml +2 -2
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/CHANGELOG.md +33 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/PKG-INFO +1 -1
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/output-formats.md +19 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/safety.md +10 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/reference/cli.md +8 -6
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/_version.py +1 -1
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/_plan.py +27 -2
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/app.py +37 -5
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/dedup_cmds.py +5 -3
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/name_cmds.py +12 -5
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/changeset.py +53 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/dedup.py +14 -13
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/naming.py +15 -1
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/source.py +47 -2
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/pyproject.toml +1 -1
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/skills/panorama-super-cli/SKILL.md +16 -2
- panorama_super_cli-0.2.3/tests/test_changeset.py +172 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_cli.py +95 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_dedup.py +19 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_naming.py +19 -0
- panorama_super_cli-0.2.3/tests/test_source.py +62 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/uv.lock +577 -577
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/CODEOWNERS +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/dependabot.yml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.gitignore +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.pre-commit-config.yaml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.python-version +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/AGENTS.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/CLAUDE.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/LICENSE +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/README.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/contributing/branching.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/contributing/development.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/contributing/release-process.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/getting-started/concepts.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/getting-started/first-run.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/getting-started/install.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/duplicates-and-merging.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/finding-objects.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/live-vs-offline.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/naming.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/references-and-audit.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/using-with-ai-agents.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/index.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/reference/config.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/reference/exit-codes.md +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/justfile +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/mkdocs.yml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/__main__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/auth_cmds.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/find_cmds.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/profile_cmds.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/refs_cmds.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/runtime.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/config/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/config/loader.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/config/models.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/apply_live.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/apply_xml.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/models.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/normalize.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/parse.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/refs.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/resolve.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/setcmd.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/output/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/output/errors.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/output/format.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/scripts/sync_agents_md.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/__init__.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/conftest.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/fixtures/nested-device-groups.xml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/fixtures/panorama-config.xml +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_apply_xml.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_auth.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_cli_auth.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_hardening.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_live_apply.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_nested_dg.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_normalize.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_output.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_parse.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_refs.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_resolve.py +0 -0
- {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/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,39 @@ project will follow [Semantic Versioning](https://semver.org/). While on
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## v0.2.3 — 2026-06-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`-of` / `--output-format xml|set` for the offline `--apply --out` artifact.**
|
|
15
|
+
Mutating commands (`dedup merge`, `name rename`, `name apply`) can now write the
|
|
16
|
+
rewritten config file as the equivalent PAN-OS `set` script instead of XML.
|
|
17
|
+
`xml` (the rewritten, `load config`-able config) stays the default, so existing
|
|
18
|
+
invocations are unchanged; `set` writes the creates/deletes/repoints that
|
|
19
|
+
achieve the same change, which is easier to read and to paste into a config
|
|
20
|
+
session or `load config partial`. This is distinct from `-o set`, which renders
|
|
21
|
+
the dry-run plan to stdout. The blocker hard-gate and repoint-before-delete
|
|
22
|
+
ordering apply to both formats; a blocked plan writes no file. (#37)
|
|
23
|
+
|
|
24
|
+
## v0.2.2 — 2026-06-04
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Repoint-before-delete no longer skipped silently.** A merge or rename whose
|
|
29
|
+
only safe repoint is a reference the appliers can't express as a flat member
|
|
30
|
+
rewrite — a NAT source/destination *translation* field, or a rule edit with no
|
|
31
|
+
rulebase — is now refused with a `blocker` (exit 6) on both the offline and
|
|
32
|
+
live paths, naming the object and the unmappable referrer. Previously the edit
|
|
33
|
+
was dropped while the delete/rename still ran, leaving a dangling reference.
|
|
34
|
+
Advisory unmappable edits with no accompanying teardown still pass with a
|
|
35
|
+
warning. (#28)
|
|
36
|
+
- **`psc` with no arguments (and unknown commands) print help, not a traceback.**
|
|
37
|
+
Typer 0.16+ vendors its own Click, so the exceptions the entry point raised
|
|
38
|
+
were not the `click.*` the wrapper caught and escaped as a traceback ending in
|
|
39
|
+
`NoArgsIsHelpError`. The wrapper now handles both the vendored and the real
|
|
40
|
+
Click hierarchies, resolving the module defensively so psc still imports on
|
|
41
|
+
older Typer. No-args/`--help` exit 0; usage errors exit 2. (#31)
|
|
42
|
+
|
|
10
43
|
## v0.2.1 — 2026-06-04
|
|
11
44
|
|
|
12
45
|
### 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.3
|
|
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/
|
|
@@ -50,6 +50,25 @@ Member-list edits become `delete` + `set` because PAN-OS `set` on a member field
|
|
|
50
50
|
can't safely render (NAT translation paths) appear as `# REVIEW` comments; the
|
|
51
51
|
structured (`json`) plan still carries them.
|
|
52
52
|
|
|
53
|
+
### `-o set` (stdout) vs `--output-format set` (the `--out` file)
|
|
54
|
+
|
|
55
|
+
These are two different knobs and it's worth keeping them straight:
|
|
56
|
+
|
|
57
|
+
- **`-o set`** controls what a command prints to **stdout** — use it to read or
|
|
58
|
+
pipe the plan during a dry-run.
|
|
59
|
+
- **`-of` / `--output-format`** (offline mutating commands only) controls the
|
|
60
|
+
format of the **`--apply --out` file artifact**: `xml` (default) rewrites the
|
|
61
|
+
whole config to load with `load config`, while `set` writes the same PAN-OS
|
|
62
|
+
`set` script shown above to that file — easier to read and to paste into a
|
|
63
|
+
config session or `load config partial`.
|
|
64
|
+
|
|
65
|
+
```console
|
|
66
|
+
psc -c panorama.xml dedup merge --keep h-web1 --remove web-primary --apply --out plan.set -of set
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`-of` only shapes the file and is ignored without `--apply`; on a live profile
|
|
70
|
+
there is no file artifact, so it has no effect there.
|
|
71
|
+
|
|
53
72
|
## Errors
|
|
54
73
|
|
|
55
74
|
Errors are emitted as a stable JSON envelope:
|
|
@@ -46,6 +46,16 @@ psc -c panorama.xml dedup merge --keep a --remove b --apply --out fixed.xml
|
|
|
46
46
|
diff <(xmllint --format panorama.xml) <(xmllint --format fixed.xml)
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
By default the `--out` file is the rewritten config XML. Pass
|
|
50
|
+
`-of/--output-format set` to instead write the equivalent PAN-OS `set` script
|
|
51
|
+
(the creates/deletes/repoints that achieve the same change) — easier to read and
|
|
52
|
+
to paste into a config session. The blocker gate and repoint-before-delete
|
|
53
|
+
ordering apply to both formats identically; a blocked plan writes no file.
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
psc -c panorama.xml dedup merge --keep a --remove b --apply --out plan.set -of set
|
|
57
|
+
```
|
|
58
|
+
|
|
49
59
|
## Live writes
|
|
50
60
|
|
|
51
61
|
Live `--apply` pushes the plan to Panorama's **candidate** config over the XML
|
|
@@ -17,9 +17,11 @@ These are **context** options — pass them *before* the subcommand:
|
|
|
17
17
|
| `--debug` | Verbose structured logs on stderr. |
|
|
18
18
|
| `--version` | Print version and exit. |
|
|
19
19
|
|
|
20
|
-
Write-execution options (`--apply`, `--out`) belong to
|
|
21
|
-
commands and are passed *after* the command. `--apply`
|
|
22
|
-
write; offline it requires `--out PATH` (a new file)
|
|
20
|
+
Write-execution options (`--apply`, `--out`, `-of/--output-format`) belong to
|
|
21
|
+
the individual mutating commands and are passed *after* the command. `--apply`
|
|
22
|
+
is the only path to a write; offline it requires `--out PATH` (a new file) and
|
|
23
|
+
`-of xml|set` chooses what that file holds (default `xml`), while live it pushes
|
|
24
|
+
to
|
|
23
25
|
Panorama's candidate config and never commits. See
|
|
24
26
|
[Writes and safety](../guides/safety.md).
|
|
25
27
|
|
|
@@ -42,7 +44,7 @@ psc dedup addresses
|
|
|
42
44
|
psc dedup services
|
|
43
45
|
psc dedup merge --keep NAME --remove NAME [--location LOC]
|
|
44
46
|
[--keep-location LOC] [--remove-location LOC]
|
|
45
|
-
[--allow-value-change] [--apply] [--out PATH]
|
|
47
|
+
[--allow-value-change] [--apply] [--out PATH] [-of xml|set]
|
|
46
48
|
```
|
|
47
49
|
|
|
48
50
|
Find duplicates; merge one object into another, repointing all references. See
|
|
@@ -63,8 +65,8 @@ Where-used, recursive unused, and dangling-reference audit. See
|
|
|
63
65
|
|
|
64
66
|
```
|
|
65
67
|
psc name lint [--all]
|
|
66
|
-
psc name rename --object OLD --to NEW [--kind KIND] [--location LOC] [--apply] [--out PATH]
|
|
67
|
-
psc name apply --object NAME [--location LOC] [--apply] [--out PATH]
|
|
68
|
+
psc name rename --object OLD --to NEW [--kind KIND] [--location LOC] [--apply] [--out PATH] [-of xml|set]
|
|
69
|
+
psc name apply --object NAME [--location LOC] [--apply] [--out PATH] [-of xml|set]
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
Opt-in naming-template lint and reference-aware rename. See
|
|
@@ -7,14 +7,39 @@ refused, dry-run is the default, and `--apply` is the only path to a write.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import typer
|
|
11
|
+
|
|
10
12
|
from psc.cli.runtime import Runtime
|
|
11
13
|
from psc.core.changeset import ChangeSet
|
|
12
14
|
from psc.core.setcmd import render_changeset
|
|
15
|
+
from psc.core.source import ConfigFormat
|
|
13
16
|
from psc.output.errors import ErrorType, PscError
|
|
14
17
|
from psc.output.format import OutputFormat, render
|
|
15
18
|
|
|
19
|
+
# Shared across every mutating command so the `--out` artifact toggle reads and
|
|
20
|
+
# behaves identically wherever `complete()` is used (dedup merge, name rename,
|
|
21
|
+
# name apply). Default `xml` keeps existing scripts byte-for-byte compatible.
|
|
22
|
+
OUT_FORMAT_OPTION = typer.Option(
|
|
23
|
+
ConfigFormat.XML,
|
|
24
|
+
"--output-format",
|
|
25
|
+
"-of",
|
|
26
|
+
help=(
|
|
27
|
+
"Format of the offline --out artifact. 'xml' (default) rewrites the whole "
|
|
28
|
+
"config to load with `load config`; 'set' writes the equivalent PAN-OS set "
|
|
29
|
+
"script (the creates/deletes/repoints) — easier to read and to paste into a "
|
|
30
|
+
"config session. Only affects --out; ignored without --apply."
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
16
34
|
|
|
17
|
-
def complete(
|
|
35
|
+
def complete(
|
|
36
|
+
rt: Runtime,
|
|
37
|
+
cs: ChangeSet,
|
|
38
|
+
*,
|
|
39
|
+
apply: bool,
|
|
40
|
+
out_path: str | None,
|
|
41
|
+
out_format: ConfigFormat = ConfigFormat.XML,
|
|
42
|
+
) -> None:
|
|
18
43
|
"""Render `cs` in the chosen format, then apply it iff `apply`.
|
|
19
44
|
|
|
20
45
|
A blocked plan raises `CONFLICT` (exit 6) before any write. The structured
|
|
@@ -43,7 +68,7 @@ def complete(rt: Runtime, cs: ChangeSet, *, apply: bool, out_path: str | None) -
|
|
|
43
68
|
return
|
|
44
69
|
|
|
45
70
|
if apply:
|
|
46
|
-
result = rt.source().apply(cs, out_path=out_path)
|
|
71
|
+
result = rt.source().apply(cs, out_path=out_path, out_format=out_format)
|
|
47
72
|
rt.stderr.print(
|
|
48
73
|
f"[green]applied[/green] {result.ops} operation(s)"
|
|
49
74
|
+ (f" → {result.out_path}" if result.out_path else "")
|
|
@@ -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
|
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
6
|
|
|
7
|
-
from psc.cli._plan import complete
|
|
7
|
+
from psc.cli._plan import OUT_FORMAT_OPTION, complete
|
|
8
8
|
from psc.cli.runtime import Runtime
|
|
9
9
|
from psc.core.dedup import (
|
|
10
10
|
DuplicateGroup,
|
|
@@ -14,6 +14,7 @@ from psc.core.dedup import (
|
|
|
14
14
|
plan_merge,
|
|
15
15
|
)
|
|
16
16
|
from psc.core.refs import ReferenceGraph
|
|
17
|
+
from psc.core.source import ConfigFormat
|
|
17
18
|
from psc.output.errors import ErrorType, PscError
|
|
18
19
|
from psc.output.format import render
|
|
19
20
|
|
|
@@ -71,8 +72,9 @@ def merge(
|
|
|
71
72
|
),
|
|
72
73
|
apply: bool = typer.Option(False, "--apply", help="Execute the merge (default: dry-run)."),
|
|
73
74
|
out: str | None = typer.Option(
|
|
74
|
-
None, "--out", help="Offline: write the rewritten config
|
|
75
|
+
None, "--out", help="Offline: write the rewritten config here (see --output-format)."
|
|
75
76
|
),
|
|
77
|
+
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
76
78
|
) -> None:
|
|
77
79
|
"""Collapse one address object into another, repointing every reference.
|
|
78
80
|
|
|
@@ -91,4 +93,4 @@ def merge(
|
|
|
91
93
|
drop=ObjectRef(name=remove, location=remove_location or default_loc),
|
|
92
94
|
allow_value_change=allow_value_change,
|
|
93
95
|
)
|
|
94
|
-
complete(rt, cs, apply=apply, out_path=out)
|
|
96
|
+
complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
|
|
@@ -4,12 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
6
|
|
|
7
|
-
from psc.cli._plan import complete
|
|
7
|
+
from psc.cli._plan import OUT_FORMAT_OPTION, complete
|
|
8
8
|
from psc.cli.runtime import Runtime
|
|
9
9
|
from psc.core.changeset import ObjectKind
|
|
10
10
|
from psc.core.naming import lint as lint_engine
|
|
11
11
|
from psc.core.naming import plan_rename
|
|
12
12
|
from psc.core.refs import ReferenceGraph
|
|
13
|
+
from psc.core.source import ConfigFormat
|
|
13
14
|
from psc.output.errors import ErrorType, PscError
|
|
14
15
|
from psc.output.format import render
|
|
15
16
|
|
|
@@ -49,7 +50,10 @@ def rename(
|
|
|
49
50
|
kind: ObjectKind = typer.Option(ObjectKind.ADDRESS, "--kind"),
|
|
50
51
|
location: str | None = typer.Option(None, "--location"),
|
|
51
52
|
apply: bool = typer.Option(False, "--apply", help="Execute the rename (default: dry-run)."),
|
|
52
|
-
out: str | None = typer.Option(
|
|
53
|
+
out: str | None = typer.Option(
|
|
54
|
+
None, "--out", help="Offline: write rewritten config here (see --output-format)."
|
|
55
|
+
),
|
|
56
|
+
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
53
57
|
) -> None:
|
|
54
58
|
"""Rename one object, repointing every reference (refuses on shadow collisions)."""
|
|
55
59
|
rt: Runtime = ctx.obj
|
|
@@ -57,7 +61,7 @@ def rename(
|
|
|
57
61
|
snap = rt.snapshot()
|
|
58
62
|
graph = ReferenceGraph.build(snap)
|
|
59
63
|
cs = plan_rename(snap, graph, kind=kind, location_name=loc, old_name=object_name, new_name=to)
|
|
60
|
-
complete(rt, cs, apply=apply, out_path=out)
|
|
64
|
+
complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
|
|
61
65
|
|
|
62
66
|
|
|
63
67
|
@app.command("apply")
|
|
@@ -66,7 +70,10 @@ def apply_scheme(
|
|
|
66
70
|
object_name: str = typer.Option(..., "--object", help="Object to rename to its scheme name."),
|
|
67
71
|
location: str | None = typer.Option(None, "--location"),
|
|
68
72
|
apply: bool = typer.Option(False, "--apply", help="Execute the rename (default: dry-run)."),
|
|
69
|
-
out: str | None = typer.Option(
|
|
73
|
+
out: str | None = typer.Option(
|
|
74
|
+
None, "--out", help="Offline: write rewritten config here (see --output-format)."
|
|
75
|
+
),
|
|
76
|
+
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
70
77
|
) -> None:
|
|
71
78
|
"""Rename one object to the name the configured scheme implies for its value."""
|
|
72
79
|
rt: Runtime = ctx.obj
|
|
@@ -98,4 +105,4 @@ def apply_scheme(
|
|
|
98
105
|
cs = plan_rename(
|
|
99
106
|
snap, graph, kind=kind, location_name=loc, old_name=object_name, new_name=suggested
|
|
100
107
|
)
|
|
101
|
-
complete(rt, cs, apply=apply, out_path=out)
|
|
108
|
+
complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
|
|
@@ -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
|
|
@@ -18,6 +18,7 @@ Writes are deliberately conservative:
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import ssl
|
|
21
|
+
from enum import Enum
|
|
21
22
|
from pathlib import Path
|
|
22
23
|
|
|
23
24
|
from pydantic import BaseModel, Field
|
|
@@ -26,9 +27,23 @@ from psc.core.apply_xml import apply_changeset
|
|
|
26
27
|
from psc.core.changeset import ChangeSet
|
|
27
28
|
from psc.core.models import Snapshot
|
|
28
29
|
from psc.core.parse import parse_config
|
|
30
|
+
from psc.core.setcmd import render_changeset
|
|
29
31
|
from psc.output.errors import ErrorType, PscError
|
|
30
32
|
|
|
31
33
|
|
|
34
|
+
class ConfigFormat(str, Enum):
|
|
35
|
+
"""Format of the artifact an offline `apply` writes to `--out`.
|
|
36
|
+
|
|
37
|
+
`xml` rewrites the whole exported config (loadable with `load config`);
|
|
38
|
+
`set` emits the equivalent PAN-OS `set` script (the creations/deletes/
|
|
39
|
+
repoints that achieve the same change) — easier to read and to paste into a
|
|
40
|
+
config session or `load config partial`.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
XML = "xml"
|
|
44
|
+
SET = "set"
|
|
45
|
+
|
|
46
|
+
|
|
32
47
|
class ApplyResult(BaseModel):
|
|
33
48
|
applied: bool
|
|
34
49
|
ops: int
|
|
@@ -83,7 +98,13 @@ class OfflineSource:
|
|
|
83
98
|
except Exception as exc:
|
|
84
99
|
raise PscError(f"failed to parse {self.path}: {exc}", ErrorType.INPUT) from exc
|
|
85
100
|
|
|
86
|
-
def apply(
|
|
101
|
+
def apply(
|
|
102
|
+
self,
|
|
103
|
+
cs: ChangeSet,
|
|
104
|
+
*,
|
|
105
|
+
out_path: str | Path | None,
|
|
106
|
+
out_format: ConfigFormat = ConfigFormat.XML,
|
|
107
|
+
) -> ApplyResult:
|
|
87
108
|
if out_path is None:
|
|
88
109
|
raise PscError(
|
|
89
110
|
"offline apply needs --out PATH (the rewritten config is never "
|
|
@@ -93,6 +114,20 @@ class OfflineSource:
|
|
|
93
114
|
out = Path(out_path)
|
|
94
115
|
if out.resolve() == self.path.resolve():
|
|
95
116
|
raise PscError("--out must differ from the source config path", ErrorType.CONFIG)
|
|
117
|
+
# The blocker gate is enforced here for *every* format — the XML path
|
|
118
|
+
# relies on `apply_changeset`, but the set path renders the plan without
|
|
119
|
+
# it, so refuse before writing rather than emit a `# BLOCKED` file.
|
|
120
|
+
if cs.is_blocked:
|
|
121
|
+
raise PscError(
|
|
122
|
+
"refusing to apply a blocked plan",
|
|
123
|
+
ErrorType.CONFLICT,
|
|
124
|
+
details={"blockers": cs.blockers},
|
|
125
|
+
)
|
|
126
|
+
# Both artifacts share the same safety guards above; only the bytes differ.
|
|
127
|
+
if out_format is ConfigFormat.SET:
|
|
128
|
+
script = render_changeset(cs)
|
|
129
|
+
out.write_text("\n".join(script) + "\n", encoding="utf-8")
|
|
130
|
+
return ApplyResult(applied=True, ops=cs.op_count, out_path=str(out), set_script=script)
|
|
96
131
|
new_xml = apply_changeset(self._xml, cs)
|
|
97
132
|
out.write_text(new_xml, encoding="utf-8")
|
|
98
133
|
return ApplyResult(applied=True, ops=cs.op_count, out_path=str(out))
|
|
@@ -221,9 +256,19 @@ class LiveSource:
|
|
|
221
256
|
def snapshot(self) -> Snapshot:
|
|
222
257
|
return parse_config(self.raw_xml())
|
|
223
258
|
|
|
224
|
-
def apply(
|
|
259
|
+
def apply(
|
|
260
|
+
self,
|
|
261
|
+
cs: ChangeSet,
|
|
262
|
+
*,
|
|
263
|
+
out_path: str | Path | None,
|
|
264
|
+
out_format: ConfigFormat = ConfigFormat.XML,
|
|
265
|
+
) -> ApplyResult:
|
|
225
266
|
"""Push `cs` to Panorama's candidate config over the XML API.
|
|
226
267
|
|
|
268
|
+
`out_path`/`out_format` are accepted for interface parity with the
|
|
269
|
+
offline source but ignored — a live apply leaves a candidate on the
|
|
270
|
+
device, not a file, so there is no artifact to format.
|
|
271
|
+
|
|
227
272
|
The plan is lowered to xpath set/edit/delete/rename ops and replayed in
|
|
228
273
|
order (repoint before delete). The `blockers` gate and name-addressing
|
|
229
274
|
check run *before* any device contact, so a refused plan never writes.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "panorama-super-cli"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.3"
|
|
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"
|