Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
92 changes: 52 additions & 40 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +31,7 @@
Excludes,
Exit,
Flag,
FormatRenderer,
Out,
ParseError,
SideEffect,
Expand Down Expand Up @@ -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'<!doctype html>\n<meta charset="utf-8">\n<pre>{body}</pre>\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.

Expand Down Expand Up @@ -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()
)
Expand All @@ -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

Expand All @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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})


Expand All @@ -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()})


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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,)
Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading