From 074c29d44bb6c9e02340af505ffd8ff80ef7a0d9 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 30 Sep 2026 10:20:29 +0300 Subject: [PATCH 1/2] feat!: cloudfall-engine runs on treaty The engine's four commands move from argparse to treaty 1.0.0rc4, the first Cloudfall CLI to do so. Every command answers one JSON envelope on stdout, errors included, and `cloudfall-engine manifest` describes each command, flag, and exit code. Engine failures exit with their own codes: 79 PROJECT_INVALID, 80 CONFIG_INVALID, 81 ARTIFACT_BUILD_FAILED, 82 PLAYBOOK_FAILED, with the old snake_case code in error.context.code. A missing playbook, inventory, or role directory, a malformed git ref, and a malformed component id exit 2 before anything runs. Relative paths still land in the project, now joined onto the resolved directory rather than through chdir. Ansible's play log streams to stderr, so stdout holds only the envelope; it still runs through subprocess, because ctx.run can neither stream nor run Ansible under its C locale (romamo/treaty#61, #62). Breaking: `playbook run --check` is `--dry-run`, since treaty recognizes a dry run only by that name (romamo/treaty#63), and `inventory render` puts the inventory under data.inventory; the --output file is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 + engine/README.md | 24 +- engine/src/cloudfall_engine/artifact.py | 10 +- engine/src/cloudfall_engine/cli.py | 579 +++++++++++++++--------- engine/src/cloudfall_engine/playbook.py | 4 + engine/tests/test_ansible_inventory.py | 58 ++- engine/tests/test_playbook.py | 86 ++-- pyproject.toml | 5 + sdk/src/cloudfall/project.py | 5 + sdk/tests/test_commands.py | 11 +- uv.lock | 11 + 11 files changed, 536 insertions(+), 267 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 539b285..506be80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,16 @@ Notable changes to Cloudfall. The format follows ### Changed +- **Breaking:** `cloudfall-engine` runs on [treaty](https://github.com/romamo/treaty). + Every command answers one JSON envelope on stdout (`ok`, `data`, `error`, + `warnings`, `meta`), errors included, which used to go to stderr in their + own shape; `inventory render` puts the inventory under `data.inventory` + (the `--output` file is unchanged). Failures exit with their own codes: + 79 `PROJECT_INVALID`, 80 `CONFIG_INVALID`, 81 `ARTIFACT_BUILD_FAILED`, + 82 `PLAYBOOK_FAILED`; the old snake_case code is in `error.context.code`. + 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 - 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 c0d186e..7b2b05b 100644 --- a/engine/README.md +++ b/engine/README.md @@ -55,12 +55,32 @@ uv run cloudfall-engine playbook run engine/ansible/playbooks/proxy.yml \ --roles engine/ansible/roles --limit proxy --tags nginx ``` -`--check`, `--diff`, `--syntax-check`, `--limit`, `--tags`, and `--extra-vars` -pass through to `ansible-playbook`. A project uses this instead of +`--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 setting `ANSIBLE_CONFIG` against a checkout; the `task` targets in this repository still call `ansible-playbook` directly because they run from the source tree. +## Output and exit codes + +`cloudfall-engine` is built on [treaty](https://github.com/romamo/treaty): every +command answers one JSON envelope on stdout (`ok`, `data`, `error`, `warnings`, +`meta`) and `cloudfall-engine manifest` describes every command, flag, and exit +code. Input errors exit 2 before anything runs. Engine failures have their own +codes, with the original snake_case code in `error.context.code`: + +| Exit | Code | Meaning | +|---|---|---| +| 79 | `PROJECT_INVALID` | no project directory could be resolved | +| 80 | `CONFIG_INVALID` | the project's resources failed validation | +| 81 | `ARTIFACT_BUILD_FAILED` | the component could not be cloned or packaged | +| 82 | `PLAYBOOK_FAILED` | `ansible-playbook` exited non-zero; `error.context.returncode` has its code | + +Relative paths such as `--output tmp/ansible-inventory.json` and the default +`--output-dir tmp/artifacts` land in the project, whatever the working directory. + ## UTC time baseline `ansible/playbooks/time.yml` enforces `Etc/UTC`, keeps the hardware clock in diff --git a/engine/src/cloudfall_engine/artifact.py b/engine/src/cloudfall_engine/artifact.py index 19366d2..e042538 100644 --- a/engine/src/cloudfall_engine/artifact.py +++ b/engine/src/cloudfall_engine/artifact.py @@ -25,9 +25,9 @@ from cloudfall.inventory import ComponentInventory, PlatformInventory _ARTIFACT_SCHEMA = "artifact.schema.json" -_GIT_TIMEOUT_SECONDS = 600 +GIT_TIMEOUT_SECONDS = 600 _SHORT_COMMIT_LENGTH = 7 -_GIT_REF_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/@^~-]{0,199}") +GIT_REF_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/@^~-]{0,199}") _ERROR_COMPONENT_MISSING = "artifact_component_missing" _ERROR_SUBDIRECTORY_MISSING = "artifact_subdirectory_missing" _ERROR_REF_INVALID = "artifact_ref_invalid" @@ -192,7 +192,7 @@ def _component( def _validate_ref(git_ref: str) -> None: - if not _GIT_REF_PATTERN.fullmatch(git_ref): + if not GIT_REF_PATTERN.fullmatch(git_ref): detail = f"git ref contains unsupported characters: {git_ref!r}" raise ArtifactBuildError(_ERROR_REF_INVALID, detail) @@ -228,7 +228,7 @@ def _run_git(*arguments: str) -> str: capture_output=True, check=True, text=True, - timeout=_GIT_TIMEOUT_SECONDS, + timeout=GIT_TIMEOUT_SECONDS, ) except subprocess.CalledProcessError as error: detail = ( @@ -238,7 +238,7 @@ def _run_git(*arguments: str) -> str: except subprocess.TimeoutExpired as error: detail = ( f"git {' '.join(arguments[:2])} exceeded " - f"{_GIT_TIMEOUT_SECONDS} seconds" + f"{GIT_TIMEOUT_SECONDS} seconds" ) raise ArtifactBuildError(_ERROR_GIT_TIMEOUT, detail) from error return completed.stdout diff --git a/engine/src/cloudfall_engine/cli.py b/engine/src/cloudfall_engine/cli.py index e3a4089..de7148f 100644 --- a/engine/src/cloudfall_engine/cli.py +++ b/engine/src/cloudfall_engine/cli.py @@ -1,30 +1,35 @@ -"""Command-line boundary for the Cloudfall execution engine.""" +"""Command-line boundary for the Cloudfall execution engine. -from __future__ import annotations +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. +""" import json import os -import sys +from dataclasses import dataclass +from importlib.metadata import version from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self -from cloudfall.arguments import ( - StrictArgumentParser, - parse_arguments, - project_path_argument, -) +from cloudfall.domain import ResourceId from cloudfall.inventory import PlatformInventory -from cloudfall.output import begin_invocation from cloudfall.project import ( PROJECT_DIRECTORY_VARIABLE, ProjectError, - project_context, + resolve_project_directory, ) 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 cloudfall_engine.ansible_inventory import render_ansible_inventory -from cloudfall_engine.artifact import ArtifactBuildError, build_artifact +from cloudfall_engine.artifact import ( + GIT_REF_PATTERN, + GIT_TIMEOUT_SECONDS, + ArtifactBuildError, + build_artifact, +) from cloudfall_engine.playbook import ( PlaybookError, PlaybookRun, @@ -34,244 +39,396 @@ ) if TYPE_CHECKING: - import argparse from collections.abc import Sequence +app = App( + "cloudfall-engine", + version=version("cloudfall"), + description="Run validated Cloudfall operations with Ansible", +) +app.scalar( + ResourceId, + parse=ResourceId.from_boundary, + pattern=r"[a-z][a-z0-9]*(-[a-z0-9]+)*", +) +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 failed validation", + retryable=False, + side_effects="none", + suggestion="fix the resource error.context names, then run the command again", +) +app.exit_code( + "ARTIFACT_BUILD_FAILED", + 81, + description="The component could not be cloned or packaged", + retryable=False, + side_effects="none", +) +app.exit_code( + "PLAYBOOK_FAILED", + 82, + 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", +) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ProjectArgs: + """Options every command that reads a project shares.""" -def _add_project_directory_argument(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--project", - type=Path, + project: Path | None = Flag( default=None, - help=( - f"project directory to run in (default: ${PROJECT_DIRECTORY_VARIABLE}, " + description=( + f"Project directory (default: ${PROJECT_DIRECTORY_VARIABLE}, " "else the current directory when it is a project)" ), ) + schemas: Path = Flag( + default=default_schema_directory(), + description="Versioned schema directory (default: bundled schemas)", + ) -def _parser() -> StrictArgumentParser: - parser = StrictArgumentParser(prog="cloudfall-engine") - commands = parser.add_subparsers(dest="command", required=True) +@dataclass(frozen=True, slots=True) +class Project: + """The resolved project and the inventory its resources validate to.""" - inventory_parser = commands.add_parser( - "inventory", help="generate execution inventory" - ) - inventory_commands = inventory_parser.add_subparsers( - dest="inventory_command", required=True - ) - render_parser = inventory_commands.add_parser( - "render", help="render Ansible JSON inventory" - ) - _add_project_directory_argument(render_parser) - render_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - render_parser.add_argument( - "--output", - type=project_path_argument, - help="write inventory JSON to this file instead of stdout", - ) + directory: Path + inventory: PlatformInventory - artifact_parser = commands.add_parser("artifact", help="build release artifacts") - artifact_commands = artifact_parser.add_subparsers( - dest="artifact_command", required=True - ) - build_parser = artifact_commands.add_parser( - "build", help="clone, package, and hash one component release" - ) - _add_project_directory_argument(build_parser) - build_parser.add_argument("component") - build_parser.add_argument( - "--ref", - required=True, - help="git ref (branch, tag, or commit) to package", + @classmethod + def acquire(cls, args: ProjectArgs, _ctx: Ctx) -> Self: + """Resolve and validate the project before the handler runs.""" + try: + directory = resolve_project_directory( + args.project, os.environ, Path.cwd() + ) + except ProjectError as error: + raise Exit.PROJECT_INVALID( + error.detail, context={"code": error.code} + ) from error + try: + state = validate_config(directory, args.schemas) + except ConfigValidationError as error: + raise _config_invalid(error) from error + return cls(directory, PlatformInventory.from_state(state)) + + def path(self, value: Path) -> Path: + """Resolve a relative path inside the project, whatever the cwd.""" + return value if value.is_absolute() else self.directory / value + + +def _config_invalid(error: ConfigValidationError) -> Exception: + return Exit.CONFIG_INVALID(error.issue.message, context=error.issue.as_dict()) + + +inventory = app.group("inventory", description="Generate execution inventory") + + +@dataclass(frozen=True, slots=True) +class RenderArgs(ProjectArgs): + """Arguments of ``inventory render``.""" + + output: Path | None = Flag( + default=None, + description=( + "Also write the inventory JSON to this file (relative to the project)" + ), ) - build_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", + + +@dataclass(frozen=True, slots=True) +class RenderedInventory: + """The rendered inventory and where it was written.""" + + inventory: dict[str, object] = Out(ordered=True, high_entropy=False) + """The Ansible JSON inventory, exactly as Ansible reads it""" + output: Path | None = None + """Where it was written, or null without --output""" + + +@inventory.command( + "render", + description="Render the validated config as Ansible JSON inventory", + danger_level="safe", + exit_codes=["PROJECT_INVALID", "CONFIG_INVALID"], + examples=[ + ( + "Write the inventory Ansible reads", + "cloudfall-engine inventory render --output tmp/ansible-inventory.json", + ), + ], +) +def render(args: RenderArgs, _ctx: Ctx, project: Project) -> RenderedInventory: + """Render the inventory, and write it when ``--output`` names a file.""" + rendered = render_ansible_inventory(project.inventory) + if args.output is None: + return RenderedInventory(rendered) + output = project.path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(f"{json.dumps(rendered, sort_keys=True)}\n", encoding="utf-8") + return RenderedInventory(rendered, output) + + +artifact = app.group("artifact", description="Build release artifacts") + + +@dataclass(frozen=True, slots=True) +class BuildArgs(ProjectArgs): + """Arguments of ``artifact build``.""" + + component: ResourceId = Arg(description="Component to package") + ref: str = Flag( + description="Git ref (branch, tag, or commit) to package", + pattern=GIT_REF_PATTERN.pattern, ) - build_parser.add_argument( - "--output-dir", - type=project_path_argument, + output_dir: Path = Flag( default=Path("tmp/artifacts"), - help="artifact output directory (default: tmp/artifacts)", + description="Artifact output directory, relative to the project", ) - playbook_parser = commands.add_parser( - "playbook", help="run Ansible playbooks with the engine configuration" + +@dataclass(frozen=True, slots=True) +class BuiltRelease: + """One packaged and hashed release.""" + + effect: str + component: str + release: str + git_ref: str + git_commit: str + archive: Path + metadata: Path + archive_sha256: str + size_bytes: int + built_at: str = Out(volatile=True) + + +@artifact.command( + "build", + description="Clone, package, and hash one component release", + danger_level="mutating", + exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "ARTIFACT_BUILD_FAILED"], + timeout=3 * GIT_TIMEOUT_SECONDS, + supports_raw_payload=True, + examples=[ + ( + "Package the main branch of one component", + "cloudfall-engine artifact build crm-backend --ref main", + ), + ], +) +def build(args: BuildArgs, _ctx: Ctx, project: Project) -> BuiltRelease: + """Clone the component at ``--ref`` and package it under the project.""" + try: + built = build_artifact( + project.inventory, + str(args.component), + args.ref, + project.path(args.output_dir), + args.schemas, + ) + except ArtifactBuildError as error: + raise Exit.ARTIFACT_BUILD_FAILED( + error.detail, context={"code": error.code} + ) from error + except ConfigValidationError as error: + raise _config_invalid(error) from error + return BuiltRelease( + effect="created", + component=built.component_id, + release=built.release, + git_ref=built.git_ref, + git_commit=built.git_commit, + archive=built.archive_path, + metadata=built.metadata_path, + archive_sha256=built.archive_sha256, + size_bytes=built.size_bytes, + built_at=built.built_at, ) - playbook_commands = playbook_parser.add_subparsers( - dest="playbook_command", required=True + + +playbook = app.group( + "playbook", description="Run Ansible playbooks with the engine configuration" +) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class EngineArgs: + """Options every playbook command shares.""" + + engine: Path = Flag( + default=default_engine_directory(), + description=( + "Engine directory containing the Ansible contracts " + "(default: bundled engine)" + ), ) - list_parser = playbook_commands.add_parser( - "list", help="list the playbooks bundled with the engine" + + +@dataclass(frozen=True, slots=True) +class ListArgs(EngineArgs): + """Arguments of ``playbook list``.""" + + +@dataclass(frozen=True, slots=True) +class Playbooks: + """The playbooks an engine directory bundles.""" + + engine: Path + playbooks: list[str] = Out(ordered=True) + + +@playbook.command( + "list", + description="List the playbooks bundled with the engine", + danger_level="safe", + exit_codes=["NOT_FOUND"], + examples=[("List the bundled playbooks", "cloudfall-engine playbook list")], +) +def list_playbooks(args: ListArgs, _ctx: Ctx) -> Playbooks: + """List the bundled playbooks by name.""" + try: + names = bundled_playbooks(args.engine) + except PlaybookError as error: + raise Exit.NOT_FOUND(error.detail, context={"code": error.code}) from error + return Playbooks(args.engine, list(names)) + + +@dataclass(frozen=True, slots=True) +class RunArgs(EngineArgs): + """Arguments of ``playbook run``, checked before anything runs.""" + + playbook: str = Arg( + description="Bundled playbook name (see playbook list) or a playbook path" ) - _add_engine_argument(list_parser) - run_parser = playbook_commands.add_parser( - "run", help="run one bundled playbook or a playbook file" + inventory: Path = Flag( + default=Path("tmp/ansible-inventory.json"), + description="Rendered inventory path", ) - run_parser.add_argument( - "playbook", - help="bundled playbook name (see `playbook list`) or a playbook path", + roles: tuple[Path, ...] = Flag( + default=(), + description="Role directory searched before the bundled roles (repeatable)", ) - _add_engine_argument(run_parser) - run_parser.add_argument( - "--inventory", - type=project_path_argument, - default=Path("tmp/ansible-inventory.json"), - help="rendered inventory path (default: tmp/ansible-inventory.json)", + extra_vars: tuple[str, ...] = Flag( + default=(), + description="Passed through to ansible-playbook unchanged (repeatable)", ) - run_parser.add_argument( - "--roles", - type=Path, - action="append", - default=[], - help="role directory searched before the bundled roles (repeatable)", + tags: tuple[str, ...] = Flag( + default=(), + description="Only run plays and tasks tagged with this value (repeatable)", ) - run_parser.add_argument( - "--extra-vars", - action="append", - default=[], - help="passed through to ansible-playbook unchanged (repeatable)", + limit: str | None = Flag( + default=None, description="Restrict the run to a host pattern" ) - run_parser.add_argument( - "--tags", - action="append", - default=[], - help="only run plays and tasks tagged with this value (repeatable)", + dry_run: bool = Flag( + default=False, + description="Run in Ansible check mode: report changes, make none", ) - run_parser.add_argument("--limit", help="restrict the run to a host pattern") - run_parser.add_argument("--check", action="store_true", help="run in check mode") - run_parser.add_argument("--diff", action="store_true", help="show file diffs") - run_parser.add_argument( - "--syntax-check", - action="store_true", - help="only check the playbook syntax", + diff: bool = Flag(default=False, description="Show file diffs") + syntax_check: bool = Flag( + default=False, description="Only check the playbook syntax" ) - return parser + def __post_init__(self) -> None: + """Report a missing playbook, inventory, or role directory as input.""" + _playbook_run(self) -def _add_engine_argument(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--engine", - type=Path, - default=default_engine_directory(), - help="engine directory containing ansible contracts (default: bundled engine)", - ) - -def _render_inventory(arguments: argparse.Namespace) -> int: +def _playbook_run(args: RunArgs) -> PlaybookRun: try: - state = validate_config( - Path(arguments.project_directory), Path(arguments.schemas) + return PlaybookRun( + playbook=resolve_playbook(args.playbook, args.engine), + inventory_file=args.inventory, + role_directories=args.roles, + extra_vars=args.extra_vars, + tags=args.tags, + limit=args.limit, + check=args.dry_run, + diff=args.diff, + syntax_check=args.syntax_check, ) - except ConfigValidationError as error: - sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n") - return 2 - - inventory = PlatformInventory.from_state(state) - rendered = render_ansible_inventory(inventory) - serialized = f"{json.dumps(rendered, sort_keys=True)}\n" - if arguments.output is None: - sys.stdout.write(serialized) - else: - output_path = Path(arguments.output) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(serialized, encoding="utf-8") - result = {"output": str(output_path), "status": "ok"} - sys.stdout.write(f"{json.dumps(result, sort_keys=True)}\n") - return 0 - - -def _build_artifact(arguments: argparse.Namespace) -> int: - try: - state = validate_config( - Path(arguments.project_directory), Path(arguments.schemas) - ) - except ConfigValidationError as error: - sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n") - return 2 + except PlaybookError as error: + raise ParseError(error.detail, context={"code": error.code}) from error - inventory = PlatformInventory.from_state(state) - try: - built = build_artifact( - inventory, - arguments.component, - arguments.ref, - Path(arguments.output_dir), - Path(arguments.schemas), - ) - except ArtifactBuildError as error: - sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n") - return 2 - except ConfigValidationError as error: - sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n") - return 2 - sys.stdout.write(f"{json.dumps(built.as_dict(), sort_keys=True)}\n") - return 0 +@dataclass(frozen=True, slots=True) +class Played: + """A playbook run that ansible-playbook finished with exit 0.""" -def _list_playbooks(arguments: argparse.Namespace) -> int: - try: - names = bundled_playbooks(Path(arguments.engine)) - except PlaybookError as error: - sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n") - return 2 - result = {"engine": str(arguments.engine), "playbooks": list(names)} - sys.stdout.write(f"{json.dumps(result, sort_keys=True)}\n") - return 0 + effect: str + playbook: Path + inventory: Path -def _run_playbook(arguments: argparse.Namespace) -> int: - engine_directory = Path(arguments.engine) +@playbook.command( + "run", + description=( + "Run one bundled or project playbook against the inventory; " + "the Ansible log streams to stderr" + ), + danger_level="mutating", + exit_codes=["PRECONDITION", "PLAYBOOK_FAILED"], + timeout=None, + supports_raw_payload=True, + examples=[ + ( + "Collect read-only server snapshots", + "cloudfall-engine playbook run inspect " + "--inventory tmp/ansible-inventory.json", + ), + ( + "Preview a converge without changing hosts", + "cloudfall-engine playbook run time --dry-run --diff", + ), + ], +) +def run_playbook(args: RunArgs, _ctx: Ctx) -> Played: + """Run ansible-playbook in the foreground and report how it ended.""" + run = _playbook_run(args) try: - run = PlaybookRun( - playbook=resolve_playbook(arguments.playbook, engine_directory), - inventory_file=Path(arguments.inventory), - role_directories=tuple(Path(role) for role in arguments.roles), - extra_vars=tuple(arguments.extra_vars), - tags=tuple(arguments.tags), - limit=arguments.limit, - check=arguments.check, - diff=arguments.diff, - syntax_check=arguments.syntax_check, - ) - return execute_playbook(run, engine_directory) + returncode = execute_playbook(run, args.engine) except PlaybookError as error: - sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n") - return 2 - - -def main(argv: Sequence[str] | None = None) -> int: - """Run one engine command and return a process exit code.""" - # The shared parser reports usage errors through cloudfall's writer, - # which stamps each document with its invocation. - begin_invocation() - arguments = parse_arguments(_parser(), argv) - if arguments.command == "playbook": - if arguments.playbook_command == "list": - return _list_playbooks(arguments) - return _run_playbook(arguments) - try: - with project_context(arguments.project, os.environ) as project_directory: - arguments.project_directory = project_directory - if arguments.command == "inventory": - return _render_inventory(arguments) - if arguments.command == "artifact": - return _build_artifact(arguments) - except ProjectError as error: - sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n") - return 2 - message = "argparse accepted an unsupported engine command" - raise RuntimeError(message) + raise Exit.PRECONDITION( + error.detail, context={"code": error.code} + ) from error + if returncode != 0: + message = f"the playbook failed: ansible-playbook exited {returncode}" + raise Exit.PLAYBOOK_FAILED( + message, + context={"returncode": returncode, "playbook": str(run.playbook)}, + ) + return Played(_effect(args), run.playbook, run.inventory_file) + + +def _effect(args: RunArgs) -> str: + if args.syntax_check: + return "noop" + # Ansible reports what changed only in its log, so a live run claims the + # converge rather than counting changed tasks. + return "would_update" if args.dry_run else "updated" + + +def main(argv: Sequence[str]) -> int: + """Run one engine command in-process and return its exit code.""" + return app.run(argv) def run() -> None: """Installed engine console-script entry point.""" - raise SystemExit(main()) + app.main() diff --git a/engine/src/cloudfall_engine/playbook.py b/engine/src/cloudfall_engine/playbook.py index b41997a..354a477 100644 --- a/engine/src/cloudfall_engine/playbook.py +++ b/engine/src/cloudfall_engine/playbook.py @@ -11,6 +11,7 @@ import os import shutil import subprocess +import sys from dataclasses import dataclass from pathlib import Path @@ -167,9 +168,12 @@ def playbook_command(run: PlaybookRun, engine_directory: Path) -> PlaybookComman 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_ansible_inventory.py b/engine/tests/test_ansible_inventory.py index cbc053a..2d80c9f 100644 --- a/engine/tests/test_ansible_inventory.py +++ b/engine/tests/test_ansible_inventory.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import shutil from pathlib import Path from typing import TYPE_CHECKING @@ -292,18 +293,19 @@ def test_engine_cli_emits_ansible_json( ] ) - captured = capsys.readouterr() - payload = json.loads(captured.out) + document = json.loads(capsys.readouterr().out) assert exit_code == 0 + assert document["ok"] is True + assert document["data"]["output"] is None + inventory = document["data"]["inventory"] assert ( - payload["all"]["children"]["cloudfall_servers"]["hosts"]["h1"]["ansible_user"] + inventory["all"]["children"]["cloudfall_servers"]["hosts"]["h1"]["ansible_user"] == "cloudfall" ) assert ( "component_crm_backend" - in payload["all"]["children"]["cloudfall_components"]["children"] + in inventory["all"]["children"]["cloudfall_components"]["children"] ) - assert captured.err == "" def test_engine_cli_can_write_inventory_file( @@ -324,15 +326,49 @@ def test_engine_cli_can_write_inventory_file( ] ) - captured = capsys.readouterr() + document = json.loads(capsys.readouterr().out) assert exit_code == 0 - assert json.loads(captured.out) == { - "output": str(output_path), - "status": "ok", - } + assert document["data"]["output"] == str(output_path) written = json.loads(output_path.read_text(encoding="utf-8")) + assert written == document["data"]["inventory"] assert sorted(written["all"]["children"]["cloudfall_servers"]["hosts"]) == [ "h1", "h2", ] - assert captured.err == "" + + +def test_engine_cli_writes_a_relative_output_inside_the_project( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + exit_code = main( + [ + "inventory", + "render", + "--project", + str(project), + "--schemas", + str(SCHEMAS), + "--output", + "tmp/inventory.json", + ] + ) + + document = json.loads(capsys.readouterr().out) + assert exit_code == 0 + assert document["data"]["output"] == str(project / "tmp" / "inventory.json") + assert (project / "tmp" / "inventory.json").is_file() + + +def test_engine_cli_names_a_missing_project( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + exit_code = main(["inventory", "render", "--project", str(tmp_path / "gone")]) + + error = json.loads(capsys.readouterr().out)["error"] + assert exit_code == 79 + assert error["code"] == "PROJECT_INVALID" + assert error["context"]["code"] == "project_directory_missing" diff --git a/engine/tests/test_playbook.py b/engine/tests/test_playbook.py index 51887f4..6e52e3c 100644 --- a/engine/tests/test_playbook.py +++ b/engine/tests/test_playbook.py @@ -4,12 +4,13 @@ import json from pathlib import Path +from typing import Any, cast import pytest from cloudfall.inventory import PlatformInventory from cloudfall.validation import validate_config from cloudfall_engine.ansible_inventory import render_ansible_inventory -from cloudfall_engine.cli import main +from cloudfall_engine.cli import app, main from cloudfall_engine.playbook import ( PlaybookError, PlaybookRun, @@ -115,12 +116,14 @@ def test_command_carries_engine_config_and_role_path(tmp_path: Path) -> None: def test_cli_lists_bundled_playbooks(capsys: pytest.CaptureFixture[str]) -> None: assert main(["playbook", "list", "--engine", str(ENGINE)]) == 0 - payload = json.loads(capsys.readouterr().out) - assert payload["engine"] == str(ENGINE) - assert "inspect" in payload["playbooks"] + data = json.loads(capsys.readouterr().out)["data"] + assert data["engine"] == str(ENGINE) + assert "inspect" in data["playbooks"] -def test_cli_syntax_checks_a_bundled_playbook(inventory_file: Path) -> None: +def test_cli_syntax_checks_a_bundled_playbook( + inventory_file: Path, capsys: pytest.CaptureFixture[str] +) -> None: exit_code = main( [ "playbook", @@ -133,10 +136,12 @@ def test_cli_syntax_checks_a_bundled_playbook(inventory_file: Path) -> None: "--syntax-check", ] ) + assert exit_code == 0 + assert json.loads(capsys.readouterr().out)["data"]["effect"] == "noop" -def test_cli_reports_unknown_playbook( +def test_cli_reports_unknown_playbook_before_running( inventory_file: Path, capsys: pytest.CaptureFixture[str] ) -> None: exit_code = main( @@ -150,44 +155,55 @@ def test_cli_reports_unknown_playbook( str(inventory_file), ] ) + + error = json.loads(capsys.readouterr().out)["error"] assert exit_code == 2 - payload = json.loads(capsys.readouterr().err) - assert payload["error"]["code"] == "playbook_missing" + assert error["phase"] == "validation" + assert error["context"]["code"] == "playbook_missing" def test_a_usage_error_is_a_json_document( capsys: pytest.CaptureFixture[str], ) -> None: - with pytest.raises(SystemExit) as exit_info: - main(["playbook", "list", "--bogus"]) + exit_code = main(["playbook", "list", "--bogus"]) captured = capsys.readouterr() - assert exit_info.value.code == 2 - assert captured.out == "" - assert json.loads(captured.err)["error"]["code"] == "invalid_argument" - - -def test_quiet_is_an_unknown_option_the_engine_names( - capsys: pytest.CaptureFixture[str], -) -> None: - """The engine has no --quiet, so it must say so instead of going silent.""" - with pytest.raises(SystemExit) as exit_info: - main(["playbook", "list", "--quiet"]) - - captured = capsys.readouterr() - assert exit_info.value.code == 2 - assert captured.out == "" - error = json.loads(captured.err)["error"] - assert error["code"] == "invalid_argument" - assert "--quiet" in error["message"] + assert exit_code == 2 + assert json.loads(captured.out)["error"]["code"] == "ARG_ERROR" -def test_engine_help_carries_no_cloudfall_exit_codes( - capsys: pytest.CaptureFixture[str], +def test_check_mode_is_the_dry_run_flag( + inventory_file: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """The table describes cloudfall; the engine does not claim it.""" - with pytest.raises(SystemExit) as exit_info: - main(["--help"]) + """Ansible's --check is spelled --dry-run, like every treaty command.""" + exit_code = main( + [ + "playbook", + "run", + "inspect", + "--engine", + str(ENGINE), + "--inventory", + str(inventory_file), + "--check", + ] + ) - assert exit_info.value.code == 0 - assert "exit codes:" not in capsys.readouterr().err + assert exit_code == 2 + assert json.loads(capsys.readouterr().out)["error"]["code"] == "ARG_ERROR" + + +def test_every_engine_failure_code_is_declared() -> None: + commands = cast("dict[str, dict[str, Any]]", app.manifest()["commands"]) + declared = { + int(number): entry["name"] + for command in commands.values() + for number, entry in command["exit_codes"].items() + } + + assert {declared[n] for n in (79, 80, 81, 82)} == { + "PROJECT_INVALID", + "CONFIG_INVALID", + "ARTIFACT_BUILD_FAILED", + "PLAYBOOK_FAILED", + } diff --git a/pyproject.toml b/pyproject.toml index bc9cd1c..a23e18d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "ansible-core>=2.21,<2.22", "jsonschema[format]>=4.23,<5", "pyyaml>=6,<7", + "treaty==1.0.0rc4", ] [project.urls] @@ -81,6 +82,10 @@ ignore = [ "D213", ] +# treaty's Arg, Flag, and Out are dataclass field specifiers, like field(). +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ["treaty.Arg", "treaty.Flag", "treaty.Out"] + [tool.ruff.lint.per-file-ignores] "**/tests/**" = ["D103", "INP001", "PLR2004", "S101"] diff --git a/sdk/src/cloudfall/project.py b/sdk/src/cloudfall/project.py index 2fa9b9e..98761e0 100644 --- a/sdk/src/cloudfall/project.py +++ b/sdk/src/cloudfall/project.py @@ -695,6 +695,11 @@ def _agent_contract(options: InitOptions) -> str: |---|---| {exit_rows} +`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 +snake_case code in `error.context.code`). + ## Evidence `{tmp}/` holds everything derived or produced: observations, receipts, diff --git a/sdk/tests/test_commands.py b/sdk/tests/test_commands.py index 63b6067..b036f9d 100644 --- a/sdk/tests/test_commands.py +++ b/sdk/tests/test_commands.py @@ -1,4 +1,4 @@ -"""The command catalog matches the argparse trees it classifies.""" +"""The command catalog matches the command trees it classifies.""" from __future__ import annotations @@ -12,7 +12,7 @@ CommandEffect, commands_with_effect, ) -from cloudfall_engine.cli import _parser as engine_parser +from cloudfall_engine.cli import app as engine_app def _leaves(parser: argparse.ArgumentParser, prefix: str = "") -> dict[str, set[str]]: @@ -47,7 +47,12 @@ def test_catalog_names_every_cli_leaf_command_once() -> None: def test_catalog_names_every_engine_leaf_command_once() -> None: cataloged = [command.name for command in ENGINE_COMMANDS] - assert sorted(cataloged) == sorted(_leaves(engine_parser())) + engine_leaves = [ + str(path).replace(".", " ") + for path in engine_app.commands + if path not in engine_app.builtins + ] + assert sorted(cataloged) == sorted(engine_leaves) assert len(cataloged) == len(set(cataloged)) assert all(command.program == "cloudfall-engine" for command in ENGINE_COMMANDS) diff --git a/uv.lock b/uv.lock index bace0ab..17b5f5b 100644 --- a/uv.lock +++ b/uv.lock @@ -260,6 +260,7 @@ dependencies = [ { name = "ansible-core" }, { name = "jsonschema", extra = ["format"] }, { name = "pyyaml" }, + { name = "treaty" }, ] [package.optional-dependencies] @@ -284,6 +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" }, ] provides-extras = ["mcp"] @@ -1081,6 +1083,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/ab/e3a3be062cd544b2803760ff707dee38f0b1cb5685b2446de0ec19be28d9/subprocess_tee-0.4.2-py3-none-any.whl", hash = "sha256:21942e976715af4a19a526918adb03a8a27a8edab959f2d075b777e3d78f532d", size = 5249, upload-time = "2024-06-17T19:51:15.949Z" }, ] +[[package]] +name = "treaty" +version = "1.0.0rc4" +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" } +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" }, +] + [[package]] name = "truststore" version = "0.10.4" From bdc2ac44c1a716aebb0499deeb547d6876077afd Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 30 Sep 2026 10:29:43 +0300 Subject: [PATCH 2/2] test: type the lifecycle helper's parsed JSON as Any _cli_lifecycle returned dict[str, object], so every payload["data"][...] in its callers failed mypy with "object is not indexable". Parsed JSON is Any-shaped; typing it so makes CI's type-check pass again, which has failed on main since these tests were written. Co-Authored-By: Claude Opus 5.5 --- sdk/tests/test_lifecycle.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/tests/test_lifecycle.py b/sdk/tests/test_lifecycle.py index 05d10c8..2382763 100644 --- a/sdk/tests/test_lifecycle.py +++ b/sdk/tests/test_lifecycle.py @@ -6,6 +6,7 @@ import json import tarfile from pathlib import Path +from typing import Any import pytest from cloudfall.cli import main @@ -212,7 +213,7 @@ def test_cli_deploy_reports_a_missing_artifact( def _cli_lifecycle( argv: list[str], tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> tuple[int, dict[str, object]]: +) -> tuple[int, dict[str, Any]]: exit_code = main( [ *argv,