From a30bc89552759137222993d44ac38da7518fd34e Mon Sep 17 00:00:00 2001 From: Romamo Date: Sat, 3 Oct 2026 16:57:47 +0300 Subject: [PATCH 1/5] feat!: cloudfall runs entirely on treaty; the MCP server is `cloudfall mcp serve` The last argparse commands move to treaty 1.0.0rc29: - `operator run` streams one event per watch pass, with `effect` per pass (treaty #175); the loop is `operator.watch()`, testable with a fake feed - `dashboard serve` streams one event once it listens, then serves until stopped; request lines go through ctx.log - `changelog` is treaty's built-in, from a packaged schema-changelog.json - root --help, --version and --schema are treaty's; cli.py calls app.run, and arguments.py, manifest.py and output.py are gone `cloudfall-mcp` becomes `cloudfall mcp serve` (treaty #239, #240, #281, #285): the tools are the commands, chosen per fleet mode, with the fleet, schema, engine and shared evidence directories bound for the run. Engine work without a command (build_artifact, converge_*) and one tool per declared operation are provided tools. init, operations approve, operator run and dashboard serve are never MCP tools. mcp_server.py, fleet_tools.py and AgentToolset are gone; stdio end-to-end tests cover the server. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 63 +- CONTRIBUTING.md | 13 + README.md | 14 +- ROADMAP.md | 2 +- docs/landscape.md | 8 +- docs/logging-service-guide.md | 6 +- docs/neon-migration-guide.md | 2 +- docs/operator-guide.md | 28 +- docs/render-migration-guide.md | 3 +- docs/secrets-guide.md | 10 +- pyproject.toml | 3 +- sdk/README.md | 47 +- sdk/src/cloudfall/agent_server.py | 415 +++++++ sdk/src/cloudfall/agent_tools.py | 827 +------------- sdk/src/cloudfall/app.py | 670 +++++++++++- sdk/src/cloudfall/arguments.py | 306 ------ sdk/src/cloudfall/cli.py | 1461 +------------------------ sdk/src/cloudfall/commands.py | 229 +--- sdk/src/cloudfall/dashboard_server.py | 21 +- sdk/src/cloudfall/fleet_tools.py | 259 ----- sdk/src/cloudfall/manifest.py | 138 --- sdk/src/cloudfall/mcp_server.py | 961 ---------------- sdk/src/cloudfall/observe.py | 2 +- sdk/src/cloudfall/operator.py | 87 +- sdk/src/cloudfall/output.py | 412 ------- sdk/src/cloudfall/project.py | 46 +- sdk/tests/test_agent_server.py | 303 +++++ sdk/tests/test_agent_tools.py | 399 ------- sdk/tests/test_app.py | 98 +- sdk/tests/test_arguments.py | 77 +- sdk/tests/test_commands.py | 38 +- sdk/tests/test_dashboard_server.py | 13 +- sdk/tests/test_fleet_tools.py | 323 ------ sdk/tests/test_lifecycle.py | 23 + sdk/tests/test_manifest.py | 159 --- sdk/tests/test_operator.py | 115 +- sdk/tests/test_output.py | 478 -------- sdk/tests/test_project.py | 10 +- site/public/index.html | 8 +- uv.lock | 8 +- 41 files changed, 1873 insertions(+), 6214 deletions(-) create mode 100644 sdk/src/cloudfall/agent_server.py delete mode 100644 sdk/src/cloudfall/arguments.py delete mode 100644 sdk/src/cloudfall/fleet_tools.py delete mode 100644 sdk/src/cloudfall/manifest.py delete mode 100644 sdk/src/cloudfall/mcp_server.py delete mode 100644 sdk/src/cloudfall/output.py create mode 100644 sdk/tests/test_agent_server.py delete mode 100644 sdk/tests/test_agent_tools.py delete mode 100644 sdk/tests/test_fleet_tools.py delete mode 100644 sdk/tests/test_manifest.py delete mode 100644 sdk/tests/test_output.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7b050b8..a7985bd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -32,7 +32,7 @@ Human / AI agent ──runs───► CLI / MCP / Python API ──► Gate The config is a store, not a pipeline stage: humans and agents edit it directly, the CLI validates and reads it, and the engine converges servers -to it. The agent operates through a stable CLI, the `cloudfall-mcp` server +to it. The agent operates through a stable CLI, the `cloudfall mcp serve` server and a Python API instead of inventing shell commands or discovering infrastructure over SSH. Every mutating operation goes through the engine's explicit playbook contracts, waits behind a gate the agent does not control diff --git a/CHANGELOG.md b/CHANGELOG.md index cd7b100..357abfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,66 @@ Notable changes to Cloudfall. The format follows ### Changed +- **Breaking:** argparse is gone: the last three `cloudfall` commands + move to treaty, and so do root `--help`, `--version` and `--schema`. + - `operator run` streams one envelope line per pass as it ends, with + `data.pass` (`alerts`, `drift`, `autonomy`) and `data.effect`: + `created` for a pass that wrote proposals, `updated` for one that ran + licensed proposals, `noop` otherwise. Stopping it ends the stream with a + `CANCELLED` line and exit 130 (SIGINT) or 143 (SIGTERM), so a systemd + unit needs `SuccessExitStatus=143` (the + [operator guide](docs/operator-guide.md) has it). A gateway it cannot + use (none declared, or a missing or unreadable certificate) is 4 + `PRECONDITION`, an unreachable one 12 `UNAVAILABLE`. `--interval` and `--drift-interval` must be above 0. The + operating contract now lists it with the commands that change servers, + since a declared `OperatorPolicy` lets it run proposals + - `dashboard serve` streams one line once the server listens, then serves + until stopped, ending like `operator run`. Its request lines are info + log lines on stderr, shown at a terminal or with `--verbose` + - `changelog` is treaty's: it lists interface changes per release from + the packaged `schema-changelog.json`, which `treaty changelog-add + cloudfall.app:app` writes, and `--since` takes a release version + - `operator approve` reports such a gateway as 4 `PRECONDITION` (was 86 + `RECORD_INVALID`) and an unreachable one as 12 `UNAVAILABLE` + - The argparse-era output contract added earlier in this release is + gone: the six-key document with a top-level `status`, `--output json`, + `--schema-version MAJOR` against that contract, the parser-built + `--schema`, the `--help` exit-code table and exit codes 0 to 3. Every + command answers treaty's envelope, and `cloudfall --schema` lists every + command's flags, output schema and exit codes. The `AGENTS.md` that + `cloudfall init` writes describes this contract +- **Breaking:** `cloudfall-mcp` is gone; the agent server is `cloudfall mcp + serve` (treaty 1.0.0rc29, from issues #239, #240, #281 and #285 filed for + it). Its tools are Cloudfall's own commands, answering with the CLI's + envelope, and tool names follow the commands (`list_operations` is + `operations_list`, `validate_config` is `config_validate`). MCP client + configs change from `cloudfall-mcp …` to `cloudfall mcp serve …` + - The server serves one fleet, chosen as before: `--project`, or + `--repository` for a team's Ansible repository. The fleet, the schema + and engine directories, and the evidence directories commands share + (snapshots, deployments, artifacts, proposals, secrets) are fixed for + the run: they leave the tool schemas, and a call that passes one is + refused, so no call reaches another fleet or engine. A command's own + output paths, such as `receipts`, stay arguments + - On a project, `deploy`, `rollback`, `restart`, `migrate` and + `data_migrate` return their plan until called with `yes: true`, as on + the CLI; the old `confirm=true` handshake is gone. `build_artifact` and + the `converge_baseline`, `converge_services` and `converge_domains` + tools stay, the converges destructive and run only with + `confirm_destructive: true`. `operator_approve`, `backup_run` and + `backup_verify` run in one call, as their commands do (they were + confirm-gated in `cloudfall-mcp`), and `operator_watch` is gone: the + watch loop runs as a service + - On a repository, the tools are the read-only fleet commands and one + `operation_*` tool per declared operation, which runs check mode, + records a proposal and answers with the approval command in + `data.next`; a destructive operation's tool asks for + `confirm_destructive` even to preview + - `init`, `operations approve`, `operator run` and `dashboard serve` are + never MCP tools (`mcp=False`), on this server or `treaty-mcp` + - A fleet that cannot be read stops the server before a client connects, + with the envelope on stderr: 79 `PROJECT_INVALID`, 80 `CONFIG_INVALID`, + 85 `INVENTORY_UNREADABLE`. `--list-tools` prints the tool list - **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 @@ -97,8 +157,7 @@ Notable changes to Cloudfall. The format follows from outside. Failures that were exit 2 are 4 `PRECONDITION` with the old code in `error.context.code`. A key a command writes only sometimes is `null` when absent, so `inventory show` writes `data.ansible` as - `null` for a project. Still on argparse: `changelog`, `operator run` - (treaty #175) and `dashboard serve` + `null` for a project - **Breaking:** `why` runs on treaty, `--format html` included (treaty #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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfdcbc3..4cc6bbb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,19 @@ uv run ansible-lint engine/ansible Keep changes scoped to one concern. Include tests for behavior changes and update the relevant README when a workflow changes. +## Releases + +After bumping `version` in `pyproject.toml`, record the release's interface +changes, which `cloudfall changelog` serves: + +```console +uv run treaty changelog-add cloudfall.app:app +``` + +It diffs the manifest against the snapshot beside +`sdk/src/cloudfall/schema-changelog.json` and appends one entry; commit both +files with the release. + ## Licensing of contributions Cloudfall is dual-licensed: AGPL-3.0-or-later for everyone, with commercial diff --git a/README.md b/README.md index f5708c6..3b5671b 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ That design is written up and not yet built; see > with a single human approval and then autonomously under declared policy, > a timer-driven drill proved a real backup restorable, and an alert crossed > the public network to a second machine. The entire migration was driven by -> an AI agent through `cloudfall-mcp` alone. Full reports: +> an AI agent through the MCP server alone. Full reports: > [proving-runs](docs/proving-runs/). The one layer not yet exercised live > is bare-metal RAID/storage provisioning (see the [roadmap](ROADMAP.md)) @@ -52,8 +52,9 @@ That design is written up and not yet built; see can't be undone - **AI-agent native.** Agents operate through a stable CLI and Python API with structured JSON results instead of inventing shell commands over SSH. - The `cloudfall-mcp` server exposes read-only evidence tools freely and - gates every server-changing tool behind an explicit confirmation handshake + `cloudfall mcp serve` exposes read-only evidence tools freely; a deploy, + rollback, restart or migration only returns its plan until called again + with `yes`, and approving a recorded operation stays a person's command - **The bill buys computers, not margins.** A steady $1,000/mo cloud bill is mostly margin on commodity compute and managed databases. On servers you own, that difference becomes runway, every year @@ -111,7 +112,7 @@ live on disposable Debian targets: evidence-derived operations dashboard - Render blueprint and live-API importers, guided data migration, and the resumable `cloudfall migrate` orchestrator with explicit cutover steps -- The `cloudfall-mcp` agent server and the always-on operator with receipted +- The `cloudfall mcp serve` agent server and the always-on operator with receipted proposals and policy-bounded autonomy The one implemented layer not yet validated live is bare-metal RAID/storage @@ -238,7 +239,7 @@ before the bundled roles. To move a project to a newer Cloudfall commit, bump Humans and AI agents both edit the resources and both run the CLI, with a human approving anything that changes a server. `cloudfall init` writes that arrangement down as the project's `AGENTS.md`, the operating contract -an agent started in the directory (Claude Code, Codex, or `cloudfall-mcp`) +an agent started in the directory (Claude Code, Codex, or `cloudfall mcp serve`) reads first: the canonical `uv run cloudfall` invocation and project resolution, every command sorted by effect (changes nothing on servers, writes project files, changes servers) with the gate each server-changing @@ -351,7 +352,8 @@ and public routes on every refresh, so service health is live. Server hardware evidence still comes from observation snapshots: schedule `task inspect` (cron or a timer) on the management host to keep it fresh. The server binds `127.0.0.1:8100` by default; set `DASHBOARD_HOST`, -`DASHBOARD_PORT`, and `DASHBOARD_REFRESH` to override. The projection stays +`DASHBOARD_PORT`, and `DASHBOARD_REFRESH` to override. Once it listens it +prints one JSON line with its URL, then serves until stopped. The projection stays strictly read-only either way. ## Always-on operator diff --git a/ROADMAP.md b/ROADMAP.md index 061d7e9..b329c0a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -354,7 +354,7 @@ against a Cloudfall project first and re-rooted in M16. row-count checks are re-expressed as verify steps - **`cloudfall why`** — the question "why did the agent do that" answered from the record for any host, operation or time window, in JSON and as a - page ✔ (also the `why` tool on `cloudfall-mcp --repository`) + page ✔ (also the `why` tool on `cloudfall mcp serve --repository`) Exit: an agent drives a declared mutating operation through the MCP server on a proving host, the client gates it from the annotation alone, the run diff --git a/docs/landscape.md b/docs/landscape.md index ee7adb3..a754d41 100644 --- a/docs/landscape.md +++ b/docs/landscape.md @@ -68,7 +68,7 @@ and treats the PaaS column as the place workloads come from. | PagerDuty, Opsgenie | Operations | Replaces at this scale | The operator remediates what its receipts prove reversible and pages you only for what it cannot undo | | Devopness | Operations SaaS | Competes | The closest operational twin: closed SaaS, imperative dashboard; Cloudfall is AGPL with typed state the user owns | | AI SRE agents (generic) | Operations | Alternative | Agents bolted onto imperative tools; their log says a tool was called. Cloudfall keeps the record of what the fleet looked like, what was proposed, who approved and what verify reported, and grants autonomy per operation class from it | -| Claude Code, Codex, any MCP client | Agent surface | Complements | The brain. They drive Cloudfall through `cloudfall-mcp`, gate on its tool annotations with their own permission modes, and leave the record to Cloudfall; read-only tools free, mutating tools behind a confirmation handshake | +| Claude Code, Codex, any MCP client | Agent surface | Complements | The brain. They drive Cloudfall through `cloudfall mcp serve`, gate on its tool annotations with their own permission modes, and leave the record to Cloudfall; read-only tools free, server changes previewed until confirmed | ## What Cloudfall adds and what it replaces @@ -134,8 +134,8 @@ with Cloudfall on running and reacting, not on knowing what to run: | `cloudfall operator approve` | Approval nodes in Automation Controller workflows | | Timer-driven jobs such as restore drills | Scheduled job templates | | Receipts | Job history and logs: a record that a job ran, not proof of its result | -| Agent surface through `cloudfall-mcp` | The AAP MCP server (jobs, inventory queries, monitoring through the controller API; a read-only mode, and a read-write mode that launches jobs; technology preview in AAP 2.6.4); the Lightspeed coding assistant and the ADT MCP server help write playbooks | -| `cloudfall-mcp` as the agent's view of the fleet | The Lightspeed intelligent assistant, a chat in the controller UI; it answers from Red Hat docs and, as a technology preview, can query live AAP data through the AAP MCP server | +| Agent surface through `cloudfall mcp serve` | The AAP MCP server (jobs, inventory queries, monitoring through the controller API; a read-only mode, and a read-write mode that launches jobs; technology preview in AAP 2.6.4); the Lightspeed coding assistant and the ADT MCP server help write playbooks | +| `cloudfall mcp serve` as the agent's view of the fleet | The Lightspeed intelligent assistant, a chat in the controller UI; it answers from Red Hat docs and, as a technology preview, can query live AAP data through the AAP MCP server | | Read-only operations dashboard | Controller web UI, which also launches jobs | What AAP does not provide, and a team would have to write themselves: @@ -170,7 +170,7 @@ For the wedge use case, the full toolchain is: - Cloudflare or your registrar holds DNS; Cloudfall verifies the flip, it does not perform it - An MCP client such as Claude Code drives the whole migration through - `cloudfall-mcp`, confirming each server-changing step + `cloudfall mcp serve`, confirming each server-changing step - The Cloudfall operator runs afterwards on a small always-on management host, watching alerts and audits diff --git a/docs/logging-service-guide.md b/docs/logging-service-guide.md index d6dac5a..ea4bb97 100644 --- a/docs/logging-service-guide.md +++ b/docs/logging-service-guide.md @@ -186,9 +186,9 @@ records: - **The operator**: watches that route, writes a proposal receipt per firing alert with the alert labels as its trigger evidence, and verifies a remediation by seeing the alert stop -- **The agent**: reads the same route through `cloudfall-mcp` and the - `operator_watch` tool; it never queries Loki or Prometheus directly, and - the gateway does not let it +- **The agent**: reads what the operator recorded from that route through + `cloudfall mcp serve` (`operator_list`, `operator_show`); it never queries + Loki or Prometheus directly, and the gateway does not let it - **The audit**: the running units, pinned versions and listener bindings declared here are checked by `cloudfall audit` like any other service, so the stack that produces the evidence is itself evidence diff --git a/docs/neon-migration-guide.md b/docs/neon-migration-guide.md index 957b3c9..f25ce9c 100644 --- a/docs/neon-migration-guide.md +++ b/docs/neon-migration-guide.md @@ -172,7 +172,7 @@ The row-count check is the verify step and the receipt is the record's entry for the migration. The restore is not done because `pg_restore` exited zero; it is done because every table's count matches, and the receipt is what you show when asked whether anything was lost. An agent -driving this through `cloudfall-mcp` gets the same receipt and the same +driving this through `cloudfall mcp serve` gets the same receipt and the same refusal guards, and cannot skip either. One database at a time; repeat per declared database. If the application diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 3539ea6..a8c0582 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -49,6 +49,13 @@ observations under `--observed`, and audit them; drifted checks become proposals split by remediation: service checks propose `services.yml`, everything else proposes `baseline.yml`. +Each pass is one JSON line on stdout, written as the pass ends: +`data.pass` says which pass it was (`alerts`, `drift`, `autonomy`) and +`data.effect` what it did (`created` when it wrote proposals, `updated` when +it ran licensed ones, `noop` otherwise). A one-pass run ends with a closing +line carrying `meta.end`; stopping a watcher (Ctrl-C or SIGTERM) ends it +with a `CANCELLED` line and exit 130 or 143. + Every proposal is a schema-validated receipt in `--proposals` (default `tmp/operator/proposals`): the trigger evidence (alert labels or drifted checks), a diagnosis, the exact operation, and later its outcome. @@ -91,6 +98,8 @@ ExecStart=/usr/local/bin/uv run cloudfall operator run \ --gateway-ca certs/ca.crt --gateway-cert certs/operator.crt \ --gateway-key certs/operator.key --interval 30 --drift-interval 900 Restart=on-failure +# `systemctl stop` sends SIGTERM, which the operator answers with exit 143. +SuccessExitStatus=143 [Install] WantedBy=multi-user.target @@ -163,21 +172,22 @@ proposals it executed and which it withheld, with reasons. ## Through an agent -`cloudfall-mcp` exposes the same surface with the standard confirmation -handshake — start it with the gateway material to enable the watch tool: +`cloudfall mcp serve` exposes the record and the approval; start it with +the gateway material so an alert-triggered approval can verify: ```sh -uv run cloudfall-mcp \ +uv run cloudfall mcp serve \ --gateway-ca certs/ca.crt --gateway-cert certs/operator.crt \ --gateway-key certs/operator.key ``` -- `operator_proposals` (read-only) — list receipts with trigger, diagnosis, - and outcome -- `operator_watch` (read-only; `drift=true` adds an audited drift pass) — - one watch pass -- `operator_approve` — previews the exact command and diagnosis, then - requires `confirm=true` to execute and verify +- `operator_list` and `operator_show` (read-only): the receipts with + trigger, diagnosis, and outcome +- `operator_approve`: executes one proposal and verifies its trigger + resolved, in one call, as `cloudfall operator approve` does + +The watch loop itself is not a tool: it runs until stopped, as the service +above. The agent is the brain here and the operator is the record. The agent can watch, read, relay the diagnosis and decide to approve; the tools are diff --git a/docs/render-migration-guide.md b/docs/render-migration-guide.md index 5bd10a4..bc39e8d 100644 --- a/docs/render-migration-guide.md +++ b/docs/render-migration-guide.md @@ -12,7 +12,8 @@ built yet), and a team leaving a PaaS gets a fleet this way. Either way the result is the same: every step below leaves a receipt, the migration ends with an audit rather than a green light, and the always-on operator takes over afterwards with a record of everything it does. An agent can drive -the whole guide through `cloudfall-mcp`; the proving run did. +the whole guide through `cloudfall mcp serve`; the proving run did, through +its predecessor `cloudfall-mcp`. > **Status:** proven live. An application actually hosted on Render was > cut over with its data and a real DNS flip on an owned domain, driven by diff --git a/docs/secrets-guide.md b/docs/secrets-guide.md index 54be135..702e9bf 100644 --- a/docs/secrets-guide.md +++ b/docs/secrets-guide.md @@ -125,15 +125,15 @@ receipt, and the audit proves the new file reached the server. ## Through an agent -`cloudfall-mcp` exposes `render_secrets` (started with `--secrets-dir` and -`--env-dir`). The agent sees key names and hashes only; the decrypted file +`cloudfall mcp serve` exposes `secrets_render`, with the secrets directory +fixed by the server's `--secrets-dir`. The agent sees key names and hashes only; the decrypted file stays on the controller. Rotation is a git commit in the private repository — which means rotation has history. The agent is the brain and it decides when to render and deploy; it is -never a party to the values. `render_secrets` is a mutating tool behind -the confirmation handshake because it writes a file, and its envelope is -what lands in the record. Nothing an agent can call returns a secret, and +never a party to the values. `secrets_render` is annotated as a mutating +tool because it writes a file, and its envelope is what lands in the +record. Nothing an agent can call returns a secret, and a tool that did would be a bug against the [manifesto](../MANIFESTO.md). ## Other backends diff --git a/pyproject.toml b/pyproject.toml index 6a19c63..6596dba 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.0rc18", + "treaty==1.0.0rc29", ] [project.urls] @@ -37,7 +37,6 @@ mcp = [ [project.scripts] cloudfall = "cloudfall.cli:run" cloudfall-engine = "cloudfall_engine.cli:run" -cloudfall-mcp = "cloudfall.mcp_server:run" [build-system] requires = ["hatchling>=1.27"] diff --git a/sdk/README.md b/sdk/README.md index 9c7d341..c6d96a0 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -75,31 +75,36 @@ guessing silently. ## MCP server -`cloudfall-mcp` (requires the `cloudfall[mcp]` extra) exposes the agent -toolset over stdio. Read-only evidence tools — validate, inventory, audit, -service status, health probes, and both inspection collectors — are exposed -freely with read-only annotations. Everything that changes servers -(deploy, rollback, restart, and the baseline, services, and domains -convergers) is annotated destructive and demands a two-step handshake: the -first call returns a `confirmation-required` preview describing exactly what -would run; only a second call with `confirm=true` executes. Deployments -through MCP always write release receipts. - -The fleet-declaring tools `add_ssh_key`, `add_server_type`, and `add_server` -mirror `cloudfall add`: they write schema-validated resource files into the -project, re-validate the whole project, remove what they wrote when that -validation fails, and never overwrite an existing resource. They are -annotated neither read-only nor destructive, since they change the project -and not a server. `cloudfall init` stays CLI-only on purpose: the server -starts inside an existing project and confines every path to it, so an -agent lays out the project first and then connects. +`cloudfall mcp serve` (requires the `cloudfall[mcp]` extra) serves one +fleet over stdio, and its tools are Cloudfall's own commands. The fleet, +the schema and engine directories, and the evidence directories commands +share are fixed when the server starts, so a tool call cannot point at +another project, schema directory or engine. Read-only commands (`config_validate`, `inventory_show`, `audit`, +`services_status`, `health`, `observe`, `services_inspect`) carry read-only +annotations. `deploy`, `rollback`, `restart`, `migrate` and `data_migrate` +return their plan until called again with `yes: true`. Four tools have no +command of their own: `build_artifact`, and `converge_baseline`, +`converge_services` and `converge_domains`, which are destructive and run +only with `confirm_destructive: true`. + +The fleet-declaring tools `add_ssh-key`, `add_server-type` and `add_server` +are `cloudfall add`: they write schema-validated resource files into the +project and never overwrite an existing resource. `cloudfall init`, +`operations approve`, `operator run` and `dashboard serve` are never tools: +the server starts inside an existing project, approving a recorded +operation is a person's command, and the loops run until stopped. ```console -uv run cloudfall-mcp --project config/examples +uv run cloudfall mcp serve --project config/examples +uv run cloudfall mcp serve --repository ../fleet-ansible +uv run cloudfall mcp serve --project config/examples --list-tools ``` -Every tool returns a structured JSON envelope, including errors, so agents -never need to parse free-form failures. An agent connected to this server +With `--repository` the server serves a team's own Ansible repository +instead: the read-only fleet commands, and one tool per declared operation, +which runs check mode and records a proposal for a person to approve. +Every tool answers with the same JSON envelope as the CLI, errors included, +so agents never need to parse free-form failures. An agent connected to this server can drive the full migration path — import a blueprint, build artifacts, converge the baseline, services, and domains, deploy with automatic rollback, and audit the result — without shell access to any server. diff --git a/sdk/src/cloudfall/agent_server.py b/sdk/src/cloudfall/agent_server.py new file mode 100644 index 0000000..aadccca --- /dev/null +++ b/sdk/src/cloudfall/agent_server.py @@ -0,0 +1,415 @@ +"""``cloudfall mcp serve``: which tools an agent gets, and what it cannot change. + +The server is started for one fleet: a Cloudfall project, or a team's own +Ansible repository. That choice decides the tool list and is bound into +every tool call, so an agent works on the fleet the person started the +server for and cannot point a call at another one. + +On a project, the tools are the commands an agent may run there, with the +server-changing ones previewing until called with ``yes``. On a repository, +they are the read-only fleet commands and one tool per declared operation, +whose call runs check mode and records a proposal; approving it is +``cloudfall operations approve``, which no tool can stand in for. + +Nothing here imports ``cloudfall.app``: ``App(mcp=...)`` needs these at +construction. The tools Cloudfall provides beside its commands, which use +the commands' own helpers, are in ``cloudfall.app``. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING + +from treaty import Exit, Flag, ParseError + +from cloudfall.ansible_api import ( + AnsibleReadError, + InventorySource, + inventory_from_config, +) +from cloudfall.catalog import CATALOG_DIRECTORY +from cloudfall.decision import DECISION_DIRECTORY +from cloudfall.project import ( + PROJECT_DIRECTORY_VARIABLE, + ProjectError, + is_project, + resolve_project_directory, +) +from cloudfall.resources import default_engine_directory, default_schema_directory + +if TYPE_CHECKING: + from cloudfall.catalog import Operation + +APPROVAL_COMMAND = "cloudfall operations approve" +"""The command a person runs to approve a proposal; no tool stands in for it.""" + +ERROR_INVENTORY_UNDECLARED = "fleet_inventory_undeclared" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class McpServeArgs: + """Startup flags of ``mcp serve``: the fleet it serves, and where its files are.""" + + project: Path | None = Flag( + default=None, + description=( + f"Project to serve (default: ${PROJECT_DIRECTORY_VARIABLE}, else the " + "current directory when it is a project)" + ), + ) + repository: Path | None = Flag( + default=None, + description=( + "Ansible repository to serve instead of a project: its declared " + "operations become tools, and no tool changes a host (default: the " + "current directory when it holds an ansible.cfg and is no project)" + ), + ) + inventory: Path | None = Flag( + default=None, + description=( + "Inventory inside the repository (default: the one its ansible.cfg names)" + ), + ) + schemas: Path = Flag( + default=default_schema_directory(), + description="Versioned schema directory (default: bundled schemas)", + ) + engine: Path = Flag( + default=default_engine_directory(), + description="Engine directory containing ansible contracts (default: bundled)", + ) + inventory_file: Path = Flag( + default=Path("tmp/ansible-inventory.json"), + description="Rendered inventory path (default: tmp/ansible-inventory.json)", + ) + observed: Path = Flag( + default=Path("tmp/observed"), + description="Server snapshot directory (default: tmp/observed)", + ) + service_observed: Path = Flag( + default=Path("tmp/observed-services"), + description="Domain observation directory (default: tmp/observed-services)", + ) + deployments: Path = Flag( + default=Path("tmp/deployments"), + description="Deployment receipt directory (default: tmp/deployments)", + ) + artifacts: Path = Flag( + default=Path("tmp/artifacts"), + description="Release artifact directory (default: tmp/artifacts)", + ) + env_receipts: Path = Flag( + default=Path("tmp/env-receipts"), + description=( + "Rendered environment receipt directory (default: tmp/env-receipts)" + ), + ) + proposals: Path = Flag( + default=Path("tmp/operator/proposals"), + description="Proposal receipt directory (default: tmp/operator/proposals)", + ) + secrets_dir: Path = Flag( + default=Path("secrets"), + description="sops-encrypted secrets directory (default: secrets)", + ) + operations: Path = Flag( + default=Path(CATALOG_DIRECTORY), + description=( + f"Operation documents in the repository (default: {CATALOG_DIRECTORY})" + ), + ) + decisions: Path = Flag( + default=Path(DECISION_DIRECTORY), + description=( + f"Decision records in the repository (default: {DECISION_DIRECTORY})" + ), + ) + gateway_url: str | None = Flag( + default=None, + description="Alerts endpoint (default: derived from the declared gateway)", + ) + gateway_ca: Path | None = Flag(default=None, description="Gateway CA file") + gateway_cert: Path | None = Flag( + default=None, description="Client certificate for the gateway" + ) + gateway_key: Path | None = Flag( + default=None, description="Client key for the gateway", secret=False + ) + + def __post_init__(self) -> None: + """Refuse two fleets: a server serves a project or a repository.""" + if self.project is not None and self.repository is not None: + message = "--project and --repository name two fleets; pass one" + raise ParseError(message, context={"flag": "repository"}) + if self.project is not None and self.inventory is not None: + message = "--inventory names a repository's inventory; it has no project" + raise ParseError(message, context={"flag": "inventory"}) + + +class ServerMode(Enum): + """What the server was started for.""" + + PROJECT = "project" + FLEET = "fleet" + + +@dataclass(frozen=True, slots=True) +class ServerRoot: + """The one fleet a server serves, and how it reads it.""" + + mode: ServerMode + directory: Path + """The project, or the Ansible repository; absolute.""" + inventory: InventorySource | None = None + """The team's inventory, for a repository.""" + + def path(self, value: Path) -> Path: + """Resolve a configured path against the served directory.""" + return value if value.is_absolute() else self.directory / value + + +def resolve_root(args: McpServeArgs) -> ServerRoot: + """Return the fleet to serve, as the CLI would choose it. + + Precedence: ``--repository``, then a project named by ``--project`` or + the environment, then the current directory when it is a project, then + the current directory when an ``ansible.cfg`` there names an inventory. + """ + current = Path.cwd() + if args.repository is not None: + return _repository(args.repository.resolve(), args.inventory) + named = args.project is not None or bool( + os.environ.get(PROJECT_DIRECTORY_VARIABLE) + ) + if not named and not is_project(current) and args.inventory is not None: + return _repository(current, args.inventory) + if not named and not is_project(current) and _has_inventory(current): + return _repository(current, None) + try: + directory = resolve_project_directory(args.project, os.environ, current) + except ProjectError as error: + raise Exit.PROJECT_INVALID( + error.detail, context={"code": error.code} + ) from error + return ServerRoot(ServerMode.PROJECT, directory.resolve()) + + +def _has_inventory(directory: Path) -> bool: + try: + return inventory_from_config(directory) is not None + except AnsibleReadError: + # An ansible.cfg that names a missing inventory still marks a + # repository; reading it again reports the error. + return True + + +def _repository(directory: Path, inventory: Path | None) -> ServerRoot: + try: + if inventory is not None: + source = InventorySource.from_boundary( + inventory if inventory.is_absolute() else directory / inventory + ) + else: + declared = inventory_from_config(directory) + if declared is None: + message = ( + f"{directory} names no inventory: add an ansible.cfg with an " + "inventory setting, or pass --inventory" + ) + raise Exit.INVENTORY_UNREADABLE( + message, context={"code": ERROR_INVENTORY_UNDECLARED} + ) + source = declared + except AnsibleReadError as error: + raise Exit.INVENTORY_UNREADABLE( + error.detail, context={"code": error.code} + ) from error + return ServerRoot(ServerMode.FLEET, directory, source) + + +def operation_tool_name(operation: Operation) -> str: + """Return the MCP tool name one declared operation is exposed under.""" + return f"operation_{operation.operation_id.value.replace('-', '_')}" + + +def operation_tool_description(operation: Operation) -> str: + """Describe one operation the way a client's tool list should read it.""" + lines = [ + operation.description + if operation.description is not None + else f"Run the {operation.operation_id} operation", + "", + f"Risk: {operation.risk.value}. Targets: {operation.targets.value}.", + "Calling this runs check mode only and records what it would change; " + f"a person approves the result with `{APPROVAL_COMMAND}`.", + ] + if operation.inputs: + declared = ", ".join( + f"{declared_input.name}: {declared_input.type.value}" + + ("" if declared_input.required else " (optional)") + for declared_input in operation.inputs + ) + lines.append(f"Inputs: {declared}.") + if operation.preconditions: + lines.append( + "Preconditions: " + + ", ".join(condition.value for condition in operation.preconditions) + + "." + ) + if operation.verify is not None: + lines.append(f"Verified by: {operation.verify.playbook}.") + return "\n".join(lines) + + +PROJECT_COMMANDS = frozenset( + { + "config.validate", + "inventory.show", + "audit", + "services.status", + "services.inspect", + "health", + "observe", + "add.ssh-key", + "add.server-type", + "add.server", + "import.render", + "import.render-api", + "secrets.render", + "deploy", + "rollback", + "restart", + "data.migrate", + "migrate", + "backup.run", + "backup.verify", + "operator.list", + "operator.show", + "operator.approve", + } +) +"""The commands an agent runs on a project; ``init`` and the loops are not tools.""" + +FLEET_COMMANDS = frozenset( + { + "inventory.show", + "observe", + "audit", + "operations.list", + "operations.show", + "operations.decisions", + "why", + } +) +"""The read-only commands on a repository; the declared operations join them.""" + + +def served_commands(args: McpServeArgs) -> frozenset[str]: + """Return the command paths served as tools for this fleet.""" + if resolve_root(args).mode is ServerMode.FLEET: + return FLEET_COMMANDS + return PROJECT_COMMANDS + + +def bound_arguments(args: McpServeArgs) -> dict[str, object]: + """Return the arguments every tool call runs with, fixed for the run. + + The fleet and every directory a command reads or writes are the + server's, so a call cannot reach another fleet or another engine. + """ + root = resolve_root(args) + shared: dict[str, object] = { + "schemas": str(args.schemas), + "engine": str(args.engine), + } + if root.mode is ServerMode.FLEET: + if root.inventory is None: + message = "a repository is served with its inventory" + raise TypeError(message) + observed = str(root.path(args.observed)) + return { + **shared, + "repository": str(root.directory), + "project": None, + "inventory": str(root.inventory.value), + "observed": observed, + "output_dir": observed, + "env_receipts": str(root.path(args.env_receipts)), + "operations": str(args.operations), + "decisions": str(args.decisions), + } + bound: dict[str, object] = { + **shared, + "project": str(root.directory), + "inventory": None, + "inventory_file": str(args.inventory_file), + "observed": str(args.observed), + "service_observed": str(args.service_observed), + "deployments": str(args.deployments), + "artifacts": str(args.artifacts), + "env_receipts": str(args.env_receipts), + "proposals": str(args.proposals), + "secrets_dir": str(args.secrets_dir), + } + gateway = { + "gateway_url": args.gateway_url, + "gateway_ca": args.gateway_ca, + "gateway_cert": args.gateway_cert, + "gateway_key": args.gateway_key, + } + # Gateway material is bound only when the server was given it; otherwise + # an alert-triggered approval names the flags it needs. + bound.update({name: str(value) for name, value in gateway.items() if value}) + return bound + + +_FLEET_INSTRUCTIONS = f"""\ +Cloudfall is the record for a fleet run through the team's own Ansible +repository. The tool list is the catalog the team declared: a playbook +they have not declared as an operation is not reachable here, and a raw +shell call is what this server exists to make unnecessary. + +The loop is fixed: read the fleet (inventory_show), collect snapshots +(observe), compare them with the declared fleet (audit), pick an operation +and say why, then call its operation_* tool to preview it. Calling an +operation tool changes nothing on a host: it runs the playbook in check +mode and records the operation, the targets, the evidence it was based on +and the diff it would produce. Approving that record is `{APPROVAL_COMMAND}`, +a command a person runs; no tool here can do it, whatever reason is given +for asking. When asked why something was done, answer from the record +with the why tool rather than from memory. + +Each operation tool carries the risk level the team declared, so read, +mutating and destructive are visible to this client's own gate; a +destructive one asks for confirm_destructive even to preview. +Every result is an envelope: read ok, then data, else error. +""" + +_PROJECT_INSTRUCTIONS = """\ +Cloudfall manages declarative infrastructure for dedicated Debian servers, +in the one project this server was started for. Read-only tools validate +the project and derive evidence. deploy, rollback, restart, migrate and +data_migrate change servers: called without yes they only return the plan; +review it, then call again with yes: true. The converge_* tools converge +every declared server and are destructive: they run only with +confirm_destructive: true. Every change writes receipts; nothing reports +success it cannot prove. + +add_ssh-key, add_server-type and add_server declare the fleet: they write +schema-validated resource files into the project, never onto a server, and +never overwrite an existing resource. The project itself is laid out with +`cloudfall init` on the CLI, and the operator's watch loop runs there too. +Every result is an envelope: read ok, then data, else error. +""" + + +def server_instructions(args: McpServeArgs) -> str: + """Return what the client is told about this server's loop and gates.""" + if resolve_root(args).mode is ServerMode.FLEET: + return _FLEET_INSTRUCTIONS + return _PROJECT_INSTRUCTIONS diff --git a/sdk/src/cloudfall/agent_tools.py b/sdk/src/cloudfall/agent_tools.py index bcd94f7..4c85cbb 100644 --- a/sdk/src/cloudfall/agent_tools.py +++ b/sdk/src/cloudfall/agent_tools.py @@ -1,108 +1,19 @@ -"""Typed agent-facing operations behind the MCP boundary. +"""The filesystem contract a migration runs against. -Every method returns a JSON-serializable envelope instead of raising, so -agents always receive structured results. Read-only evidence tools are -exposed freely; anything that changes servers demands an explicit -confirmation handshake and always produces receipts. +``cloudfall migrate`` and its steps read the project, the engine and every +evidence directory through one ``AgentConfig``. """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING -from cloudfall.audit import audit_inventory -from cloudfall.authoring import ( - AuthoringError, - ServerOptions, - ServerTypeOptions, - SshKeyOptions, - add_server, - add_server_type, - add_ssh_key, -) -from cloudfall.domain import ( - ConnectionAddress, - Hostname, - LinuxUser, - ReleaseId, - ResourceId, - TcpPort, -) -from cloudfall.importer import ( - ImportTargets, - RenderImportError, - import_render_blueprint, -) -from cloudfall.inventory import PlatformInventory from cloudfall.lifecycle import ( - DeployOptions, EngineContext, - LifecycleError, StepRunner, - backup_service, - build_release_artifact, - deploy, - health, - migrate_data, - restart, - rollback, - run_engine_playbook, subprocess_step, - verify_backup, -) -from cloudfall.migrate import MigrateError, MigrateOptions, execute_migration -from cloudfall.observation import load_observations -from cloudfall.operations import UtcTimestamp, build_operations_view -from cloudfall.operator import ( - ApproveOptions, - OperatorError, - ProposalStatus, - ProposalStore, - TriggerKind, - alert_resolution_verifier, - drift_pass, - drift_resolution_verifier, - engine_auditor, - engine_executor, - gateway_feed, -) -from cloudfall.operator import ( - approve as operator_approve_proposal, -) -from cloudfall.operator import ( - run_once as operator_run_once, -) -from cloudfall.project import project_path -from cloudfall.render_api import HttpRenderApiClient, read_api_key -from cloudfall.render_api import ( - import_render_api as render_api_import, -) -from cloudfall.secrets import ( - SecretsError, - SopsSecretProvider, - load_environment_receipts, - render_environment, -) -from cloudfall.service_evidence import ( - DeploymentReceiptSet, - DomainObservationSet, - EvidenceTimestamp, - SocketDomainNetworkClient, - inspect_domains, - load_deployment_receipts, - load_domain_observations, ) -from cloudfall.validation import ConfigValidationError, SchemaCatalog, validate_config - -if TYPE_CHECKING: - from collections.abc import Callable - - from cloudfall.operator import AlertFeed - from cloudfall.validation import ValidatedConfig - -_CODE_INVALID_ARGUMENT = "invalid_argument" @dataclass(frozen=True, slots=True) @@ -139,735 +50,3 @@ def context(self) -> EngineContext: inventory_file=self.inventory_file, run=self.run, ) - - -class AgentToolset: - """Structured operations offered to AI agents.""" - - def __init__(self, config: AgentConfig) -> None: - """Bind the toolset to one filesystem contract.""" - self._config = config - - def validate(self) -> dict[str, object]: - """Validate declared state and return the structured summary.""" - try: - return self._state().as_dict() - except ConfigValidationError as error: - return error.as_dict() - - def inventory(self) -> dict[str, object]: - """Return the non-secret platform inventory.""" - try: - state = self._state() - except ConfigValidationError as error: - return error.as_dict() - return { - "status": "ok", - "inventory": PlatformInventory.from_state(state).as_dict(), - } - - def audit(self) -> dict[str, object]: - """Compare desired state with collected server observations.""" - try: - state = self._state() - observations = load_observations( - self._config.observed_directory, self._config.schema_directory - ) - except ConfigValidationError as error: - return error.as_dict() - report = audit_inventory( - PlatformInventory.from_state(state), - observations, - load_environment_receipts( - self._config.environment_receipts_directory, - self._config.schema_directory, - ), - ) - return report.as_dict() - - def services_status(self) -> dict[str, object]: - """Derive the evidence-based public service lifecycle.""" - try: - state = self._state() - observations = load_observations( - self._config.observed_directory, self._config.schema_directory - ) - except ConfigValidationError as error: - return error.as_dict() - receipts = ( - load_deployment_receipts( - self._config.deployments_directory, - self._config.schema_directory, - ) - if self._config.deployments_directory.is_dir() - else DeploymentReceiptSet.empty() - ) - domain_observations = ( - load_domain_observations( - self._config.service_observed_directory, - self._config.schema_directory, - ) - if self._config.service_observed_directory.is_dir() - else DomainObservationSet.empty() - ) - view = build_operations_view( - PlatformInventory.from_state(state), - observations, - receipts, - domain_observations, - generated_at=UtcTimestamp.now(), - ) - return { - "status": "ok", - "health": view.health.value, - "services": [domain.as_dict() for domain in view.domains], - } - - def component_health(self, component: str) -> dict[str, object]: - """Probe one component's declared health check on its servers.""" - try: - component_id = ResourceId.from_boundary(component) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - return health(self._config.context(), component_id).as_dict() - except LifecycleError as error: - return error.as_dict() - - def inspect_servers(self) -> dict[str, object]: - """Collect read-only server evidence into the observation directory.""" - observed = self._config.observed_directory.resolve() - try: - run_engine_playbook( - self._config.context(), - "inspect.yml", - {"cloudfall_inspect_output_directory": str(observed)}, - ) - except LifecycleError as error: - return error.as_dict() - return {"status": "ok", "observed": str(observed)} - - def inspect_services(self) -> dict[str, object]: - """Collect DNS, TLS, origin, and public route evidence.""" - try: - state = self._state() - except ConfigValidationError as error: - return error.as_dict() - paths = inspect_domains( - PlatformInventory.from_state(state), - self._config.service_observed_directory, - SocketDomainNetworkClient(), - observed_at=EvidenceTimestamp.now(), - ) - return { - "status": "ok", - "observations": [str(path) for path in paths], - } - - def add_ssh_key( - self, - key_file: str, - owner: str, - resource_id: str | None = None, - environment: str = "production", - description: str | None = None, - ) -> dict[str, object]: - """Declare one SSH public key read from a controller-side key file.""" - try: - options = SshKeyOptions( - key_path=self._path(key_file).expanduser(), - owner=ResourceId.from_boundary(owner), - environment=ResourceId.from_boundary(environment), - resource_id=( - ResourceId.from_boundary(resource_id) - if resource_id is not None - else None - ), - description=description, - ) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - result = add_ssh_key( - self._config.project_directory, options, self._config.schema_directory - ) - except (AuthoringError, ConfigValidationError) as error: - return error.as_dict() - return result.as_dict() - - def add_server_type( - self, resource_id: str, description: str | None = None - ) -> dict[str, object]: - """Declare one server type from the bundled Debian 13 baseline.""" - try: - options = ServerTypeOptions( - resource_id=ResourceId.from_boundary(resource_id), - description=description, - ) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - result = add_server_type( - self._config.project_directory, options, self._config.schema_directory - ) - except (AuthoringError, ConfigValidationError) as error: - return error.as_dict() - return result.as_dict() - - def add_server( # noqa: PLR0913 - boundary signature mirrors the CLI. - self, - resource_id: str, - address: str, - server_type: str = "debian-application", - environment: str = "production", - hostname: str | None = None, - ssh_user: str = "root", - ssh_port: int = 22, - description: str | None = None, - ) -> dict[str, object]: - """Declare one server, creating its server type when the project lacks it.""" - try: - options = ServerOptions( - resource_id=ResourceId.from_boundary(resource_id), - address=ConnectionAddress.from_boundary(address), - server_type=ResourceId.from_boundary(server_type), - environment=ResourceId.from_boundary(environment), - ssh_user=LinuxUser.from_boundary(ssh_user), - ssh_port=TcpPort.from_boundary(ssh_port), - hostname=( - Hostname.from_boundary(hostname) if hostname is not None else None - ), - description=description, - ) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - result = add_server( - self._config.project_directory, options, self._config.schema_directory - ) - except (AuthoringError, ConfigValidationError) as error: - return error.as_dict() - return result.as_dict() - - def import_render( - self, - blueprint: str, - application: str, - server: str, - output_directory: str, - environment_directory: str, - ) -> dict[str, object]: - """Map a Render blueprint onto Cloudfall config fragments.""" - try: - targets = ImportTargets( - application_id=ResourceId.from_boundary(application), - server_id=ResourceId.from_boundary(server), - project_directory=project_path(output_directory), - environment_directory=project_path(environment_directory), - ) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - result = import_render_blueprint( - self._path(blueprint), targets, self._config.schema_directory - ) - except (RenderImportError, ConfigValidationError) as error: - return error.as_dict() - return result.as_dict() - - def import_render_api( - self, - api_key_file: str, - application: str, - server: str, - output_directory: str, - environment_directory: str, - ) -> dict[str, object]: - """Map a live Render workspace onto Cloudfall config fragments.""" - try: - targets = ImportTargets( - application_id=ResourceId.from_boundary(application), - server_id=ResourceId.from_boundary(server), - project_directory=project_path(output_directory), - environment_directory=project_path(environment_directory), - ) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - client = HttpRenderApiClient( - api_key=read_api_key(self._path(api_key_file)) - ) - result = render_api_import( - client, targets, self._config.schema_directory - ) - except (RenderImportError, ConfigValidationError) as error: - return error.as_dict() - return result.as_dict() - - def build_artifact(self, component: str, git_ref: str) -> dict[str, object]: - """Build one hashed release artifact from the component repository.""" - try: - component_id = ResourceId.from_boundary(component) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - return build_release_artifact( - self._config.context(), - component_id, - git_ref, - self._config.artifacts_directory, - ) - except LifecycleError as error: - return error.as_dict() - - def deploy_component( - self, - component: str, - release: str, - environment_file: str | None = None, - *, - confirm: bool = False, - ) -> dict[str, object]: - """Deploy one built release behind the engine's health gate.""" - gate = _confirmation_gate( - confirm, - "deploy", - f"deploy release {release} of component {component} to its " - "declared servers with automatic rollback on a failed health " - "check", - ) - if gate is not None: - return gate - try: - component_id = ResourceId.from_boundary(component) - release_id = ReleaseId.from_boundary(release) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - result = deploy( - self._config.context(), - component_id, - release_id, - self._config.artifacts_directory, - DeployOptions( - environment_file=( - self._path(environment_file) - if environment_file is not None - else None - ), - receipt_directory=self._config.releases_directory, - ), - ) - except LifecycleError as error: - return error.as_dict() - return result.as_dict() - - def rollback_component( - self, component: str, release: str, *, confirm: bool = False - ) -> dict[str, object]: - """Activate an already-retained release behind the health gate.""" - gate = _confirmation_gate( - confirm, - "rollback", - f"switch component {component} back to retained release " - f"{release} and restart it", - ) - if gate is not None: - return gate - try: - component_id = ResourceId.from_boundary(component) - release_id = ReleaseId.from_boundary(release) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - result = rollback(self._config.context(), component_id, release_id) - except LifecycleError as error: - return error.as_dict() - return result.as_dict() - - def restart_component( - self, component: str, *, confirm: bool = False - ) -> dict[str, object]: - """Restart one component and require its declared health check.""" - gate = _confirmation_gate( - confirm, - "restart", - f"restart component {component} on its declared servers", - ) - if gate is not None: - return gate - try: - component_id = ResourceId.from_boundary(component) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - result = restart(self._config.context(), component_id) - except LifecycleError as error: - return error.as_dict() - return result.as_dict() - - def migrate_database( - self, - service: str, - database: str, - source_url_file: str, - *, - confirm: bool = False, - ) -> dict[str, object]: - """Dump an external database and restore it into a declared service.""" - gate = _confirmation_gate( - confirm, - "data-migration", - ( - f"dump the database behind {source_url_file} and restore it " - f"into declared database {database} on service {service} " - "with per-table row-count verification" - ), - ) - if gate is not None: - return gate - try: - service_id = ResourceId.from_boundary(service) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - return migrate_data( - self._config.context(), - service_id, - database, - Path(source_url_file), - self._config.data_migrations_directory, - ) - except LifecycleError as error: - return error.as_dict() - - def converge_baseline(self, *, confirm: bool = False) -> dict[str, object]: - """Converge every declared server to the managed baseline.""" - return self._converge( - confirm, - "converge-baseline", - "run baseline.yml: bootstrap, UTC, SSH hardening, unattended " - "upgrades, and the declared firewall on every server", - "baseline.yml", - {}, - ) - - def converge_services(self, *, confirm: bool = False) -> dict[str, object]: - """Converge every declared infrastructure service.""" - return self._converge( - confirm, - "converge-services", - "run services.yml: install and configure every declared " - "infrastructure service", - "services.yml", - {}, - ) - - def converge_domains( - self, *, issue_certificates: bool = False, confirm: bool = False - ) -> dict[str, object]: - """Converge every declared public domain route.""" - return self._converge( - confirm, - "converge-domains", - "run domains.yml: render every declared domain route" - + ( - " and issue missing certificates" - if issue_certificates - else " without certificate issuance" - ), - "domains.yml", - { - "cloudfall_domains_issue_certificates": issue_certificates, - "cloudfall_domains_receipt_directory": str( - self._config.deployments_directory.resolve() - ), - }, - ) - - def migrate( # noqa: PLR0913 - boundary signature mirrors the CLI. - self, - builds: dict[str, str] | None = None, - releases: dict[str, str] | None = None, - environment_files: dict[str, str] | None = None, - data_migrations: dict[str, str] | None = None, - plan_file: str = "tmp/migrate/plan.json", - *, - restart_plan: bool = False, - confirm: bool = False, - ) -> dict[str, object]: - """Preview or execute the resumable end-to-end migration plan.""" - try: - confined_plan_file = project_path(plan_file) - except ValueError as error: - return _invalid_argument(error) - options = MigrateOptions( - plan_file=confined_plan_file, - builds=builds if builds is not None else {}, - releases=releases if releases is not None else {}, - environment_files={ - component: self._path(value) - for component, value in ( - environment_files if environment_files is not None else {} - ).items() - }, - data_migrations={ - database: self._path(value) - for database, value in ( - data_migrations if data_migrations is not None else {} - ).items() - }, - execute=confirm, - restart=restart_plan, - ) - try: - result = execute_migration(self._config, options) - except (MigrateError, ConfigValidationError) as error: - return error.as_dict() - if not confirm: - result["instruction"] = ( - "review the plan and call this tool again with confirm=true " - "to execute it; interrupted runs resume automatically" - ) - return result - - def render_secrets(self, component: str) -> dict[str, object]: - """Render one component's secret references into its env file.""" - try: - component_id = ResourceId.from_boundary(component) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - try: - return render_environment( - self._config.context(), - component_id, - SopsSecretProvider( - secrets_directory=self._config.secrets_directory - ), - self._config.environment_directory - / f"{component_id.value}.env", - receipt_directory=( - self._config.environment_receipts_directory - ), - ) - except (SecretsError, ConfigValidationError) as error: - return error.as_dict() - - def backup_service( - self, service: str, *, confirm: bool = False - ) -> dict[str, object]: - """Run the declared backup for one service, receipted.""" - return self._backup_operation( - service, - confirm=confirm, - action="backup-service", - detail_prefix="run the declared backup for", - operation=backup_service, - ) - - def verify_backup( - self, service: str, *, confirm: bool = False - ) -> dict[str, object]: - """Prove the newest backup of one service restores, receipted.""" - return self._backup_operation( - service, - confirm=confirm, - action="verify-backup", - detail_prefix="prove the newest backup restores for", - operation=verify_backup, - ) - - def _backup_operation( - self, - service: str, - *, - confirm: bool, - action: str, - detail_prefix: str, - operation: Callable[ - [EngineContext, ResourceId, Path], dict[str, object] - ], - ) -> dict[str, object]: - try: - service_id = ResourceId.from_boundary(service) - except (TypeError, ValueError) as error: - return _invalid_argument(error) - gate = _confirmation_gate( - confirm, - action, - f"{detail_prefix} {service_id.value} on its declared server", - ) - if gate is not None: - return gate - try: - return operation( - self._config.context(), - service_id, - self._config.backups_directory, - ) - except (LifecycleError, ConfigValidationError) as error: - return error.as_dict() - - def operator_proposals(self) -> dict[str, object]: - """List every operator proposal receipt with its status.""" - try: - proposals = [ - proposal.as_document() - for proposal in self._proposal_store().list() - ] - except OperatorError as error: - return error.as_dict() - return {"status": "ok", "proposals": proposals} - - def operator_watch(self, *, drift: bool = False) -> dict[str, object]: - """Run one watch pass: fetch alerts, optionally audit for drift.""" - try: - state = self._state() - except ConfigValidationError as error: - return error.as_dict() - inventory = PlatformInventory.from_state(state) - try: - store = self._proposal_store() - feed = self._alert_feed(inventory) - report = operator_run_once(feed, inventory, store) - passes: dict[str, object] = {"alerts": report.as_dict()} - if drift: - auditor = engine_auditor( - self._config.context(), - inventory, - self._config.observed_directory, - ) - passes["drift"] = drift_pass(auditor, store).as_dict() - except (OperatorError, LifecycleError) as error: - return error.as_dict() - return {"status": "ok", **passes} - - def operator_approve( - self, proposal: str, *, confirm: bool = False - ) -> dict[str, object]: - """Execute one proposal after confirmation and verify its trigger.""" - try: - state = self._state() - except ConfigValidationError as error: - return error.as_dict() - inventory = PlatformInventory.from_state(state) - try: - store = self._proposal_store() - proposal_id = ResourceId.from_boundary(proposal) - pending = store.load(proposal_id) - except (OperatorError, ValueError) as error: - return _invalid_argument(error) - gate = _confirmation_gate( - confirm, - "operator-approve", - f"execute {' '.join(pending.command)} because " - f"{pending.diagnosis_summary}", - ) - if gate is not None: - return gate - try: - if pending.trigger_kind is TriggerKind.ALERT: - verifier = alert_resolution_verifier( - self._alert_feed(inventory) - ) - else: - verifier = drift_resolution_verifier( - engine_auditor( - self._config.context(), - inventory, - self._config.observed_directory, - ) - ) - result = operator_approve_proposal( - store, - proposal_id, - engine_executor(self._config.context()), - verifier, - ApproveOptions(), - ) - except (OperatorError, LifecycleError) as error: - return error.as_dict() - return { - "status": ( - "ok" - if result.status is ProposalStatus.VERIFIED - else "failed" - ), - "proposal": result.as_document(), - } - - def _proposal_store(self) -> ProposalStore: - return ProposalStore( - directory=self._config.proposals_directory, - catalog=SchemaCatalog(self._config.schema_directory), - ) - - def _alert_feed(self, inventory: PlatformInventory) -> AlertFeed: - config = self._config - if ( - config.gateway_ca_path is None - or config.gateway_certificate_path is None - or config.gateway_key_path is None - ): - code = "operator_gateway_material_missing" - message = ( - "the server was started without gateway TLS material; " - "pass --gateway-ca, --gateway-cert, and --gateway-key" - ) - raise OperatorError(code, message) - return gateway_feed( - inventory, - ca_path=config.gateway_ca_path, - certificate_path=config.gateway_certificate_path, - key_path=config.gateway_key_path, - ) - - def _converge( - self, - confirm: bool, # noqa: FBT001 - explicit gate flag at the boundary. - action: str, - detail: str, - playbook: str, - extra_vars: dict[str, object], - ) -> dict[str, object]: - gate = _confirmation_gate(confirm, action, detail) - if gate is not None: - return gate - try: - run_engine_playbook(self._config.context(), playbook, extra_vars) - except LifecycleError as error: - return error.as_dict() - return {"status": "ok", "action": action} - - def _state(self) -> ValidatedConfig: - return validate_config( - self._config.project_directory, self._config.schema_directory - ) - - def _path(self, value: str) -> Path: - return Path(value) - - -def _confirmation_gate( - confirm: bool, # noqa: FBT001 - explicit gate flag at the boundary. - action: str, - detail: str, -) -> dict[str, object] | None: - if confirm: - return None - return { - "status": "confirmation-required", - "action": action, - "wouldRun": detail, - "instruction": ( - "review the description and call this tool again with " - "confirm=true to execute" - ), - } - - -def _invalid_argument(error: Exception) -> dict[str, object]: - return { - "status": "error", - "error": {"code": _CODE_INVALID_ARGUMENT, "message": str(error)}, - } diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 8ea68fa..c58e71d 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -14,9 +14,14 @@ from __future__ import annotations +import dataclasses import errno import os import shutil + +# treaty reads a handler's annotations at registration, so a streaming +# handler's Iterator and a provided MCP tool's Mapping must exist at runtime. +from collections.abc import Iterator, Mapping from dataclasses import dataclass from functools import partial from http import HTTPStatus @@ -32,6 +37,8 @@ Exit, Flag, FormatRenderer, + McpServe, + McpTool, Out, ParseError, SideEffect, @@ -39,6 +46,18 @@ Timeout, ) +from cloudfall.agent_server import ( + APPROVAL_COMMAND, + McpServeArgs, + ServerMode, + ServerRoot, + bound_arguments, + operation_tool_description, + operation_tool_name, + resolve_root, + served_commands, + server_instructions, +) from cloudfall.agent_tools import AgentConfig from cloudfall.ansible_api import ( AnsibleReadError, @@ -65,10 +84,16 @@ CATALOG_DIRECTORY, ERROR_OPERATION_UNDECLARED, OperationCatalog, + RiskLevel, load_catalog, ) from cloudfall.commands import CLI_COMMANDS -from cloudfall.dashboard import build_dashboard +from cloudfall.dashboard import RefreshInterval, build_dashboard +from cloudfall.dashboard_server import ( + EvidenceSources, + ListenEndpoint, + create_dashboard_server, +) from cloudfall.decision import ( CHECK_TIMEOUT_SECONDS, DECISION_DIRECTORY, @@ -109,6 +134,7 @@ StepRun, StepRunner, backup_service, + build_release_artifact, deploy, health, migrate_data, @@ -118,6 +144,7 @@ preview_rollback, restart, rollback, + run_engine_playbook, verify_backup, ) from cloudfall.migrate import MigrateError, MigrateOptions, execute_migration @@ -131,18 +158,26 @@ ) from cloudfall.operations import FleetOperations, UtcTimestamp, build_operations_view from cloudfall.operator import ( + ERROR_FEED_INVALID, + ERROR_FEED_UNREACHABLE, + ERROR_GATEWAY_MATERIAL_INVALID, + ERROR_GATEWAY_UNDECLARED, ERROR_PROPOSAL_MISSING, AlertFeed, ApproveOptions, + DriftCheck, OperatorError, + PassKind, ProposalStatus, ProposalStore, TriggerKind, alert_resolution_verifier, + autonomous_pass, drift_resolution_verifier, engine_auditor, engine_executor, gateway_feed, + watch, ) from cloudfall.operator import approve as approve_proposal from cloudfall.project import ( @@ -191,7 +226,23 @@ from cloudfall.why import WhyError, WhyQuery, answer, render_why_document if TYPE_CHECKING: - from collections.abc import Callable, Mapping, Sequence + from collections.abc import Callable, Sequence + + from cloudfall.audit import AuditReport + from cloudfall.catalog import Operation + from cloudfall.operator import OperatorProposal + +def _serve_setup(args: McpServeArgs, _ctx: Ctx) -> None: + """Check the served fleet before ``mcp serve`` accepts a client.""" + # The check and the tools are defined at the end of the module, after the + # commands they reuse; App(mcp=...) needs these entry points first. + _check_served_fleet(args) + + +def _serve_tools(args: McpServeArgs, ctx: Ctx) -> list[McpTool]: + """Return the tools ``mcp serve`` provides beside the commands.""" + return _provided_tools(args, ctx) + app = App( "cloudfall", @@ -200,6 +251,18 @@ "Run a fleet from validated config, and keep the record of what an " "agent did to it" ), + # `treaty changelog-add cloudfall.app:app` records each release's + # interface changes here, beside the manifest snapshot it diffs against. + schema_changelog=Path(__file__).with_name("schema-changelog.json"), + mcp=McpServe( + args=McpServeArgs, + setup=_serve_setup, + tools=_serve_tools, + commands=served_commands, + bind=bound_arguments, + instructions=server_instructions, + exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "INVENTORY_UNREADABLE"], + ), ) app.scalar( ResourceId, @@ -990,6 +1053,8 @@ class Scaffolded: @app.command( "init", + # Lays out a project elsewhere; the server is confined to the one it serves. + mcp=False, description="Create a new project: fleet, applications, and operations", danger_level="mutating", # Two git steps of up to GIT_TIMEOUT_SECONDS each, then local files. @@ -1334,6 +1399,113 @@ def dashboard_build( ) +@dataclass(frozen=True, slots=True, kw_only=True) +class DashboardServeArgs(EvidenceArgs): + """Arguments of ``dashboard serve``.""" + + host: str = Flag( + default="127.0.0.1", + description=( + "Listen address; use the VPS address to expose it (default: 127.0.0.1)" + ), + ) + port: int = Flag( + default=8100, description="Listen port; 0 picks a free port (default: 8100)" + ) + refresh: int = Flag( + default=10, description="Evidence refresh interval in seconds (default: 10)" + ) + inspect_services: bool = Flag( + default=False, + description="Probe DNS, TLS, origin, and public routes on every refresh", + ) + + def __post_init__(self) -> None: + """Keep relative evidence paths inside the project; check the endpoint.""" + EvidenceArgs.__post_init__(self) + for flag, check in ( + ("host", self.endpoint), + ("refresh", partial(RefreshInterval.from_boundary, self.refresh)), + ): + try: + check() + except ValueError as error: + raise ParseError(str(error), context={"flag": flag}) from error + + def endpoint(self) -> ListenEndpoint: + """Return the address the server binds.""" + return ListenEndpoint(host=self.host, port=self.port) + + +class ServingPayload(Payload): + """``dashboard serve``: where the dashboard is served.""" + + command = "dashboard serve" + + +@dashboard.command( + "serve", + # Serves until stopped; a tool call would never answer. + mcp=False, + description=( + "Serve the read-only dashboard over HTTP until stopped; one event once " + "the server listens" + ), + danger_level="safe", + streaming=True, + timeout=None, + exit_codes=["PROJECT_INVALID", "CONFIG_INVALID", "PRECONDITION"], + examples=[ + ( + "Serve the dashboard on the default port", + "cloudfall dashboard serve --observed tmp/observed", + ), + ], +) +def dashboard_serve( + args: DashboardServeArgs, ctx: Ctx, fleet: Fleet +) -> Iterator[ServingPayload]: + """Bind the server, say where it listens, then serve until stopped.""" + sources = EvidenceSources( + project_directory=fleet.directory, + schema_directory=args.schemas, + observed_directory=fleet.path(args.observed), + service_observed_directory=fleet.path(args.service_observed), + deployments_directory=fleet.path(args.deployments), + inspect_services=args.inspect_services, + ) + refresh = RefreshInterval.from_boundary(args.refresh) + try: + server = create_dashboard_server( + sources, + args.endpoint(), + refresh, + request_log=lambda line: ctx.log("request", line=line), + ) + except ConfigValidationError as error: + raise _config_invalid(error) from error + except OSError as error: + message = f"cannot listen on {args.host}:{args.port}: {error.strerror}" + raise Exit.PRECONDITION( + message, context={"code": "dashboard_listen_failed"} + ) from error + try: + port = int(server.server_address[1]) + yield ServingPayload( + { + "status": "ok", + "dashboard": { + "url": f"http://{args.host}:{port}/", + "refreshSeconds": refresh.seconds, + "inspectServices": args.inspect_services, + }, + } + ) + server.serve_forever() + finally: + server.server_close() + + @dataclass(frozen=True, slots=True, kw_only=True) class ServicesInspectArgs(ProjectArgs): """Arguments of ``services inspect``.""" @@ -1790,6 +1962,8 @@ def operations_propose(args: ProposeArgs, ctx: Ctx) -> Decided: @operations.command( "approve", + # A person approves; no agent tool stands in for it. + mcp=False, description=( "Approve one recorded proposal, run it, and verify it; without --yes it " "shows the proposal and runs nothing" @@ -1849,10 +2023,9 @@ def operations_approve(args: ApproveDecisionArgs, ctx: Ctx) -> Decided: @dataclass(frozen=True, slots=True, kw_only=True) -class OperatorApproveArgs(ProposalArgs): - """Arguments of ``operator approve``.""" +class OperatorEngineArgs(ProposalArgs): + """Options of an operator command that runs engine playbooks.""" - proposal: ResourceId = Arg(description="Proposal id") engine: Path = Flag( default=default_engine_directory(), description="Engine directory containing ansible contracts (default: bundled)", @@ -1864,25 +2037,14 @@ class OperatorApproveArgs(ProposalArgs): observed: Path = Flag( default=Path("tmp/operator/observed"), description=( - "Observation directory for drift verification " + "Observation directory for drift checks and their verification " "(default: tmp/operator/observed)" ), ) - verify_timeout: float = Flag( - default=180.0, - description="Seconds to wait for the trigger to resolve (default: 180)", - ) gateway_url: str | None = Flag( default=None, description="Alerts endpoint (default: derived from the declared gateway)", ) - gateway_ca: Path | None = Flag(default=None, description="Gateway CA file") - gateway_cert: Path | None = Flag( - default=None, description="Client certificate for the gateway" - ) - gateway_key: Path | None = Flag( - default=None, description="Client key for the gateway", secret=False - ) def __post_init__(self) -> None: """Keep relative paths inside the project.""" @@ -1900,6 +2062,44 @@ def context(self, fleet: Fleet, ctx: Ctx) -> EngineContext: run=_step_runner(ctx, fleet.directory), ) + def auditor(self, fleet: Fleet, ctx: Ctx) -> Callable[[], AuditReport]: + """Return the audited drift check over the operator's observations.""" + return engine_auditor( + self.context(fleet, ctx), fleet.inventory, fleet.path(self.observed) + ) + + def verifier_for( + self, fleet: Fleet, ctx: Ctx, feed: Callable[[], AlertFeed] + ) -> Callable[[OperatorProposal], Callable[[OperatorProposal], bool]]: + """Return how a proposal's resolution is verified, by its trigger.""" + + def verifier( + proposal: OperatorProposal, + ) -> Callable[[OperatorProposal], bool]: + if proposal.trigger_kind is TriggerKind.ALERT: + return alert_resolution_verifier(feed()) + return drift_resolution_verifier(self.auditor(fleet, ctx)) + + return verifier + + +@dataclass(frozen=True, slots=True, kw_only=True) +class OperatorApproveArgs(OperatorEngineArgs): + """Arguments of ``operator approve``.""" + + proposal: ResourceId = Arg(description="Proposal id") + verify_timeout: float = Flag( + default=180.0, + description="Seconds to wait for the trigger to resolve (default: 180)", + ) + gateway_ca: Path | None = Flag(default=None, description="Gateway CA file") + gateway_cert: Path | None = Flag( + default=None, description="Client certificate for the gateway" + ) + gateway_key: Path | None = Flag( + default=None, description="Client key for the gateway", secret=False + ) + def feed(self, fleet: Fleet) -> AlertFeed: """Return the gateway's alert feed, which an alert proposal needs.""" if ( @@ -1914,13 +2114,39 @@ def feed(self, fleet: Fleet) -> AlertFeed: raise Exit.PRECONDITION( message, context={"code": "operator_gateway_material_missing"} ) + return _gateway_feed( + fleet, + self.gateway_ca, + self.gateway_cert, + self.gateway_key, + self.gateway_url, + ) + + +def _gateway_feed( + fleet: Fleet, ca: Path, cert: Path, key: Path, url: str | None +) -> AlertFeed: + try: return gateway_feed( fleet.inventory, - ca_path=self.gateway_ca, - certificate_path=self.gateway_cert, - key_path=self.gateway_key, - url_override=self.gateway_url, + ca_path=ca, + certificate_path=cert, + key_path=key, + url_override=url, ) + except OperatorError as error: + raise _operator_failed(error) from error + + +def _operator_failed(error: OperatorError) -> Exception: + context = {"code": error.code} + if error.code == ERROR_FEED_UNREACHABLE: + return Exit.UNAVAILABLE(error.message, context=context) + if error.code in {ERROR_GATEWAY_UNDECLARED, ERROR_GATEWAY_MATERIAL_INVALID}: + return Exit.PRECONDITION(error.message, context=context) + if error.code == ERROR_FEED_INVALID: + return Exit.GENERAL_ERROR(error.message, context=context) + return _proposal_failed(error) @dataclass(frozen=True, slots=True) @@ -1941,6 +2167,7 @@ class Approved: "CONFIG_INVALID", "NOT_FOUND", "PRECONDITION", + "UNAVAILABLE", "RECORD_INVALID", "ENGINE_STEP_FAILED", "NOT_VERIFIED", @@ -1958,24 +2185,17 @@ def operator_approve( ) -> Approved: """Run the proposal's operation, then wait for its trigger to resolve.""" store = args.store(fleet) - context = args.context(fleet, ctx) + verifier_for = args.verifier_for(fleet, ctx, partial(args.feed, fleet)) try: - pending = store.load(args.proposal) - if pending.trigger_kind is TriggerKind.ALERT: - verifier = alert_resolution_verifier(args.feed(fleet)) - else: - verifier = drift_resolution_verifier( - engine_auditor(context, fleet.inventory, fleet.path(args.observed)) - ) proposal = approve_proposal( store, args.proposal, - engine_executor(context), - verifier, + engine_executor(args.context(fleet, ctx)), + verifier_for(store.load(args.proposal)), ApproveOptions(verify_timeout_seconds=args.verify_timeout), ) except OperatorError as error: - raise _proposal_failed(error) from error + raise _operator_failed(error) from error except LifecycleError as error: raise _lifecycle_failed(error) from error verified = proposal.status is ProposalStatus.VERIFIED @@ -1990,6 +2210,134 @@ def operator_approve( return approved +# operator run + + +@dataclass(frozen=True, slots=True, kw_only=True) +class OperatorRunArgs(OperatorEngineArgs): + """Arguments of ``operator run``.""" + + gateway_ca: Path = Flag(description="Gateway CA file") + gateway_cert: Path = Flag(description="Client certificate for the gateway") + gateway_key: Path = Flag(description="Client key for the gateway", secret=False) + interval: float | None = Flag( + default=None, + description="Seconds between watch passes (default: one pass, then exit)", + ) + drift_interval: float | None = Flag( + default=None, + description=( + "Seconds between audited drift checks (default: no drift checks; " + "a single pass runs one when set)" + ), + ) + + def __post_init__(self) -> None: + """Keep relative paths inside the project; intervals are positive.""" + OperatorEngineArgs.__post_init__(self) + for flag, value in ( + ("interval", self.interval), + ("drift-interval", self.drift_interval), + ): + if value is not None and value <= 0: + message = f"--{flag} is a number of seconds above 0" + raise ParseError(message, context={"flag": flag, "value": value}) + + def feed(self, fleet: Fleet) -> AlertFeed: + """Return the gateway's alert feed.""" + return _gateway_feed( + fleet, + self.gateway_ca, + self.gateway_cert, + self.gateway_key, + self.gateway_url, + ) + + +class OperatorPassPayload(Payload): + """``operator run``: one watch pass and what it proposed or executed.""" + + command = "operator run" + writes = True + + +@operator.command( + "run", + # A watch loop that acts until stopped; it runs as a service. + mcp=False, + description=( + "Watch declared alerts and drift, write proposal receipts, and run the " + "proposals a declared policy licenses; one event per pass" + ), + danger_level="mutating", + streaming=True, + timeout=None, + supports_raw_payload=True, + exit_codes=[ + "PROJECT_INVALID", + "CONFIG_INVALID", + "NOT_FOUND", + "PRECONDITION", + "UNAVAILABLE", + "RECORD_INVALID", + "ENGINE_STEP_FAILED", + ], + subprocess=Subprocess("ansible-playbook"), + required_tools={"ansible-playbook": "2.21.0"}, + examples=[ + ( + "Watch every minute, checking drift every ten", + "cloudfall operator run --gateway-ca ca.pem --gateway-cert operator.pem " + "--gateway-key operator.key --interval 60 --drift-interval 600", + ), + ], +) +def operator_run( + args: OperatorRunArgs, ctx: Ctx, fleet: Fleet +) -> Iterator[OperatorPassPayload]: + """Run watch passes until stopped, or one round without ``--interval``.""" + store = args.store(fleet) + feed = args.feed(fleet) + autonomy = None + if fleet.inventory.operator_policies: + autonomy = partial( + autonomous_pass, + store, + fleet.inventory, + engine_executor(args.context(fleet, ctx)), + args.verifier_for(fleet, ctx, lambda: feed), + ) + drift = None + if args.drift_interval is not None: + drift = DriftCheck(args.auditor(fleet, ctx), args.drift_interval) + passes = watch( + feed, + fleet.inventory, + store, + drift=drift, + autonomy=autonomy, + interval_seconds=args.interval, + ) + try: + for report in passes: + effect = _PASS_EFFECTS[report.kind] if report.changed else "noop" + yield OperatorPassPayload( + {"status": "ok", "effect": effect, **report.as_dict()} + ) + except OperatorError as error: + raise _operator_failed(error) from error + except LifecycleError as error: + raise _lifecycle_failed(error) from error + + +_PASS_EFFECTS: Mapping[PassKind, str] = { + PassKind.ALERTS: "created", + PassKind.DRIFT: "created", + PassKind.AUTONOMY: "updated", +} +"""What a pass that changed something did: wrote proposals, or executed them.""" + + # migrate @@ -2948,3 +3296,261 @@ def data_migrate( except LifecycleError as error: raise _lifecycle_failed(error) from error return DataMigratePayload({**body, "effect": "updated"}) + + +# mcp serve: the fleet check before serving, and the tools beside the commands + + +def _check_served_fleet(args: McpServeArgs) -> None: + """Refuse to serve a fleet that cannot be read, before any client connects.""" + root = resolve_root(args) + if root.mode is ServerMode.PROJECT: + try: + validate_config(root.directory, args.schemas) + except ConfigValidationError as error: + raise _config_invalid(error) from error + return + if root.inventory is None: + message = "a repository is served with its inventory" + raise TypeError(message) + try: + read_fleet(read_inventory(root.inventory), 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 + _served_catalog(args, root) + + +def _served_catalog(args: McpServeArgs, root: ServerRoot) -> OperationCatalog: + try: + return load_catalog( + root.directory, args.schemas, root.path(args.operations) + ) + except ConfigValidationError as error: + raise _config_invalid(error) from error + + +def _provided_tools(args: McpServeArgs, ctx: Ctx) -> list[McpTool]: + """Return the tools Cloudfall serves beside its commands, for this fleet.""" + root = resolve_root(args) + if root.mode is ServerMode.FLEET: + return [ + _operation_tool(args, root, operation) + for operation in _served_catalog(args, root).operations + ] + return _engine_tools(args, root, ctx) + + +_PROPOSE_EXIT_CODES = ("CONFIG_INVALID", "NOT_FOUND", "PRECONDITION", "CHECK_FAILED") + + +def _operation_tool( + args: McpServeArgs, root: ServerRoot, operation: Operation +) -> McpTool: + """One declared operation as a tool: calling it previews and records it. + + It is read-only in effect, since it runs check mode; the destructive + hint says what approving it would mean. + """ + declared = { + declared_input.name.value: {"type": "string"} + for declared_input in operation.inputs + } + required = [ + declared_input.name.value + for declared_input in operation.inputs + if declared_input.required + ] + schema: dict[str, object] = { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": ( + "Host or group the operation runs against, as its target " + f"scope ({operation.targets.value}) requires" + ), + }, + "inputs": { + "type": "object", + "properties": declared, + "required": required, + "additionalProperties": False, + }, + }, + "required": ["inputs"] if required else [], + "additionalProperties": False, + } + + def propose_operation(arguments: Mapping[str, object], ctx: Ctx) -> Decided: + inputs = cast("Mapping[str, str]", arguments.get("inputs", {})) + target = arguments.get("target") + decided = operations_propose( + ProposeArgs( + repository=root.directory, + schemas=args.schemas, + operations=args.operations, + operation=operation.operation_id, + target=None if target is None else str(target), + input=tuple(f"{name}={value}" for name, value in inputs.items()), + observed=root.path(args.observed), + decisions=args.decisions, + ), + ctx, + ) + record = cast("Mapping[str, Mapping[str, object]]", decided.decision) + approve = f"{APPROVAL_COMMAND} {record['metadata']['id']} --yes" + return dataclasses.replace(decided, next=[approve]) + + return McpTool( + name=operation_tool_name(operation), + description=operation_tool_description(operation), + input_schema=schema, + handler=propose_operation, + danger_level="mutating", + read_only=True, + destructive=operation.risk is RiskLevel.DESTRUCTIVE, + exit_codes=_PROPOSE_EXIT_CODES, + ) + + +@dataclass(frozen=True, slots=True) +class ArtifactBuilt: + """``build_artifact``: the release artifact and its hash.""" + + status: str + artifact: dict[str, object] = Out(ordered=True) + + +@dataclass(frozen=True, slots=True) +class Converged: + """A ``converge_*`` tool: the engine playbook that ran on every server.""" + + status: str + action: str + playbook: str + + +_ENGINE_EXIT_CODES = ("PRECONDITION", "ENGINE_STEP_FAILED") +_COMPONENT_PATTERN = r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$" + + +def _engine_tools(args: McpServeArgs, root: ServerRoot, ctx: Ctx) -> list[McpTool]: + """Return the engine work no ``cloudfall`` command covers, as tools.""" + del ctx # each call brings its own + + def context(call: Ctx) -> EngineContext: + return EngineContext( + project_directory=root.directory, + schema_directory=args.schemas, + engine_directory=args.engine, + inventory_file=root.path(args.inventory_file), + run=_step_runner(call, root.directory), + ) + + def build_artifact(arguments: Mapping[str, object], call: Ctx) -> ArtifactBuilt: + component = ResourceId.from_boundary(arguments["component"]) + try: + built = build_release_artifact( + context(call), + component, + str(arguments["git_ref"]), + root.path(args.artifacts), + ) + except LifecycleError as error: + raise _lifecycle_failed(error) from error + return ArtifactBuilt(status="ok", artifact=built) + + def converge( + action: str, + playbook: str, + extra_vars: Callable[[Mapping[str, object]], dict[str, object]], + ) -> Callable[[Mapping[str, object], Ctx], Converged]: + def run(arguments: Mapping[str, object], call: Ctx) -> Converged: + try: + run_engine_playbook(context(call), playbook, extra_vars(arguments)) + except LifecycleError as error: + raise _lifecycle_failed(error) from error + return Converged(status="ok", action=action, playbook=playbook) + + run.__annotations__ = { + "arguments": Mapping[str, object], + "call": Ctx, + "return": Converged, + } + return run + + def domain_vars(arguments: Mapping[str, object]) -> dict[str, object]: + return { + "cloudfall_domains_issue_certificates": bool( + arguments.get("issue_certificates", False) + ), + "cloudfall_domains_receipt_directory": str(root.path(args.deployments)), + } + + no_inputs: dict[str, object] = { + "type": "object", + "properties": {}, + "additionalProperties": False, + } + return [ + McpTool( + name="build_artifact", + description=( + "Clone, package, and hash one component release under the " + "artifacts directory, for deploy to ship" + ), + input_schema={ + "type": "object", + "properties": { + "component": {"type": "string", "pattern": _COMPONENT_PATTERN}, + "git_ref": {"type": "string", "minLength": 1}, + }, + "required": ["component", "git_ref"], + "additionalProperties": False, + }, + handler=build_artifact, + danger_level="mutating", + exit_codes=_ENGINE_EXIT_CODES, + ), + McpTool( + name="converge_baseline", + description=( + "Run baseline.yml on every declared server: bootstrap, UTC, SSH " + "hardening, unattended upgrades, and the declared firewall" + ), + input_schema=no_inputs, + handler=converge("converge-baseline", "baseline.yml", lambda _: {}), + danger_level="destructive", + exit_codes=_ENGINE_EXIT_CODES, + ), + McpTool( + name="converge_services", + description=( + "Run services.yml on every declared server: install and configure " + "every declared infrastructure service" + ), + input_schema=no_inputs, + handler=converge("converge-services", "services.yml", lambda _: {}), + danger_level="destructive", + exit_codes=_ENGINE_EXIT_CODES, + ), + McpTool( + name="converge_domains", + description=( + "Run domains.yml: render every declared domain route, and issue " + "missing certificates when issue_certificates is true" + ), + input_schema={ + "type": "object", + "properties": {"issue_certificates": {"type": "boolean"}}, + "additionalProperties": False, + }, + handler=converge("converge-domains", "domains.yml", domain_vars), + danger_level="destructive", + exit_codes=_ENGINE_EXIT_CODES, + ), + ] diff --git a/sdk/src/cloudfall/arguments.py b/sdk/src/cloudfall/arguments.py deleted file mode 100644 index 3f14bd6..0000000 --- a/sdk/src/cloudfall/arguments.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Strict argument parsing shared by every Cloudfall command-line entry point. - -Agents guess flag names. With argparse's default prefix matching a guessed -``--api-key`` silently binds to ``--api-key-file``, and its "unrecognized -arguments" error repeats every stray value verbatim, so a secret an agent -passes by mistake lands in stderr. The parser here matches long options -exactly, reports every usage error as the same JSON envelope the commands -use, and names unrecognized options without their values. -""" - -from __future__ import annotations - -import argparse -import sys -import textwrap -from typing import TYPE_CHECKING, Any, NoReturn - -from cloudfall.commands import EXIT_CODES -from cloudfall.domain import ReleaseId, ResourceId -from cloudfall.manifest import build_manifest, leaf_parsers -from cloudfall.output import ( - TOOL_VERSION, - OutputOptions, - SchemaVersion, - configure_output, - current_invocation, - schema_versions, - supported_schema_major, - write_error, - write_result, -) -from cloudfall.project import project_path - -if TYPE_CHECKING: - from collections.abc import Sequence - from pathlib import Path - - from _typeshed import SupportsWrite - -INVALID_ARGUMENT = "invalid_argument" - - -class StrictArgumentParser(argparse.ArgumentParser): - """Argument parser without abbreviations that fails with a JSON error. - - Subparsers are created with the parent's class, so every nested command - inherits the same behavior. - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 - mirrors ArgumentParser - """Create the parser with long-option abbreviations disabled.""" - kwargs["allow_abbrev"] = False - super().__init__(*args, **kwargs) - self.output_options = False - """Whether ``add_output_options`` gave this command tree ``--quiet``.""" - - def print_help(self, file: SupportsWrite[str] | None = None) -> None: - """Print help to stdout for a person, to stderr when stdout is not a TTY. - - A caller capturing stdout expects JSON there, so help text goes to - stderr instead, and ``--quiet`` silences it like any other stderr. - """ - if file is None: - if sys.stdout.isatty(): - file = sys.stdout - elif current_invocation().options.quiet: - return - else: - file = sys.stderr - super().print_help(file) - - def format_help(self) -> str: - """Return the help text, ending with the exit codes where they apply.""" - text = super().format_help() - if not self.output_options: - return text - return f"{text}\n{exit_code_help()}" - - def error(self, message: str) -> NoReturn: - """Emit the usage error as a JSON envelope and exit with code 2.""" - payload = { - "status": "error", - "error": {"code": INVALID_ARGUMENT, "message": f"{self.prog}: {message}"}, - } - raise SystemExit(write_error(payload, 2)) - - -class VersionAction(argparse.Action): - """``--version``: print the tool version as a JSON result and exit 0.""" - - def __init__(self, option_strings: Sequence[str], dest: str) -> None: - """Register a flag that takes no value.""" - super().__init__( - option_strings, - dest, - nargs=0, - default=argparse.SUPPRESS, - help="print the cloudfall version as JSON and exit", - ) - - def __call__( - self, - parser: argparse.ArgumentParser, # noqa: ARG002 - argparse Action signature - namespace: argparse.Namespace, # noqa: ARG002 - values: object, # noqa: ARG002 - option_string: str | None = None, # noqa: ARG002 - ) -> NoReturn: - """Write the version document before any required argument is checked.""" - write_result( - { - "status": "ok", - "version": TOOL_VERSION, - "schemaVersions": schema_versions(), - } - ) - raise SystemExit(0) - - -class SchemaAction(argparse.Action): - """``--schema``: print every command's flags and output contract, exit 0.""" - - def __init__(self, option_strings: Sequence[str], dest: str) -> None: - """Register a flag that takes no value.""" - super().__init__( - option_strings, - dest, - nargs=0, - default=argparse.SUPPRESS, - help=( - "print every command with its flags, effect, and output schema " - "(a stability tier per top-level key) as JSON and exit" - ), - ) - - def __call__( - self, - parser: argparse.ArgumentParser, - namespace: argparse.Namespace, # noqa: ARG002 - argparse Action signature - values: object, # noqa: ARG002 - option_string: str | None = None, # noqa: ARG002 - ) -> NoReturn: - """Write the manifest before any required argument is checked.""" - write_result(build_manifest(parser)) - raise SystemExit(0) - - -def root_parser(prog: str) -> StrictArgumentParser: - """Create a command's top-level parser, with ``--version``.""" - parser = StrictArgumentParser(prog=prog) - parser.add_argument("--version", action=VersionAction) - parser.add_argument("--schema", "--print-schema", action=SchemaAction) - parser.add_argument( - "--schema-version", - type=schema_major_argument, - metavar="MAJOR", - help=( - "fail unless this build still writes output schema MAJOR, so a " - "pinned caller stops at a breaking release instead of misreading it" - ), - ) - return parser - - -OUTPUT_FORMATS = ("json",) -"""What ``--output`` accepts. JSON is the only format, so the flag changes -nothing; it exists so a caller that asks for JSON the usual way gets it.""" - -_QUIET = "--quiet" -_WARNINGS_AS_ERRORS = "--warnings-as-errors" - - -def add_output_options(parser: StrictArgumentParser) -> None: - """Accept the output options before the command and after every leaf command. - - ``--output json``, ``--quiet`` and ``--warnings-as-errors`` describe the - output channels, not the command, so every command takes them, and - every command's ``--help`` ends with the exit codes. - """ - parser.output_options = True - _add_output_arguments(parser, root=True) - for _name, leaf in leaf_parsers(parser): - if leaf is not parser: - if isinstance(leaf, StrictArgumentParser): - leaf.output_options = True - _add_output_arguments(leaf, root=False) - - -def exit_code_help() -> str: - """Return the exit codes as the closing section of ``--help``.""" - lines = ["exit codes:"] - for exit_code in EXIT_CODES: - meaning = exit_code.meaning.replace("`", "") - lines.extend( - textwrap.wrap( - meaning, - width=78, - initial_indent=f" {exit_code.code} ", - subsequent_indent=" ", - ) - ) - return "\n".join(lines) + "\n" - - -def _add_output_arguments(parser: argparse.ArgumentParser, *, root: bool) -> None: - # On a leaf, SUPPRESS keeps an absent flag from resetting the value - # given before the command. - parser.add_argument( - "--output", - dest="output_format", - choices=OUTPUT_FORMATS, - default=OUTPUT_FORMATS[0] if root else argparse.SUPPRESS, - help="output format; JSON is the only one and the default", - ) - parser.add_argument( - _QUIET, - action="store_true", - default=False if root else argparse.SUPPRESS, - help="write nothing to stderr, not even an error; read the exit code", - ) - parser.add_argument( - _WARNINGS_AS_ERRORS, - action="store_true", - default=False if root else argparse.SUPPRESS, - help="fail, exit 1, when a result carries any warning", - ) - - -def parse_arguments( - parser: StrictArgumentParser, argv: Sequence[str] | None -) -> argparse.Namespace: - """Parse ``argv``, rejecting unrecognized input without echoing values. - - Where the parser defines the output options, they apply before parsing, - read from the raw tokens, so a usage error honors ``--quiet``; once - parsing succeeds, the parsed values replace them. A parser without them - reports ``--quiet`` as the unrecognized option it is. - """ - tokens = list(sys.argv[1:] if argv is None else argv) - given = tokens[: tokens.index("--")] if "--" in tokens else tokens - if parser.output_options: - configure_output( - OutputOptions( - quiet=_QUIET in given, warnings_as_errors=_WARNINGS_AS_ERRORS in given - ) - ) - arguments, unrecognized = parser.parse_known_args(argv) - if unrecognized: - options = sorted( - {token.partition("=")[0] for token in unrecognized if token.startswith("-")} - ) - values = len(unrecognized) - sum( - 1 for token in unrecognized if token.startswith("-") - ) - parts = [] - if options: - parts.append(f"unrecognized options {' '.join(options)}") - if values: - parts.append(f"{values} unexpected value(s), not shown") - parser.error("; ".join(parts)) - configure_output( - OutputOptions( - quiet=bool(getattr(arguments, "quiet", False)), - warnings_as_errors=bool(getattr(arguments, "warnings_as_errors", False)), - ) - ) - return arguments - - -def resource_id_argument(value: str) -> ResourceId: - """Convert a resource id argument, failing as a usage error.""" - try: - return ResourceId.from_boundary(value) - except ValueError as error: - raise argparse.ArgumentTypeError(str(error)) from error - - -def release_id_argument(value: str) -> ReleaseId: - """Convert a release id argument, failing as a usage error.""" - try: - return ReleaseId.from_boundary(value) - except ValueError as error: - raise argparse.ArgumentTypeError(str(error)) from error - - -def schema_major_argument(value: str) -> int: - """Convert a ``--schema-version`` pin, failing as a usage error.""" - try: - return supported_schema_major(value) - except ValueError as error: - raise argparse.ArgumentTypeError(str(error)) from error - - -def schema_version_argument(value: str) -> SchemaVersion: - """Convert a ``MAJOR.MINOR`` output schema version, failing as a usage error.""" - try: - return SchemaVersion.parse(value) - except ValueError as error: - raise argparse.ArgumentTypeError(str(error)) from error - - -def project_path_argument(value: str) -> Path: - """Convert a runtime path that must stay inside the project directory.""" - try: - return project_path(value) - except ValueError as error: - raise argparse.ArgumentTypeError(str(error)) from error diff --git a/sdk/src/cloudfall/cli.py b/sdk/src/cloudfall/cli.py index 405fb18..2366453 100644 --- a/sdk/src/cloudfall/cli.py +++ b/sdk/src/cloudfall/cli.py @@ -1,1468 +1,21 @@ -"""Command-line boundary for Cloudfall operations.""" +"""The ``cloudfall`` console script: every command runs on ``cloudfall.app``.""" from __future__ import annotations -import errno -import os import sys -import time -from pathlib import Path from typing import TYPE_CHECKING -if TYPE_CHECKING: - import argparse - from argparse import Namespace - from collections.abc import Callable, Mapping, Sequence - - from cloudfall.operator import AlertFeed, OperatorProposal - -from cloudfall.ansible_api import ( - ANSIBLE_CONFIG_FILE, - AnsibleReadError, - InventorySource, - inventory_from_config, - read_inventory, -) -from cloudfall.ansible_reader import read_fleet -from cloudfall.app import app as treaty_app -from cloudfall.arguments import ( - StrictArgumentParser, - add_output_options, - parse_arguments, - project_path_argument, - release_id_argument, - resource_id_argument, - root_parser, - schema_version_argument, -) -from cloudfall.catalog import CATALOG_DIRECTORY -from cloudfall.dashboard import RefreshInterval -from cloudfall.dashboard_server import ( - EvidenceSources, - ListenEndpoint, - create_dashboard_server, -) -from cloudfall.decision import ( - DECISION_DIRECTORY, - DecisionError, -) -from cloudfall.inventory import PlatformInventory -from cloudfall.lifecycle import ( - EngineContext, -) -from cloudfall.operator import ( - OperatorError, - ProposalStore, - TriggerKind, - alert_resolution_verifier, - autonomous_pass, - drift_pass, - drift_resolution_verifier, - engine_auditor, - engine_executor, - gateway_feed, -) -from cloudfall.operator import ( - run_once as operator_run_once, -) -from cloudfall.output import ( - begin_invocation, - current_invocation, - exit_code, - schema_changes_since, - schema_versions, - write_error, - write_result, -) -from cloudfall.project import ( - PROJECT_DIRECTORY_VARIABLE, - ProjectError, - is_project, - project_context, -) -from cloudfall.resources import default_engine_directory, default_schema_directory -from cloudfall.validation import ( - ConfigValidationError, - SchemaCatalog, - ValidatedConfig, - validate_config, -) -from cloudfall.why import WhyError - - -def _add_config_parsers( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - config_parser = commands.add_parser("config", help="operate on the config") - config_commands = config_parser.add_subparsers(dest="config_command", required=True) - validate_parser = config_commands.add_parser( - "validate", help="validate the YAML config and resource references" - ) - _add_project_directory_argument(validate_parser) - validate_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - - inventory_parser = commands.add_parser( - "inventory", help="query validated platform inventory" - ) - inventory_commands = inventory_parser.add_subparsers( - dest="inventory_command", required=True - ) - show_parser = inventory_commands.add_parser( - "show", help="show non-secret platform inventory" - ) - _add_project_directory_argument(show_parser) - _add_inventory_argument(show_parser) - show_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - - -def _add_project_directory_argument(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--project", - type=Path, - default=None, - help=( - f"project directory to run in (default: ${PROJECT_DIRECTORY_VARIABLE}, " - "else the current directory when it is a project)" - ), - ) - - -def _add_projectless_parsers( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - """Add the commands that run before, or without, a project.""" - _add_init_parser(commands) - _add_changelog_parser(commands) - - -def _add_changelog_parser( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - changelog_parser = commands.add_parser( - "changelog", - help="list changes to the JSON output contract, newest first", - ) - changelog_parser.add_argument( - "--since", - type=schema_version_argument, - metavar="MAJOR.MINOR", - help="only changes after this output schema version", - ) - - -def _add_init_parser( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - init_parser = commands.add_parser( - "init", help="create a new project: fleet, applications, and operations" - ) - init_parser.add_argument( - "directory", - type=Path, - nargs="?", - default=Path(), - help="project directory to create; must be empty or absent (default: .)", - ) - init_parser.add_argument( - "--name", - help="package name written to pyproject.toml (default: the directory name)", - ) - init_parser.add_argument( - "--description", - help=( - "one line saying what the project manages, written to the README " - "and pyproject.toml" - ), - ) - - -def _add_operations_parsers( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - """Add the commands that read the declared operations catalog.""" - operations_parser = commands.add_parser( - "operations", help="read the catalog of operations an agent may run" - ) - operations_commands = operations_parser.add_subparsers( - dest="operations_command", required=True - ) - for name, help_text in ( - ("list", "list every declared operation with its risk level"), - ("show", "show one declared operation in full"), - ("propose", "run one operation in check mode and record what it would do"), - ("approve", "approve one recorded proposal, run it, and verify it"), - ("decisions", "list the decision records this repository holds"), - ): - subparser = operations_commands.add_parser(name, help=help_text) - subparser.add_argument( - "--repository", - type=Path, - help=( - "repository holding the operations directory and the " - "playbooks it declares (default: the current directory)" - ), - ) - if name in {"show", "propose"}: - subparser.add_argument( - "operation", type=resource_id_argument, help="operation id" - ) - if name == "approve": - subparser.add_argument( - "decision", - type=resource_id_argument, - help="decision id from `operations propose`", - ) - subparser.add_argument( - "--approver", - help="who is approving (default: the USER environment variable)", - ) - subparser.add_argument( - "--yes", - action="store_true", - help=( - "change the servers; without this flag the command shows " - "the recorded proposal and runs nothing" - ), - ) - if name == "propose": - subparser.add_argument( - "--target", - help=( - "host or group the operation runs against, as the " - "operation's target scope requires" - ), - ) - subparser.add_argument( - "--input", - action="append", - default=[], - metavar="NAME=VALUE", - help="value for one declared input (repeatable)", - ) - subparser.add_argument( - "--observed", - type=project_path_argument, - default=Path("tmp/observed"), - help=( - "snapshot directory the proposal cites as its basis " - "(default: tmp/observed)" - ), - ) - subparser.add_argument( - "--decisions", - type=project_path_argument, - default=Path(DECISION_DIRECTORY), - help=( - "directory holding the decision records " - f"(default: {DECISION_DIRECTORY})" - ), - ) - if name in {"approve", "decisions"}: - subparser.add_argument( - "--decisions", - type=project_path_argument, - default=Path(DECISION_DIRECTORY), - help=( - "directory holding the decision records " - f"(default: {DECISION_DIRECTORY})" - ), - ) - subparser.add_argument( - "--operations", - type=project_path_argument, - default=Path(CATALOG_DIRECTORY), - help=( - "directory holding the operation documents " - f"(default: {CATALOG_DIRECTORY})" - ), - ) - subparser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - - -def _add_observe_parser( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - """Add the command that collects snapshots from the fleet.""" - observe_parser = commands.add_parser( - "observe", help="collect read-only server snapshots from the fleet" - ) - _add_project_directory_argument(observe_parser) - _add_inventory_argument(observe_parser) - observe_parser.add_argument( - "--output-dir", - type=project_path_argument, - default=Path("tmp/observed"), - help="snapshot directory to write (default: tmp/observed)", - ) - observe_parser.add_argument( - "--limit", - help="Ansible host pattern to inspect a subset of the fleet", - ) - observe_parser.add_argument( - "--engine", - type=Path, - default=default_engine_directory(), - help="engine directory holding the playbooks (default: bundled engine)", - ) - observe_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - - -def _add_inventory_argument(parser: StrictArgumentParser) -> None: - """Let a command read the fleet from the team's own Ansible inventory.""" - parser.add_argument( - "--inventory", - type=Path, - help=( - "Ansible inventory to read the fleet from instead of a project " - f"(default: the inventory an {ANSIBLE_CONFIG_FILE} in the current " - "directory names)" - ), - ) - - -def _add_add_parsers( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - add_parser = commands.add_parser( - "add", help="write a fleet resource into the project" - ) - add_commands = add_parser.add_subparsers(dest="add_command", required=True) - - key_parser = add_commands.add_parser( - "ssh-key", help="declare an SSH public key read from a file" - ) - key_parser.add_argument("key_file", type=Path, help="e.g. ~/.ssh/id_ed25519.pub") - key_parser.add_argument("--owner", required=True, help="who the key belongs to") - key_parser.add_argument("--id", help="resource id (default: the owner)") - key_parser.add_argument( - "--environment", default="production", help="(default: production)" - ) - key_parser.add_argument("--description") - - type_parser = add_commands.add_parser( - "server-type", - help="declare a server type from the bundled Debian 13 baseline", - ) - type_parser.add_argument("id", help="resource id, e.g. debian-application") - type_parser.add_argument("--description") - - server_parser = add_commands.add_parser( - "server", help="declare a server; creates its server type when missing" - ) - server_parser.add_argument("id", help="resource id, e.g. h1") - server_parser.add_argument( - "--address", required=True, help="IP address or hostname to connect to" - ) - server_parser.add_argument( - "--type", - default="debian-application", - help="server type id (default: debian-application)", - ) - server_parser.add_argument( - "--environment", default="production", help="(default: production)" - ) - server_parser.add_argument( - "--hostname", help="(default: the address when it is a hostname, else the id)" - ) - server_parser.add_argument("--ssh-user", default="root", help="(default: root)") - server_parser.add_argument("--ssh-port", type=int, default=22, help="(default: 22)") - server_parser.add_argument("--description") - - for subparser in (key_parser, type_parser, server_parser): - _add_project_directory_argument(subparser) - subparser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - _add_why_parser(commands) - - -def _add_why_parser( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - """Add the command that answers "why did the agent do that".""" - why_parser = commands.add_parser( - "why", - help=( - "answer why the agent did that, from the record, for a host, an " - "operation or a time window" - ), - ) - why_parser.add_argument( - "--repository", - type=Path, - help="repository holding the decision records (default: the current directory)", - ) - why_parser.add_argument( - "--host", help="only decisions whose record names this host" - ) - why_parser.add_argument( - "--operation", - type=resource_id_argument, - help="only decisions of this declared operation", - ) - why_parser.add_argument( - "--since", - help="only decisions with a moment at or after this ISO 8601 time or date", - ) - why_parser.add_argument( - "--until", - help="only decisions with a moment at or before this ISO 8601 time or date", - ) - why_parser.add_argument( - "--format", - choices=("json", "html"), - default="json", - help="answer as JSON or as one HTML page (default: json)", - ) - why_parser.add_argument( - "--decisions", - type=project_path_argument, - default=Path(DECISION_DIRECTORY), - help=f"directory holding the decision records (default: {DECISION_DIRECTORY})", - ) - why_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - - -def _parser() -> StrictArgumentParser: - parser = _command_parser() - add_output_options(parser) - return parser - - -def _command_parser() -> StrictArgumentParser: - parser = root_parser("cloudfall") - commands = parser.add_subparsers(dest="command", required=True) - _add_projectless_parsers(commands) - _add_add_parsers(commands) - _add_config_parsers(commands) - - _add_observe_parser(commands) - _add_operations_parsers(commands) - - audit_parser = commands.add_parser( - "audit", help="compare the config with observed server snapshots" - ) - _add_project_directory_argument(audit_parser) - _add_inventory_argument(audit_parser) - audit_parser.add_argument( - "--observed", - type=project_path_argument, - required=True, - help="directory containing observed-server JSON snapshots", - ) - audit_parser.add_argument( - "--env-receipts", - type=project_path_argument, - default=Path("tmp/env-receipts"), - help=( - "rendered environment receipt directory used for env-file " - "drift checks (default: tmp/env-receipts)" - ), - ) - - _add_operator_parsers(commands) - _add_backup_parsers(commands) - _add_secrets_parsers(commands) - - services_parser = commands.add_parser( - "services", help="inspect and report public service lifecycles" - ) - services_commands = services_parser.add_subparsers( - dest="services_command", required=True - ) - services_inspect_parser = services_commands.add_parser( - "inspect", help="collect DNS, TLS, origin, and public route evidence" - ) - _add_project_directory_argument(services_inspect_parser) - services_inspect_parser.add_argument( - "--output-dir", - type=project_path_argument, - default=Path("tmp/observed-services"), - help="service observation directory (default: tmp/observed-services)", - ) - services_inspect_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - services_status_parser = services_commands.add_parser( - "status", help="derive service lifecycle status from current evidence" - ) - _add_project_directory_argument(services_status_parser) - services_status_parser.add_argument( - "--observed", - type=project_path_argument, - required=True, - help="directory containing observed-server JSON snapshots", - ) - services_status_parser.add_argument( - "--service-observed", - type=project_path_argument, - default=Path("tmp/observed-services"), - help="domain observation directory (default: tmp/observed-services)", - ) - services_status_parser.add_argument( - "--deployments", - type=project_path_argument, - default=Path("tmp/deployments"), - help="deployment receipt directory (default: tmp/deployments)", - ) - services_status_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - audit_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - - dashboard_parser = commands.add_parser( - "dashboard", help="build operations dashboard artifacts" - ) - dashboard_commands = dashboard_parser.add_subparsers( - dest="dashboard_command", required=True - ) - dashboard_build_parser = dashboard_commands.add_parser( - "build", help="build a static read-only operations dashboard" - ) - _add_project_directory_argument(dashboard_build_parser) - dashboard_build_parser.add_argument( - "--observed", - type=project_path_argument, - required=True, - help="directory containing observed-server JSON snapshots", - ) - dashboard_build_parser.add_argument( - "--service-observed", - type=project_path_argument, - default=Path("tmp/observed-services"), - help="domain observation directory (default: tmp/observed-services)", - ) - dashboard_build_parser.add_argument( - "--deployments", - type=project_path_argument, - default=Path("tmp/deployments"), - help="deployment receipt directory (default: tmp/deployments)", - ) - dashboard_build_parser.add_argument( - "--output-dir", - type=project_path_argument, - default=Path("tmp/dashboard"), - help="dashboard output directory (default: tmp/dashboard)", - ) - dashboard_build_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - - dashboard_serve_parser = dashboard_commands.add_parser( - "serve", help="serve a live-refreshing read-only dashboard over HTTP" - ) - _add_project_directory_argument(dashboard_serve_parser) - dashboard_serve_parser.add_argument( - "--observed", - type=project_path_argument, - required=True, - help="directory containing observed-server JSON snapshots", - ) - dashboard_serve_parser.add_argument( - "--service-observed", - type=project_path_argument, - default=Path("tmp/observed-services"), - help="domain observation directory (default: tmp/observed-services)", - ) - dashboard_serve_parser.add_argument( - "--deployments", - type=project_path_argument, - default=Path("tmp/deployments"), - help="deployment receipt directory (default: tmp/deployments)", - ) - dashboard_serve_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - dashboard_serve_parser.add_argument( - "--host", - default="127.0.0.1", - help="listen address; use the VPS address to expose it (default: 127.0.0.1)", - ) - dashboard_serve_parser.add_argument( - "--port", - type=int, - default=8100, - help="listen port; 0 picks a free port (default: 8100)", - ) - dashboard_serve_parser.add_argument( - "--refresh", - type=int, - default=10, - help="evidence refresh interval in seconds (default: 10)", - ) - dashboard_serve_parser.add_argument( - "--inspect-services", - action="store_true", - help="probe DNS, TLS, origin, and public routes on every refresh", - ) - - _add_lifecycle_parsers(commands) - _add_import_parsers(commands) - _add_migrate_parser(commands) - return parser - - -def _add_migrate_parser( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - migrate_parser = commands.add_parser( - "migrate", - help="run the resumable end-to-end migration plan", - ) - _add_project_directory_argument(migrate_parser) - migrate_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - migrate_parser.add_argument( - "--engine", - type=Path, - default=default_engine_directory(), - help="engine directory containing ansible contracts (default: bundled engine)", - ) - migrate_parser.add_argument( - "--inventory-file", - type=project_path_argument, - default=Path("tmp/ansible-inventory.json"), - help="rendered inventory path (default: tmp/ansible-inventory.json)", - ) - migrate_parser.add_argument( - "--observed", - type=project_path_argument, - default=Path("tmp/observed"), - help="server observation directory (default: tmp/observed)", - ) - migrate_parser.add_argument( - "--service-observed", - type=project_path_argument, - default=Path("tmp/observed-services"), - help="domain observation directory (default: tmp/observed-services)", - ) - migrate_parser.add_argument( - "--deployments", - type=project_path_argument, - default=Path("tmp/deployments"), - help="domain receipt directory (default: tmp/deployments)", - ) - migrate_parser.add_argument( - "--receipts", - type=project_path_argument, - default=Path("tmp/releases"), - help="release receipt directory (default: tmp/releases)", - ) - migrate_parser.add_argument( - "--artifacts", - type=project_path_argument, - default=Path("tmp/artifacts"), - help="artifact directory (default: tmp/artifacts)", - ) - migrate_parser.add_argument( - "--plan-file", - type=project_path_argument, - default=Path("tmp/migrate/plan.json"), - help="persisted plan location (default: tmp/migrate/plan.json)", - ) - migrate_parser.add_argument( - "--build", - action="append", - default=[], - metavar="COMPONENT=GIT_REF", - help="build this component from a git ref (repeatable)", - ) - migrate_parser.add_argument( - "--release", - action="append", - default=[], - metavar="COMPONENT=RELEASE_ID", - help="deploy this component from an already-built release", - ) - migrate_parser.add_argument( - "--env-file", - action="append", - default=[], - metavar="COMPONENT=PATH", - help="environment file passed to this component's deployment", - ) - migrate_parser.add_argument( - "--data", - action="append", - default=[], - metavar="DATABASE=URL_FILE", - help=( - "migrate external data into a declared database before cutover; " - "URL_FILE is a controller file containing only the source " - "connection URL" - ), - ) - migrate_parser.add_argument( - "--yes", - action="store_true", - help="execute the plan; without this flag only the plan is shown", - ) - migrate_parser.add_argument( - "--restart", - action="store_true", - help="discard the persisted plan and start over", - ) - - -def _add_lifecycle_parsers( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - data_parser = commands.add_parser( - "data", help="migrate data into declared services" - ) - data_commands = data_parser.add_subparsers(dest="data_command", required=True) - data_migrate_parser = data_commands.add_parser( - "migrate", - help=( - "dump an external PostgreSQL database and restore it into a " - "declared service with row-count verification" - ), - ) - _add_project_directory_argument(data_migrate_parser) - data_migrate_parser.add_argument("service", type=resource_id_argument) - _add_execute_argument(data_migrate_parser) - data_migrate_parser.add_argument( - "--database", - required=True, - help="declared database name inside the service", - ) - data_migrate_parser.add_argument( - "--source-url-file", - type=Path, - required=True, - help=( - "controller-side file whose only content is the source " - "database connection URL" - ), - ) - data_migrate_parser.add_argument( - "--receipts", - type=project_path_argument, - default=Path("tmp/data-migrations"), - help="migration receipt directory (default: tmp/data-migrations)", - ) - data_migrate_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - data_migrate_parser.add_argument( - "--engine", - type=Path, - default=default_engine_directory(), - help="engine directory containing ansible contracts (default: bundled engine)", - ) - data_migrate_parser.add_argument( - "--inventory-file", - type=project_path_argument, - default=Path("tmp/ansible-inventory.json"), - help="rendered inventory path (default: tmp/ansible-inventory.json)", - ) - - deploy_parser = commands.add_parser( - "deploy", help="deploy one built component release" - ) - _add_lifecycle_arguments(deploy_parser) - _add_execute_argument(deploy_parser) - deploy_parser.add_argument( - "--release", - type=release_id_argument, - required=True, - help="release id produced by cloudfall-engine artifact build", - ) - deploy_parser.add_argument( - "--artifacts", - type=project_path_argument, - default=Path("tmp/artifacts"), - help="artifact directory (default: tmp/artifacts)", - ) - deploy_parser.add_argument( - "--env-file", - type=Path, - help="optional controller-side environment file for the component", - ) - deploy_parser.add_argument( - "--receipts", - type=project_path_argument, - default=Path("tmp/releases"), - help="release receipt directory (default: tmp/releases)", - ) - - rollback_parser = commands.add_parser( - "rollback", help="switch one component back to an existing release" - ) - _add_lifecycle_arguments(rollback_parser) - _add_execute_argument(rollback_parser) - rollback_parser.add_argument( - "--release", - type=release_id_argument, - required=True, - help="existing release id to activate", - ) - - restart_parser = commands.add_parser( - "restart", help="restart one component behind its health check" - ) - _add_lifecycle_arguments(restart_parser) - _add_execute_argument(restart_parser) - - health_parser = commands.add_parser( - "health", help="probe one component's declared health check" - ) - _add_lifecycle_arguments(health_parser) - - -def _add_import_parsers( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - import_parser = commands.add_parser( - "import", help="import external platform definitions" - ) - import_commands = import_parser.add_subparsers(dest="import_command", required=True) - render_parser = import_commands.add_parser( - "render", help="map a render.yaml blueprint onto Cloudfall config" - ) - _add_project_directory_argument(render_parser) - render_parser.add_argument("blueprint", type=Path) - render_parser.add_argument( - "--application", - type=resource_id_argument, - required=True, - help="Cloudfall application id (also the application's Linux user)", - ) - render_parser.add_argument( - "--server", - type=resource_id_argument, - required=True, - help="declared server id that receives every imported resource", - ) - render_parser.add_argument( - "--output-dir", - type=project_path_argument, - default=Path("tmp/import/config"), - help="config fragment output directory (default: tmp/import/config)", - ) - render_parser.add_argument( - "--env-dir", - type=project_path_argument, - default=Path("tmp/import/env"), - help="environment file output directory (default: tmp/import/env)", - ) - render_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - render_api_parser = import_commands.add_parser( - "render-api", - help="map a live Render workspace onto Cloudfall config via the API", - ) - _add_project_directory_argument(render_api_parser) - render_api_parser.add_argument( - "--api-key-file", - type=Path, - required=True, - help="file containing only the Render API key", - ) - render_api_parser.add_argument( - "--api-url", - default="https://api.render.com/v1", - help="Render API base URL (default: https://api.render.com/v1)", - ) - render_api_parser.add_argument( - "--application", - type=resource_id_argument, - required=True, - help="Cloudfall application id (also the application's Linux user)", - ) - render_api_parser.add_argument( - "--server", - type=resource_id_argument, - required=True, - help="declared server id that receives every imported resource", - ) - render_api_parser.add_argument( - "--output-dir", - type=project_path_argument, - default=Path("tmp/import/config"), - help="config fragment output directory (default: tmp/import/config)", - ) - render_api_parser.add_argument( - "--env-dir", - type=project_path_argument, - default=Path("tmp/import/env"), - help="environment file output directory (default: tmp/import/env)", - ) - render_api_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - - -def _add_operator_parsers( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - operator_parser = commands.add_parser( - "operator", help="alert-driven propose-and-approve operation" - ) - operator_commands = operator_parser.add_subparsers( - dest="operator_command", required=True - ) - operator_run_parser = operator_commands.add_parser( - "run", help="watch declared alerts and drift, write proposals" - ) - _add_operator_arguments(operator_run_parser) - _add_operator_feed_arguments(operator_run_parser, required=True) - _add_operator_engine_arguments(operator_run_parser) - operator_run_parser.add_argument( - "--interval", - type=float, - default=None, - help="seconds between watch passes (default: one pass, then exit)", - ) - operator_run_parser.add_argument( - "--drift-interval", - type=float, - default=None, - help=( - "seconds between audited drift checks (default: no drift " - "checks; a single pass runs one when set)" - ), - ) - operator_run_parser.add_argument( - "--observed", - type=project_path_argument, - default=Path("tmp/operator/observed"), - help=( - "observation directory for drift checks (default: tmp/operator/observed)" - ), - ) - operator_list_parser = operator_commands.add_parser( - "list", help="list proposal receipts" - ) - _add_operator_arguments(operator_list_parser) - operator_show_parser = operator_commands.add_parser( - "show", help="show one proposal receipt" - ) - _add_operator_arguments(operator_show_parser) - operator_show_parser.add_argument("proposal", type=resource_id_argument) - operator_approve_parser = operator_commands.add_parser( - "approve", help="execute a proposal and verify its trigger resolves" - ) - _add_operator_arguments(operator_approve_parser) - _add_operator_feed_arguments(operator_approve_parser, required=False) - _add_operator_engine_arguments(operator_approve_parser) - operator_approve_parser.add_argument("proposal", type=resource_id_argument) - operator_approve_parser.add_argument( - "--observed", - type=project_path_argument, - default=Path("tmp/operator/observed"), - help=( - "observation directory for drift verification " - "(default: tmp/operator/observed)" - ), - ) - operator_approve_parser.add_argument( - "--verify-timeout", - type=float, - default=180.0, - help="seconds to wait for the trigger to resolve (default: 180)", - ) - +from cloudfall.app import app -def _add_secrets_parsers( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - secrets_parser = commands.add_parser( - "secrets", help="resolve declared secret references" - ) - secrets_commands = secrets_parser.add_subparsers( - dest="secrets_command", required=True - ) - render_parser = secrets_commands.add_parser( - "render", - help="render one component's references into its environment file", - ) - _add_project_directory_argument(render_parser) - render_parser.add_argument("component", type=resource_id_argument) - render_parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - render_parser.add_argument( - "--secrets-dir", - type=Path, - default=Path("secrets"), - help="sops-encrypted secrets directory (default: secrets)", - ) - render_parser.add_argument( - "--output-file", - type=project_path_argument, - default=None, - help=("environment file to write (default: tmp/env/.env)"), - ) - render_parser.add_argument( - "--receipts", - type=project_path_argument, - default=Path("tmp/env-receipts"), - help=("environment receipt directory (default: tmp/env-receipts)"), - ) - render_parser.add_argument( - "--engine", - type=Path, - default=default_engine_directory(), - help="engine directory containing ansible contracts (default: bundled engine)", - ) - render_parser.add_argument( - "--inventory-file", - type=project_path_argument, - default=Path("tmp/ansible-inventory.json"), - help="rendered inventory path (default: tmp/ansible-inventory.json)", - ) - - -def _add_backup_parsers( - commands: argparse._SubParsersAction[StrictArgumentParser], -) -> None: - backup_parser = commands.add_parser( - "backup", help="run and prove declared service backups" - ) - backup_commands = backup_parser.add_subparsers(dest="backup_command", required=True) - for name, description in ( - ("run", "run the declared backup for one service"), - ("verify", "prove the newest backup restores for one service"), - ): - subparser = backup_commands.add_parser(name, help=description) - _add_project_directory_argument(subparser) - subparser.add_argument("service", type=resource_id_argument) - subparser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - subparser.add_argument( - "--receipts", - type=project_path_argument, - default=Path("tmp/backups"), - help="backup receipt directory (default: tmp/backups)", - ) - _add_operator_engine_arguments(subparser) - - -def _add_operator_arguments(parser: argparse.ArgumentParser) -> None: - _add_project_directory_argument(parser) - parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - parser.add_argument( - "--proposals", - type=project_path_argument, - default=Path("tmp/operator/proposals"), - help="proposal receipt directory (default: tmp/operator/proposals)", - ) - - -def _add_operator_feed_arguments( - parser: argparse.ArgumentParser, *, required: bool -) -> None: - parser.add_argument( - "--gateway-url", - default=None, - help=("alerts endpoint (default: derived from the declared logging gateway)"), - ) - parser.add_argument("--gateway-ca", type=Path, required=required) - parser.add_argument("--gateway-cert", type=Path, required=required) - parser.add_argument("--gateway-key", type=Path, required=required) - - -def _add_operator_engine_arguments(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--engine", - type=Path, - default=default_engine_directory(), - help="engine directory containing ansible contracts (default: bundled engine)", - ) - parser.add_argument( - "--inventory-file", - type=project_path_argument, - default=Path("tmp/ansible-inventory.json"), - help="rendered inventory path (default: tmp/ansible-inventory.json)", - ) - - -def _add_execute_argument(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--yes", - action="store_true", - help=( - "change the servers; without this flag the command validates the " - "request and only shows what it would do" - ), - ) - - -def _add_lifecycle_arguments(parser: argparse.ArgumentParser) -> None: - _add_project_directory_argument(parser) - parser.add_argument("component", type=resource_id_argument) - parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help="versioned schema directory (default: bundled schemas)", - ) - parser.add_argument( - "--engine", - type=Path, - default=default_engine_directory(), - help="engine directory containing ansible contracts (default: bundled engine)", - ) - parser.add_argument( - "--inventory-file", - type=project_path_argument, - default=Path("tmp/ansible-inventory.json"), - help="rendered inventory path (default: tmp/ansible-inventory.json)", - ) +if TYPE_CHECKING: + from collections.abc import Sequence def main(argv: Sequence[str] | None = None) -> int: - """Run the CLI and return a process exit code. - - A command already moved to treaty runs there; argparse answers every - other command line, root ``--help`` and ``--version`` included, until the - last command moves (see ``cloudfall.app``). - """ - words = list(sys.argv[1:] if argv is None else argv) - if treaty_app.resolves(words): - return treaty_app.run(words) - begin_invocation() - try: - code = _main(words) - except OSError as error: - # A path the command must write is read-only or not ours: a fact - # about the environment, reported like any other failure. Every - # other OS error is a bug and keeps its traceback. - if error.errno not in _NOT_WRITABLE: - raise - message = f"cannot write {error.filename}: {error.strerror}" - error_body = {"code": "path_not_writable", "message": message} - # Exit 1, not 2: the command may have done part of its work first. - code = write_error({"status": "error", "error": error_body}, 1) - return exit_code(code) - - -_NOT_WRITABLE = frozenset({errno.EACCES, errno.EPERM, errno.EROFS}) - - -def _main(argv: Sequence[str] | None) -> int: - arguments = parse_arguments(_parser(), argv) - projectless_command = _PROJECTLESS_COMMANDS.get(arguments.command) - if projectless_command is not None: - return projectless_command(arguments) - try: - record_command = _RECORD_COMMANDS.get(arguments.command) - if record_command is not None: - return record_command(arguments) - source = _inventory_source(arguments, os.environ) - if source is not None: - return _dispatch_from_inventory(arguments, source) - with project_context(arguments.project, os.environ) as project_directory: - arguments.project_directory = project_directory - schema_directory = Path(arguments.schemas) - state = validate_config(project_directory, schema_directory) - return _dispatch(arguments, state, schema_directory) - except ( - ProjectError, - ConfigValidationError, - AnsibleReadError, - DecisionError, - WhyError, - ) as error: - return write_error(error.as_dict(), 2) - - -_ERROR_SOURCE_AMBIGUOUS = "project_source_ambiguous" - - -def _inventory_source( - arguments: Namespace, environment: Mapping[str, str] -) -> InventorySource | None: - """Return the Ansible inventory this run reads the fleet from, if any. - - Precedence: ``--inventory``, then an ``ansible.cfg`` in the current - directory when no project was asked for. A command that declares no - ``--inventory`` never reads one, so brownfield support is stated per - command rather than implied for all of them. - """ - if not hasattr(arguments, "inventory"): - return None - if arguments.inventory is not None: - if arguments.project is not None: - message = "--inventory and --project name two fleets; pass one" - raise ProjectError(_ERROR_SOURCE_AMBIGUOUS, message) - return InventorySource.from_boundary(arguments.inventory) - if arguments.project is not None or environment.get(PROJECT_DIRECTORY_VARIABLE): - return None - if is_project(Path.cwd()): - return None - return inventory_from_config(Path.cwd()) - - -def _dispatch_from_inventory(arguments: Namespace, source: InventorySource) -> int: - """Run the command against a fleet read from the team's inventory. - - The Ansible repository is the working directory, so relative paths such - as the ``tmp/`` defaults land beside their playbooks rather than in a - Cloudfall project that does not exist here. - """ - schema_directory = Path(arguments.schemas) - read = read_fleet(read_inventory(source), schema_directory) - arguments.project_directory = Path.cwd() - arguments.fleet_read = read - arguments.inventory_source = source - return _dispatch(arguments, read.config, schema_directory) - - -def _dispatch( - arguments: Namespace, state: ValidatedConfig, schema_directory: Path -) -> int: - handlers: dict[str, Callable[[Namespace, ValidatedConfig, Path], int]] = { - "dashboard:serve": _run_dashboard_serve, - "operator:run": _run_operator_run, - } - key = _command_key(arguments) - try: - handler = handlers[key] - except KeyError as error: - message = f"argparse accepted an unsupported command: {key}" - raise RuntimeError(message) from error - return handler(arguments, state, schema_directory) - - -def _command_key(arguments: Namespace) -> str: - top_level = { - "audit", - "deploy", - "health", - "migrate", - "observe", - "restart", - "rollback", - } - if arguments.command in top_level: - return str(arguments.command) - subcommand = getattr(arguments, f"{arguments.command}_command", None) - return f"{arguments.command}:{subcommand}" - - -def _run_changelog(arguments: Namespace) -> int: - write_result( - { - "status": "ok", - "entries": schema_changes_since(arguments.since), - "schemaVersions": schema_versions(), - } - ) - return 0 - - -_PROJECTLESS_COMMANDS: Mapping[str, Callable[[Namespace], int]] = { - "changelog": _run_changelog, -} -"""The commands that must run before a project exists, so none is resolved.""" - - -_RECORD_COMMANDS: Mapping[str, Callable[[Namespace], int]] = { -} -"""The commands that read the repository and nothing else: no fleet, no project.""" - - -def _operator_store(arguments: Namespace, schema_directory: Path) -> ProposalStore: - return ProposalStore( - directory=Path(arguments.proposals), - catalog=SchemaCatalog(schema_directory), - ) - - -def _operator_feed(arguments: Namespace, inventory: PlatformInventory) -> AlertFeed: - return gateway_feed( - inventory, - ca_path=Path(arguments.gateway_ca), - certificate_path=Path(arguments.gateway_cert), - key_path=Path(arguments.gateway_key), - url_override=arguments.gateway_url, - ) - - -def _operator_exit(error: OperatorError) -> int: - return write_error(error.as_dict(), 2) - - -def _run_operator_run( - arguments: Namespace, state: ValidatedConfig, schema_directory: Path -) -> int: - inventory = PlatformInventory.from_state(state) - try: - store = _operator_store(arguments, schema_directory) - feed = _operator_feed(arguments, inventory) - auditor = None - if arguments.drift_interval is not None: - auditor = engine_auditor( - _engine_context(arguments, schema_directory), - inventory, - Path(arguments.observed), - ) - context = _engine_context(arguments, schema_directory) - - def _verifier_for( - proposal: OperatorProposal, - ) -> Callable[[OperatorProposal], bool]: - if proposal.trigger_kind is TriggerKind.ALERT: - return alert_resolution_verifier(feed) - return drift_resolution_verifier( - engine_auditor(context, inventory, Path(arguments.observed)) - ) - - drift_due = 0.0 - while True: - report = operator_run_once(feed, inventory, store) - write_result({"status": "ok", "pass": "alerts", **report.as_dict()}) - if auditor is not None and time.monotonic() >= drift_due: - drift_report = drift_pass(auditor, store) - write_result( - { - "status": "ok", - "pass": "drift", - **drift_report.as_dict(), - } - ) - drift_due = time.monotonic() + arguments.drift_interval - if inventory.operator_policies: - autonomy_report = autonomous_pass( - store, - inventory, - engine_executor(context), - _verifier_for, - ) - write_result( - { - "status": "ok", - "pass": "autonomy", - **autonomy_report.as_dict(), - } - ) - if arguments.interval is None: - return 0 - time.sleep(arguments.interval) - except OperatorError as error: - return _operator_exit(error) - - -def _run_dashboard_serve( - arguments: Namespace, _state: ValidatedConfig, schema_directory: Path -) -> int: - sources = EvidenceSources( - project_directory=Path(arguments.project_directory), - schema_directory=schema_directory, - observed_directory=Path(arguments.observed), - service_observed_directory=Path(arguments.service_observed), - deployments_directory=Path(arguments.deployments), - inspect_services=bool(arguments.inspect_services), - ) - endpoint = ListenEndpoint(host=str(arguments.host), port=int(arguments.port)) - refresh = RefreshInterval.from_boundary(int(arguments.refresh)) - server = create_dashboard_server( - sources, endpoint, refresh, quiet=current_invocation().options.quiet - ) - bound_port = int(server.server_address[1]) - write_result( - { - "status": "ok", - "dashboard": { - "url": f"http://{endpoint.host}:{bound_port}/", - "refreshSeconds": refresh.seconds, - "inspectServices": sources.inspect_services, - }, - } - ) - try: - server.serve_forever() - except KeyboardInterrupt: - return 0 - finally: - server.server_close() - return 0 - - -def _engine_context(arguments: Namespace, schema_directory: Path) -> EngineContext: - return EngineContext( - project_directory=Path(arguments.project_directory), - schema_directory=schema_directory, - engine_directory=Path(arguments.engine), - inventory_file=Path(arguments.inventory_file), - ) + """Run one command in-process and return its exit code.""" + return app.run(list(sys.argv[1:] if argv is None else argv)) def run() -> None: """Installed console-script entry point.""" - raise SystemExit(main()) - - + app.main() diff --git a/sdk/src/cloudfall/commands.py b/sdk/src/cloudfall/commands.py index d2ae47b..49a7859 100644 --- a/sdk/src/cloudfall/commands.py +++ b/sdk/src/cloudfall/commands.py @@ -3,8 +3,9 @@ The catalog is the one place that says which commands change servers and which do not. ``cloudfall init`` renders it into the project's ``AGENTS.md`` so an agent reads the same classification the CLI enforces, and the tests -check it against the argparse trees, so a command added to a parser +hold it to the commands the treaty app registers, so a command added without a catalog entry fails the suite instead of going unclassified. +Each entry's output shapes name the keys its ``data`` carries. """ from __future__ import annotations @@ -13,36 +14,6 @@ from enum import Enum -@dataclass(frozen=True, slots=True) -class ExitCode: - """One process exit code and what it tells the caller.""" - - code: int - meaning: str - """One clause; Markdown code spans for values and command names.""" - - -EXIT_CODES: tuple[ExitCode, ...] = ( - ExitCode(0, "the command ran and its result is positive (`ok: true`)"), - ExitCode( - 1, - "the command ran and its result is negative: drift, unhealthy, a failed " - "step, or a path it had to write turned out read-only partway", - ), - ExitCode( - 2, - "nothing ran: invalid input or usage, a resource not found or already " - "there, or another unmet precondition", - ), - ExitCode( - 3, - "the result is unknown: observations missing or stale (`audit`), or a " - "migration paused before its next step (`migrate`)", - ), -) -"""Every exit code ``cloudfall`` uses; ``--help`` and ``AGENTS.md`` print it.""" - - class CommandEffect(Enum): """The furthest a command's side effects reach.""" @@ -57,133 +28,28 @@ class CommandEffect(Enum): """Changes servers, always through the engine, always with a receipt.""" -class Stability(Enum): - """How long an output key is promised to keep its name and meaning.""" - - STABLE = "stable" - """Removed or changed only in a MAJOR schema release, after at least one - MINOR release in which documents holding it warn ``FIELD_DEPRECATED``.""" - - EXPERIMENTAL = "experimental" - """Not yet released; may change in any release without a warning.""" - - @dataclass(frozen=True, slots=True) class OutputKey: - """One key of a command's payload, as the command writes it.""" + """One key of a command's ``data``, as the command writes it.""" name: str - stability: Stability = Stability.STABLE optional: bool = False """Written only under some conditions, such as a flag or a result.""" @dataclass(frozen=True, slots=True) class OutputShape: - """One shape of a command's stdout document, and when it is written. - - The keys are the command's flat payload. The writer lifts ``status`` and - ``error`` to the top level, moves the rest under ``data``, and adds - ``ok``, ``meta`` and ``warnings``; the schema describes that document. - ``error`` is on every document: an object where the shape declares one, - otherwise ``null`` unless ``--warnings-as-errors`` failed the result. - Only the keys are declared; what each one holds is open. - """ + """One shape of a command's ``data``, and when it is written.""" keys: tuple[OutputKey, ...] when: str | None = None - def json_schema(self) -> dict[str, object]: - """Return the JSON Schema of this shape, with a stability per key.""" - lifted = {key.name: key for key in self.keys if key.name in _LIFTED} - if "status" not in lifted: - message = f"an output shape declares status: {self.keys}" - raise ValueError(message) - data_keys = tuple(key for key in self.keys if key.name not in _LIFTED) - error = lifted.get("error", _ERROR) - top = (_OK, lifted["status"], _DATA, _ERROR, *_WRITER_KEYS) - data = {**_object_schema(data_keys), **_stability(_DATA)} - error_schema = {"type": _error_type(lifted.get("error")), **_stability(error)} - schema = _object_schema(top, {"data": data, "error": error_schema}) - if self.when is not None: - schema["description"] = self.when - return schema - - def treaty_schema(self) -> dict[str, object]: - """Return the JSON Schema of this shape in treaty's envelope. - - ``status`` moves into ``data`` beside the other keys, every key is - written on every answer (an optional one as ``null``), and a - failed run keeps ``data`` only for a verdict such as drift. A command - treaty runs as mutating also writes ``effect`` (REQ-C-003). - """ - data_keys = ( - *( - OutputKey(key.name, key.stability) - for key in self.keys - if key.name != "error" - ), - OutputKey("effect", optional=True), - ) - data = { - **_object_schema(data_keys), - "type": ["object", "null"], - **_stability(_DATA), - } - error_schema = {"type": ["null", "object"], **_stability(_ERROR)} - schema = _object_schema( - (_OK, _DATA, _ERROR, *_WRITER_KEYS), - {"data": data, "error": error_schema}, - ) - if self.when is not None: - schema["description"] = self.when - return schema - - -def _object_schema( - keys: tuple[OutputKey, ...], nested: dict[str, object] | None = None -) -> dict[str, object]: - properties = {key.name: _stability(key) for key in keys} - return { - "type": "object", - "properties": {**properties, **(nested or {})}, - "required": sorted(key.name for key in keys if not key.optional), - "additionalProperties": False, - } - - -def _stability(key: OutputKey) -> dict[str, object]: - return {"x-stability": key.stability.value} - - -def _error_type(declared: OutputKey | None) -> str | list[str]: - """Type ``error``: an object where declared, else null or an object. - - Any result can become a failure under ``--warnings-as-errors``, so only a - declared, required error rules out ``null``. - """ - if declared is not None and not declared.optional: - return "object" - return ["null", "object"] - - -_LIFTED = frozenset({"status", "error"}) -_OK = OutputKey("ok") -_DATA = OutputKey("data") -_ERROR = OutputKey("error") -_WRITER_KEYS = (OutputKey("meta"), OutputKey("warnings")) - def _shape(*keys: str, when: str | None = None) -> OutputShape: - """Declare a shape from key names: ``name?`` is optional, ``name~`` experimental.""" + """Declare a shape from key names: ``name?`` is optional.""" return OutputShape( keys=tuple( - OutputKey( - key.rstrip("?~"), - Stability.EXPERIMENTAL if "~" in key else Stability.STABLE, - optional="?" in key, - ) - for key in keys + OutputKey(key.rstrip("?"), optional=key.endswith("?")) for key in keys ), when=when, ) @@ -210,33 +76,11 @@ class CommandContract: output: tuple[OutputShape, ...] = () """Each shape of the JSON document the command writes to stdout.""" - treaty: bool = False - """The command runs on treaty (``cloudfall.app``), in treaty's envelope.""" - @property def invocation(self) -> str: """Return the command as typed from the project directory.""" return f"uv run {self.program} {self.name}" - def output_schema(self) -> dict[str, object]: - """Return the JSON Schema of the command's stdout documents.""" - if self.treaty: - return self._treaty_shape().treaty_schema() - schemas = [shape.json_schema() for shape in self.output] - if len(schemas) == 1: - return schemas[0] - return {"oneOf": schemas} - - def _treaty_shape(self) -> OutputShape: - """Merge the shapes: treaty writes one object, a missing key as null.""" - keys: dict[str, OutputKey] = {} - for shape in self.output: - for key in shape.keys: - keys.setdefault(key.name, key) - if len(self.output) == 1: - return self.output[0] - return OutputShape(keys=tuple(keys.values())) - _YES = "`--yes`; without it the command validates and prints the plan only" @@ -279,14 +123,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.PROJECT, "lay out a new project directory", output=(_shape("status", "project", "files", "next"),), - treaty=True, - ), - CommandContract( - "cloudfall", - "changelog", - CommandEffect.READ, - "list changes to the JSON output contract, newest first", - output=(_shape("status~", "entries~", "schemaVersions~"),), ), CommandContract( "cloudfall", @@ -294,7 +130,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.PROJECT, "declare an `SshPublicKey` from a public key file", output=_ADDED, - treaty=True, ), CommandContract( "cloudfall", @@ -302,7 +137,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.PROJECT, "declare a `ServerType` from the bundled Debian 13 baseline", output=_ADDED, - treaty=True, ), CommandContract( "cloudfall", @@ -310,7 +144,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.PROJECT, "declare a `Server`, creating its type on first use", output=_ADDED, - treaty=True, ), CommandContract( "cloudfall", @@ -318,7 +151,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "validate every resource against the schemas and cross-references", output=(_shape("status", "resources", "byKind"),), - treaty=True, ), CommandContract( "cloudfall", @@ -333,7 +165,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: when="`ansible` only when the fleet is read from an Ansible inventory", ), ), - treaty=True, ), CommandContract( "cloudfall", @@ -341,7 +172,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "list the operations an agent may run, with their risk levels", output=(_shape("status", "directory", "operations", "byRisk"),), - treaty=True, ), CommandContract( "cloudfall", @@ -349,7 +179,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "show one declared operation with its inputs and verify step", output=(_shape("status", "operation"),), - treaty=True, ), CommandContract( "cloudfall", @@ -357,7 +186,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "run one operation in check mode and record what it would change", output=(_shape("status", "decision"),), - treaty=True, ), CommandContract( "cloudfall", @@ -369,7 +197,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: _shape("status", "decision", "next", when="without `--yes`"), _shape("status", "decision", when="with `--yes`"), ), - treaty=True, ), CommandContract( "cloudfall", @@ -377,7 +204,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "list what was proposed, what check mode showed, and who approved", output=(_shape("status", "directory", "decisions"),), - treaty=True, ), CommandContract( "cloudfall", @@ -386,15 +212,14 @@ def _plan(subject: str, *extra: str) -> OutputShape: "answer why the agent did that from the record, by host, operation or time", output=( _shape( - "status~", - "query~", - "directory~", - "count~", - "answers~", + "status", + "query", + "directory", + "count", + "answers", when="`--format json`, the default; `--format html` is not JSON", ), ), - treaty=True, ), CommandContract( "cloudfall", @@ -413,7 +238,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: when="`detail`, the end of Ansible's output, when the playbook failed", ), ), - treaty=True, ), CommandContract( "cloudfall", @@ -421,7 +245,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "compare the declared config with observed server snapshots", output=(_shape("status", "summary", "unmatchedObservations", "servers"),), - treaty=True, ), CommandContract( "cloudfall", @@ -429,7 +252,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "probe DNS, TLS, origin, and public routes into evidence", output=(_shape("status", "observations"),), - treaty=True, ), CommandContract( "cloudfall", @@ -437,7 +259,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "derive each public service's lifecycle from current evidence", output=(_shape("status", "services"),), - treaty=True, ), CommandContract( "cloudfall", @@ -455,7 +276,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: when="`detail` only when the component is unhealthy", ), ), - treaty=True, ), CommandContract( "cloudfall", @@ -463,14 +283,15 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "build the static read-only operations dashboard", output=(_shape("status", "health", "tasks", "dashboard"),), - treaty=True, ), CommandContract( "cloudfall", "dashboard serve", CommandEffect.READ, "serve the read-only dashboard over HTTP (long-running)", - output=(_shape("status", "dashboard", when="once, before the server starts"),), + output=( + _shape("status", "dashboard", when="once, when the server listens"), + ), ), CommandContract( "cloudfall", @@ -478,7 +299,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "map a `render.yaml` blueprint onto config fragments under `tmp/`", output=_IMPORTED, - treaty=True, ), CommandContract( "cloudfall", @@ -486,7 +306,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "map a live Render workspace onto config fragments under `tmp/`", output=_IMPORTED, - treaty=True, ), CommandContract( "cloudfall", @@ -505,13 +324,17 @@ def _plan(subject: str, *extra: str) -> OutputShape: "receipt", ), ), - treaty=True, ), CommandContract( "cloudfall", "operator run", - CommandEffect.READ, - "watch declared alerts and drift, write proposal receipts", + CommandEffect.SERVERS, + "watch declared alerts and drift, write proposal receipts, and run " + "the proposals a declared `OperatorPolicy` licenses (long-running)", + gate=( + "a human starts it; only proposals a declared `OperatorPolicy` " + "licenses run unattended, each with a receipt" + ), output=( _shape( "status", @@ -536,7 +359,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "list proposal receipts", output=(_shape("status", "proposals"),), - treaty=True, ), CommandContract( "cloudfall", @@ -544,7 +366,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: CommandEffect.READ, "show one proposal receipt", output=_PROPOSAL, - treaty=True, ), CommandContract( "cloudfall", @@ -556,7 +377,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: _plan("component", "release"), _shape(*_EXECUTED, "release", "receipt", when="with `--yes`"), ), - treaty=True, ), CommandContract( "cloudfall", @@ -568,7 +388,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: _plan("component", "release"), _shape(*_EXECUTED, "release", when="with `--yes`"), ), - treaty=True, ), CommandContract( "cloudfall", @@ -577,7 +396,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: "restart one component behind its health check", gate=_YES, output=(_plan("component"), _shape(*_EXECUTED, when="with `--yes`")), - treaty=True, ), CommandContract( "cloudfall", @@ -598,7 +416,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: when="with `--yes`", ), ), - treaty=True, ), CommandContract( "cloudfall", @@ -624,7 +441,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: when="`paused` (exit 3) or `error` (exit 1) at `step`", ), ), - treaty=True, ), CommandContract( "cloudfall", @@ -633,7 +449,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: "run the declared backup for one service on its server", gate="the service must declare the backup; a receipt is written", output=_BACKUP, - treaty=True, ), CommandContract( "cloudfall", @@ -642,7 +457,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: "restore the newest backup into a scratch database on the server", gate="the service must declare the backup; a receipt is written", output=_BACKUP, - treaty=True, ), CommandContract( "cloudfall", @@ -651,7 +465,6 @@ def _plan(subject: str, *extra: str) -> OutputShape: "execute one proposal and verify that its trigger resolves", gate="the proposal id names a receipt a human has reviewed", output=_PROPOSAL, - treaty=True, ), ) diff --git a/sdk/src/cloudfall/dashboard_server.py b/sdk/src/cloudfall/dashboard_server.py index a5d3f5e..80f9729 100644 --- a/sdk/src/cloudfall/dashboard_server.py +++ b/sdk/src/cloudfall/dashboard_server.py @@ -145,12 +145,16 @@ class DashboardHTTPServer(ThreadingHTTPServer): daemon_threads = True def __init__( - self, endpoint: ListenEndpoint, cache: SnapshotCache, *, quiet: bool = False + self, + endpoint: ListenEndpoint, + cache: SnapshotCache, + *, + request_log: Callable[[str], None] | None = None, ) -> None: """Bind the endpoint and expose the cache to request handlers.""" self.cache = cache - self.quiet = quiet - """``--quiet``: handlers log no request line to stderr.""" + self.request_log = request_log + """Receives each request line; ``None`` logs none.""" super().__init__((endpoint.host, endpoint.port), _DashboardRequestHandler) @@ -177,11 +181,10 @@ def do_GET(self) -> None: self._respond(HTTPStatus.OK, "text/html; charset=utf-8", snapshot.html) def log_message(self, format: str, *args: object) -> None: # noqa: A002 - base signature - """Log the request line to stderr unless the server runs ``--quiet``.""" + """Hand the request line to the server's request log, if it has one.""" server = self.server - if isinstance(server, DashboardHTTPServer) and server.quiet: - return - super().log_message(format, *args) + if isinstance(server, DashboardHTTPServer) and server.request_log is not None: + server.request_log(format % args) def _cache(self) -> SnapshotCache: server = self.server @@ -206,9 +209,9 @@ def create_dashboard_server( refresh: RefreshInterval, clock: Callable[[], float] = time.monotonic, *, - quiet: bool = False, + request_log: Callable[[str], None] | None = None, ) -> DashboardHTTPServer: """Build one snapshot eagerly (fail fast), then bind the HTTP server.""" cache = SnapshotCache(sources=sources, refresh=refresh, clock=clock) cache.current() - return DashboardHTTPServer(endpoint, cache, quiet=quiet) + return DashboardHTTPServer(endpoint, cache, request_log=request_log) diff --git a/sdk/src/cloudfall/fleet_tools.py b/sdk/src/cloudfall/fleet_tools.py deleted file mode 100644 index 6884334..0000000 --- a/sdk/src/cloudfall/fleet_tools.py +++ /dev/null @@ -1,259 +0,0 @@ -"""The agent's surface on a brownfield repository. - -The loop is fixed: observe, decide, preview, approve, run, verify. An -agent reaches the first three steps and the last two of them only through -what the team declared, and it does not reach the approval at all. There -is no tool here that changes a host: proposing runs check mode, and the -approval that follows is a command a person runs. - -The tool list is the catalog. A playbook the team has not declared as an -operation is not reachable, which is the point of having a catalog rather -than a shell. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING - -from cloudfall.ansible_api import InventorySource, inventory_from_config, read_inventory -from cloudfall.ansible_reader import read_fleet -from cloudfall.audit import audit_inventory -from cloudfall.catalog import CATALOG_DIRECTORY, load_catalog -from cloudfall.decision import ( - DECISION_DIRECTORY, - DecisionStore, - ProposalRequest, - Targets, - propose, -) -from cloudfall.domain import ResourceId -from cloudfall.inventory import PlatformInventory -from cloudfall.observation import load_observations -from cloudfall.observe import ObservationRequest, collect_observations -from cloudfall.validation import SchemaCatalog -from cloudfall.why import WhyQuery, answer - -if TYPE_CHECKING: - from collections.abc import Mapping - - from cloudfall.catalog import Operation, OperationCatalog - -APPROVAL_COMMAND = "cloudfall operations approve" -"""The command a person runs; no tool here can stand in for it.""" - -_ERROR_INVENTORY_UNDECLARED = "fleet_inventory_undeclared" - - -@dataclass(frozen=True, slots=True) -class FleetConfig: - """Filesystem contract for one agent-facing brownfield repository.""" - - repository: Path - schema_directory: Path - engine_directory: Path - inventory: Path | None = None - operations_directory: Path = Path(CATALOG_DIRECTORY) - decisions_directory: Path = Path(DECISION_DIRECTORY) - observed_directory: Path = Path("tmp/observed") - - def source(self) -> InventorySource: - """Return the inventory to read, as the team's own config names it.""" - if self.inventory is not None: - return InventorySource(self.repository / self.inventory) - declared = inventory_from_config(self.repository) - if declared is None: - message = ( - f"{self.repository} names no inventory: add an " - "ansible.cfg with an inventory setting, or pass --inventory" - ) - raise FleetToolError(_ERROR_INVENTORY_UNDECLARED, message) - return declared - - def observations(self) -> Path: - """Return the snapshot directory inside the repository.""" - return self.repository / self.observed_directory - - -class FleetToolError(RuntimeError): - """Fail-fast agent surface error with a stable machine-readable code.""" - - def __init__(self, code: str, message: str) -> None: - """Record the failure code and human-readable detail.""" - self.code = code - self.detail = message - super().__init__(f"{code}: {message}") - - def as_dict(self) -> dict[str, object]: - """Serialize the error envelope for system boundaries.""" - return { - "status": "error", - "error": {"code": self.code, "message": self.detail}, - } - - -@dataclass(frozen=True, slots=True) -class FleetToolset: - """Every tool an agent may reach on a brownfield repository.""" - - config: FleetConfig - - def catalog(self) -> OperationCatalog: - """Return the declared operations: the agent's whole tool list.""" - return load_catalog( - self.config.repository, - self.config.schema_directory, - self.config.repository / self.config.operations_directory, - ) - - def operations(self) -> dict[str, object]: - """List what the team has declared an agent may run.""" - return self.catalog().as_dict() - - def operation(self, operation_id: str) -> dict[str, object]: - """Show one declared operation with its inputs and verify step.""" - operation = self.catalog().get(ResourceId.from_boundary(operation_id)) - return {"status": "ok", "operation": operation.as_dict()} - - def fleet(self) -> dict[str, object]: - """Read the fleet from the team's inventory.""" - read = read_fleet( - read_inventory(self.config.source()), self.config.schema_directory - ) - return { - "status": "ok", - "ansible": read.as_dict(), - "inventory": PlatformInventory.from_state(read.config).as_dict(), - } - - def observe(self) -> dict[str, object]: - """Collect one read-only snapshot per declared server.""" - read = read_fleet( - read_inventory(self.config.source()), self.config.schema_directory - ) - inventory = PlatformInventory.from_state(read.config) - request = ObservationRequest( - inventory_sources=(self.config.source().value,), - output_directory=self.config.observations().resolve(), - engine_directory=self.config.engine_directory, - configuration=_configuration(self.config.repository), - ) - result = collect_observations( - inventory, - request, - self.config.repository / "tmp" / "cloudfall", - ) - return result.as_dict() - - def audit(self) -> dict[str, object]: - """Compare the declared fleet with the snapshots on disk.""" - read = read_fleet( - read_inventory(self.config.source()), self.config.schema_directory - ) - observations = load_observations( - self.config.observations(), self.config.schema_directory - ) - report = audit_inventory( - PlatformInventory.from_state(read.config), observations - ) - return report.as_dict() - - def propose( - self, - operation_id: str, - target: str | None = None, - inputs: Mapping[str, str] | None = None, - ) -> dict[str, object]: - """Run one operation in check mode and record what it would do. - - Nothing changes: the record this leaves is what a person approves, - with the command to do it. - """ - operation = self.catalog().get(ResourceId.from_boundary(operation_id)) - request = ProposalRequest( - operation=operation, - targets=Targets( - scope=operation.targets, - pattern=target or None, - ), - inputs=dict(inputs) if inputs else {}, - repository=self.config.repository, - observations=self.config.observations(), - ) - decision = propose(request, self.store()) - payload = decision.as_dict() - payload["approval"] = { - "required": True, - "command": (f"{APPROVAL_COMMAND} {decision.decision_id.value} --yes"), - "note": ("a person approves this; no tool on this server runs it"), - } - return payload - - def decisions(self) -> dict[str, object]: - """List what was proposed, what check mode showed, and who approved.""" - store = self.store() - return { - "status": "ok", - "directory": str(store.directory), - "decisions": [decision.as_document() for decision in store.list()], - } - - def why( - self, - host: str | None = None, - operation: str | None = None, - since: str | None = None, - until: str | None = None, - ) -> dict[str, object]: - """Answer why the agent did that, from the record alone.""" - query = WhyQuery.from_boundary( - host=host, operation=operation, since=since, until=until - ) - return answer(self.store(), query).as_dict() - - def store(self) -> DecisionStore: - """Return the decision record store for this repository.""" - return DecisionStore( - directory=self.config.repository / self.config.decisions_directory, - catalog=SchemaCatalog(self.config.schema_directory), - ) - - -def operation_tool_name(operation: Operation) -> str: - """Return the MCP tool name one declared operation is exposed under.""" - return f"operation_{operation.operation_id.value.replace('-', '_')}" - - -def operation_tool_description(operation: Operation) -> str: - """Describe one operation the way a client's tool list should read it.""" - lines = [ - operation.description - if operation.description is not None - else f"Run the {operation.operation_id} operation", - "", - f"Risk: {operation.risk.value}. Targets: {operation.targets.value}.", - "Calling this runs check mode only and records what it would change; " - f"a person approves the result with `{APPROVAL_COMMAND}`.", - ] - if operation.inputs: - declared = ", ".join( - f"{declared_input.name}: {declared_input.type.value}" - + ("" if declared_input.required else " (optional)") - for declared_input in operation.inputs - ) - lines.append(f"Inputs: {declared}.") - if operation.preconditions: - lines.append( - "Preconditions: " - + ", ".join(condition.value for condition in operation.preconditions) - + "." - ) - if operation.verify is not None: - lines.append(f"Verified by: {operation.verify.playbook}.") - return "\n".join(lines) - - -def _configuration(repository: Path) -> Path | None: - candidate = repository / "ansible.cfg" - return candidate if candidate.is_file() else None diff --git a/sdk/src/cloudfall/manifest.py b/sdk/src/cloudfall/manifest.py deleted file mode 100644 index ad006e6..0000000 --- a/sdk/src/cloudfall/manifest.py +++ /dev/null @@ -1,138 +0,0 @@ -"""``cloudfall --schema``: every command, its flags, and its output contract. - -Flags are read from the argparse tree, so a new option appears here without -being documented twice. Effects, gates and output keys come from the command -catalog, which the tests hold to the same tree. The document is identical -between invocations of one build; ``etag`` changes only when a command, -flag, or declared output does. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -from pathlib import Path -from typing import TYPE_CHECKING - -from cloudfall.commands import CLI_COMMANDS, CommandContract - -if TYPE_CHECKING: - from collections.abc import Iterator - - -def build_manifest(parser: argparse.ArgumentParser) -> dict[str, object]: - """Return the manifest of every ``cloudfall`` leaf command.""" - leaves = dict(leaf_parsers(parser)) - contracts = {contract.name: contract for contract in CLI_COMMANDS} - if set(leaves) != set(contracts): - message = ( - "command catalog and parser disagree: " - f"{sorted(set(leaves) ^ set(contracts))}" - ) - raise ValueError(message) - commands = { - name: _command_entry(contracts[name], leaves[name]) for name in sorted(leaves) - } - global_flags = { - name: flag - for action in parser._actions # noqa: SLF001 - argparse has no public walk. - if not isinstance(action, argparse._SubParsersAction) # noqa: SLF001 - for name, flag in _flag_entry(action) - } - digest = hashlib.sha256( - json.dumps([global_flags, commands], sort_keys=True).encode("utf-8") - ).hexdigest() - return { - "status": "ok", - "etag": f"sha256:{digest}", - "global_flags": global_flags, - "commands": commands, - } - - -def _command_entry( - contract: CommandContract, parser: argparse.ArgumentParser -) -> dict[str, object]: - entry: dict[str, object] = { - "description": contract.summary, - "effect": contract.effect.value, - "flags": { - name: flag - for action in parser._actions # noqa: SLF001 - argparse has no public walk. - for name, flag in _flag_entry(action) - }, - "output_schema": contract.output_schema(), - } - if contract.gate is not None: - entry["gate"] = contract.gate - return entry - - -def _flag_entry(action: argparse.Action) -> Iterator[tuple[str, dict[str, object]]]: - if isinstance(action, argparse._HelpAction): # noqa: SLF001 - return - long_options = [ - option for option in action.option_strings if option.startswith("--") - ] - positional = not action.option_strings - name = action.dest if positional else long_options[0].removeprefix("--") - flag: dict[str, object] = { - "type": _flag_type(action), - "required": bool(action.required), - "description": action.help or "", - } - if positional: - flag["positional"] = True - if action.choices is not None: - flag["enum_values"] = [str(choice) for choice in action.choices] - default = _json_default(action.default) - if default is not None: - flag["default"] = default - yield name, flag - - -def _flag_type(action: argparse.Action) -> str: - if isinstance(action, argparse._StoreTrueAction | argparse._StoreFalseAction): # noqa: SLF001 - return "boolean" - if action.choices is not None: - return "enum" - if isinstance(action, argparse._AppendAction) or action.nargs in {"*", "+"}: # noqa: SLF001 - return "array" - if action.type is int: - return "integer" - if action.type is float: - return "number" - return "string" - - -def _json_default(value: object) -> object: - """Return a default as JSON, or ``None`` when there is none to show.""" - if value is None or value is argparse.SUPPRESS or value is False: - return None - if isinstance(value, Path) and value.is_absolute(): - # Resolved on this machine (the bundled engine or schemas): the help - # text names it, and the path would differ on every install. - return None - if isinstance(value, bool | int | float | str): - return value - if isinstance(value, list | tuple): - return [str(item) for item in value] or None - return str(value) - - -def leaf_parsers( - parser: argparse.ArgumentParser, prefix: str = "" -) -> Iterator[tuple[str, argparse.ArgumentParser]]: - """Yield every leaf command's space-joined name and parser.""" - subparsers = [ - action - for action in parser._actions # noqa: SLF001 - if isinstance(action, argparse._SubParsersAction) # noqa: SLF001 - ] - if not subparsers: - yield prefix.strip(), parser - return - for action in subparsers: - for name, child in action.choices.items(): - yield from leaf_parsers(child, f"{prefix} {name}") diff --git a/sdk/src/cloudfall/mcp_server.py b/sdk/src/cloudfall/mcp_server.py deleted file mode 100644 index cf02098..0000000 --- a/sdk/src/cloudfall/mcp_server.py +++ /dev/null @@ -1,961 +0,0 @@ -"""MCP server exposing the Cloudfall agent toolset over stdio. - -Requires the ``cloudfall[mcp]`` extra. Read-only evidence tools carry a -read-only annotation; anything that changes servers is annotated -destructive and demands the toolset's explicit confirmation handshake. -The fleet-declaring tools (``add_ssh_key``, ``add_server_type``, -``add_server``) write resources into the project and are neither: they -never touch a server and never overwrite an existing resource. - -``cloudfall init`` is deliberately CLI-only. The server is started inside -a project (``--project``, ``CLOUDFALL_PROJECT``, or the current directory) -and refuses to start outside one, so by the time an agent can call a tool -the project already exists; a tool that lays out a new project elsewhere -would escape the directory every other tool is confined to. -""" - -from __future__ import annotations - -import json -import os -import sys -from pathlib import Path -from typing import TYPE_CHECKING - -from mcp.server.mcpserver import MCPServer -from mcp.types import ToolAnnotations - -from cloudfall.agent_tools import AgentConfig, AgentToolset -from cloudfall.ansible_api import AnsibleReadError, inventory_from_config -from cloudfall.arguments import ( - StrictArgumentParser, - parse_arguments, - project_path_argument, -) -from cloudfall.catalog import CATALOG_DIRECTORY, RiskLevel -from cloudfall.decision import DECISION_DIRECTORY, DecisionError -from cloudfall.fleet_tools import ( - FleetConfig, - FleetToolError, - FleetToolset, - operation_tool_description, - operation_tool_name, -) -from cloudfall.observe import ObserveError -from cloudfall.project import ( - PROJECT_DIRECTORY_VARIABLE, - ProjectError, - is_project, - project_context, -) -from cloudfall.resources import default_engine_directory, default_schema_directory -from cloudfall.validation import ConfigValidationError -from cloudfall.why import WhyError - -if TYPE_CHECKING: - import argparse - from collections.abc import Callable, Mapping, Sequence - - _Registration = tuple[Callable[..., str], str, str, ToolAnnotations | None] - - from cloudfall.catalog import Operation - -_FLEET_INSTRUCTIONS = """\ -Cloudfall is the record for a fleet run through the team's own Ansible -repository. The tool list is the catalog the team declared: a playbook -they have not declared as an operation is not reachable here, and a raw -shell call is what this server exists to make unnecessary. - -The loop is fixed: read the fleet, collect snapshots, pick an operation -and say why, then call it to preview it in check mode. Calling an -operation tool changes nothing: it runs the playbook in check mode and -records the operation, the targets, the evidence it was based on and the -diff it would produce. Approving that record is a command a person runs; -no tool here can do it, whatever reason is given for asking. When asked -why something was done, answer from the record with the why tool rather -than from memory. - -Each operation tool carries the risk level the team declared, so read, -mutating and destructive are visible to this client's own gate. -""" - -_INSTRUCTIONS = """\ -Cloudfall manages declarative infrastructure state for dedicated Debian -servers. Read-only tools validate state and derive evidence; destructive -tools change servers and require calling twice: the first call returns a -confirmation-required preview, the second call with confirm=true executes. -Every mutation writes receipts; nothing reports success it cannot prove. -The add_ssh_key, add_server_type, and add_server tools declare the fleet: -they write schema-validated resource files into the project (never onto a -server), re-validate the whole project afterwards, and remove what they -wrote when that validation fails. They never overwrite an existing -resource. The project itself is laid out beforehand with `cloudfall init` -on the CLI; this server always runs inside an existing project. -""" - - -def create_fleet_server(config: FleetConfig) -> MCPServer: - """Create the server for a brownfield repository: the catalog is the list. - - Every declared operation becomes one tool carrying its risk level as - annotations, so a client gates on risk without Cloudfall's help. No - tool here changes a host: an operation tool runs check mode and - records what it would do, and the approval is a command a person runs. - """ - server = MCPServer(name="cloudfall", instructions=_FLEET_INSTRUCTIONS) - toolset = FleetToolset(config) - for handler, name, description, tool_annotations in ( - *_fleet_registrations(toolset), - *_operation_registrations(toolset), - ): - server.add_tool( - handler, - name=name, - description=description, - annotations=tool_annotations, - ) - return server - - -def _fleet_result(call: Callable[[], dict[str, object]]) -> str: - """Return Cloudfall's own error envelope rather than an opaque crash. - - A declaration an agent can fix is worth more than a tool error it - cannot read, so every failure this surface knows about comes back as - data with its code intact. - """ - try: - return _dump(call()) - except ( - AnsibleReadError, - ConfigValidationError, - DecisionError, - FleetToolError, - ObserveError, - WhyError, - ) as error: - return _dump(error.as_dict()) - - -def _fleet_registrations(toolset: FleetToolset) -> tuple[_Registration, ...]: - read_only = ToolAnnotations(read_only_hint=True) - - def list_operations() -> str: - return _fleet_result(toolset.operations) - - def show_operation(operation: str) -> str: - return _fleet_result(lambda: toolset.operation(operation)) - - def show_fleet() -> str: - return _fleet_result(toolset.fleet) - - def observe_fleet() -> str: - return _fleet_result(toolset.observe) - - def audit_fleet() -> str: - return _fleet_result(toolset.audit) - - def list_decisions() -> str: - return _fleet_result(toolset.decisions) - - def why( - host: str = "", operation: str = "", since: str = "", until: str = "" - ) -> str: - return _fleet_result( - lambda: toolset.why( - host or None, operation or None, since or None, until or None - ) - ) - - return ( - ( - list_operations, - "list_operations", - "List the operations the team declared, with risk and inputs", - read_only, - ), - ( - show_operation, - "show_operation", - "Show one declared operation in full", - read_only, - ), - ( - show_fleet, - "show_fleet", - "Read the fleet from the team's own Ansible inventory", - read_only, - ), - ( - observe_fleet, - "observe_fleet", - "Collect one read-only snapshot per declared server", - read_only, - ), - ( - audit_fleet, - "audit_fleet", - "Compare the declared fleet with the snapshots on disk", - read_only, - ), - ( - list_decisions, - "list_decisions", - "List what was proposed, what check mode showed, and who approved", - read_only, - ), - ( - why, - "why", - "Answer why the agent did that: the decisions the record holds " - "for a host, an operation or a time window (ISO 8601), each told " - "as what it saw, proposed, showed, who approved and how it ended", - read_only, - ), - ) - - -def _operation_registrations(toolset: FleetToolset) -> tuple[_Registration, ...]: - """Expose each declared operation as its own risk-annotated tool.""" - return tuple( - ( - _operation_handler(toolset, operation), - operation_tool_name(operation), - operation_tool_description(operation), - _operation_annotations(operation), - ) - for operation in toolset.catalog().operations - ) - - -def _operation_handler( - toolset: FleetToolset, operation: Operation -) -> Callable[..., str]: - operation_id = operation.operation_id.value - - def propose_operation( - target: str = "", inputs: dict[str, str] | None = None - ) -> str: - return _fleet_result( - lambda: toolset.propose(operation_id, target or None, inputs) - ) - - return propose_operation - - -def _operation_annotations(operation: Operation) -> ToolAnnotations: - """Carry the declared risk level into the client's own gate. - - Every operation tool is read-only in effect, because calling one runs - check mode; the destructive hint says what approving it would mean. - """ - if operation.risk is RiskLevel.DESTRUCTIVE: - return ToolAnnotations(read_only_hint=True, destructive_hint=True) - return ToolAnnotations(read_only_hint=True, destructive_hint=False) - - -def create_server(config: AgentConfig) -> MCPServer: - """Create the MCP server and register every agent tool.""" - server = MCPServer(name="cloudfall", instructions=_INSTRUCTIONS) - toolset = AgentToolset(config) - registrations = ( - *_evidence_registrations(toolset), - *_authoring_registrations(toolset), - *_build_registrations(toolset), - *_mutation_registrations(toolset), - *_backup_registrations(toolset), - *_operator_registrations(toolset), - ) - for handler, name, description, tool_annotations in registrations: - server.add_tool( - handler, - name=name, - description=description, - annotations=tool_annotations, - ) - return server - - -def _evidence_registrations( - toolset: AgentToolset, -) -> tuple[_Registration, ...]: - read_only = ToolAnnotations(read_only_hint=True) - - def validate_config() -> str: - return _dump(toolset.validate()) - - def show_inventory() -> str: - return _dump(toolset.inventory()) - - def audit_servers() -> str: - return _dump(toolset.audit()) - - def services_status() -> str: - return _dump(toolset.services_status()) - - def component_health(component: str) -> str: - return _dump(toolset.component_health(component)) - - def inspect_servers() -> str: - return _dump(toolset.inspect_servers()) - - def inspect_services() -> str: - return _dump(toolset.inspect_services()) - - return ( - ( - validate_config, - "validate_config", - "Validate declared config and resource references", - read_only, - ), - ( - show_inventory, - "show_inventory", - "Return the non-secret typed platform inventory", - read_only, - ), - ( - audit_servers, - "audit_servers", - "Compare desired state with collected server evidence", - read_only, - ), - ( - services_status, - "services_status", - "Derive the evidence-based public service lifecycle", - read_only, - ), - ( - component_health, - "component_health", - "Probe one component's declared health check on its servers", - read_only, - ), - ( - inspect_servers, - "inspect_servers", - "Collect read-only server evidence snapshots over SSH", - read_only, - ), - ( - inspect_services, - "inspect_services", - "Collect DNS, TLS, origin, and public route evidence", - read_only, - ), - ) - - -def _authoring_registrations( - toolset: AgentToolset, -) -> tuple[_Registration, ...]: - writes_project = ToolAnnotations( - read_only_hint=False, destructive_hint=False, idempotent_hint=False - ) - - def add_ssh_key( - key_file: str, - owner: str, - id: str | None = None, # noqa: A002 - mirrors `cloudfall add --id`. - environment: str = "production", - description: str | None = None, - ) -> str: - return _dump(toolset.add_ssh_key(key_file, owner, id, environment, description)) - - def add_server_type( - id: str, # noqa: A002 - mirrors the `cloudfall add server-type` id. - description: str | None = None, - ) -> str: - return _dump(toolset.add_server_type(id, description)) - - def add_server( # noqa: PLR0913 - boundary signature mirrors the CLI. - id: str, # noqa: A002 - mirrors the `cloudfall add server` id. - address: str, - type: str = "debian-application", # noqa: A002 - mirrors `--type`. - environment: str = "production", - hostname: str | None = None, - ssh_user: str = "root", - ssh_port: int = 22, - description: str | None = None, - ) -> str: - return _dump( - toolset.add_server( - id, - address, - type, - environment, - hostname, - ssh_user, - ssh_port, - description, - ) - ) - - return ( - ( - add_ssh_key, - "add_ssh_key", - "Declare an SshPublicKey resource read from a controller-side " - "public key file (e.g. ~/.ssh/id_ed25519.pub); writes into the " - "project and re-validates it, never overwrites", - writes_project, - ), - ( - add_server_type, - "add_server_type", - "Declare a ServerType resource from the bundled Debian 13 " - "baseline (no software RAID, default-deny firewall with " - "22/80/443); writes into the project and re-validates it, " - "never overwrites", - writes_project, - ), - ( - add_server, - "add_server", - "Declare a Server resource reachable at an address, creating " - "its baseline ServerType when the project lacks it; writes " - "into the project and re-validates it, never overwrites", - writes_project, - ), - ) - - -def _build_registrations(toolset: AgentToolset) -> tuple[_Registration, ...]: - def import_render( - blueprint: str, - application: str, - server_id: str, - output_directory: str = "tmp/import/config", - environment_directory: str = "tmp/import/env", - ) -> str: - return _dump( - toolset.import_render( - blueprint, - application, - server_id, - output_directory, - environment_directory, - ) - ) - - def import_render_api( - api_key_file: str, - application: str, - server_id: str, - output_directory: str = "tmp/import/config", - environment_directory: str = "tmp/import/env", - ) -> str: - return _dump( - toolset.import_render_api( - api_key_file, - application, - server_id, - output_directory, - environment_directory, - ) - ) - - def render_secrets(component: str) -> str: - return _dump(toolset.render_secrets(component)) - - def build_artifact(component: str, git_ref: str) -> str: - return _dump(toolset.build_artifact(component, git_ref)) - - return ( - ( - import_render, - "import_render", - "Map a render.yaml blueprint onto Cloudfall config fragments " - "with a structured gap report", - None, - ), - ( - import_render_api, - "import_render_api", - "Map a live Render workspace onto Cloudfall config fragments " - "through the Render API; the api_key_file is a controller-side " - "file containing only the API key", - None, - ), - ( - render_secrets, - "render_secrets", - "Resolve a component's declared secret references from the " - "sops-encrypted secrets directory into its 0600 environment " - "file; the envelope carries key names and a hash, never values", - None, - ), - ( - build_artifact, - "build_artifact", - "Clone a component repository at a git ref and package a " - "hashed release artifact", - None, - ), - ) - - -def _mutation_registrations( - toolset: AgentToolset, -) -> tuple[_Registration, ...]: - destructive = ToolAnnotations(read_only_hint=False, destructive_hint=True) - - def deploy_component( - component: str, - release: str, - environment_file: str | None = None, - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump( - toolset.deploy_component( - component, release, environment_file, confirm=confirm - ) - ) - - def rollback_component( - component: str, - release: str, - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump(toolset.rollback_component(component, release, confirm=confirm)) - - def restart_component( - component: str, - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump(toolset.restart_component(component, confirm=confirm)) - - def migrate_database( - service: str, - database: str, - source_url_file: str, - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump( - toolset.migrate_database( - service, database, source_url_file, confirm=confirm - ) - ) - - def converge_baseline( - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump(toolset.converge_baseline(confirm=confirm)) - - def converge_services( - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump(toolset.converge_services(confirm=confirm)) - - def converge_domains( - issue_certificates: bool = False, # noqa: FBT001, FBT002 - explicit. - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump( - toolset.converge_domains( - issue_certificates=issue_certificates, confirm=confirm - ) - ) - - def migrate( # noqa: PLR0913 - boundary signature mirrors the toolset. - builds: dict[str, str] | None = None, - releases: dict[str, str] | None = None, - environment_files: dict[str, str] | None = None, - data_migrations: dict[str, str] | None = None, - plan_file: str = "tmp/migrate/plan.json", - restart_plan: bool = False, # noqa: FBT001, FBT002 - explicit gate. - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump( - toolset.migrate( - builds, - releases, - environment_files, - data_migrations, - plan_file, - restart_plan=restart_plan, - confirm=confirm, - ) - ) - - return ( - ( - deploy_component, - "deploy_component", - "Deploy one built release with digest verification, a health " - "gate, and automatic rollback; requires confirm=true", - destructive, - ), - ( - rollback_component, - "rollback_component", - "Activate an already-retained release behind the health gate; " - "requires confirm=true", - destructive, - ), - ( - restart_component, - "restart_component", - "Restart one component behind its health check; requires confirm=true", - destructive, - ), - ( - migrate_database, - "migrate_database", - "Dump an external PostgreSQL database and restore it into a " - "declared service with per-table row-count verification; the " - "source_url_file is a controller-side file containing only the " - "connection URL; requires confirm=true", - destructive, - ), - ( - converge_baseline, - "converge_baseline", - "Converge every server to the managed baseline; requires confirm=true", - destructive, - ), - ( - converge_services, - "converge_services", - "Converge every declared infrastructure service; requires confirm=true", - destructive, - ), - ( - converge_domains, - "converge_domains", - "Converge every declared public domain route; requires confirm=true", - destructive, - ), - ( - migrate, - "migrate", - "Preview (without confirm) or execute (confirm=true) the " - "resumable end-to-end migration plan: baseline, services, " - "builds, deployments, routes, DNS verification, TLS, and a " - "final compliance-proving evidence pass", - destructive, - ), - ) - - -def _backup_registrations( - toolset: AgentToolset, -) -> tuple[_Registration, ...]: - destructive = ToolAnnotations(read_only_hint=False, destructive_hint=True) - - def backup_service( - service: str, - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump(toolset.backup_service(service, confirm=confirm)) - - def verify_backup( - service: str, - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump(toolset.verify_backup(service, confirm=confirm)) - - return ( - ( - backup_service, - "backup_service", - "Run the declared backup for one service on its server, " - "writing a schema-valid receipt; requires confirm=true", - destructive, - ), - ( - verify_backup, - "verify_backup", - "Prove the newest backup of one service restores (dump " - "restore-check or snapshot integrity), receipted; requires " - "confirm=true", - destructive, - ), - ) - - -def _operator_registrations( - toolset: AgentToolset, -) -> tuple[_Registration, ...]: - read_only = ToolAnnotations(read_only_hint=True) - destructive = ToolAnnotations(read_only_hint=False, destructive_hint=True) - - def operator_proposals() -> str: - return _dump(toolset.operator_proposals()) - - def operator_watch( - drift: bool = False, # noqa: FBT001, FBT002 - explicit agent flag. - ) -> str: - return _dump(toolset.operator_watch(drift=drift)) - - def operator_approve( - proposal: str, - confirm: bool = False, # noqa: FBT001, FBT002 - explicit agent gate. - ) -> str: - return _dump(toolset.operator_approve(proposal, confirm=confirm)) - - return ( - ( - operator_proposals, - "operator_proposals", - "List every operator proposal receipt: trigger evidence, " - "diagnosis, proposed operation, and outcome", - read_only, - ), - ( - operator_watch, - "operator_watch", - "Run one operator watch pass: fetch firing declared alerts " - "through the mTLS gateway (and audit for drift when " - "drift=true) and write proposals for anything new", - read_only, - ), - ( - operator_approve, - "operator_approve", - "Execute one operator proposal's declared remediation and " - "verify its trigger evidence resolves; requires confirm=true", - destructive, - ), - ) - - -def _dump(payload: dict[str, object]) -> str: - return json.dumps(payload, sort_keys=True) - - -def _parser() -> StrictArgumentParser: - parser = StrictArgumentParser( - prog="cloudfall-mcp", - description=( - "Expose the Cloudfall agent toolset over MCP stdio. Read-only" - " evidence tools are available freely; every server-changing" - " tool requires an explicit two-step confirmation handshake." - ), - ) - parser.add_argument( - "--project", - type=Path, - default=None, - help=( - f"project directory to run in (default: ${PROJECT_DIRECTORY_VARIABLE}, " - "else the current directory when it is a project)" - ), - ) - parser.add_argument( - "--repository", - type=Path, - default=None, - help=( - "Ansible repository to serve instead of a project: the declared " - "operations become the tool list, and no tool changes a host" - ), - ) - parser.add_argument( - "--inventory", - type=Path, - default=None, - help=( - "inventory inside the repository (default: the one its ansible.cfg names)" - ), - ) - parser.add_argument( - "--operations", - type=project_path_argument, - default=Path(CATALOG_DIRECTORY), - help="directory holding the operation documents (default: %(default)s)", - ) - parser.add_argument( - "--decisions", - type=project_path_argument, - default=Path(DECISION_DIRECTORY), - help="directory holding the decision records (default: %(default)s)", - ) - parser.add_argument( - "--schemas", - type=Path, - default=default_schema_directory(), - help=( - "JSON Schema directory used to validate the project (default: %(default)s)" - ), - ) - parser.add_argument( - "--engine", - type=Path, - default=default_engine_directory(), - help="engine module directory containing the Ansible playbooks" - " (default: %(default)s)", - ) - parser.add_argument( - "--inventory-file", - type=project_path_argument, - default=Path("tmp/ansible-inventory.json"), - help="path where the rendered Ansible inventory is written" - " (default: %(default)s)", - ) - parser.add_argument( - "--observed", - type=project_path_argument, - default=Path("tmp/observed"), - help="directory holding read-only server observation snapshots" - " (default: %(default)s)", - ) - parser.add_argument( - "--service-observed", - type=project_path_argument, - default=Path("tmp/observed-services"), - help="directory holding DNS, TLS, origin, and public route evidence" - " (default: %(default)s)", - ) - parser.add_argument( - "--deployments", - type=project_path_argument, - default=Path("tmp/deployments"), - help="directory holding domain deployment receipts (default: %(default)s)", - ) - parser.add_argument( - "--releases", - type=project_path_argument, - default=Path("tmp/releases"), - help="directory holding component release receipts (default: %(default)s)", - ) - parser.add_argument( - "--artifacts", - type=project_path_argument, - default=Path("tmp/artifacts"), - help="directory holding built release artifacts (default: %(default)s)", - ) - parser.add_argument( - "--data-migrations", - type=project_path_argument, - default=Path("tmp/data-migrations"), - help="directory holding data-migration receipts (default: %(default)s)", - ) - parser.add_argument( - "--secrets-dir", - type=Path, - default=Path("secrets"), - help="sops-encrypted secrets directory (default: %(default)s)", - ) - parser.add_argument( - "--env-dir", - type=project_path_argument, - default=Path("tmp/env"), - help="rendered environment file directory (default: %(default)s)", - ) - parser.add_argument( - "--env-receipts", - type=project_path_argument, - default=Path("tmp/env-receipts"), - help="rendered environment receipt directory (default: %(default)s)", - ) - parser.add_argument( - "--backups", - type=project_path_argument, - default=Path("tmp/backups"), - help="directory holding backup operation receipts (default: %(default)s)", - ) - parser.add_argument( - "--proposals", - type=project_path_argument, - default=Path("tmp/operator/proposals"), - help="directory holding operator proposal receipts (default: %(default)s)", - ) - parser.add_argument( - "--gateway-ca", - type=Path, - default=None, - help="CA bundle for the logging gateway's alerts route", - ) - parser.add_argument( - "--gateway-cert", - type=Path, - default=None, - help="client certificate for the logging gateway's alerts route", - ) - parser.add_argument( - "--gateway-key", - type=Path, - default=None, - help="client key for the logging gateway's alerts route", - ) - return parser - - -def main(argv: Sequence[str] | None = None) -> int: - """Run the MCP server over stdio.""" - arguments = parse_arguments(_parser(), argv) - try: - repository = _repository(arguments, os.environ) - if repository is not None: - _serve_fleet(arguments, repository) - return 0 - with project_context(arguments.project, os.environ) as project_directory: - _serve(arguments, project_directory) - except (ProjectError, FleetToolError, ConfigValidationError) as error: - sys.stderr.write(f"{_dump(error.as_dict())}\n") - return 2 - return 0 - - -def _repository( - arguments: argparse.Namespace, environment: Mapping[str, str] -) -> Path | None: - """Return the Ansible repository to serve, if this is a brownfield run. - - Precedence matches the CLI: an explicitly named repository, then the - current directory when it is an Ansible control repository rather than - a Cloudfall project. - """ - if arguments.repository is not None: - return Path(arguments.repository) - if arguments.project is not None or environment.get(PROJECT_DIRECTORY_VARIABLE): - return None - current = Path.cwd() - if is_project(current): - return None - return current if inventory_from_config(current) is not None else None - - -def _serve_fleet(arguments: argparse.Namespace, repository: Path) -> None: - config = FleetConfig( - repository=repository, - schema_directory=Path(arguments.schemas), - engine_directory=Path(arguments.engine), - inventory=arguments.inventory, - operations_directory=Path(arguments.operations), - decisions_directory=Path(arguments.decisions), - observed_directory=Path(arguments.observed), - ) - create_fleet_server(config).run(transport="stdio") - - -def _serve(arguments: argparse.Namespace, project_directory: Path) -> None: - config = AgentConfig( - project_directory=project_directory, - schema_directory=Path(arguments.schemas), - engine_directory=Path(arguments.engine), - inventory_file=Path(arguments.inventory_file), - observed_directory=Path(arguments.observed), - service_observed_directory=Path(arguments.service_observed), - deployments_directory=Path(arguments.deployments), - releases_directory=Path(arguments.releases), - artifacts_directory=Path(arguments.artifacts), - data_migrations_directory=Path(arguments.data_migrations), - backups_directory=Path(arguments.backups), - secrets_directory=Path(arguments.secrets_dir), - environment_directory=Path(arguments.env_dir), - environment_receipts_directory=Path(arguments.env_receipts), - proposals_directory=Path(arguments.proposals), - gateway_ca_path=arguments.gateway_ca, - gateway_certificate_path=arguments.gateway_cert, - gateway_key_path=arguments.gateway_key, - ) - create_server(config).run(transport="stdio") - - -def run() -> None: - """Installed console-script entry point.""" - raise SystemExit(main()) diff --git a/sdk/src/cloudfall/observe.py b/sdk/src/cloudfall/observe.py index 338cd59..353976d 100644 --- a/sdk/src/cloudfall/observe.py +++ b/sdk/src/cloudfall/observe.py @@ -279,7 +279,7 @@ def requested_servers( def _run_playbook(argv: Sequence[str], environment: Mapping[str, str]) -> PlaybookRun: # Captured, never inherited: the CLI's stdout carries one JSON document - # and cloudfall-mcp's carries the protocol, so Ansible may write to neither. + # and `mcp serve`'s carries the protocol, so Ansible may write to neither. completed = subprocess.run( # noqa: S603 - resolved binary, built argv. tuple(argv), env={**os.environ, **environment}, diff --git a/sdk/src/cloudfall/operator.py b/sdk/src/cloudfall/operator.py index 60774fc..5d86cb6 100644 --- a/sdk/src/cloudfall/operator.py +++ b/sdk/src/cloudfall/operator.py @@ -26,7 +26,7 @@ from cloudfall.observation import load_observations if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import Callable, Iterator, Mapping from pathlib import Path from cloudfall.audit import AuditReport @@ -36,15 +36,15 @@ _PROPOSAL_SCHEMA = "operator-proposal.schema.json" _FINGERPRINT_LENGTH = 16 -_ERROR_FEED_UNREACHABLE = "operator_feed_unreachable" -_ERROR_FEED_INVALID = "operator_feed_invalid" -_ERROR_GATEWAY_MATERIAL_INVALID = "operator_gateway_material_invalid" +ERROR_FEED_UNREACHABLE = "operator_feed_unreachable" +ERROR_FEED_INVALID = "operator_feed_invalid" +ERROR_GATEWAY_MATERIAL_INVALID = "operator_gateway_material_invalid" _ERROR_PROPOSAL_EXISTS = "operator_proposal_exists" ERROR_PROPOSAL_MISSING = "operator_proposal_missing" """Code of the error ``ProposalStore`` raises for an id it holds no receipt for.""" _ERROR_PROPOSAL_NOT_OPEN = "operator_proposal_not_open" _ERROR_OPERATION_UNSUPPORTED = "operator_operation_unsupported" -_ERROR_GATEWAY_UNDECLARED = "operator_gateway_undeclared" +ERROR_GATEWAY_UNDECLARED = "operator_gateway_undeclared" _SERVICE_CHECK_PREFIXES = ("services.bind[", "alerting.rules[") _OUTCOME_DETAIL_MAX = 1000 """``spec.outcome.detail`` ``maxLength`` in the proposal schema.""" @@ -282,7 +282,7 @@ def fetch(self) -> tuple[OperatorAlert, ...]: f"(ca {self.ca_path}, certificate {self.certificate_path}, " f"key {self.key_path}): {error}" ) - raise OperatorError(_ERROR_GATEWAY_MATERIAL_INVALID, message) from error + raise OperatorError(ERROR_GATEWAY_MATERIAL_INVALID, message) from error request = urllib.request.Request(self.url) # noqa: S310 - declared https gateway try: with urllib.request.urlopen( # noqa: S310 - declared https gateway @@ -291,7 +291,7 @@ def fetch(self) -> tuple[OperatorAlert, ...]: body = response.read() except OSError as error: message = f"alert feed unreachable: {self.url}: {error}" - raise OperatorError(_ERROR_FEED_UNREACHABLE, message) from error + raise OperatorError(ERROR_FEED_UNREACHABLE, message) from error return parse_prometheus_alerts(body.decode("utf-8")) @@ -301,14 +301,14 @@ def parse_prometheus_alerts(raw: str) -> tuple[OperatorAlert, ...]: payload = cast("object", json.loads(raw)) except json.JSONDecodeError as error: message = f"alert feed returned invalid JSON: {error.msg}" - raise OperatorError(_ERROR_FEED_INVALID, message) from error + raise OperatorError(ERROR_FEED_INVALID, message) from error if not isinstance(payload, dict) or payload.get("status") != "success": message = "alert feed returned a non-success payload" - raise OperatorError(_ERROR_FEED_INVALID, message) + raise OperatorError(ERROR_FEED_INVALID, message) data = payload.get("data") if not isinstance(data, dict) or not isinstance(data.get("alerts"), list): message = "alert feed payload is missing data.alerts" - raise OperatorError(_ERROR_FEED_INVALID, message) + raise OperatorError(ERROR_FEED_INVALID, message) alerts: list[OperatorAlert] = [] for item in cast("list[object]", data["alerts"]): if not isinstance(item, dict): @@ -1102,7 +1102,7 @@ def gateway_feed( "no LoggingStack declares alerting; pass an explicit " "gateway URL or declare an alerting block" ) - raise OperatorError(_ERROR_GATEWAY_UNDECLARED, message) + raise OperatorError(ERROR_GATEWAY_UNDECLARED, message) url = ( f"https://{stack.gateway.server_name.value}:" f"{stack.gateway.port.value}/api/v1/alerts" @@ -1115,5 +1115,70 @@ def gateway_feed( ) +class PassKind(StrEnum): + """What one pass of the watch loop looked at.""" + + ALERTS = "alerts" + DRIFT = "drift" + AUTONOMY = "autonomy" + + +@dataclass(frozen=True, slots=True) +class PassReport: + """One pass of the watch loop and what it found or did.""" + + kind: PassKind + report: RunReport | AutonomyReport + + @property + def changed(self) -> bool: + """The pass wrote a proposal or executed one.""" + if isinstance(self.report, AutonomyReport): + return bool(self.report.executed) + return bool(self.report.proposed) + + def as_dict(self) -> dict[str, object]: + """Serialize for structured run output.""" + return {"pass": self.kind.value, **self.report.as_dict()} + + +@dataclass(frozen=True, slots=True) +class DriftCheck: + """An audited drift pass, run at most once per ``interval_seconds``.""" + + auditor: Callable[[], AuditReport] + interval_seconds: float + + +def watch( # noqa: PLR0913 - the loop's whole schedule is its boundary. + feed: AlertFeed, + inventory: PlatformInventory, + store: ProposalStore, + *, + drift: DriftCheck | None, + autonomy: Callable[[], AutonomyReport] | None, + interval_seconds: float | None, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, +) -> Iterator[PassReport]: + """Run watch passes until stopped, one report per pass. + + Each round runs the alerts pass, then the drift pass when it is due + (the first round always runs it), then the autonomy pass when one is + given. Without ``interval_seconds`` the loop stops after one round. + """ + drift_due = clock() + while True: + yield PassReport(PassKind.ALERTS, run_once(feed, inventory, store)) + if drift is not None and clock() >= drift_due: + yield PassReport(PassKind.DRIFT, drift_pass(drift.auditor, store)) + drift_due = clock() + drift.interval_seconds + if autonomy is not None: + yield PassReport(PassKind.AUTONOMY, autonomy()) + if interval_seconds is None: + return + sleep(interval_seconds) + + def _utc_now() -> str: return datetime.now(UTC).isoformat(timespec="seconds") diff --git a/sdk/src/cloudfall/output.py b/sdk/src/cloudfall/output.py deleted file mode 100644 index ca8db09..0000000 --- a/sdk/src/cloudfall/output.py +++ /dev/null @@ -1,412 +0,0 @@ -"""The single writer for every JSON document the ``cloudfall`` CLI prints. - -Every document has the same top level, whatever the command: - -``ok`` - ``true`` exactly when the command exits 0. - -``status`` - The command's own verdict, such as ``ok``, ``plan``, ``unhealthy`` or - ``error``; ``ok`` says whether it succeeded, ``status`` says how. - -``data`` - The command's payload, ``{}`` when a result has none. ``null`` on a - failure unless it comes with more than its code, such as a migration's - steps. - -``error`` - ``{"code", "message", "exit_code"}`` on a failure, ``null`` otherwise; - ``exit_code`` is the code the process exits with. - -All six top-level keys are on every document, so a caller reads them -without checking whether they exist. - -Each document also carries a ``meta`` object naming the output contract and the -running tool, so an agent that parsed yesterday's shape can tell when today's -may differ without a separate ``--version`` call: - -``schema_version`` - ``MAJOR.MINOR`` of the output contract shared by every command. Bump - MINOR when a command gains a key; bump MAJOR when a key is removed, - renamed, or changes meaning. It moves independently of the tool version. - -``tool_version`` - The installed ``cloudfall`` package version, the same string - ``cloudfall --version`` prints. - -``request_id`` - A UUID naming this invocation, the same on every document it writes, - so a caller can quote it in a retry or a bug report. - -``duration_ms`` - Milliseconds from the start of the invocation to this document. - -Each document also carries a ``warnings`` list, empty unless something in it -needs the caller's attention. A key is removed only after a MINOR release in -which it appears in ``DEPRECATED_FIELDS``; while it does, every document that -contains it warns ``FIELD_DEPRECATED`` with its replacement and the version -that drops it. Every change to the contract is recorded in -``SCHEMA_CHANGELOG``, which ``cloudfall changelog`` prints. -""" - -from __future__ import annotations - -import json -import re -import sys -import time -import uuid -from collections.abc import Mapping -from contextvars import ContextVar -from dataclasses import dataclass -from datetime import date -from importlib.metadata import version -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import TextIO - - -@dataclass(frozen=True, order=True, slots=True) -class SchemaVersion: - """``MAJOR.MINOR`` version of the CLI output contract.""" - - major: int - minor: int - - def __post_init__(self) -> None: - """Reject negative components.""" - if self.major < 0 or self.minor < 0: - message = f"invalid schema version: {self.major}.{self.minor}" - raise ValueError(message) - - @classmethod - def parse(cls, value: str) -> SchemaVersion: - """Parse ``MAJOR.MINOR``; anything else is rejected.""" - match = re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", value) - if match is None: - message = f"invalid schema version: {value!r}, expected MAJOR.MINOR" - raise ValueError(message) - return cls(int(match[1]), int(match[2])) - - def __str__(self) -> str: - """Return the wire form, ``MAJOR.MINOR``.""" - return f"{self.major}.{self.minor}" - - -@dataclass(frozen=True, slots=True) -class FieldPath: - """Dotted path of a key from the document root, such as ``inventory.servers``.""" - - value: str - - def __post_init__(self) -> None: - """Require non-empty dot-separated key names.""" - if not re.fullmatch( - r"[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*", self.value - ): - message = f"invalid field path: {self.value!r}" - raise ValueError(message) - - def present_in(self, document: Mapping[str, object]) -> bool: - """Return whether every key along the path exists in ``document``.""" - node: object = document - for key in self.value.split("."): - if not isinstance(node, Mapping) or key not in node: - return False - node = node[key] - return True - - -@dataclass(frozen=True, slots=True) -class DeprecatedField: - """A key still written for compatibility, and what replaces it.""" - - path: FieldPath - replacement: FieldPath - removed_in: SchemaVersion - - def warning(self) -> dict[str, object]: - """Return the ``FIELD_DEPRECATED`` warning for a document holding the key.""" - return { - "code": "FIELD_DEPRECATED", - "message": ( - f"field '{self.path.value}' is deprecated and is removed in " - f"schema {self.removed_in}; use '{self.replacement.value}'" - ), - "removed_in": str(self.removed_in), - "context": { - "field": self.path.value, - "replacement": self.replacement.value, - }, - } - - -@dataclass(frozen=True, slots=True) -class SchemaChange: - """One release of the output contract.""" - - version: SchemaVersion - released: date - added: tuple[FieldPath, ...] - removed: tuple[FieldPath, ...] - changed: tuple[FieldPath, ...] - - @property - def breaking(self) -> bool: - """A release is breaking when it removes or changes a key.""" - return bool(self.removed or self.changed) - - def as_dict(self) -> dict[str, object]: - """Return the changelog entry as written to the wire.""" - return { - "version": str(self.version), - "date": self.released.isoformat(), - "breaking": self.breaking, - "added": [path.value for path in self.added], - "removed": [path.value for path in self.removed], - "changed": [path.value for path in self.changed], - } - - -@dataclass(frozen=True, slots=True) -class ResponseMeta: - """Versions stamped into every document the CLI writes.""" - - schema_version: SchemaVersion - tool_version: str - - def as_dict(self) -> dict[str, str]: - """Return the versions, the part of ``meta`` every invocation shares.""" - return { - "schema_version": str(self.schema_version), - "tool_version": self.tool_version, - } - - -@dataclass(frozen=True, slots=True) -class OutputOptions: - """What the caller asked of the output channels, beyond the format.""" - - quiet: bool = False - """``--quiet``: write nothing to stderr, not even an error document.""" - - warnings_as_errors: bool = False - """``--warnings-as-errors``: a result carrying a warning is a failure.""" - - -@dataclass(slots=True) -class Invocation: - """One run of the CLI: its id, when it started, and its output options.""" - - request_id: uuid.UUID - started: float - """``time.monotonic()`` at the start, immune to wall-clock changes.""" - - options: OutputOptions = OutputOptions() - - failed_on_warnings: bool = False - """Whether ``--warnings-as-errors`` turned a result into a failure.""" - - @classmethod - def start(cls) -> Invocation: - """Begin an invocation now, with a fresh random id.""" - return cls(request_id=uuid.uuid4(), started=time.monotonic()) - - def meta(self) -> dict[str, object]: - """Return the ``meta`` object for a document written now.""" - elapsed = time.monotonic() - self.started - return { - **RESPONSE_META.as_dict(), - "request_id": str(self.request_id), - "duration_ms": int(elapsed * 1000), - } - - -_INVOCATION: ContextVar[Invocation] = ContextVar("cloudfall_invocation") - - -def begin_invocation() -> Invocation: - """Start the invocation every document written from now on belongs to.""" - invocation = Invocation.start() - _INVOCATION.set(invocation) - return invocation - - -def current_invocation() -> Invocation: - """Return the running invocation; writing outside one is a bug.""" - try: - return _INVOCATION.get() - except LookupError: - message = "no invocation has begun; call begin_invocation() first" - raise RuntimeError(message) from None - - -def configure_output(options: OutputOptions) -> None: - """Apply the caller's output options to the running invocation.""" - current_invocation().options = options - - -def exit_code(code: int) -> int: - """Return the process exit code, 1 where a warning failed a successful run.""" - return 1 if code == 0 and current_invocation().failed_on_warnings else code - - -SCHEMA_CHANGELOG: tuple[SchemaChange, ...] = ( - SchemaChange( - version=SchemaVersion(1, 0), - released=date(2026, 9, 23), - added=( - FieldPath("ok"), - FieldPath("status"), - FieldPath("data"), - FieldPath("error"), - FieldPath("error.exit_code"), - FieldPath("meta.schema_version"), - FieldPath("meta.tool_version"), - FieldPath("meta.request_id"), - FieldPath("meta.duration_ms"), - FieldPath("warnings"), - ), - removed=(), - changed=(), - ), -) -"""Newest last. Add an entry with every change to the output contract.""" - -OUTPUT_SCHEMA_VERSION = SCHEMA_CHANGELOG[-1].version -MINIMUM_SCHEMA_MAJOR = 1 -"""The oldest MAJOR ``--schema-version`` still accepts.""" - -DEPRECATED_FIELDS: tuple[DeprecatedField, ...] = () -"""Keys on their way out; each warns until the MAJOR named by ``removed_in``.""" - -TOOL_VERSION = version("cloudfall") -RESPONSE_META = ResponseMeta(OUTPUT_SCHEMA_VERSION, TOOL_VERSION) - - -def schema_changes_since(since: SchemaVersion | None) -> list[dict[str, object]]: - """Return the changelog entries newer than ``since``, newest first.""" - return [ - change.as_dict() - for change in reversed(SCHEMA_CHANGELOG) - if since is None or change.version > since - ] - - -def supported_schema_major(value: str) -> int: - """Validate a ``--schema-version`` pin against the majors this build writes.""" - if not re.fullmatch(r"[1-9][0-9]*", value): - message = f"invalid schema version: {value!r}, expected a MAJOR number" - raise ValueError(message) - major = int(value) - if not MINIMUM_SCHEMA_MAJOR <= major <= OUTPUT_SCHEMA_VERSION.major: - message = ( - f"schema version {major} is not supported; this build writes " - f"{MINIMUM_SCHEMA_MAJOR} to {OUTPUT_SCHEMA_VERSION.major}" - ) - raise ValueError(message) - return major - - -def schema_versions() -> dict[str, str]: - """Return the current and oldest accepted output contract versions.""" - return { - "current": str(OUTPUT_SCHEMA_VERSION), - "minimum": f"{MINIMUM_SCHEMA_MAJOR}.0", - } - - -def write_result(payload: Mapping[str, object], *, exit_code: int = 0) -> None: - """Write a result document to stdout for a command exiting with ``exit_code``.""" - _write(sys.stdout, envelope(payload, exit_code=exit_code)) - # A pipe makes stdout block-buffered; flush so long-running commands such - # as `operator run --interval` deliver each document when it is written. - sys.stdout.flush() - - -def write_error(payload: Mapping[str, object], exit_code: int) -> int: - """Write an error document to stderr and return the code to exit with. - - The payload is ``{"status": "error", "error": ...}``; returning - ``exit_code`` lets a caller write ``return write_error(payload, 2)``, so - the code in the document is the code the process exits with. - """ - if set(payload) != {"status", "error"} or payload["status"] != "error": - message = f"an error payload is status and error only, got {sorted(payload)}" - raise ValueError(message) - if exit_code == 0: - message = "an error document cannot exit 0" - raise ValueError(message) - document = envelope(payload, exit_code=exit_code) - if not current_invocation().options.quiet: - _write(sys.stderr, document) - return exit_code - - -def envelope( - payload: Mapping[str, object], - *, - exit_code: int, - deprecated_fields: tuple[DeprecatedField, ...] = DEPRECATED_FIELDS, -) -> dict[str, object]: - """Return the document for a command's flat ``payload``. - - The payload names its ``status`` and, on a failure, its ``error``; every - other key moves under ``data``. The writer adds ``ok`` (``exit_code`` is - 0), ``meta`` and ``warnings``, adds ``exit_code`` to the error, and - writes ``data`` and ``error`` as ``null`` when absent. - """ - ok = exit_code == 0 - for owned in _WRITER_KEYS: - if owned in payload: - message = f"command payloads must not set {owned!r}; the writer owns it" - raise ValueError(message) - status = payload.get("status") - if not isinstance(status, str): - message = f"a command payload names its status, got {status!r}" - raise TypeError(message) - error = payload.get("error") - if ok and (error is not None or status == "error"): - message = f"a document with status {status!r} and an error cannot be ok" - raise ValueError(message) - if error is not None: - if not isinstance(error, Mapping) or "exit_code" in error: - message = f"an error names code and message; exit_code is added: {error!r}" - raise ValueError(message) - error = {**error, "exit_code": exit_code} - data = {key: value for key, value in payload.items() if key not in _LIFTED_KEYS} - document: dict[str, object] = { - "ok": ok, - "status": status, - "data": data if data or error is None else None, - "error": error, - } - warnings = [ - deprecated.warning() - for deprecated in deprecated_fields - if deprecated.path.present_in(document) - ] - invocation = current_invocation() - if ok and warnings and invocation.options.warnings_as_errors: - invocation.failed_on_warnings = True - codes = ", ".join(sorted({str(warning["code"]) for warning in warnings})) - document["ok"] = False - document["error"] = { - "code": WARNINGS_AS_ERRORS, - "message": f"--warnings-as-errors: {len(warnings)} warning(s) ({codes})", - "exit_code": 1, - } - return {**document, "meta": invocation.meta(), "warnings": warnings} - - -WARNINGS_AS_ERRORS = "warnings_as_errors" -"""The error code of a result that ``--warnings-as-errors`` failed.""" - - -_LIFTED_KEYS = frozenset({"status", "error"}) -_WRITER_KEYS = ("ok", "data", "meta", "warnings") - - -def _write(stream: TextIO, document: Mapping[str, object]) -> None: - stream.write(f"{json.dumps(document, sort_keys=True)}\n") diff --git a/sdk/src/cloudfall/project.py b/sdk/src/cloudfall/project.py index 0f37221..5f56870 100644 --- a/sdk/src/cloudfall/project.py +++ b/sdk/src/cloudfall/project.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Protocol -from cloudfall.commands import EXIT_CODES, CommandEffect, commands_with_effect +from cloudfall.commands import CommandEffect, commands_with_effect from cloudfall.domain import ResourceKind if TYPE_CHECKING: @@ -481,7 +481,7 @@ def _pyproject(options: InitOptions) -> str: # Cloudfall is an installed package: the schema catalog and the Ansible engine # ship inside the wheel. Bump this version to move the project to a newer # Cloudfall, then run `uv sync`. Add the `mcp` extra (`cloudfall[mcp]`) to run -# the `cloudfall-mcp` agent server. +# the `cloudfall mcp serve` agent server. dependencies = ["cloudfall=={options.version}"] [tool.uv] @@ -637,9 +637,6 @@ def _agent_contract(options: InitOptions) -> str: reads = _command_rows(CommandEffect.READ) writes = _command_rows(CommandEffect.PROJECT) mutations = _command_rows(CommandEffect.SERVERS, with_gate=True) - exit_rows = "\n".join( - f"| `{exit_code.code}` | {exit_code.meaning} |" for exit_code in EXIT_CODES - ) return f"""# Agent operating contract This is a [Cloudfall](https://cloudfall.dev) project: `{options.name}`. @@ -706,31 +703,20 @@ def _agent_contract(options: InitOptions) -> str: ## Output contract -Every command prints one JSON document on stdout and nothing else, with the -same top level whatever the command: `ok` is true exactly when the exit -code is 0, `status` is the command's verdict (`ok`, `plan`, `drift`, -`unhealthy`, `paused`, `failed`), `data` holds the result, and `error` is -`null`. A failure prints `{{"ok": false, "status": "error", "data": null, -"error": {{"code": …, "message": …, "exit_code": …}}}}` on stderr, with the -code the process exits with; the `code` is stable: branch on it, not on -the message. A failed `migrate` step is the one -failure on stdout, with its steps under `data`. `--output json` is accepted -and changes nothing; `--quiet` writes nothing to stderr, not even the error, -and `--warnings-as-errors` fails a result that carries a warning. `--help` -is not JSON: use `cloudfall --schema` for the interface. Read the exit code -first: - -| Exit | Meaning | -|---|---| -{exit_rows} - -`cloudfall` is moving to the treaty contract a command at a time; the -commands `cloudfall manifest` lists have moved. Their envelope has no -top-level `status`: the verdict is `data.status`, beside the keys described -above. A failure is an envelope on stdout too, the exit code is the one -`cloudfall manifest` declares (a negative verdict such as drift keeps its -report in `data`), and the snake_case code is in `error.context.code`. Pick -the format with `--format json`; `--output` names a file there. +Every command prints a JSON envelope on stdout, a failure included: `ok` is +true exactly when the exit code is 0, `data` holds the result with the +command's verdict in `data.status` (`ok`, `plan`, `drift`, `unhealthy`, +`paused`, `failed`), `error` is `null` or says what failed, and `meta` +names the command and the request. Branch on the exit code first, then on +the snake_case code in `error.context.code`, never on the message. A +negative verdict such as drift exits non-zero and keeps its report in +`data`. `operator run` and `dashboard serve` stream: one envelope line per +event, then a closing line with `meta.end`. + +`uv run cloudfall --schema` prints every command with its flags, output +schema, and exit codes; `uv run cloudfall --schema` prints one. +`uv run cloudfall changelog` lists what changed in that interface by +release. `--help` is for people. `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 diff --git a/sdk/tests/test_agent_server.py b/sdk/tests/test_agent_server.py new file mode 100644 index 0000000..75bd426 --- /dev/null +++ b/sdk/tests/test_agent_server.py @@ -0,0 +1,303 @@ +"""``cloudfall mcp serve``, over its real stdio transport.""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Any + +import pytest +from mcp import ClientSession, StdioServerParameters, stdio_client +from mcp.types import TextContent + +ROOT = Path(__file__).parents[2] +EXAMPLES = ROOT / "config" / "examples" +SERVE = ("-c", "from cloudfall.cli import run; run()", "mcp", "serve") + +HOSTS = """\ +--- +all: + children: + app_servers: + hosts: + web-1: + ansible_host: 127.0.0.1 +""" + +GROUP_VARS = """\ +--- +ansible_user: ansible +cloudfall_server_types: + web: + os: + distribution: Debian + versions: + - '13' + service_manager: systemd + storage: + mounts: + - path: / + filesystem: ext4 + minimum_bytes: 20000000000 + packages: + required: + - name: curl + forbidden: + - telnetd + services: + required: + - name: ssh.service + state: running + status: enabled + configuration: + files: + - path: /etc/ssh/sshd_config + capture: hash +""" + +WEB_1 = """\ +--- +cloudfall: + environment: production + server_type: web + lifecycle: active +""" + +MARKER = """\ +id: write-marker +description: Write the release marker +playbook: playbooks/marker.yml +risk: mutating +targets: host +inputs: + version: string +verify: + playbook: playbooks/marker.yml +""" + +PURGE = """\ +id: purge +description: Remove what nothing points at +playbook: playbooks/marker.yml +risk: destructive +targets: fleet +verify: + playbook: playbooks/marker.yml +""" + +PLAYBOOK = """\ +--- +- name: Write the marker + hosts: all + connection: local + gather_facts: false + tasks: + - name: Place the marker + ansible.builtin.copy: + content: "release {{ version | default('none') }}\\n" + dest: "{{ playbook_dir }}/../marker.txt" + mode: "0600" +""" + + +def _repository(tmp_path: Path, **operations: str) -> Path: + repository = tmp_path / "fleet-ansible" + inventory = repository / "inventories" / "production" + (inventory / "group_vars" / "all").mkdir(parents=True) + (inventory / "host_vars").mkdir(parents=True) + (inventory / "hosts.yml").write_text(HOSTS, encoding="utf-8") + (inventory / "group_vars" / "all" / "main.yml").write_text( + GROUP_VARS, encoding="utf-8" + ) + (inventory / "host_vars" / "web-1.yml").write_text(WEB_1, encoding="utf-8") + (repository / "ansible.cfg").write_text( + textwrap.dedent("""\ + [defaults] + inventory = inventories/production + retry_files_enabled = False + interpreter_python = auto_silent + """), + encoding="utf-8", + ) + (repository / "playbooks").mkdir() + (repository / "playbooks" / "marker.yml").write_text(PLAYBOOK, encoding="utf-8") + declared = operations or {"write-marker": MARKER} + (repository / "operations").mkdir() + for name, body in declared.items(): + (repository / "operations" / f"{name}.yml").write_text(body, encoding="utf-8") + return repository + + +def _serve(*flags: str, cwd: Path) -> StdioServerParameters: + return StdioServerParameters( + command=sys.executable, args=[*SERVE, *flags], cwd=str(cwd) + ) + + +async def _session_calls( + server: StdioServerParameters, calls: list[tuple[str, dict[str, Any]]] +) -> tuple[list[Any], list[dict[str, Any]]]: + async with ( + stdio_client(server) as (read, write), + ClientSession(read, write) as session, + ): + await session.initialize() + tools = (await session.list_tools()).tools + answers = [] + for name, arguments in calls: + result = await session.call_tool(name, arguments) + content = result.content[0] + assert isinstance(content, TextContent) + answers.append(json.loads(content.text)) + return tools, answers + + +def _talk( + server: StdioServerParameters, *calls: tuple[str, dict[str, Any]] +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + tools, answers = asyncio.run(_session_calls(server, list(calls))) + return {tool.name: tool for tool in tools}, answers + + +def test_a_repository_serves_its_catalog_and_no_approval(tmp_path: Path) -> None: + repository = _repository(tmp_path, **{"write-marker": MARKER, "purge": PURGE}) + + tools, _ = _talk(_serve("--repository", str(repository), cwd=tmp_path)) + + assert set(tools) == { + "audit", + "inventory_show", + "observe", + "operations_decisions", + "operations_list", + "operations_show", + "why", + "operation_write_marker", + "operation_purge", + } + marker = tools["operation_write_marker"].annotations + purge = tools["operation_purge"].annotations + assert (marker.read_only_hint, marker.destructive_hint) == (True, False) + assert (purge.read_only_hint, purge.destructive_hint) == (True, True) + assert "cloudfall operations approve" in tools["operation_write_marker"].description + + +def test_an_operation_tool_records_a_proposal_and_changes_nothing( + tmp_path: Path, +) -> None: + repository = _repository(tmp_path) + + _, (proposed, decisions, why) = _talk( + _serve(cwd=repository), + ( + "operation_write_marker", + {"target": "web-1", "inputs": {"version": "2.0.0"}}, + ), + ("operations_decisions", {}), + ("why", {"operation": "write-marker"}), + ) + + assert proposed["ok"] is True, proposed + decision = proposed["data"]["decision"] + assert decision["spec"]["status"] == "proposed" + assert decision["spec"]["inputs"] == {"version": "2.0.0"} + assert proposed["data"]["next"] == [ + f"cloudfall operations approve {decision['metadata']['id']} --yes" + ] + assert not (repository / "marker.txt").exists() + recorded = decisions["data"]["decisions"] + assert [entry["spec"]["operation"]["id"] for entry in recorded] == ["write-marker"] + assert why["ok"] is True + + +def test_a_call_cannot_reach_another_fleet_or_approve(tmp_path: Path) -> None: + repository = _repository(tmp_path) + other = _repository(tmp_path / "other") + + _, (redirected, approved) = _talk( + _serve("--repository", str(repository), cwd=tmp_path), + ("operations_list", {"repository": str(other)}), + ("operations_approve", {"decision": "anything", "yes": True}), + ) + + assert redirected["ok"] is False + assert redirected["error"]["code"] == "ARG_ERROR" + assert approved["error"]["code"] == "UNKNOWN_TOOL" + + +def test_a_project_serves_its_commands_with_the_project_bound(tmp_path: Path) -> None: + tools, (converge,) = _talk( + _serve("--project", str(EXAMPLES), cwd=tmp_path), + ("converge_baseline", {}), + ) + + assert {"deploy", "audit", "operator_approve", "build_artifact"} <= set(tools) + assert not { + "init", + "operations_approve", + "operator_run", + "dashboard_serve", + "cleanup", + } & set(tools) + for tool in tools.values(): + properties = tool.input_schema.get("properties", {}) + assert not {"project", "schemas", "engine", "inventory"} & set(properties) + assert tools["converge_baseline"].annotations.destructive_hint is True + assert converge["ok"] is False + assert converge["error"]["code"] == "CONFIRMATION_REQUIRED" + + +def test_no_fleet_to_serve_fails_before_serving(tmp_path: Path) -> None: + done = subprocess.run( # noqa: S603 - fixed interpreter and arguments. + [sys.executable, *SERVE], + cwd=tmp_path, + stdin=subprocess.PIPE, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + envelope = json.loads(done.stderr.splitlines()[-1]) + assert done.returncode == 79 + assert done.stdout == "" + assert envelope["error"]["code"] == "PROJECT_INVALID" + + +@pytest.mark.parametrize("flags", [("--project", "x", "--repository", "y")]) +def test_a_server_serves_one_fleet(flags: tuple[str, ...], tmp_path: Path) -> None: + done = subprocess.run( # noqa: S603 - fixed interpreter and arguments. + [sys.executable, *SERVE, *flags], + cwd=tmp_path, + stdin=subprocess.PIPE, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + assert done.returncode == 2 + assert "two fleets" in done.stderr + + +def test_a_repository_without_an_inventory_says_so(tmp_path: Path) -> None: + repository = _repository(tmp_path) + (repository / "ansible.cfg").write_text("[defaults]\n", encoding="utf-8") + + done = subprocess.run( # noqa: S603 - fixed interpreter and arguments. + [sys.executable, *SERVE, "--repository", str(repository)], + cwd=tmp_path, + stdin=subprocess.PIPE, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + envelope = json.loads(done.stderr.splitlines()[-1]) + assert done.returncode == 85 + assert envelope["error"]["context"]["code"] == "fleet_inventory_undeclared" diff --git a/sdk/tests/test_agent_tools.py b/sdk/tests/test_agent_tools.py deleted file mode 100644 index 54c3618..0000000 --- a/sdk/tests/test_agent_tools.py +++ /dev/null @@ -1,399 +0,0 @@ -"""Agent toolset and MCP server tests.""" - -from __future__ import annotations - -import asyncio -from pathlib import Path - -import pytest -import yaml -from cloudfall.agent_tools import AgentConfig, AgentToolset -from cloudfall.project import ( - InitOptions, - ProjectName, - ReleaseVersion, - init_project, -) - -ROOT = Path(__file__).parents[2] -SCHEMAS = ROOT / "config" / "schemas" / "v1" -EXAMPLES = ROOT / "config" / "examples" -ENGINE = ROOT / "engine" -COMPLIANT = ROOT / "config" / "tests" / "observed" / "compliant" - -BLUEPRINT = """\ -services: - - type: worker - name: acme-worker - runtime: python - repo: https://github.com/example/acme.git - startCommand: celery -A acme worker -""" - - -VERSION = "0.3.0" - - -def _config( - tmp_path: Path, - observed: Path | None = None, - project_directory: Path = EXAMPLES, -) -> AgentConfig: - return AgentConfig( - project_directory=project_directory, - schema_directory=SCHEMAS, - engine_directory=ENGINE, - inventory_file=tmp_path / "inventory.json", - observed_directory=observed if observed is not None else COMPLIANT, - service_observed_directory=tmp_path / "observed-services", - deployments_directory=tmp_path / "deployments", - releases_directory=tmp_path / "releases", - artifacts_directory=tmp_path / "artifacts", - proposals_directory=tmp_path / "proposals", - ) - - -def test_read_only_tools_return_structured_evidence(tmp_path: Path) -> None: - toolset = AgentToolset(_config(tmp_path)) - - validated = toolset.validate() - inventory = toolset.inventory() - audit = toolset.audit() - - assert validated["status"] == "ok" - assert validated["resources"] == 12 - assert inventory["status"] == "ok" - assert audit["status"] == "compliant" - - -def test_audit_reports_missing_evidence_as_a_structured_error( - tmp_path: Path, -) -> None: - toolset = AgentToolset(_config(tmp_path, observed=tmp_path / "missing")) - - result = toolset.audit() - - assert result["status"] == "error" - error = result["error"] - assert isinstance(error, dict) - assert error["code"] == "observation_directory_missing" - - -def test_mutating_tools_require_an_explicit_confirmation( - tmp_path: Path, -) -> None: - toolset = AgentToolset(_config(tmp_path)) - - deploy_preview = toolset.deploy_component( - "crm-backend", "20260101T000000Z-abcdef0" - ) - baseline_preview = toolset.converge_baseline() - - assert deploy_preview["status"] == "confirmation-required" - assert deploy_preview["action"] == "deploy" - assert "crm-backend" in str(deploy_preview["wouldRun"]) - assert baseline_preview["status"] == "confirmation-required" - assert "confirm=true" in str(baseline_preview["instruction"]) - - -def test_confirmed_deploy_still_verifies_the_artifact(tmp_path: Path) -> None: - toolset = AgentToolset(_config(tmp_path)) - - result = toolset.deploy_component( - "crm-backend", "20260101T000000Z-abcdef0", confirm=True - ) - - assert result["status"] == "error" - error = result["error"] - assert isinstance(error, dict) - assert error["code"] == "lifecycle_artifact_missing" - - -def test_invalid_identifiers_return_structured_errors(tmp_path: Path) -> None: - toolset = AgentToolset(_config(tmp_path)) - - result = toolset.rollback_component( - "crm-backend", "not-a-release", confirm=True - ) - - assert result["status"] == "error" - error = result["error"] - assert isinstance(error, dict) - assert error["code"] == "invalid_argument" - - -def test_path_arguments_must_stay_inside_the_project(tmp_path: Path) -> None: - blueprint = tmp_path / "render.yaml" - blueprint.write_text(BLUEPRINT, encoding="utf-8") - toolset = AgentToolset(_config(tmp_path)) - - imported = toolset.import_render( - str(blueprint), "acme", "h1", "../../config", "tmp/env" - ) - planned = toolset.migrate(plan_file="tmp/../../plan.json") - - for result in (imported, planned): - assert result["status"] == "error" - error = result["error"] - assert isinstance(error, dict) - assert error["code"] == "invalid_argument" - assert "leaves the project directory" in str(error["message"]) - - -def _fresh_project(tmp_path: Path) -> AgentToolset: - directory = tmp_path / "project" - init_project( - InitOptions( - directory=directory, - name=ProjectName("project"), - version=ReleaseVersion(VERSION), - ) - ) - return AgentToolset(_config(tmp_path, project_directory=directory)) - - -def _example_key() -> str: - document = yaml.safe_load( - (EXAMPLES / "ssh-public-keys" / "example-admin.yaml").read_text( - encoding="utf-8" - ) - ) - return str(document["spec"]["publicKey"]) - - -def test_fleet_tools_write_resources_and_revalidate_the_project( - tmp_path: Path, -) -> None: - toolset = _fresh_project(tmp_path) - key_file = tmp_path / "id_ed25519.pub" - key_file.write_text(f"{_example_key()}\n", encoding="utf-8") - - key = toolset.add_ssh_key(str(key_file), "roman") - server = toolset.add_server("h1", "203.0.113.10", description="first host") - validated = toolset.validate() - - assert key["status"] == "ok" - assert key["added"] == [ - {"kind": "SshPublicKey", "id": "roman", "path": "ssh-public-keys/roman.yaml"} - ] - assert server["status"] == "ok" - added = server["added"] - assert isinstance(added, list) - assert [resource["kind"] for resource in added] == ["ServerType", "Server"] - assert validated["status"] == "ok" - assert validated["resources"] == 3 - - -def test_fleet_tools_return_structured_errors_and_never_overwrite( - tmp_path: Path, -) -> None: - toolset = _fresh_project(tmp_path) - - missing_key = toolset.add_ssh_key(str(tmp_path / "absent.pub"), "roman") - bad_port = toolset.add_server("h1", "203.0.113.10", ssh_port=70000) - first = toolset.add_server_type("debian-application") - again = toolset.add_server_type("debian-application") - - assert first["status"] == "ok" - for result, code in ( - (missing_key, "ssh_key_file_missing"), - (bad_port, "invalid_argument"), - (again, "resource_exists"), - ): - assert result["status"] == "error" - error = result["error"] - assert isinstance(error, dict) - assert error["code"] == code - - -def test_import_render_tool_maps_a_blueprint(tmp_path: Path) -> None: - blueprint = tmp_path / "render.yaml" - blueprint.write_text(BLUEPRINT, encoding="utf-8") - toolset = AgentToolset(_config(tmp_path)) - - result = toolset.import_render( - str(blueprint), - "acme", - "h1", - str(tmp_path / "config"), - str(tmp_path / "env"), - ) - - assert result["status"] == "ok" - assert result["components"] == ["acme-worker"] - - -def test_migrate_tool_previews_the_plan_without_confirmation( - tmp_path: Path, -) -> None: - toolset = AgentToolset(_config(tmp_path)) - - result = toolset.migrate( - builds={"crm-backend": "main"}, - plan_file=str(tmp_path / "plan.json"), - ) - - assert result["status"] == "plan" - assert result["next"] == "baseline" - assert "confirm=true" in str(result["instruction"]) - assert not (tmp_path / "plan.json").exists() - - -def test_mcp_server_registers_annotated_tools(tmp_path: Path) -> None: - pytest.importorskip("mcp") - from cloudfall.mcp_server import ( # noqa: PLC0415 - optional extra. - create_server, - ) - - server = create_server(_config(tmp_path)) - tools = {tool.name: tool for tool in asyncio.run(server.list_tools())} - - assert { - "validate_config", - "audit_servers", - "inspect_servers", - "add_ssh_key", - "add_server_type", - "add_server", - "import_render", - "build_artifact", - "deploy_component", - "converge_baseline", - "converge_domains", - "migrate", - } <= set(tools) - assert tools["audit_servers"].annotations is not None - assert tools["audit_servers"].annotations.read_only_hint is True - assert tools["deploy_component"].annotations is not None - assert tools["deploy_component"].annotations.destructive_hint is True - assert "confirm" in str(tools["deploy_component"].input_schema) - assert tools["add_server"].annotations is not None - assert tools["add_server"].annotations.read_only_hint is False - assert tools["add_server"].annotations.destructive_hint is False - assert "ssh_port" in str(tools["add_server"].input_schema) - assert "confirm" not in str(tools["add_server"].input_schema) - - -def test_operator_tools_list_gate_and_report_missing_material( - tmp_path: Path, -) -> None: - toolset = AgentToolset(_config(tmp_path)) - - proposals = toolset.operator_proposals() - gate = toolset.operator_approve("op-nonexistent") - watch = toolset.operator_watch() - - assert proposals == {"status": "ok", "proposals": []} - assert gate["status"] == "error" - assert watch["status"] == "error" - error = watch["error"] - assert isinstance(error, dict) - assert set(error) == {"code", "message"} - assert error["code"] == "operator_gateway_material_missing" - - -def test_operator_approve_gates_before_executing(tmp_path: Path) -> None: - from cloudfall.domain import ResourceId # noqa: PLC0415 - test-local. - from cloudfall.operator import ( # noqa: PLC0415 - test-local. - OperationKind, - ProposalStore, - propose_for_drift, - ) - from cloudfall.validation import SchemaCatalog # noqa: PLC0415 - - config = _config(tmp_path) - store = ProposalStore( - directory=config.proposals_directory, catalog=SchemaCatalog(SCHEMAS) - ) - proposal = propose_for_drift( - ResourceId.from_boundary("h1"), - ("packages.required[curl]",), - OperationKind.CONVERGE_BASELINE, - "2026-09-10T15:00:00+00:00", - ) - store.save(proposal) - toolset = AgentToolset(config) - - gate = toolset.operator_approve(proposal.resource_id.value) - - assert gate["status"] == "confirmation-required" - assert "baseline.yml" in str(gate["wouldRun"]) - - -def test_operator_approve_reports_engine_failures_in_one_envelope( - tmp_path: Path, -) -> None: - from dataclasses import replace # noqa: PLC0415 - test-local. - - from cloudfall.domain import ResourceId # noqa: PLC0415 - test-local. - from cloudfall.operator import ( # noqa: PLC0415 - test-local. - OperationKind, - ProposalStore, - propose_for_drift, - ) - from cloudfall.validation import SchemaCatalog # noqa: PLC0415 - - config = replace(_config(tmp_path), engine_directory=tmp_path / "no-engine") - store = ProposalStore( - directory=config.proposals_directory, catalog=SchemaCatalog(SCHEMAS) - ) - proposal = propose_for_drift( - ResourceId.from_boundary("h1"), - ("packages.required[curl]",), - OperationKind.CONVERGE_BASELINE, - "2026-09-10T15:00:00+00:00", - ) - store.save(proposal) - - failure = AgentToolset(config).operator_approve( - proposal.resource_id.value, confirm=True - ) - - assert failure["status"] == "error" - error = failure["error"] - assert isinstance(error, dict) - assert set(error) == {"code", "message"} - assert error["code"] == "lifecycle_engine_missing" - - -def test_backup_tools_gate_and_reject_unknown_services( - tmp_path: Path, -) -> None: - toolset = AgentToolset(_config(tmp_path)) - - gate = toolset.backup_service("postgresql-main") - verify_gate = toolset.verify_backup("postgresql-main") - unknown = toolset.backup_service("mystery-service", confirm=True) - - assert gate["status"] == "confirmation-required" - assert "run the declared backup" in str(gate["wouldRun"]) - assert verify_gate["status"] == "confirmation-required" - assert "prove the newest backup restores" in str(verify_gate["wouldRun"]) - assert unknown["status"] == "error" - error = unknown["error"] - assert isinstance(error, dict) - assert error["code"] == "lifecycle_service_missing" - - -def test_backup_receipt_schema_accepts_the_playbook_shape() -> None: - from cloudfall.validation import SchemaCatalog # noqa: PLC0415 - - receipt = { - "apiVersion": "cloudfall/v1", - "kind": "BackupReceipt", - "metadata": { - "id": "postgresql-main-restore-check", - "description": "Backup operation receipt written by backup.yml", - }, - "spec": { - "service": "postgresql-main", - "server": "h1", - "action": "restore-check", - "executedAt": "2026-09-11T10:00:00Z", - "summary": "restore check passed for crm: latest.dump", - }, - } - - SchemaCatalog(SCHEMAS).validate_named( - "backup-receipt.schema.json", receipt - ) diff --git a/sdk/tests/test_app.py b/sdk/tests/test_app.py index 272c30a..568af7c 100644 --- a/sdk/tests/test_app.py +++ b/sdk/tests/test_app.py @@ -3,49 +3,53 @@ from __future__ import annotations import json +import select import shutil +import signal +import subprocess +import sys +import urllib.request from pathlib import Path +from typing import TYPE_CHECKING -import pytest -from cloudfall.app import Fleet, MigrateArgs, app +from cloudfall.app import Fleet, MigrateArgs from cloudfall.cli import main from cloudfall.commands import CLI_COMMANDS from cloudfall.resources import default_schema_directory from cloudfall.validation import validate_config +if TYPE_CHECKING: + import pytest + ROOT = Path(__file__).parents[2] EXAMPLES = ROOT / "config" / "examples" COMPLIANT = ROOT / "config" / "tests" / "observed" / "compliant" SCHEMAS = default_schema_directory() -def test_the_contract_marks_exactly_the_commands_treaty_runs() -> None: - marked = { - contract.name - for contract in CLI_COMMANDS - if contract.program == "cloudfall" and contract.treaty - } - routed = { - contract.name - for contract in CLI_COMMANDS - if contract.program == "cloudfall" and app.resolves(contract.name.split()) - } +def test_root_help_lists_the_commands(capsys: pytest.CaptureFixture[str]) -> None: + code = main(["--help"]) - assert marked == routed - assert "audit" in marked + captured = capsys.readouterr() + assert code == 0 + assert "audit" in captured.out + captured.err -def test_root_help_still_lists_a_command_that_moved( +def test_root_schema_lists_every_cataloged_command( capsys: pytest.CaptureFixture[str], ) -> None: - with pytest.raises(SystemExit) as exit_info: - main(["--help"]) + code = main(["--schema"]) - assert exit_info.value.code == 0 - assert "audit" in capsys.readouterr().err + manifest = json.loads(capsys.readouterr().out)["data"] + listed = set(manifest["commands"]) + assert code == 0 + assert { + contract.name.replace(" ", ".") + for contract in CLI_COMMANDS + } <= listed -def test_a_moved_command_keeps_its_keys_with_status_under_data( +def test_a_command_keeps_its_keys_with_status_under_data( capsys: pytest.CaptureFixture[str], ) -> None: code = main(["operations", "decisions", "--repository", str(EXAMPLES)]) @@ -57,19 +61,6 @@ def test_a_moved_command_keeps_its_keys_with_status_under_data( assert set(document["data"]) == {"status", "directory", "decisions"} -def test_a_half_moved_group_sends_the_rest_to_argparse( - capsys: pytest.CaptureFixture[str], -) -> None: - """`operator list` runs on treaty; `operator run` is still argparse's.""" - with pytest.raises(SystemExit) as exit_info: - main(["operator", "run", "--project", str(EXAMPLES)]) - - error = json.loads(capsys.readouterr().err) - assert exit_info.value.code == 2 - assert error["status"] == "error" - assert "--gateway-ca" in error["error"]["message"] - - def test_a_drift_report_keeps_the_order_the_checks_ran_in( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -92,7 +83,7 @@ def test_a_drift_report_keeps_the_order_the_checks_ran_in( def test_migrate_resolves_env_and_data_files_against_the_project( tmp_path: Path, ) -> None: - """The argparse CLI ran migrate inside the project; relative paths meant it.""" + """Relative env and data paths name files inside the project.""" fleet = Fleet(EXAMPLES, validate_config(EXAMPLES, SCHEMAS)) args = MigrateArgs( env_file=("crm-backend=tmp/env/crm.env",), @@ -105,3 +96,40 @@ def test_migrate_resolves_env_and_data_files_against_the_project( "crm-backend": EXAMPLES / "tmp/env/crm.env" } assert options.data_migrations == {"crm": tmp_path / "source.url"} + + +def test_dashboard_serve_streams_where_it_listens_until_stopped( + tmp_path: Path, +) -> None: + command = [ + sys.executable, + "-c", + "from cloudfall.cli import run; run()", + *("dashboard", "serve", "--project", str(EXAMPLES)), + *("--observed", str(COMPLIANT), "--port", "0"), + ] + process = subprocess.Popen( # noqa: S603 - fixed interpreter and arguments. + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + cwd=tmp_path, + ) + try: + assert process.stdout is not None + readable, _, _ = select.select([process.stdout], [], [], 30) + assert readable, "the listening event never arrived" + listening = json.loads(process.stdout.readline()) + url = listening["data"]["dashboard"]["url"] + with urllib.request.urlopen(f"{url}operations.json", timeout=10) as response: # noqa: S310 - local server + assert response.status == 200 + process.send_signal(signal.SIGTERM) + closing = json.loads(process.stdout.read().splitlines()[-1]) + assert process.wait(timeout=10) == 143 + finally: + process.kill() + process.wait() + + assert listening["meta"]["seq"] == 1 + assert listening["data"]["status"] == "ok" + assert closing["error"]["code"] == "CANCELLED" diff --git a/sdk/tests/test_arguments.py b/sdk/tests/test_arguments.py index 917c468..5d66a40 100644 --- a/sdk/tests/test_arguments.py +++ b/sdk/tests/test_arguments.py @@ -3,16 +3,10 @@ from __future__ import annotations import json -import os -import select -import subprocess -import sys from pathlib import Path -from unittest.mock import ANY import pytest from cloudfall.cli import main -from cloudfall.output import RESPONSE_META ROOT = Path(__file__).parents[2] SCHEMAS = ROOT / "config" / "schemas" / "v1" @@ -20,23 +14,10 @@ SECRET = "postgresql://migrator:hunter2@db.example.test/crm" # noqa: S105 - fake -def _usage_error( - argv: list[str], capsys: pytest.CaptureFixture[str] -) -> dict[str, object]: - with pytest.raises(SystemExit) as exit_info: - main(argv) - assert exit_info.value.code == 2 - captured = capsys.readouterr() - assert captured.out == "" - payload = json.loads(captured.err) - assert isinstance(payload, dict) - return payload - - def test_abbreviated_secret_file_flag_is_rejected_without_echo( capsys: pytest.CaptureFixture[str], ) -> None: - payload = _treaty_usage_error( + payload = _usage_error( [ "data", "migrate", @@ -58,10 +39,10 @@ def test_abbreviated_secret_file_flag_is_rejected_without_echo( assert "hunter2" not in json.dumps(payload) -def _treaty_usage_error( +def _usage_error( argv: list[str], capsys: pytest.CaptureFixture[str] ) -> dict[str, object]: - """Run a command that moved to treaty: a usage error is exit 2 on stdout.""" + """Run a command: a usage error is exit 2, as an envelope on stdout.""" assert main(argv) == 2 payload = json.loads(capsys.readouterr().out) assert isinstance(payload, dict) @@ -72,7 +53,7 @@ def _treaty_usage_error( def test_unrecognized_option_values_are_not_echoed( capsys: pytest.CaptureFixture[str], ) -> None: - payload = _treaty_usage_error( + payload = _usage_error( [ "config", "validate", @@ -94,7 +75,7 @@ def test_unrecognized_option_values_are_not_echoed( def test_missing_required_argument_is_a_json_usage_error( capsys: pytest.CaptureFixture[str], ) -> None: - payload = _treaty_usage_error( + payload = _usage_error( ["deploy", "crm-backend", "--project", str(EXAMPLES)], capsys ) @@ -113,10 +94,10 @@ def test_missing_required_argument_is_a_json_usage_error( (["rollback", "crm-backend", "--release", "r1"], "release"), ], ) -def test_an_invalid_identifier_fails_before_a_treaty_command_runs( +def test_an_invalid_identifier_fails_before_the_command_runs( argv: list[str], field: str, capsys: pytest.CaptureFixture[str] ) -> None: - payload = _treaty_usage_error([*argv, "--project", str(EXAMPLES)], capsys) + payload = _usage_error([*argv, "--project", str(EXAMPLES)], capsys) error = payload["error"] assert isinstance(error, dict) @@ -130,7 +111,7 @@ def test_import_render_rejects_an_invalid_application_id( blueprint = tmp_path / "render.yaml" blueprint.write_text("services: []\n", encoding="utf-8") - payload = _treaty_usage_error( + payload = _usage_error( [ "import", "render", @@ -148,53 +129,13 @@ def test_import_render_rejects_an_invalid_application_id( assert "application" in json.dumps(payload["error"]) -def test_json_documents_are_flushed_when_stdout_is_a_pipe(tmp_path: Path) -> None: - script = ( - "import sys, time\n" - "from cloudfall.output import begin_invocation, write_result\n" - "begin_invocation()\n" - "write_result({'status': 'ok'})\n" - "sys.stderr.write('written\\n')\n" - "sys.stderr.flush()\n" - "time.sleep(30)\n" - ) - environment = { - key: value for key, value in os.environ.items() if key != "PYTHONUNBUFFERED" - } - process = subprocess.Popen( # noqa: S603 - fixed interpreter and script. - [sys.executable, "-c", script], - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=tmp_path, - env=environment, - ) - try: - assert process.stderr is not None - assert process.stdout is not None - assert process.stderr.readline() == b"written\n" - readable, _, _ = select.select([process.stdout], [], [], 5) - assert readable, "the JSON document stayed in the stdout buffer" - assert json.loads(process.stdout.readline()) == { - "ok": True, - "status": "ok", - "error": None, - "data": {}, - "meta": {**RESPONSE_META.as_dict(), "request_id": ANY, "duration_ms": ANY}, - "warnings": [], - } - finally: - process.kill() - process.wait() - - def test_relative_output_leaving_the_project_is_rejected( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: observed = tmp_path / "observed" observed.mkdir() - payload = _treaty_usage_error( + payload = _usage_error( [ "dashboard", "build", diff --git a/sdk/tests/test_commands.py b/sdk/tests/test_commands.py index b036f9d..5936d77 100644 --- a/sdk/tests/test_commands.py +++ b/sdk/tests/test_commands.py @@ -1,10 +1,10 @@ -"""The command catalog matches the command trees it classifies.""" +"""The command catalog matches the commands the treaty apps register.""" from __future__ import annotations -import argparse +import dataclasses -from cloudfall.cli import _parser as cloudfall_parser +from cloudfall.app import app from cloudfall.commands import ( CLI_COMMANDS, COMMANDS, @@ -15,31 +15,21 @@ from cloudfall_engine.cli import app as engine_app -def _leaves(parser: argparse.ArgumentParser, prefix: str = "") -> dict[str, set[str]]: - """Map every leaf command path to the long options it accepts.""" - subparsers = [ - action - for action in parser._actions # noqa: SLF001 - argparse has no public walk. - if isinstance(action, argparse._SubParsersAction) # noqa: SLF001 - ] - if not subparsers: - options = { - option - for action in parser._actions # noqa: SLF001 - for option in action.option_strings +def _leaves() -> dict[str, set[str]]: + """Map every ``cloudfall`` command path to the fields its arguments take.""" + return { + str(path).replace(".", " "): { + field.name for field in dataclasses.fields(command.args_type) } - return {prefix.strip(): options} - leaves: dict[str, set[str]] = {} - for action in subparsers: - for name, child in action.choices.items(): - leaves.update(_leaves(child, f"{prefix} {name}")) - return leaves + for path, command in app.commands.items() + if path not in app.builtins + } def test_catalog_names_every_cli_leaf_command_once() -> None: cataloged = [command.name for command in CLI_COMMANDS] - assert sorted(cataloged) == sorted(_leaves(cloudfall_parser())) + assert sorted(cataloged) == sorted(_leaves()) assert len(cataloged) == len(set(cataloged)) assert all(command.program == "cloudfall" for command in CLI_COMMANDS) @@ -60,8 +50,8 @@ def test_catalog_names_every_engine_leaf_command_once() -> None: def test_every_yes_gated_command_is_classified_as_changing_servers() -> None: gated = { name - for name, options in _leaves(cloudfall_parser()).items() - if "--yes" in options + for name, fields in _leaves().items() + if "yes" in fields } changing = { command.name for command in commands_with_effect(CommandEffect.SERVERS) diff --git a/sdk/tests/test_dashboard_server.py b/sdk/tests/test_dashboard_server.py index f700fe5..1c8cb78 100644 --- a/sdk/tests/test_dashboard_server.py +++ b/sdk/tests/test_dashboard_server.py @@ -192,18 +192,13 @@ def test_eager_first_snapshot_fails_fast(tmp_path: Path) -> None: ) -@pytest.mark.parametrize(("quiet", "logged"), [(False, True), (True, False)]) -def test_quiet_keeps_request_lines_off_stderr( - quiet: bool, # noqa: FBT001 - parametrized - logged: bool, # noqa: FBT001 - parametrized - tmp_path: Path, - capfd: pytest.CaptureFixture[str], -) -> None: +def test_request_lines_go_to_the_request_log(tmp_path: Path) -> None: + lines: list[str] = [] server = create_dashboard_server( _sources(tmp_path), ListenEndpoint(host="127.0.0.1", port=0), RefreshInterval(60), - quiet=quiet, + request_log=lines.append, ) thread = _serve(server) try: @@ -214,4 +209,4 @@ def test_quiet_keeps_request_lines_off_stderr( thread.join(timeout=5) assert status == 404 - assert ("GET /missing" in capfd.readouterr().err) is logged + assert any("GET /missing" in line for line in lines) diff --git a/sdk/tests/test_fleet_tools.py b/sdk/tests/test_fleet_tools.py deleted file mode 100644 index d4bbd49..0000000 --- a/sdk/tests/test_fleet_tools.py +++ /dev/null @@ -1,323 +0,0 @@ -"""The agent surface on a brownfield repository.""" - -from __future__ import annotations - -import asyncio -import json -import textwrap -from typing import TYPE_CHECKING, Any, cast - -import pytest -from cloudfall.fleet_tools import ( - FleetConfig, - FleetToolError, - FleetToolset, - operation_tool_description, - operation_tool_name, -) -from cloudfall.mcp_server import create_fleet_server -from cloudfall.resources import default_engine_directory, default_schema_directory - -if TYPE_CHECKING: - from pathlib import Path - - from mcp.server.mcpserver import MCPServer - from mcp.types import CallToolResult, TextContent - -SCHEMAS = default_schema_directory() -ENGINE = default_engine_directory() - -HOSTS = """\ ---- -all: - children: - app_servers: - hosts: - web-1: - ansible_host: 127.0.0.1 -""" - -GROUP_VARS = """\ ---- -ansible_user: ansible -cloudfall_server_types: - web: - os: - distribution: Debian - versions: - - '13' - service_manager: systemd - storage: - mounts: - - path: / - filesystem: ext4 - minimum_bytes: 20000000000 - packages: - required: - - name: curl - forbidden: - - telnetd - services: - required: - - name: ssh.service - state: running - status: enabled - configuration: - files: - - path: /etc/ssh/sshd_config - capture: hash -""" - -WEB_1 = """\ ---- -cloudfall: - environment: production - server_type: web - lifecycle: active -""" - -MARKER = """\ -id: write-marker -description: Write the release marker -playbook: playbooks/marker.yml -risk: mutating -targets: host -inputs: - version: string -verify: - playbook: playbooks/marker.yml -""" - -PURGE = """\ -id: purge -description: Remove what nothing points at -playbook: playbooks/marker.yml -risk: destructive -targets: fleet -verify: - playbook: playbooks/marker.yml -""" - -PLAYBOOK = """\ ---- -- name: Write the marker - hosts: all - connection: local - gather_facts: false - tasks: - - name: Place the marker - ansible.builtin.copy: - content: "release {{ version | default('none') }}\\n" - dest: "{{ playbook_dir }}/../marker.txt" - mode: "0600" -""" - - -def _repository(tmp_path: Path, **operations: str) -> Path: - repository = tmp_path / "fleet-ansible" - inventory = repository / "inventories" / "production" - (inventory / "group_vars" / "all").mkdir(parents=True) - (inventory / "host_vars").mkdir(parents=True) - (inventory / "hosts.yml").write_text(HOSTS, encoding="utf-8") - (inventory / "group_vars" / "all" / "main.yml").write_text( - GROUP_VARS, encoding="utf-8" - ) - (inventory / "host_vars" / "web-1.yml").write_text(WEB_1, encoding="utf-8") - (repository / "ansible.cfg").write_text( - textwrap.dedent("""\ - [defaults] - inventory = inventories/production - retry_files_enabled = False - interpreter_python = auto_silent - """), - encoding="utf-8", - ) - (repository / "playbooks").mkdir() - (repository / "playbooks" / "marker.yml").write_text(PLAYBOOK, encoding="utf-8") - declared = operations or {"write-marker": MARKER} - (repository / "operations").mkdir() - for name, body in declared.items(): - (repository / "operations" / f"{name}.yml").write_text(body, encoding="utf-8") - return repository - - -def _toolset(repository: Path) -> FleetToolset: - return FleetToolset( - FleetConfig( - repository=repository, - schema_directory=SCHEMAS, - engine_directory=ENGINE, - ) - ) - - -def _server(repository: Path) -> MCPServer: - return create_fleet_server( - FleetConfig( - repository=repository, - schema_directory=SCHEMAS, - engine_directory=ENGINE, - ) - ) - - -def _call(server: MCPServer, name: str, arguments: dict[str, object]) -> Any: # noqa: ANN401 - a tool's envelope is read like parsed JSON - """Call one tool and read the JSON envelope it returned.""" - result = asyncio.run(server.call_tool(name, arguments)) - content = cast("CallToolResult", result).content[0] - return json.loads(cast("TextContent", content).text) - - -def test_the_tool_list_is_the_catalog(tmp_path: Path) -> None: - """A playbook the team has not declared is not reachable.""" - repository = _repository(tmp_path, **{"write-marker": MARKER, "purge": PURGE}) - - tools = asyncio.run(_server(repository).list_tools()) - - names = [tool.name for tool in tools] - assert "operation_write_marker" in names - assert "operation_purge" in names - assert not any("approve" in name for name in names) - assert { - "list_operations", - "show_operation", - "show_fleet", - "observe_fleet", - "audit_fleet", - "list_decisions", - "why", - } <= set(names) - - -def test_the_declared_risk_reaches_the_client_as_annotations( - tmp_path: Path, -) -> None: - """A client gates on risk without Cloudfall's help.""" - repository = _repository(tmp_path, **{"write-marker": MARKER, "purge": PURGE}) - - tools = {tool.name: tool for tool in asyncio.run(_server(repository).list_tools())} - - mutating = tools["operation_write_marker"].annotations - destructive = tools["operation_purge"].annotations - assert mutating is not None - assert destructive is not None - assert mutating.read_only_hint is True - assert mutating.destructive_hint is False - assert destructive.read_only_hint is True - assert destructive.destructive_hint is True - - -def test_an_operation_tool_describes_its_risk_and_its_approval( - tmp_path: Path, -) -> None: - repository = _repository(tmp_path) - operation = _toolset(repository).catalog().operations[0] - - description = operation_tool_description(operation) - - assert operation_tool_name(operation) == "operation_write_marker" - assert "Risk: mutating. Targets: host." in description - assert "check mode only" in description - assert "cloudfall operations approve" in description - assert "Inputs: version: string." in description - - -def test_calling_an_operation_tool_changes_nothing(tmp_path: Path) -> None: - """The agent's reach ends at a recorded proposal.""" - repository = _repository(tmp_path) - - payload = _call( - _server(repository), - "operation_write_marker", - {"target": "web-1", "inputs": {"version": "2.0.0"}}, - ) - - assert payload["decision"]["spec"]["status"] == "proposed" - assert payload["decision"]["spec"]["inputs"] == {"version": "2.0.0"} - assert payload["approval"]["required"] is True - assert payload["approval"]["command"].startswith("cloudfall operations approve") - assert not (repository / "marker.txt").exists() - - -def test_a_proposal_through_the_surface_lands_in_the_record( - tmp_path: Path, -) -> None: - repository = _repository(tmp_path) - server = _server(repository) - - _call( - server, - "operation_write_marker", - {"target": "web-1", "inputs": {"version": "2.0.0"}}, - ) - listed = _call(server, "list_decisions", {}) - - assert [entry["spec"]["operation"]["id"] for entry in listed["decisions"]] == [ - "write-marker" - ] - - -def test_the_surface_answers_why_from_the_record(tmp_path: Path) -> None: - """The record answers for a host; a host it never names gets nothing.""" - repository = _repository(tmp_path) - server = _server(repository) - _call( - server, - "operation_write_marker", - {"target": "web-1", "inputs": {"version": "2.0.0"}}, - ) - - asked = _call(server, "why", {"host": "web-1"}) - nobody = _call(server, "why", {"host": "web-9"}) - refused = _call(server, "why", {"since": "last tuesday"}) - - assert asked["count"] == 1 - assert asked["answers"][0]["decision"]["spec"]["operation"]["id"] == ( - "write-marker" - ) - assert asked["answers"][0]["story"][-1] == ( - "It is still proposed: nobody has approved or rejected it." - ) - assert nobody["count"] == 0 - assert refused["error"]["code"] == "why_instant_invalid" - - -def test_the_surface_reads_the_fleet_from_the_team_inventory( - tmp_path: Path, -) -> None: - repository = _repository(tmp_path) - - payload = _call(_server(repository), "show_fleet", {}) - - assert payload["ansible"]["managed"] == ["web-1"] - assert [server["id"] for server in payload["inventory"]["servers"]] == ["web-1"] - - -def test_an_undeclared_operation_is_not_callable(tmp_path: Path) -> None: - repository = _repository(tmp_path) - - tools = asyncio.run(_server(repository).list_tools()) - - assert "operation_purge" not in {tool.name for tool in tools} - - -def test_a_repository_that_names_no_inventory_says_so(tmp_path: Path) -> None: - repository = tmp_path / "bare" - repository.mkdir() - config = FleetConfig( - repository=repository, schema_directory=SCHEMAS, engine_directory=ENGINE - ) - - with pytest.raises(FleetToolError) as error: - config.source() - - assert error.value.code == "fleet_inventory_undeclared" - - -def test_the_inventory_comes_from_their_own_configuration(tmp_path: Path) -> None: - repository = _repository(tmp_path) - config = FleetConfig( - repository=repository, schema_directory=SCHEMAS, engine_directory=ENGINE - ) - - assert config.source().value == repository / "inventories" / "production" diff --git a/sdk/tests/test_lifecycle.py b/sdk/tests/test_lifecycle.py index b2c05af..23c38ae 100644 --- a/sdk/tests/test_lifecycle.py +++ b/sdk/tests/test_lifecycle.py @@ -23,6 +23,7 @@ plan_health, verify_artifact, ) +from cloudfall.validation import SchemaCatalog ROOT = Path(__file__).parents[2] SCHEMAS = ROOT / "config" / "schemas" / "v1" @@ -381,3 +382,25 @@ def test_release_artifact_build_reads_the_engine_envelope(tmp_path: Path) -> Non assert built["status"] == "ok" assert isinstance(built["release"], str) assert Path(str(built["archive"])).is_file() + + +def test_backup_receipt_schema_accepts_the_playbook_shape() -> None: + receipt = { + "apiVersion": "cloudfall/v1", + "kind": "BackupReceipt", + "metadata": { + "id": "postgresql-main-restore-check", + "description": "Backup operation receipt written by backup.yml", + }, + "spec": { + "service": "postgresql-main", + "server": "h1", + "action": "restore-check", + "executedAt": "2026-09-11T10:00:00Z", + "summary": "restore check passed for crm: latest.dump", + }, + } + + SchemaCatalog(SCHEMAS).validate_named( + "backup-receipt.schema.json", receipt + ) diff --git a/sdk/tests/test_manifest.py b/sdk/tests/test_manifest.py deleted file mode 100644 index 6a6e685..0000000 --- a/sdk/tests/test_manifest.py +++ /dev/null @@ -1,159 +0,0 @@ -"""``cloudfall --schema`` declares what each command writes, and it holds.""" - -from __future__ import annotations - -import json -import shutil -from pathlib import Path - -import pytest -from cloudfall.cli import main -from cloudfall.commands import CLI_COMMANDS, CommandContract, Stability -from jsonschema import Draft202012Validator - -ROOT = Path(__file__).parents[2] -EXAMPLES = ROOT / "config" / "examples" -RELEASE = "20260923T000000Z-abcdef0" - - -def _schema(capsys: pytest.CaptureFixture[str], flag: str = "--schema") -> str: - with pytest.raises(SystemExit) as exit_info: - main([flag]) - assert exit_info.value.code == 0 - return capsys.readouterr().out - - -def test_schema_is_json_stable_and_aliased( - capsys: pytest.CaptureFixture[str], -) -> None: - first = _schema(capsys) - again = _schema(capsys) - alias = _schema(capsys, "--print-schema") - - manifest = json.loads(first) - # Only meta, the invocation's own id and duration, may differ. - documents = [json.loads(output) for output in (first, again, alias)] - assert documents[0]["data"] == documents[1]["data"] == documents[2]["data"] - assert manifest["status"] == "ok" - data = manifest["data"] - assert data["etag"].startswith("sha256:") - assert set(data["commands"]) == {command.name for command in CLI_COMMANDS} - global_flags = set(data["global_flags"]) - assert {"schema", "schema-version", "version", "output"} <= global_flags - assert "/Users/" not in first - assert str(ROOT) not in first - - -def test_flags_are_read_from_the_parser(capsys: pytest.CaptureFixture[str]) -> None: - deploy = json.loads(_schema(capsys))["data"]["commands"]["deploy"] - - assert deploy["effect"] == "servers" - assert deploy["flags"]["release"] == { - "type": "string", - "required": True, - "description": "release id produced by cloudfall-engine artifact build", - } - assert deploy["flags"]["component"]["positional"] is True - assert deploy["flags"]["yes"]["type"] == "boolean" - - -@pytest.mark.parametrize("contract", CLI_COMMANDS, ids=lambda contract: contract.name) -def test_every_command_declares_a_valid_output_schema( - contract: CommandContract, -) -> None: - schema = contract.output_schema() - - Draft202012Validator.check_schema(schema) - shapes = schema.get("oneOf", [schema]) - assert isinstance(shapes, list) - assert contract.output - for shape in shapes: - properties = shape["properties"] - assert {"meta", "warnings"} <= set(properties) - assert {entry["x-stability"] for entry in properties.values()} <= { - tier.value for tier in Stability - } - - -def _documents(argv: list[str], capsys: pytest.CaptureFixture[str]) -> list[object]: - code = main(argv) - documents = [json.loads(line) for line in capsys.readouterr().out.splitlines()] - # `ok` is the exit code as a boolean, on every document the command wrote. - assert [document["ok"] for document in documents] == [code == 0] * len(documents) - for document in documents: - assert isinstance(document, dict) - if "status" in document: - # The argparse envelope: an error on stdout names its exit code. - top = {"ok", "status", "data", "error", "meta", "warnings"} - assert set(document) == top - if document["error"] is not None: - assert document["error"]["exit_code"] == code - else: - # treaty's envelope, for a command that moved; `_conforms` checks - # which envelope the contract declares. - assert set(document) == {"ok", "data", "error", "meta", "warnings"} - assert document["meta"]["exit_code"] == code - return documents - - -def _conforms(name: str, documents: list[object]) -> None: - contract = next(command for command in CLI_COMMANDS if command.name == name) - validator = Draft202012Validator(contract.output_schema()) - assert documents - for document in documents: - assert [error.message for error in validator.iter_errors(document)] == [] - - -@pytest.mark.parametrize( - ("name", "argv"), - [ - ("config validate", ["config", "validate"]), - ("inventory show", ["inventory", "show"]), - ("operator list", ["operator", "list"]), - ("restart", ["restart", "crm-backend"]), - ("rollback", ["rollback", "crm-backend", "--release", RELEASE]), - ( - "data migrate", - [ - *("data", "migrate", "postgresql-main", "--database", "crm"), - *("--source-url-file", "source.url"), - ], - ), - ("migrate", ["migrate", "--build", "crm-backend=main"]), - ], -) -def test_project_output_matches_its_declared_schema( - name: str, - argv: list[str], - tmp_path: Path, - capsys: pytest.CaptureFixture[str], -) -> None: - project = tmp_path / "project" - shutil.copytree(EXAMPLES, project) - (project / "source.url").write_text("postgresql://example.test/crm\n") - - _conforms(name, _documents([*argv, "--project", str(project)], capsys)) - - -def test_projectless_output_matches_its_declared_schema( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - project = tmp_path / "fresh" - - _conforms("changelog", _documents(["changelog"], capsys)) - _conforms("why", _documents(["why", "--repository", str(tmp_path)], capsys)) - _conforms( - "operations decisions", - _documents(["operations", "decisions", "--repository", str(tmp_path)], capsys), - ) - _conforms("init", _documents(["init", str(project)], capsys)) - _conforms( - "add server", - _documents( - [ - *("add", "server", "h1", "--address", "192.0.2.1"), - *("--project", str(project)), - ], - capsys, - ), - ) diff --git a/sdk/tests/test_operator.py b/sdk/tests/test_operator.py index 8eb0afc..f7b560e 100644 --- a/sdk/tests/test_operator.py +++ b/sdk/tests/test_operator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import itertools import json from dataclasses import dataclass, field from pathlib import Path @@ -9,15 +10,17 @@ import pytest from cloudfall.audit import AuditCheck, AuditReport, AuditStatus, ServerAudit from cloudfall.cli import main -from cloudfall.commands import CLI_COMMANDS from cloudfall.domain import ResourceId from cloudfall.inventory import PlatformInventory from cloudfall.lifecycle import LifecycleError from cloudfall.operator import ( ApproveOptions, + AutonomyReport, + DriftCheck, GatewayAlertFeed, OperatorAlert, OperatorError, + PassKind, ProposalStatus, ProposalStore, RunReport, @@ -30,9 +33,9 @@ fingerprint_of, parse_prometheus_alerts, run_once, + watch, ) from cloudfall.validation import SchemaCatalog, validate_config -from jsonschema import Draft202012Validator _FAST_APPROVE = ApproveOptions( verify_timeout_seconds=2.0, @@ -489,9 +492,6 @@ def test_cli_operator_show_wraps_the_proposal_in_the_result_envelope( "status": "ok", "proposal": store.load(ResourceId.from_boundary(proposal_id)).as_document(), } - contract = next(c for c in CLI_COMMANDS if c.name == "operator show") - validator = Draft202012Validator(contract.output_schema()) - assert [error.message for error in validator.iter_errors(document)] == [] def test_cli_operator_errors_use_the_shared_error_envelope( @@ -506,6 +506,111 @@ def test_cli_operator_errors_use_the_shared_error_envelope( assert document["error"]["context"]["code"] == "operator_proposal_missing" +def test_watch_without_an_interval_runs_one_round(tmp_path: Path) -> None: + feed = FakeFeed(payloads=[_alerts_payload()]) + slept: list[float] = [] + + passes = list( + watch( + feed, + _inventory(), + _store(tmp_path), + drift=None, + autonomy=None, + interval_seconds=None, + sleep=slept.append, + ) + ) + + assert [report.kind for report in passes] == [PassKind.ALERTS] + assert passes[0].changed + assert passes[0].as_dict()["pass"] == PassKind.ALERTS.value + assert (feed.fetches, slept) == (1, []) + + +def test_watch_runs_drift_when_due_and_autonomy_every_round(tmp_path: Path) -> None: + store = _store(tmp_path) + audits: list[int] = [] + + def auditor() -> AuditReport: + audits.append(len(audits)) + return _audit_report(("os.distribution", AuditStatus.COMPLIANT)) + + ticks = itertools.count() + passes = watch( + FakeFeed(payloads=[_alerts_payload()]), + _inventory(), + store, + drift=DriftCheck(auditor, interval_seconds=2), + autonomy=lambda: AutonomyReport(executed=(), withheld=()), + interval_seconds=1, + clock=lambda: float(next(ticks)), + sleep=lambda _: None, + ) + + kinds = [report.kind for report in itertools.islice(passes, 8)] + + assert kinds[:3] == [PassKind.ALERTS, PassKind.DRIFT, PassKind.AUTONOMY] + assert kinds.count(PassKind.AUTONOMY) == kinds.count(PassKind.ALERTS) + assert 1 < len(audits) < kinds.count(PassKind.ALERTS) + + +def test_cli_operator_run_refuses_unreadable_gateway_material( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + missing = tmp_path / "missing.pem" + + exit_code = main( + [ + "operator", + "run", + "--project", + str(EXAMPLES), + "--schemas", + str(SCHEMAS), + "--proposals", + str(tmp_path / "proposals"), + "--gateway-url", + "https://127.0.0.1:9/api/v1/alerts", + "--gateway-ca", + str(missing), + "--gateway-cert", + str(missing), + "--gateway-key", + str(missing), + ] + ) + + lines = [json.loads(line) for line in capsys.readouterr().out.splitlines()] + assert exit_code == 4 + assert lines[-1]["error"]["code"] == "PRECONDITION" + assert ( + lines[-1]["error"]["context"]["code"] == "operator_gateway_material_invalid" + ) + + +@pytest.mark.parametrize("flag", ["--interval", "--drift-interval"]) +def test_cli_operator_run_refuses_a_non_positive_interval( + flag: str, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + exit_code = main( + [ + "operator", + "run", + "--project", + str(EXAMPLES), + *("--gateway-ca", "ca.pem", "--gateway-cert", "c.pem"), + *("--gateway-key", "k.pem", flag, "0"), + "--proposals", + str(tmp_path / "proposals"), + ] + ) + + document = json.loads(capsys.readouterr().out.splitlines()[-1]) + assert exit_code == 2 + assert flag.removeprefix("--") in json.dumps(document["error"]) + + def _policied_inventory() -> PlatformInventory: inventory = _inventory() assert len(inventory.operator_policies) == 1 diff --git a/sdk/tests/test_output.py b/sdk/tests/test_output.py deleted file mode 100644 index 3f14972..0000000 --- a/sdk/tests/test_output.py +++ /dev/null @@ -1,478 +0,0 @@ -"""Every JSON document names its output contract and the running tool.""" - -from __future__ import annotations - -import json -import subprocess -import sys -import uuid -from datetime import date -from importlib.metadata import version -from pathlib import Path -from unittest.mock import ANY - -import pytest -from cloudfall.cli import main -from cloudfall.commands import EXIT_CODES -from cloudfall.output import ( - RESPONSE_META, - SCHEMA_CHANGELOG, - DeprecatedField, - FieldPath, - OutputOptions, - SchemaChange, - SchemaVersion, - begin_invocation, - configure_output, - envelope, - exit_code, - write_error, - write_result, -) - -ROOT = Path(__file__).parents[2] -EXAMPLES = ROOT / "config" / "examples" -VERSIONS = {"schema_version": "1.0", "tool_version": version("cloudfall")} -META = {**VERSIONS, "request_id": ANY, "duration_ms": ANY} - - -def _run(argv: list[str], capsys: pytest.CaptureFixture[str]) -> tuple[int, str, str]: - try: - code = main(argv) - except SystemExit as exit_info: - code = int(str(exit_info.code)) - captured = capsys.readouterr() - return code, captured.out, captured.err - - -def test_version_flag_prints_the_package_and_schema_versions( - capsys: pytest.CaptureFixture[str], -) -> None: - code, out, err = _run(["--version"], capsys) - - assert (code, err) == (0, "") - assert json.loads(out) == { - "ok": True, - "status": "ok", - "error": None, - "data": { - "version": version("cloudfall"), - "schemaVersions": {"current": "1.0", "minimum": "1.0"}, - }, - "meta": META, - "warnings": [], - } - - -def test_results_and_errors_carry_the_same_meta_and_warnings( - capsys: pytest.CaptureFixture[str], -) -> None: - ok, result, _ = _run(["changelog"], capsys) - failed, _, error = _run( - ["dashboard", "serve", "--project", str(ROOT / "missing"), "--observed", "x"], - capsys, - ) - usage_code, _, usage = _run(["dashboard", "serve"], capsys) - - assert (ok, failed, usage_code) == (0, 2, 2) - for document in (json.loads(result), json.loads(error), json.loads(usage)): - assert document["meta"] == META - assert { - key: document["meta"][key] for key in VERSIONS - } == RESPONSE_META.as_dict() - assert document["warnings"] == [] - - -@pytest.mark.parametrize("owned", ["ok", "data", "meta", "warnings"]) -def test_a_payload_cannot_set_what_the_writer_owns(owned: str) -> None: - with pytest.raises(ValueError, match="writer owns it"): - write_result({"status": "ok", owned: {}}) - - -def test_a_deprecated_field_warns_while_it_is_still_written() -> None: - deprecated = DeprecatedField( - path=FieldPath("data.inventory.servers"), - replacement=FieldPath("data.inventory.hosts"), - removed_in=SchemaVersion(2, 0), - ) - begin_invocation() - - with_field = envelope( - {"status": "ok", "inventory": {"servers": []}}, - exit_code=0, - deprecated_fields=(deprecated,), - ) - without_field = envelope( - {"status": "ok", "inventory": {"hosts": []}}, - exit_code=0, - deprecated_fields=(deprecated,), - ) - - assert with_field["warnings"] == [ - { - "code": "FIELD_DEPRECATED", - "message": ( - "field 'data.inventory.servers' is deprecated and is removed in " - "schema 2.0; use 'data.inventory.hosts'" - ), - "removed_in": "2.0", - "context": { - "field": "data.inventory.servers", - "replacement": "data.inventory.hosts", - }, - } - ] - assert without_field["warnings"] == [] - - -def test_changelog_lists_contract_changes_newest_first( - capsys: pytest.CaptureFixture[str], -) -> None: - code, out, _ = _run(["changelog"], capsys) - since_code, since_out, _ = _run(["changelog", "--since", "1.0"], capsys) - - assert (code, since_code) == (0, 0) - entries = json.loads(out)["data"]["entries"] - assert entries[0] == { - "version": "1.0", - "date": "2026-09-23", - "breaking": False, - "added": [ - "ok", - "status", - "data", - "error", - "error.exit_code", - "meta.schema_version", - "meta.tool_version", - "meta.request_id", - "meta.duration_ms", - "warnings", - ], - "removed": [], - "changed": [], - } - assert [entry["version"] for entry in entries] == [ - str(change.version) for change in reversed(SCHEMA_CHANGELOG) - ] - assert json.loads(since_out)["data"]["entries"] == [] - - -def test_a_removal_or_change_is_breaking() -> None: - def change(**fields: tuple[FieldPath, ...]) -> SchemaChange: - return SchemaChange( - version=SchemaVersion(2, 0), - released=date(2027, 1, 1), - added=fields.get("added", ()), - removed=fields.get("removed", ()), - changed=fields.get("changed", ()), - ) - - assert not change(added=(FieldPath("a"),)).breaking - assert change(removed=(FieldPath("a"),)).breaking - assert change(changed=(FieldPath("a"),)).breaking - - -def test_changelog_since_rejects_a_bare_major( - capsys: pytest.CaptureFixture[str], -) -> None: - code, _, err = _run(["changelog", "--since", "1"], capsys) - - assert code == 2 - assert "expected MAJOR.MINOR" in json.loads(err)["error"]["message"] - - -def test_schema_version_pin_accepts_the_current_major( - capsys: pytest.CaptureFixture[str], -) -> None: - code, out, _ = _run(["--schema-version", "1", "changelog"], capsys) - - assert code == 0 - assert json.loads(out)["meta"]["schema_version"] == "1.0" - - -@pytest.mark.parametrize( - ("pin", "reason"), - [("2", "not supported"), ("0", "expected a MAJOR number"), ("1.0", "MAJOR")], -) -def test_schema_version_pin_fails_before_any_command_runs( - pin: str, reason: str, capsys: pytest.CaptureFixture[str] -) -> None: - code, out, err = _run(["--schema-version", pin, "changelog"], capsys) - - assert (code, out) == (2, "") - error = json.loads(err)["error"] - assert error["code"] == "invalid_argument" - assert reason in error["message"] - - -@pytest.mark.parametrize("value", ["1", "1.0.0", "v1.0", "01.0", "1.x", ""]) -def test_schema_version_is_major_dot_minor(value: str) -> None: - with pytest.raises(ValueError, match="invalid schema version"): - SchemaVersion.parse(value) - - -def test_schema_versions_order_numerically() -> None: - assert SchemaVersion.parse("1.10") > SchemaVersion.parse("1.9") - - -def test_the_payload_moves_under_data_and_status_and_error_stay_on_top() -> None: - begin_invocation() - result = envelope({"status": "plan", "steps": []}, exit_code=0) - failure = envelope( - {"status": "error", "error": {"code": "x", "message": "y"}}, exit_code=2 - ) - failed_step = envelope( - {"status": "error", "error": {"code": "x", "message": "y"}, "step": "a"}, - exit_code=1, - ) - - assert result == { - "ok": True, - "status": "plan", - "error": None, - "data": {"steps": []}, - "meta": META, - "warnings": [], - } - assert failure == { - "ok": False, - "status": "error", - "data": None, - "error": {"code": "x", "message": "y", "exit_code": 2}, - "meta": META, - "warnings": [], - } - assert failed_step["data"] == {"step": "a"} - assert failed_step["error"] == {"code": "x", "message": "y", "exit_code": 1} - - -def test_a_document_with_an_error_cannot_be_ok() -> None: - with pytest.raises(ValueError, match="cannot be ok"): - envelope({"status": "error", "error": {"code": "x"}}, exit_code=0) - - -def test_a_payload_must_name_its_status() -> None: - with pytest.raises(TypeError, match="names its status"): - envelope({"inventory": {}}, exit_code=0) - - -def test_an_error_document_carries_only_its_error() -> None: - begin_invocation() - with pytest.raises(ValueError, match="status and error only"): - write_error({"status": "error", "error": {"code": "x"}, "extra": 1}, 2) - - -@pytest.mark.parametrize( - "argv", - [ - ["--output", "json", "changelog"], - ["changelog", "--output", "json"], - ["changelog", "--output=json"], - ], -) -def test_output_json_is_accepted_before_and_after_the_command( - argv: list[str], capsys: pytest.CaptureFixture[str] -) -> None: - code, out, err = _run(argv, capsys) - - assert (code, err) == (0, "") - assert json.loads(out)["ok"] is True - - -def test_output_accepts_only_json(capsys: pytest.CaptureFixture[str]) -> None: - code, out, err = _run(["changelog", "--output", "text"], capsys) - - assert (code, out) == (2, "") - assert json.loads(err)["error"]["code"] == "invalid_argument" - - -def test_one_invocation_has_one_request_id_and_a_growing_duration() -> None: - invocation = begin_invocation() - first = envelope({"status": "ok"}, exit_code=0)["meta"] - second = envelope({"status": "ok"}, exit_code=0)["meta"] - - assert isinstance(first, dict) - assert isinstance(second, dict) - assert first["request_id"] == second["request_id"] == str(invocation.request_id) - assert uuid.UUID(str(first["request_id"])).version == 4 - assert isinstance(first["duration_ms"], int) - assert 0 <= first["duration_ms"] <= second["duration_ms"] - - -def test_every_invocation_gets_its_own_request_id( - capsys: pytest.CaptureFixture[str], -) -> None: - _, first, _ = _run(["changelog"], capsys) - _, second, _ = _run(["changelog"], capsys) - - assert json.loads(first)["meta"]["request_id"] != json.loads(second)["meta"][ - "request_id" - ] - - -def test_writing_outside_an_invocation_is_a_bug() -> None: - script = ( - "from cloudfall.output import write_result\n" - "write_result({'status': 'ok'})\n" - ) - completed = subprocess.run( # noqa: S603 - fixed interpreter and script. - [sys.executable, "-c", script], - capture_output=True, - text=True, - stdin=subprocess.DEVNULL, - check=False, - ) - - assert completed.returncode != 0 - assert completed.stdout == "" - assert "no invocation has begun" in completed.stderr - - -def test_help_goes_to_stderr_when_stdout_is_not_a_terminal( - capsys: pytest.CaptureFixture[str], -) -> None: - code, out, err = _run(["dashboard", "serve", "--help"], capsys) - - assert (code, out) == (0, "") - assert err.startswith("usage: cloudfall dashboard serve") - - -def test_quiet_silences_help_off_a_terminal( - capsys: pytest.CaptureFixture[str], -) -> None: - assert _run(["--quiet", "--help"], capsys) == (0, "", "") - - -def test_help_stays_on_stdout_for_a_person_at_a_terminal() -> None: - import os # noqa: PLC0415 - test-local. - import pty # noqa: PLC0415 - test-local. - - pid, descriptor = pty.fork() - if pid == 0: - entry = "from cloudfall.cli import run; run()" - # Python 3.14's argparse colors help on a terminal; NO_COLOR keeps - # the text comparable. - os.execve( # noqa: S606 - fixed interpreter and entry point. - sys.executable, - [sys.executable, "-c", entry, "--help"], - {**os.environ, "NO_COLOR": "1"}, - ) - printed = b"" - while True: - try: - chunk = os.read(descriptor, 65536) - except OSError: - break - if not chunk: - break - printed += chunk - _, status = os.waitpid(pid, 0) - - assert os.waitstatus_to_exitcode(status) == 0 - assert printed.decode().startswith("usage: cloudfall") - - -@pytest.mark.parametrize( - "argv", - [ - [ - *("--quiet", "dashboard", "serve"), - *("--project", str(ROOT / "missing"), "--observed", "x"), - ], - [ - *("dashboard", "serve", "--project", str(ROOT / "missing")), - *("--observed", "x", "--quiet"), - ], - ["--quiet", "dashboard", "shoe"], - ], -) -def test_quiet_leaves_only_the_exit_code( - argv: list[str], capsys: pytest.CaptureFixture[str] -) -> None: - assert _run(argv, capsys) == (2, "", "") - - -def test_quiet_keeps_the_result_on_stdout(capsys: pytest.CaptureFixture[str]) -> None: - code, out, err = _run(["changelog", "--quiet"], capsys) - - assert (code, err) == (0, "") - assert json.loads(out)["ok"] is True - - -def test_warnings_as_errors_fails_a_result_that_carries_a_warning() -> None: - deprecated = DeprecatedField( - path=FieldPath("data.inventory"), - replacement=FieldPath("data.fleet"), - removed_in=SchemaVersion(2, 0), - ) - begin_invocation() - configure_output(OutputOptions(warnings_as_errors=True)) - - document = envelope( - {"status": "ok", "inventory": {}}, exit_code=0, deprecated_fields=(deprecated,) - ) - - assert (document["ok"], document["status"]) == (False, "ok") - assert document["data"] == {"inventory": {}} - assert document["error"] == { - "code": "warnings_as_errors", - "message": "--warnings-as-errors: 1 warning(s) (FIELD_DEPRECATED)", - "exit_code": 1, - } - assert (exit_code(0), exit_code(2)) == (1, 2) - - -def test_warnings_as_errors_leaves_a_clean_result_alone( - capsys: pytest.CaptureFixture[str], -) -> None: - code, out, _ = _run(["changelog", "--warnings-as-errors"], capsys) - - assert code == 0 - assert (json.loads(out)["ok"], json.loads(out)["error"]) == (True, None) - - -@pytest.mark.parametrize("argv", [["--help"], ["dashboard", "serve", "--help"]]) -def test_help_ends_with_every_exit_code( - argv: list[str], capsys: pytest.CaptureFixture[str] -) -> None: - code, _, err = _run(argv, capsys) - - assert code == 0 - table = err[err.index("\nexit codes:\n") :] - for declared in EXIT_CODES: - first_words = " ".join(declared.meaning.replace("`", "").split()[:4]) - assert f" {declared.code} {first_words}" in table - - -def test_the_error_carries_the_code_the_process_exits_with( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - cases = [ - (_run(["dashboard", "serve"], capsys), 2), - ( - _run( - ["dashboard", "serve", "--project", str(tmp_path), "--observed", "x"], - capsys, - ), - 2, - ), - ] - - for (code, _, err), expected in cases: - assert code == expected - assert json.loads(err)["error"]["exit_code"] == code - - -def test_an_error_cannot_exit_zero() -> None: - begin_invocation() - with pytest.raises(ValueError, match="cannot exit 0"): - write_error({"status": "error", "error": {"code": "x", "message": "y"}}, 0) - - -def test_the_writer_owns_exit_code() -> None: - begin_invocation() - with pytest.raises(ValueError, match="exit_code is added"): - envelope( - {"status": "error", "error": {"code": "x", "exit_code": 9}}, exit_code=2 - ) diff --git a/sdk/tests/test_project.py b/sdk/tests/test_project.py index 6cf1c8b..2152b58 100644 --- a/sdk/tests/test_project.py +++ b/sdk/tests/test_project.py @@ -11,7 +11,7 @@ import pytest from cloudfall.cli import main -from cloudfall.commands import COMMANDS, EXIT_CODES, CommandEffect +from cloudfall.commands import COMMANDS, CommandEffect from cloudfall.domain import ResourceKind from cloudfall.project import ( GitSetup, @@ -435,10 +435,10 @@ def test_init_writes_the_agent_contract_and_the_claude_pointer( assert "Ask the human what this project manages" in _section( contract, "What this project manages" ) - assert '`{"ok": false, "status": "error",' in _section(contract, "Output contract") - for exit_code in EXIT_CODES: - row = f"| `{exit_code.code}` | {exit_code.meaning} |" - assert row in _section(contract, "Output contract") + output = _section(contract, "Output contract") + assert "`data.status`" in output + assert "`uv run cloudfall --schema`" in output + assert "`uv run cloudfall changelog`" in output assert "`secretRefs`" in _section(contract, "Secrets") assert "`tmp/`" in _section(contract, "Evidence") assert "`.venv/`" in _section(contract, "Git") diff --git a/site/public/index.html b/site/public/index.html index 6e9abc6..73918ec 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -298,7 +298,7 @@

Your servers, run by an agent.
With a record data and a real TTL-lowered DNS flip on an owned domain, resuming to byte-identical responses from the Cloudfall host, with a deliberately bad release rolled back automatically along the way — the entire run driven by an AI agent through - cloudfall-mcp alone. The operator then remediated an induced failure + its MCP server alone. The operator then remediated an induced failure with a single approval, remediated the next identical failure autonomously under declared policy in 2m06s, proved a real backup restorable in a timer-driven drill, and delivered an alert to a second machine across the public network, receipts @@ -349,8 +349,8 @@

Good-enough databases, with proof

Autonomy earned, never assumed

The always-on operator diagnoses from evidence and proposes fixes - you approve through the cloudfall-mcp confirmation - handshake. Autonomy is granted per operation class, licensed by + you approve, from the CLI or through + cloudfall mcp serve. Autonomy is granted per operation class, licensed by declared policy and earned receipt history; DNS cutover, data deletion, and promotion always require your confirmation.

@@ -436,7 +436,7 @@

Implemented and proven live

  • Let's Encrypt TLS and nginx routes from declared domains
  • Render blueprint and live-API importers with guided data migration
  • Resumable migrate with a real DNS cutover on an owned domain
  • -
  • cloudfall-mcp agent server with confirm-gated writes
  • +
  • cloudfall mcp serve agent server with confirm-gated writes
  • sops/age secret references rendered to per-component env files
  • Always-on operator: receipted proposals, policy-bounded autonomy
  • diff --git a/uv.lock b/uv.lock index 4afac3b..060090b 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.0rc18" }, + { name = "treaty", specifier = "==1.0.0rc29" }, ] provides-extras = ["mcp"] @@ -1085,11 +1085,11 @@ wheels = [ [[package]] name = "treaty" -version = "1.0.0rc18" +version = "1.0.0rc29" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/d0/3c17b51f57328feab7b65eeea18f333a876436d52216a17df7f7300285f0/treaty-1.0.0rc18.tar.gz", hash = "sha256:33defd5ede0e9568eaf5a34dc759422483cc508ea9368b90b61cb8120125018c", size = 519893, upload-time = "2026-10-02T09:36:27.466Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/50/e564b3e75431d5df24f170cf8726968a536bceb0ff7468da2308afac92de/treaty-1.0.0rc29.tar.gz", hash = "sha256:11f30f51f051d7da2008a2b15970edcb708a7d9becae8f1a0ecdd9631bb70a2f", size = 562870, upload-time = "2026-10-03T10:03:37.738Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/47/07abe2d4f97af626eeb4628032a2b0927ac2709d557669bcb3fb957ae7e5/treaty-1.0.0rc18-py3-none-any.whl", hash = "sha256:bdd353f80aeafc46fa8d12a34cbdff7007cf95f2087825076f0e724829854073", size = 520884, upload-time = "2026-10-02T09:36:25.96Z" }, + { url = "https://files.pythonhosted.org/packages/02/b0/80f2a44a6151ddbc295f769dbf63542c88548f170f90fabb8c16d338e618/treaty-1.0.0rc29-py3-none-any.whl", hash = "sha256:1c7314db7ee78a7dc16199444c6486ece85a4ed1b24b622aedccf23b152d73c4", size = 561162, upload-time = "2026-10-03T10:03:35.85Z" }, ] [[package]] From 8bd9fd7dc434d071575041d5c58d14940323968d Mon Sep 17 00:00:00 2001 From: Romamo Date: Sat, 3 Oct 2026 17:08:37 +0300 Subject: [PATCH 2/5] fix: mcp serve binds the Render API URL, the secrets env file and the gateway URL A project-mode call could name api_url and api_key_file together and send any controller file as the bearer key to a host of its choosing, write decrypted values to any path through secrets_render's output_file, or point operator_approve's verification at its own gateway. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++-- sdk/src/cloudfall/agent_server.py | 13 +++++++++--- sdk/src/cloudfall/app.py | 3 ++- sdk/src/cloudfall/render_api.py | 6 +++++- sdk/tests/test_agent_server.py | 34 +++++++++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 357abfb..bd34879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,8 +109,11 @@ Notable changes to Cloudfall. The format follows and engine directories, and the evidence directories commands share (snapshots, deployments, artifacts, proposals, secrets) are fixed for the run: they leave the tool schemas, and a call that passes one is - refused, so no call reaches another fleet or engine. A command's own - output paths, such as `receipts`, stay arguments + refused, so no call reaches another fleet or engine. So are the Render + API URL `import_render-api` sends its key file to, the env file + `secrets_render` writes, and the alert gateway `operator_approve` + verifies against. A command's own output paths, such as `receipts`, + stay arguments - On a project, `deploy`, `rollback`, `restart`, `migrate` and `data_migrate` return their plan until called with `yes: true`, as on the CLI; the old `confirm=true` handshake is gone. `build_artifact` and diff --git a/sdk/src/cloudfall/agent_server.py b/sdk/src/cloudfall/agent_server.py index aadccca..48b5206 100644 --- a/sdk/src/cloudfall/agent_server.py +++ b/sdk/src/cloudfall/agent_server.py @@ -39,6 +39,7 @@ is_project, resolve_project_directory, ) +from cloudfall.render_api import RENDER_API_URL from cloudfall.resources import default_engine_directory, default_schema_directory if TYPE_CHECKING: @@ -355,15 +356,21 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]: "env_receipts": str(args.env_receipts), "proposals": str(args.proposals), "secrets_dir": str(args.secrets_dir), + # A chosen URL would send any file the call names as the key to it. + "api_url": RENDER_API_URL, + # Decrypted values are written where the server puts them, in the project. + "output_file": None, + # A chosen gateway would answer the verification of its own approval. + "gateway_url": args.gateway_url, } gateway = { - "gateway_url": args.gateway_url, "gateway_ca": args.gateway_ca, "gateway_cert": args.gateway_cert, "gateway_key": args.gateway_key, } - # Gateway material is bound only when the server was given it; otherwise - # an alert-triggered approval names the flags it needs. + # TLS material is bound only when the server was given it; otherwise an + # alert-triggered approval names the flags it needs, and the gateway it + # reaches is still the declared one (or the server's --gateway-url). bound.update({name: str(value) for name, value in gateway.items() if value}) return bound diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index c58e71d..e4c0739 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -195,6 +195,7 @@ ) from cloudfall.render_api import ( ERROR_API_UNREACHABLE, + RENDER_API_URL, HttpRenderApiClient, import_render_api, read_api_key, @@ -2884,7 +2885,7 @@ class ImportApiArgs(ImportArgs): secret=False, ) api_url: str = Flag( - default="https://api.render.com/v1", + default=RENDER_API_URL, description="Render API base URL (default: https://api.render.com/v1)", ) diff --git a/sdk/src/cloudfall/render_api.py b/sdk/src/cloudfall/render_api.py index 9b5ede4..02bb1ad 100644 --- a/sdk/src/cloudfall/render_api.py +++ b/sdk/src/cloudfall/render_api.py @@ -63,12 +63,16 @@ def postgres_connection_strings(self, postgres_id: str) -> tuple[str, ...]: ... +RENDER_API_URL = "https://api.render.com/v1" +"""The Render API an import reads; an MCP call cannot point it elsewhere.""" + + @dataclass(frozen=True, slots=True) class HttpRenderApiClient: """Bearer-authenticated client for the Render REST API.""" api_key: str - base_url: str = "https://api.render.com/v1" + base_url: str = RENDER_API_URL timeout_seconds: float = 30.0 transport: Callable[[str, str], object] | None = None diff --git a/sdk/tests/test_agent_server.py b/sdk/tests/test_agent_server.py index 75bd426..1f20034 100644 --- a/sdk/tests/test_agent_server.py +++ b/sdk/tests/test_agent_server.py @@ -251,6 +251,40 @@ def test_a_project_serves_its_commands_with_the_project_bound(tmp_path: Path) -> assert converge["error"]["code"] == "CONFIRMATION_REQUIRED" +def test_a_project_call_cannot_send_a_file_elsewhere_or_write_secrets_out( + tmp_path: Path, +) -> None: + # An agent that chose the Render API URL could send any controller file + # it names as the bearer key to a host of its choosing; one that chose + # the env file could write decrypted values outside the project; one + # that chose the gateway URL could answer its own verification. + secret = tmp_path / "outside.txt" + secret.write_text("not-a-render-key\n", encoding="utf-8") + + tools, (sent, rendered, approved) = _talk( + _serve("--project", str(EXAMPLES), cwd=tmp_path), + ( + "import_render-api", + { + "api_key_file": str(secret), + "api_url": "http://127.0.0.1:9", + "application": "crm", + "server": "h1", + }, + ), + ("secrets_render", {"component": "crm", "output_file": str(secret)}), + ("operator_approve", {"proposal": "p", "gateway_url": "http://127.0.0.1:9"}), + ) + + assert "api_url" not in tools["import_render-api"].input_schema["properties"] + assert "output_file" not in tools["secrets_render"].input_schema["properties"] + assert "gateway_url" not in tools["operator_approve"].input_schema["properties"] + for refused in (sent, rendered, approved): + assert refused["ok"] is False + assert refused["error"]["code"] == "ARG_ERROR" + assert secret.read_text(encoding="utf-8") == "not-a-render-key\n" + + def test_no_fleet_to_serve_fails_before_serving(tmp_path: Path) -> None: done = subprocess.run( # noqa: S603 - fixed interpreter and arguments. [sys.executable, *SERVE], From 910ead659668e4085d733751125acad640d00fee Mon Sep 17 00:00:00 2001 From: Romamo Date: Sat, 3 Oct 2026 17:08:42 +0300 Subject: [PATCH 3/5] fix: mcp serve takes --secrets-dir as a plain flag The startup flag was read as a secret, so --secrets-dir was refused and only --secrets-dir-from-env/--secrets-dir-from-file were offered, unlike cloudfall-mcp and the secrets guide. Co-Authored-By: Claude Opus 5.5 --- sdk/src/cloudfall/agent_server.py | 2 ++ sdk/tests/test_agent_server.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/sdk/src/cloudfall/agent_server.py b/sdk/src/cloudfall/agent_server.py index 48b5206..31e5b9d 100644 --- a/sdk/src/cloudfall/agent_server.py +++ b/sdk/src/cloudfall/agent_server.py @@ -117,6 +117,8 @@ class McpServeArgs: secrets_dir: Path = Flag( default=Path("secrets"), description="sops-encrypted secrets directory (default: secrets)", + # A directory of encrypted files; the name is no secret. + secret=False, ) operations: Path = Flag( default=Path(CATALOG_DIRECTORY), diff --git a/sdk/tests/test_agent_server.py b/sdk/tests/test_agent_server.py index 1f20034..bc25db5 100644 --- a/sdk/tests/test_agent_server.py +++ b/sdk/tests/test_agent_server.py @@ -285,6 +285,30 @@ def test_a_project_call_cannot_send_a_file_elsewhere_or_write_secrets_out( assert secret.read_text(encoding="utf-8") == "not-a-render-key\n" +def test_a_project_server_takes_its_secrets_directory(tmp_path: Path) -> None: + done = subprocess.run( # noqa: S603 - fixed interpreter and arguments. + [ + sys.executable, + *SERVE, + "--project", + str(EXAMPLES), + "--secrets-dir", + "private/secrets", + "--list-tools", + ], + cwd=tmp_path, + stdin=subprocess.PIPE, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + assert done.returncode == 0, done.stderr + tools = {tool["name"] for tool in json.loads(done.stdout)["tools"]} + assert "secrets_render" in tools + + def test_no_fleet_to_serve_fails_before_serving(tmp_path: Path) -> None: done = subprocess.run( # noqa: S603 - fixed interpreter and arguments. [sys.executable, *SERVE], From e2db6130da609a996b4c7ba214ce2640ec957d27 Mon Sep 17 00:00:00 2001 From: Romamo Date: Sat, 3 Oct 2026 17:08:53 +0300 Subject: [PATCH 4/5] docs: operator guide drops the MCP confirm handshake mcp serve no longer has Co-Authored-By: Claude Opus 5.5 --- docs/operator-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/operator-guide.md b/docs/operator-guide.md index a8c0582..fb8b0cc 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -70,7 +70,7 @@ operator do that" without anyone writing a note: - **What it saw**: the alert labels or the drifted checks that triggered it - **What it proposed**: the diagnosis and the exact engine operation - **Who approved**: a human through `operator approve`, an agent through - the MCP confirm handshake, or a declared `OperatorPolicy` + the `operator_approve` tool, or a declared `OperatorPolicy` (`approval.mode: autonomous` with the policy id) - **What happened**: `verified` or `failed`, with timestamps, from the verify step below, never from the playbook's exit code @@ -192,6 +192,6 @@ above. The agent is the brain here and the operator is the record. The agent can watch, read, relay the diagnosis and decide to approve; the tools are annotated so the client gates the mutating one with its own permission -mode, and nothing mutates a server without the explicit confirm. Whichever +mode; the server adds no confirm step of its own. Whichever way a proposal is approved, the receipt is the same, so an agent-approved remediation is as explainable afterwards as a human-approved one. From 324f296245cf8a8774a94d5daca980c1bac8c5b5 Mon Sep 17 00:00:00 2001 From: Romamo Date: Sat, 3 Oct 2026 17:17:12 +0300 Subject: [PATCH 5/5] fix: mcp serve fixes every directory a tool writes; one flag name per directory A project-mode tool call could name an absolute receipts, output_dir, env_dir or plan_file path and write outside the project: an import's env files hold the application's real values. Binding needs one field name per directory, so the CLI flags that named the same directory differently are renamed: observe --output-dir is --observed, services inspect --output-dir is --service-observed, and --receipts is --releases, --backups, --data-migrations or --env-receipts by what it holds. A non-default --observed or --env-receipts now reaches observe and secrets render too. dashboard serve reports only a failed bind as dashboard_listen_failed; the eager snapshot read is no longer caught as one. The site and fleet-goals no longer call every MCP write confirm-gated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 28 ++++++++---- Taskfile.yml | 4 +- docs/fleet-goals.md | 2 +- sdk/README.md | 6 +-- sdk/src/cloudfall/agent_server.py | 26 ++++++++++- sdk/src/cloudfall/app.py | 62 +++++++++++++++------------ sdk/src/cloudfall/dashboard_server.py | 12 +++++- sdk/tests/test_agent_server.py | 38 ++++++++++++++++ sdk/tests/test_app.py | 20 +++++++++ sdk/tests/test_lifecycle.py | 2 +- site/public/index.html | 2 +- 11 files changed, 156 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd34879..d31ca4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,14 +106,14 @@ Notable changes to Cloudfall. The format follows configs change from `cloudfall-mcp …` to `cloudfall mcp serve …` - The server serves one fleet, chosen as before: `--project`, or `--repository` for a team's Ansible repository. The fleet, the schema - and engine directories, and the evidence directories commands share - (snapshots, deployments, artifacts, proposals, secrets) are fixed for - the run: they leave the tool schemas, and a call that passes one is - refused, so no call reaches another fleet or engine. So are the Render - API URL `import_render-api` sends its key file to, the env file - `secrets_render` writes, and the alert gateway `operator_approve` - verifies against. A command's own output paths, such as `receipts`, - stay arguments + and engine directories, and every directory a tool writes (snapshots, + receipts, artifacts, proposals, import output, the migrate plan) are + fixed for the run: they leave the tool schemas, and a call that passes + one is refused, so no call reaches another fleet or engine or writes + outside the project. So are the Render API URL `import_render-api` + sends its key file to, the env file `secrets_render` writes, and the + alert gateway `operator_approve` verifies against. Files a tool reads + as input (a blueprint, a key file, an env file) stay arguments - On a project, `deploy`, `rollback`, `restart`, `migrate` and `data_migrate` return their plan until called with `yes: true`, as on the CLI; the old `confirm=true` handshake is gone. `build_artifact` and @@ -128,11 +128,23 @@ Notable changes to Cloudfall. The format follows records a proposal and answers with the approval command in `data.next`; a destructive operation's tool asks for `confirm_destructive` even to preview + - Each directory has one flag name across the commands that read and + write it, so a server can fix it once and a non-default one reaches + them all: `observe --output-dir` is `--observed` (as `audit` reads it), + `services inspect --output-dir` is `--service-observed` (as `services + status` reads it), and `--receipts` is `--releases` on `deploy` and + `migrate`, `--backups` on `backup run|verify`, `--data-migrations` on + `data migrate` and `--env-receipts` on `secrets render` (as `audit` + reads it). `mcp serve` takes `--releases`, `--backups` and + `--data-migrations` too - `init`, `operations approve`, `operator run` and `dashboard serve` are never MCP tools (`mcp=False`), on this server or `treaty-mcp` - A fleet that cannot be read stops the server before a client connects, with the envelope on stderr: 79 `PROJECT_INVALID`, 80 `CONFIG_INVALID`, 85 `INVENTORY_UNREADABLE`. `--list-tools` prints the tool list + - `dashboard serve` reports a port it cannot bind as 4 `PRECONDITION` + with `dashboard_listen_failed`; an unreadable evidence file is no + longer reported as a listen failure - **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 diff --git a/Taskfile.yml b/Taskfile.yml index f4d493c..6fdb112 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -66,7 +66,7 @@ tasks: cmds: - >- uv run cloudfall services inspect --project {{ .CONFIG_DIR }} - --schemas {{ .SCHEMA_DIR }} --output-dir {{ .SERVICE_OBSERVATION_DIR }} + --schemas {{ .SCHEMA_DIR }} --service-observed {{ .SERVICE_OBSERVATION_DIR }} services:status: desc: Show evidence-derived service lifecycle status @@ -152,7 +152,7 @@ tasks: uv run cloudfall deploy --project {{ .CONFIG_DIR }} {{ .COMPONENT }} --release {{ .RELEASE }} --schemas {{ .SCHEMA_DIR }} --artifacts {{ .ARTIFACT_DIR | default "tmp/artifacts" }} - --receipts {{ .ROOT_DIR }}/tmp/releases + --releases {{ .ROOT_DIR }}/tmp/releases {{ if .ENV_FILE }}--env-file {{ .ENV_FILE }}{{ end }} --yes rollback: diff --git a/docs/fleet-goals.md b/docs/fleet-goals.md index 74e7c4f..7e03d4e 100644 --- a/docs/fleet-goals.md +++ b/docs/fleet-goals.md @@ -115,7 +115,7 @@ acceptable if the following hold: | 9 | Data movement verb | `data migrate` + file-storage move + receipts | Partial: databases yes, file storage no | | 10 | Routing follows placement | Domain re-render on convergence | Partial: rendering exists, not wired to placement change | | 11 | Drain / decommission | Evacuation procedure over 9 + 10 | Missing | -| 12 | Declared operations with verify | `Operation` catalog, MCP annotations, verify playbook | Partial: MCP tools are annotated and confirm-gated; deploys, backups and migrations verify; no catalog over arbitrary playbooks, no declared verify step | +| 12 | Declared operations with verify | `Operation` catalog, MCP annotations, verify playbook | Partial: MCP tools are annotated, server changes preview until confirmed, and approval stays a person's command; deploys, backups and migrations verify; no catalog over arbitrary playbooks, no declared verify step | | 13 | Audit entry per decision | Receipts as the record, `cloudfall why` | Partial: operator proposals and every deploy, backup and drill write receipts; no single entry joining snapshot, diff, approver and verify; no query | | 14 | One config, the team's | Fleet reader over their inventory, `cloudfall` key | Missing: the config is Cloudfall's own resources today (design written) | diff --git a/sdk/README.md b/sdk/README.md index c6d96a0..738e17b 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -77,9 +77,9 @@ guessing silently. `cloudfall mcp serve` (requires the `cloudfall[mcp]` extra) serves one fleet over stdio, and its tools are Cloudfall's own commands. The fleet, -the schema and engine directories, and the evidence directories commands -share are fixed when the server starts, so a tool call cannot point at -another project, schema directory or engine. Read-only commands (`config_validate`, `inventory_show`, `audit`, +the schema and engine directories, and every directory a tool writes are +fixed when the server starts, so a tool call cannot point at another +project, schema directory or engine, or write outside the project. Read-only commands (`config_validate`, `inventory_show`, `audit`, `services_status`, `health`, `observe`, `services_inspect`) carry read-only annotations. `deploy`, `rollback`, `restart`, `migrate` and `data_migrate` return their plan until called again with `yes: true`. Four tools have no diff --git a/sdk/src/cloudfall/agent_server.py b/sdk/src/cloudfall/agent_server.py index 31e5b9d..38bd53c 100644 --- a/sdk/src/cloudfall/agent_server.py +++ b/sdk/src/cloudfall/agent_server.py @@ -104,6 +104,18 @@ class McpServeArgs: default=Path("tmp/artifacts"), description="Release artifact directory (default: tmp/artifacts)", ) + releases: Path = Flag( + default=Path("tmp/releases"), + description="Release receipt directory (default: tmp/releases)", + ) + backups: Path = Flag( + default=Path("tmp/backups"), + description="Backup receipt directory (default: tmp/backups)", + ) + data_migrations: Path = Flag( + default=Path("tmp/data-migrations"), + description="Data migration receipt directory (default: tmp/data-migrations)", + ) env_receipts: Path = Flag( default=Path("tmp/env-receipts"), description=( @@ -319,6 +331,11 @@ def served_commands(args: McpServeArgs) -> frozenset[str]: return PROJECT_COMMANDS +_IMPORT_CONFIG = "tmp/import/config" +_IMPORT_ENV = "tmp/import/env" +_MIGRATE_PLAN = "tmp/migrate/plan.json" + + def bound_arguments(args: McpServeArgs) -> dict[str, object]: """Return the arguments every tool call runs with, fixed for the run. @@ -341,7 +358,6 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]: "project": None, "inventory": str(root.inventory.value), "observed": observed, - "output_dir": observed, "env_receipts": str(root.path(args.env_receipts)), "operations": str(args.operations), "decisions": str(args.decisions), @@ -355,7 +371,15 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]: "service_observed": str(args.service_observed), "deployments": str(args.deployments), "artifacts": str(args.artifacts), + "releases": str(args.releases), + "backups": str(args.backups), + "data_migrations": str(args.data_migrations), "env_receipts": str(args.env_receipts), + # What an import writes, and where migrate keeps its plan, stay in the + # project: an import's env files hold the application's real values. + "output_dir": _IMPORT_CONFIG, + "env_dir": _IMPORT_ENV, + "plan_file": _MIGRATE_PLAN, "proposals": str(args.proposals), "secrets_dir": str(args.secrets_dir), # A chosen URL would send any file the call names as the key to it. diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index e4c0739..f2e1f95 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -90,9 +90,10 @@ from cloudfall.commands import CLI_COMMANDS from cloudfall.dashboard import RefreshInterval, build_dashboard from cloudfall.dashboard_server import ( + DashboardHTTPServer, EvidenceSources, ListenEndpoint, - create_dashboard_server, + warm_snapshot_cache, ) from cloudfall.decision import ( CHECK_TIMEOUT_SECONDS, @@ -1477,14 +1478,15 @@ def dashboard_serve( ) refresh = RefreshInterval.from_boundary(args.refresh) try: - server = create_dashboard_server( - sources, + cache = warm_snapshot_cache(sources, refresh) + except ConfigValidationError as error: + raise _config_invalid(error) from error + try: + server = DashboardHTTPServer( args.endpoint(), - refresh, + cache, request_log=lambda line: ctx.log("request", line=line), ) - except ConfigValidationError as error: - raise _config_invalid(error) from error except OSError as error: message = f"cannot listen on {args.host}:{args.port}: {error.strerror}" raise Exit.PRECONDITION( @@ -1511,14 +1513,14 @@ def dashboard_serve( class ServicesInspectArgs(ProjectArgs): """Arguments of ``services inspect``.""" - output_dir: Path = Flag( + service_observed: Path = Flag( default=Path("tmp/observed-services"), description="Service observation directory (default: tmp/observed-services)", ) def __post_init__(self) -> None: """Keep a relative output path inside the project.""" - _inside_project(self.output_dir, "output-dir") + _inside_project(self.service_observed, "service-observed") class InspectedPayload(Payload): @@ -1547,7 +1549,7 @@ def services_inspect( try: paths = inspect_domains( fleet.inventory, - fleet.path(args.output_dir), + fleet.path(args.service_observed), SocketDomainNetworkClient(), observed_at=EvidenceTimestamp.now(), ) @@ -1735,7 +1737,7 @@ class BackupArgs(EngineArgs): """Arguments of ``backup run`` and ``backup verify``.""" service: ResourceId = Arg(description="Declared service to back up") - receipts: Path = Flag( + backups: Path = Flag( default=Path("tmp/backups"), description="Backup receipt directory (default: tmp/backups)", ) @@ -1743,7 +1745,7 @@ class BackupArgs(EngineArgs): def __post_init__(self) -> None: """Keep relative paths inside the project.""" EngineArgs.__post_init__(self) - _inside_project(self.receipts, "receipts") + _inside_project(self.backups, "backups") @dataclass(frozen=True, slots=True) @@ -1788,7 +1790,7 @@ def backup_run(args: BackupArgs, ctx: Ctx, fleet: Fleet) -> BackedUp: """Run the backup playbook for the service.""" try: body = backup_service( - args.context(fleet, ctx), args.service, fleet.path(args.receipts) + args.context(fleet, ctx), args.service, fleet.path(args.backups) ) except LifecycleError as error: raise _lifecycle_failed(error) from error @@ -1815,7 +1817,7 @@ def backup_verify(args: BackupArgs, ctx: Ctx, fleet: Fleet) -> BackedUp: """Restore the newest backup into scratch and keep the proof.""" try: body = verify_backup( - args.context(fleet, ctx), args.service, fleet.path(args.receipts) + args.context(fleet, ctx), args.service, fleet.path(args.backups) ) except LifecycleError as error: raise _lifecycle_failed(error) from error @@ -2358,7 +2360,7 @@ class MigrateArgs(EngineArgs): default=Path("tmp/deployments"), description="Deployment receipt directory (default: tmp/deployments)", ) - receipts: Path = Flag( + releases: Path = Flag( default=Path("tmp/releases"), description="Release receipt directory (default: tmp/releases)", ) @@ -2408,7 +2410,7 @@ def __post_init__(self) -> None: "observed", "service_observed", "deployments", - "receipts", + "releases", "artifacts", "plan_file", ): @@ -2450,7 +2452,7 @@ def config(self, fleet: Fleet, ctx: Ctx) -> AgentConfig: observed_directory=fleet.path(self.observed), service_observed_directory=fleet.path(self.service_observed), deployments_directory=fleet.path(self.deployments), - releases_directory=fleet.path(self.receipts), + releases_directory=fleet.path(self.releases), artifacts_directory=fleet.path(self.artifacts), run=_step_runner(ctx, fleet.directory), ) @@ -2631,7 +2633,7 @@ def why(args: WhyArgs, _ctx: Ctx) -> WhyPayload: class ObserveArgs(FleetArgs): """Arguments of ``observe``.""" - output_dir: Path = Flag( + observed: Path = Flag( default=Path("tmp/observed"), description="Snapshot directory to write (default: tmp/observed)", ) @@ -2645,7 +2647,7 @@ class ObserveArgs(FleetArgs): def __post_init__(self) -> None: """Keep a relative output path inside the project.""" - _inside_project(self.output_dir, "output-dir") + _inside_project(self.observed, "observed") class ObservedPayload(Payload): @@ -2701,7 +2703,7 @@ def observe(args: ObserveArgs, ctx: Ctx, fleet: InventoryFleet) -> ObservedPaylo if fleet.source is not None else (fleet.directory / "tmp/ansible-inventory.json",) ) - output = fleet.path(args.output_dir) + output = fleet.path(args.observed) try: request = ObservationRequest( inventory_sources=sources, @@ -2747,7 +2749,7 @@ class SecretsRenderArgs(EngineArgs): default=None, description="Environment file to write (default: tmp/env/.env)", ) - receipts: Path = Flag( + env_receipts: Path = Flag( default=Path("tmp/env-receipts"), description="Environment receipt directory (default: tmp/env-receipts)", ) @@ -2756,7 +2758,7 @@ def __post_init__(self) -> None: """Keep relative paths inside the project.""" EngineArgs.__post_init__(self) _inside_project(self.secrets_dir, "secrets-dir") - _inside_project(self.receipts, "receipts") + _inside_project(self.env_receipts, "env-receipts") if self.output_file is not None: _inside_project(self.output_file, "output-file") @@ -2821,7 +2823,7 @@ def secrets_render( args.component, provider, output, - receipt_directory=fleet.path(args.receipts), + receipt_directory=fleet.path(args.env_receipts), ) except SecretsError as error: raise Exit.PRECONDITION(error.message, context={"code": error.code}) from error @@ -3047,7 +3049,7 @@ class DeployArgs(ComponentArgs): default=None, description="Optional controller-side environment file for the component", ) - receipts: Path = Flag( + releases: Path = Flag( default=Path("tmp/releases"), description="Release receipt directory (default: tmp/releases)", ) @@ -3056,7 +3058,7 @@ def __post_init__(self) -> None: """Keep relative paths inside the project.""" ComponentArgs.__post_init__(self) _inside_project(self.artifacts, "artifacts") - _inside_project(self.receipts, "receipts") + _inside_project(self.releases, "releases") @dataclass(frozen=True, slots=True, kw_only=True) @@ -3080,7 +3082,7 @@ class DataMigrateArgs(EngineArgs): # The flag names a file; the URL with its password never reaches argv. secret=False, ) - receipts: Path = Flag( + data_migrations: Path = Flag( default=Path("tmp/data-migrations"), description="Migration receipt directory (default: tmp/data-migrations)", ) @@ -3096,7 +3098,7 @@ class DataMigrateArgs(EngineArgs): def __post_init__(self) -> None: """Keep relative paths inside the project.""" EngineArgs.__post_init__(self) - _inside_project(self.receipts, "receipts") + _inside_project(self.data_migrations, "data-migrations") class DeployPayload(Payload): @@ -3185,7 +3187,7 @@ def deploy_command(args: DeployArgs, ctx: Ctx, fleet: Fleet) -> DeployPayload: environment_file=( fleet.path(args.env_file) if args.env_file is not None else None ), - receipt_directory=fleet.path(args.receipts), + receipt_directory=fleet.path(args.releases), ), ) except LifecycleError as error: @@ -3292,7 +3294,11 @@ def data_migrate( ) ) body = migrate_data( - context, args.service, args.database, source, fleet.path(args.receipts) + context, + args.service, + args.database, + source, + fleet.path(args.data_migrations), ) except LifecycleError as error: raise _lifecycle_failed(error) from error diff --git a/sdk/src/cloudfall/dashboard_server.py b/sdk/src/cloudfall/dashboard_server.py index 80f9729..12607a6 100644 --- a/sdk/src/cloudfall/dashboard_server.py +++ b/sdk/src/cloudfall/dashboard_server.py @@ -212,6 +212,16 @@ def create_dashboard_server( request_log: Callable[[str], None] | None = None, ) -> DashboardHTTPServer: """Build one snapshot eagerly (fail fast), then bind the HTTP server.""" + cache = warm_snapshot_cache(sources, refresh, clock) + return DashboardHTTPServer(endpoint, cache, request_log=request_log) + + +def warm_snapshot_cache( + sources: EvidenceSources, + refresh: RefreshInterval, + clock: Callable[[], float] = time.monotonic, +) -> SnapshotCache: + """Return a cache holding its first snapshot, so bad evidence fails first.""" cache = SnapshotCache(sources=sources, refresh=refresh, clock=clock) cache.current() - return DashboardHTTPServer(endpoint, cache, request_log=request_log) + return cache diff --git a/sdk/tests/test_agent_server.py b/sdk/tests/test_agent_server.py index bc25db5..0f631dd 100644 --- a/sdk/tests/test_agent_server.py +++ b/sdk/tests/test_agent_server.py @@ -309,6 +309,44 @@ def test_a_project_server_takes_its_secrets_directory(tmp_path: Path) -> None: assert "secrets_render" in tools +def test_a_project_call_cannot_write_outside_the_project(tmp_path: Path) -> None: + # Snapshots, receipts, import output and the migrate plan are the + # server's; an absolute path would land wherever the agent named. + outside = tmp_path / "outside" + written = { + "observed", + "service_observed", + "releases", + "backups", + "data_migrations", + "env_receipts", + "output_dir", + "env_dir", + "plan_file", + "receipts", + } + + tools, (snapshot, imported) = _talk( + _serve("--project", str(EXAMPLES), cwd=tmp_path), + ("observe", {"observed": str(outside)}), + ( + "import_render", + { + "blueprint": str(EXAMPLES / "render.yaml"), + "application": "crm", + "server": "h1", + "env_dir": str(outside), + }, + ), + ) + + for tool in tools.values(): + assert not written & set(tool.input_schema.get("properties", {})), tool.name + for refused in (snapshot, imported): + assert refused["error"]["code"] == "ARG_ERROR" + assert not outside.exists() + + def test_no_fleet_to_serve_fails_before_serving(tmp_path: Path) -> None: done = subprocess.run( # noqa: S603 - fixed interpreter and arguments. [sys.executable, *SERVE], diff --git a/sdk/tests/test_app.py b/sdk/tests/test_app.py index 568af7c..9374c3a 100644 --- a/sdk/tests/test_app.py +++ b/sdk/tests/test_app.py @@ -6,6 +6,7 @@ import select import shutil import signal +import socket import subprocess import sys import urllib.request @@ -133,3 +134,22 @@ def test_dashboard_serve_streams_where_it_listens_until_stopped( assert listening["meta"]["seq"] == 1 assert listening["data"]["status"] == "ok" assert closing["error"]["code"] == "CANCELLED" + + +def test_dashboard_serve_names_a_port_it_cannot_listen_on( + capsys: pytest.CaptureFixture[str], +) -> None: + with socket.socket() as taken: + taken.bind(("127.0.0.1", 0)) + taken.listen() + port = taken.getsockname()[1] + code = main( + [ + *("dashboard", "serve", "--project", str(EXAMPLES)), + *("--observed", str(COMPLIANT), "--port", str(port)), + ] + ) + + closing = json.loads(capsys.readouterr().out.splitlines()[-1]) + assert code == 4 + assert closing["error"]["context"]["code"] == "dashboard_listen_failed" diff --git a/sdk/tests/test_lifecycle.py b/sdk/tests/test_lifecycle.py index 23c38ae..547e7c7 100644 --- a/sdk/tests/test_lifecycle.py +++ b/sdk/tests/test_lifecycle.py @@ -204,7 +204,7 @@ def test_cli_deploy_reports_a_missing_artifact( str(tmp_path / "artifacts"), "--inventory-file", str(tmp_path / "inventory.json"), - "--receipts", + "--releases", str(tmp_path / "releases"), ] ) diff --git a/site/public/index.html b/site/public/index.html index 73918ec..ca38b39 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -436,7 +436,7 @@

    Implemented and proven live

  • Let's Encrypt TLS and nginx routes from declared domains
  • Render blueprint and live-API importers with guided data migration
  • Resumable migrate with a real DNS cutover on an owned domain
  • -
  • cloudfall mcp serve agent server with confirm-gated writes
  • +
  • cloudfall mcp serve agent server, confined to one fleet
  • sops/age secret references rendered to per-component env files
  • Always-on operator: receipted proposals, policy-bounded autonomy