From 23d8a89ddd7bc1fdf0d94c12d791052ea1e4086e Mon Sep 17 00:00:00 2001 From: Romamo Date: Thu, 1 Oct 2026 16:31:35 +0300 Subject: [PATCH 1/4] feat: run Ansible and git through treaty's ctx.run on treaty 1.0.0rc10 treaty rc10 closes the issues that kept the engine on subprocess: ctx.run streams a child's output (#61) and gives children a UTF-8 locale Ansible accepts (#62). playbook run and artifact build now start their children through ctx.run, so --timeout and Ctrl-C stop the process group and secrets are redacted from the play log. The play log streams to stderr at a terminal and under -v; elsewhere a failed run carries the log's tail in error.context.output. A git step past its limit is treaty's TIMEOUT (exit 10). Both commands declare their program and minimum version, so doctor checks ansible-playbook and git. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +++ engine/README.md | 9 ++- engine/src/cloudfall_engine/artifact.py | 61 ++++++++++-------- engine/src/cloudfall_engine/cli.py | 45 ++++++++++--- engine/src/cloudfall_engine/playbook.py | 18 +----- engine/tests/test_artifact_builder.py | 85 ++++++++++++++++++------- engine/tests/test_playbook.py | 64 +++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 8 +-- 9 files changed, 219 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d034a3..50aeba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,16 @@ 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 +- `cloudfall-engine` runs Ansible and git through treaty's `ctx.run` + (treaty 1.0.0rc10), 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`. + `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 + ansible-playbook 2.21 and git 2.24 - Every JSON document has the same six top-level keys: `ok` (true exactly when the exit code is 0), `status` (the command's verdict, such as `ok`, `plan`, `drift` or `unhealthy`), `data` (the command's payload, which diff --git a/engine/README.md b/engine/README.md index 7b2b05b..29a0e4a 100644 --- a/engine/README.md +++ b/engine/README.md @@ -56,9 +56,12 @@ 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`. Ansible's play -log streams to stderr as it runs; stdout carries one JSON document when the run -ends. A project uses this instead of +`--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`. +`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 repository still call `ansible-playbook` directly because they run from the source tree. diff --git a/engine/src/cloudfall_engine/artifact.py b/engine/src/cloudfall_engine/artifact.py index e042538..71954bf 100644 --- a/engine/src/cloudfall_engine/artifact.py +++ b/engine/src/cloudfall_engine/artifact.py @@ -6,7 +6,6 @@ import json import re import shutil -import subprocess import tarfile import tempfile from dataclasses import dataclass @@ -22,10 +21,18 @@ from jsonschema.exceptions import ValidationError if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from cloudfall.inventory import ComponentInventory, PlatformInventory + from treaty import Completed + + Runner = Callable[[Sequence[str]], Completed] + """Runs one git argv to completion without raising on a non-zero exit""" _ARTIFACT_SCHEMA = "artifact.schema.json" GIT_TIMEOUT_SECONDS = 600 +GIT_MINIMUM_VERSION = "2.24.0" +"""The first git with ``--end-of-options``, which ref resolution passes""" _SHORT_COMMIT_LENGTH = 7 GIT_REF_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/@^~-]{0,199}") _ERROR_COMPONENT_MISSING = "artifact_component_missing" @@ -33,7 +40,6 @@ _ERROR_REF_INVALID = "artifact_ref_invalid" _ERROR_GIT_MISSING = "artifact_git_missing" _ERROR_GIT_FAILED = "artifact_git_failed" -_ERROR_GIT_TIMEOUT = "artifact_git_timeout" class ArtifactBuildError(RuntimeError): @@ -83,28 +89,43 @@ def as_dict(self) -> dict[str, object]: } -def build_artifact( +def build_artifact( # noqa: PLR0913 - the build inputs plus its git runner. inventory: PlatformInventory, component_id: str, git_ref: str, output_directory: Path, schema_directory: Path, + *, + run: Runner, ) -> BuiltArtifact: - """Clone the component repository at a ref and package a release.""" + """Clone the component repository at a ref and package a release. + + ``run`` starts git: the CLI passes ``ctx.run``, so treaty owns its time + limit, locale, and cancellation. + """ component = _component(inventory, component_id) _validate_ref(git_ref) built_at = datetime.now(tz=UTC) built_at_text = built_at.strftime("%Y-%m-%dT%H:%M:%SZ") with tempfile.TemporaryDirectory(prefix="cloudfall-artifact-") as workdir: checkout = Path(workdir) / "source" - _run_git("clone", "--quiet", component.repository.url.value, str(checkout)) + _run_git( + run, "clone", "--quiet", component.repository.url.value, str(checkout) + ) # A fresh clone has local branches only for the default branch, and # bare branch names trigger git's remote-branch DWIM, which conflicts # with --detach. Resolving the ref to a commit first supports # branches, tags, and commits uniformly. - commit = _resolve_ref_commit(checkout, git_ref) + commit = _resolve_ref_commit(run, checkout, git_ref) _run_git( - "-C", str(checkout), "checkout", "--quiet", "--detach", commit, "--" + run, + "-C", + str(checkout), + "checkout", + "--quiet", + "--detach", + commit, + "--", ) source_root = checkout if component.repository.subdirectory is not None: @@ -197,11 +218,12 @@ def _validate_ref(git_ref: str) -> None: raise ArtifactBuildError(_ERROR_REF_INVALID, detail) -def _resolve_ref_commit(checkout: Path, git_ref: str) -> str: +def _resolve_ref_commit(run: Runner, checkout: Path, git_ref: str) -> str: candidates = (git_ref, f"origin/{git_ref}") for candidate in candidates: try: return _run_git( + run, "-C", str(checkout), "rev-parse", @@ -217,30 +239,17 @@ def _resolve_ref_commit(checkout: Path, git_ref: str) -> str: raise ArtifactBuildError(_ERROR_GIT_FAILED, detail) -def _run_git(*arguments: str) -> str: +def _run_git(run: Runner, *arguments: str) -> str: binary = shutil.which("git") if binary is None: detail = "git is not installed on the build host" raise ArtifactBuildError(_ERROR_GIT_MISSING, detail) - try: - completed = subprocess.run( # noqa: S603 - fixed binary, validated args. - [binary, *arguments], - capture_output=True, - check=True, - text=True, - timeout=GIT_TIMEOUT_SECONDS, - ) - except subprocess.CalledProcessError as error: - detail = ( - f"git {' '.join(arguments[:2])} failed: {error.stderr.strip()}" - ) - raise ArtifactBuildError(_ERROR_GIT_FAILED, detail) from error - except subprocess.TimeoutExpired as error: + completed = run((binary, *arguments)) + if completed.returncode != 0: detail = ( - f"git {' '.join(arguments[:2])} exceeded " - f"{GIT_TIMEOUT_SECONDS} seconds" + f"git {' '.join(arguments[:2])} failed: {completed.stderr.strip()}" ) - raise ArtifactBuildError(_ERROR_GIT_TIMEOUT, detail) from error + raise ArtifactBuildError(_ERROR_GIT_FAILED, detail) return completed.stdout diff --git a/engine/src/cloudfall_engine/cli.py b/engine/src/cloudfall_engine/cli.py index de7148f..15f499b 100644 --- a/engine/src/cloudfall_engine/cli.py +++ b/engine/src/cloudfall_engine/cli.py @@ -2,12 +2,14 @@ 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's play log streams to stderr. +each command. Ansible and git run through ``ctx.run``; Ansible's play log +streams to stderr on a terminal or under ``-v``. """ import json import os from dataclasses import dataclass +from functools import partial from importlib.metadata import version from pathlib import Path from typing import TYPE_CHECKING, Self @@ -21,20 +23,22 @@ ) 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 +from treaty import App, Arg, Ctx, Exit, Flag, Out, ParseError, Subprocess, Timeout from cloudfall_engine.ansible_inventory import render_ansible_inventory from cloudfall_engine.artifact import ( + GIT_MINIMUM_VERSION, GIT_REF_PATTERN, GIT_TIMEOUT_SECONDS, ArtifactBuildError, build_artifact, ) from cloudfall_engine.playbook import ( + ANSIBLE_MINIMUM_VERSION, PlaybookError, PlaybookRun, bundled_playbooks, - execute_playbook, + playbook_command, resolve_playbook, ) @@ -83,7 +87,10 @@ description="ansible-playbook exited non-zero; hosts may be partly converged", retryable=False, side_effects="partial", - suggestion="read the Ansible log on stderr, fix the failing task, and run again", + 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" + ), ) @@ -225,6 +232,8 @@ class BuiltRelease: danger_level="mutating", exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "ARTIFACT_BUILD_FAILED"], timeout=3 * GIT_TIMEOUT_SECONDS, + subprocess=Subprocess("git"), + required_tools={"git": GIT_MINIMUM_VERSION}, supports_raw_payload=True, examples=[ ( @@ -233,7 +242,7 @@ class BuiltRelease: ), ], ) -def build(args: BuildArgs, _ctx: Ctx, project: Project) -> BuiltRelease: +def build(args: BuildArgs, ctx: Ctx, project: Project) -> BuiltRelease: """Clone the component at ``--ref`` and package it under the project.""" try: built = build_artifact( @@ -242,6 +251,9 @@ def build(args: BuildArgs, _ctx: Ctx, project: Project) -> BuiltRelease: args.ref, project.path(args.output_dir), args.schemas, + run=partial( + ctx.run, timeout=Timeout(GIT_TIMEOUT_SECONDS), check=False + ), ) except ArtifactBuildError as error: raise Exit.ARTIFACT_BUILD_FAILED( @@ -380,11 +392,14 @@ class Played: "run", description=( "Run one bundled or project playbook against the inventory; " - "the Ansible log streams to stderr" + "the Ansible log streams to stderr at a terminal or under -v" ), danger_level="mutating", exit_codes=["PRECONDITION", "PLAYBOOK_FAILED"], timeout=None, + subprocess=Subprocess("ansible-playbook"), + required_tools={"ansible-playbook": ANSIBLE_MINIMUM_VERSION}, + external=False, supports_raw_payload=True, examples=[ ( @@ -398,20 +413,30 @@ class Played: ), ], ) -def run_playbook(args: RunArgs, _ctx: Ctx) -> Played: +def run_playbook(args: RunArgs, ctx: Ctx) -> Played: """Run ansible-playbook in the foreground and report how it ended.""" run = _playbook_run(args) try: - returncode = execute_playbook(run, args.engine) + command = playbook_command(run, args.engine) except PlaybookError as error: raise Exit.PRECONDITION( error.detail, context={"code": error.code} ) from error - if returncode != 0: + # 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. + completed = ctx.run( + command.argv, env=command.environment, check=False, stream=True + ) + if completed.returncode != 0: + returncode = completed.returncode message = f"the playbook failed: ansible-playbook exited {returncode}" raise Exit.PLAYBOOK_FAILED( message, - context={"returncode": returncode, "playbook": str(run.playbook)}, + context={ + "returncode": returncode, + "playbook": str(run.playbook), + "output": completed.stdout, + }, ) return Played(_effect(args), run.playbook, run.inventory_file) diff --git a/engine/src/cloudfall_engine/playbook.py b/engine/src/cloudfall_engine/playbook.py index 354a477..5bd9347 100644 --- a/engine/src/cloudfall_engine/playbook.py +++ b/engine/src/cloudfall_engine/playbook.py @@ -10,11 +10,11 @@ import os import shutil -import subprocess -import sys from dataclasses import dataclass from pathlib import Path +ANSIBLE_MINIMUM_VERSION = "2.21.0" +"""The ansible-core release the bundled roles are written against""" _PLAYBOOK_SUFFIX = ".yml" _ERROR_PLAYBOOK_MISSING = "playbook_missing" _ERROR_INVENTORY_MISSING = "inventory_missing" @@ -163,17 +163,3 @@ def playbook_command(run: PlaybookRun, engine_directory: Path) -> PlaybookComman ), } return PlaybookCommand(argv=tuple(argv), environment=environment) - - -def execute_playbook(run: PlaybookRun, engine_directory: Path) -> int: - """Run the playbook in the foreground and return its exit code.""" - command = playbook_command(run, engine_directory) - # Stdout belongs to the caller's result document, so Ansible's play log - # streams to stderr, live, for the person watching the run. - completed = subprocess.run( # noqa: S603 - fixed binary, validated args. - command.argv, - env={**os.environ, **command.environment}, - stdout=sys.__stderr__, - check=False, - ) - return completed.returncode diff --git a/engine/tests/test_artifact_builder.py b/engine/tests/test_artifact_builder.py index 23dcfa6..842dce2 100644 --- a/engine/tests/test_artifact_builder.py +++ b/engine/tests/test_artifact_builder.py @@ -8,11 +8,16 @@ import subprocess import tarfile from pathlib import Path +from typing import TYPE_CHECKING, Any, NoReturn, cast import pytest from cloudfall.inventory import PlatformInventory from cloudfall.validation import validate_config from cloudfall_engine.artifact import ArtifactBuildError, build_artifact +from cloudfall_engine.cli import main + +if TYPE_CHECKING: + from collections.abc import Sequence ROOT = Path(__file__).parents[2] SCHEMAS = ROOT / "config" / "schemas" / "v1" @@ -60,55 +65,83 @@ def _fixture_inventory(tmp_path: Path) -> PlatformInventory: return PlatformInventory.from_state(validate_config(project_directory, SCHEMAS)) +def _build( + tmp_path: Path, ref: str, capsys: pytest.CaptureFixture[str] +) -> dict[str, Any]: + """Build through the CLI, so git runs under treaty's ``ctx.run``.""" + exit_code = main( + [ + "artifact", + "build", + "crm-backend", + "--ref", + ref, + "--project", + str(tmp_path / "config"), + "--schemas", + str(SCHEMAS), + "--output-dir", + str(tmp_path / "artifacts"), + ] + ) + envelope = cast("dict[str, Any]", json.loads(capsys.readouterr().out)) + assert exit_code == 0, envelope["error"] + return cast("dict[str, Any]", envelope["data"]) + + +def _git_never_runs(argv: Sequence[str]) -> NoReturn: + message = f"git must not run for refused input: {argv}" + raise AssertionError(message) + + def test_builder_packages_a_hashed_release_with_metadata( - tmp_path: Path, + tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - inventory = _fixture_inventory(tmp_path) - output_directory = tmp_path / "artifacts" + _fixture_inventory(tmp_path) - built = build_artifact( - inventory, "crm-backend", "main", output_directory, SCHEMAS - ) + built = _build(tmp_path, "main", capsys) - assert built.archive_path.is_file() - assert built.metadata_path.is_file() - digest = hashlib.sha256(built.archive_path.read_bytes()).hexdigest() - assert digest == built.archive_sha256 + archive_path = Path(built["archive"]) + metadata_path = Path(built["metadata"]) + assert archive_path.is_file() + assert metadata_path.is_file() + digest = hashlib.sha256(archive_path.read_bytes()).hexdigest() + assert digest == built["archive_sha256"] - metadata = json.loads(built.metadata_path.read_text(encoding="utf-8")) + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) assert metadata["kind"] == "Artifact" assert metadata["spec"]["component"] == "crm-backend" - assert metadata["spec"]["release"] == built.release - assert metadata["spec"]["gitCommit"] == built.git_commit + assert metadata["spec"]["release"] == built["release"] + assert metadata["spec"]["gitCommit"] == built["git_commit"] assert metadata["spec"]["archiveSha256"] == digest - assert metadata["spec"]["sizeBytes"] == built.archive_path.stat().st_size + assert metadata["spec"]["sizeBytes"] == archive_path.stat().st_size - with tarfile.open(built.archive_path) as archive: + with tarfile.open(archive_path) as archive: names = archive.getnames() assert "./app.py" in names assert "./pyproject.toml" in names assert all(".git" not in Path(name).parts for name in names) -def test_builder_packages_a_non_default_branch(tmp_path: Path) -> None: +def test_builder_packages_a_non_default_branch( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: """A fresh clone must build refs beyond the default branch. The 2026-09-08 M2/M3 proving run failed here: bare branch names trigger git's remote-branch DWIM checkout, which conflicts with ``--detach``. """ - inventory = _fixture_inventory(tmp_path) + _fixture_inventory(tmp_path) repository = tmp_path / "repository" _git("-C", str(repository), "checkout", "--quiet", "-b", "feature") (repository / "app.py").write_text("print('feature')\n", encoding="utf-8") _git("-C", str(repository), "commit", "--quiet", "-am", "feature change") _git("-C", str(repository), "checkout", "--quiet", "main") - built = build_artifact( - inventory, "crm-backend", "feature", tmp_path / "artifacts", SCHEMAS - ) + built = _build(tmp_path, "feature", capsys) - assert built.git_ref == "feature" - assert built.archive_path.is_file() + assert built["git_ref"] == "feature" + assert Path(built["archive"]).is_file() def test_builder_rejects_an_unknown_component(tmp_path: Path) -> None: @@ -116,7 +149,12 @@ def test_builder_rejects_an_unknown_component(tmp_path: Path) -> None: with pytest.raises(ArtifactBuildError) as error: build_artifact( - inventory, "billing-backend", "main", tmp_path / "artifacts", SCHEMAS + inventory, + "billing-backend", + "main", + tmp_path / "artifacts", + SCHEMAS, + run=_git_never_runs, ) assert error.value.code == "artifact_component_missing" @@ -132,6 +170,7 @@ def test_builder_rejects_an_unsafe_git_ref(tmp_path: Path) -> None: "--upload-pack=/bin/false", tmp_path / "artifacts", SCHEMAS, + run=_git_never_runs, ) assert error.value.code == "artifact_ref_invalid" diff --git a/engine/tests/test_playbook.py b/engine/tests/test_playbook.py index 6e52e3c..42e166b 100644 --- a/engine/tests/test_playbook.py +++ b/engine/tests/test_playbook.py @@ -193,6 +193,70 @@ def test_check_mode_is_the_dry_run_flag( assert json.loads(capsys.readouterr().out)["error"]["code"] == "ARG_ERROR" +def _local_playbook(tmp_path: Path, task: str) -> Path: + playbook = tmp_path / "local.yml" + playbook.write_text( + "- hosts: localhost\n" + " connection: local\n" + " gather_facts: false\n" + " tasks:\n" + f" - {task}\n", + encoding="utf-8", + ) + return playbook + + +def test_a_failed_play_carries_the_end_of_the_ansible_log( + tmp_path: Path, inventory_file: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Off a terminal the log is not streamed, so the failure must carry it.""" + playbook = _local_playbook( + tmp_path, "ansible.builtin.fail: {msg: cloudfall-probe-failure}" + ) + + exit_code = main( + [ + "playbook", + "run", + str(playbook), + "--engine", + str(ENGINE), + "--inventory", + str(inventory_file), + ] + ) + + error = json.loads(capsys.readouterr().out)["error"] + assert exit_code == 82 + assert error["code"] == "PLAYBOOK_FAILED" + assert error["context"]["returncode"] == 2 + assert "cloudfall-probe-failure" in error["context"]["output"] + + +def test_a_dry_run_says_so_in_meta( + tmp_path: Path, inventory_file: Path, capsys: pytest.CaptureFixture[str] +) -> None: + playbook = _local_playbook(tmp_path, "ansible.builtin.debug: {msg: probe}") + + exit_code = main( + [ + "playbook", + "run", + str(playbook), + "--engine", + str(ENGINE), + "--inventory", + str(inventory_file), + "--dry-run", + ] + ) + + envelope = json.loads(capsys.readouterr().out) + assert exit_code == 0, envelope["error"] + assert envelope["data"]["effect"] == "would_update" + assert envelope["meta"]["dry_run"] is True + + def test_every_engine_failure_code_is_declared() -> None: commands = cast("dict[str, dict[str, Any]]", app.manifest()["commands"]) declared = { diff --git a/pyproject.toml b/pyproject.toml index a23e18d..82e4ef4 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.0rc4", + "treaty==1.0.0rc10", ] [project.urls] diff --git a/uv.lock b/uv.lock index 17b5f5b..568e363 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.0rc4" }, + { name = "treaty", specifier = "==1.0.0rc10" }, ] provides-extras = ["mcp"] @@ -1085,11 +1085,11 @@ wheels = [ [[package]] name = "treaty" -version = "1.0.0rc4" +version = "1.0.0rc10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/47/98c6ffb6ae5f1389f26d1e56eb1a898e3153c022d3207c650ed17bd4acd1/treaty-1.0.0rc4.tar.gz", hash = "sha256:ebcfe521fb17308fc5a867791a04089d8139988d8c9602d0eb3fb66435fc5207", size = 379721, upload-time = "2026-09-30T05:46:15.223Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/26/38fc89b23e871b78e687bcfe4e0af0e90dda72ee4b25d2dd204247f23070/treaty-1.0.0rc4-py3-none-any.whl", hash = "sha256:9699363e4e7fb8f4488df557d16f94ea04ad1143a183a5d7f3e8ef31d777b4c5", size = 388000, upload-time = "2026-09-30T05:46:13.536Z" }, + { 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" }, ] [[package]] From cc5a8f5aa812bb0eaa18758bbad457d3e6fbce44 Mon Sep 17 00:00:00 2001 From: Romamo Date: Thu, 1 Oct 2026 17:21:24 +0300 Subject: [PATCH 2/4] fix: migrate --build reads the release from the engine's treaty envelope Since the engine moved to treaty (PR #4) its answer is an envelope with the build under data, but build_release_artifact returned the envelope itself, so migrate found no release id and every build step failed. The new test runs the real engine command against a local repository. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++ sdk/src/cloudfall/lifecycle.py | 11 ++++--- sdk/tests/test_lifecycle.py | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50aeba8..af17536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,11 @@ Notable changes to Cloudfall. The format follows ### Fixed +- `migrate --build` and the `cloudfall-mcp` `build_artifact` tool read the + release from the engine's treaty envelope, under `data`. Since the engine + moved to treaty every build step failed with "artifact builder returned no + release id". `build_artifact` now returns the engine's `data` keys + (`git_ref`, `archive_sha256`, ...) beside `status` - The time baseline accepts a host whose clock another daemon keeps (ntp, ntpsec, chrony). `timedatectl set-ntp` drives systemd-timesyncd only, so on such hosts check mode reported "Would enable network time diff --git a/sdk/src/cloudfall/lifecycle.py b/sdk/src/cloudfall/lifecycle.py index 1e0f76a..f9c773f 100644 --- a/sdk/src/cloudfall/lifecycle.py +++ b/sdk/src/cloudfall/lifecycle.py @@ -395,13 +395,16 @@ def build_release_artifact( environment={}, ) output = _execute_step_with_output(step) + # The engine answers one treaty envelope: the build is under ``data``. parsed = cast("object", json.loads(output)) - if not isinstance(parsed, dict) or not all( - isinstance(key, str) for key in parsed - ): + if not isinstance(parsed, dict) or parsed.get("ok") is not True: + detail = "artifact builder returned no successful envelope" + raise LifecycleError(_ERROR_EXECUTION_FAILED, detail) + data = cast("object", parsed.get("data")) + if not isinstance(data, dict) or not all(isinstance(key, str) for key in data): detail = "artifact builder returned a non-object payload" raise LifecycleError(_ERROR_EXECUTION_FAILED, detail) - return cast("dict[str, object]", parsed) + return {"status": "ok", **cast("dict[str, object]", data)} def _execute_steps(steps: tuple[ExecutionStep, ...]) -> None: diff --git a/sdk/tests/test_lifecycle.py b/sdk/tests/test_lifecycle.py index 2382763..6e49f51 100644 --- a/sdk/tests/test_lifecycle.py +++ b/sdk/tests/test_lifecycle.py @@ -4,6 +4,8 @@ import hashlib import json +import shutil +import subprocess import tarfile from pathlib import Path from typing import Any @@ -15,6 +17,7 @@ DeployOptions, EngineContext, LifecycleError, + build_release_artifact, deploy, plan_deploy, plan_health, @@ -322,3 +325,55 @@ def test_cli_data_migration_plan_checks_the_source_url_file( assert payload["status"] == "plan" assert payload["data"]["service"] == "postgresql-main" assert "u:p@" not in json.dumps(payload) + + +def _git(*arguments: str) -> None: + subprocess.run( # noqa: S603 - fixture repository setup. + [ + shutil.which("git") or "git", + "-c", + "user.email=test@example.test", + "-c", + "user.name=Cloudfall Test", + *arguments, + ], + capture_output=True, + check=True, + ) + + +def test_release_artifact_build_reads_the_engine_envelope(tmp_path: Path) -> None: + """The real engine answers a treaty envelope; the release id is under data. + + PR #4 moved the engine to treaty and ``migrate --build`` read the release + from the top level, so every build step failed with no release id. + """ + repository = tmp_path / "repository" + repository.mkdir() + _git("-C", str(repository), "-c", "init.defaultBranch=main", "init", "--quiet") + (repository / "app.py").write_text("print('crm')\n", encoding="utf-8") + _git("-C", str(repository), "add", ".") + _git("-C", str(repository), "commit", "--quiet", "--message", "initial") + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + component = project / "components" / "crm-backend.yaml" + component.write_text( + component.read_text(encoding="utf-8").replace( + "https://github.com/example/crm-backend.git", f"file://{repository}" + ), + encoding="utf-8", + ) + context = EngineContext( + project_directory=project, + schema_directory=SCHEMAS, + engine_directory=ENGINE, + inventory_file=tmp_path / "inventory.json", + ) + + built = build_release_artifact( + context, ResourceId("crm-backend"), "main", tmp_path / "artifacts" + ) + + assert built["status"] == "ok" + assert isinstance(built["release"], str) + assert Path(str(built["archive"])).is_file() From 85e2eb476b25310f62c52a7fcc298160311e3c2f Mon Sep 17 00:00:00 2001 From: Romamo Date: Thu, 1 Oct 2026 17:35:13 +0300 Subject: [PATCH 3/4] feat!: nine read-only cloudfall commands run on treaty behind a shim The first step of moving the cloudfall CLI off argparse. cloudfall.app holds a treaty App with config validate, inventory show, operations list/show/decisions, operator list/show, audit and services status; cli.main sends a command line there when app.resolves claims it and to argparse otherwise, so root --help still lists every command. data keeps the keys argparse wrote, status included: each answer is a Payload whose schema comes from the command's output shape in the operating contract, which now marks the commands treaty runs and describes their envelope. audit's drift and unknown verdicts exit 83 and 84 with the report in data. Paths resolve against the project without os.chdir, and --inventory with --project is a declared Excludes rule. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 + sdk/src/cloudfall/app.py | 740 +++++++++++++++++++++++++++++++ sdk/src/cloudfall/catalog.py | 4 +- sdk/src/cloudfall/cli.py | 129 +----- sdk/src/cloudfall/commands.py | 48 +- sdk/src/cloudfall/operator.py | 7 +- sdk/src/cloudfall/project.py | 8 + sdk/tests/test_ansible_reader.py | 12 +- sdk/tests/test_app.py | 62 +++ sdk/tests/test_arguments.py | 29 +- sdk/tests/test_audit.py | 13 +- sdk/tests/test_catalog.py | 7 +- sdk/tests/test_inventory.py | 3 +- sdk/tests/test_manifest.py | 21 +- sdk/tests/test_operator.py | 39 +- sdk/tests/test_output.py | 20 +- sdk/tests/test_project.py | 7 +- sdk/tests/test_validation.py | 33 +- 18 files changed, 998 insertions(+), 202 deletions(-) create mode 100644 sdk/src/cloudfall/app.py create mode 100644 sdk/tests/test_app.py diff --git a/CHANGELOG.md b/CHANGELOG.md index af17536..88f9a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,24 @@ 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:** nine read-only `cloudfall` commands run on treaty, the + first step of moving the CLI off argparse: `config validate`, + `inventory show`, `operations list`, `operations show`, + `operations decisions`, `operator list`, `operator show`, `audit` and + `services status`. `cloudfall manifest` lists them; every other command, + root `--help` and `--version` stay on argparse for now. Their answer is a + treaty envelope (`ok`, `data`, `error`, `meta`, `warnings`): the verdict + moves from the top-level `status` to `data.status`, and the other `data` + keys are unchanged. `inventory show` writes `data.ansible` as `{}` for a + project. Failures are an envelope on stdout with their own exit codes: + 79 `PROJECT_INVALID`, 80 `CONFIG_INVALID` (resources or evidence files), + 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`. 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 group, and secrets are redacted from the play log. The play log streams diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py new file mode 100644 index 0000000..980acb2 --- /dev/null +++ b/sdk/src/cloudfall/app.py @@ -0,0 +1,740 @@ +"""treaty boundary for the ``cloudfall`` commands moved off argparse so far. + +The CLI moves to treaty a command at a time. ``cloudfall.cli.main`` sends a +command line here when ``app.resolves`` says one of these commands owns it +and to argparse otherwise, so both run side by side until the last command +moves. + +Every answer keeps the keys the argparse CLI wrote under ``data``, +``status`` included: a ``Payload`` subclass names its command in the +operating contract (``cloudfall.commands``), and that contract's output +shape is the schema treaty checks the answer against. A negative verdict, +such as drift, exits with its own code and keeps the report in ``data``. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from importlib.metadata import version +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar, Self + +from treaty import App, Arg, Ctx, Excludes, Exit, Flag, ParseError + +from cloudfall.ansible_api import ( + AnsibleReadError, + InventorySource, + inventory_from_config, + read_inventory, +) +from cloudfall.ansible_reader import FleetRead, read_fleet +from cloudfall.audit import AuditStatus, audit_inventory +from cloudfall.catalog import ( + CATALOG_DIRECTORY, + ERROR_OPERATION_UNDECLARED, + OperationCatalog, + load_catalog, +) +from cloudfall.commands import CLI_COMMANDS +from cloudfall.decision import DECISION_DIRECTORY, DecisionError, DecisionStore +from cloudfall.domain import ResourceId +from cloudfall.inventory import PlatformInventory +from cloudfall.observation import load_observations +from cloudfall.operations import UtcTimestamp, build_operations_view +from cloudfall.operator import ERROR_PROPOSAL_MISSING, OperatorError, ProposalStore +from cloudfall.project import ( + PROJECT_DIRECTORY_VARIABLE, + ProjectError, + is_project, + project_path, + resolve_project_directory, +) +from cloudfall.resources import default_schema_directory +from cloudfall.secrets import load_environment_receipts +from cloudfall.service_evidence import ( + DeploymentReceiptSet, + DomainObservationSet, + load_deployment_receipts, + load_domain_observations, +) +from cloudfall.validation import ( + ConfigValidationError, + SchemaCatalog, + ValidatedConfig, + validate_config, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +app = App( + "cloudfall", + version=version("cloudfall"), + description=( + "Run a fleet from validated config, and keep the record of what an " + "agent did to it" + ), +) +app.scalar( + ResourceId, + parse=ResourceId.from_boundary, + pattern=r"[a-z][a-z0-9]*(-[a-z0-9]+)*", +) +# 79 and 80 mean what they mean on cloudfall-engine; 81 and 82 stay the +# engine's (artifact build, playbook run) for the commands that wrap it. +app.exit_code( + "PROJECT_INVALID", + 79, + description="No project directory could be resolved", + retryable=False, + side_effects="none", + suggestion=( + f"pass --project, set {PROJECT_DIRECTORY_VARIABLE}, " + "or run inside a project" + ), +) +app.exit_code( + "CONFIG_INVALID", + 80, + description="The project's resources or evidence files failed validation", + retryable=False, + side_effects="none", + suggestion="fix the file error.context names, then run the command again", +) +app.exit_code( + "DRIFT", + 83, + description="The fleet differs from its declared config; data holds the report", + retryable=False, + side_effects="none", + suggestion="read data.servers for the checks that drifted", +) +app.exit_code( + "UNKNOWN", + 84, + description=( + "The evidence is missing or stale, so the result is not known; " + "data holds the report" + ), + retryable=True, + side_effects="none", + suggestion="collect fresh snapshots with `cloudfall observe`, then run again", +) +app.exit_code( + "INVENTORY_UNREADABLE", + 85, + description="The Ansible inventory could not be read as a fleet", + retryable=False, + side_effects="none", +) +app.exit_code( + "RECORD_INVALID", + 86, + description="A decision record or proposal receipt could not be read", + retryable=False, + side_effects="none", +) + + +class Payload: + """A command's flat payload, written as ``data`` under its declared keys. + + ``command`` names the command in the operating contract; its output + shape is this payload's schema. A key the shape marks optional is + written as an empty object when the command has nothing for it, since + treaty writes every key of a schema on every answer. + """ + + command: ClassVar[str] + + def __init__(self, body: Mapping[str, object]) -> None: + """Keep the payload as the domain serialized it.""" + self.body = dict(body) + + +def _shape_keys(command: str) -> tuple[tuple[str, bool], ...]: + """Return each data key of the command's output shape and if it is optional.""" + contract = next( + entry + for entry in CLI_COMMANDS + if entry.program == "cloudfall" and entry.name == command + ) + (shape,) = contract.output + return tuple((key.name, key.optional) for key in shape.keys if key.name != "error") + + +def _payload_schema(cls: type[Payload]) -> dict[str, object]: + names = [name for name, _ in _shape_keys(cls.command)] + return { + "type": "object", + # The domain writes each array in the order it means: a ranking, a + # timeline, the order checks ran in. treaty would sort them. + "properties": {name: {"x-ordered": True} for name in names}, + "required": names, + "additionalProperties": False, + } + + +def _payload_document(payload: Payload) -> dict[str, object]: + document = dict(payload.body) + for name, optional in _shape_keys(payload.command): + if optional and name not in document: + document[name] = {} + return document + + +app.output_adapter(Payload, schema=_payload_schema, dump=_payload_document) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class SchemaArgs: + """The option every command that validates documents shares.""" + + schemas: Path = Flag( + default=default_schema_directory(), + description="Versioned schema directory (default: bundled schemas)", + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ProjectArgs(SchemaArgs): + """Options every command that reads a project shares.""" + + project: Path | None = Flag( + default=None, + description=( + f"Project directory to run in (default: ${PROJECT_DIRECTORY_VARIABLE}, " + "else the current directory when it is a project)" + ), + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class FleetArgs(ProjectArgs): + """Options of a command that also reads a team's own Ansible inventory.""" + + inventory: Path | None = Flag( + default=None, + description=( + "Ansible inventory to read the fleet from instead of a project " + "(default: the inventory an ansible.cfg in the current directory names)" + ), + ) + + +ONE_FLEET = Excludes("inventory", prohibited=("project",)) +"""``--inventory`` and ``--project`` name two fleets: a command reads one.""" + + +def _inside_project(value: Path, flag: str) -> None: + """Refuse a relative path that climbs out of the project directory.""" + try: + project_path(str(value)) + except ValueError as error: + raise ParseError(str(error), context={"flag": flag}) from error + + +@dataclass(frozen=True, slots=True) +class Fleet: + """The fleet a command reads: a validated project, or a team's inventory. + + Relative paths resolve against ``directory``: the project, or for an + inventory the directory the command was started in, beside its + playbooks. Nothing changes the working directory. + """ + + directory: Path + state: ValidatedConfig + read: FleetRead | None = None + + @classmethod + def acquire(cls, args: ProjectArgs, _ctx: Ctx) -> Self: + """Resolve and validate the fleet before the handler runs.""" + try: + source = _inventory_source(args) + if source is not None: + read = read_fleet(read_inventory(source), args.schemas) + return cls(Path.cwd(), read.config, read) + directory = resolve_project_directory( + args.project, os.environ, Path.cwd() + ) + return cls(directory, validate_config(directory, args.schemas)) + except ProjectError as error: + raise Exit.PROJECT_INVALID( + error.detail, context={"code": error.code} + ) from error + except AnsibleReadError as error: + raise Exit.INVENTORY_UNREADABLE( + error.detail, context={"code": error.code} + ) from error + except ConfigValidationError as error: + raise _config_invalid(error) from error + + def path(self, value: Path) -> Path: + """Resolve a relative runtime path against the fleet's directory.""" + return value if value.is_absolute() else self.directory / value + + @property + def inventory(self) -> PlatformInventory: + """The typed, secret-free inventory the config declares.""" + return PlatformInventory.from_state(self.state) + + +def _inventory_source(args: ProjectArgs) -> InventorySource | None: + """Return the Ansible inventory this run reads, if any. + + Precedence: ``--inventory``, then an ``ansible.cfg`` in the current + directory when no project was asked for and the directory is no project. + Only a command that declares ``--inventory`` reads one. + """ + if not isinstance(args, FleetArgs): + return None + if args.inventory is not None: + return InventorySource.from_boundary(args.inventory) + if args.project is not None or os.environ.get(PROJECT_DIRECTORY_VARIABLE): + return None + if is_project(Path.cwd()): + return None + return inventory_from_config(Path.cwd()) + + +def _config_invalid(error: ConfigValidationError) -> Exception: + return Exit.CONFIG_INVALID(error.issue.message, context=error.issue.as_dict()) + + +def _record_invalid(error: DecisionError | OperatorError) -> Exception: + message = error.detail if isinstance(error, DecisionError) else error.message + if isinstance(error, OperatorError) and error.code == ERROR_PROPOSAL_MISSING: + return Exit.NOT_FOUND(message, context={"code": error.code}) + return Exit.RECORD_INVALID(message, context={"code": error.code}) + + +# config validate, inventory show + + +config = app.group("config", description="Operate on the config") +inventory = app.group("inventory", description="Query the validated platform inventory") + + +class ValidatedPayload(Payload): + """``config validate``: the resources the config declares, by kind.""" + + command = "config validate" + + +@config.command( + "validate", + description="Validate every resource against the schemas and cross-references", + danger_level="safe", + exit_codes=["PROJECT_INVALID", "CONFIG_INVALID"], + examples=[("Validate the project in this directory", "cloudfall config validate")], +) +def config_validate(_args: ProjectArgs, _ctx: Ctx, fleet: Fleet) -> ValidatedPayload: + """Answer the validation summary of the resolved project.""" + return ValidatedPayload(fleet.state.as_dict()) + + +class InventoryPayload(Payload): + """``inventory show``: the inventory, and how it was read from Ansible.""" + + command = "inventory show" + + +@inventory.command( + "show", + description=( + "Show the typed, secret-free platform inventory; data.ansible says " + "how it was read from an Ansible inventory, empty for a project" + ), + danger_level="safe", + exit_codes=[ + "PROJECT_INVALID", + "CONFIG_INVALID", + "INVENTORY_UNREADABLE", + ], + requires=[ONE_FLEET], + examples=[ + ("Show the project's inventory", "cloudfall inventory show"), + ( + "Read the fleet from a team's Ansible inventory", + "cloudfall inventory show --inventory hosts.yml", + ), + ], +) +def inventory_show(_args: FleetArgs, _ctx: Ctx, fleet: Fleet) -> InventoryPayload: + """Answer the inventory the fleet declares.""" + body: dict[str, object] = { + "status": "ok", + "inventory": fleet.inventory.as_dict(), + } + if fleet.read is not None: + body["ansible"] = fleet.read.as_dict() + return InventoryPayload(body) + + +# operations list, show, decisions + + +operations = app.group( + "operations", description="Read the catalog of operations an agent may run" +) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class RepositoryArgs(SchemaArgs): + """Options of a command that reads the repository and nothing else.""" + + repository: Path | None = Flag( + default=None, + description=( + "Repository holding the operations directory and the playbooks it " + "declares (default: the current directory)" + ), + ) + + @property + def root(self) -> Path: + """The repository, the current directory by default.""" + return self.repository if self.repository is not None else Path.cwd() + + +@dataclass(frozen=True, slots=True, kw_only=True) +class CatalogArgs(RepositoryArgs): + """Options of a command that reads the operation catalog.""" + + operations: Path = Flag( + default=Path(CATALOG_DIRECTORY), + description=( + "Directory holding the operation documents " + f"(default: {CATALOG_DIRECTORY})" + ), + ) + + def __post_init__(self) -> None: + """Keep a relative catalog directory inside the repository.""" + _inside_project(self.operations, "operations") + + def catalog(self) -> OperationCatalog: + """Load and validate the catalog.""" + try: + return load_catalog(self.root, self.schemas, self.root / self.operations) + except ConfigValidationError as error: + raise _config_invalid(error) from error + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ShowOperationArgs(CatalogArgs): + """Arguments of ``operations show``.""" + + operation: ResourceId = Arg(description="Operation id") + + +@dataclass(frozen=True, slots=True, kw_only=True) +class DecisionsArgs(RepositoryArgs): + """Arguments of ``operations decisions``.""" + + decisions: Path = Flag( + default=Path(DECISION_DIRECTORY), + description=( + f"Directory holding the decision records (default: {DECISION_DIRECTORY})" + ), + ) + + def __post_init__(self) -> None: + """Keep a relative record directory inside the repository.""" + _inside_project(self.decisions, "decisions") + + +class CatalogPayload(Payload): + """``operations list``: every declared operation and the risk counts.""" + + command = "operations list" + + +class OperationPayload(Payload): + """``operations show``: one declared operation in full.""" + + command = "operations show" + + +class DecisionsPayload(Payload): + """``operations decisions``: every decision record, oldest first.""" + + command = "operations decisions" + + +@operations.command( + "list", + description="List every declared operation with its risk level", + danger_level="safe", + exit_codes=["CONFIG_INVALID"], + examples=[("List the operations an agent may run", "cloudfall operations list")], +) +def operations_list(args: CatalogArgs, _ctx: Ctx) -> CatalogPayload: + """Answer the catalog, which needs no fleet and no project.""" + return CatalogPayload(args.catalog().as_dict()) + + +@operations.command( + "show", + description="Show one declared operation in full", + danger_level="safe", + exit_codes=["CONFIG_INVALID", "NOT_FOUND"], + examples=[("Show one operation", "cloudfall operations show restart-nginx")], +) +def operations_show(args: ShowOperationArgs, _ctx: Ctx) -> OperationPayload: + """Answer one operation, or say it is not declared.""" + try: + operation = args.catalog().get(args.operation) + except ConfigValidationError as error: + if error.issue.code == ERROR_OPERATION_UNDECLARED: + raise Exit.NOT_FOUND( + error.issue.message, context=error.issue.as_dict() + ) from error + raise _config_invalid(error) from error + return OperationPayload({"status": "ok", "operation": operation.as_dict()}) + + +@operations.command( + "decisions", + description="List the decision records this repository holds", + danger_level="safe", + exit_codes=["RECORD_INVALID"], + examples=[("List the decision records", "cloudfall operations decisions")], +) +def operations_decisions(args: DecisionsArgs, _ctx: Ctx) -> DecisionsPayload: + """Answer every decision record: the record outlives the catalog.""" + store = DecisionStore( + directory=args.root / args.decisions, catalog=SchemaCatalog(args.schemas) + ) + try: + decisions = [decision.as_document() for decision in store.list()] + except DecisionError as error: + raise _record_invalid(error) from error + return DecisionsPayload( + {"status": "ok", "directory": str(store.directory), "decisions": decisions} + ) + + +# operator list, show + + +operator = app.group( + "operator", description="Read and act on the operator's proposal receipts" +) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ProposalArgs(ProjectArgs): + """Options of a command that reads the operator's proposal receipts.""" + + proposals: Path = Flag( + default=Path("tmp/operator/proposals"), + description="Proposal receipt directory (default: tmp/operator/proposals)", + ) + + def __post_init__(self) -> None: + """Keep a relative receipt directory inside the project.""" + _inside_project(self.proposals, "proposals") + + def store(self, fleet: Fleet) -> ProposalStore: + """Open the receipt store under the fleet's directory.""" + return ProposalStore( + directory=fleet.path(self.proposals), + catalog=SchemaCatalog(self.schemas), + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ShowProposalArgs(ProposalArgs): + """Arguments of ``operator show``.""" + + proposal: ResourceId = Arg(description="Proposal id") + + +class ProposalsPayload(Payload): + """``operator list``: every proposal receipt, oldest first.""" + + command = "operator list" + + +class ProposalPayload(Payload): + """``operator show``: one proposal receipt.""" + + command = "operator show" + + +@operator.command( + "list", + description="List proposal receipts, oldest first", + danger_level="safe", + exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "RECORD_INVALID"], + examples=[("List the operator's proposals", "cloudfall operator list")], +) +def operator_list(args: ProposalArgs, _ctx: Ctx, fleet: Fleet) -> ProposalsPayload: + """Answer every proposal receipt the operator wrote.""" + try: + proposals = [proposal.as_document() for proposal in args.store(fleet).list()] + except OperatorError as error: + raise _record_invalid(error) from error + return ProposalsPayload({"status": "ok", "proposals": proposals}) + + +@operator.command( + "show", + description="Show one proposal receipt", + danger_level="safe", + exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "RECORD_INVALID", "NOT_FOUND"], + examples=[("Show one proposal", "cloudfall operator show nginx-down-20260101")], +) +def operator_show(args: ShowProposalArgs, _ctx: Ctx, fleet: Fleet) -> ProposalPayload: + """Answer one proposal receipt, or say there is none.""" + try: + proposal = args.store(fleet).load(args.proposal) + except OperatorError as error: + raise _record_invalid(error) from error + return ProposalPayload({"status": "ok", "proposal": proposal.as_document()}) + + +# audit, services status + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AuditArgs(FleetArgs): + """Arguments of ``audit``.""" + + observed: Path = Flag( + description="Directory containing observed-server JSON snapshots" + ) + env_receipts: Path = Flag( + default=Path("tmp/env-receipts"), + description=( + "Rendered environment receipt directory used for env-file drift " + "checks (default: tmp/env-receipts)" + ), + ) + + def __post_init__(self) -> None: + """Keep relative evidence paths inside the project.""" + _inside_project(self.observed, "observed") + _inside_project(self.env_receipts, "env-receipts") + + +class AuditPayload(Payload): + """``audit``: the desired-versus-observed report.""" + + command = "audit" + + +@app.command( + "audit", + description=( + "Compare the declared config with observed server snapshots; drift and " + "unknown evidence exit non-zero with the report in data" + ), + danger_level="safe", + exit_codes=[ + "PROJECT_INVALID", + "CONFIG_INVALID", + "INVENTORY_UNREADABLE", + "DRIFT", + "UNKNOWN", + ], + requires=[ONE_FLEET], + examples=[ + ( + "Audit the fleet against fresh snapshots", + "cloudfall audit --observed tmp/observed", + ), + ], +) +def audit(args: AuditArgs, _ctx: Ctx, fleet: Fleet) -> AuditPayload: + """Answer the audit report; a negative verdict is its own exit code.""" + try: + observations = load_observations(fleet.path(args.observed), args.schemas) + receipts = load_environment_receipts( + fleet.path(args.env_receipts), args.schemas + ) + except ConfigValidationError as error: + raise _config_invalid(error) from error + report = audit_inventory(fleet.inventory, observations, receipts) + payload = AuditPayload(report.as_dict()) + if report.status is AuditStatus.DRIFT: + message = "the fleet differs from its declared config" + raise Exit.DRIFT(message, data=payload) + if report.status is AuditStatus.UNKNOWN: + message = "the evidence is missing or stale, so drift is not known" + raise Exit.UNKNOWN(message, data=payload) + return payload + + +services = app.group( + "services", description="Inspect and report public service lifecycles" +) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ServicesStatusArgs(ProjectArgs): + """Arguments of ``services status``.""" + + observed: Path = Flag( + description="Directory containing observed-server JSON snapshots" + ) + service_observed: Path = Flag( + default=Path("tmp/observed-services"), + description="Domain observation directory (default: tmp/observed-services)", + ) + deployments: Path = Flag( + default=Path("tmp/deployments"), + description="Deployment receipt directory (default: tmp/deployments)", + ) + + def __post_init__(self) -> None: + """Keep relative evidence paths inside the project.""" + _inside_project(self.observed, "observed") + _inside_project(self.service_observed, "service-observed") + _inside_project(self.deployments, "deployments") + + +class ServicesPayload(Payload): + """``services status``: each public service's lifecycle.""" + + command = "services status" + + +@services.command( + "status", + description="Derive service lifecycle status from current evidence", + danger_level="safe", + exit_codes=["PROJECT_INVALID", "CONFIG_INVALID"], + examples=[ + ( + "Report each service from fresh snapshots", + "cloudfall services status --observed tmp/observed", + ), + ], +) +def services_status( + args: ServicesStatusArgs, _ctx: Ctx, fleet: Fleet +) -> ServicesPayload: + """Answer each declared service's lifecycle from the evidence on disk.""" + deployments = fleet.path(args.deployments) + domains = fleet.path(args.service_observed) + try: + view = build_operations_view( + fleet.inventory, + load_observations(fleet.path(args.observed), args.schemas), + load_deployment_receipts(deployments, args.schemas) + if deployments.is_dir() + else DeploymentReceiptSet.empty(), + load_domain_observations(domains, args.schemas) + if domains.is_dir() + else DomainObservationSet.empty(), + generated_at=UtcTimestamp.now(), + ) + except ConfigValidationError as error: + raise _config_invalid(error) from error + return ServicesPayload( + {"status": "ok", "services": [domain.as_dict() for domain in view.domains]} + ) diff --git a/sdk/src/cloudfall/catalog.py b/sdk/src/cloudfall/catalog.py index 09b49be..dc2c369 100644 --- a/sdk/src/cloudfall/catalog.py +++ b/sdk/src/cloudfall/catalog.py @@ -30,6 +30,8 @@ if TYPE_CHECKING: from collections.abc import Iterable, Mapping +ERROR_OPERATION_UNDECLARED = "operation_undeclared" +"""Code of the issue ``OperationCatalog.get`` raises for an id it does not declare.""" CATALOG_DIRECTORY = "operations" """Directory holding one document per declared operation.""" @@ -185,7 +187,7 @@ def get(self, operation_id: ResourceId) -> Operation: operation.operation_id.value for operation in self.operations ) issue = ValidationIssue( - code="operation_undeclared", + code=ERROR_OPERATION_UNDECLARED, message=( f"no operation {operation_id} is declared in {self.directory}; " f"declared operations: {declared or 'none'}" diff --git a/sdk/src/cloudfall/cli.py b/sdk/src/cloudfall/cli.py index ef82a95..9c8aa1e 100644 --- a/sdk/src/cloudfall/cli.py +++ b/sdk/src/cloudfall/cli.py @@ -25,7 +25,8 @@ inventory_from_config, read_inventory, ) -from cloudfall.ansible_reader import FleetRead, read_fleet +from cloudfall.ansible_reader import read_fleet +from cloudfall.app import app as treaty_app from cloudfall.arguments import ( StrictArgumentParser, add_output_options, @@ -36,7 +37,6 @@ root_parser, schema_version_argument, ) -from cloudfall.audit import AuditStatus, audit_inventory from cloudfall.authoring import ( AuthoringError, ServerOptions, @@ -152,7 +152,6 @@ from cloudfall.secrets import ( SecretsError, SopsSecretProvider, - load_environment_receipts, render_environment, ) from cloudfall.service_evidence import ( @@ -1267,10 +1266,18 @@ def _add_lifecycle_arguments(parser: argparse.ArgumentParser) -> None: def main(argv: Sequence[str] | None = None) -> int: - """Run the CLI and return a process exit code.""" + """Run the CLI and return a process exit code. + + A command already moved to treaty runs there; argparse answers every + other command line, root ``--help`` and ``--version`` included, until the + last command moves (see ``cloudfall.app``). + """ + words = list(sys.argv[1:] if argv is None else argv) + if treaty_app.resolves(words): + return treaty_app.run(words) begin_invocation() try: - code = _main(argv) + code = _main(words) except OSError as error: # A path the command must write is read-only or not ours: a fact # about the environment, reported like any other failure. Every @@ -1484,7 +1491,6 @@ def _dispatch( arguments: Namespace, state: ValidatedConfig, schema_directory: Path ) -> int: handlers: dict[str, Callable[[Namespace, ValidatedConfig, Path], int]] = { - "audit": _run_audit, "backup:run": _run_backup_run, "backup:verify": _run_backup_verify, "secrets:render": _run_secrets_render, @@ -1493,18 +1499,13 @@ def _dispatch( "dashboard:serve": _run_dashboard_serve, "deploy": _run_deploy, "health": _run_health, - "inventory:show": _run_inventory_show, "observe": _run_observe, "migrate": _run_migrate, "operator:approve": _run_operator_approve, - "operator:list": _run_operator_list, "operator:run": _run_operator_run, - "operator:show": _run_operator_show, "restart": _run_restart, "rollback": _run_rollback, "services:inspect": _run_services_inspect, - "services:status": _run_services_status, - "config:validate": _run_config_validate, } key = _command_key(arguments) try: @@ -1531,29 +1532,8 @@ def _command_key(arguments: Namespace) -> str: return f"{arguments.command}:{subcommand}" -def _run_config_validate( - _arguments: Namespace, state: ValidatedConfig, _schema_directory: Path -) -> int: - write_result(state.as_dict()) - return 0 - - -def _run_inventory_show( - arguments: Namespace, state: ValidatedConfig, _schema_directory: Path -) -> int: - payload: dict[str, object] = { - "status": "ok", - "inventory": PlatformInventory.from_state(state).as_dict(), - } - read = getattr(arguments, "fleet_read", None) - if isinstance(read, FleetRead): - payload["ansible"] = read.as_dict() - write_result(payload) - return 0 - - def _run_operations(arguments: Namespace) -> int: - """Read the catalog, which needs no fleet and no inventory. + """Propose or approve against the catalog, which needs no fleet. An agent needs its tool list from a repository Cloudfall cannot read a fleet from yet, so the catalog resolves against the repository it lives @@ -1566,33 +1546,14 @@ def _run_operations(arguments: Namespace) -> int: ) if arguments.operations_command == "approve": return _approve_decision(arguments, repository) - if arguments.operations_command == "decisions": - # The record outlives the catalog: what was proposed and approved - # stays readable however the declared operations change. - store = _decision_store(arguments, repository) - write_result( - { - "status": "ok", - "directory": str(store.directory), - "decisions": [ - decision.as_document() for decision in store.list() - ], - } - ) - return 0 + # list, show and decisions run on treaty (cloudfall.app); propose is + # the only other subcommand that reaches argparse. catalog = load_catalog( repository, Path(arguments.schemas), repository / Path(arguments.operations), ) - if arguments.operations_command == "show": - operation = catalog.get(arguments.operation) - write_result({"status": "ok", "operation": operation.as_dict()}) - return 0 - if arguments.operations_command == "propose": - return _propose_operation(arguments, repository, catalog) - write_result(catalog.as_dict()) - return 0 + return _propose_operation(arguments, repository, catalog) def _run_why(arguments: Namespace) -> int: @@ -1752,27 +1713,6 @@ def _run_observe( return code -_AUDIT_EXIT_CODES = { - AuditStatus.COMPLIANT: 0, - AuditStatus.DRIFT: 1, - AuditStatus.UNKNOWN: 3, -} - - -def _run_audit( - arguments: Namespace, state: ValidatedConfig, schema_directory: Path -) -> int: - observations = load_observations(Path(arguments.observed), schema_directory) - report = audit_inventory( - PlatformInventory.from_state(state), - observations, - load_environment_receipts(Path(arguments.env_receipts), schema_directory), - ) - code = _AUDIT_EXIT_CODES[report.status] - write_result(report.as_dict(), exit_code=code) - return code - - def _run_secrets_render( arguments: Namespace, _state: ValidatedConfig, schema_directory: Path ) -> int: @@ -1919,30 +1859,6 @@ def _verifier_for( return _operator_exit(error) -def _run_operator_list( - arguments: Namespace, _state: ValidatedConfig, schema_directory: Path -) -> int: - try: - store = _operator_store(arguments, schema_directory) - proposals = [proposal.as_document() for proposal in store.list()] - except OperatorError as error: - return _operator_exit(error) - write_result({"status": "ok", "proposals": proposals}) - return 0 - - -def _run_operator_show( - arguments: Namespace, _state: ValidatedConfig, schema_directory: Path -) -> int: - try: - store = _operator_store(arguments, schema_directory) - proposal = store.load(arguments.proposal) - except OperatorError as error: - return _operator_exit(error) - write_result({"status": "ok", "proposal": proposal.as_document()}) - return 0 - - def _run_operator_approve( arguments: Namespace, state: ValidatedConfig, schema_directory: Path ) -> int: @@ -1999,19 +1915,6 @@ def _run_services_inspect( return 0 -def _run_services_status( - arguments: Namespace, state: ValidatedConfig, schema_directory: Path -) -> int: - operations = _service_operations(arguments, state, schema_directory) - write_result( - { - "status": "ok", - "services": [domain.as_dict() for domain in operations.domains], - } - ) - return 0 - - def _run_dashboard_build( arguments: Namespace, state: ValidatedConfig, schema_directory: Path ) -> int: diff --git a/sdk/src/cloudfall/commands.py b/sdk/src/cloudfall/commands.py index 60fb8d8..b34d830 100644 --- a/sdk/src/cloudfall/commands.py +++ b/sdk/src/cloudfall/commands.py @@ -109,6 +109,32 @@ def json_schema(self) -> dict[str, object]: schema["description"] = self.when return schema + def treaty_schema(self) -> dict[str, object]: + """Return the JSON Schema of this shape in treaty's envelope. + + ``status`` moves into ``data`` beside the other keys, every key is + written on every answer (an optional one as an empty object), and a + failed run keeps ``data`` only for a verdict such as drift. + """ + data_keys = tuple( + OutputKey(key.name, key.stability) + for key in self.keys + if key.name != "error" + ) + data = { + **_object_schema(data_keys), + "type": ["object", "null"], + **_stability(_DATA), + } + error_schema = {"type": ["null", "object"], **_stability(_ERROR)} + schema = _object_schema( + (_OK, _DATA, _ERROR, *_WRITER_KEYS), + {"data": data, "error": error_schema}, + ) + if self.when is not None: + schema["description"] = self.when + return schema + def _object_schema( keys: tuple[OutputKey, ...], nested: dict[str, object] | None = None @@ -180,6 +206,9 @@ class CommandContract: output: tuple[OutputShape, ...] = () """Each shape of the JSON document the command writes to stdout.""" + treaty: bool = False + """The command runs on treaty (``cloudfall.app``), in treaty's envelope.""" + @property def invocation(self) -> str: """Return the command as typed from the project directory.""" @@ -187,9 +216,13 @@ def invocation(self) -> str: def output_schema(self) -> dict[str, object]: """Return the JSON Schema of the command's stdout documents.""" - if len(self.output) == 1: - return self.output[0].json_schema() - return {"oneOf": [shape.json_schema() for shape in self.output]} + schemas = [ + shape.treaty_schema() if self.treaty else shape.json_schema() + for shape in self.output + ] + if len(schemas) == 1: + return schemas[0] + return {"oneOf": schemas} _YES = "`--yes`; without it the command validates and prints the plan only" @@ -268,6 +301,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "validate every resource against the schemas and cross-references", output=(_shape("status", "resources", "byKind"),), + treaty=True, ), CommandContract( "cloudfall", @@ -282,6 +316,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: when="`ansible` only when the fleet is read from an Ansible inventory", ), ), + treaty=True, ), CommandContract( "cloudfall", @@ -289,6 +324,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "list the operations an agent may run, with their risk levels", output=(_shape("status", "directory", "operations", "byRisk"),), + treaty=True, ), CommandContract( "cloudfall", @@ -296,6 +332,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "show one declared operation with its inputs and verify step", output=(_shape("status", "operation"),), + treaty=True, ), CommandContract( "cloudfall", @@ -321,6 +358,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "list what was proposed, what check mode showed, and who approved", output=(_shape("status", "directory", "decisions"),), + treaty=True, ), CommandContract( "cloudfall", @@ -362,6 +400,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "compare the declared config with observed server snapshots", output=(_shape("status", "summary", "unmatchedObservations", "servers"),), + treaty=True, ), CommandContract( "cloudfall", @@ -376,6 +415,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "derive each public service's lifecycle from current evidence", output=(_shape("status", "services"),), + treaty=True, ), CommandContract( "cloudfall", @@ -469,6 +509,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "list proposal receipts", output=(_shape("status", "proposals"),), + treaty=True, ), CommandContract( "cloudfall", @@ -476,6 +517,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "show one proposal receipt", output=_PROPOSAL, + treaty=True, ), CommandContract( "cloudfall", diff --git a/sdk/src/cloudfall/operator.py b/sdk/src/cloudfall/operator.py index 453051c..60774fc 100644 --- a/sdk/src/cloudfall/operator.py +++ b/sdk/src/cloudfall/operator.py @@ -40,7 +40,8 @@ _ERROR_FEED_INVALID = "operator_feed_invalid" _ERROR_GATEWAY_MATERIAL_INVALID = "operator_gateway_material_invalid" _ERROR_PROPOSAL_EXISTS = "operator_proposal_exists" -_ERROR_PROPOSAL_MISSING = "operator_proposal_missing" +ERROR_PROPOSAL_MISSING = "operator_proposal_missing" +"""Code of the error ``ProposalStore`` raises for an id it holds no receipt for.""" _ERROR_PROPOSAL_NOT_OPEN = "operator_proposal_not_open" _ERROR_OPERATION_UNSUPPORTED = "operator_operation_unsupported" _ERROR_GATEWAY_UNDECLARED = "operator_gateway_undeclared" @@ -488,7 +489,7 @@ def update(self, proposal: OperatorProposal) -> Path: path = self._path(proposal.resource_id) if not path.exists(): message = f"proposal does not exist: {path}" - raise OperatorError(_ERROR_PROPOSAL_MISSING, message) + raise OperatorError(ERROR_PROPOSAL_MISSING, message) return self._write(path, proposal) def load(self, proposal_id: ResourceId) -> OperatorProposal: @@ -496,7 +497,7 @@ def load(self, proposal_id: ResourceId) -> OperatorProposal: path = self._path(proposal_id) if not path.is_file(): message = f"proposal does not exist: {path}" - raise OperatorError(_ERROR_PROPOSAL_MISSING, message) + raise OperatorError(ERROR_PROPOSAL_MISSING, message) document = cast( "dict[str, object]", json.loads(path.read_text(encoding="utf-8")) ) diff --git a/sdk/src/cloudfall/project.py b/sdk/src/cloudfall/project.py index 98761e0..715e5b1 100644 --- a/sdk/src/cloudfall/project.py +++ b/sdk/src/cloudfall/project.py @@ -695,6 +695,14 @@ def _agent_contract(options: InitOptions) -> str: |---|---| {exit_rows} +`cloudfall` is moving to the treaty contract a command at a time; the +commands `cloudfall manifest` lists have moved. Their envelope has no +top-level `status`: the verdict is `data.status`, beside the keys described +above. A failure is an envelope on stdout too, the exit code is the one +`cloudfall manifest` declares (a negative verdict such as drift keeps its +report in `data`), and the snake_case code is in `error.context.code`. Pick +the format with `--format json`; `--output` names a file there. + `cloudfall-engine` keeps its own contract: its envelope has no `status`, a failure is an envelope on stdout too, and its codes are listed by `cloudfall-engine manifest` (79 to 82 for engine failures, with the diff --git a/sdk/tests/test_ansible_reader.py b/sdk/tests/test_ansible_reader.py index b63a00c..2ae304c 100644 --- a/sdk/tests/test_ansible_reader.py +++ b/sdk/tests/test_ansible_reader.py @@ -351,9 +351,10 @@ def test_the_cli_refuses_two_fleets_at_once( ] ) - payload = json.loads(capsys.readouterr().err) + payload = json.loads(capsys.readouterr().out) assert exit_code == 2 - assert payload["error"]["code"] == "project_source_ambiguous" + assert payload["error"]["code"] == "ARG_ERROR" + assert "--project" in json.dumps(payload["error"]) def test_the_cli_reports_a_missing_inventory_as_json( @@ -361,6 +362,7 @@ def test_the_cli_reports_a_missing_inventory_as_json( ) -> None: exit_code = main(["inventory", "show", "--inventory", str(tmp_path / "absent")]) - payload = json.loads(capsys.readouterr().err) - assert exit_code == 2 - assert payload["error"]["code"] == "ansible_inventory_missing" + payload = json.loads(capsys.readouterr().out) + assert exit_code == 85 + assert payload["error"]["code"] == "INVENTORY_UNREADABLE" + assert payload["error"]["context"]["code"] == "ansible_inventory_missing" diff --git a/sdk/tests/test_app.py b/sdk/tests/test_app.py new file mode 100644 index 0000000..e942362 --- /dev/null +++ b/sdk/tests/test_app.py @@ -0,0 +1,62 @@ +"""The commands that moved to treaty, and the shim that routes to them.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from cloudfall.app import app +from cloudfall.cli import main +from cloudfall.commands import CLI_COMMANDS + +ROOT = Path(__file__).parents[2] +EXAMPLES = ROOT / "config" / "examples" + + +def test_the_contract_marks_exactly_the_commands_treaty_runs() -> None: + marked = { + contract.name + for contract in CLI_COMMANDS + if contract.program == "cloudfall" and contract.treaty + } + routed = { + contract.name + for contract in CLI_COMMANDS + if contract.program == "cloudfall" and app.resolves(contract.name.split()) + } + + assert marked == routed + assert "audit" in marked + + +def test_root_help_still_lists_a_command_that_moved( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exit_info: + main(["--help"]) + + assert exit_info.value.code == 0 + assert "audit" in capsys.readouterr().err + + +def test_a_moved_command_keeps_its_keys_with_status_under_data( + capsys: pytest.CaptureFixture[str], +) -> None: + code = main(["operations", "decisions", "--repository", str(EXAMPLES)]) + + document = json.loads(capsys.readouterr().out) + assert code == 0 + assert "status" not in document + assert document["data"]["status"] == "ok" + assert set(document["data"]) == {"status", "directory", "decisions"} + + +def test_a_half_moved_group_sends_the_rest_to_argparse( + capsys: pytest.CaptureFixture[str], +) -> None: + code = main(["operations", "propose", "restart", "--repository", str(EXAMPLES)]) + + error = json.loads(capsys.readouterr().err) + assert code == 2 + assert error["status"] == "error" diff --git a/sdk/tests/test_arguments.py b/sdk/tests/test_arguments.py index d72d8ef..e7a649f 100644 --- a/sdk/tests/test_arguments.py +++ b/sdk/tests/test_arguments.py @@ -59,10 +59,21 @@ def test_abbreviated_secret_file_flag_is_rejected_without_echo( assert "hunter2" not in json.dumps(payload) +def _treaty_usage_error( + argv: list[str], capsys: pytest.CaptureFixture[str] +) -> dict[str, object]: + """Run a command that moved to treaty: a usage error is exit 2 on stdout.""" + assert main(argv) == 2 + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, dict) + assert payload["ok"] is False + return payload + + def test_unrecognized_option_values_are_not_echoed( capsys: pytest.CaptureFixture[str], ) -> None: - payload = _usage_error( + payload = _treaty_usage_error( [ "config", "validate", @@ -75,7 +86,7 @@ def test_unrecognized_option_values_are_not_echoed( capsys, ) - message = str(payload["error"]) + message = json.dumps(payload["error"]) assert "--token" in message assert "--debug" in message assert "secret" not in message @@ -97,7 +108,6 @@ def test_missing_required_argument_is_a_json_usage_error( [ ["health", "Bad ID!"], ["rollback", "crm-backend", "--release", "r1"], - ["operator", "show", "../ghost"], ["backup", "run", "acme%2Fdb"], ], ) @@ -114,6 +124,19 @@ def test_invalid_identifiers_fail_before_dispatch( assert "invalid" in str(error["message"]) +def test_an_invalid_identifier_fails_before_a_treaty_command_runs( + capsys: pytest.CaptureFixture[str], +) -> None: + payload = _treaty_usage_error( + ["operator", "show", "../ghost", "--project", str(EXAMPLES)], capsys + ) + + error = payload["error"] + assert isinstance(error, dict) + assert error["code"] == "ARG_ERROR" + assert "proposal" in str(error["message"]) + + def test_import_render_rejects_an_invalid_application_id( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/sdk/tests/test_audit.py b/sdk/tests/test_audit.py index debf01a..10ee1db 100644 --- a/sdk/tests/test_audit.py +++ b/sdk/tests/test_audit.py @@ -137,9 +137,11 @@ def test_audit_reports_raid_package_service_and_config_drift( ] ) captured = capsys.readouterr() - assert exit_code == 1 + assert exit_code == 83 document = json.loads(captured.out) - assert (document["ok"], document["status"]) == (False, "drift") + assert (document["ok"], document["error"]["code"]) == (False, "DRIFT") + assert document["data"]["status"] == "drift" + assert document["data"]["summary"]["servers"]["drift"] == 1 assert captured.err == "" @@ -414,9 +416,10 @@ def test_missing_observation_is_unknown( ] ) captured = capsys.readouterr() - assert exit_code == 3 + assert exit_code == 84 document = json.loads(captured.out) - assert (document["ok"], document["status"]) == (False, "unknown") + assert (document["ok"], document["error"]["code"]) == (False, "UNKNOWN") + assert document["data"]["status"] == "unknown" assert captured.err == "" @@ -451,7 +454,7 @@ def test_audit_cli_emits_json_and_compliance_exit_code( captured = capsys.readouterr() assert exit_code == 0 - assert json.loads(captured.out)["status"] == "compliant" + assert json.loads(captured.out)["data"]["status"] == "compliant" assert captured.err == "" diff --git a/sdk/tests/test_catalog.py b/sdk/tests/test_catalog.py index 44201bc..f45766b 100644 --- a/sdk/tests/test_catalog.py +++ b/sdk/tests/test_catalog.py @@ -250,6 +250,7 @@ def test_the_cli_refuses_an_undeclared_operation( ["operations", "show", "restart", "--repository", str(repository)] ) - payload = json.loads(capsys.readouterr().err) - assert exit_code == 2 - assert payload["error"]["code"] == "operation_undeclared" + payload = json.loads(capsys.readouterr().out) + assert exit_code == 5 + assert payload["error"]["code"] == "NOT_FOUND" + assert payload["error"]["context"]["code"] == "operation_undeclared" diff --git a/sdk/tests/test_inventory.py b/sdk/tests/test_inventory.py index 332c2d4..40c2fa3 100644 --- a/sdk/tests/test_inventory.py +++ b/sdk/tests/test_inventory.py @@ -216,6 +216,7 @@ def test_inventory_cli_emits_structured_output( captured = capsys.readouterr() payload = json.loads(captured.out) assert exit_code == 0 - assert payload["status"] == "ok" + assert payload["data"]["status"] == "ok" assert payload["data"]["inventory"]["components"][0]["id"] == "crm-backend" + assert payload["data"]["ansible"] == {} assert captured.err == "" diff --git a/sdk/tests/test_manifest.py b/sdk/tests/test_manifest.py index 0c1ac33..6a6e685 100644 --- a/sdk/tests/test_manifest.py +++ b/sdk/tests/test_manifest.py @@ -80,14 +80,19 @@ def _documents(argv: list[str], capsys: pytest.CaptureFixture[str]) -> list[obje documents = [json.loads(line) for line in capsys.readouterr().out.splitlines()] # `ok` is the exit code as a boolean, on every document the command wrote. assert [document["ok"] for document in documents] == [code == 0] * len(documents) - top = {"ok", "status", "data", "error", "meta", "warnings"} - assert all(set(document) == top for document in documents) - # An error on stdout names the code the process exits with. - assert all( - document["error"]["exit_code"] == code - for document in documents - if document["error"] is not None - ) + for document in documents: + assert isinstance(document, dict) + if "status" in document: + # The argparse envelope: an error on stdout names its exit code. + top = {"ok", "status", "data", "error", "meta", "warnings"} + assert set(document) == top + if document["error"] is not None: + assert document["error"]["exit_code"] == code + else: + # treaty's envelope, for a command that moved; `_conforms` checks + # which envelope the contract declares. + assert set(document) == {"ok", "data", "error", "meta", "warnings"} + assert document["meta"]["exit_code"] == code return documents diff --git a/sdk/tests/test_operator.py b/sdk/tests/test_operator.py index 66d47a8..8eb0afc 100644 --- a/sdk/tests/test_operator.py +++ b/sdk/tests/test_operator.py @@ -5,7 +5,6 @@ import json from dataclasses import dataclass, field from pathlib import Path -from unittest.mock import ANY import pytest from cloudfall.audit import AuditCheck, AuditReport, AuditStatus, ServerAudit @@ -32,7 +31,6 @@ parse_prometheus_alerts, run_once, ) -from cloudfall.output import RESPONSE_META from cloudfall.validation import SchemaCatalog, validate_config from jsonschema import Draft202012Validator @@ -451,15 +449,14 @@ def test_cli_operator_list_emits_structured_output( ) captured = capsys.readouterr() + document = json.loads(captured.out) assert exit_code == 0 - assert json.loads(captured.out) == { - "ok": True, - "status": "ok", - "error": None, - "data": {"proposals": []}, - "meta": {**RESPONSE_META.as_dict(), "request_id": ANY, "duration_ms": ANY}, - "warnings": [], - } + assert (document["ok"], document["error"], document["warnings"]) == ( + True, + None, + [], + ) + assert document["data"] == {"status": "ok", "proposals": []} def _operator_show(tmp_path: Path, proposal: str) -> list[str]: @@ -488,15 +485,9 @@ def test_cli_operator_show_wraps_the_proposal_in_the_result_envelope( captured = capsys.readouterr() assert exit_code == 0 document = json.loads(captured.out) - assert document == { - "ok": True, + assert document["data"] == { "status": "ok", - "error": None, - "data": { - "proposal": store.load(ResourceId.from_boundary(proposal_id)).as_document() - }, - "meta": {**RESPONSE_META.as_dict(), "request_id": ANY, "duration_ms": ANY}, - "warnings": [], + "proposal": store.load(ResourceId.from_boundary(proposal_id)).as_document(), } contract = next(c for c in CLI_COMMANDS if c.name == "operator show") validator = Draft202012Validator(contract.output_schema()) @@ -508,13 +499,11 @@ def test_cli_operator_errors_use_the_shared_error_envelope( ) -> None: exit_code = main(_operator_show(tmp_path, "ghost")) - captured = capsys.readouterr() - assert exit_code == 2 - assert captured.out == "" - document = json.loads(captured.err) - assert document["status"] == "error" - assert document["error"]["code"] == "operator_proposal_missing" - assert "code" not in document + document = json.loads(capsys.readouterr().out) + assert exit_code == 5 + assert (document["ok"], document["data"]) == (False, None) + assert document["error"]["code"] == "NOT_FOUND" + assert document["error"]["context"]["code"] == "operator_proposal_missing" def _policied_inventory() -> PlatformInventory: diff --git a/sdk/tests/test_output.py b/sdk/tests/test_output.py index 717b00e..7c867cd 100644 --- a/sdk/tests/test_output.py +++ b/sdk/tests/test_output.py @@ -67,10 +67,8 @@ def test_version_flag_prints_the_package_and_schema_versions( def test_results_and_errors_carry_the_same_meta_and_warnings( capsys: pytest.CaptureFixture[str], ) -> None: - ok, result, _ = _run(["config", "validate", "--project", str(EXAMPLES)], capsys) - failed, _, error = _run( - ["config", "validate", "--project", str(ROOT / "missing")], capsys - ) + ok, result, _ = _run(["changelog"], capsys) + failed, _, error = _run(["observe", "--project", str(ROOT / "missing")], capsys) usage_code, _, usage = _run(["deploy"], capsys) assert (ok, failed, usage_code) == (0, 2, 2) @@ -331,10 +329,10 @@ def test_writing_outside_an_invocation_is_a_bug() -> None: def test_help_goes_to_stderr_when_stdout_is_not_a_terminal( capsys: pytest.CaptureFixture[str], ) -> None: - code, out, err = _run(["inventory", "show", "--help"], capsys) + code, out, err = _run(["observe", "--help"], capsys) assert (code, out) == (0, "") - assert err.startswith("usage: cloudfall inventory show") + assert err.startswith("usage: cloudfall observe") def test_quiet_silences_help_off_a_terminal( @@ -375,9 +373,9 @@ def test_help_stays_on_stdout_for_a_person_at_a_terminal() -> None: @pytest.mark.parametrize( "argv", [ - ["--quiet", "config", "validate", "--project", str(ROOT / "missing")], - ["config", "validate", "--project", str(ROOT / "missing"), "--quiet"], - ["--quiet", "inventory", "shoe"], + ["--quiet", "observe", "--project", str(ROOT / "missing")], + ["observe", "--project", str(ROOT / "missing"), "--quiet"], + ["--quiet", "dashboard", "shoe"], ], ) def test_quiet_leaves_only_the_exit_code( @@ -425,7 +423,7 @@ def test_warnings_as_errors_leaves_a_clean_result_alone( assert (json.loads(out)["ok"], json.loads(out)["error"]) == (True, None) -@pytest.mark.parametrize("argv", [["--help"], ["inventory", "show", "--help"]]) +@pytest.mark.parametrize("argv", [["--help"], ["observe", "--help"]]) def test_help_ends_with_every_exit_code( argv: list[str], capsys: pytest.CaptureFixture[str] ) -> None: @@ -447,7 +445,7 @@ def test_the_error_carries_the_code_the_process_exits_with( try: cases = [ (_run(["deploy"], capsys), 2), - (_run(["inventory", "show", "--project", str(tmp_path)], capsys), 2), + (_run(["observe", "--project", str(tmp_path)], capsys), 2), (_run(["init", str(read_only / "project")], capsys), 1), ] finally: diff --git a/sdk/tests/test_project.py b/sdk/tests/test_project.py index 8ead7ae..afa2dee 100644 --- a/sdk/tests/test_project.py +++ b/sdk/tests/test_project.py @@ -391,9 +391,10 @@ def test_cli_reports_a_missing_project_as_json( ) -> None: exit_code = main(["audit", "--project", str(tmp_path / "nope"), "--observed", "x"]) - payload = json.loads(capsys.readouterr().err) - assert exit_code == 2 - assert payload["error"]["code"] == "project_directory_missing" + payload = json.loads(capsys.readouterr().out) + assert exit_code == 79 + assert payload["error"]["code"] == "PROJECT_INVALID" + assert payload["error"]["context"]["code"] == "project_directory_missing" def _section(document: str, heading: str) -> str: diff --git a/sdk/tests/test_validation.py b/sdk/tests/test_validation.py index edae87a..00103c7 100644 --- a/sdk/tests/test_validation.py +++ b/sdk/tests/test_validation.py @@ -5,12 +5,10 @@ import json import shutil from pathlib import Path -from unittest.mock import ANY import pytest from cloudfall.cli import main from cloudfall.domain import ResourceId, ResourceKind -from cloudfall.output import RESPONSE_META from cloudfall.validation import ConfigValidationError, validate_config ROOT = Path(__file__).parents[2] @@ -269,14 +267,18 @@ def test_cli_emits_structured_success( ) captured = capsys.readouterr() + document = json.loads(captured.out) assert exit_code == 0 - assert json.loads(captured.out) == { - "ok": True, + assert (document["ok"], document["error"], document["warnings"]) == ( + True, + None, + [], + ) + assert document["meta"]["command"] == "config.validate" + assert document["data"] == { "status": "ok", - "error": None, - "data": { - "resources": 12, - "byKind": { + "resources": 12, + "byKind": { "AlertRule": 1, "OperatorPolicy": 1, "Component": 1, @@ -287,12 +289,8 @@ def test_cli_emits_structured_success( "Server": 2, "Service": 2, "SshPublicKey": 1, - }, }, - "meta": {**RESPONSE_META.as_dict(), "request_id": ANY, "duration_ms": ANY}, - "warnings": [], } - assert captured.err == "" def test_cli_emits_structured_failure( @@ -310,9 +308,8 @@ def test_cli_emits_structured_failure( ] ) - captured = capsys.readouterr() - payload = json.loads(captured.err) - assert exit_code == 2 - assert payload["status"] == "error" - assert payload["error"]["code"] == "resource_reference_missing" - assert captured.out == "" + payload = json.loads(capsys.readouterr().out) + assert exit_code == 80 + assert payload["ok"] is False + assert payload["error"]["code"] == "CONFIG_INVALID" + assert payload["error"]["context"]["code"] == "resource_reference_missing" From c08359363b122ed3c80e0576bc237ee8c81eca10 Mon Sep 17 00:00:00 2001 From: Romamo Date: Thu, 1 Oct 2026 17:37:13 +0300 Subject: [PATCH 4/4] test: pin the drift report's order to treaty #181 A drift or unknown audit returns its report through Exit(data=...), and treaty 1.0.0rc10 re-sorts an output adapter's arrays on that path. The strict xfail fails the suite once treaty keeps the declared order. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++- sdk/tests/test_app.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88f9a6f..654008d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,7 +94,9 @@ 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`. Pick + `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 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 diff --git a/sdk/tests/test_app.py b/sdk/tests/test_app.py index e942362..6edc8ff 100644 --- a/sdk/tests/test_app.py +++ b/sdk/tests/test_app.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import shutil from pathlib import Path import pytest @@ -12,6 +13,7 @@ ROOT = Path(__file__).parents[2] EXAMPLES = ROOT / "config" / "examples" +COMPLIANT = ROOT / "config" / "tests" / "observed" / "compliant" def test_the_contract_marks_exactly_the_commands_treaty_runs() -> None: @@ -60,3 +62,26 @@ def test_a_half_moved_group_sends_the_rest_to_argparse( error = json.loads(capsys.readouterr().err) assert code == 2 assert error["status"] == "error" + + +@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: + observations = tmp_path / "observed" + shutil.copytree(COMPLIANT, observations) + h1 = observations / "h1.json" + h1.write_text( + h1.read_text(encoding="utf-8").replace("[UU]", "[U_]"), encoding="utf-8" + ) + + code = main( + ["audit", "--project", str(EXAMPLES), "--observed", str(observations)] + ) + + report = json.loads(capsys.readouterr().out)["data"] + assert code == 83 + assert report["servers"][0]["checks"][0]["check"] == "server_type.id"