panorama-super-cli 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/CHANGELOG.md +27 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/PKG-INFO +1 -1
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/duplicates-and-merging.md +21 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/output-formats.md +19 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/safety.md +10 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/reference/cli.md +9 -7
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/_version.py +1 -1
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/_plan.py +27 -2
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/dedup_cmds.py +21 -6
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/name_cmds.py +12 -5
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/dedup.py +17 -5
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/normalize.py +32 -4
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/source.py +47 -2
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/pyproject.toml +1 -1
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/skills/panorama-super-cli/SKILL.md +18 -3
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_cli.py +75 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_dedup.py +45 -1
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_normalize.py +27 -0
- panorama_super_cli-0.2.4/tests/test_source.py +62 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/uv.lock +577 -577
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/CODEOWNERS +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/dependabot.yml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/workflows/docs.yml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/workflows/lint.yml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/workflows/release.yml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/workflows/test.yml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.gitignore +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.pre-commit-config.yaml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.python-version +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/AGENTS.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/CLAUDE.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/LICENSE +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/README.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/contributing/branching.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/contributing/development.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/contributing/release-process.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/getting-started/concepts.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/getting-started/first-run.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/getting-started/install.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/finding-objects.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/live-vs-offline.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/naming.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/references-and-audit.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/using-with-ai-agents.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/index.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/reference/config.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/reference/exit-codes.md +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/justfile +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/mkdocs.yml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/__init__.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/__main__.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/__init__.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/app.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/auth_cmds.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/find_cmds.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/profile_cmds.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/refs_cmds.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/runtime.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/config/__init__.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/config/loader.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/config/models.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/__init__.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/apply_live.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/apply_xml.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/changeset.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/models.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/naming.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/parse.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/refs.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/resolve.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/setcmd.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/output/__init__.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/output/errors.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/output/format.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/scripts/sync_agents_md.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/__init__.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/conftest.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/fixtures/nested-device-groups.xml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/fixtures/panorama-config.xml +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_apply_xml.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_auth.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_changeset.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_cli_auth.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_hardening.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_live_apply.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_naming.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_nested_dg.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_output.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_parse.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_refs.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_resolve.py +0 -0
- {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_setcmd.py +0 -0
|
@@ -7,6 +7,33 @@ project will follow [Semantic Versioning](https://semver.org/). While on
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## v0.2.4 — 2026-06-04
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`psc dedup addresses` is now strict by default** (#38). Addresses are
|
|
15
|
+
grouped only when their values are byte-identical, so a host accidentally
|
|
16
|
+
written with a subnet mask (`10.1.1.50/24`) is no longer reported as a
|
|
17
|
+
duplicate of the network `10.1.1.0/24` — merging those would have silently
|
|
18
|
+
changed rule matching. Genuinely identical forms (`10.0.0.10` and
|
|
19
|
+
`10.0.0.10/32`) still group. Pass `--not-strict` for the previous
|
|
20
|
+
host-bit-masking behaviour. The `dedup merge` safety gate now compares exact
|
|
21
|
+
values too, so such a merge is blocked unless `--allow-value-change` is given.
|
|
22
|
+
|
|
23
|
+
## v0.2.3 — 2026-06-04
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`-of` / `--output-format xml|set` for the offline `--apply --out` artifact.**
|
|
28
|
+
Mutating commands (`dedup merge`, `name rename`, `name apply`) can now write the
|
|
29
|
+
rewritten config file as the equivalent PAN-OS `set` script instead of XML.
|
|
30
|
+
`xml` (the rewritten, `load config`-able config) stays the default, so existing
|
|
31
|
+
invocations are unchanged; `set` writes the creates/deletes/repoints that
|
|
32
|
+
achieve the same change, which is easier to read and to paste into a config
|
|
33
|
+
session or `load config partial`. This is distinct from `-o set`, which renders
|
|
34
|
+
the dry-run plan to stdout. The blocker hard-gate and repoint-before-delete
|
|
35
|
+
ordering apply to both formats; a blocked plan writes no file. (#37)
|
|
36
|
+
|
|
10
37
|
## v0.2.2 — 2026-06-04
|
|
11
38
|
|
|
12
39
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: panorama-super-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely.
|
|
5
5
|
Project-URL: Homepage, https://github.com/thomaschristory/panorama-super-cli
|
|
6
6
|
Project-URL: Documentation, https://thomaschristory.github.io/panorama-super-cli/
|
|
@@ -12,6 +12,27 @@ Objects are grouped by **value**, not name. `h-web1`, `web-primary`, and
|
|
|
12
12
|
`svc-https` (both TCP/443) in another. Each row tells you the value and every
|
|
13
13
|
object name + location that defines it.
|
|
14
14
|
|
|
15
|
+
### Strict by default
|
|
16
|
+
|
|
17
|
+
Address matching is **strict**: only byte-identical values are duplicates. A
|
|
18
|
+
host accidentally written with a subnet mask — `web-server = 10.1.1.50/24` —
|
|
19
|
+
is **not** reported as a duplicate of the network `internal = 10.1.1.0/24`,
|
|
20
|
+
even though both mask down to the same `/24`. (`10.0.0.10` and `10.0.0.10/32`
|
|
21
|
+
*are* still the same host, so they group.)
|
|
22
|
+
|
|
23
|
+
Pass `--not-strict` for the looser, fringe behaviour that masks host bits and
|
|
24
|
+
collapses a host-with-mask onto its network:
|
|
25
|
+
|
|
26
|
+
```console
|
|
27
|
+
psc -c panorama.xml dedup addresses --not-strict
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`--not-strict` only widens what's *listed* — it grants no merge power. A pair it
|
|
31
|
+
surfaces (e.g. a host and its network) has different exact values, so
|
|
32
|
+
[`dedup merge`](#the-safety-gate) still refuses it unless you pass
|
|
33
|
+
`--allow-value-change`. Treat `--not-strict` as a discovery aid, then decide
|
|
34
|
+
case by case whether the masked-equal objects really should be merged.
|
|
35
|
+
|
|
15
36
|
## Merge two objects
|
|
16
37
|
|
|
17
38
|
`dedup merge` collapses one object (`--remove`) into another (`--keep`),
|
|
@@ -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
|
|
|
@@ -38,11 +40,11 @@ object by exact name. See [Finding objects](../guides/finding-objects.md).
|
|
|
38
40
|
### dedup
|
|
39
41
|
|
|
40
42
|
```
|
|
41
|
-
psc dedup addresses
|
|
43
|
+
psc dedup addresses [--not-strict]
|
|
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 "")
|
|
@@ -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
|
|
|
@@ -29,10 +30,23 @@ def _dup_rows(groups: list[DuplicateGroup]) -> list[dict[str, object]]:
|
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
@app.command("addresses")
|
|
32
|
-
def addresses(
|
|
33
|
-
|
|
33
|
+
def addresses(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
not_strict: bool = typer.Option(
|
|
36
|
+
False,
|
|
37
|
+
"--not-strict",
|
|
38
|
+
help="Also group host objects onto their network (mask host bits). "
|
|
39
|
+
"Default: only byte-identical values are duplicates.",
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""List address objects that share an identical value under different names.
|
|
43
|
+
|
|
44
|
+
By default this is *strict*: a host written with a subnet mask
|
|
45
|
+
(`10.1.1.50/24`) is not treated as a duplicate of the network `10.1.1.0/24`.
|
|
46
|
+
Pass `--not-strict` for the looser, host-bit-masking behaviour.
|
|
47
|
+
"""
|
|
34
48
|
rt: Runtime = ctx.obj
|
|
35
|
-
groups = find_duplicate_addresses(rt.snapshot())
|
|
49
|
+
groups = find_duplicate_addresses(rt.snapshot(), strict=not not_strict)
|
|
36
50
|
if rt.strict and not groups:
|
|
37
51
|
raise PscError("no duplicate addresses", ErrorType.NOT_FOUND)
|
|
38
52
|
render(
|
|
@@ -71,8 +85,9 @@ def merge(
|
|
|
71
85
|
),
|
|
72
86
|
apply: bool = typer.Option(False, "--apply", help="Execute the merge (default: dry-run)."),
|
|
73
87
|
out: str | None = typer.Option(
|
|
74
|
-
None, "--out", help="Offline: write the rewritten config
|
|
88
|
+
None, "--out", help="Offline: write the rewritten config here (see --output-format)."
|
|
75
89
|
),
|
|
90
|
+
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
76
91
|
) -> None:
|
|
77
92
|
"""Collapse one address object into another, repointing every reference.
|
|
78
93
|
|
|
@@ -91,4 +106,4 @@ def merge(
|
|
|
91
106
|
drop=ObjectRef(name=remove, location=remove_location or default_loc),
|
|
92
107
|
allow_value_change=allow_value_change,
|
|
93
108
|
)
|
|
94
|
-
complete(rt, cs, apply=apply, out_path=out)
|
|
109
|
+
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)
|
|
@@ -45,21 +45,30 @@ class DuplicateGroup(BaseModel):
|
|
|
45
45
|
return len(self.members)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def find_duplicate_addresses(snapshot: Snapshot) -> list[DuplicateGroup]:
|
|
49
|
-
"""Address objects sharing
|
|
48
|
+
def find_duplicate_addresses(snapshot: Snapshot, *, strict: bool = True) -> list[DuplicateGroup]:
|
|
49
|
+
"""Address objects sharing the same value, 2+ per bucket.
|
|
50
|
+
|
|
51
|
+
`strict` (default) groups only byte-identical values, so a host written with
|
|
52
|
+
a subnet mask (`10.1.1.50/24`) is *not* a duplicate of its network
|
|
53
|
+
(`10.1.1.0/24`). `strict=False` groups by masked network — the looser,
|
|
54
|
+
fringe behaviour that collapses host-with-mask onto the network.
|
|
55
|
+
"""
|
|
50
56
|
buckets: dict[str, list[ObjectRef]] = {}
|
|
51
57
|
display: dict[str, str] = {}
|
|
52
58
|
for a in snapshot.addresses:
|
|
53
59
|
nv = normalize_address(a)
|
|
54
60
|
if nv is None:
|
|
55
61
|
continue
|
|
56
|
-
key = nv.overlaps_key()
|
|
62
|
+
key = nv.exact_key() if strict else nv.overlaps_key()
|
|
57
63
|
buckets.setdefault(key, []).append(ObjectRef(name=a.name, location=a.location.name))
|
|
58
|
-
|
|
64
|
+
label = (nv.exact or nv.key) if strict else nv.key
|
|
65
|
+
display.setdefault(key, f"{a.type.value} {label}")
|
|
59
66
|
return _to_groups("address", buckets, display)
|
|
60
67
|
|
|
61
68
|
|
|
62
69
|
def find_duplicate_services(snapshot: Snapshot) -> list[DuplicateGroup]:
|
|
70
|
+
# Service dedup is already exact (protocol + normalized port lists); there
|
|
71
|
+
# is no host-bit-masking analogue, so no strict/loose distinction applies.
|
|
63
72
|
buckets: dict[str, list[ObjectRef]] = {}
|
|
64
73
|
display: dict[str, str] = {}
|
|
65
74
|
for s in snapshot.services:
|
|
@@ -93,10 +102,13 @@ def _rewrite_members(before: list[str], drop: str, keep: str) -> list[str]:
|
|
|
93
102
|
|
|
94
103
|
|
|
95
104
|
def _addr_value_key(snapshot: Snapshot, ref: ObjectRef) -> str | None:
|
|
105
|
+
# Exact (host-bit-preserving) key: the merge gate must treat a /24-masked
|
|
106
|
+
# host and the /24 network as *different* values, so it blocks the merge
|
|
107
|
+
# unless --allow-value-change is passed.
|
|
96
108
|
for a in snapshot.addresses:
|
|
97
109
|
if a.name == ref.name and a.location == ref.loc:
|
|
98
110
|
nv = normalize_address(a)
|
|
99
|
-
return nv.
|
|
111
|
+
return nv.exact_key() if nv else None
|
|
100
112
|
return None
|
|
101
113
|
|
|
102
114
|
|
|
@@ -41,11 +41,20 @@ class AddrValue:
|
|
|
41
41
|
range: tuple[int, int] | None = None
|
|
42
42
|
family: int | None = None
|
|
43
43
|
fqdn: str | None = None
|
|
44
|
+
exact: str | None = None
|
|
45
|
+
"""Host-bit-preserving canonical string for ip-netmask (e.g. `10.1.1.50/24`
|
|
46
|
+
stays distinct from `10.1.1.0/24`); `None` means `key` is already exact."""
|
|
44
47
|
|
|
45
48
|
def overlaps_key(self) -> str:
|
|
46
|
-
"""Kind-qualified key
|
|
49
|
+
"""Kind-qualified key grouping by *masked network* — loose: a host
|
|
50
|
+
written with a subnet mask collapses onto its network."""
|
|
47
51
|
return f"{self.kind.value}:{self.key}"
|
|
48
52
|
|
|
53
|
+
def exact_key(self) -> str:
|
|
54
|
+
"""Kind-qualified key grouping only *byte-identical* values — strict:
|
|
55
|
+
host bits are preserved, so `10.1.1.50/24` != `10.1.1.0/24`."""
|
|
56
|
+
return f"{self.kind.value}:{self.exact or self.key}"
|
|
57
|
+
|
|
49
58
|
|
|
50
59
|
def _as_network(value: str) -> IPNetwork | None:
|
|
51
60
|
try:
|
|
@@ -54,6 +63,16 @@ def _as_network(value: str) -> IPNetwork | None:
|
|
|
54
63
|
return None
|
|
55
64
|
|
|
56
65
|
|
|
66
|
+
def _as_interface(value: str) -> ipaddress.IPv4Interface | ipaddress.IPv6Interface | None:
|
|
67
|
+
"""Parse host-preserving: `10.1.1.50/24` keeps its host bits, `10.0.0.10`
|
|
68
|
+
becomes `10.0.0.10/32`. `.network` is the masked form (equals
|
|
69
|
+
`_as_network`), `str(...)` is the exact form. `None` if unparseable."""
|
|
70
|
+
try:
|
|
71
|
+
return ipaddress.ip_interface(value.strip())
|
|
72
|
+
except ValueError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
57
76
|
def _range_bounds(value: str) -> tuple[int, int, int] | None:
|
|
58
77
|
"""`a-b` -> (start_int, end_int, family); None if unparseable."""
|
|
59
78
|
if "-" not in value:
|
|
@@ -75,10 +94,19 @@ def normalize_address(addr: Address) -> AddrValue | None:
|
|
|
75
94
|
"""
|
|
76
95
|
v = addr.value.strip()
|
|
77
96
|
if addr.type is AddressType.IP_NETMASK:
|
|
78
|
-
|
|
79
|
-
|
|
97
|
+
# One parse yields both keys: `.network` is the masked grouping key
|
|
98
|
+
# (loose), `str(iface)` preserves host bits for the exact key (strict).
|
|
99
|
+
iface = _as_interface(v)
|
|
100
|
+
if iface is None:
|
|
80
101
|
return None
|
|
81
|
-
|
|
102
|
+
net = iface.network
|
|
103
|
+
return AddrValue(
|
|
104
|
+
kind=addr.type,
|
|
105
|
+
key=str(net),
|
|
106
|
+
network=net,
|
|
107
|
+
family=net.version,
|
|
108
|
+
exact=str(iface),
|
|
109
|
+
)
|
|
82
110
|
if addr.type is AddressType.IP_RANGE:
|
|
83
111
|
bounds = _range_bounds(v)
|
|
84
112
|
if bounds is None:
|
|
@@ -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.4"
|
|
4
4
|
description = "Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -29,7 +29,8 @@ psc [GLOBAL OPTS] <group> <command> [ARGS] [WRITE OPTS]
|
|
|
29
29
|
- **Global (context) options go *before* the group:** `-c/--config FILE`,
|
|
30
30
|
`-p/--profile NAME`, `-o/--output FMT`, `-d/--device-group NAME`, `--strict`,
|
|
31
31
|
`--debug`.
|
|
32
|
-
- **Write-execution options go *after* the command:** `--apply`, `--out PATH
|
|
32
|
+
- **Write-execution options go *after* the command:** `--apply`, `--out PATH`,
|
|
33
|
+
`-of/--output-format xml|set`.
|
|
33
34
|
|
|
34
35
|
```bash
|
|
35
36
|
psc -c panorama.xml -o json find ip 10.0.0.10
|
|
@@ -39,7 +40,20 @@ psc -c panorama.xml dedup merge --keep h-web1 --remove web-primary --apply --out
|
|
|
39
40
|
## Pick a source
|
|
40
41
|
|
|
41
42
|
- **Offline:** `-c panorama.xml` — an exported config. Read-only against the
|
|
42
|
-
device; `--apply` writes a *new* file via `--out
|
|
43
|
+
device; `--apply` writes a *new* file via `--out` (it never overwrites the
|
|
44
|
+
source export). Choose what that file holds with `-of/--output-format`:
|
|
45
|
+
- `xml` (default) — the whole config rewritten, loadable with `load config`.
|
|
46
|
+
- `set` — just the equivalent PAN-OS `set` script (the creates/deletes/
|
|
47
|
+
repoints that achieve the change). Easier to read and to paste into a
|
|
48
|
+
config session or `load config partial`. Use this when a human reviews the
|
|
49
|
+
change or you want a minimal, diff-like artifact; use `xml` when you need a
|
|
50
|
+
full, directly-loadable replacement config.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
psc -c cfg.xml dedup merge --keep a --remove b --apply --out plan.set -of set
|
|
54
|
+
```
|
|
55
|
+
(`-of` only shapes the `--out` file; for the script on stdout in a dry-run,
|
|
56
|
+
use `-o set` instead.)
|
|
43
57
|
- **Live:** `-p prod` — fetches the running config over the XML API. `--apply`
|
|
44
58
|
(no `--out`) pushes the plan to Panorama's **candidate** config and never
|
|
45
59
|
commits; you review and commit yourself.
|
|
@@ -64,7 +78,8 @@ like `10.0.0.0/8` would otherwise drown out the host you asked for.
|
|
|
64
78
|
### dedup — duplicates and merging
|
|
65
79
|
|
|
66
80
|
```bash
|
|
67
|
-
psc -c cfg.xml -o json dedup addresses #
|
|
81
|
+
psc -c cfg.xml -o json dedup addresses # strict: byte-identical values only
|
|
82
|
+
psc -c cfg.xml -o json dedup addresses --not-strict # also mask host bits (10.1.1.50/24 ~ 10.1.1.0/24)
|
|
68
83
|
psc -c cfg.xml -o json dedup services
|
|
69
84
|
psc -c cfg.xml -o json dedup merge --keep h-web1 --remove web-primary # dry-run plan
|
|
70
85
|
psc -c cfg.xml dedup merge --keep h-web1 --remove web-primary --apply --out fixed.xml
|
|
@@ -64,6 +64,36 @@ def test_strict_not_found_exit_5() -> None:
|
|
|
64
64
|
assert json.loads(cp.stdout)["type"] == "not_found"
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
_HOST_AND_NET_CONFIG = """<?xml version="1.0"?>
|
|
68
|
+
<config version="11.0.0">
|
|
69
|
+
<shared>
|
|
70
|
+
<address>
|
|
71
|
+
<entry name="host-with-mask"><ip-netmask>10.1.1.50/24</ip-netmask></entry>
|
|
72
|
+
<entry name="real-network"><ip-netmask>10.1.1.0/24</ip-netmask></entry>
|
|
73
|
+
</address>
|
|
74
|
+
</shared>
|
|
75
|
+
</config>
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_dedup_addresses_strict_default_finds_no_duplicates(tmp_path: Path) -> None:
|
|
80
|
+
cfg = tmp_path / "cfg.xml"
|
|
81
|
+
cfg.write_text(_HOST_AND_NET_CONFIG)
|
|
82
|
+
cp = run("-c", str(cfg), "-o", "json", "dedup", "addresses")
|
|
83
|
+
assert cp.returncode == 0
|
|
84
|
+
assert json.loads(cp.stdout) == []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_dedup_addresses_not_strict_groups_host_with_network(tmp_path: Path) -> None:
|
|
88
|
+
cfg = tmp_path / "cfg.xml"
|
|
89
|
+
cfg.write_text(_HOST_AND_NET_CONFIG)
|
|
90
|
+
cp = run("-c", str(cfg), "-o", "json", "dedup", "addresses", "--not-strict")
|
|
91
|
+
assert cp.returncode == 0
|
|
92
|
+
groups = json.loads(cp.stdout)
|
|
93
|
+
assert len(groups) == 1
|
|
94
|
+
assert {m["name"] for m in groups[0]["members"]} == {"host-with-mask", "real-network"}
|
|
95
|
+
|
|
96
|
+
|
|
67
97
|
def test_merge_dry_run_exit_0_writes_nothing(tmp_path: Path) -> None:
|
|
68
98
|
out = tmp_path / "x.xml"
|
|
69
99
|
cp = run(
|
|
@@ -122,6 +152,51 @@ def test_merge_apply_writes_out(tmp_path: Path) -> None:
|
|
|
122
152
|
assert "web-primary" not in out.read_text(encoding="utf-8")
|
|
123
153
|
|
|
124
154
|
|
|
155
|
+
def test_merge_apply_out_set_writes_set_script(tmp_path: Path) -> None:
|
|
156
|
+
out = tmp_path / "fixed.set"
|
|
157
|
+
cp = run(
|
|
158
|
+
"-c",
|
|
159
|
+
str(FIXTURE),
|
|
160
|
+
"dedup",
|
|
161
|
+
"merge",
|
|
162
|
+
"--keep",
|
|
163
|
+
"h-web1",
|
|
164
|
+
"--remove",
|
|
165
|
+
"web-primary",
|
|
166
|
+
"--apply",
|
|
167
|
+
"--out",
|
|
168
|
+
str(out),
|
|
169
|
+
"-of",
|
|
170
|
+
"set",
|
|
171
|
+
)
|
|
172
|
+
assert cp.returncode == 0
|
|
173
|
+
assert out.exists()
|
|
174
|
+
text = out.read_text(encoding="utf-8")
|
|
175
|
+
assert "delete shared address web-primary" in text
|
|
176
|
+
assert "<entry" not in text # the --out artifact is a set script, not XML
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_merge_apply_out_xml_is_default(tmp_path: Path) -> None:
|
|
180
|
+
out = tmp_path / "fixed.xml"
|
|
181
|
+
cp = run(
|
|
182
|
+
"-c",
|
|
183
|
+
str(FIXTURE),
|
|
184
|
+
"dedup",
|
|
185
|
+
"merge",
|
|
186
|
+
"--keep",
|
|
187
|
+
"h-web1",
|
|
188
|
+
"--remove",
|
|
189
|
+
"web-primary",
|
|
190
|
+
"--apply",
|
|
191
|
+
"--out",
|
|
192
|
+
str(out),
|
|
193
|
+
"--output-format",
|
|
194
|
+
"xml",
|
|
195
|
+
)
|
|
196
|
+
assert cp.returncode == 0
|
|
197
|
+
assert "web-primary" not in out.read_text(encoding="utf-8") # rewritten XML, object gone
|
|
198
|
+
|
|
199
|
+
|
|
125
200
|
def test_no_source_errors_config() -> None:
|
|
126
201
|
cp = run("-o", "json", "find", "ip", "10.0.0.10")
|
|
127
202
|
assert cp.returncode == 9
|