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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Notable changes to Cloudfall. The format follows

### Added

- `operations propose` returns a read operation's play output in
`data.output`, so an agent acts on what the operation reported without
opening the decision's `.diff` file; the record keeps the file as before.
The output is the hosts' text, so `data` carries `_trusted: false`.
A proposed change answers `output: null`, and the read operation's MCP
tool no longer suggests approving a run that needs no approval (#24)
- `cloudfall why` answers "why did the agent do that" from the decision
records alone, for a host (`--host`), an operation (`--operation`) or a
time window (`--since`, `--until`, ISO 8601 or a bare date), as JSON or
Expand Down
21 changes: 19 additions & 2 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
DecisionStatus,
DecisionStore,
ProposalRequest,
Requirement,
Targets,
approve,
propose,
Expand Down Expand Up @@ -1947,12 +1948,17 @@ class ApproveDecisionArgs(DecisionsArgs):

@dataclass(frozen=True, slots=True)
class Decided:
"""One decision record, and what to do next when it waits for approval."""
"""One decision record, and what to do next when it waits for approval.

A read operation has run by the time it is recorded, so its play output
comes back with the record; it is the hosts' text, never trusted.
"""

effect: str
status: str
decision: dict[str, object] = Out(ordered=True)
next: list[str] = Out(ordered=True)
output: str | None = Out(default=None, external=True)


def _decision_failed(error: DecisionError) -> Exception:
Expand Down Expand Up @@ -2003,8 +2009,17 @@ def operations_propose(args: ProposeArgs, ctx: Ctx) -> Decided:
decision = propose(request, store, run=_check_runner(ctx, args.root))
except DecisionError as error:
raise _decision_failed(error) from error
output = (
(args.root / decision.check.diff.path).read_text(encoding="utf-8")
if decision.requirement is Requirement.RUNS_FREELY
else None
)
decided = Decided(
effect="created", status="ok", decision=decision.as_document(), next=[]
effect="created",
status="ok",
decision=decision.as_document(),
next=[],
output=output,
)
if decision.check.exit_code != 0:
message = (
Expand Down Expand Up @@ -3462,6 +3477,8 @@ def propose_operation(arguments: Mapping[str, object], ctx: Ctx) -> Decided:
),
ctx,
)
if operation.risk is RiskLevel.READ:
return decided
record = cast("Mapping[str, Mapping[str, object]]", decided.decision)
approve = f"{APPROVAL_COMMAND} {record['metadata']['id']} --yes"
return dataclasses.replace(decided, next=[approve])
Expand Down
52 changes: 52 additions & 0 deletions sdk/tests/test_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,58 @@ def test_the_cli_proposes_and_then_lists_the_record(
assert payload["data"]["decisions"][0]["spec"]["operation"]["id"] == "deploy"


def test_the_cli_returns_what_a_read_operation_reported(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
repository = _repository(tmp_path)
(repository / "playbooks" / "facts.yml").write_text(
"---\n- hosts: localhost\n connection: local\n gather_facts: false\n"
" tasks:\n - name: Report\n ansible.builtin.debug:\n"
" msg: /var/log/shop holds 36G\n",
encoding="utf-8",
)

exit_code = main(
["operations", "propose", "facts", "--repository", str(repository)]
)

payload = json.loads(capsys.readouterr().out)
assert exit_code == 0
assert "/var/log/shop holds 36G" in payload["data"]["output"]
assert payload["data"]["_trusted"] is False
assert payload["data"]["next"] == []


def test_the_cli_returns_no_output_for_a_proposed_change(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
repository = _repository(tmp_path)
_observations(repository, "web-1")
(repository / "playbooks" / "deploy.yml").write_text(
"---\n- hosts: localhost\n connection: local\n gather_facts: false\n"
" tasks:\n - name: Nothing\n ansible.builtin.debug:\n"
" msg: nothing\n",
encoding="utf-8",
)

main(
[
"operations",
"propose",
"deploy",
"--repository",
str(repository),
"--target",
"localhost",
"--input",
"version=1.4.0",
]
)

payload = json.loads(capsys.readouterr().out)
assert payload["data"]["output"] is None


def test_the_cli_reports_a_gate_refusal_as_json(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
Expand Down
Loading