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/116-converge-one-domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- The `converge_domains` MCP tool takes an optional `domain`: given, `domains.yml` runs only on that Domain's proxy server (`--limit`) and renders only that Domain, and an unknown id exits `PRECONDITION` (`lifecycle_domain_missing`) before the inventory render; omitted, it runs on every server as before. Each domain receipt now records `previousConfigurationSha256`, the sha256 of the vhost file the run replaced, or `null` when none was there; receipts without it still load (#116)
11 changes: 11 additions & 0 deletions config/schemas/v1/deployment-receipt.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@
},
"configurationSha256": {
"$ref": "common.schema.json#/$defs/sha256"
},
"previousConfigurationSha256": {
"description": "sha256 of the file at configurationPath before this run rendered it, or null when no file was there",
"anyOf": [
{
"$ref": "common.schema.json#/$defs/sha256"
},
{
"type": "null"
}
]
}
},
"required": [
Expand Down
10 changes: 9 additions & 1 deletion engine/ansible/playbooks/domains.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@
{{ cloudfall_domains_issue_certificates | default(false) | bool }}
cloudfall_nginx_site_receipt_directory: >-
{{ cloudfall_domains_receipt_directory | default("") }}
loop: "{{ cloudfall_domains | default([]) }}"
# cloudfall_domains_domain_id limits the run to one Domain.
loop: >-
{{
cloudfall_domains | default([])
| selectattr('id', 'equalto', cloudfall_domains_domain_id)
| list
if cloudfall_domains_domain_id is defined
else cloudfall_domains | default([])
}}
loop_control:
loop_var: cloudfall_domain_item
label: "{{ cloudfall_domain_item.id }}"
16 changes: 16 additions & 0 deletions engine/ansible/roles/cloudfall_nginx_site/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@
else ''
}}

# The receipt records what this run replaced, so a hand-written vhost the
# render overwrites is not lost without a trace. It is hashed once, before
# the first render: the second render below would only see this run's own.
# A link is followed: stat gives no checksum for a link itself, and the file
# it points to is what Nginx read.
- name: Hash the virtual host this run replaces
ansible.builtin.stat:
path: "{{ cloudfall_nginx_site_domain.proxy.configurationPath }}"
checksum_algorithm: sha256
follow: true
register: cloudfall_nginx_site_replaced

- name: Apply the virtual host before issuance
ansible.builtin.include_tasks: apply_virtual_host.yml

Expand Down Expand Up @@ -212,6 +224,10 @@
cloudfall_nginx_site_domain.proxy.configurationPath,
'configurationSha256':
cloudfall_nginx_site_applied.stat.checksum,
'previousConfigurationSha256':
cloudfall_nginx_site_replaced.stat.checksum
if cloudfall_nginx_site_replaced.stat.exists
else none,
},
} | to_nice_json
}}
Expand Down
177 changes: 177 additions & 0 deletions engine/tests/test_domains_one_domain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""``domains.yml`` for one Domain, and the hash of the vhost a run replaces.

The role needs a Debian host with Nginx, so the loop is evaluated with
Ansible's own templating, and the receipt tasks run for real on localhost
against a vhost file under ``tmp_path``.
"""

from __future__ import annotations

import hashlib
import json
import shutil
import subprocess
from pathlib import Path

import yaml
from ansible.parsing.dataloader import DataLoader
from ansible.template import Templar, trust_as_template
from cloudfall.validation import SchemaCatalog

ROOT = Path(__file__).parents[2]
ANSIBLE = ROOT / "engine" / "ansible"
TASKS = ANSIBLE / "roles" / "cloudfall_nginx_site" / "tasks"
SCHEMAS = ROOT / "config" / "schemas" / "v1"
RECEIPT_TASKS = (
"Hash the virtual host this run replaces",
"Hash the applied configuration for the receipt",
"Ensure the controller receipt directory exists",
"Write the deployment receipt on the controller",
)
RENDERED = "server { listen 80; }\n"


def _loop(variables: dict[str, object]) -> object:
play = DataLoader().load_from_file(str(ANSIBLE / "playbooks" / "domains.yml"))
assert isinstance(play, list)
(task,) = play[0]["tasks"]
templar = Templar(loader=DataLoader(), variables=variables)
return templar.template(trust_as_template(task["loop"]))


def _domain(domain_id: str) -> dict[str, object]:
return {
"id": domain_id,
"proxy": {"configurationPath": f"/etc/nginx/sites-available/{domain_id}.conf"},
}


def test_s004_2_one_domain_id_renders_only_that_domain() -> None:
domains = [_domain("d1"), _domain("d2")]

only_d1 = _loop({"cloudfall_domains": domains, "cloudfall_domains_domain_id": "d1"})

assert only_d1 == [_domain("d1")]


def test_s004_17_without_a_domain_id_every_domain_on_the_host_renders() -> None:
domains = [_domain("d1"), _domain("d2")]

assert _loop({"cloudfall_domains": domains}) == domains
assert _loop({}) == []


def test_s004_2_a_host_without_the_domain_renders_nothing() -> None:
variables: dict[str, object] = {
"cloudfall_domains": [_domain("d2")],
"cloudfall_domains_domain_id": "d1",
}

assert _loop(variables) == []


def _receipt_play(vhost: Path, receipts: Path) -> list[dict[str, object]]:
"""Replay the role's receipt tasks, with a stand-in for the render."""
# Plain YAML, not Ansible's loader: the tasks are written out again.
tasks = yaml.safe_load((TASKS / "main.yml").read_text(encoding="utf-8"))
assert isinstance(tasks, list)
named = {task["name"]: task for task in tasks}
render = {
"name": "Stand in for the render",
"ansible.builtin.copy": {
"content": RENDERED,
"dest": str(vhost),
"mode": "0644",
},
}
hash_replaced, *receipt = (named[name] for name in RECEIPT_TASKS)
return [
{
"name": "Replay the receipt tasks of cloudfall_nginx_site",
"hosts": "localhost",
"connection": "local",
"gather_facts": True,
"gather_subset": ["min"],
"vars": {
"cloudfall_server_id": "h2",
"cloudfall_nginx_site_receipt_directory": str(receipts),
"cloudfall_nginx_site_domain": {
"id": "crm-site",
"proxy": {"configurationPath": str(vhost)},
},
},
"tasks": [hash_replaced, render, *receipt],
}
]


def _run(tmp_path: Path, vhost: Path) -> dict[str, object]:
binary = shutil.which("ansible-playbook")
assert binary is not None
receipts = tmp_path / "deployments"
playbook = tmp_path / "receipt.yml"
play = _receipt_play(vhost, receipts)
playbook.write_text(yaml.safe_dump(play), encoding="utf-8")
done = subprocess.run( # noqa: S603 - fixed binary and arguments.
[binary, "--inventory", "localhost,", str(playbook)],
check=False,
capture_output=True,
text=True,
timeout=300,
)
assert done.returncode == 0, done.stdout + done.stderr
receipt = json.loads((receipts / "crm-site.json").read_text(encoding="utf-8"))
SchemaCatalog(SCHEMAS).validate_named("deployment-receipt.schema.json", receipt)
spec = receipt["spec"]
assert isinstance(spec, dict)
return spec


def _sha256(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()


def test_s004_11_the_receipt_keeps_the_hash_of_a_hand_written_vhost(
tmp_path: Path,
) -> None:
vhost = tmp_path / "crm.example.test.conf"
hand_written = "server { listen 80; server_name crm.example.test; }\n"
vhost.write_text(hand_written, encoding="utf-8")

spec = _run(tmp_path, vhost)

assert spec["previousConfigurationSha256"] == _sha256(hand_written)
assert spec["configurationSha256"] == _sha256(RENDERED)


def test_s004_11_a_run_with_no_vhost_there_records_null(tmp_path: Path) -> None:
spec = _run(tmp_path, tmp_path / "crm.example.test.conf")

assert spec["previousConfigurationSha256"] is None
assert spec["configurationSha256"] == _sha256(RENDERED)


def test_s004_11_a_vhost_linked_into_place_is_hashed_through_the_link(
tmp_path: Path,
) -> None:
"""Debian keeps vhosts as links into sites-available; hash what it reads."""
hand_written = "server { listen 80; server_name crm.example.test; }\n"
target = tmp_path / "sites-available.conf"
target.write_text(hand_written, encoding="utf-8")
vhost = tmp_path / "crm.example.test.conf"
vhost.symlink_to(target)

spec = _run(tmp_path, vhost)

assert spec["previousConfigurationSha256"] == _sha256(hand_written)


def test_s004_11_the_replaced_vhost_is_hashed_before_the_first_render() -> None:
"""The second render would see only this run's own file."""
tasks = DataLoader().load_from_file(str(TASKS / "main.yml"))
assert isinstance(tasks, list)
names = [task["name"] for task in tasks]

assert names.index(RECEIPT_TASKS[0]) < names.index(
"Apply the virtual host before issuance"
)
37 changes: 26 additions & 11 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
ERROR_EXECUTION_FAILED,
STEP_TIMEOUT_SECONDS,
DeployOptions,
DomainsRun,
EngineContext,
ExecutionStep,
LifecycleError,
Expand All @@ -169,6 +170,7 @@
StepRunner,
backup_service,
build_release_artifact,
converge_domains,
deploy,
health,
migrate_data,
Expand Down Expand Up @@ -4192,13 +4194,22 @@ def run(arguments: Mapping[str, object], call: Ctx) -> 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)),
}
def converge_domain_routes(
arguments: Mapping[str, object], call: Ctx
) -> Converged:
domain = arguments.get("domain")
run = DomainsRun(
issue_certificates=bool(arguments.get("issue_certificates", False)),
receipt_directory=root.path(args.deployments),
domain_id=None if domain is None else ResourceId.from_boundary(domain),
)
try:
converge_domains(context(call), run)
except LifecycleError as error:
raise _lifecycle_failed(error) from error
except ConfigValidationError as error:
raise _config_invalid(error) from error
return Converged(status="ok", action="converge-domains", playbook="domains.yml")

no_inputs: dict[str, object] = {
"type": "object",
Expand Down Expand Up @@ -4251,16 +4262,20 @@ def domain_vars(arguments: Mapping[str, object]) -> dict[str, object]:
name="converge_domains",
description=(
"Run domains.yml: render every declared domain route, and issue "
"missing certificates when issue_certificates is true"
"missing certificates when issue_certificates is true. Given a "
"domain, run only on that Domain's proxy server and render only it"
),
input_schema={
"type": "object",
"properties": {"issue_certificates": {"type": "boolean"}},
"properties": {
"issue_certificates": {"type": "boolean"},
"domain": {"type": "string", "pattern": _COMPONENT_PATTERN},
},
"additionalProperties": False,
},
handler=converge("converge-domains", "domains.yml", domain_vars),
handler=converge_domain_routes,
danger_level="destructive",
exit_codes=_ENGINE_EXIT_CODES,
exit_codes=(*_ENGINE_EXIT_CODES, "CONFIG_INVALID"),
),
]

Expand Down
Loading
Loading