diff --git a/DESIGN.md b/DESIGN.md index 50d206b..28f0974 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -68,6 +68,7 @@ Supported: - cross-model relations via `juju offer` and `juju integrate .` - virtual bundles (e.g. `juju deploy cos-lite`) - `juju run` (actions) on virtual charms +- app-managed and user secrets, including `secret-changed` event dispatch Not supported: @@ -257,6 +258,12 @@ jjx implements several juju commands that jubilant/pytest-jubilant may call duri - `juju version` — returns a minimal version response - `juju show-model` — returns model metadata - `juju models` — lists all models in state +- `juju add-secret` — creates a user secret owned by the model; prints the secret URI +- `juju grant-secret` — grants an application read access to a user secret +- `juju update-secret` — creates a new revision of a secret's content and dispatches `secret-changed` to every observing application +- `juju secrets` — lists secrets in the model (JSON format matching jubilant's `Secret` type) +- `juju show-secret` — returns secret metadata, optionally with revealed content +- `juju remove-secret` — removes a secret (or a single revision) ## hook tools @@ -268,10 +275,34 @@ Implemented: - `is-leader` — always returns `true` (single-unit model) - `juju-log` — appends to the model's log in `state.json` - `relation-ids`, `relation-list`, `relation-get`, `relation-set`, `relation-model-get` — relation data access -- `secret-add`, `secret-get`, `secret-grant`, `secret-info-get`, `secret-ids`, `secret-remove`, `secret-revoke`, `secret-set` — secret management +- `secret-add`, `secret-get`, `secret-grant`, `secret-info-get`, `secret-ids`, `secret-remove`, `secret-revoke`, `secret-set` — secret management (see "secrets" below) - `network-get` — returns the workload container's IP address (from `state.json`, not Docker, since Docker isn't available inside the charm runner). All bindings resolve to the workload's IP. - `application-version-set` — sets the workload version in state +## secrets + +jjx supports both app-managed secrets (created by the charm via `secret-add`) and user secrets (created via `juju add-secret` and granted to the charm via `juju grant-secret`). + +Secrets are stored in `state.json` under `model_state["secrets"]`. Each secret tracks: + +- `id` — the canonical URI (`secret:///`) +- `label` — the owner-assigned label (app secrets) or `null` (user secrets) +- `name` — the user-facing name (user secrets) or `null` (app secrets) +- `owner` — the owning application name, or `"model"` for user secrets +- `content` — the latest revision's content (a denormalized copy of `revisions[-1]`) +- `revisions` — a list of content dicts; index 0 is revision 1 +- `revision` — the latest revision number +- `grants` — which applications have read access +- `rotate`, `expire`, `description` — optional metadata +- `created`, `updated` — ISO timestamps (for jubilant's `Secret.created`/`Secret.updated`) +- `observers` — per-app tracking: `{app: {label, tracked_revision}}` + +When a charm calls `secret-get` with both an `id` and a `label`, jjx records the observer label so subsequent `secret-changed` events can supply it via `JUJU_SECRET_LABEL`. `secret-get` with `--refresh` or `--peek` returns the latest revision's content; `--refresh` also updates the observer's tracked revision. Without either flag, the tracked revision's content is returned (or the latest, if the observer hasn't tracked a revision yet). + +`juju update-secret` creates a new revision and dispatches a `secret-changed` event to every granted application that is observing the secret. The event carries `JUJU_SECRET_ID` and the observer's `JUJU_SECRET_LABEL`; the charm re-reads the content with `get_content(refresh=True)`. The `secret-set` hook tool creates a new revision but does not dispatch `secret-changed` — it is called by the secret owner mid-hook (e.g. during `secret-rotate`), and jjx is single-app so there are no other observers to notify. + +`secret-info-get` returns a single-entry dict keyed by the secret URI (`{uri: {revision, label, ...}}`), matching the format `ops` parses. + ## constraints - requires Docker diff --git a/src/jjx/_cli.py b/src/jjx/_cli.py index c56cb91..0dd59b5 100644 --- a/src/jjx/_cli.py +++ b/src/jjx/_cli.py @@ -26,6 +26,7 @@ _cmd_offer, _cmd_remove_application, _cmd_run, + _cmd_secret, _cmd_status, _cmd_wait_for, _engine, @@ -84,6 +85,18 @@ def run_juju_command(argv: list[str]) -> int: return _cmd_misc.show_model(rest, model) if command == "models": return _cmd_misc.models(rest) + if command == "add-secret": + return _cmd_secret.add_secret(rest, model) + if command == "grant-secret": + return _cmd_secret.grant_secret(rest, model) + if command == "update-secret": + return _cmd_secret.update_secret(rest, model) + if command == "secrets": + return _cmd_secret.secrets(rest, model) + if command == "show-secret": + return _cmd_secret.show_secret(rest, model) + if command == "remove-secret": + return _cmd_secret.remove_secret(rest, model) raise _engine.CliError(f"unknown command: {command}") diff --git a/src/jjx/_cmd_hook_tool.py b/src/jjx/_cmd_hook_tool.py index 9c44ead..382e901 100644 --- a/src/jjx/_cmd_hook_tool.py +++ b/src/jjx/_cmd_hook_tool.py @@ -215,13 +215,13 @@ def hook_tool(args: list[str]) -> int: return _secret_add(tool_args, model_state, app_name, state) if tool == "secret-get": - return _secret_get(tool_args, model_state) + return _secret_get(tool_args, model_state, app_name, state) if tool == "secret-grant": return _secret_grant(tool_args, model_state, state) if tool == "secret-info-get": - return _secret_info_get(tool_args, model_state) + return _secret_info_get(tool_args, model_state, app_name) if tool == "secret-ids": return _secret_ids(model_state, app_name) @@ -233,7 +233,7 @@ def hook_tool(args: list[str]) -> int: return _secret_revoke(tool_args, model_state, state) if tool == "secret-set": - return _secret_set(tool_args, model_state, state) + return _secret_set(tool_args, model_state, state, app_name) if tool == "network-get": return _network_get(tool_args, app_state) @@ -550,14 +550,24 @@ def _relation_model_get(tool_args: list[str], model_state: dict[str, Any]) -> in # --------------------------------------------------------------------------- +def _read_secret_file(file_path: str) -> str: + try: + return open(file_path).read() + except OSError as exc: + raise _engine.CliError(f"failed to read secret file {file_path}: {exc}") from None + + def _secret_add( tool_args: list[str], model_state: dict[str, Any], app_name: str, state: dict[str, Any], ) -> int: - """secret-add [--label ] [--owner application] #file= ...""" + """secret-add [--label ] [--description ] [--expire ] [--rotate

] [--owner ] #file= ...""" label: str | None = None + description: str | None = None + expire: str | None = None + rotate: str | None = None content: dict[str, str] = {} i = 0 while i < len(tool_args): @@ -570,6 +580,30 @@ def _secret_add( label = token.split("=", 1)[1] i += 1 continue + if token == "--description" and i + 1 < len(tool_args): + description = tool_args[i + 1] + i += 2 + continue + if token.startswith("--description="): + description = token.split("=", 1)[1] + i += 1 + continue + if token == "--expire" and i + 1 < len(tool_args): + expire = tool_args[i + 1] + i += 2 + continue + if token.startswith("--expire="): + expire = token.split("=", 1)[1] + i += 1 + continue + if token == "--rotate" and i + 1 < len(tool_args): + rotate = tool_args[i + 1] + i += 2 + continue + if token.startswith("--rotate="): + rotate = token.split("=", 1)[1] + i += 1 + continue if token == "--owner" and i + 1 < len(tool_args): # Owner is always the application in jjx; consume and ignore. i += 2 @@ -583,33 +617,46 @@ def _secret_add( # key#file=path format if "#file=" in token: key, _, file_path = token.partition("#file=") - try: - content[key] = open(file_path).read() # noqa: SIM115 - except OSError as exc: - raise _engine.CliError(f"failed to read secret file {file_path}: {exc}") from None + content[key] = _read_secret_file(file_path) i += 1 secret_id = _engine._next_secret_id(model_state) + now = _engine._now_iso() secret = { "id": secret_id, "label": label, + "name": None, "owner": app_name, - "content": content, + "content": dict(content), + "revisions": [dict(content)], "revision": 1, "grants": [], + "rotate": rotate, + "expire": expire, + "description": description, + "observers": {}, + "created": now, + "updated": now, } _engine._secrets(model_state).append(secret) _engine._save_state(state) - # secret-add prints the secret ID (the URI form) on stdout - sys.stdout.write(f"{secret_id}\n") + # secret-add prints the secret URI (secret:) on stdout. + sys.stdout.write(f"{_engine._secret_uri_for_juju(secret)}\n") return 0 -def _secret_get(tool_args: list[str], model_state: dict[str, Any]) -> int: +def _secret_get( + tool_args: list[str], + model_state: dict[str, Any], + app_name: str, + state: dict[str, Any], +) -> int: """secret-get [--format=json] [--label ] [--refresh|--peek]""" output_format = "json" secret_id: str | None = None label: str | None = None + refresh = False + peek = False i = 0 while i < len(tool_args): token = tool_args[i] @@ -629,7 +676,12 @@ def _secret_get(tool_args: list[str], model_state: dict[str, Any]) -> int: label = token.split("=", 1)[1] i += 1 continue - if token in ("--refresh", "--peek"): + if token == "--refresh": + refresh = True + i += 1 + continue + if token == "--peek": + peek = True i += 1 continue if token.startswith("-"): @@ -643,11 +695,33 @@ def _secret_get(tool_args: list[str], model_state: dict[str, Any]) -> int: if secret_id: secret = _engine._find_secret_by_id(model_state, secret_id) if secret is None and label: - secret = _engine._find_secret_by_label(model_state, label) + secret = _engine._find_secret_by_label(model_state, label, app_name=app_name) if secret is None: raise _engine.CliError(f"secret not found: {secret_id or label}") - _output(dict(secret.get("content", {})), output_format) + # Record the observer label if one was provided (the charm assigns a + # label when it calls get_secret(id=..., label=...)). + if label: + observer = _engine._secret_observer(secret, app_name) + observer["label"] = label + + latest = _engine._secret_latest_revision(secret) + observer = _engine._secret_observer(secret, app_name) + tracked = observer.get("tracked_revision") + + if refresh or peek: + revision = latest + elif tracked is not None: + revision = tracked + else: + revision = latest + + if refresh: + observer["tracked_revision"] = latest + + content = _engine._secret_content_for_revision(secret, revision) + _output(content, output_format) + _engine._save_state(state) return 0 @@ -688,15 +762,19 @@ def _secret_grant(tool_args: list[str], model_state: dict[str, Any], state: dict if secret is None: raise _engine.CliError(f"secret not found: {secret_id}") - grants = secret.setdefault("grants", []) - grant_entry = {"relation_id": relation_id, "unit": unit} + grants = _engine._secret_grants(secret) + grant_entry = {"relation_id": relation_id, "unit": unit, "app": None} if grant_entry not in grants: grants.append(grant_entry) _engine._save_state(state) return 0 -def _secret_info_get(tool_args: list[str], model_state: dict[str, Any]) -> int: +def _secret_info_get( + tool_args: list[str], + model_state: dict[str, Any], + app_name: str, +) -> int: """secret-info-get [--format=json] | --label """ output_format = "json" secret_id: str | None = None @@ -731,20 +809,20 @@ def _secret_info_get(tool_args: list[str], model_state: dict[str, Any]) -> int: if secret_id: secret = _engine._find_secret_by_id(model_state, secret_id) if secret is None and label: - secret = _engine._find_secret_by_label(model_state, label) + secret = _engine._find_secret_by_label(model_state, label, app_name=app_name) if secret is None: raise _engine.CliError(f"secret not found: {secret_id or label}") + # ops expects {secret_id: {info}} — a single-entry dict keyed by the id. info = { - "revision": secret.get("revision", 1), + "revision": _engine._secret_latest_revision(secret), "label": secret.get("label"), - "owner": secret.get("owner"), - "expires": None, - "rotation": None, + "description": secret.get("description"), + "expiry": secret.get("expire"), + "rotation": secret.get("rotate"), "rotates": None, - "description": None, } - _output(info, output_format) + _output({_engine._secret_uri_for_juju(secret): info}, output_format) return 0 @@ -753,7 +831,7 @@ def _secret_ids(model_state: dict[str, Any], app_name: str) -> int: result = [] for secret in _engine._secrets(model_state): if secret.get("owner") == app_name: - result.append(secret["id"]) + result.append(_engine._secret_uri_for_juju(secret)) sys.stdout.write(json.dumps(result)) return 0 @@ -763,13 +841,22 @@ def _secret_remove( ) -> int: """secret-remove [--revision ]""" secret_id: str | None = None + revision: int | None = None i = 0 while i < len(tool_args): token = tool_args[i] if token == "--revision" and i + 1 < len(tool_args): + try: + revision = int(tool_args[i + 1]) + except ValueError: + raise _engine.CliError(f"invalid --revision value: {tool_args[i + 1]}") from None i += 2 continue if token.startswith("--revision="): + try: + revision = int(token.split("=", 1)[1]) + except ValueError: + raise _engine.CliError(f"invalid --revision value: {token}") from None i += 1 continue if token.startswith("-"): @@ -782,9 +869,22 @@ def _secret_remove( if secret_id is None: raise _engine.CliError("secret-remove requires a secret ID") secret = _engine._find_secret_by_id(model_state, secret_id) - if secret is not None: + if secret is None: + raise _engine.CliError(f"secret not found: {secret_id}") + + if revision is not None: + revisions = _engine._secret_revisions(secret) + idx = revision - 1 + if 0 <= idx < len(revisions): + del revisions[idx] + if revisions: + secret["content"] = revisions[-1] + secret["revision"] = len(revisions) + else: + _engine._secrets(model_state).remove(secret) + else: _engine._secrets(model_state).remove(secret) - _engine._save_state(state) + _engine._save_state(state) return 0 @@ -816,25 +916,60 @@ def _secret_revoke( raise _engine.CliError("secret-revoke requires a secret ID") secret = _engine._find_secret_by_id(model_state, secret_id) if secret is not None: - grants = secret.get("grants", []) + grants = _engine._secret_grants(secret) secret["grants"] = [g for g in grants if g.get("relation_id") != relation_id] _engine._save_state(state) return 0 -def _secret_set(tool_args: list[str], model_state: dict[str, Any], state: dict[str, Any]) -> int: - """secret-set [--label ] [key#file= ...]""" +def _secret_set( + tool_args: list[str], + model_state: dict[str, Any], + state: dict[str, Any], + app_name: str, +) -> int: + """secret-set [--label ] [--description ] [--expire ] [--rotate

] [--owner ] [key#file= ...]""" secret_id: str | None = None content: dict[str, str] = {} + label: str | None = None + description: str | None = None + expire: str | None = None + rotate: str | None = None i = 0 while i < len(tool_args): token = tool_args[i] + if token == "--label" and i + 1 < len(tool_args): + label = tool_args[i + 1] + i += 2 + continue if token.startswith("--label="): + label = token.split("=", 1)[1] i += 1 continue - if token == "--label" and i + 1 < len(tool_args): + if token == "--description" and i + 1 < len(tool_args): + description = tool_args[i + 1] + i += 2 + continue + if token.startswith("--description="): + description = token.split("=", 1)[1] + i += 1 + continue + if token == "--expire" and i + 1 < len(tool_args): + expire = tool_args[i + 1] i += 2 continue + if token.startswith("--expire="): + expire = token.split("=", 1)[1] + i += 1 + continue + if token == "--rotate" and i + 1 < len(tool_args): + rotate = tool_args[i + 1] + i += 2 + continue + if token.startswith("--rotate="): + rotate = token.split("=", 1)[1] + i += 1 + continue if token.startswith("--owner"): if "=" in token: i += 1 @@ -848,10 +983,7 @@ def _secret_set(tool_args: list[str], model_state: dict[str, Any], state: dict[s secret_id = token elif "#file=" in token: key, _, file_path = token.partition("#file=") - try: - content[key] = open(file_path).read() # noqa: SIM115 - except OSError as exc: - raise _engine.CliError(f"failed to read secret file {file_path}: {exc}") from None + content[key] = _read_secret_file(file_path) i += 1 if secret_id is None: @@ -859,9 +991,21 @@ def _secret_set(tool_args: list[str], model_state: dict[str, Any], state: dict[s secret = _engine._find_secret_by_id(model_state, secret_id) if secret is None: raise _engine.CliError(f"secret not found: {secret_id}") + if content: - secret["content"].update(content) - secret["revision"] = secret.get("revision", 1) + 1 + revisions = _engine._secret_revisions(secret) + revisions.append(dict(content)) + secret["content"] = dict(content) + secret["revision"] = int(secret.get("revision", 1)) + 1 + if label is not None: + secret["label"] = label + if description is not None: + secret["description"] = description + if expire is not None: + secret["expire"] = expire + if rotate is not None: + secret["rotate"] = rotate + secret["updated"] = _engine._now_iso() _engine._save_state(state) return 0 diff --git a/src/jjx/_cmd_secret.py b/src/jjx/_cmd_secret.py new file mode 100644 index 0000000..e3d7406 --- /dev/null +++ b/src/jjx/_cmd_secret.py @@ -0,0 +1,478 @@ +"""Secret management commands. + +Implements the ``juju`` CLI secret commands that jubilant calls during +integration tests: ``add-secret``, ``grant-secret``, ``update-secret``, +``secrets``, ``show-secret``, and ``remove-secret``. + +User secrets (created via ``juju add-secret``) are owned by the model and +granted to charm applications via ``juju grant-secret``. App-owned secrets +are created by the charm via the ``secret-add`` hook tool. + +Secret content revisions are tracked so that ``juju update-secret`` can +create a new revision and dispatch a ``secret-changed`` event to observing +charms. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from . import _engine + + +def _read_content_file(file_arg: str) -> dict[str, str]: + """Read secret content from a YAML ``--file`` argument.""" + import yaml + + path = Path(file_arg) + if not path.exists(): + raise _engine.CliError(f"secret content file not found: {file_arg}") + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if data is None: + return {} + if not isinstance(data, dict): + raise _engine.CliError(f"secret content file must be a mapping, got {type(data).__name__}") + return {str(k): str(v) for k, v in data.items()} + + +def add_secret(args: list[str], model: str | None) -> int: + """Execute the ``add-secret`` command. + + Usage: juju add-secret [--file ] [--info ] + + Creates a user secret owned by the model. The secret's user-facing name + is recorded; the charm references it by URI (printed on stdout). + """ + state = _engine._load_state() + model_name = _engine._require_model_name(state, model) + model_state = state["models"][model_name] + + name: str | None = None + content: dict[str, str] = {} + info: str | None = None + i = 0 + while i < len(args): + token = args[i] + if token == "--file" and i + 1 < len(args): + content = _read_content_file(args[i + 1]) + i += 2 + continue + if token.startswith("--file="): + content = _read_content_file(token.split("=", 1)[1]) + i += 1 + continue + if token == "--info" and i + 1 < len(args): + info = args[i + 1] + i += 2 + continue + if token.startswith("--info="): + info = token.split("=", 1)[1] + i += 1 + continue + if token.startswith("-"): + i += 1 + continue + if name is None: + name = token + i += 1 + + if name is None: + raise _engine.CliError("usage: juju add-secret [--file ]") + + secret_id = _engine._next_secret_id(model_state) + now = _engine._now_iso() + secret: dict[str, Any] = { + "id": secret_id, + "label": None, + "name": name, + "owner": "model", + "content": dict(content), + "revisions": [dict(content)], + "revision": 1, + "grants": [], + "rotate": None, + "expire": None, + "description": info, + "observers": {}, + "created": now, + "updated": now, + } + _engine._secrets(model_state).append(secret) + _engine._save_state(state) + # juju add-secret prints the secret URI (secret:) on stdout. + sys.stdout.write(f"{_engine._secret_uri_for_juju(secret)}\n") + return 0 + + +def grant_secret(args: list[str], model: str | None) -> int: + """Execute the ``grant-secret`` command. + + Usage: juju grant-secret [,...] + + Grants the named applications read access to the secret. The identifier + may be the secret's user-facing name or its URI. + """ + state = _engine._load_state() + model_name = _engine._require_model_name(state, model) + model_state = state["models"][model_name] + + identifier: str | None = None + apps: list[str] = [] + i = 0 + while i < len(args): + token = args[i] + if token.startswith("-"): + i += 1 + continue + if identifier is None: + identifier = token + else: + apps.extend(a.strip() for a in token.split(",") if a.strip()) + i += 1 + + if identifier is None: + raise _engine.CliError("usage: juju grant-secret ") + if not apps: + raise _engine.CliError("juju grant-secret requires at least one application") + + secret = _find_secret_by_identifier(model_state, identifier) + if secret is None: + raise _engine.CliError(f"secret not found: {identifier}") + + grants = _engine._secret_grants(secret) + for app in apps: + if app not in model_state.get("apps", {}): + raise _engine.CliError(f"application {app} not found") + grant_entry = {"app": app, "relation_id": None, "unit": None} + if grant_entry not in grants: + grants.append(grant_entry) + _engine._save_state(state) + return 0 + + +def update_secret(args: list[str], model: str | None) -> int: + """Execute the ``update-secret`` command. + + Usage: juju update-secret [--file ] [--info ] + [--name ] [--auto-prune] + + Creates a new revision of the secret's content and dispatches a + ``secret-changed`` event to every observing application. + """ + state = _engine._load_state() + model_name = _engine._require_model_name(state, model) + model_state = state["models"][model_name] + + identifier: str | None = None + content: dict[str, str] | None = None + info: str | None = None + name: str | None = None + i = 0 + while i < len(args): + token = args[i] + if token == "--file" and i + 1 < len(args): + content = _read_content_file(args[i + 1]) + i += 2 + continue + if token.startswith("--file="): + content = _read_content_file(token.split("=", 1)[1]) + i += 1 + continue + if token == "--info" and i + 1 < len(args): + info = args[i + 1] + i += 2 + continue + if token.startswith("--info="): + info = token.split("=", 1)[1] + i += 1 + continue + if token == "--name" and i + 1 < len(args): + name = args[i + 1] + i += 2 + continue + if token.startswith("--name="): + name = token.split("=", 1)[1] + i += 1 + continue + if token == "--auto-prune": + i += 1 + continue + if token.startswith("-"): + i += 1 + continue + if identifier is None: + identifier = token + i += 1 + + if identifier is None: + raise _engine.CliError("usage: juju update-secret [--file ]") + + secret = _find_secret_by_identifier(model_state, identifier) + if secret is None: + raise _engine.CliError(f"secret not found: {identifier}") + + if content is not None: + revisions = _engine._secret_revisions(secret) + revisions.append(dict(content)) + secret["content"] = dict(content) + secret["revision"] = int(secret.get("revision", 1)) + 1 + if info is not None: + secret["description"] = info + if name is not None: + secret["name"] = name + secret["updated"] = _engine._now_iso() + _engine._save_state(state) + + # Dispatch secret-changed to every observing app that has been granted + # access and has tracked a revision. The owner of an app secret is also + # an observer of its own secret if it tracks it by label. + observers = secret.get("observers", {}) + granted_apps: set[str] = set() + for grant in _engine._secret_grants(secret): + app_name = grant.get("app") + if app_name: + granted_apps.add(app_name) + # The owner app observes its own secret too. + owner = secret.get("owner") + if owner and owner != "model": + granted_apps.add(owner) + + for app in sorted(granted_apps): + if app not in model_state.get("apps", {}): + continue + # Only dispatch to apps that are actually observing (have tracked a + # revision) — matching real Juju, which only notifies observers. + observer = observers.get(app) + if observer is None and app != owner: + continue + _engine._run_secret_changed_event(model_name, app, secret) + + return 0 + + +def secrets(args: list[str], model: str | None) -> int: + """Execute the ``secrets`` command. + + Usage: juju secrets [--owner ] [--format json] + + Returns a JSON mapping of ``{uri: {info}}`` in the format jubilant expects. + """ + state = _engine._load_state() + model_name = _engine._require_model_name(state, model) + model_state = state["models"][model_name] + + owner_filter: str | None = None + as_json = False + i = 0 + while i < len(args): + if args[i] == "--owner" and i + 1 < len(args): + owner_filter = args[i + 1] + i += 2 + continue + if args[i].startswith("--owner="): + owner_filter = args[i].split("=", 1)[1] + i += 1 + continue + if args[i] == "--format" and i + 1 < len(args): + as_json = args[i + 1] == "json" + i += 2 + continue + if args[i].startswith("--format="): + as_json = args[i].split("=", 1)[1] == "json" + i += 1 + continue + i += 1 + + result: dict[str, dict[str, Any]] = {} + for secret in _engine._secrets(model_state): + owner = secret.get("owner", "model") + if owner_filter is not None and owner != owner_filter: + continue + uri = _engine._secret_uri_for_juju(secret) + created = secret.get("created", _engine._now_iso()) + updated = secret.get("updated", created) + info: dict[str, Any] = { + "revision": _engine._secret_latest_revision(secret), + "owner": owner, + "created": created, + "updated": updated, + "expires": secret.get("expire"), + "rotation": secret.get("rotate"), + "name": secret.get("name"), + "label": secret.get("label"), + "description": secret.get("description"), + } + # jubilant parses 'rotates' with fromisoformat only if the key is + # present, so omit it entirely when there is no rotation schedule. + rotates = secret.get("rotates") + if rotates is not None: + info["rotates"] = rotates + result[uri] = info + + if as_json: + sys.stdout.write(json.dumps(result)) + else: + for uri, info in result.items(): + sys.stdout.write(f"{uri}\t{info.get('label') or info.get('name') or ''}\n") + return 0 + + +def show_secret(args: list[str], model: str | None) -> int: + """Execute the ``show-secret`` command. + + Usage: juju show-secret [--format json] [--reveal] [--revision ] + + Returns secret metadata, optionally with revealed content. + """ + state = _engine._load_state() + model_name = _engine._require_model_name(state, model) + model_state = state["models"][model_name] + + identifier: str | None = None + reveal = False + revision: int | None = None + i = 0 + while i < len(args): + token = args[i] + if token == "--format" and i + 1 < len(args): + i += 2 + continue + if token.startswith("--format="): + i += 1 + continue + if token == "--reveal": + reveal = True + i += 1 + continue + if token == "--revision" and i + 1 < len(args): + try: + revision = int(args[i + 1]) + except ValueError: + raise _engine.CliError(f"invalid --revision value: {args[i + 1]}") from None + i += 2 + continue + if token.startswith("--revision="): + try: + revision = int(token.split("=", 1)[1]) + except ValueError: + raise _engine.CliError(f"invalid --revision value: {token}") from None + i += 1 + continue + if token.startswith("-"): + i += 1 + continue + if identifier is None: + identifier = token + i += 1 + + if identifier is None: + raise _engine.CliError("usage: juju show-secret ") + + secret = _find_secret_by_identifier(model_state, identifier) + if secret is None: + raise _engine.CliError(f"secret not found: {identifier}") + + uri = _engine._secret_uri_for_juju(secret) + created = secret.get("created", _engine._now_iso()) + updated = secret.get("updated", created) + info: dict[str, Any] = { + "revision": _engine._secret_latest_revision(secret), + "owner": secret.get("owner", "model"), + "created": created, + "updated": updated, + "expires": secret.get("expire"), + "rotation": secret.get("rotate"), + "name": secret.get("name"), + "label": secret.get("label"), + "description": secret.get("description"), + } + rotates = secret.get("rotates") + if rotates is not None: + info["rotates"] = rotates + if reveal: + if revision is not None: + content = _engine._secret_content_for_revision(secret, revision) + else: + content = dict(secret.get("content", {})) + info["content"] = {"Data": content} + info["checksum"] = "" + + sys.stdout.write(json.dumps({uri: info})) + return 0 + + +def remove_secret(args: list[str], model: str | None) -> int: + """Execute the ``remove-secret`` command. + + Usage: juju remove-secret [--revision ] + """ + state = _engine._load_state() + model_name = _engine._require_model_name(state, model) + model_state = state["models"][model_name] + + identifier: str | None = None + revision: int | None = None + i = 0 + while i < len(args): + token = args[i] + if token == "--revision" and i + 1 < len(args): + try: + revision = int(args[i + 1]) + except ValueError: + raise _engine.CliError(f"invalid --revision value: {args[i + 1]}") from None + i += 2 + continue + if token.startswith("--revision="): + try: + revision = int(token.split("=", 1)[1]) + except ValueError: + raise _engine.CliError(f"invalid --revision value: {token}") from None + i += 1 + continue + if token.startswith("-"): + i += 1 + continue + if identifier is None: + identifier = token + i += 1 + + if identifier is None: + raise _engine.CliError("usage: juju remove-secret ") + + secret = _find_secret_by_identifier(model_state, identifier) + if secret is None: + raise _engine.CliError(f"secret not found: {identifier}") + + if revision is not None: + revisions = _engine._secret_revisions(secret) + idx = revision - 1 + if 0 <= idx < len(revisions): + del revisions[idx] + if revisions: + secret["content"] = revisions[-1] + secret["revision"] = len(revisions) + else: + _engine._secrets(model_state).remove(secret) + else: + _engine._secrets(model_state).remove(secret) + _engine._save_state(state) + return 0 + + +def _find_secret_by_identifier( + model_state: dict[str, Any], identifier: str +) -> dict[str, Any] | None: + """Find a secret by URI, user-facing name, or label.""" + secret = _engine._find_secret_by_id(model_state, identifier) + if secret is not None: + return secret + # Match by user-facing name (user secrets) or owner label. + for candidate in _engine._secrets(model_state): + if candidate.get("name") == identifier: + return candidate + if candidate.get("label") == identifier: + return candidate + return None diff --git a/src/jjx/_engine.py b/src/jjx/_engine.py index 6475028..3eb0ca3 100644 --- a/src/jjx/_engine.py +++ b/src/jjx/_engine.py @@ -214,11 +214,19 @@ def _relation_remote_app(relation: dict[str, Any], local_app: str) -> str | None # Secrets are stored in model_state["secrets"] as a list of dicts: # { # "id": "secret:///", -# "label": "...", -# "owner": "postgresql-k8s", -# "content": {"username": "...", "password": "..."}, -# "revision": 1, -# "grants": [{"relation_id": 0, "app": "fastapi-demo"}], +# "label": "...", # owner label (app secrets) or None +# "name": "...", # user-facing name (user secrets) or None +# "owner": "" | "model", # app name for app secrets, "model" for user secrets +# "content": {...}, # latest revision content (revisions[-1]) +# "revisions": [{...}, ...], # all revisions; index 0 == revision 1 +# "revision": 2, # latest revision number +# "grants": [{"app": "", "relation_id": null, "unit": null}], +# "rotate": "monthly" | None, +# "expire": "" | None, +# "description": "..." | None, +# "observers": { # per-app observer tracking +# "": {"label": "