diff --git a/CHANGELOG.md b/CHANGELOG.md index 4514e68..f84c6cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,9 +103,8 @@ Notable changes to Cloudfall. The format follows #179): the page is drawn from the answer's JSON document, as before. Its verdict moves to `data.status`; a bad `--since` or `--until` is 2 `ARG_ERROR` with `why_instant_invalid` in `error.context.code`, and a - record that cannot be read 86 `RECORD_INVALID`. treaty offers a custom - format on every command (#209), so `--format html` on any other treaty - command renders its `data` as a page of JSON + record that cannot be read 86 `RECORD_INVALID`. `--format html` is a + `why` format only; other commands refuse it - **Breaking:** seven commands that run Ansible move to treaty: `health`, `backup run`, `backup verify`, `operations propose`, `operations approve`, `operator approve` and `migrate`. Ansible runs through treaty in the @@ -155,7 +154,7 @@ Notable changes to Cloudfall. The format follows 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.0rc13), so `--timeout` and Ctrl-C stop the whole process + (treaty 1.0.0rc14), so `--timeout` and Ctrl-C stop the whole process group, and secrets are redacted from the play log. The play log streams to stderr as plain text, as before, terminal or not; `--quiet` silences it. A failed playbook carries the last 4096 characters of the log in diff --git a/pyproject.toml b/pyproject.toml index 75f0287..10749a7 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.0rc13", + "treaty==1.0.0rc14", ] [project.urls] diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 4e10aad..6634477 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -15,12 +15,10 @@ from __future__ import annotations import errno -import json import os import shutil from dataclasses import dataclass from functools import partial -from html import escape from http import HTTPStatus from importlib.metadata import version from pathlib import Path @@ -33,6 +31,7 @@ Excludes, Exit, Flag, + FormatRenderer, Out, ParseError, SideEffect, @@ -269,19 +268,6 @@ ) -def _html_page(data: object) -> str: - """Render any command's data as a page of its JSON, for ``--format html``. - - Only ``why`` has a page of its own; treaty offers a format on every - command once one has it (treaty #209), so the others answer this. - """ - body = escape(json.dumps(data, indent=2, sort_keys=True)) - return f'\n\n
{body}
\n' - - -app.format("html", render=_html_page, media_type="text/html") - - class Payload: """A command's flat payload, written as ``data`` under its declared keys. @@ -419,12 +405,12 @@ class Fleet: @classmethod def acquire(cls, args: ProjectArgs, _ctx: Ctx) -> Self: - """Resolve and validate the fleet before the handler runs.""" + """Resolve and validate the project before the handler runs.""" + return cls._project(args) + + @classmethod + def _project(cls, args: ProjectArgs) -> Self: 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, source) directory = resolve_project_directory( args.project, os.environ, Path.cwd() ) @@ -433,10 +419,6 @@ def acquire(cls, args: ProjectArgs, _ctx: Ctx) -> Self: 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 @@ -450,15 +432,40 @@ def inventory(self) -> PlatformInventory: return PlatformInventory.from_state(self.state) -def _inventory_source(args: ProjectArgs) -> InventorySource | None: +@dataclass(frozen=True, slots=True) +class InventoryFleet(Fleet): + """A fleet a command may also read from a team's own Ansible inventory. + + Only the commands that declare ``--inventory`` take it, so only they can + fail on an unreadable inventory. + """ + + @classmethod + # Narrower than Fleet.acquire on purpose: only --inventory commands take it. + def acquire( # type: ignore[override] + cls, args: FleetArgs, _ctx: Ctx + ) -> Self: + """Read the team's inventory when one applies, else the project.""" + try: + source = _inventory_source(args) + if source is None: + return cls._project(args) + read = read_fleet(read_inventory(source), args.schemas) + 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 + return cls(Path.cwd(), read.config, read, source) + + +def _inventory_source(args: FleetArgs) -> 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): @@ -472,11 +479,14 @@ 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}) +def _record_invalid(error: DecisionError) -> Exception: + return Exit.RECORD_INVALID(error.detail, context={"code": error.code}) + + +def _proposal_failed(error: OperatorError) -> Exception: + if error.code == ERROR_PROPOSAL_MISSING: + return Exit.NOT_FOUND(error.message, context={"code": error.code}) + return Exit.RECORD_INVALID(error.message, context={"code": error.code}) # config validate, inventory show @@ -531,7 +541,9 @@ class InventoryPayload(Payload): ), ], ) -def inventory_show(_args: FleetArgs, _ctx: Ctx, fleet: Fleet) -> InventoryPayload: +def inventory_show( + _args: FleetArgs, _ctx: Ctx, fleet: InventoryFleet +) -> InventoryPayload: """Answer the inventory the fleet declares.""" body: dict[str, object] = { "status": "ok", @@ -746,7 +758,7 @@ def operator_list(args: ProposalArgs, _ctx: Ctx, fleet: Fleet) -> ProposalsPaylo try: proposals = [proposal.as_document() for proposal in args.store(fleet).list()] except OperatorError as error: - raise _record_invalid(error) from error + raise _proposal_failed(error) from error return ProposalsPayload({"status": "ok", "proposals": proposals}) @@ -762,7 +774,7 @@ def operator_show(args: ShowProposalArgs, _ctx: Ctx, fleet: Fleet) -> ProposalPa try: proposal = args.store(fleet).load(args.proposal) except OperatorError as error: - raise _record_invalid(error) from error + raise _proposal_failed(error) from error return ProposalPayload({"status": "ok", "proposal": proposal.as_document()}) @@ -818,7 +830,7 @@ class AuditPayload(Payload): ), ], ) -def audit(args: AuditArgs, _ctx: Ctx, fleet: Fleet) -> AuditPayload: +def audit(args: AuditArgs, _ctx: Ctx, fleet: InventoryFleet) -> AuditPayload: """Answer the audit report; a negative verdict is its own exit code.""" try: observations = load_observations(fleet.path(args.observed), args.schemas) @@ -1963,7 +1975,7 @@ def operator_approve( ApproveOptions(verify_timeout_seconds=args.verify_timeout), ) except OperatorError as error: - raise _record_invalid(error) from error + raise _proposal_failed(error) from error except LifecycleError as error: raise _lifecycle_failed(error) from error verified = proposal.status is ProposalStatus.VERIFIED @@ -2230,7 +2242,7 @@ class WhyPayload(Payload): ), danger_level="safe", exit_codes=["RECORD_INVALID"], - renderers={"html": render_why_document}, + renderers={"html": FormatRenderer(render_why_document, media_type="text/html")}, examples=[ ("Ask about one host", "cloudfall why --host h1"), ("One page for a person", "cloudfall why --since 2026-10-01 --format html"), @@ -2332,7 +2344,7 @@ def run(argv: Sequence[str], environment: Mapping[str, str]) -> PlaybookRun: supports_raw_payload=True, examples=[("Snapshot every server", "cloudfall observe")], ) -def observe(args: ObserveArgs, ctx: Ctx, fleet: Fleet) -> ObservedPayload: +def observe(args: ObserveArgs, ctx: Ctx, fleet: InventoryFleet) -> ObservedPayload: """Inspect every declared server and write one snapshot each.""" sources = ( (fleet.source.value,) diff --git a/uv.lock b/uv.lock index ca5c1d7..2d0da73 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.0rc13" }, + { name = "treaty", specifier = "==1.0.0rc14" }, ] provides-extras = ["mcp"] @@ -1085,11 +1085,11 @@ wheels = [ [[package]] name = "treaty" -version = "1.0.0rc13" +version = "1.0.0rc14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/64/cbd7c0e95a1075e9d81cac543d6d856972b5eab2830775a38594a43509ad/treaty-1.0.0rc13.tar.gz", hash = "sha256:d408444ca0068fe4c5941e6642064ca00f56144bd0401b95b68a4a5975cd5335", size = 509701, upload-time = "2026-10-02T00:34:24.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/20/81f1ed3c12939a83d78651fbe82ce89903b05c9079cc7e88e4417ba17e9e/treaty-1.0.0rc14.tar.gz", hash = "sha256:26498a8dc0c163e46bdf3c1fc1fe7c144638b1798cad14742f8efde13763f49e", size = 513570, upload-time = "2026-10-02T01:44:36.77Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/45/3106c78e2dd7cb873eb0702a2ffc8dfbc9514bc5a9b07a377fae979cf1d0/treaty-1.0.0rc13-py3-none-any.whl", hash = "sha256:42e8b873294255072545e5e16803b10baf0637adc89c89378f9cdaa682c62728", size = 511505, upload-time = "2026-10-02T00:34:22.55Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c6/f377b673fde544a69305646674d6cdd234455285336a33abcce3dee3879a/treaty-1.0.0rc14-py3-none-any.whl", hash = "sha256:3178710be0b507d4d7a481db11326e2c94c29aa8c2633db1645d8e4c67a2f28e", size = 515200, upload-time = "2026-10-02T01:44:34.915Z" }, ] [[package]]