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:

Why did the agent do that

{asked} · {counted} - · from {escape(result.directory)}

+ · from {escape(str(document["directory"]))}

{cards}