diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c76c91..4514e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,11 +84,13 @@ Notable changes to Cloudfall. The format follows `observe`, `secrets render`, `import render`, `import render-api`, `deploy`, `rollback`, `restart` and `data migrate` (treaty #183 lets their camelCase keys through). Each keeps its `data` keys, `status` - included, and adds `effect`. `deploy`, `rollback`, `restart` and - `data migrate` still change nothing without `--yes`; their plan answers - `effect: noop` and writes the result keys as `null`, and the result + included; the ones that write add `effect`. `deploy`, `rollback`, + `restart` and `data migrate` still change nothing without `--yes`: that + run is treaty's dry run (`effect: would_update`, `meta.dry_run: true`, + treaty #197). The plan writes the result keys as `null`, and the result writes the plan keys (`wouldRun`, `instruction`) as `null`, since treaty - answers one object per command. `observe` exits 91 `INCOMPLETE` (was 1) + answers one object per command. `observe` is read-only (it declares the + snapshots it writes under the project) and exits 91 `INCOMPLETE` (was 1) with the run in `data` when a server gave no snapshot. Engine steps, sops and the Render API go through treaty: `doctor` checks sops, and `import render-api` honours `--proxy` and marks its answer as content @@ -109,8 +111,9 @@ Notable changes to Cloudfall. The format follows `operator approve` and `migrate`. Ansible runs through treaty in the project directory, so `--timeout` and Ctrl-C stop it, and `doctor` checks ansible-playbook. `operations approve` and `migrate` still change nothing - without `--yes`; that preview answers `effect: noop` with - `data.status` `pending` or `plan`. New exit codes, each with the result + without `--yes`; that preview is treaty's dry run, `effect: would_update` + and `meta.dry_run: true`, with `data.status` `pending` or `plan`. New exit + codes, each with the result in `data`: 87 `ENGINE_STEP_FAILED` (an inventory render or playbook failed, was 1), 88 `UNHEALTHY` (was 1), 89 `CHECK_FAILED` when check mode fails in `propose` (was 1), 90 `NOT_VERIFIED` when an approved run fails @@ -122,9 +125,10 @@ Notable changes to Cloudfall. The format follows - **Breaking:** six more `cloudfall` commands run on treaty: `init`, `add ssh-key`, `add server-type`, `add server`, `dashboard build` and `services inspect`. Their answer is the treaty envelope described below, - with the same `data` keys plus `effect` (`created`, or `updated` for a - dashboard written over an older one), which treaty requires of every - command that writes. Failures: 6 `CONFLICT` for a resource that already + with the same `data` keys, plus `effect: created` on `init` and `add`, + which treaty requires of a command that changes the project. + `dashboard build` and `services inspect` stay read-only and declare the + reports they regenerate under `tmp/`. Failures: 6 `CONFLICT` for a resource that already exists (was 2), 7 `PERMISSION_DENIED` for a read-only project or path (was 1 or 2), 4 `PRECONDITION` for a non-empty `init` directory (was 2), 5 `NOT_FOUND` for a missing key file, 2 `ARG_ERROR` for a bad id, name or @@ -151,7 +155,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.0rc12), so `--timeout` and Ctrl-C stop the whole process + (treaty 1.0.0rc13), 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 b894dc4..75f0287 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.0rc12", + "treaty==1.0.0rc13", ] [project.urls] diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 330a462..4e10aad 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -35,6 +35,7 @@ Flag, Out, ParseError, + SideEffect, Subprocess, Timeout, ) @@ -89,6 +90,7 @@ LinuxUser, ReleaseId, ResourceId, + ResourceKind, TcpPort, ) from cloudfall.importer import ( @@ -207,6 +209,8 @@ ) # 79 and 80 mean what they mean on cloudfall-engine; 81 and 82 stay the # engine's (artifact build, playbook run) for the commands that wrap it. +PROJECT_MARKERS = tuple(kind.directory for kind in ResourceKind) +"""A project holds at least one resource kind directory, such as ``servers``.""" GIT_TIMEOUT_SECONDS = 30 """How long one git step of ``init`` may take.""" _NOT_WRITABLE = frozenset({errno.EACCES, errno.EPERM, errno.EROFS}) @@ -1275,23 +1279,20 @@ def __post_init__(self) -> None: _inside_project(self.output_dir, "output-dir") -@dataclass(frozen=True, slots=True) -class DashboardBuilt: - """The static dashboard written, and the fleet health it shows.""" +class DashboardPayload(Payload): + """``dashboard build``: the pages written, and the fleet health they show.""" - effect: str - status: str - health: str - tasks: int - dashboard: dict[str, str] + command = "dashboard build" @dashboard.command( "build", description="Build a static read-only operations dashboard", - danger_level="mutating", + danger_level="safe", timeout=120, supports_raw_payload=True, + project_root=PROJECT_MARKERS, + filesystem_side_effects=[SideEffect("{project_root}/tmp/dashboard/", "cache")], exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "PERMISSION_DENIED"], examples=[ ( @@ -1302,23 +1303,22 @@ class DashboardBuilt: ) def dashboard_build( args: DashboardBuildArgs, _ctx: Ctx, fleet: Fleet -) -> DashboardBuilt: +) -> DashboardPayload: """Write the dashboard pages for the fleet's current evidence.""" view = args.operations_view(fleet) - output = fleet.path(args.output_dir) - effect = "updated" if output.exists() else "created" try: - artifacts = build_dashboard(view, output) + artifacts = build_dashboard(view, fleet.path(args.output_dir)) except OSError as error: if error.errno not in _NOT_WRITABLE: raise raise _not_writable(error) from error - return DashboardBuilt( - effect=effect, - status="ok", - health=view.health.value, - tasks=len(view.tasks), - dashboard=artifacts.as_dict(), + return DashboardPayload( + { + "status": "ok", + "health": view.health.value, + "tasks": len(view.tasks), + "dashboard": artifacts.as_dict(), + } ) @@ -1336,27 +1336,28 @@ def __post_init__(self) -> None: _inside_project(self.output_dir, "output-dir") -@dataclass(frozen=True, slots=True) -class ServicesInspected: - """The domain observation files written.""" +class InspectedPayload(Payload): + """``services inspect``: the domain observation files written.""" - effect: str - status: str - observations: list[str] = Out(ordered=True) + command = "services inspect" @services.command( "inspect", description="Collect DNS, TLS, origin, and public route evidence", - danger_level="mutating", + danger_level="safe", # DNS, TLS, and HTTP probes of every declared domain, one after another. timeout=600, + project_root=PROJECT_MARKERS, + filesystem_side_effects=[ + SideEffect("{project_root}/tmp/observed-services/", "cache") + ], exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "PERMISSION_DENIED"], examples=[("Probe every declared domain", "cloudfall services inspect")], ) def services_inspect( args: ServicesInspectArgs, _ctx: Ctx, fleet: Fleet -) -> ServicesInspected: +) -> InspectedPayload: """Probe each declared domain and write one observation per domain.""" try: paths = inspect_domains( @@ -1369,10 +1370,8 @@ def services_inspect( if error.errno not in _NOT_WRITABLE: raise raise _not_writable(error) from error - return ServicesInspected( - effect="created", - status="ok", - observations=[str(path) for path in paths], + return InspectedPayload( + {"status": "ok", "observations": [str(path) for path in paths]} ) @@ -1699,6 +1698,7 @@ class ApproveDecisionArgs(DecisionsArgs): ) yes: bool = Flag( default=False, + confirm=True, description=( "Change the servers; without it the command shows the recorded " "proposal and runs nothing" @@ -1805,10 +1805,9 @@ def operations_approve(args: ApproveDecisionArgs, ctx: Ctx) -> Decided: raise Exit.NOT_FOUND(error.detail, context={"code": error.code}) from error raise _record_invalid(error) from error if not args.yes: - # Nothing ran. treaty reads would_* only from a dry run it switched, - # and --yes is the inverse of its --dry-run (treaty #197). + # Without --yes the run is treaty's dry run (Flag(confirm=True)). return Decided( - effect="noop", + effect="would_update", status="pending", decision=decision.as_document(), next=[ @@ -2030,6 +2029,7 @@ class MigrateArgs(EngineArgs): ) yes: bool = Flag( default=False, + confirm=True, description=( "Change the servers; without it the command prints the plan and " "runs nothing" @@ -2150,8 +2150,8 @@ def migrate(args: MigrateArgs, ctx: Ctx, fleet: Fleet) -> Migration: ) from error status = str(result["status"]) migration = Migration( - # A plan changes nothing: noop, as without --yes in `operations approve`. - effect="noop" if status == "plan" else "updated", + # A plan is the dry run --yes confirms. + effect="would_update" if status == "plan" else "updated", status=status, steps=list(cast("list[dict[str, object]]", result["steps"])), completed=int(cast("int", result["completed"])), @@ -2259,7 +2259,7 @@ def why(args: WhyArgs, _ctx: Ctx) -> WhyPayload: "Some servers produced no snapshot or Ansible failed; data says which" ), retryable=True, - # The hosts are only read; a retry rewrites the snapshots it wrote. + # observe only reads the hosts; a retry rewrites the snapshots it wrote. side_effects="none", suggestion="read data.missing and data.detail, fix the hosts, then observe again", ) @@ -2290,7 +2290,6 @@ class ObservedPayload(Payload): """``observe``: which servers produced a snapshot.""" command = "observe" - writes = True def _playbook_runner( @@ -2311,7 +2310,14 @@ def run(argv: Sequence[str], environment: Mapping[str, str]) -> PlaybookRun: "Collect read-only server snapshots from the fleet; servers without one " "exit non-zero with the run in data" ), - danger_level="mutating", + # It only reads the hosts; its snapshots and the inventory overlay are + # regenerated under the project on every run. + danger_level="safe", + project_root=PROJECT_MARKERS, + filesystem_side_effects=[ + SideEffect("{project_root}/tmp/observed/", "cache"), + SideEffect("{project_root}/tmp/cloudfall/", "cache"), + ], exit_codes=[ "PROJECT_INVALID", "CONFIG_INVALID", @@ -2334,7 +2340,6 @@ def observe(args: ObserveArgs, ctx: Ctx, fleet: Fleet) -> ObservedPayload: else (fleet.directory / "tmp/ansible-inventory.json",) ) output = fleet.path(args.output_dir) - effect = "updated" if output.exists() else "created" try: request = ObservationRequest( inventory_sources=sources, @@ -2353,7 +2358,7 @@ def observe(args: ObserveArgs, ctx: Ctx, fleet: Fleet) -> ObservedPayload: ) except ObserveError as error: raise Exit.PRECONDITION(error.detail, context={"code": error.code}) from error - payload = ObservedPayload({**result.as_dict(), "effect": effect}) + payload = ObservedPayload(result.as_dict()) if not result.complete: message = f"{len(result.missing)} server(s) produced no snapshot" raise Exit.INCOMPLETE(message, data=payload) @@ -2657,6 +2662,7 @@ class ComponentArgs(EngineArgs): component: ResourceId = Arg(description="Component to act on") yes: bool = Flag( default=False, + confirm=True, description=( "Change the servers; without it the command validates the request " "and only shows what it would do" @@ -2718,6 +2724,7 @@ class DataMigrateArgs(EngineArgs): ) yes: bool = Flag( default=False, + confirm=True, description=( "Change the servers; without it the command validates the request " "and only shows what it would do" @@ -2759,9 +2766,12 @@ class DataMigratePayload(Payload): def _plan(preview: LifecyclePreview) -> dict[str, object]: - # Nothing ran: noop, as treaty reads would_* only from its own dry run - # (treaty #197). - return {**preview.as_dict(), "instruction": _PLAN_INSTRUCTION, "effect": "noop"} + # Without --yes the run is treaty's dry run (Flag(confirm=True)). + return { + **preview.as_dict(), + "instruction": _PLAN_INSTRUCTION, + "effect": "would_update", + } _LIFECYCLE_EXIT_CODES = [ diff --git a/sdk/tests/test_decision.py b/sdk/tests/test_decision.py index 6efa6ab..512f138 100644 --- a/sdk/tests/test_decision.py +++ b/sdk/tests/test_decision.py @@ -626,7 +626,8 @@ def test_the_cli_shows_the_proposal_before_it_runs_anything( payload = json.loads(capsys.readouterr().out) assert exit_code == 0 assert payload["data"]["status"] == "pending" - assert payload["data"]["effect"] == "noop" + assert payload["data"]["effect"] == "would_update" + assert payload["meta"]["dry_run"] is True assert payload["data"]["decision"]["spec"]["status"] == "proposed" assert store.load(proposed.decision_id).status is DecisionStatus.PROPOSED diff --git a/sdk/tests/test_lifecycle.py b/sdk/tests/test_lifecycle.py index e1b2336..b2c05af 100644 --- a/sdk/tests/test_lifecycle.py +++ b/sdk/tests/test_lifecycle.py @@ -252,7 +252,8 @@ def test_cli_server_changes_show_a_plan_without_yes( assert exit_code == 0 assert payload["data"]["status"] == "plan" - assert payload["data"]["effect"] == "noop" + assert payload["data"]["effect"] == "would_update" + assert payload["meta"]["dry_run"] is True assert payload["data"]["action"] == action assert payload["data"]["component"] == "crm-backend" assert payload["data"]["servers"] @@ -281,7 +282,8 @@ def test_cli_deploy_plan_verifies_the_artifact( assert exit_code == 0 assert payload["data"]["status"] == "plan" - assert payload["data"]["effect"] == "noop" + assert payload["data"]["effect"] == "would_update" + assert payload["meta"]["dry_run"] is True assert payload["data"]["release"] == RELEASE would_run = str(payload["data"]["wouldRun"]) assert f"release {RELEASE} of component crm-backend" in would_run @@ -323,7 +325,8 @@ def test_cli_data_migration_plan_checks_the_source_url_file( exit_code, payload = _cli_lifecycle(argv, tmp_path, capsys) assert exit_code == 0 assert payload["data"]["status"] == "plan" - assert payload["data"]["effect"] == "noop" + assert payload["data"]["effect"] == "would_update" + assert payload["meta"]["dry_run"] is True assert payload["data"]["service"] == "postgresql-main" assert "u:p@" not in json.dumps(payload) diff --git a/sdk/tests/test_migrate.py b/sdk/tests/test_migrate.py index b5170a1..8ce7d8d 100644 --- a/sdk/tests/test_migrate.py +++ b/sdk/tests/test_migrate.py @@ -222,7 +222,8 @@ def test_cli_migrate_previews_without_executing( payload = json.loads(captured.out) assert exit_code == 0 assert payload["data"]["status"] == "plan" - assert payload["data"]["effect"] == "noop" + assert payload["data"]["effect"] == "would_update" + assert payload["meta"]["dry_run"] is True assert payload["data"]["next"] == "baseline" diff --git a/uv.lock b/uv.lock index 9b77e95..ca5c1d7 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.0rc12" }, + { name = "treaty", specifier = "==1.0.0rc13" }, ] provides-extras = ["mcp"] @@ -1085,11 +1085,11 @@ wheels = [ [[package]] name = "treaty" -version = "1.0.0rc12" +version = "1.0.0rc13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/0e/a4015390a80e1325b76f1ce63ab4cb2342bc58483955da1bf961875934ec/treaty-1.0.0rc12.tar.gz", hash = "sha256:eb49549e14ed50b8a57834c593401bc0dda9236e9db995ffb11f738f7e76670f", size = 504914, upload-time = "2026-10-01T22:47:18.114Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/5d/c57f406a7d2390bd2ee7a54fc5034a382a2a1489f01e4f63ff76fc53f4a9/treaty-1.0.0rc12-py3-none-any.whl", hash = "sha256:a77fcecb6666181bb62e425dca463141fcfda769aaeab9e03a846eb83d832b19", size = 507121, upload-time = "2026-10-01T22:47:16.078Z" }, + { 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" }, ] [[package]]