diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb0f2e..4ea846d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 07fcb17..cc22ee9 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -105,6 +105,7 @@ DecisionStatus, DecisionStore, ProposalRequest, + Requirement, Targets, approve, propose, @@ -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: @@ -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 = ( @@ -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]) diff --git a/sdk/tests/test_decision.py b/sdk/tests/test_decision.py index ceae79f..bc508d3 100644 --- a/sdk/tests/test_decision.py +++ b/sdk/tests/test_decision.py @@ -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: