From df3f2e570a1f7f2685a835fc48c3ae1e067e90ca Mon Sep 17 00:00:00 2001
From: Romamo
Date: Fri, 2 Oct 2026 01:51:24 +0300
Subject: [PATCH 1/3] feat: treaty 1.0.0rc12; the play log streams off a
terminal again
rc12 lands the treaty issues the port filed. playbook run streams the
Ansible log to stderr as plain text in any format (stream="always",
treaty #173), so a timer's journal or a CI log keeps it, and the log tail
in a failure's context is treaty.External (#174). The drift report keeps
its declared order on the failure path (#181), so its strict xfail
becomes a regression test.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 12 +++++-------
engine/README.md | 9 +++++----
engine/src/cloudfall_engine/cli.py | 30 ++++++++++++++++++++++--------
pyproject.toml | 2 +-
sdk/tests/test_app.py | 4 ----
uv.lock | 8 ++++----
6 files changed, 37 insertions(+), 28 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a27cb7c..49629bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -122,18 +122,16 @@ Notable changes to Cloudfall. The format follows
85 `INVENTORY_UNREADABLE`, 86 `RECORD_INVALID`, 5 `NOT_FOUND` for an
undeclared operation or a missing proposal, 2 `ARG_ERROR` for bad input;
the old snake_case code is in `error.context.code`. `audit` exits 83
- `DRIFT` (was 1) or 84 `UNKNOWN` (was 3) with the report in `data`; until
- treaty #181 is fixed, that report's lists come back sorted rather than in
- the order the servers and checks were declared. Pick
+ `DRIFT` (was 1) or 84 `UNKNOWN` (was 3) with the report in `data`. Pick
the format with `--format json`: `--output` after one of these commands
is refused, since treaty's `--output` names a file. Relative paths still
resolve against the project, without changing the working directory
- `cloudfall-engine` runs Ansible and git through treaty's `ctx.run`
- (treaty 1.0.0rc10), so `--timeout` and Ctrl-C stop the whole process
+ (treaty 1.0.0rc12), so `--timeout` and Ctrl-C stop the whole process
group, and secrets are redacted from the play log. The play log streams
- to stderr at a terminal; elsewhere, such as under an agent or in CI, it
- shows only with `-v`, one JSON line per log line. A failed playbook
- carries the last 4096 characters of the log in `error.context.output`.
+ to stderr as plain text, as before, terminal or not; `--quiet` silences
+ it. A failed playbook carries the last 4096 characters of the log in
+ `error.context.output`, marked as untrusted content from the hosts.
`playbook run` reports `meta.dry_run: true` under `--dry-run`. A git step
that runs past its 600-second limit now exits 10 `TIMEOUT`, not 81 with
`artifact_git_timeout`. `cloudfall-engine doctor` checks for
diff --git a/engine/README.md b/engine/README.md
index 29a0e4a..c548657 100644
--- a/engine/README.md
+++ b/engine/README.md
@@ -56,10 +56,11 @@ uv run cloudfall-engine playbook run engine/ansible/playbooks/proxy.yml \
```
`--dry-run` (Ansible's check mode), `--diff`, `--syntax-check`, `--limit`,
-`--tags`, and `--extra-vars` pass through to `ansible-playbook`. At a terminal,
-Ansible's play log streams to stderr as it runs; elsewhere, such as under an
-agent or in CI, pass `-v` to see it. stdout carries one JSON document when the
-run ends, and a failed run keeps the end of the log in `error.context.output`.
+`--tags`, and `--extra-vars` pass through to `ansible-playbook`. Ansible's play
+log streams to stderr as it runs, as plain text whether or not a terminal is
+attached (`--quiet` silences it). stdout carries one JSON document when the run
+ends, and a failed run keeps the end of the log in `error.context.output`,
+marked as untrusted content from the hosts.
`cloudfall-engine doctor` checks that ansible-playbook and git are installed.
A project uses this instead of
setting `ANSIBLE_CONFIG` against a checkout; the `task` targets in this
diff --git a/engine/src/cloudfall_engine/cli.py b/engine/src/cloudfall_engine/cli.py
index 15f499b..6a75734 100644
--- a/engine/src/cloudfall_engine/cli.py
+++ b/engine/src/cloudfall_engine/cli.py
@@ -3,7 +3,7 @@
Built on treaty: every run answers one JSON envelope on stdout, a failure
carries a declared exit code, and ``cloudfall-engine manifest`` describes
each command. Ansible and git run through ``ctx.run``; Ansible's play log
-streams to stderr on a terminal or under ``-v``.
+streams to stderr as plain text.
"""
import json
@@ -23,7 +23,18 @@
)
from cloudfall.resources import default_engine_directory, default_schema_directory
from cloudfall.validation import ConfigValidationError, validate_config
-from treaty import App, Arg, Ctx, Exit, Flag, Out, ParseError, Subprocess, Timeout
+from treaty import (
+ App,
+ Arg,
+ Ctx,
+ Exit,
+ External,
+ Flag,
+ Out,
+ ParseError,
+ Subprocess,
+ Timeout,
+)
from cloudfall_engine.ansible_inventory import render_ansible_inventory
from cloudfall_engine.artifact import (
@@ -88,8 +99,8 @@
retryable=False,
side_effects="partial",
suggestion=(
- "read the end of the Ansible log in error.context.output, or run "
- "again with -v to stream all of it; fix the failing task, and run again"
+ "read the Ansible log on stderr, or its end in error.context.output; "
+ "fix the failing task, and run again"
),
)
@@ -392,13 +403,14 @@ class Played:
"run",
description=(
"Run one bundled or project playbook against the inventory; "
- "the Ansible log streams to stderr at a terminal or under -v"
+ "the Ansible log streams to stderr"
),
danger_level="mutating",
exit_codes=["PRECONDITION", "PLAYBOOK_FAILED"],
timeout=None,
subprocess=Subprocess("ansible-playbook"),
required_tools={"ansible-playbook": ANSIBLE_MINIMUM_VERSION},
+ child_log=True,
external=False,
supports_raw_payload=True,
examples=[
@@ -423,9 +435,10 @@ def run_playbook(args: RunArgs, ctx: Ctx) -> Played:
error.detail, context={"code": error.code}
) from error
# Stdout belongs to the envelope, so treaty streams Ansible's play log to
- # stderr line by line, redacted, and keeps its tail for a failure.
+ # stderr as plain text, redacted, terminal or not (a timer's journal or a
+ # CI log keeps it), and keeps its tail for a failure.
completed = ctx.run(
- command.argv, env=command.environment, check=False, stream=True
+ command.argv, env=command.environment, check=False, stream="always"
)
if completed.returncode != 0:
returncode = completed.returncode
@@ -435,7 +448,8 @@ def run_playbook(args: RunArgs, ctx: Ctx) -> Played:
context={
"returncode": returncode,
"playbook": str(run.playbook),
- "output": completed.stdout,
+ # The hosts wrote this text: the agent gets it marked untrusted.
+ "output": External(completed.stdout),
},
)
return Played(_effect(args), run.playbook, run.inventory_file)
diff --git a/pyproject.toml b/pyproject.toml
index 82e4ef4..b894dc4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,7 @@ dependencies = [
"ansible-core>=2.21,<2.22",
"jsonschema[format]>=4.23,<5",
"pyyaml>=6,<7",
- "treaty==1.0.0rc10",
+ "treaty==1.0.0rc12",
]
[project.urls]
diff --git a/sdk/tests/test_app.py b/sdk/tests/test_app.py
index c38812d..80d6915 100644
--- a/sdk/tests/test_app.py
+++ b/sdk/tests/test_app.py
@@ -67,10 +67,6 @@ def test_a_half_moved_group_sends_the_rest_to_argparse(
assert "--gateway-ca" in error["error"]["message"]
-@pytest.mark.xfail(
- strict=True,
- reason="treaty #181: a failure's data loses an output adapter's array order",
-)
def test_a_drift_report_keeps_the_order_the_checks_ran_in(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
diff --git a/uv.lock b/uv.lock
index 568e363..9b77e95 100644
--- a/uv.lock
+++ b/uv.lock
@@ -285,7 +285,7 @@ requires-dist = [
{ name = "jsonschema", extras = ["format"], specifier = ">=4.23,<5" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=2,<3" },
{ name = "pyyaml", specifier = ">=6,<7" },
- { name = "treaty", specifier = "==1.0.0rc10" },
+ { name = "treaty", specifier = "==1.0.0rc12" },
]
provides-extras = ["mcp"]
@@ -1085,11 +1085,11 @@ wheels = [
[[package]]
name = "treaty"
-version = "1.0.0rc10"
+version = "1.0.0rc12"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/bd/fc/d20b8b58e214bcec8dce6c7f24f9356760330a9fa1cf5675cfab3cad0d1b/treaty-1.0.0rc10.tar.gz", hash = "sha256:410fa7e0fbe58c6d4b09188a8e34ac28e02b00581737da197876ddd8d5ff76b2", size = 477923, upload-time = "2026-10-01T12:55:22.599Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fb/0e/a4015390a80e1325b76f1ce63ab4cb2342bc58483955da1bf961875934ec/treaty-1.0.0rc12.tar.gz", hash = "sha256:eb49549e14ed50b8a57834c593401bc0dda9236e9db995ffb11f738f7e76670f", size = 504914, upload-time = "2026-10-01T22:47:18.114Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5e/ab/0abc9621bd36885d4b1966d259701272e10c69e0daf6ae41c665e3dc14a7/treaty-1.0.0rc10-py3-none-any.whl", hash = "sha256:28ccbafe5aafae074ad769be7f6bd7d54d134d80c1938546294ec97d75d7c94d", size = 482571, upload-time = "2026-10-01T12:55:20.959Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/5d/c57f406a7d2390bd2ee7a54fc5034a382a2a1489f01e4f63ff76fc53f4a9/treaty-1.0.0rc12-py3-none-any.whl", hash = "sha256:a77fcecb6666181bb62e425dca463141fcfda769aaeab9e03a846eb83d832b19", size = 507121, upload-time = "2026-10-01T22:47:16.078Z" },
]
[[package]]
From c5969bb4c2a8600bbf55c9de57e7d7cae96ae0ad Mon Sep 17 00:00:00 2001
From: Romamo
Date: Fri, 2 Oct 2026 01:54:01 +0300
Subject: [PATCH 2/3] feat!: why runs on treaty, with --format html
treaty rc12 lets an app register a format outside its enum (#179), so
why moves with its page: render_why_document draws it from the answer's
JSON, which is what a treaty renderer receives. treaty offers a custom
format on every command (#209), so the others render their data as a
page of JSON.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 7 +++
sdk/src/cloudfall/app.py | 92 +++++++++++++++++++++++++++++++++++
sdk/src/cloudfall/cli.py | 37 +-------------
sdk/src/cloudfall/commands.py | 1 +
sdk/src/cloudfall/why.py | 46 ++++++++++++------
sdk/tests/test_why.py | 7 +--
6 files changed, 137 insertions(+), 53 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49629bb..782ad13 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -80,6 +80,13 @@ Notable changes to Cloudfall. The format follows
A missing playbook, inventory, or role directory exits 2 before Ansible
starts. `playbook run --check` is now `--dry-run`; Ansible's play log
streams to stderr. `cloudfall-engine manifest` describes every command
+- **Breaking:** `why` runs on treaty, `--format html` included (treaty
+ #179): the page is drawn from the answer's JSON document, as before. Its
+ verdict moves to `data.status`; a bad `--since` or `--until` is 2
+ `ARG_ERROR` with `why_instant_invalid` in `error.context.code`, and a
+ record that cannot be read 86 `RECORD_INVALID`. treaty offers a custom
+ format on every command (#209), so `--format html` on any other treaty
+ command renders its `data` as a page of JSON
- **Breaking:** seven commands that run Ansible move to treaty: `health`,
`backup run`, `backup verify`, `operations propose`, `operations approve`,
`operator approve` and `migrate`. Ansible runs through treaty in the
diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py
index 14af68f..dbbac37 100644
--- a/sdk/src/cloudfall/app.py
+++ b/sdk/src/cloudfall/app.py
@@ -15,9 +15,11 @@
from __future__ import annotations
import errno
+import json
import os
from dataclasses import dataclass
from functools import partial
+from html import escape
from importlib.metadata import version
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, Self, cast
@@ -147,6 +149,7 @@
ValidatedConfig,
validate_config,
)
+from cloudfall.why import WhyError, WhyQuery, answer, render_why_document
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
@@ -224,6 +227,19 @@
)
+def _html_page(data: object) -> str:
+ """Render any command's data as a page of its JSON, for ``--format html``.
+
+ Only ``why`` has a page of its own; treaty offers a format on every
+ command once one has it (treaty #209), so the others answer this.
+ """
+ body = escape(json.dumps(data, indent=2, sort_keys=True))
+ return f'\n\n{body}\n'
+
+
+app.format("html", render=_html_page, media_type="text/html")
+
+
class Payload:
"""A command's flat payload, written as ``data`` under its declared keys.
@@ -2090,3 +2106,79 @@ def migrate(args: MigrateArgs, ctx: Ctx, fleet: Fleet) -> Migration:
message = f"the migration failed at {migration.step}"
raise Exit.ENGINE_STEP_FAILED(message, context=cause, data=migration)
return migration
+
+
+# why
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class WhyArgs(DecisionsArgs):
+ """Arguments of ``why``."""
+
+ host: str | None = Flag(
+ default=None, description="Only decisions whose record names this host"
+ )
+ operation: ResourceId | None = Flag(
+ default=None, description="Only decisions of this declared operation"
+ )
+ since: str | None = Flag(
+ default=None,
+ description=(
+ "Only decisions with a moment at or after this ISO 8601 time or date"
+ ),
+ )
+ until: str | None = Flag(
+ default=None,
+ description=(
+ "Only decisions with a moment at or before this ISO 8601 time or date"
+ ),
+ )
+
+ def __post_init__(self) -> None:
+ """Refuse a time the question cannot be asked with."""
+ DecisionsArgs.__post_init__(self)
+ try:
+ self.query()
+ except WhyError as error:
+ raise ParseError(error.detail, context={"code": error.code}) from error
+
+ def query(self) -> WhyQuery:
+ """Return the question, as the record is filtered by it."""
+ return WhyQuery.from_boundary(
+ host=self.host,
+ operation=self.operation.value if self.operation is not None else None,
+ since=self.since,
+ until=self.until,
+ )
+
+
+class WhyPayload(Payload):
+ """``why``: each decision the question is about, told from its record."""
+
+ command = "why"
+
+
+@app.command(
+ "why",
+ description=(
+ "Answer why the agent did that, from the record, for a host, an operation "
+ "or a time window; --format html renders one page"
+ ),
+ danger_level="safe",
+ exit_codes=["RECORD_INVALID"],
+ renderers={"html": render_why_document},
+ examples=[
+ ("Ask about one host", "cloudfall why --host h1"),
+ ("One page for a person", "cloudfall why --since 2026-10-01 --format html"),
+ ],
+)
+def why(args: WhyArgs, _ctx: Ctx) -> WhyPayload:
+ """Answer from the record alone: no catalog, no fleet."""
+ store = DecisionStore(
+ directory=args.root / args.decisions, catalog=SchemaCatalog(args.schemas)
+ )
+ try:
+ result = answer(store, args.query())
+ except (DecisionError, WhyError) as error:
+ raise Exit.RECORD_INVALID(error.detail, context={"code": error.code}) from error
+ return WhyPayload(result.as_dict())
diff --git a/sdk/src/cloudfall/cli.py b/sdk/src/cloudfall/cli.py
index a2d2a58..100b8dd 100644
--- a/sdk/src/cloudfall/cli.py
+++ b/sdk/src/cloudfall/cli.py
@@ -46,7 +46,6 @@
from cloudfall.decision import (
DECISION_DIRECTORY,
DecisionError,
- DecisionStore,
)
from cloudfall.importer import (
ImportTargets,
@@ -129,7 +128,7 @@
ValidatedConfig,
validate_config,
)
-from cloudfall.why import WhyError, WhyQuery, answer, render_why_html
+from cloudfall.why import WhyError
def _add_config_parsers(
@@ -1398,39 +1397,6 @@ def _command_key(arguments: Namespace) -> str:
return f"{arguments.command}:{subcommand}"
-def _run_why(arguments: Namespace) -> int:
- """Answer the question from the record alone: no catalog, no fleet.
-
- The record outlives both, so the question must be answerable from a
- checkout that holds nothing but the decisions directory.
- """
- repository = (
- Path(arguments.repository) if arguments.repository is not None else Path.cwd()
- )
- query = WhyQuery.from_boundary(
- host=arguments.host,
- operation=(
- arguments.operation.value if arguments.operation is not None else None
- ),
- since=arguments.since,
- until=arguments.until,
- )
- result = answer(_decision_store(arguments, repository), query)
- if arguments.format == "html":
- sys.stdout.write(render_why_html(result))
- sys.stdout.flush()
- return 0
- write_result(result.as_dict())
- return 0
-
-
-def _decision_store(arguments: Namespace, repository: Path) -> DecisionStore:
- return DecisionStore(
- directory=repository / Path(arguments.decisions),
- catalog=SchemaCatalog(Path(arguments.schemas)),
- )
-
-
def _run_changelog(arguments: Namespace) -> int:
write_result(
{
@@ -1449,7 +1415,6 @@ def _run_changelog(arguments: Namespace) -> int:
_RECORD_COMMANDS: Mapping[str, Callable[[Namespace], int]] = {
- "why": _run_why,
}
"""The commands that read the repository and nothing else: no fleet, no project."""
diff --git a/sdk/src/cloudfall/commands.py b/sdk/src/cloudfall/commands.py
index 2700be2..42e0f58 100644
--- a/sdk/src/cloudfall/commands.py
+++ b/sdk/src/cloudfall/commands.py
@@ -385,6 +385,7 @@ def _plan(subject: str, *extra: str) -> OutputShape:
when="`--format json`, the default; `--format html` is not JSON",
),
),
+ treaty=True,
),
CommandContract(
"cloudfall",
diff --git a/sdk/src/cloudfall/why.py b/sdk/src/cloudfall/why.py
index b7b0525..f1da1a9 100644
--- a/sdk/src/cloudfall/why.py
+++ b/sdk/src/cloudfall/why.py
@@ -18,13 +18,13 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from html import escape
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, cast
from cloudfall.decision import DecisionStatus, Requirement
from cloudfall.domain import Hostname, ResourceId
if TYPE_CHECKING:
- from collections.abc import Iterable
+ from collections.abc import Iterable, Mapping, Sequence
from cloudfall.decision import Decision, DecisionStore, RunRecord
@@ -234,17 +234,28 @@ def moments_of(decision: Decision) -> tuple[Instant, ...]:
def render_why_html(result: WhyAnswer) -> str:
"""Render the answer as one page a person reads without the JSON."""
- cards = "".join(_render_card(entry) for entry in result.explanations)
+ return render_why_document(result.as_dict())
+
+
+def render_why_document(document: Mapping[str, object]) -> str:
+ """Render the answer's JSON document as one page, as ``--format html`` does.
+
+ The page is drawn from the document alone, so treaty's renderer, which
+ receives the answer's ``data``, draws the same page as the object would.
+ """
+ answers = cast("Sequence[Mapping[str, object]]", document["answers"])
+ query = cast("Mapping[str, object]", document["query"])
+ cards = "".join(_render_card(entry) for entry in answers)
if not cards:
cards = (
'The record holds no decision this question is about.
'
)
filters = ", ".join(
f"{escape(key)} = {escape(str(value))}"
- for key, value in result.query.as_dict().items()
+ for key, value in query.items()
)
asked = escape(filters) if filters else "Every decision the record holds"
- count = len(result.explanations)
+ count = len(answers)
counted = f"{count} decision{'' if count == 1 else 's'}"
return f"""
@@ -285,7 +296,7 @@ def render_why_html(result: WhyAnswer) -> str:
{cards}