diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d034a3..654008d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,36 @@ 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`; 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 +- `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 @@ -111,6 +141,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/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/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/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/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..6edc8ff --- /dev/null +++ b/sdk/tests/test_app.py @@ -0,0 +1,87 @@ +"""The commands that moved to treaty, and the shim that routes to them.""" + +from __future__ import annotations + +import json +import shutil +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" +COMPLIANT = ROOT / "config" / "tests" / "observed" / "compliant" + + +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" + + +@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" 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_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() 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" 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]]