Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Supported:
- multiple models (e.g. a charm model and a COS model)
- cross-model relations via `juju offer` and `juju integrate <model>.<app>`
- virtual bundles (e.g. `juju deploy cos-lite`)
- `juju run` (actions) on virtual charms
- `juju run` (actions) on real and virtual charms
- app-managed and user secrets, including `secret-changed` event dispatch

Not supported:
Expand Down Expand Up @@ -116,6 +116,7 @@ The `.charm` file passed to deploy is a trigger only. `jjx` does not inspect or
- `./.jjx/charm/` (staged runtime charm directory with `src/`, `lib/`, `metadata.yaml`, `config.yaml`, and `.unit-state.db`)
- `./.jjx/socket` (Pebble API Unix socket, bind-mounted into both the workload and charm runner containers)
- `./.jjx/<app>.<pid>.deploy` (marker files for in-flight background pebble-ready processes; created by deploy, deleted by the process on completion or by teardown)
- `./.jjx/action-<uuid>.json` (transient per-action results file; created by `juju run`, read/written by the action hook tools, deleted after the hook exits)
- `./.jjx/prom-config-<app>/` (Prometheus config directory, bind-mounted into the Prometheus container)
- `./.jjx/grafana-config-<app>/` (Grafana provisioning directory, bind-mounted into the Grafana container)

Expand Down Expand Up @@ -253,7 +254,7 @@ When `jjx down` tears down all models, models are destroyed in reverse creation
jjx implements several juju commands that jubilant/pytest-jubilant may call during setup, teardown, or status checks. These are minimal stubs that return just enough data for jubilant to function:

- `juju offer` — records a cross-model offer in model state
- `juju run` — executes actions on virtual charms (e.g. traefik's `show-proxied-endpoints`)
- `juju run` — executes actions on real charms (dispatching the `actions/<name>` hook via `docker exec`) and on virtual charms (returning dynamically-computed results)
- `juju switch` — no-op (jjx always uses `--model`)
- `juju version` — returns a minimal version response
- `juju show-model` — returns model metadata
Expand All @@ -278,6 +279,13 @@ Implemented:
- `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
- `action-get`, `action-set`, `action-fail`, `action-log` — action parameter access and result collection (see "actions" below)

## actions

`juju run <unit> <action>` dispatches the charm's `actions/<name>` hook via `docker exec` into the charm runner, with `JUJU_ACTION_NAME` and `JUJU_ACTION_UUID` set and `JUJU_HOOK_NAME` empty (per `ops`). The action hook tools communicate with `juju run` through a transient per-action results file (`./.jjx/action-<uuid>.json`): `action-get` reads the params, `action-set` accumulates results, `action-fail` sets the failure message, and `action-log` appends progress messages. After the hook exits, `juju run` reads the file and emits the task JSON jubilant expects.

A hook that exits non-zero (uncaught exception) marks the task `failed` and sets unit/app status to `error`, matching real Juju. A hook that calls `action-fail` marks the task `failed` without changing unit status. In both cases `juju run` exits non-zero with `task failed` on stderr (and the task JSON on stdout) so jubilant raises `TaskError`.

## secrets

Expand Down
178 changes: 178 additions & 0 deletions src/jjx/_cmd_hook_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import os
import pathlib
import sys
from typing import Any

Expand Down Expand Up @@ -238,6 +239,9 @@ def hook_tool(args: list[str]) -> int:
if tool == "network-get":
return _network_get(tool_args, app_state)

if tool in ("action-get", "action-set", "action-fail", "action-log"):
return _action_tool(tool, tool_args)

raise _engine.CliError(f"unsupported hook tool: {tool}")


Expand Down Expand Up @@ -1084,3 +1088,177 @@ def _network_get(tool_args: list[str], app_state: dict[str, Any]) -> int:
else:
sys.stdout.write(yaml.safe_dump(network_info, default_flow_style=False))
return 0


# ---------------------------------------------------------------------------
# Action hook tools
# ---------------------------------------------------------------------------


def _action_results_path() -> pathlib.Path:
"""Return the path to the current action's results file.

The file is keyed by ``JUJU_ACTION_UUID`` (set by the action dispatch in
``_engine._run_action_event``). It holds the params (input) and the
accumulating results, log messages, failure message, and failed flag that
the action hook tools write during the hook. ``_run_action_event`` reads
it after the hook exits to construct the task result jubilant expects.
"""
action_uuid = os.environ.get("JUJU_ACTION_UUID")
if not action_uuid:
raise _engine.CliError("action hook tool called outside an action hook")
return _engine._jjx_dir() / f"action-{action_uuid}.json"


def _load_action_results() -> dict[str, Any]:
path = _action_results_path()
if not path.exists():
raise _engine.CliError(f"action results file not found: {path.name}")
return json.loads(path.read_text(encoding="utf-8"))


def _save_action_results(data: dict[str, Any]) -> None:
path = _action_results_path()
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data), encoding="utf-8")
os.replace(tmp, path)


def _action_get(tool_args: list[str]) -> int:
"""action-get [--format=json] [key]

Returns the action parameters. With a dotted key, recurses into the
params map (matching real Juju's ``action-get``).
"""
output_format = "json"
key: str | None = None
i = 0
while i < len(tool_args):
token = tool_args[i]
if token == "--format" and i + 1 < len(tool_args):
output_format = tool_args[i + 1]
i += 2
continue
if token.startswith("--format="):
output_format = token.split("=", 1)[1]
i += 1
continue
if token.startswith("-"):
i += 1
continue
key = token
i += 1

data = _load_action_results()
params = data.get("params", {})

if key is None:
answer: Any = params if params else {}
else:
answer = params
for part in key.split("."):
if isinstance(answer, dict) and part in answer:
answer = answer[part]
else:
answer = None
break

if output_format == "yaml":
sys.stdout.write(yaml.safe_dump(answer, sort_keys=False))
else:
sys.stdout.write(json.dumps(answer))
return 0


def _action_set(tool_args: list[str]) -> int:
"""action-set <key>=<value> [<key>=<value> ...]

Adds the given dotted key=value pairs to the action results map. Nested
keys (e.g. ``foo.bar=baz``) build a nested dict, matching real Juju.
"""
data = _load_action_results()
results = data.setdefault("results", {})
for arg in tool_args:
if arg.startswith("-"):
continue
key, sep, value = arg.partition("=")
if not sep:
raise _engine.CliError(f"action-set argument must be key=value: {arg}")
_set_nested(results, key.split("."), value)
_save_action_results(data)
return 0


def _set_nested(target: dict[str, Any], keys: list[str], value: Any) -> None:
for key in keys[:-1]:
target = target.setdefault(key, {})
if not isinstance(target, dict):
raise _engine.CliError(f"cannot set nested key under non-dict value: {key}")
target[keys[-1]] = value


def _action_fail(tool_args: list[str]) -> int:
"""action-fail [message]

Marks the action as failed with the given message.
"""
message = ""
i = 0
while i < len(tool_args):
token = tool_args[i]
if token == "--":
message = " ".join(tool_args[i + 1 :])
break
if token.startswith("-"):
i += 1
continue
if message:
message += " " + token
else:
message = token
i += 1

data = _load_action_results()
data["failed"] = True
data["message"] = message
_save_action_results(data)
return 0


def _action_log(tool_args: list[str]) -> int:
"""action-log <message>

Appends a progress message to the action's log.
"""
message = ""
i = 0
while i < len(tool_args):
token = tool_args[i]
if token == "--":
message = " ".join(tool_args[i + 1 :])
break
if token.startswith("-"):
i += 1
continue
if message:
message += " " + token
else:
message = token
i += 1

data = _load_action_results()
data.setdefault("log", []).append(message)
_save_action_results(data)
return 0


def _action_tool(tool: str, tool_args: list[str]) -> int:
if tool == "action-get":
return _action_get(tool_args)
if tool == "action-set":
return _action_set(tool_args)
if tool == "action-fail":
return _action_fail(tool_args)
if tool == "action-log":
return _action_log(tool_args)
raise _engine.CliError(f"unsupported action hook tool: {tool}")
44 changes: 36 additions & 8 deletions src/jjx/_cmd_run.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Run command wrapper (actions).

Executes an action on a unit. For real charms, this would run the action
hook via ``docker exec`` into the charm runner (not yet implemented). For
virtual charms, it returns dynamically-computed results (e.g. traefik's
Executes an action on a unit. For real charms, this runs the action hook via
``docker exec`` into the charm runner (see :func:`jjx._engine._run_action_event`).
For virtual charms, it returns dynamically-computed results (e.g. traefik's
``show-proxied-endpoints`` discovers the URLs of other COS charms in the model).
"""

Expand All @@ -12,6 +12,8 @@
import sys
from typing import Any

import yaml

from . import _engine, _virtual_traefik


Expand All @@ -24,6 +26,7 @@ def run(args: list[str], model: str | None) -> int:
output_format = "json"
unit: str | None = None
action: str | None = None
params_file: str | None = None

i = 0
while i < len(args):
Expand All @@ -43,8 +46,13 @@ def run(args: list[str], model: str | None) -> int:
i += 1
continue
if token == "--params" and i + 1 < len(args):
params_file = args[i + 1]
i += 2
continue
if token.startswith("--params="):
params_file = token.split("=", 1)[1]
i += 1
continue
if token.startswith("--"):
i += 1
continue
Expand Down Expand Up @@ -76,11 +84,31 @@ def run(args: list[str], model: str | None) -> int:
if app_state.get("virtual"):
return _run_virtual_action(model_state, app_name, app_state, action, output_format)

# For real charms, we would need to run the action hook. This is not
# implemented yet — the k8s-5-observe tests only use actions on traefik
# (virtual) and the charm's own get-db-info action (which the tests
# don't call in the COS Lite test flow).
raise _engine.CliError(f"actions on real charms not yet supported: {action}")
# Real charm: dispatch the action hook and return the task result.
params: dict[str, Any] = {}
if params_file:
try:
loaded = yaml.safe_load(open(params_file).read()) # noqa: SIM115
except OSError as exc:
raise _engine.CliError(f"failed to read params file: {exc}") from None
if loaded is not None:
if not isinstance(loaded, dict):
raise _engine.CliError(
f"action params must be a mapping, got {type(loaded).__name__}"
)
params = loaded

task = _engine._run_action_event(model_name, app_name, action, params=params)
unit_name = app_state.get("unit", f"{app_name}/0")
result = {unit_name: task}
sys.stdout.write(json.dumps(result))
# jubilant expects the CLI to exit non-zero with "task failed" in stderr
# when the action fails, so it can distinguish a failed action from a
# command error. The task JSON is still on stdout either way.
if task["status"] != "completed":
sys.stderr.write(f"task failed: action {action!r} on {unit_name}\n")
return 1
return 0


def _run_virtual_action(
Expand Down
Loading
Loading