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]]