Skip to content

Commit 59abe10

Browse files
Merge pull request #27 from cloudfall-dev/feat/read-operation-output
Return a read operation's output from operations propose
2 parents d84a2ae + 3bc6c3d commit 59abe10

3 files changed

Lines changed: 77 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ Notable changes to Cloudfall. The format follows
88

99
### Added
1010

11+
- `operations propose` returns a read operation's play output in
12+
`data.output`, so an agent acts on what the operation reported without
13+
opening the decision's `.diff` file; the record keeps the file as before.
14+
The output is the hosts' text, so `data` carries `_trusted: false`.
15+
A proposed change answers `output: null`, and the read operation's MCP
16+
tool no longer suggests approving a run that needs no approval (#24)
1117
- `cloudfall why` answers "why did the agent do that" from the decision
1218
records alone, for a host (`--host`), an operation (`--operation`) or a
1319
time window (`--since`, `--until`, ISO 8601 or a bare date), as JSON or

‎sdk/src/cloudfall/app.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
DecisionStatus,
106106
DecisionStore,
107107
ProposalRequest,
108+
Requirement,
108109
Targets,
109110
approve,
110111
propose,
@@ -1947,12 +1948,17 @@ class ApproveDecisionArgs(DecisionsArgs):
19471948

19481949
@dataclass(frozen=True, slots=True)
19491950
class Decided:
1950-
"""One decision record, and what to do next when it waits for approval."""
1951+
"""One decision record, and what to do next when it waits for approval.
1952+
1953+
A read operation has run by the time it is recorded, so its play output
1954+
comes back with the record; it is the hosts' text, never trusted.
1955+
"""
19511956

19521957
effect: str
19531958
status: str
19541959
decision: dict[str, object] = Out(ordered=True)
19551960
next: list[str] = Out(ordered=True)
1961+
output: str | None = Out(default=None, external=True)
19561962

19571963

19581964
def _decision_failed(error: DecisionError) -> Exception:
@@ -2003,8 +2009,17 @@ def operations_propose(args: ProposeArgs, ctx: Ctx) -> Decided:
20032009
decision = propose(request, store, run=_check_runner(ctx, args.root))
20042010
except DecisionError as error:
20052011
raise _decision_failed(error) from error
2012+
output = (
2013+
(args.root / decision.check.diff.path).read_text(encoding="utf-8")
2014+
if decision.requirement is Requirement.RUNS_FREELY
2015+
else None
2016+
)
20062017
decided = Decided(
2007-
effect="created", status="ok", decision=decision.as_document(), next=[]
2018+
effect="created",
2019+
status="ok",
2020+
decision=decision.as_document(),
2021+
next=[],
2022+
output=output,
20082023
)
20092024
if decision.check.exit_code != 0:
20102025
message = (
@@ -3462,6 +3477,8 @@ def propose_operation(arguments: Mapping[str, object], ctx: Ctx) -> Decided:
34623477
),
34633478
ctx,
34643479
)
3480+
if operation.risk is RiskLevel.READ:
3481+
return decided
34653482
record = cast("Mapping[str, Mapping[str, object]]", decided.decision)
34663483
approve = f"{APPROVAL_COMMAND} {record['metadata']['id']} --yes"
34673484
return dataclasses.replace(decided, next=[approve])

‎sdk/tests/test_decision.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,58 @@ def test_the_cli_proposes_and_then_lists_the_record(
394394
assert payload["data"]["decisions"][0]["spec"]["operation"]["id"] == "deploy"
395395

396396

397+
def test_the_cli_returns_what_a_read_operation_reported(
398+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
399+
) -> None:
400+
repository = _repository(tmp_path)
401+
(repository / "playbooks" / "facts.yml").write_text(
402+
"---\n- hosts: localhost\n connection: local\n gather_facts: false\n"
403+
" tasks:\n - name: Report\n ansible.builtin.debug:\n"
404+
" msg: /var/log/shop holds 36G\n",
405+
encoding="utf-8",
406+
)
407+
408+
exit_code = main(
409+
["operations", "propose", "facts", "--repository", str(repository)]
410+
)
411+
412+
payload = json.loads(capsys.readouterr().out)
413+
assert exit_code == 0
414+
assert "/var/log/shop holds 36G" in payload["data"]["output"]
415+
assert payload["data"]["_trusted"] is False
416+
assert payload["data"]["next"] == []
417+
418+
419+
def test_the_cli_returns_no_output_for_a_proposed_change(
420+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
421+
) -> None:
422+
repository = _repository(tmp_path)
423+
_observations(repository, "web-1")
424+
(repository / "playbooks" / "deploy.yml").write_text(
425+
"---\n- hosts: localhost\n connection: local\n gather_facts: false\n"
426+
" tasks:\n - name: Nothing\n ansible.builtin.debug:\n"
427+
" msg: nothing\n",
428+
encoding="utf-8",
429+
)
430+
431+
main(
432+
[
433+
"operations",
434+
"propose",
435+
"deploy",
436+
"--repository",
437+
str(repository),
438+
"--target",
439+
"localhost",
440+
"--input",
441+
"version=1.4.0",
442+
]
443+
)
444+
445+
payload = json.loads(capsys.readouterr().out)
446+
assert payload["data"]["output"] is None
447+
448+
397449
def test_the_cli_reports_a_gate_refusal_as_json(
398450
tmp_path: Path, capsys: pytest.CaptureFixture[str]
399451
) -> None:

0 commit comments

Comments
 (0)