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/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, 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"