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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/118-domains-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- `cloudfall domains observe <domain>` 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)
22 changes: 21 additions & 1 deletion docs/specs/004-domains-apply.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# S-004: Domains apply

status: approved
status: built

## Problem

Expand Down Expand Up @@ -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`
8 changes: 8 additions & 0 deletions engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions engine/ansible/playbooks/domains-observe.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <domain>` 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
Expand Down
11 changes: 7 additions & 4 deletions sdk/src/cloudfall/agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ def operation_tool_description(operation: Operation) -> str:
"rollback",
"restart",
"data.copy",
"domains.apply",
"domains.observe",
"migrate",
"backup.run",
"backup.verify",
Expand Down Expand Up @@ -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.

Expand Down
76 changes: 75 additions & 1 deletion sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -166,6 +167,7 @@
ERROR_EXECUTION_FAILED,
DeployOptions,
DomainApply,
DomainObserve,
DomainsRun,
EngineContext,
ExecutionStep,
Expand All @@ -182,6 +184,7 @@
deploy,
health,
migrate_data,
observe_domain,
preview_data_migration,
preview_deploy,
preview_domain_apply,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
Loading
Loading