Skip to content

Commit 93e45ff

Browse files
howbazaarclaude
andauthored
feat: Support dynamic policy collections and repository views (#122)
### what Adds CLI support for dynamic policy collections and the repository views they carry, in two forms: - **`policy-collection add`** gained `--repository-uuid` (which makes the collection dynamic), plus `--branch-name`, `--policy-file-suffix`, `--policy-directory`, `--start-rev-spec` and `--auto-update`. `policy-collection update` takes the same view options apart from `--start-rev-spec`, which the schema only accepts at creation time. - **`repository register`** runs `addRepositoryConfig` and `addPolicyCollection` together, since both are needed before a repository's policies are scanned at all. It takes the options of both halves, with `--collection-name`/`--collection-description` for the collection, and `--deep-import` as shorthand for `--start-rev-spec=TAIL`. The policy collection snippets also now select `autoUpdate`, `isDynamic`, `repositoryConfig` and `repositoryView`, and no longer select the deprecated `repository` field. Also bumps the version to `2026.08.13` and moves these entries under a dated `NEWS.md` heading, so merging this is ready to tag. ### why 2026.08.10 removed `--branch-name`, `--policy-file-suffix`, `--policy-directory` and `--deep-import` from `repository add`, because `addRepositoryConfig` doesn't accept them. Those settings live on the repository *view*, and the platform has no standalone view mutation — a view only comes into existence as part of a dynamic policy collection. So there was no way to configure any of it from the CLI, pre- or post-creation, and no way to create a dynamic collection at all. `--deep-import` maps to `--start-rev-spec=TAIL`: the platform translates the old boolean to exactly that (`start_revid = "TAIL" if input_.view.deep_import else "HEAD"`), and the rev-spec form is strictly more expressive. The composite command exists because registering a repository is genuinely two mutations: a bare repository config has no view, and policies are scanned per view, so it yields nothing on its own. The platform's own functional tests worked around the gap by shelling out to `stacklet-admin graphql run` with a hand-written mutation, noting it as "a gap to close in stacklet-admin". ### testing - `just test` — 105 passed - `uv run pre-commit run --all-files` — all 14 hooks pass New tests cover the query-builder edges that carry risk here: an unset view collapses to `repositoryView: {}` with no stray variables; `TAIL` travels as a variable so the builder's quote stripping can't mangle it; an omitted `--auto-update` is absent rather than `false` (`autoUpdate: false` is an error on a dynamic collection, and variables are transformed *before* the query is built, so the transformer has to preserve `None`); and view options without `--repository-uuid` are rejected before anything is sent. `repository register` is tested for the happy path, `--deep-import`, its conflict with `--start-rev-spec`, repository `problems`, and the partial failure where the config is created but the collection isn't. ### docs `NEWS.md` updated. This also back-fills the `repository remove --cascade` entry that 2026.08.10 added but didn't note. ### notes for review - The node selection was duplicated across all seven policy collection snippets, and this change had to touch every copy, so it is now shared constants interpolated with `%` — the style `snippet.py` itself uses, since f-strings and `.format()` fight GraphQL's braces. It makes the diff larger than the feature alone. - Boolean options are now constrained with `click.Choice`, after review caught that `--auto-update=treu` became `autoUpdate: false` rather than an error. `repository remove --cascade` had the same flaw — and silently skipped the cascade — so it got the same treatment rather than being left inconsistent with the option this branch adds. That drops the undocumented `t`/`yes`/`y` spellings `--cascade` used to accept, which is a behaviour change on an existing option. - On partial failure, `repository register` reports rather than rolls back: the error names the repository UUID and gives both exits (retry the collection, or remove the repository). Auto-deleting a resource the user may already care about seemed like the wrong default, but say if you'd rather it clean up. - `items` and `owner` on `addPolicyCollection` are left out — both are nested object inputs that the string-substitution snippet layer can't express cleanly, and `policy-collection add-item` already covers items. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 7d5f470 commit 93e45ff

11 files changed

Lines changed: 663 additions & 175 deletions

File tree

‎NEWS.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,45 @@
88

99
---
1010

11+
## August 13, 2026
12+
13+
### Features
14+
15+
- **Dynamic policy collections**: `policy-collection add` accepts `--repository-uuid`,
16+
which makes the collection dynamic — its policies always match the latest scan of
17+
that repository. The repository view that does the scanning is configured with
18+
`--branch-name`, `--policy-file-suffix`, `--policy-directory` and `--start-rev-spec`
19+
(`TAIL` for a deep import, settable only at creation time). `policy-collection
20+
update` takes the same view options apart from `--start-rev-spec`.
21+
22+
This restores the capability behind the `repository add` options removed in
23+
2026.08.10: the platform moved that configuration onto the repository *view*, which
24+
only exists as part of a dynamic policy collection.
25+
26+
- **`repository register`**: registers a repository config and its dynamic policy
27+
collection in one command, which is what it takes for a repository's policies to
28+
actually get scanned — `addRepositoryConfig` alone creates no view, and policies are
29+
scanned per view. Accepts the options of both halves, with `--collection-name` and
30+
`--collection-description` for the collection, plus `--deep-import` as shorthand for
31+
`--start-rev-spec=TAIL`.
32+
33+
- `repository remove` accepts `--cascade`, to also remove bindings and policy
34+
collections tied to the repository. (Added in 2026.08.10 but not noted at the time.)
35+
36+
### Changes
37+
38+
- `policy-collection` output now includes `autoUpdate`, `isDynamic`, `repositoryConfig`
39+
and `repositoryView`, and no longer selects the deprecated `repository` field. Use
40+
`repositoryConfig` instead.
41+
42+
### Fixes
43+
44+
- `repository remove --cascade` now rejects a value that isn't `true` or `false`.
45+
Previously anything unrecognised was taken as `false`, so a typo silently skipped
46+
the cascade instead of reporting the mistake.
47+
48+
---
49+
1150
## August 10, 2026
1251

1352
### Changes
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright Stacklet, Inc.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
__version__ = "2026.08.10"
4+
__version__ = "2026.08.13"

‎stacklet/client/platform/commands/policy_collection.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
# Copyright Stacklet, Inc.
22
# SPDX-License-Identifier: Apache-2.0
33

4+
from typing import Any
5+
46
import click
57

8+
from ..context import StackletContext
9+
from ..exceptions import InvalidInputException
610
from ..graphql.cli import GraphQLCommand, register_graphql_commands
711
from ..graphql.snippets import (
812
AddPolicyCollection,
@@ -13,6 +17,7 @@
1317
ShowPolicyCollection,
1418
UpdatePolicyCollection,
1519
)
20+
from ..graphql.snippets.policy_collection import VIEW_OPTIONS
1621

1722

1823
@click.group(short_help="Run policy collection queries/mutations")
@@ -22,11 +27,31 @@ def policy_collection(*args, **kwargs):
2227
"""
2328

2429

30+
# The view only exists on a dynamic collection, which is what --repository-uuid asks for.
31+
_VIEW_ARGS = (*VIEW_OPTIONS, "start_rev_spec")
32+
33+
34+
def _add_pre_check(context: StackletContext, cli_args: dict[str, Any]) -> dict[str, Any]:
35+
if not cli_args.get("repository_uuid"):
36+
if given := sorted(name for name in _VIEW_ARGS if cli_args.get(name)):
37+
options = ", ".join("--" + name.replace("_", "-") for name in given)
38+
raise InvalidInputException(
39+
f"{options} only applies to a dynamic policy collection, so it needs "
40+
"--repository-uuid too"
41+
)
42+
return cli_args
43+
44+
2545
register_graphql_commands(
2646
policy_collection,
2747
[
2848
GraphQLCommand("list", ListPolicyCollections, "List policy collections in Stacklet"),
29-
GraphQLCommand("add", AddPolicyCollection, "Add policy collection in Stacklet"),
49+
GraphQLCommand(
50+
"add",
51+
AddPolicyCollection,
52+
"Add policy collection in Stacklet",
53+
pre_check=_add_pre_check,
54+
),
3055
GraphQLCommand("show", ShowPolicyCollection, "Show policy collection in Stacklet"),
3156
GraphQLCommand("update", UpdatePolicyCollection, "Update policy collection in Stacklet"),
3257
GraphQLCommand(

‎stacklet/client/platform/commands/repository.py‎

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,137 @@
88

99
from ..context import StackletContext
1010
from ..exceptions import InvalidInputException
11-
from ..graphql.cli import GraphQLCommand, register_graphql_commands
11+
from ..graphql.cli import GraphQLCommand, register_graphql_commands, run_graphql
1212
from ..graphql.snippets import (
13+
AddPolicyCollection,
1314
AddRepository,
1415
ListRepository,
1516
ProcessRepository,
1617
RemoveRepository,
1718
ScanRepository,
1819
ShowRepository,
1920
)
21+
from ..graphql.snippets.policy_collection import VIEW_OPTIONS
22+
from ..utils import wrap_command
2023

2124

2225
@click.group(short_help="Run repository queries/mutations")
2326
def repository(*args, **kwargs):
2427
"""Query against and Run mutations against Repository objects in Stacklet"""
2528

2629

27-
def _add_pre_check(context: StackletContext, cli_args: dict[str, Any]) -> dict[str, Any]:
30+
def _read_ssh_private_key(cli_args: dict[str, Any]) -> None:
31+
"""Replace a --ssh-private-key path with the key itself, in place."""
2832
private_key = cli_args.get("ssh_private_key")
29-
auth_user = cli_args.get("auth_user")
30-
if private_key:
31-
if auth_user is None:
32-
raise InvalidInputException("Both --auth-user and --ssh-private-key are required")
33-
with open(os.path.expanduser(private_key), "r") as f:
34-
cli_args["ssh_private_key"] = f.read().strip("\n")
33+
if not private_key:
34+
return
35+
if cli_args.get("auth_user") is None:
36+
raise InvalidInputException("Both --auth-user and --ssh-private-key are required")
37+
with open(os.path.expanduser(private_key), "r") as f:
38+
cli_args["ssh_private_key"] = f.read().strip("\n")
39+
3540

41+
def _add_pre_check(context: StackletContext, cli_args: dict[str, Any]) -> dict[str, Any]:
42+
_read_ssh_private_key(cli_args)
3643
return cli_args
3744

3845

46+
# `register`'s arguments that belong to the repository config half.
47+
REGISTER_REPOSITORY_ARGS = ("url", "name", *AddRepository.optional)
48+
49+
REGISTER_REQUIRED = {
50+
"url": AddRepository.required["url"],
51+
"name": AddRepository.required["name"],
52+
"provider": "Cloud provider for the policy collection",
53+
}
54+
55+
REGISTER_OPTIONAL = {
56+
**AddRepository.optional,
57+
"collection_name": "Name for the policy collection. Defaults to the repository name",
58+
"collection_description": "Policy Collection Description",
59+
**VIEW_OPTIONS,
60+
"start_rev_spec": (
61+
"Revision the first scan starts from. TAIL scans the whole history, recording a "
62+
"policy version per change -- a deep import. Only settable at creation time, so "
63+
"it has to be decided here"
64+
),
65+
"deep_import": {
66+
"help": "Scan the repository's whole history. Shorthand for --start-rev-spec=TAIL",
67+
"is_flag": True,
68+
},
69+
}
70+
71+
72+
def register(context: StackletContext, **cli_args):
73+
"""
74+
Register a policy repository and the dynamic policy collection that scans it.
75+
76+
Registering a repository takes two mutations. `addRepositoryConfig` records the
77+
repository's details but gives it no view, and policies are scanned per view -- so a
78+
bare repository config yields no policies at all. Creating a dynamic policy
79+
collection is what creates the view and triggers the first scan. This runs both, the
80+
way the console onboards a repository.
81+
82+
Use `repository add` and `policy-collection add` to drive either half on its own.
83+
"""
84+
if cli_args.pop("deep_import"):
85+
if cli_args["start_rev_spec"]:
86+
raise InvalidInputException("--deep-import and --start-rev-spec are exclusive")
87+
cli_args["start_rev_spec"] = "TAIL"
88+
89+
_read_ssh_private_key(cli_args)
90+
91+
repo_args = {name: cli_args.pop(name) for name in REGISTER_REPOSITORY_ARGS}
92+
repo_result = run_graphql(context, snippet_class=AddRepository, variables=repo_args, raw=True)
93+
94+
added = (repo_result.get("data") or {}).get("addRepositoryConfig") or {}
95+
if problems := added.get("problems"):
96+
raise InvalidInputException(
97+
"could not add repository: %s" % "; ".join(p["message"] for p in problems)
98+
)
99+
repo_config = added.get("repositoryConfig")
100+
if not repo_config:
101+
# Errors rather than problems: show the response instead of guessing at it.
102+
raise InvalidInputException("could not add repository: %s" % repo_result)
103+
104+
repo_uuid = repo_config["uuid"]
105+
collection_args = dict(cli_args)
106+
collection_args["repository_uuid"] = repo_uuid
107+
collection_args["name"] = collection_args.pop("collection_name") or repo_args["name"]
108+
collection_args["description"] = collection_args.pop("collection_description")
109+
# A dynamic collection is always auto-updating; the platform rejects anything else.
110+
collection_args["auto_update"] = "true"
111+
112+
collection_result = run_graphql(
113+
context, snippet_class=AddPolicyCollection, variables=collection_args, raw=True
114+
)
115+
collection = ((collection_result.get("data") or {}).get("addPolicyCollection") or {}).get(
116+
"collection"
117+
)
118+
if not collection:
119+
# The repository config exists now, and has no view until a dynamic collection
120+
# gives it one, so spell out the state rather than leaving it to be rediscovered.
121+
raise InvalidInputException(
122+
f"repository {repo_uuid} was added, but creating its policy collection "
123+
"failed, so nothing will be scanned. Either retry with `policy-collection "
124+
f"add --repository-uuid={repo_uuid}`, or remove the repository with "
125+
f"`repository remove --uuid={repo_uuid}`. Response: {collection_result}"
126+
)
127+
128+
fmt = context.formatter()
129+
click.echo(fmt({"repositoryConfig": repo_config, "policyCollection": collection}))
130+
131+
132+
register = wrap_command(register, REGISTER_OPTIONAL)
133+
register = wrap_command(register, REGISTER_REQUIRED, required=True)
134+
repository.add_command(
135+
click.command(
136+
"register",
137+
short_help="Register a repository and a dynamic policy collection to scan it",
138+
)(click.pass_obj(register))
139+
)
140+
141+
39142
register_graphql_commands(
40143
repository,
41144
[

0 commit comments

Comments
 (0)