From 6b96e1bfff6dbf791fe523fc019d33940b1b9143 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:44:58 +0200 Subject: [PATCH] Read one Domain on its proxy server with cloudfall domains observe cloudfall domains observe runs the new read-only domains-observe.yml on the Domain's proxy server alone and reports the vhost file (present, sha256), the certificate's not_after, whether certbot.timer is enabled and active, and whether the vhost still has the configurationSha256 of the last receipt (receipt: null before one). Every server task is a stat or a command with changed_when: false; the reading comes back through a controller file, and the inventory is rendered in a temporary directory, so the project is left as it was. A missing vhost exits 0, an unknown Domain exits PRECONDITION (lifecycle_domain_missing) before the inventory render. domains apply and domains observe join the MCP project tools; domains_apply keeps its yes gate. Spec S-004 is built. Fixes #118 Co-Authored-By: Claude Opus 5.5 --- changelog.d/118-domains-observe.md | 3 + docs/specs/004-domains-apply.md | 22 +- engine/README.md | 8 + engine/ansible/playbooks/domains-observe.yml | 134 ++++ sdk/README.md | 9 + sdk/src/cloudfall/agent_server.py | 11 +- sdk/src/cloudfall/app.py | 76 ++- sdk/src/cloudfall/commands.py | 40 +- sdk/src/cloudfall/lifecycle.py | 206 +++++- sdk/src/cloudfall/service_evidence.py | 85 ++- sdk/tests/test_agent_server.py | 23 + sdk/tests/test_domains_apply.py | 5 +- sdk/tests/test_domains_observe.py | 623 +++++++++++++++++++ 13 files changed, 1202 insertions(+), 43 deletions(-) create mode 100644 changelog.d/118-domains-observe.md create mode 100644 engine/ansible/playbooks/domains-observe.yml create mode 100644 sdk/tests/test_domains_observe.py diff --git a/changelog.d/118-domains-observe.md b/changelog.d/118-domains-observe.md new file mode 100644 index 0000000..2affe58 --- /dev/null +++ b/changelog.d/118-domains-observe.md @@ -0,0 +1,3 @@ +### Added + +- `cloudfall domains observe ` reads one Domain on its proxy server alone, through the new read-only `domains-observe.yml`: the vhost file and its sha256, the certificate's expiry, `certbot.timer`, and whether the vhost still matches the last receipt (`receipt: null` before the first). It changes nothing on the server or in the project, and a missing vhost exits 0. `cloudfall mcp serve` lists `domains_apply` (with its `yes` gate) and `domains_observe` as tools (#118) diff --git a/docs/specs/004-domains-apply.md b/docs/specs/004-domains-apply.md index 891951b..f7c74bc 100644 --- a/docs/specs/004-domains-apply.md +++ b/docs/specs/004-domains-apply.md @@ -1,6 +1,6 @@ # S-004: Domains apply -status: approved +status: built ## Problem @@ -127,3 +127,23 @@ the `converge_domains` MCP tool. - cloudfall-dev/cloudfall#118: S-004-4, S-004-14, S-004-15, S-004-16 ## Verification + +No criterion has run against a live Debian host yet. The SDK side ran through recording runners and through `ansible-playbook` against local connections (every server a `local` host, `cloudfall_nginx_site` swapped for a recording role); the role's certificate and receipt tasks ran on localhost or had their `when` conditions evaluated with Ansible's templating; `domains-observe.yml` ran for real on localhost with stand-ins for `openssl` and `systemctl`. Real Nginx (`nginx -t`, the reload), real certbot issuance by webroot, a real `certbot.timer`, `openssl` reading a real certificate, and SSH to a lent server are unexercised. + +- S-004-1: `test_s004_1_only_the_proxy_server_runs_domains_yml` ran `domains.yml` with a recording role on two local hosts and found only `h2` in the play recap and only the domains tasks; `test_s004_1_domains_yml_runs_no_baseline_firewall_or_service_role` found `cloudfall_nginx_site` the only role the playbook can reach +- S-004-2: `test_s004_2_one_domain_id_renders_only_that_domain` and `test_s004_2_a_host_without_the_domain_renders_nothing` evaluated the render loop with Ansible's templating; `test_s004_2_one_domain_runs_on_its_proxy_and_names_only_it` read the `--limit` and extra vars of the recorded run. A second Domain's files on a real server were not checked +- S-004-3: `test_s004_3_without_yes_the_plan_runs_nothing` (CLI) and `test_s004_3_the_plan_starts_no_engine_step`: no step started and no receipt +- S-004-4: `test_s004_4_apply_refuses_an_unknown_domain_before_any_engine_step`, `test_s004_4_observe_refuses_an_unknown_domain_before_any_engine_step` (CLI, no `tmp/` created) and `test_s004_4_observe_starts_no_step_for_an_unknown_domain` +- S-004-5: `test_s004_5_an_invalid_config_exits_before_any_engine_step`, with and without `--yes` +- S-004-6: `test_s004_6_an_alias_elsewhere_is_refused_naming_it` and `test_s004_6_a_name_that_resolves_to_nothing_is_refused`, with a stand-in resolver +- S-004-7: `test_s004_7_a_proxied_domain_skips_the_dns_check` (CLI) and `test_s004_7_a_proxied_domain_asks_no_resolver` +- S-004-8: `test_s004_8_tls_disabled_refuses_issue_certificate` and `test_s004_8_tls_disabled_is_refused_before_dns` +- S-004-9: `test_s004_9_issue_certificate_passes_the_flag_to_domains_yml` (the extra var and the receipt path) and `test_s004_1_only_the_proxy_server_runs_domains_yml` (the flag reached the recording role); `test_s004_9_a_fresh_domain_issues_and_enables_the_renewal_timer` evaluated the issuance and timer tasks' conditions only. No certificate was issued +- S-004-10: `test_s004_10_a_second_run_skips_issuance` (task conditions) and `test_s004_10_a_second_run_records_the_vhost_it_rendered_before` (the receipt tasks ran twice on localhost) +- S-004-11: `test_s004_11_the_receipt_keeps_the_hash_of_a_hand_written_vhost`, `test_s004_11_a_run_with_no_vhost_there_records_null`, `test_s004_11_a_vhost_linked_into_place_is_hashed_through_the_link` and `test_s004_11_the_replaced_vhost_is_hashed_before_the_first_render`, the receipt tasks on localhost with a stand-in render +- S-004-12: `test_s004_12_a_receipt_with_or_without_the_previous_hash_loads` +- S-004-13: `test_s004_13_a_rejected_vhost_fails_the_run_as_an_engine_step`, with a failing recorded playbook step; a real `nginx -t` rejection was not run +- S-004-14: `test_s004_14_observe_reads_the_proxy_server_and_changes_nothing` and `test_s004_14_a_missing_vhost_is_a_reading_not_an_error` ran the real `domains-observe.yml` on local hosts (only `h2` in the recap; project and server files unchanged by content and modification time); `test_s004_14_the_playbook_holds_only_read_tasks` holds every server task to `stat`, `set_fact`, `assert` or a command with `changed_when: false`; `test_s004_14_the_cli_answers_snake_case_and_leaves_the_project_as_it_was` ran the CLI with a stand-in playbook and found the project unchanged, the inventory rendered outside it +- S-004-15: `test_s004_15_receipt_matches_until_the_vhost_is_edited`, `test_s004_15_no_receipt_reads_as_null` and `test_s004_15_a_removed_vhost_does_not_match_its_receipt` on local hosts, and `test_s004_15_the_cli_answers_the_receipt_comparison` +- S-004-16: `test_s004_16_a_project_serves_domains_apply_and_observe` over the real stdio transport: both tools listed, `domains_apply` without `yes` answered the plan +- S-004-17: `test_s004_17_the_tool_takes_a_domain_and_refuses_an_unknown_one` over the real stdio transport, `test_s004_17_without_a_domain_the_run_is_as_before`, `test_s004_17_an_unknown_domain_is_refused_before_any_step`, `test_s004_17_a_domain_that_is_no_resource_id_is_refused` and `test_s004_17_without_a_domain_id_every_domain_on_the_host_renders` diff --git a/engine/README.md b/engine/README.md index 8b4620a..d93d50b 100644 --- a/engine/README.md +++ b/engine/README.md @@ -256,6 +256,14 @@ applications cannot be spoofed by forged headers. When TLS is required the schema-valid deployment receipt on the controller; a failed run never emits a receipt. +`ansible/playbooks/domains-observe.yml` reads one Domain +(`cloudfall_domains_domain_id`) on its proxy server without changing it: the +vhost file's sha256, the certificate's expiry (`openssl x509 -enddate`) and +whether `certbot.timer` is enabled and active. Every server task is a `stat` +or a command with `changed_when: false` that also runs in check mode; the one +write is the reading, handed back to the controller at +`cloudfall_domains_observe_reading`. `cloudfall domains observe` runs it. + ## Read-only inspection role `ansible/roles/cloudfall_inspect` collects normalized evidence without changing the diff --git a/engine/ansible/playbooks/domains-observe.yml b/engine/ansible/playbooks/domains-observe.yml new file mode 100644 index 0000000..44b0cd8 --- /dev/null +++ b/engine/ansible/playbooks/domains-observe.yml @@ -0,0 +1,134 @@ +--- +# Read one Domain on its proxy server: the vhost file, the certificate and +# certbot.timer. Every server task only reads (stat, or a command that +# changes nothing), and runs in check mode too, so `--check` reads the same. +# The one write is the reading itself, handed back to the controller at +# cloudfall_domains_observe_reading, as backup.yml hands back its receipt. +- name: Read one declared Domain on its proxy server without changing it + hosts: cloudfall_servers + become: true + gather_facts: false + any_errors_fatal: true + tasks: + - name: Select the requested Domain on this host + ansible.builtin.set_fact: + cloudfall_domains_observe_matched: >- + {{ + cloudfall_domains | default([]) + | selectattr('id', 'equalto', cloudfall_domains_domain_id) + | list + }} + + - name: Require the Domain to be proxied by this host + ansible.builtin.assert: + that: + - cloudfall_domains_observe_matched | length == 1 + fail_msg: >- + Domain {{ cloudfall_domains_domain_id }} is not proxied by {{ + cloudfall_server_id }}; nothing was read + quiet: true + + - name: Name what this run reads + ansible.builtin.set_fact: + cloudfall_domains_observe_domain: >- + {{ cloudfall_domains_observe_matched | first }} + cloudfall_domains_observe_certificate_path: >- + {{ + cloudfall_domains_observe_certificate_root + | default('/etc/letsencrypt/live') + }}/{{ + (cloudfall_domains_observe_matched | first).primaryName + }}/fullchain.pem + + # A link is followed, as cloudfall_nginx_site hashes the vhost it replaces. + - name: Hash the virtual host file + ansible.builtin.stat: + path: "{{ cloudfall_domains_observe_domain.proxy.configurationPath }}" + checksum_algorithm: sha256 + follow: true + register: cloudfall_domains_observe_vhost + + - name: Inspect the certificate file + ansible.builtin.stat: + path: "{{ cloudfall_domains_observe_certificate_path }}" + follow: true + get_checksum: false + register: cloudfall_domains_observe_certificate + + - name: Read the certificate expiry + ansible.builtin.command: + argv: + - openssl + - x509 + - -noout + - -enddate + - -in + - "{{ cloudfall_domains_observe_certificate_path }}" + register: cloudfall_domains_observe_enddate + changed_when: false + check_mode: false + when: cloudfall_domains_observe_certificate.stat.exists + + # is-enabled exits 1 for a disabled or missing unit, is-active 3 or 4 for + # an inactive or unknown one: readings, not failures. + - name: Read whether certbot.timer is enabled + ansible.builtin.command: + argv: + - systemctl + - is-enabled + - certbot.timer + register: cloudfall_domains_observe_timer_enabled + changed_when: false + failed_when: cloudfall_domains_observe_timer_enabled.rc not in [0, 1] + check_mode: false + + - name: Read whether certbot.timer is active + ansible.builtin.command: + argv: + - systemctl + - is-active + - certbot.timer + register: cloudfall_domains_observe_timer_active + changed_when: false + failed_when: cloudfall_domains_observe_timer_active.rc not in [0, 3, 4] + check_mode: false + + - name: Hand the reading back to the controller + ansible.builtin.copy: + content: >- + {{ + { + 'domain': cloudfall_domains_observe_domain.id, + 'server': cloudfall_server_id, + 'vhost': { + 'path': cloudfall_domains_observe_domain.proxy.configurationPath, + 'present': cloudfall_domains_observe_vhost.stat.exists, + 'sha256': + cloudfall_domains_observe_vhost.stat.checksum + if cloudfall_domains_observe_vhost.stat.exists + else none, + }, + 'certificate': { + 'path': cloudfall_domains_observe_certificate_path, + 'present': cloudfall_domains_observe_certificate.stat.exists, + 'notAfter': + cloudfall_domains_observe_enddate.stdout + | regex_replace('^notAfter=', '') + if cloudfall_domains_observe_certificate.stat.exists + else none, + }, + 'certbotTimer': { + 'enabled': + cloudfall_domains_observe_timer_enabled.stdout | trim + == 'enabled', + 'active': + cloudfall_domains_observe_timer_active.stdout | trim + == 'active', + }, + } | to_nice_json + }} + dest: "{{ cloudfall_domains_observe_reading }}" + mode: "0600" + delegate_to: localhost + become: false + check_mode: false diff --git a/sdk/README.md b/sdk/README.md index 2c099b6..94f1a2b 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -70,6 +70,15 @@ runs; it is safe on a server whose baseline another project owns. With proxy server (a `proxied` Domain skips the check), then issues the certificate when none exists yet. +`domains observe ` is the server-side view of the same Domain. It runs +the read-only `domains-observe.yml` on the proxy server alone and reports the +vhost file (`present`, `sha256`), the certificate's `not_after`, whether +`certbot.timer` is enabled and active, and whether the vhost still has the +`configurationSha256` of the last receipt in `--deployments` (`receipt` is +`null` before the first one). It changes nothing on the server or in the +project, and a missing vhost is a reading that exits `0`. Both commands are +MCP tools too: `domains_apply` keeps the `yes` gate. + `deploy` refuses to run when the built artifact is missing, misidentified, or fails digest verification, and refuses to report success if the engine wrote no release receipt. `health` exits `0` when every declared server passes the diff --git a/sdk/src/cloudfall/agent_server.py b/sdk/src/cloudfall/agent_server.py index 712f53f..1db95d5 100644 --- a/sdk/src/cloudfall/agent_server.py +++ b/sdk/src/cloudfall/agent_server.py @@ -292,6 +292,8 @@ def operation_tool_description(operation: Operation) -> str: "rollback", "restart", "data.copy", + "domains.apply", + "domains.observe", "migrate", "backup.run", "backup.verify", @@ -411,10 +413,11 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]: _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_copy 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 +the project and derive evidence; domains_observe reads one Domain on its +proxy server and changes nothing. deploy, rollback, restart, migrate, +data_copy and domains_apply 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. diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 26e4a8d..08adc78 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -18,6 +18,7 @@ import errno import os import shutil +import tempfile import threading # treaty reads a handler's annotations at registration, so a streaming @@ -166,6 +167,7 @@ ERROR_EXECUTION_FAILED, DeployOptions, DomainApply, + DomainObserve, DomainsRun, EngineContext, ExecutionStep, @@ -182,6 +184,7 @@ deploy, health, migrate_data, + observe_domain, preview_data_migration, preview_deploy, preview_domain_apply, @@ -411,7 +414,7 @@ def __init__(self, body: Mapping[str, object]) -> None: def _key_schema(name: str, kind: str, *, optional: bool) -> dict[str, object]: """Return the schema of one data key, by its snake_case name.""" schema: dict[str, object] = {"type": [kind, "null"] if optional else kind} - properties = _FIXED_SHAPES.get(name) + properties = _FIXED_SHAPES.get(name) if kind == "object" else None if properties is None and kind in {"array", "object"}: # The domain writes each array in the order it means: a ranking, # a timeline, the order checks ran in, and a stored record keeps @@ -4145,6 +4148,77 @@ def domains_apply( return DomainApplyPayload({**applied.as_dict(), "effect": "updated"}) +@dataclass(frozen=True, slots=True, kw_only=True) +class DomainObserveArgs(ProjectArgs): + """Arguments of ``domains observe``. + + No ``--inventory-file``: the inventory is rendered outside the project, + so a read leaves the project as it found it. + """ + + domain: ResourceId = Arg(description="Domain to read, by its metadata.id") + engine: Path = Flag( + default=default_engine_directory(), + description="Engine directory containing ansible contracts (default: bundled)", + ) + deployments: Path = Flag( + default=Path("tmp/deployments"), + description="Deployment receipt directory (default: tmp/deployments)", + ) + + def __post_init__(self) -> None: + """Keep a relative receipt path inside the project.""" + _inside_project(self.deployments, "deployments") + + +class DomainObservePayload(Payload): + """``domains observe``: the Domain as read on its proxy server.""" + + command = "domains observe" + + +@domains.command( + "observe", + description=( + "Read one Domain's virtual host, certificate and certbot.timer on its " + "proxy server, and compare the vhost with the last receipt; changes nothing" + ), + danger_level="safe", + exit_codes=_LIFECYCLE_EXIT_CODES, + timeout=None, + has_network_io=True, + subprocess=Subprocess("ansible-playbook"), + required_tools={"ansible-playbook": "2.21.0"}, + examples=[ + ("Read one Domain on its proxy server", "cloudfall domains observe crm-site") + ], +) +def domains_observe( + args: DomainObserveArgs, ctx: Ctx, fleet: Fleet +) -> DomainObservePayload: + """Read the Domain on its proxy server; a missing file is a reading.""" + with tempfile.TemporaryDirectory(prefix="cloudfall-domains-observe-") as scratch: + context = EngineContext( + project_directory=fleet.directory, + schema_directory=args.schemas, + engine_directory=args.engine, + inventory_file=Path(scratch) / "ansible-inventory.json", + run=_step_runner(ctx, fleet.directory), + ) + request = DomainObserve( + domain_id=args.domain, + receipt_directory=fleet.path(args.deployments), + reading_file=Path(scratch) / "reading.json", + ) + try: + observed = observe_domain(context, request) + except LifecycleError as error: + raise _lifecycle_failed(error) from error + except ConfigValidationError as error: + raise _config_invalid(error) from error + return DomainObservePayload(observed.as_dict()) + + # mcp serve: the fleet check before serving, and the tools beside the commands diff --git a/sdk/src/cloudfall/commands.py b/sdk/src/cloudfall/commands.py index f714dc2..e43702d 100644 --- a/sdk/src/cloudfall/commands.py +++ b/sdk/src/cloudfall/commands.py @@ -79,7 +79,7 @@ class OutputKey: "action", "application", "component", "database", "detail", "directory", "dnsCheck", "domain", "environmentFile", "health", "instruction", "output", "pass", "path", "project", "receipt", - "release", "report", "service", "sha256", "status", "step", + "release", "report", "server", "service", "sha256", "status", "step", "verification", "wouldRun", ), "string", @@ -102,9 +102,9 @@ class OutputKey: ), **dict.fromkeys( ( - "ansible", "byKind", "byRisk", "dashboard", "decision", "error", - "inventory", "operation", "proposal", "query", "releaseCommand", - "summary", + "ansible", "byKind", "byRisk", "certbotTimer", "certificate", + "dashboard", "decision", "error", "inventory", "operation", + "proposal", "query", "releaseCommand", "summary", "vhost", ), "object", ), @@ -118,11 +118,18 @@ class OutputKey: "timeoutSeconds": "integer", "server": "string", }, + "receipt": { + "configurationSha256": "string", + "deployedAt": "string", + "matches": "boolean", + }, } """The named properties of an object key that has a fixed shape, by key name. Such an object is written as ``null`` when the command has none, where an -open object would be written as ``{}``. +open object would be written as ``{}``. The shape applies only where the key +is an object: ``receipt`` is a path string in most commands, and this object +in ``domains observe``. """ @@ -544,6 +551,26 @@ def _plan(subject: str, *extra: str) -> OutputShape: ), ), ), + CommandContract( + "cloudfall", + "domains observe", + CommandEffect.READ, + "read one Domain's vhost, certificate and certbot.timer on its proxy " + "server, and whether the vhost still matches its last receipt", + output=( + _shape( + "status", + "action", + "domain", + "server", + "vhost", + "receipt:object?", + "certificate", + "certbotTimer", + when="`receipt` is null when the Domain has no receipt yet", + ), + ), + ), CommandContract( "cloudfall", "migrate", @@ -623,7 +650,8 @@ def _plan(subject: str, *extra: str) -> OutputShape: "playbook run", CommandEffect.SERVERS, "run one bundled or project playbook against the inventory " - "(`inspect` only reads; every other playbook converges servers)", + "(`inspect` and `domains-observe` only read; every other playbook " + "converges servers)", gate="a human runs it or names the playbook to run", ), ) diff --git a/sdk/src/cloudfall/lifecycle.py b/sdk/src/cloudfall/lifecycle.py index e081236..1f53dc3 100644 --- a/sdk/src/cloudfall/lifecycle.py +++ b/sdk/src/cloudfall/lifecycle.py @@ -13,9 +13,11 @@ import os import shutil import socket +import ssl import subprocess import sys from dataclasses import dataclass +from datetime import UTC, datetime from enum import StrEnum from ipaddress import ip_address from pathlib import PurePosixPath @@ -23,7 +25,14 @@ from jsonschema.exceptions import ValidationError -from cloudfall.domain import AbsolutePath, DnsMode, Hostname, IpAddress, TlsMode +from cloudfall.domain import ( + AbsolutePath, + DnsMode, + Hostname, + IpAddress, + Sha256Digest, + TlsMode, +) from cloudfall.inventory import PlatformInventory from cloudfall.secrets import ( SecretsError, @@ -31,6 +40,7 @@ owned_role_passwords, staged_password_files, ) +from cloudfall.service_evidence import EvidenceTimestamp, load_deployment_receipt from cloudfall.validation import SchemaCatalog, validate_config if TYPE_CHECKING: @@ -50,6 +60,7 @@ ServiceInventory, ) from cloudfall.secrets import OwnedRolePassword, SopsSecretProvider + from cloudfall.service_evidence import DeploymentReceipt _ARTIFACT_SCHEMA = "artifact.schema.json" _OUTPUT_TAIL_CHARACTERS = 2000 @@ -740,6 +751,199 @@ def _address_list(addresses: frozenset[IpAddress]) -> str: return ", ".join(sorted(address.value for address in addresses)) +@dataclass(frozen=True, slots=True) +class DomainObserve: + """One ``domains observe`` request.""" + + domain_id: ResourceId + receipt_directory: Path + reading_file: Path + """Where ``domains-observe.yml`` hands its reading back, outside the project.""" + + +@dataclass(frozen=True, slots=True) +class VhostReading: + """The Domain's virtual host file as read on its proxy server.""" + + path: AbsolutePath + sha256: Sha256Digest | None + """The file's hash; ``None`` when no file is there.""" + + def as_dict(self) -> dict[str, object]: + """Serialize the reading.""" + return { + "path": self.path.value, + "present": self.sha256 is not None, + "sha256": None if self.sha256 is None else self.sha256.value, + } + + +@dataclass(frozen=True, slots=True) +class CertificateReading: + """The Domain's certificate as read on its proxy server.""" + + path: AbsolutePath + not_after: EvidenceTimestamp | None + """When the certificate expires; ``None`` when no certificate is there.""" + + def as_dict(self) -> dict[str, object]: + """Serialize the reading.""" + return { + "path": self.path.value, + "present": self.not_after is not None, + "notAfter": None if self.not_after is None else self.not_after.as_string(), + } + + +@dataclass(frozen=True, slots=True) +class DomainObserved: + """What ``domains observe`` read for one Domain on its proxy server.""" + + domain_id: ResourceId + server: ResourceId + vhost: VhostReading + certificate: CertificateReading + timer_enabled: bool + timer_active: bool + receipt: DeploymentReceipt | None + """The last ``domains.yml`` receipt for the Domain, if any.""" + + def as_dict(self) -> dict[str, object]: + """Serialize the reading for agent consumers.""" + receipt: dict[str, object] | None = None + if self.receipt is not None: + receipt = { + "configurationSha256": self.receipt.configuration_sha256.value, + "deployedAt": self.receipt.deployed_at.as_string(), + "matches": self.vhost.sha256 == self.receipt.configuration_sha256, + } + return { + "status": "ok", + "action": "domains-observe", + "domain": self.domain_id.value, + "server": self.server.value, + "vhost": self.vhost.as_dict(), + "receipt": receipt, + "certificate": self.certificate.as_dict(), + "certbotTimer": { + "enabled": self.timer_enabled, + "active": self.timer_active, + }, + } + + +def observe_domain(context: EngineContext, request: DomainObserve) -> DomainObserved: + """Read one Domain's vhost, certificate and timer on its proxy server. + + The Domain and its receipt are checked first, so an unknown id renders + no inventory and contacts no server. ``domains-observe.yml`` only reads + on the server; its one write is the reading, at ``request.reading_file``. + """ + state = validate_config(context.project_directory, context.schema_directory) + domain = _find_domain(PlatformInventory.from_state(state), request.domain_id) + receipt_path = request.receipt_directory / f"{domain.resource_id.value}.json" + receipt = ( + load_deployment_receipt(receipt_path, context.schema_directory) + if receipt_path.is_file() + else None + ) + run_engine_playbook( + context, + "domains-observe.yml", + { + "cloudfall_domains_domain_id": domain.resource_id.value, + "cloudfall_domains_observe_reading": str(request.reading_file.resolve()), + }, + limit=domain.proxy.server_id, + ) + reading = _domain_reading(request.reading_file) + if reading.get("domain") != domain.resource_id.value or reading.get( + "server" + ) != domain.proxy.server_id.value: + detail = ( + f"domains-observe.yml read {reading.get('domain')} on " + f"{reading.get('server')}, not {domain.resource_id} on " + f"{domain.proxy.server_id}" + ) + raise LifecycleError(ERROR_EXECUTION_FAILED, detail) + vhost = _reading_object(reading, "vhost") + certificate = _reading_object(reading, "certificate") + timer = _reading_object(reading, "certbotTimer") + return DomainObserved( + domain_id=domain.resource_id, + server=domain.proxy.server_id, + vhost=VhostReading( + path=AbsolutePath.from_boundary(vhost.get("path")), + sha256=_present(vhost, "sha256", Sha256Digest.from_boundary), + ), + certificate=CertificateReading( + path=AbsolutePath.from_boundary(certificate.get("path")), + not_after=_present(certificate, "notAfter", _certificate_expiry), + ), + timer_enabled=_reading_flag(timer, "enabled"), + timer_active=_reading_flag(timer, "active"), + receipt=receipt, + ) + + +def _domain_reading(path: Path) -> Mapping[str, object]: + if not path.is_file(): + detail = f"domains-observe.yml reported success but wrote no reading: {path}" + raise LifecycleError(ERROR_EXECUTION_FAILED, detail) + raw = cast("object", json.loads(path.read_text(encoding="utf-8"))) + if not isinstance(raw, dict): + detail = f"domains-observe.yml wrote a reading that is not an object: {path}" + raise LifecycleError(ERROR_EXECUTION_FAILED, detail) + return cast("Mapping[str, object]", raw) + + +def _reading_object(reading: Mapping[str, object], key: str) -> Mapping[str, object]: + value = reading.get(key) + if not isinstance(value, dict): + detail = f"domains-observe.yml wrote no {key} object" + raise LifecycleError(ERROR_EXECUTION_FAILED, detail) + return cast("Mapping[str, object]", value) + + +def _reading_flag(reading: Mapping[str, object], key: str) -> bool: + value = reading.get(key) + if not isinstance(value, bool): + detail = f"domains-observe.yml wrote {key}: {value!r}, not a boolean" + raise LifecycleError(ERROR_EXECUTION_FAILED, detail) + return value + + +def _present[T]( + reading: Mapping[str, object], key: str, parse: Callable[[object], T] +) -> T | None: + """Parse ``key`` when ``present`` is true; require it absent otherwise.""" + present = _reading_flag(reading, "present") + value = reading.get(key) + if not present: + if value is not None: + detail = f"domains-observe.yml wrote {key} for a file that is not there" + raise LifecycleError(ERROR_EXECUTION_FAILED, detail) + return None + try: + return parse(value) + except (TypeError, ValueError) as error: + detail = f"domains-observe.yml wrote {key}: {value!r}, which is invalid" + raise LifecycleError(ERROR_EXECUTION_FAILED, detail) from error + + +def _certificate_expiry(value: object) -> EvidenceTimestamp: + """Parse ``openssl x509 -enddate``'s date, such as ``Jan 5 09:34:43 2027 GMT``.""" + if not isinstance(value, str): + detail = f"domains-observe.yml wrote notAfter: {value!r}, not a date" + raise LifecycleError(ERROR_EXECUTION_FAILED, detail) + try: + seconds = ssl.cert_time_to_seconds(value.strip()) + except ValueError as error: + detail = f"cannot read the certificate expiry {value!r}: {error}" + raise LifecycleError(ERROR_EXECUTION_FAILED, detail) from error + return EvidenceTimestamp(datetime.fromtimestamp(seconds, UTC)) + + ERROR_SERVICES_SECRET = "lifecycle_services_secret_unavailable" # noqa: S105 - a code """An owner role's password could not be read; no playbook ran.""" diff --git a/sdk/src/cloudfall/service_evidence.py b/sdk/src/cloudfall/service_evidence.py index 7d2f62a..8464f23 100644 --- a/sdk/src/cloudfall/service_evidence.py +++ b/sdk/src/cloudfall/service_evidence.py @@ -373,29 +373,46 @@ def load_deployment_receipts( receipts: list[DeploymentReceipt] = [] index: dict[ResourceId, DeploymentReceipt] = {} for path, content in contents: - metadata = _mapping(content, "metadata") - spec = _mapping(content, "spec") - domain_id = ResourceId.from_boundary(spec.get("domain")) - _require_matching_identity(metadata, domain_id, path, "deployment") - if domain_id in index: - _raise_duplicate(domain_id, index[domain_id].source, path, "deployment") - receipt = DeploymentReceipt( - domain_id=domain_id, - server_id=ResourceId.from_boundary(spec.get("server")), - deployed_at=EvidenceTimestamp.from_boundary(spec.get("deployedAt")), - configuration_path=AbsolutePath.from_boundary( - spec.get("configurationPath") - ), - configuration_sha256=Sha256Digest.from_boundary( - spec.get("configurationSha256") - ), - source=path, - ) + receipt = _deployment_receipt(path, content) + if receipt.domain_id in index: + _raise_duplicate( + receipt.domain_id, index[receipt.domain_id].source, path, "deployment" + ) receipts.append(receipt) - index[domain_id] = receipt + index[receipt.domain_id] = receipt return DeploymentReceiptSet(receipts=tuple(receipts), _index=index) +def load_deployment_receipt(path: Path, schema_directory: Path) -> DeploymentReceipt: + """Load one strictly validated deployment receipt file.""" + content = _load_json(path) + _validate_document( + SchemaCatalog(schema_directory), + path, + content, + _DEPLOYMENT_RECEIPT_SCHEMA, + "deployment receipt", + ) + return _deployment_receipt(path, content) + + +def _deployment_receipt(path: Path, content: Mapping[str, object]) -> DeploymentReceipt: + metadata = _mapping(content, "metadata") + spec = _mapping(content, "spec") + domain_id = ResourceId.from_boundary(spec.get("domain")) + _require_matching_identity(metadata, domain_id, path, "deployment") + return DeploymentReceipt( + domain_id=domain_id, + server_id=ResourceId.from_boundary(spec.get("server")), + deployed_at=EvidenceTimestamp.from_boundary(spec.get("deployedAt")), + configuration_path=AbsolutePath.from_boundary(spec.get("configurationPath")), + configuration_sha256=Sha256Digest.from_boundary( + spec.get("configurationSha256") + ), + source=path, + ) + + def load_domain_observations( observation_directory: Path, schema_directory: Path ) -> DomainObservationSet: @@ -458,20 +475,30 @@ def _validated_json_documents( documents: list[tuple[Path, Mapping[str, object]]] = [] for path in sorted(directory.rglob("*.json")): content = _load_json(path) - try: - catalog.validate_named(schema_name, content) - except ValidationError as error: - issue = ValidationIssue( - code=f"{concept.replace(' ', '_')}_schema_validation_failed", - message=error.message, - source=SourceLocation(path=path, document_number=1), - field_path=tuple(error.absolute_path), - ) - raise ConfigValidationError(issue) from error + _validate_document(catalog, path, content, schema_name, concept) documents.append((path, content)) return tuple(documents) +def _validate_document( + catalog: SchemaCatalog, + path: Path, + content: Mapping[str, object], + schema_name: str, + concept: str, +) -> None: + try: + catalog.validate_named(schema_name, content) + except ValidationError as error: + issue = ValidationIssue( + code=f"{concept.replace(' ', '_')}_schema_validation_failed", + message=error.message, + source=SourceLocation(path=path, document_number=1), + field_path=tuple(error.absolute_path), + ) + raise ConfigValidationError(issue) from error + + def _load_json(path: Path) -> Mapping[str, object]: try: raw = cast("object", json.loads(path.read_text(encoding="utf-8"))) diff --git a/sdk/tests/test_agent_server.py b/sdk/tests/test_agent_server.py index 068a856..a3d5afa 100644 --- a/sdk/tests/test_agent_server.py +++ b/sdk/tests/test_agent_server.py @@ -254,6 +254,29 @@ def test_a_project_serves_its_commands_with_the_project_bound(tmp_path: Path) -> assert converge["error"]["code"] == "CONFIRMATION_REQUIRED" +def test_s004_16_a_project_serves_domains_apply_and_observe(tmp_path: Path) -> None: + # apply keeps the CLI's --yes gate: without yes the call is the plan, and + # no playbook runs. + tools, (planned,) = _talk( + _serve("--project", str(EXAMPLES), cwd=tmp_path), + ("domains_apply", {"domain": "crm-site"}), + ) + + assert {"domains_apply", "domains_observe"} <= set(tools) + assert "yes" in tools["domains_apply"].input_schema["properties"] + assert tools["domains_observe"].annotations.read_only_hint is True + assert tools["domains_apply"].annotations.read_only_hint is not True + for name in ("domains_apply", "domains_observe"): + properties = tools[name].input_schema["properties"] + assert "domain" in properties + assert not {"deployments", "engine", "project"} & set(properties) + assert planned["ok"] is True, planned + assert planned["data"]["status"] == "plan" + assert planned["data"]["effect"] == "would_update" + assert planned["data"]["servers"] == ["h2"] + assert planned["meta"]["dry_run"] is True + + def test_a_project_call_cannot_approve_its_own_proposal(tmp_path: Path) -> None: # A person approves a proposal from a shell; an agent that could call # operator approve would run and verify the change it proposed itself. diff --git a/sdk/tests/test_domains_apply.py b/sdk/tests/test_domains_apply.py index 05dae79..0feb8da 100644 --- a/sdk/tests/test_domains_apply.py +++ b/sdk/tests/test_domains_apply.py @@ -199,8 +199,11 @@ def test_s004_3_the_plan_starts_no_engine_step(tmp_path: Path) -> None: _untouched(project) +# S-004-4: an unknown Domain (domains observe: test_domains_observe.py) + + @pytest.mark.parametrize("yes", [[], ["--yes"]], ids=["plan", "yes"]) -def test_an_unknown_domain_is_refused_before_any_engine_step( +def test_s004_4_apply_refuses_an_unknown_domain_before_any_engine_step( yes: list[str], tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: project = _project(tmp_path) diff --git a/sdk/tests/test_domains_observe.py b/sdk/tests/test_domains_observe.py new file mode 100644 index 0000000..a6a5635 --- /dev/null +++ b/sdk/tests/test_domains_observe.py @@ -0,0 +1,623 @@ +"""``cloudfall domains observe``: one Domain read on its proxy server (spec S-004). + +The real ``domains-observe.yml`` runs against a local connection: the proxy +server's files sit under ``tmp_path``, and ``openssl`` and ``systemctl`` are +stand-in scripts found first on ``PATH``, since the real ones need a Debian +server with a certificate and systemd. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import pytest +import yaml +from cloudfall.cli import main +from cloudfall.domain import ResourceId +from cloudfall.lifecycle import ( + DomainObserve, + EngineContext, + ExecutionStep, + LifecycleError, + StepRun, + StepRunner, + observe_domain, +) + +ROOT = Path(__file__).parents[2] +SCHEMAS = ROOT / "config" / "schemas" / "v1" +EXAMPLES = ROOT / "config" / "examples" +ENGINE = ROOT / "engine" +PLAYBOOK = ENGINE / "ansible" / "playbooks" / "domains-observe.yml" +# crm-site in the examples: proxy server h2, origin h1, primaryName crm.example.test. +PRIMARY_NAME = "crm.example.test" +NOT_AFTER = "Jan 5 09:34:43 2027 GMT" + + +def _project(tmp_path: Path, vhost: Path) -> Path: + """Copy the examples, with crm-site's vhost at ``vhost``.""" + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + path = project / "domains" / "crm-site.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["spec"]["proxy"]["configurationPath"] = str(vhost) + path.write_text(yaml.safe_dump(document), encoding="utf-8") + return project + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _write_receipt(project: Path, vhost: Path) -> Path: + """Write the receipt a ``domains.yml`` run leaves for the vhost as it is.""" + receipts = project / "tmp" / "deployments" + receipts.mkdir(parents=True) + receipt = receipts / "crm-site.json" + receipt.write_text( + json.dumps( + { + "apiVersion": "cloudfall/v1", + "kind": "DeploymentReceipt", + "metadata": {"id": "crm-site", "description": "test receipt"}, + "spec": { + "domain": "crm-site", + "server": "h2", + "deployedAt": "2026-10-27T10:00:00Z", + "configurationPath": str(vhost), + "configurationSha256": _sha256(vhost), + "previousConfigurationSha256": None, + }, + } + ), + encoding="utf-8", + ) + return receipt + + +def _snapshot(directory: Path) -> dict[str, tuple[str, int]]: + """Every file under ``directory``, by content and modification time.""" + return { + str(path.relative_to(directory)): (_sha256(path), path.stat().st_mtime_ns) + for path in sorted(directory.rglob("*")) + if path.is_file() + } + + +def _stand_ins(tmp_path: Path, *, enabled: bool, active: bool) -> Path: + """``openssl`` and ``systemctl`` scripts answering as a server would.""" + tools = tmp_path / "bin" + tools.mkdir() + openssl = tools / "openssl" + openssl.write_text( + '#!/bin/sh\ntest "$3" = -enddate || exit 64\n' + f'echo "notAfter={NOT_AFTER}"\n', + encoding="utf-8", + ) + is_enabled = "echo enabled; exit 0" if enabled else "echo disabled; exit 1" + is_active = "echo active; exit 0" if active else "echo inactive; exit 3" + systemctl = tools / "systemctl" + systemctl.write_text( + "#!/bin/sh\n" + 'test "$2" = certbot.timer || exit 64\n' + 'case "$1" in\n' + f" is-enabled) {is_enabled};;\n" + f" is-active) {is_active};;\n" + "esac\n" + "exit 64\n", + encoding="utf-8", + ) + for script in (openssl, systemctl): + script.chmod(0o755) + return tools + + +@dataclass(slots=True) +class _LocalRun: + """Run each step for real, with every server a local connection. + + The rendered inventory keeps every host and group; only how Ansible + reaches a host changes, and where the certificates live. + """ + + inventory_file: Path + project: Path + tools: Path + certificates: Path + playbook_output: list[str] = field(default_factory=list) + + def __call__(self, step: ExecutionStep) -> StepRun: + environment = {**os.environ, **step.environment} + if step.description.startswith("run "): + self._connect_locally() + environment["PATH"] = f"{self.tools}{os.pathsep}{environment['PATH']}" + done = subprocess.run( # noqa: S603 - argv built by the SDK. + list(step.argv), + check=False, + capture_output=True, + text=True, + timeout=600, + env=environment, + cwd=self.project, + ) + if step.description.startswith("run "): + self.playbook_output.append(done.stdout + done.stderr) + return done + + def _connect_locally(self) -> None: + rendered = json.loads(self.inventory_file.read_text(encoding="utf-8")) + hosts = rendered["all"]["children"]["cloudfall_servers"]["hosts"] + assert sorted(hosts) == ["h1", "h2"] + for host in hosts.values(): + host.update( + { + "ansible_connection": "local", + "ansible_python_interpreter": sys.executable, + "ansible_become": False, + "cloudfall_domains_observe_certificate_root": str( + self.certificates + ), + } + ) + self.inventory_file.write_text(json.dumps(rendered), encoding="utf-8") + + +@dataclass(frozen=True, slots=True) +class _Server: + """The proxy server's files, under ``tmp_path``.""" + + root: Path + + @property + def vhost(self) -> Path: + return self.root / "etc" / "nginx" / "crm.example.test.conf" + + @property + def certificates(self) -> Path: + return self.root / "etc" / "letsencrypt" / "live" + + @property + def certificate(self) -> Path: + return self.certificates / PRIMARY_NAME / "fullchain.pem" + + +def _setup( + tmp_path: Path, *, enabled: bool = True, active: bool = True +) -> tuple[Path, _Server, _LocalRun, EngineContext, DomainObserve]: + server = _Server(tmp_path / "server") + server.vhost.parent.mkdir(parents=True) + project = _project(tmp_path, server.vhost) + scratch = tmp_path / "scratch" + scratch.mkdir() + run = _LocalRun( + inventory_file=scratch / "ansible-inventory.json", + project=project, + tools=_stand_ins(tmp_path, enabled=enabled, active=active), + certificates=server.certificates, + ) + context = EngineContext( + project_directory=project, + schema_directory=SCHEMAS, + engine_directory=ENGINE, + inventory_file=run.inventory_file, + run=run, + ) + request = DomainObserve( + domain_id=ResourceId("crm-site"), + receipt_directory=project / "tmp" / "deployments", + reading_file=scratch / "reading.json", + ) + return project, server, run, context, request + + +def _observed(context: EngineContext, request: DomainObserve) -> dict[str, Any]: + return observe_domain(context, request).as_dict() + + +def _recap_hosts(output: str) -> list[str]: + recap = output.split("PLAY RECAP", 1)[1] + return re.findall(r"^(\S+)\s+:\s+ok=", recap, flags=re.MULTILINE) + + +# S-004-14: the reading + + +def test_s004_14_observe_reads_the_proxy_server_and_changes_nothing( + tmp_path: Path, +) -> None: + project, server, run, context, request = _setup(tmp_path) + server.vhost.write_text("server { listen 80; }\n", encoding="utf-8") + server.certificate.parent.mkdir(parents=True) + server.certificate.write_text("stand-in certificate\n", encoding="utf-8") + _write_receipt(project, server.vhost) + project_before = _snapshot(project) + server_before = _snapshot(server.root) + + observed = _observed(context, request) + + (output,) = run.playbook_output + assert _recap_hosts(output) == ["h2"], output + assert observed["server"] == "h2" + assert observed["vhost"] == { + "path": str(server.vhost), + "present": True, + "sha256": _sha256(server.vhost), + } + assert observed["certificate"] == { + "path": str(server.certificate), + "present": True, + "notAfter": "2027-01-05T09:34:43Z", + } + assert observed["certbotTimer"] == {"enabled": True, "active": True} + assert _snapshot(project) == project_before + assert _snapshot(server.root) == server_before + + +def test_s004_14_a_missing_vhost_is_a_reading_not_an_error(tmp_path: Path) -> None: + project, server, _run, context, request = _setup( + tmp_path, enabled=False, active=False + ) + project_before = _snapshot(project) + + observed = _observed(context, request) + + assert observed["status"] == "ok" + assert observed["vhost"] == { + "path": str(server.vhost), + "present": False, + "sha256": None, + } + assert observed["certificate"] == { + "path": str(server.certificate), + "present": False, + "notAfter": None, + } + assert observed["certbotTimer"] == {"enabled": False, "active": False} + assert not server.vhost.exists() + assert _snapshot(project) == project_before + + +def test_s004_14_the_playbook_holds_only_read_tasks() -> None: + """Every server task reads; the one write is the reading, on the controller.""" + (play,) = yaml.safe_load(PLAYBOOK.read_text(encoding="utf-8")) + assert set(play) & {"roles", "import_playbook", "pre_tasks", "post_tasks"} == set() + reads = { + "ansible.builtin.set_fact", + "ansible.builtin.assert", + "ansible.builtin.stat", + } + for task in play["tasks"]: + module = next(key for key in task if key.startswith("ansible.builtin.")) + if module == "ansible.builtin.command": + assert task["changed_when"] is False, task["name"] + elif module == "ansible.builtin.copy": + assert task["delegate_to"] == "localhost", task["name"] + assert task["become"] is False + assert task["ansible.builtin.copy"]["dest"] == ( + "{{ cloudfall_domains_observe_reading }}" + ) + else: + assert module in reads, task["name"] + + +# S-004-15: the vhost against the last receipt + + +def test_s004_15_receipt_matches_until_the_vhost_is_edited(tmp_path: Path) -> None: + project, server, _run, context, request = _setup(tmp_path) + server.vhost.write_text("server { listen 80; }\n", encoding="utf-8") + receipt = _write_receipt(project, server.vhost) + applied = _sha256(server.vhost) + + unchanged = _observed(context, request) + server.vhost.write_text("server { listen 8080; }\n", encoding="utf-8") + edited = _observed(context, request) + + assert unchanged["receipt"] == { + "configurationSha256": applied, + "deployedAt": "2026-10-27T10:00:00Z", + "matches": True, + } + assert edited["receipt"] == {**unchanged["receipt"], "matches": False} + assert edited["vhost"]["sha256"] == _sha256(server.vhost) + assert json.loads(receipt.read_text(encoding="utf-8"))["spec"][ + "configurationSha256" + ] == applied + + +def test_s004_15_no_receipt_reads_as_null(tmp_path: Path) -> None: + _project_dir, server, _run, context, request = _setup(tmp_path) + server.vhost.write_text("server { listen 80; }\n", encoding="utf-8") + + observed = _observed(context, request) + + assert observed["receipt"] is None + assert observed["vhost"]["present"] is True + + +def test_s004_15_a_removed_vhost_does_not_match_its_receipt(tmp_path: Path) -> None: + project, server, _run, context, request = _setup(tmp_path) + server.vhost.write_text("server { listen 80; }\n", encoding="utf-8") + _write_receipt(project, server.vhost) + server.vhost.unlink() + + observed = _observed(context, request) + + assert observed["receipt"]["matches"] is False + + +# S-004-4: an unknown Domain + + +@dataclass(frozen=True, slots=True) +class _Done: + returncode: int + stdout: str = "" + stderr: str = "" + + +@dataclass(slots=True) +class _Recorder: + """Answer each step, writing ``reading`` where the playbook hands it back.""" + + reading: object + steps: list[ExecutionStep] = field(default_factory=list) + + def __call__(self, step: ExecutionStep) -> _Done: + self.steps.append(step) + if step.description == "run domains-observe.yml": + argv = list(step.argv) + extra_vars = json.loads(argv[argv.index("--extra-vars") + 1]) + Path(extra_vars["cloudfall_domains_observe_reading"]).write_text( + json.dumps(self.reading), encoding="utf-8" + ) + return _Done(0) + + +def _recorded(tmp_path: Path, run: StepRunner) -> tuple[EngineContext, DomainObserve]: + project = tmp_path / "project" + if not project.exists(): + shutil.copytree(EXAMPLES, project) + context = EngineContext( + project_directory=project, + schema_directory=SCHEMAS, + engine_directory=ENGINE, + inventory_file=tmp_path / "inventory.json", + run=run, + ) + request = DomainObserve( + domain_id=ResourceId("crm-site"), + receipt_directory=project / "tmp" / "deployments", + reading_file=tmp_path / "reading.json", + ) + return context, request + + +def _cli( + project: Path, + capsys: pytest.CaptureFixture[str], + *argv: str, + engine: Path = ENGINE, +) -> tuple[int, dict[str, Any]]: + exit_code = main( + [ + "domains", + "observe", + *argv, + "--project", + str(project), + "--schemas", + str(SCHEMAS), + "--engine", + str(engine), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, dict) + return exit_code, payload + + +def test_s004_4_observe_refuses_an_unknown_domain_before_any_engine_step( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + + exit_code, payload = _cli(project, capsys, "nope") + + assert exit_code == 4, payload + assert payload["error"]["code"] == "PRECONDITION" + assert payload["error"]["context"]["code"] == "lifecycle_domain_missing" + assert not (project / "tmp").exists() + + +def test_s004_4_observe_starts_no_step_for_an_unknown_domain(tmp_path: Path) -> None: + recorder = _Recorder(reading={}) + context, request = _recorded(tmp_path, recorder) + + with pytest.raises(LifecycleError) as error: + observe_domain( + context, + DomainObserve( + domain_id=ResourceId("nope"), + receipt_directory=request.receipt_directory, + reading_file=request.reading_file, + ), + ) + + assert error.value.code == "lifecycle_domain_missing" + assert recorder.steps == [] + assert not context.inventory_file.exists() + + +# The engine contract + + +def _reading(**overrides: object) -> dict[str, object]: + reading: dict[str, object] = { + "domain": "crm-site", + "server": "h2", + "vhost": {"path": "/etc/nginx/x.conf", "present": False, "sha256": None}, + "certificate": {"path": "/etc/x.pem", "present": False, "notAfter": None}, + "certbotTimer": {"enabled": False, "active": False}, + } + reading.update(overrides) + return reading + + +def test_observe_runs_the_read_playbook_on_the_proxy_server_only( + tmp_path: Path, +) -> None: + recorder = _Recorder(reading=_reading()) + context, request = _recorded(tmp_path, recorder) + + observe_domain(context, request) + + render, playbook = recorder.steps + assert render.description == "render Ansible inventory" + assert playbook.argv[-1] == str(PLAYBOOK) + assert playbook.argv[playbook.argv.index("--limit") + 1] == "h2" + extra_vars = json.loads(playbook.argv[playbook.argv.index("--extra-vars") + 1]) + assert extra_vars == { + "cloudfall_domains_domain_id": "crm-site", + "cloudfall_domains_observe_reading": str(request.reading_file.resolve()), + } + + +@pytest.mark.parametrize( + "reading", + [ + _reading(server="h1"), + _reading(certbotTimer={"enabled": "yes", "active": False}), + _reading(vhost={"path": "/etc/x.conf", "present": False, "sha256": "ab"}), + _reading( + certificate={"path": "/etc/x.pem", "present": True, "notAfter": "soon"} + ), + _reading(vhost={"path": "/etc/x.conf", "present": True, "sha256": "ab"}), + ], + ids=["other-server", "not-a-boolean", "hash-without-file", "bad-date", "bad-hash"], +) +def test_a_malformed_reading_fails_the_engine_step( + reading: dict[str, object], tmp_path: Path +) -> None: + context, request = _recorded(tmp_path, _Recorder(reading=reading)) + + with pytest.raises(LifecycleError) as error: + observe_domain(context, request) + + assert error.value.code == "lifecycle_execution_failed" + + +STAND_IN_PLAYBOOK = """\ +--- +- name: Hand back a canned reading without contacting a server + hosts: cloudfall_servers + gather_facts: false + tasks: + - name: Write the reading + ansible.builtin.copy: + content: {reading} + dest: "{{{{ cloudfall_domains_observe_reading }}}}" + mode: "0600" + delegate_to: localhost +""" + + +def _stand_in_engine(tmp_path: Path, reading: dict[str, object]) -> Path: + """Lay out an engine whose ``domains-observe.yml`` hands back ``reading``.""" + engine = tmp_path / "engine" + (engine / "ansible" / "playbooks").mkdir(parents=True) + shutil.copy(ENGINE / "ansible" / "ansible.cfg", engine / "ansible" / "ansible.cfg") + (engine / "ansible" / "playbooks" / "domains-observe.yml").write_text( + STAND_IN_PLAYBOOK.format(reading=json.dumps(json.dumps(reading))), + encoding="utf-8", + ) + return engine + + +def test_s004_14_the_cli_answers_snake_case_and_leaves_the_project_as_it_was( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A stand-in engine hands back a reading; the CLI renders outside the project.""" + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + engine = _stand_in_engine( + tmp_path, + _reading( + certificate={ + "path": f"/etc/letsencrypt/live/{PRIMARY_NAME}/fullchain.pem", + "present": True, + "notAfter": NOT_AFTER, + }, + certbotTimer={"enabled": True, "active": True}, + ), + ) + before = _snapshot(project) + + exit_code, payload = _cli(project, capsys, "crm-site", engine=engine) + + assert exit_code == 0, payload + assert payload["data"] == { + "status": "ok", + "action": "domains-observe", + "domain": "crm-site", + "server": "h2", + "vhost": {"path": "/etc/nginx/x.conf", "present": False, "sha256": None}, + "receipt": None, + "certificate": { + "path": f"/etc/letsencrypt/live/{PRIMARY_NAME}/fullchain.pem", + "present": True, + "not_after": "2027-01-05T09:34:43Z", + }, + "certbot_timer": {"enabled": True, "active": True}, + } + assert _snapshot(project) == before + + +def test_s004_15_the_cli_answers_the_receipt_comparison( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + vhost = tmp_path / "applied.conf" + vhost.write_text("server { listen 80; }\n", encoding="utf-8") + _write_receipt(project, vhost) + engine = _stand_in_engine( + tmp_path, + _reading(vhost={"path": str(vhost), "present": True, "sha256": _sha256(vhost)}), + ) + + exit_code, payload = _cli(project, capsys, "crm-site", engine=engine) + + assert exit_code == 0, payload + assert payload["data"]["receipt"] == { + "configuration_sha256": _sha256(vhost), + "deployed_at": "2026-10-27T10:00:00Z", + "matches": True, + } + + +def test_an_unreachable_proxy_server_exits_engine_step_failed( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + server = project / "servers" / "h2.yaml" + document = yaml.safe_load(server.read_text(encoding="utf-8")) + # .invalid never resolves (RFC 6761), so the run fails without a network. + document["spec"]["address"] = "h2.invalid" + server.write_text(yaml.safe_dump(document), encoding="utf-8") + + exit_code, payload = _cli(project, capsys, "crm-site") + + assert exit_code != 0, payload + assert payload["error"]["code"] == "ENGINE_STEP_FAILED"