panorama-super-cli 0.4.0__tar.gz → 0.4.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.4.0 → panorama_super_cli-0.4.2}/CHANGELOG.md +24 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/PKG-INFO +1 -1
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/reference/cli.md +17 -0
- panorama_super_cli-0.4.2/docs/superpowers/specs/2026-06-08-move-objects-to-shared-design.md +143 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/_version.py +1 -1
- panorama_super_cli-0.4.2/psc/cli/_options.py +42 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/app.py +5 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/decommission_cmds.py +3 -6
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/dedup_cmds.py +12 -20
- panorama_super_cli-0.4.2/psc/cli/move_cmds.py +64 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/name_cmds.py +3 -12
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/refs_cmds.py +3 -4
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/rule_cmds.py +4 -9
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/set_cmds.py +22 -28
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/config/loader.py +3 -1
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/apply_live.py +2 -1
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/apply_xml.py +2 -1
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/changeset.py +10 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/crud.py +14 -9
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/decommission.py +24 -17
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/dedup.py +0 -4
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/normalize.py +15 -8
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/parse.py +8 -7
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/refs.py +10 -14
- panorama_super_cli-0.4.2/psc/core/relocate.py +308 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/setcmd.py +2 -1
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/source.py +32 -32
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/output/format.py +3 -6
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/pyproject.toml +1 -1
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/skills/panorama-super-cli/SKILL.md +20 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_changeset.py +11 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli.py +24 -0
- panorama_super_cli-0.4.2/tests/test_cli_move.py +143 -0
- panorama_super_cli-0.4.2/tests/test_relocate.py +246 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/uv.lock +577 -577
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/CODEOWNERS +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/dependabot.yml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/workflows/docs.yml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/workflows/lint.yml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/workflows/release.yml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/workflows/test.yml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.gitignore +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.pre-commit-config.yaml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.python-version +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/AGENTS.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/CLAUDE.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/LICENSE +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/README.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/contributing/branching.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/contributing/development.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/contributing/release-process.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/getting-started/concepts.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/getting-started/first-run.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/getting-started/install.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/coverage-and-limitations.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/duplicates-and-merging.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/editing-objects.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/finding-objects.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/live-vs-offline.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/naming.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/output-formats.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/references-and-audit.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/safety.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/using-with-ai-agents.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/index.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/reference/config.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/reference/exit-codes.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/superpowers/specs/2026-06-04-where-used-all-rulebases-design.md +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/justfile +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/mkdocs.yml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/__init__.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/__main__.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/__init__.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/_plan.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/audit_cmds.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/auth_cmds.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/find_cmds.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/profile_cmds.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/runtime.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/version_cmds.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/config/__init__.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/config/models.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/__init__.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/audit.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/models.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/naming.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/resolve.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/rule_edit.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/rulebases.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/version_check.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/output/__init__.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/output/errors.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/scripts/sync_agents_md.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/__init__.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/conftest.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/fixtures/all-rulebases.xml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/fixtures/decommission-config.xml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/fixtures/dedup-groups.xml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/fixtures/nested-device-groups.xml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/fixtures/panorama-config.xml +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_apply_xml.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_audit.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_auth.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_audit.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_auth.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_decommission.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_profile.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_rule.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_set.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_crud.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_decommission.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_dedup.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_hardening.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_live_apply.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_naming.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_nested_dg.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_normalize.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_output.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_parse.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_refs.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_resolve.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_rule_edit.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_rulebases.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_setcmd.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_source.py +0 -0
- {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_version_check.py +0 -0
|
@@ -7,6 +7,30 @@ project will follow [Semantic Versioning](https://semver.org/). While on
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## v0.4.2 — 2026-06-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`psc move <kind> <name> --from <loc> --to <loc>`** (#74) — promote an
|
|
15
|
+
object (`address`/`address-group`/`service`/`service-group`/`tag`) from a
|
|
16
|
+
device-group toward `shared`. Restricted to the safe direction: `--to` must be
|
|
17
|
+
`shared` or an *ancestor* of `--from`, where references fall through to the
|
|
18
|
+
destination with no repoint. Blocks (exit `6`) on a sibling/child/unrelated
|
|
19
|
+
destination (would orphan references), an intermediate device-group that
|
|
20
|
+
already defines the name (a shadow), the object's own dependencies
|
|
21
|
+
(members/tags) not being visible at the destination, or a collision with a
|
|
22
|
+
different-valued object already there. A collision with an identical-valued
|
|
23
|
+
object simply drops the source copy. Dry-run by default; offline `--apply`
|
|
24
|
+
round-trips through the rewritten config.
|
|
25
|
+
|
|
26
|
+
## v0.4.1 — 2026-06-08
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **`psc dedup addresses|services|groups`** (#72) — the table view now draws a
|
|
31
|
+
horizontal rule between each group of duplicates, so the blocks are easy to
|
|
32
|
+
tell apart at a glance. Machine formats (json/jsonl/yaml/csv) are unchanged.
|
|
33
|
+
|
|
10
34
|
## v0.4.0 — 2026-06-05
|
|
11
35
|
|
|
12
36
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: panorama-super-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.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/
|
|
@@ -150,6 +150,23 @@ NAT-translation/PBF-next-hop references and DAG-filter-tag matches; orphan-rule
|
|
|
150
150
|
deletions are warnings. See
|
|
151
151
|
[Editing objects](../guides/editing-objects.md#decommission-an-address).
|
|
152
152
|
|
|
153
|
+
### move
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
psc move <address|address-group|service|service-group|tag> <name>
|
|
157
|
+
--from <shared|DG> --to <shared|DG> [--apply] [--out PATH] [-of xml|set]
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Promote one object from a device-group toward `shared` (create at the
|
|
161
|
+
destination, delete at the source). `--to` must be `shared` or an *ancestor* of
|
|
162
|
+
`--from` — the only direction in which references fall through to the
|
|
163
|
+
destination with no repoint. Blocks (exit `6`) on a sibling/child/unrelated
|
|
164
|
+
destination, an intermediate device-group that already defines the name (a
|
|
165
|
+
shadow), the object's own dependencies (members/tags) not being visible at the
|
|
166
|
+
destination, or a collision with a different-valued object already there. An
|
|
167
|
+
identical-valued collision drops the source copy. Single object per run;
|
|
168
|
+
dry-run by default.
|
|
169
|
+
|
|
153
170
|
### init
|
|
154
171
|
|
|
155
172
|
```
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# move objects toward shared — design (issue #74)
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
While cleaning a Panorama, objects are routinely stranded in a device-group
|
|
6
|
+
when their natural home is `shared` (or a parent device-group that several
|
|
7
|
+
siblings inherit from). Today `psc` can find, dedup, rename, audit, and
|
|
8
|
+
decommission objects, but it cannot *relocate* one — the user has to delete and
|
|
9
|
+
recreate by hand, which is exactly the reference-unsafe operation `psc` exists
|
|
10
|
+
to make safe.
|
|
11
|
+
|
|
12
|
+
A "move" is, in `ChangeSet` terms, **create-at-destination + delete-at-source**.
|
|
13
|
+
The danger is identical to merge/rename: references are by *name*, resolved up
|
|
14
|
+
the device-group chain (`Snapshot.ancestors`), with nearer definitions
|
|
15
|
+
shadowing farther ones. A naive move can dangle references or silently change
|
|
16
|
+
which object a rule matches.
|
|
17
|
+
|
|
18
|
+
## Scope (confirmed with the user)
|
|
19
|
+
|
|
20
|
+
1. **Direction: promote *toward* shared only.** `--to` must be `shared` or a
|
|
21
|
+
*strict ancestor* of `--from`. This is the safe-by-construction direction:
|
|
22
|
+
`shared`/an ancestor is in the chain of every location in `--from`'s subtree
|
|
23
|
+
and is *farther* than `--from`, so any reference that resolved to the object
|
|
24
|
+
at `--from` still resolves to it at the destination — **no repoint needed**.
|
|
25
|
+
A sibling / unrelated / child destination would orphan references in
|
|
26
|
+
`--from`'s scope, so it is a hard **blocker**, not supported in v1.
|
|
27
|
+
2. **Collision at destination → identical-value merges, different-value blocks.**
|
|
28
|
+
Because the move only promotes *toward* shared, an identical-value collision
|
|
29
|
+
is just a **delete-source**: the destination object already holds the value,
|
|
30
|
+
so removing the source copy lets every reference in the source subtree fall
|
|
31
|
+
through (ordinary shadowing) onto the destination object — no repoint, no
|
|
32
|
+
second copy. This works uniformly for all five kinds, so v1 supports the
|
|
33
|
+
identical-value merge everywhere, not only addresses.
|
|
34
|
+
- "identical value" per kind: address → `AddressType` + normalized value;
|
|
35
|
+
service → protocol + normalized ports (`normalize.service_key`);
|
|
36
|
+
address-group → same static members (set) + same dynamic filter;
|
|
37
|
+
service-group → same members (set); tag → always mergeable (a tag carries
|
|
38
|
+
no match-affecting value; a differing colour/comment is a warning).
|
|
39
|
+
- different value → **blocker** (consolidate or rename first).
|
|
40
|
+
|
|
41
|
+
We deliberately do *not* delegate to `dedup.plan_merge`: there the source
|
|
42
|
+
object *shadows* the destination one in the source scope, so dedup's
|
|
43
|
+
"kept object visible here" check would wrongly block. The delete-source +
|
|
44
|
+
fall-through plan is both simpler and correct for the promote direction.
|
|
45
|
+
3. **Dependencies must already be visible at the destination — no auto-cascade.**
|
|
46
|
+
The moved object's own downward references (a group's `static` members, an
|
|
47
|
+
object's `tags`, a service-group's members, a dynamic group's filter tags)
|
|
48
|
+
must each resolve from the destination. Any that don't → **blocker** listing
|
|
49
|
+
exactly what to move first.
|
|
50
|
+
4. **Single object per invocation.** Bulk/filtered moves are a follow-up.
|
|
51
|
+
5. Dry-run by default; `--apply` to commit; same `complete()` contract as every
|
|
52
|
+
other mutating command.
|
|
53
|
+
|
|
54
|
+
## Key insight
|
|
55
|
+
|
|
56
|
+
Because the move is restricted to *promote-toward-shared*, the create-at-dest +
|
|
57
|
+
delete-at-source plan needs **zero reference edits**. Every reference that
|
|
58
|
+
pointed at the object falls through, by ordinary PAN-OS shadowing, onto the new
|
|
59
|
+
(farther-up) definition once the source copy is removed — *provided* nothing
|
|
60
|
+
between source and destination already defines the name. That one caveat is the
|
|
61
|
+
**intermediate-shadow guard**: if any device-group strictly between `--from` and
|
|
62
|
+
`--to` already defines the name (same namespace), removing the source would
|
|
63
|
+
re-resolve references to that intermediate instead of the destination —
|
|
64
|
+
**blocker**.
|
|
65
|
+
|
|
66
|
+
This is why the engine is small: the two genuinely hard cases (collision,
|
|
67
|
+
shadowing) are delegated to `dedup` / handled by a single guard; the common
|
|
68
|
+
case is one `ObjectUpsert` + one `ObjectDelete`.
|
|
69
|
+
|
|
70
|
+
## Architecture
|
|
71
|
+
|
|
72
|
+
New engine `psc/core/relocate.py` (pure, returns a `ChangeSet`) + one thin CLI
|
|
73
|
+
command `psc/cli/move_cmds.py`. No new `ChangeSet` op kind, no new applier code —
|
|
74
|
+
`ObjectUpsert`/`ObjectDelete` already render (`setcmd`) and apply
|
|
75
|
+
(`apply_xml`/`apply_live`).
|
|
76
|
+
|
|
77
|
+
### `relocate.plan_move(snapshot, graph, *, kind, name, source_name, dest_name) -> ChangeSet`
|
|
78
|
+
|
|
79
|
+
Order of checks (each returns a blocked, zero-op `ChangeSet` on failure, per the
|
|
80
|
+
repo invariant):
|
|
81
|
+
|
|
82
|
+
1. **Existence.** Object of `kind` named `name` defined *directly* at `source`.
|
|
83
|
+
Absent → blocker.
|
|
84
|
+
2. **Direction.** `dest` must be in `ancestors(source)` and `dest != source`.
|
|
85
|
+
Otherwise → blocker ("`move` only promotes toward shared…").
|
|
86
|
+
3. **Intermediate-shadow guard.** No location strictly between `source` and
|
|
87
|
+
`dest` may already define `name` in the object's namespace → blocker.
|
|
88
|
+
4. **Dependency gate.** Every downward dependency must `graph.resolve(...)` from
|
|
89
|
+
`dest`. Unresolved ones → blocker listing them.
|
|
90
|
+
5. **Collision dispatch.**
|
|
91
|
+
- destination has no same-kind `name` → **clean promote**: build the
|
|
92
|
+
destination `ObjectUpsert` by calling the matching `crud.plan_*` with
|
|
93
|
+
`location=dest` (reuses validation + the leaf-key contract + cross-kind
|
|
94
|
+
collision check), then append `ObjectDelete` at `source`.
|
|
95
|
+
- destination has same-kind `name`, identical value → **merge by
|
|
96
|
+
delete-source**: emit only `ObjectDelete` at `source` (no upsert), with a
|
|
97
|
+
warning that references now resolve to the destination object (and a second
|
|
98
|
+
warning if cosmetic metadata — tag colour/comment — differs).
|
|
99
|
+
- destination has same-kind `name`, different value → blocker.
|
|
100
|
+
In both non-blocked cases, if destination is `shared`, add a warning naming
|
|
101
|
+
any currently-*dangling* references elsewhere that will now newly resolve to
|
|
102
|
+
the promoted object.
|
|
103
|
+
6. Enforce the zero-op-when-blocked invariant.
|
|
104
|
+
|
|
105
|
+
The namespace per kind mirrors `naming.plan_rename` / `refs`: address &
|
|
106
|
+
address-group → `address`, service & service-group → `service`, tag → `tag`.
|
|
107
|
+
|
|
108
|
+
### CLI: `psc move <kind> <name> --from <loc> --to <loc> [--apply] [--out] [-of]`
|
|
109
|
+
|
|
110
|
+
`kind` is an `ObjectKind` positional (`address|address-group|service|service-group|tag`);
|
|
111
|
+
`--from`/`--to` are required location strings (`shared` or a device-group name);
|
|
112
|
+
dry-run by default. Wired as a single top-level command (like `decommission`),
|
|
113
|
+
not a sub-app. Delegates rendering/apply to `cli/_plan.complete`.
|
|
114
|
+
|
|
115
|
+
## Testing (TDD; safety-critical paths first)
|
|
116
|
+
|
|
117
|
+
Engine (`tests/test_relocate.py`):
|
|
118
|
+
- clean promote DG→shared: one upsert@shared (exists=False) + one delete@source;
|
|
119
|
+
no reference edits; referrers still resolve (assert via a rebuilt graph or by
|
|
120
|
+
inspecting ops).
|
|
121
|
+
- promote DG→ancestor DG (nested fixture).
|
|
122
|
+
- sibling/child/unrelated destination → blocked.
|
|
123
|
+
- dest == source → blocked.
|
|
124
|
+
- object absent at source → blocked.
|
|
125
|
+
- collision, identical value → merge plan (delete source, no second copy).
|
|
126
|
+
- collision, different value → blocked.
|
|
127
|
+
- intermediate-shadow guard → blocked.
|
|
128
|
+
- dependency not visible at dest (group member / tag) → blocked, names listed.
|
|
129
|
+
- promote-to-shared revives a sibling dangling ref → warning emitted.
|
|
130
|
+
|
|
131
|
+
CLI (`tests/test_cli_move.py`):
|
|
132
|
+
- dry-run prints plan, exits 0, writes nothing.
|
|
133
|
+
- `--apply --out` offline round-trips (object now under destination scope, gone
|
|
134
|
+
from source) using `apply_xml`.
|
|
135
|
+
- blocked plan → exit 6 (`CONFLICT`), nothing written.
|
|
136
|
+
- `-o set` renders create+delete lines.
|
|
137
|
+
|
|
138
|
+
## Out of scope (v1)
|
|
139
|
+
|
|
140
|
+
- Moving *away* from shared / to a sibling or child (would require repointing or
|
|
141
|
+
copying — deliberately blocked).
|
|
142
|
+
- Auto-cascading dependencies.
|
|
143
|
+
- Bulk / filtered selection.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared Typer Option constants and location helpers for the CLI layer.
|
|
2
|
+
|
|
3
|
+
These canonical Option objects exist so the mutating commands that genuinely
|
|
4
|
+
share a flag (`--apply`, `--out`, `--location`) declare it once. They are only
|
|
5
|
+
reused where the flags, defaults, AND help text are byte-identical — commands
|
|
6
|
+
that phrase their `--apply`/`--location`/`--out` help differently keep their own
|
|
7
|
+
local Option (sharing would silently change `--help`).
|
|
8
|
+
|
|
9
|
+
The two helpers collapse the shared-aware `Location` conversion duplicated
|
|
10
|
+
across command modules. They live here (in `cli/`, never `core/`) because the
|
|
11
|
+
"shared" sentinel string is a CLI-surface concern, not a domain-model one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from psc.core.models import Location
|
|
19
|
+
|
|
20
|
+
# Canonical mutating-command flags. Shared only where help/default are identical
|
|
21
|
+
# to these strings (currently `psc set` for all three; the `--out` help is also
|
|
22
|
+
# shared by dedup/name/rule, whose wording matches verbatim).
|
|
23
|
+
LOCATION_OPTION = typer.Option(
|
|
24
|
+
None, "--location", help="Target location (default: global --device-group, else shared)."
|
|
25
|
+
)
|
|
26
|
+
APPLY_OPTION = typer.Option(False, "--apply", help="Execute the change (default: dry-run).")
|
|
27
|
+
OUT_OPTION = typer.Option(
|
|
28
|
+
None,
|
|
29
|
+
"--out",
|
|
30
|
+
help="Write the plan artifact (set script or rewritten config) to this file; "
|
|
31
|
+
"honoured even in a dry-run (see --output-format).",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def location_from_name(name: str) -> Location:
|
|
36
|
+
"""Convert a location *name* to a `Location`, honouring the "shared" sentinel."""
|
|
37
|
+
return Location.shared() if name == "shared" else Location.dg(name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def optional_location(name: str | None) -> Location | None:
|
|
41
|
+
"""Like `location_from_name`, but pass `None` through unchanged (no scope)."""
|
|
42
|
+
return None if name is None else location_from_name(name)
|
|
@@ -16,6 +16,7 @@ from psc.cli import (
|
|
|
16
16
|
decommission_cmds,
|
|
17
17
|
dedup_cmds,
|
|
18
18
|
find_cmds,
|
|
19
|
+
move_cmds,
|
|
19
20
|
name_cmds,
|
|
20
21
|
profile_cmds,
|
|
21
22
|
refs_cmds,
|
|
@@ -153,6 +154,10 @@ app.command(
|
|
|
153
154
|
"decommission",
|
|
154
155
|
help="Reference-safe teardown of address objects matching an IP/CIDR.",
|
|
155
156
|
)(decommission_cmds.decommission)
|
|
157
|
+
app.command(
|
|
158
|
+
"move",
|
|
159
|
+
help="Promote an object toward shared (to shared or an ancestor device-group).",
|
|
160
|
+
)(move_cmds.move)
|
|
156
161
|
app.command(
|
|
157
162
|
"init",
|
|
158
163
|
help="Interactively bootstrap a live profile (fetches an API key from a username/password).",
|
|
@@ -12,10 +12,11 @@ from pathlib import Path
|
|
|
12
12
|
|
|
13
13
|
import typer
|
|
14
14
|
|
|
15
|
+
from psc.cli._options import location_from_name
|
|
15
16
|
from psc.cli._plan import OUT_FORMAT_OPTION, complete
|
|
16
17
|
from psc.cli.runtime import Runtime
|
|
17
18
|
from psc.core.decommission import plan_decommission
|
|
18
|
-
from psc.core.models import Address
|
|
19
|
+
from psc.core.models import Address
|
|
19
20
|
from psc.core.normalize import MatchKind
|
|
20
21
|
from psc.core.refs import ReferenceGraph
|
|
21
22
|
from psc.core.resolve import find_ips
|
|
@@ -86,7 +87,7 @@ def decommission(
|
|
|
86
87
|
raise PscError("provide one or more IP/CIDR/range targets, or --file", ErrorType.VALIDATION)
|
|
87
88
|
|
|
88
89
|
snap = rt.snapshot()
|
|
89
|
-
loc =
|
|
90
|
+
loc = location_from_name(scope) if scope is not None else rt.scope()
|
|
90
91
|
|
|
91
92
|
results = find_ips(snap, items, loc)
|
|
92
93
|
index = snap.address_index()
|
|
@@ -109,7 +110,3 @@ def decommission(
|
|
|
109
110
|
snap, graph, matched, scope=loc, keep_groups=keep_groups, keep_rules=keep_rules
|
|
110
111
|
)
|
|
111
112
|
complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _resolve_scope(name: str) -> Location:
|
|
115
|
-
return Location.shared() if name == "shared" else Location.dg(name)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
6
|
|
|
7
|
+
from psc.cli._options import OUT_OPTION, optional_location
|
|
7
8
|
from psc.cli._plan import OUT_FORMAT_OPTION, complete
|
|
8
9
|
from psc.cli.runtime import Runtime
|
|
9
10
|
from psc.core.dedup import (
|
|
@@ -15,7 +16,6 @@ from psc.core.dedup import (
|
|
|
15
16
|
plan_merge,
|
|
16
17
|
plan_merge_group,
|
|
17
18
|
)
|
|
18
|
-
from psc.core.models import Location
|
|
19
19
|
from psc.core.refs import ReferenceGraph
|
|
20
20
|
from psc.core.source import ConfigFormat
|
|
21
21
|
from psc.output.errors import ErrorType, PscError
|
|
@@ -58,6 +58,7 @@ def addresses(
|
|
|
58
58
|
model=groups,
|
|
59
59
|
rows=_dup_rows(groups),
|
|
60
60
|
table_title="duplicate addresses",
|
|
61
|
+
group_by="group",
|
|
61
62
|
)
|
|
62
63
|
|
|
63
64
|
|
|
@@ -69,7 +70,12 @@ def services(ctx: typer.Context) -> None:
|
|
|
69
70
|
if rt.strict and not groups:
|
|
70
71
|
raise PscError("no duplicate services", ErrorType.NOT_FOUND)
|
|
71
72
|
render(
|
|
72
|
-
rt.stdout,
|
|
73
|
+
rt.stdout,
|
|
74
|
+
rt.output,
|
|
75
|
+
model=groups,
|
|
76
|
+
rows=_dup_rows(groups),
|
|
77
|
+
table_title="duplicate services",
|
|
78
|
+
group_by="group",
|
|
73
79
|
)
|
|
74
80
|
|
|
75
81
|
|
|
@@ -94,12 +100,7 @@ def groups(
|
|
|
94
100
|
rt: Runtime = ctx.obj
|
|
95
101
|
snap = rt.snapshot()
|
|
96
102
|
graph = ReferenceGraph.build(snap)
|
|
97
|
-
|
|
98
|
-
loc = (
|
|
99
|
-
None
|
|
100
|
-
if loc_name is None
|
|
101
|
-
else (Location.shared() if loc_name == "shared" else Location.dg(loc_name))
|
|
102
|
-
)
|
|
103
|
+
loc = optional_location(location or rt.device_group)
|
|
103
104
|
result = find_duplicate_groups(snap, graph, loc)
|
|
104
105
|
if result.dynamic_skipped or result.unresolvable_skipped:
|
|
105
106
|
rt.stderr.print(
|
|
@@ -116,6 +117,7 @@ def groups(
|
|
|
116
117
|
model=result.buckets,
|
|
117
118
|
rows=_dup_rows(result.buckets),
|
|
118
119
|
table_title="duplicate address-groups",
|
|
120
|
+
group_by="group",
|
|
119
121
|
)
|
|
120
122
|
|
|
121
123
|
|
|
@@ -133,12 +135,7 @@ def merge(
|
|
|
133
135
|
False, "--allow-value-change", help="Permit merging objects with different values."
|
|
134
136
|
),
|
|
135
137
|
apply: bool = typer.Option(False, "--apply", help="Execute the merge (default: dry-run)."),
|
|
136
|
-
out: str | None =
|
|
137
|
-
None,
|
|
138
|
-
"--out",
|
|
139
|
-
help="Write the plan artifact (set script or rewritten config) to this "
|
|
140
|
-
"file; honoured even in a dry-run (see --output-format).",
|
|
141
|
-
),
|
|
138
|
+
out: str | None = OUT_OPTION,
|
|
142
139
|
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
143
140
|
) -> None:
|
|
144
141
|
"""Collapse one address object into another, repointing every reference.
|
|
@@ -174,12 +171,7 @@ def merge_group(
|
|
|
174
171
|
keep_location: str | None = typer.Option(None, "--keep-location"),
|
|
175
172
|
remove_location: str | None = typer.Option(None, "--remove-location"),
|
|
176
173
|
apply: bool = typer.Option(False, "--apply", help="Execute the merge (default: dry-run)."),
|
|
177
|
-
out: str | None =
|
|
178
|
-
None,
|
|
179
|
-
"--out",
|
|
180
|
-
help="Write the plan artifact (set script or rewritten config) to this "
|
|
181
|
-
"file; honoured even in a dry-run (see --output-format).",
|
|
182
|
-
),
|
|
174
|
+
out: str | None = OUT_OPTION,
|
|
183
175
|
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
184
176
|
) -> None:
|
|
185
177
|
"""Collapse one address-group into an equivalent one, repointing references.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""`psc move` — promote an object toward shared, reference-safely (issue #74).
|
|
2
|
+
|
|
3
|
+
A thin shell over `core.relocate.plan_move`: resolve the runtime source, build
|
|
4
|
+
the reference graph, plan the promotion, and hand the `ChangeSet` to the shared
|
|
5
|
+
dry-run/apply completion. All the safety logic lives in the engine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from psc.cli._plan import OUT_FORMAT_OPTION, complete
|
|
13
|
+
from psc.cli.runtime import Runtime
|
|
14
|
+
from psc.core.changeset import ObjectKind
|
|
15
|
+
from psc.core.refs import ReferenceGraph
|
|
16
|
+
from psc.core.relocate import plan_move
|
|
17
|
+
from psc.core.source import ConfigFormat
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def move(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
kind: ObjectKind = typer.Argument(
|
|
23
|
+
..., help="Object kind: address, address-group, service, service-group, or tag."
|
|
24
|
+
),
|
|
25
|
+
name: str = typer.Argument(..., help="Name of the object to move."),
|
|
26
|
+
from_location: str = typer.Option(
|
|
27
|
+
..., "--from", help="Current location: 'shared' or a device-group name."
|
|
28
|
+
),
|
|
29
|
+
to_location: str = typer.Option(
|
|
30
|
+
...,
|
|
31
|
+
"--to",
|
|
32
|
+
help="Destination: 'shared' or an ancestor device-group of --from. "
|
|
33
|
+
"move only promotes toward shared.",
|
|
34
|
+
),
|
|
35
|
+
apply: bool = typer.Option(False, "--apply", help="Execute the move (default: dry-run)."),
|
|
36
|
+
out: str | None = typer.Option(
|
|
37
|
+
None,
|
|
38
|
+
"--out",
|
|
39
|
+
help="Write the plan artifact (set script or rewritten config) to this file.",
|
|
40
|
+
),
|
|
41
|
+
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Promote an object from a device-group toward `shared`.
|
|
44
|
+
|
|
45
|
+
A move is create-at-destination + delete-at-source. It is restricted to the
|
|
46
|
+
safe direction — `--to` must be `shared` or an *ancestor* of `--from` — so
|
|
47
|
+
references fall through to the destination without any repoint. Dry-run by
|
|
48
|
+
default; refuses (exit 6) if the move would orphan references, hit a
|
|
49
|
+
shadowing device-group between source and destination, leave the object's
|
|
50
|
+
own dependencies unresolved at the destination, or collide with a
|
|
51
|
+
different-valued object already at the destination.
|
|
52
|
+
"""
|
|
53
|
+
rt: Runtime = ctx.obj
|
|
54
|
+
snap = rt.snapshot()
|
|
55
|
+
graph = ReferenceGraph.build(snap)
|
|
56
|
+
cs = plan_move(
|
|
57
|
+
snap,
|
|
58
|
+
graph,
|
|
59
|
+
kind=kind,
|
|
60
|
+
name=name,
|
|
61
|
+
source_name=from_location,
|
|
62
|
+
dest_name=to_location,
|
|
63
|
+
)
|
|
64
|
+
complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
6
|
|
|
7
|
+
from psc.cli._options import OUT_OPTION
|
|
7
8
|
from psc.cli._plan import OUT_FORMAT_OPTION, complete
|
|
8
9
|
from psc.cli.runtime import Runtime
|
|
9
10
|
from psc.core.changeset import ObjectKind
|
|
@@ -50,12 +51,7 @@ def rename(
|
|
|
50
51
|
kind: ObjectKind = typer.Option(ObjectKind.ADDRESS, "--kind"),
|
|
51
52
|
location: str | None = typer.Option(None, "--location"),
|
|
52
53
|
apply: bool = typer.Option(False, "--apply", help="Execute the rename (default: dry-run)."),
|
|
53
|
-
out: str | None =
|
|
54
|
-
None,
|
|
55
|
-
"--out",
|
|
56
|
-
help="Write the plan artifact (set script or rewritten config) to this "
|
|
57
|
-
"file; honoured even in a dry-run (see --output-format).",
|
|
58
|
-
),
|
|
54
|
+
out: str | None = OUT_OPTION,
|
|
59
55
|
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
60
56
|
) -> None:
|
|
61
57
|
"""Rename one object, repointing every reference (refuses on shadow collisions)."""
|
|
@@ -73,12 +69,7 @@ def apply_scheme(
|
|
|
73
69
|
object_name: str = typer.Option(..., "--object", help="Object to rename to its scheme name."),
|
|
74
70
|
location: str | None = typer.Option(None, "--location"),
|
|
75
71
|
apply: bool = typer.Option(False, "--apply", help="Execute the rename (default: dry-run)."),
|
|
76
|
-
out: str | None =
|
|
77
|
-
None,
|
|
78
|
-
"--out",
|
|
79
|
-
help="Write the plan artifact (set script or rewritten config) to this "
|
|
80
|
-
"file; honoured even in a dry-run (see --output-format).",
|
|
81
|
-
),
|
|
72
|
+
out: str | None = OUT_OPTION,
|
|
82
73
|
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
83
74
|
) -> None:
|
|
84
75
|
"""Rename one object to the name the configured scheme implies for its value."""
|
|
@@ -4,8 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
6
|
|
|
7
|
+
from psc.cli._options import location_from_name
|
|
7
8
|
from psc.cli.runtime import Runtime
|
|
8
|
-
from psc.core.models import Location
|
|
9
9
|
from psc.core.refs import ReferenceGraph
|
|
10
10
|
from psc.core.resolve import find_object
|
|
11
11
|
from psc.output.errors import ErrorType, PscError
|
|
@@ -41,7 +41,7 @@ def used(
|
|
|
41
41
|
kind = kind or hits[0].kind
|
|
42
42
|
location = location or hits[0].location
|
|
43
43
|
|
|
44
|
-
loc =
|
|
44
|
+
loc = location_from_name(location)
|
|
45
45
|
refs = graph.where_used(kind, name, loc)
|
|
46
46
|
rows = [
|
|
47
47
|
{
|
|
@@ -68,10 +68,9 @@ def unused(
|
|
|
68
68
|
graph = ReferenceGraph.build(rt.snapshot())
|
|
69
69
|
targets = graph.unused(kind)
|
|
70
70
|
rows = [{"kind": t.kind, "name": t.name, "location": t.location.name} for t in targets]
|
|
71
|
-
model = [{"kind": t.kind, "name": t.name, "location": t.location.name} for t in targets]
|
|
72
71
|
if rt.strict and not targets:
|
|
73
72
|
raise PscError(f"no unused {kind}", ErrorType.NOT_FOUND)
|
|
74
|
-
render(rt.stdout, rt.output, model=
|
|
73
|
+
render(rt.stdout, rt.output, model=rows, rows=rows, table_title=f"unused {kind}")
|
|
75
74
|
if targets:
|
|
76
75
|
# `unused` only sees device-group objects + policy rulebases. Objects
|
|
77
76
|
# referenced from templates/network config, NAT-rule tags, or matched
|
|
@@ -4,9 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
6
|
|
|
7
|
+
from psc.cli._options import OUT_OPTION, location_from_name
|
|
7
8
|
from psc.cli._plan import OUT_FORMAT_OPTION, complete
|
|
8
9
|
from psc.cli.runtime import Runtime
|
|
9
|
-
from psc.core.models import
|
|
10
|
+
from psc.core.models import Rulebase
|
|
10
11
|
from psc.core.rule_edit import plan_rule_member_edit
|
|
11
12
|
from psc.core.source import ConfigFormat
|
|
12
13
|
from psc.output.errors import ErrorType, PscError
|
|
@@ -32,12 +33,7 @@ def edit_member(
|
|
|
32
33
|
add: str | None = typer.Option(None, "--add", help="Member to add (idempotent)."),
|
|
33
34
|
remove: str | None = typer.Option(None, "--remove", help="Member to remove (idempotent)."),
|
|
34
35
|
apply: bool = typer.Option(False, "--apply", help="Execute the edit (default: dry-run)."),
|
|
35
|
-
out: str | None =
|
|
36
|
-
None,
|
|
37
|
-
"--out",
|
|
38
|
-
help="Write the plan artifact (set script or rewritten config) to this "
|
|
39
|
-
"file; honoured even in a dry-run (see --output-format).",
|
|
40
|
-
),
|
|
36
|
+
out: str | None = OUT_OPTION,
|
|
41
37
|
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
42
38
|
) -> None:
|
|
43
39
|
"""Add or remove one member of a rule field, idempotently.
|
|
@@ -58,8 +54,7 @@ def edit_member(
|
|
|
58
54
|
f"invalid --rulebase '{rulebase}' (choose pre or post)", ErrorType.VALIDATION
|
|
59
55
|
) from exc
|
|
60
56
|
|
|
61
|
-
|
|
62
|
-
loc = Location.shared() if loc_name == "shared" else Location.dg(loc_name)
|
|
57
|
+
loc = location_from_name(location or rt.device_group or "shared")
|
|
63
58
|
|
|
64
59
|
cs = plan_rule_member_edit(rt.snapshot(), rule, loc, rb, field, add=add, remove=remove)
|
|
65
60
|
complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
|
|
@@ -16,6 +16,12 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import typer
|
|
18
18
|
|
|
19
|
+
from psc.cli._options import (
|
|
20
|
+
APPLY_OPTION,
|
|
21
|
+
LOCATION_OPTION,
|
|
22
|
+
OUT_OPTION,
|
|
23
|
+
location_from_name,
|
|
24
|
+
)
|
|
19
25
|
from psc.cli._plan import OUT_FORMAT_OPTION, complete
|
|
20
26
|
from psc.cli.runtime import Runtime
|
|
21
27
|
from psc.core import crud
|
|
@@ -24,21 +30,9 @@ from psc.core.source import ConfigFormat
|
|
|
24
30
|
|
|
25
31
|
app = typer.Typer(no_args_is_help=True)
|
|
26
32
|
|
|
27
|
-
_LOCATION_OPTION = typer.Option(
|
|
28
|
-
None, "--location", help="Target location (default: global --device-group, else shared)."
|
|
29
|
-
)
|
|
30
|
-
_APPLY_OPTION = typer.Option(False, "--apply", help="Execute the change (default: dry-run).")
|
|
31
|
-
_OUT_OPTION = typer.Option(
|
|
32
|
-
None,
|
|
33
|
-
"--out",
|
|
34
|
-
help="Write the plan artifact (set script or rewritten config) to this file; "
|
|
35
|
-
"honoured even in a dry-run (see --output-format).",
|
|
36
|
-
)
|
|
37
|
-
|
|
38
33
|
|
|
39
34
|
def _resolve_location(rt: Runtime, location: str | None) -> Location:
|
|
40
|
-
|
|
41
|
-
return Location.shared() if name == "shared" else Location.dg(name)
|
|
35
|
+
return location_from_name(location or rt.device_group or "shared")
|
|
42
36
|
|
|
43
37
|
|
|
44
38
|
@app.command("address")
|
|
@@ -51,9 +45,9 @@ def address(
|
|
|
51
45
|
value: str = typer.Option(..., "--value", help="The address value (stored verbatim)."),
|
|
52
46
|
description: str | None = typer.Option(None, "--description"),
|
|
53
47
|
tag: list[str] = typer.Option([], "--tag", help="Tag name (repeatable)."),
|
|
54
|
-
location: str | None =
|
|
55
|
-
apply: bool =
|
|
56
|
-
out: str | None =
|
|
48
|
+
location: str | None = LOCATION_OPTION,
|
|
49
|
+
apply: bool = APPLY_OPTION,
|
|
50
|
+
out: str | None = OUT_OPTION,
|
|
57
51
|
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
58
52
|
) -> None:
|
|
59
53
|
"""Create or update an address object.
|
|
@@ -94,9 +88,9 @@ def address_group(
|
|
|
94
88
|
),
|
|
95
89
|
description: str | None = typer.Option(None, "--description"),
|
|
96
90
|
tag: list[str] = typer.Option([], "--tag", help="Tag name (repeatable)."),
|
|
97
|
-
location: str | None =
|
|
98
|
-
apply: bool =
|
|
99
|
-
out: str | None =
|
|
91
|
+
location: str | None = LOCATION_OPTION,
|
|
92
|
+
apply: bool = APPLY_OPTION,
|
|
93
|
+
out: str | None = OUT_OPTION,
|
|
100
94
|
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
101
95
|
) -> None:
|
|
102
96
|
"""Create or update an address-group (exactly one of --member.../--filter).
|
|
@@ -130,9 +124,9 @@ def service(
|
|
|
130
124
|
),
|
|
131
125
|
description: str | None = typer.Option(None, "--description"),
|
|
132
126
|
tag: list[str] = typer.Option([], "--tag", help="Tag name (repeatable)."),
|
|
133
|
-
location: str | None =
|
|
134
|
-
apply: bool =
|
|
135
|
-
out: str | None =
|
|
127
|
+
location: str | None = LOCATION_OPTION,
|
|
128
|
+
apply: bool = APPLY_OPTION,
|
|
129
|
+
out: str | None = OUT_OPTION,
|
|
136
130
|
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
137
131
|
) -> None:
|
|
138
132
|
"""Create or update a service object (--dest-port required; --source-port optional).
|
|
@@ -160,9 +154,9 @@ def service_group(
|
|
|
160
154
|
name: str = typer.Option(..., "--name", help="Group name."),
|
|
161
155
|
member: list[str] = typer.Option(..., "--member", help="Member name (repeatable, >=1)."),
|
|
162
156
|
tag: list[str] = typer.Option([], "--tag", help="Tag name (repeatable)."),
|
|
163
|
-
location: str | None =
|
|
164
|
-
apply: bool =
|
|
165
|
-
out: str | None =
|
|
157
|
+
location: str | None = LOCATION_OPTION,
|
|
158
|
+
apply: bool = APPLY_OPTION,
|
|
159
|
+
out: str | None = OUT_OPTION,
|
|
166
160
|
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
167
161
|
) -> None:
|
|
168
162
|
"""Create or update a service-group.
|
|
@@ -187,9 +181,9 @@ def tag(
|
|
|
187
181
|
name: str = typer.Option(..., "--name", help="Tag name (<=127 chars)."),
|
|
188
182
|
color: str | None = typer.Option(None, "--color", help="color1..color42."),
|
|
189
183
|
comments: str | None = typer.Option(None, "--comments"),
|
|
190
|
-
location: str | None =
|
|
191
|
-
apply: bool =
|
|
192
|
-
out: str | None =
|
|
184
|
+
location: str | None = LOCATION_OPTION,
|
|
185
|
+
apply: bool = APPLY_OPTION,
|
|
186
|
+
out: str | None = OUT_OPTION,
|
|
193
187
|
output_format: ConfigFormat = OUT_FORMAT_OPTION,
|
|
194
188
|
) -> None:
|
|
195
189
|
"""Create or update a tag.
|