diff --git a/changelog.d/161-command-health-check-schema.md b/changelog.d/161-command-health-check-schema.md new file mode 100644 index 0000000..a31cc82 --- /dev/null +++ b/changelog.d/161-command-health-check-schema.md @@ -0,0 +1,3 @@ +### Added + +- Components: a `healthCheck` of `type: command` (a required non-empty `command` argv, `timeoutSeconds` 1 to 300 and `attempts` 1 to 20, no other key) validates, and `deploy`, `restart` and `rollback` without `--yes` name the check and its command in their plan's `wouldRun`. The engine does not run the check yet: until it does, `deploy`, `restart`, `rollback` and `health` of such a component fail on their first task, before anything changes on the server, instead of passing an unrun gate (#161) diff --git a/config/schemas/v1/component.schema.json b/config/schemas/v1/component.schema.json index 65d6fbe..6fe6505 100644 --- a/config/schemas/v1/component.schema.json +++ b/config/schemas/v1/component.schema.json @@ -218,6 +218,40 @@ "type" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "command" + }, + "command": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\u0000]+$" + }, + "minItems": 1 + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 300 + }, + "attempts": { + "type": "integer", + "minimum": 1, + "maximum": 20 + } + }, + "required": [ + "type", + "command", + "timeoutSeconds", + "attempts" + ], + "additionalProperties": false } ] }, diff --git a/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml b/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml index 27b90fd..3816e06 100644 --- a/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +++ b/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml @@ -60,7 +60,11 @@ argument_specs: choices: - http - none + - command required: true + command: + type: list + elements: str scheme: type: str choices: diff --git a/engine/ansible/roles/cloudfall_deploy/tasks/health.yml b/engine/ansible/roles/cloudfall_deploy/tasks/health.yml index 7a8b453..8c15bf8 100644 --- a/engine/ansible/roles/cloudfall_deploy/tasks/health.yml +++ b/engine/ansible/roles/cloudfall_deploy/tasks/health.yml @@ -1,3 +1,13 @@ --- +# Until cloudfall-dev/cloudfall#164 runs it, a command health check fails here +# rather than reporting healthy without having run. +- name: Refuse a command health check this engine does not run yet + ansible.builtin.fail: + msg: >- + Component {{ cloudfall_deploy_component.id }} declares a command health + check, which this engine does not run yet + (cloudfall-dev/cloudfall#164); nothing was changed + when: cloudfall_deploy_component.healthCheck.type == "command" + - name: Probe the component's declared health check ansible.builtin.include_tasks: health_gate.yml diff --git a/engine/ansible/roles/cloudfall_deploy/tasks/main.yml b/engine/ansible/roles/cloudfall_deploy/tasks/main.yml index c4e5381..1efdfc9 100644 --- a/engine/ansible/roles/cloudfall_deploy/tasks/main.yml +++ b/engine/ansible/roles/cloudfall_deploy/tasks/main.yml @@ -1,4 +1,14 @@ --- +# Until cloudfall-dev/cloudfall#164 runs it, a command health check fails here, +# before anything changes, rather than letting the deploy pass an unrun gate. +- name: Refuse a command health check this engine does not run yet + ansible.builtin.fail: + msg: >- + Component {{ cloudfall_deploy_component.id }} declares a command health + check, which this engine does not run yet + (cloudfall-dev/cloudfall#164); nothing was changed + when: cloudfall_deploy_component.healthCheck.type == "command" + - name: Validate deployment target and declaration ansible.builtin.assert: that: diff --git a/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml b/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml index 89b9829..f72ae81 100644 --- a/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml +++ b/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml @@ -1,4 +1,14 @@ --- +# Until cloudfall-dev/cloudfall#164 runs it, a command health check fails here, +# before anything changes, rather than letting the restart pass an unrun gate. +- name: Refuse a command health check this engine does not run yet + ansible.builtin.fail: + msg: >- + Component {{ cloudfall_deploy_component.id }} declares a command health + check, which this engine does not run yet + (cloudfall-dev/cloudfall#164); nothing was changed + when: cloudfall_deploy_component.healthCheck.type == "command" + - name: Validate restart target and declaration ansible.builtin.assert: that: diff --git a/engine/ansible/roles/cloudfall_deploy/tasks/rollback.yml b/engine/ansible/roles/cloudfall_deploy/tasks/rollback.yml index fc7deb4..d17bbff 100644 --- a/engine/ansible/roles/cloudfall_deploy/tasks/rollback.yml +++ b/engine/ansible/roles/cloudfall_deploy/tasks/rollback.yml @@ -1,4 +1,14 @@ --- +# Until cloudfall-dev/cloudfall#164 runs it, a command health check fails here, +# before anything changes, rather than letting the rollback pass an unrun gate. +- name: Refuse a command health check this engine does not run yet + ansible.builtin.fail: + msg: >- + Component {{ cloudfall_deploy_component.id }} declares a command health + check, which this engine does not run yet + (cloudfall-dev/cloudfall#164); nothing was changed + when: cloudfall_deploy_component.healthCheck.type == "command" + - name: Validate rollback target and declaration ansible.builtin.assert: that: diff --git a/engine/tests/test_ansible_inventory.py b/engine/tests/test_ansible_inventory.py index 722a544..f250cbd 100644 --- a/engine/tests/test_ansible_inventory.py +++ b/engine/tests/test_ansible_inventory.py @@ -311,6 +311,35 @@ def test_s005_6_a_release_renders_with_its_release_server(tmp_path: Path) -> Non } +def test_s009_1_a_command_health_check_renders_as_declared(tmp_path: Path) -> None: + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + path = project / "components" / "crm-backend.yaml" + text = path.read_text(encoding="utf-8") + declared = text[text.index(" healthCheck:\n") : text.index(" secretRefs:\n")] + check = { + "type": "command", + "command": [".venv/bin/python", "-m", "app.smoke"], + "timeoutSeconds": 60, + "attempts": 3, + } + path.write_text( + text.replace(declared, f" healthCheck: {json.dumps(check)}\n"), + encoding="utf-8", + ) + rendered = render_ansible_inventory( + PlatformInventory.from_state(validate_config(project, SCHEMAS)) + ) + hosts = _mapping_entry( + _mapping_entry(_inventory_children(rendered), "cloudfall_servers"), "hosts" + ) + + for host in ("h1", "h2"): + components = _mapping_entry(hosts, host)["cloudfall_components"] + assert isinstance(components, list) + assert components[0]["healthCheck"] == check + + def test_engine_cli_emits_ansible_json( capsys: pytest.CaptureFixture[str], ) -> None: diff --git a/engine/tests/test_deploy_role.py b/engine/tests/test_deploy_role.py index fda5b8a..242da59 100644 --- a/engine/tests/test_deploy_role.py +++ b/engine/tests/test_deploy_role.py @@ -12,6 +12,7 @@ from pathlib import Path import pytest +from ansible.module_utils.common.arg_spec import ArgumentSpecValidator from ansible.parsing.dataloader import DataLoader from ansible.template import Templar, trust_as_template @@ -479,3 +480,62 @@ def test_the_failure_of_a_first_release_without_a_kept_unit_is_unchanged() -> No "Release r2 failed its health check and was stopped because no previous" " release exists" ) + + +COMMAND_CHECK: dict[str, object] = { + "type": "command", + "command": [".venv/bin/python", "-m", "app.smoke"], + "timeoutSeconds": 60, + "attempts": 3, +} +REFUSE_COMMAND = "Refuse a command health check this engine does not run yet" + + +@pytest.mark.parametrize( + "entry_point", ["main.yml", "restart.yml", "rollback.yml", "health.yml"] +) +def test_the_engine_refuses_a_command_check_it_does_not_run_yet( + entry_point: str, +) -> None: + """Until #164 runs it, a command check fails first, never passes unrun.""" + tasks = _load(ROLE / "tasks" / entry_point) + assert isinstance(tasks, list) + refusal = tasks[0] + assert refusal["name"] == REFUSE_COMMAND + fail = refusal["ansible.builtin.fail"] + assert isinstance(fail, dict) + + def variables(health_check: dict[str, object]) -> dict[str, object]: + return { + "cloudfall_deploy_component": { + "id": "crm-backend", + "healthCheck": health_check, + } + } + + assert all(_holds(c, variables(COMMAND_CHECK)) for c in _conditions(refusal)) + for other in (HTTP_CHECK, NO_CHECK): + assert not all(_holds(c, variables(other)) for c in _conditions(refusal)) + message = str( + _templar(variables(COMMAND_CHECK)).template(trust_as_template(fail["msg"])) + ) + assert message.startswith( + "Component crm-backend declares a command health check" + ) + assert "cloudfall-dev/cloudfall#164" in message + + +@pytest.mark.parametrize("health_check", [HTTP_CHECK, NO_CHECK, COMMAND_CHECK]) +def test_s009_1_the_role_argument_spec_accepts_every_declared_check( + health_check: dict[str, object], +) -> None: + specs = _load(ROLE / "meta" / "argument_specs.yml") + assert isinstance(specs, dict) + component_spec = specs["argument_specs"]["main"]["options"][ + "cloudfall_deploy_component" + ]["options"]["healthCheck"] + validator = ArgumentSpecValidator(component_spec["options"]) + + result = validator.validate(dict(health_check)) + + assert result.error_messages == [] diff --git a/sdk/src/cloudfall/domain.py b/sdk/src/cloudfall/domain.py index 10d30eb..42e3b17 100644 --- a/sdk/src/cloudfall/domain.py +++ b/sdk/src/cloudfall/domain.py @@ -621,6 +621,7 @@ class HealthCheckType(StrEnum): HTTP = "http" NONE = "none" + COMMAND = "command" @classmethod def from_boundary(cls, value: object) -> HealthCheckType: @@ -628,6 +629,64 @@ def from_boundary(cls, value: object) -> HealthCheckType: return cls(_required_string(value, "health check type")) +_HEALTH_CHECK_TIMEOUT_MAXIMUM = 300 +_HEALTH_CHECK_ATTEMPTS_MAXIMUM = 20 + + +def _required_bounded_integer(value: object, label: str, maximum: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + message = f"{label} must be an integer, got {type(value).__name__}" + raise TypeError(message) + if not 1 <= value <= maximum: + message = f"{label} must be 1 to {maximum}, got {value}" + raise ValueError(message) + return value + + +@dataclass(frozen=True, slots=True) +class HealthCheckTimeout: + """How long one run of a command health check may take: 1 to 300 seconds.""" + + value: int + + def __post_init__(self) -> None: + """Reject booleans and values outside 1..300.""" + _required_bounded_integer( + self.value, "health check timeout", _HEALTH_CHECK_TIMEOUT_MAXIMUM + ) + + @classmethod + def from_boundary(cls, value: object) -> HealthCheckTimeout: + """Coerce a required boundary value into a health-check timeout.""" + return cls( + _required_bounded_integer( + value, "health check timeout", _HEALTH_CHECK_TIMEOUT_MAXIMUM + ) + ) + + +@dataclass(frozen=True, slots=True) +class HealthCheckAttempts: + """How many times a command health check runs before it fails: 1 to 20.""" + + value: int + + def __post_init__(self) -> None: + """Reject booleans and values outside 1..20.""" + _required_bounded_integer( + self.value, "health check attempts", _HEALTH_CHECK_ATTEMPTS_MAXIMUM + ) + + @classmethod + def from_boundary(cls, value: object) -> HealthCheckAttempts: + """Coerce a required boundary value into a health-check attempt count.""" + return cls( + _required_bounded_integer( + value, "health check attempts", _HEALTH_CHECK_ATTEMPTS_MAXIMUM + ) + ) + + class RuntimeType(StrEnum): """Component runtimes supported by the v1 contract.""" diff --git a/sdk/src/cloudfall/inventory.py b/sdk/src/cloudfall/inventory.py index 038680e..4ecf56b 100644 --- a/sdk/src/cloudfall/inventory.py +++ b/sdk/src/cloudfall/inventory.py @@ -27,6 +27,8 @@ FileMode, FilesystemName, FirewallPolicy, + HealthCheckAttempts, + HealthCheckTimeout, HealthCheckType, Hostname, HttpScheme, @@ -477,22 +479,48 @@ class ComponentHttpHealthCheck: attempts: PositiveCount +@dataclass(frozen=True, slots=True) +class ComponentCommandHealthCheck: + """Command contract for a component health gate: an argv run on the server.""" + + command: CommandArgv + timeout_seconds: HealthCheckTimeout + attempts: HealthCheckAttempts + + @property + def described(self) -> str: + """The command as one line, for a plan's ``wouldRun``.""" + return " ".join(self.command.value) + + @dataclass(frozen=True, slots=True) class ComponentHealthCheck: """Health gate for a deployed component.""" check_type: HealthCheckType http: ComponentHttpHealthCheck | None + command: ComponentCommandHealthCheck | None = None def __post_init__(self) -> None: - """Require the HTTP contract exactly for HTTP health checks.""" + """Require each type's contract exactly for checks of that type.""" if (self.check_type is HealthCheckType.HTTP) != (self.http is not None): message = "HTTP health checks require an HTTP contract" raise ValueError(message) + if (self.check_type is HealthCheckType.COMMAND) != (self.command is not None): + message = "command health checks require a command contract" + raise ValueError(message) def as_dict(self) -> dict[str, object]: """Serialize the health contract.""" result: dict[str, object] = {"type": self.check_type.value} + if self.command is not None: + result.update( + { + "command": list(self.command.command.value), + "timeoutSeconds": self.command.timeout_seconds.value, + "attempts": self.command.attempts.value, + } + ) if self.http is not None: result.update( { @@ -1734,6 +1762,18 @@ def _component_health_check( check_type = HealthCheckType.from_boundary(health.get("type")) if check_type is HealthCheckType.NONE: return ComponentHealthCheck(check_type=check_type, http=None) + if check_type is HealthCheckType.COMMAND: + return ComponentHealthCheck( + check_type=check_type, + http=None, + command=ComponentCommandHealthCheck( + command=CommandArgv.from_boundary(health.get("command")), + timeout_seconds=HealthCheckTimeout.from_boundary( + health.get("timeoutSeconds") + ), + attempts=HealthCheckAttempts.from_boundary(health.get("attempts")), + ), + ) return ComponentHealthCheck( check_type=check_type, http=ComponentHttpHealthCheck( diff --git a/sdk/src/cloudfall/lifecycle.py b/sdk/src/cloudfall/lifecycle.py index 126a5a3..d3a0498 100644 --- a/sdk/src/cloudfall/lifecycle.py +++ b/sdk/src/cloudfall/lifecycle.py @@ -1143,6 +1143,7 @@ def preview_deploy( f"once on {release_command.server}, before any server switches " "to the release" ) + effect += _command_health_check_effect(component) return LifecyclePreview( action="deploy", subject=PreviewSubject.COMPONENT, @@ -1174,6 +1175,7 @@ def preview_rollback( "; its release command " f"{' '.join(component.release.command.value)} is not run" ) + effect += _command_health_check_effect(component) return LifecyclePreview( action="rollback", subject=PreviewSubject.COMPONENT, @@ -1199,11 +1201,22 @@ def preview_restart( effect=( f"restart component {component_id} on " f"{_server_list(component.server_ids)} and require its declared " - "health check" + "health check" + _command_health_check_effect(component) ), ) +def _command_health_check_effect(component: ComponentInventory) -> str: + """Name a component's command health check for a plan's ``wouldRun``.""" + command = component.health_check.command + if command is None: + return "" + return ( + f"; its command health check {command.described} runs on each server " + "after its restart" + ) + + def rollback( context: EngineContext, component_id: ResourceId, diff --git a/sdk/tests/test_command_health_check.py b/sdk/tests/test_command_health_check.py new file mode 100644 index 0000000..f91731a --- /dev/null +++ b/sdk/tests/test_command_health_check.py @@ -0,0 +1,365 @@ +"""A component's ``healthCheck`` of ``type: command`` (S-009-1, S-009-13). + +The config contract and the plans only: the engine running the check is +cloudfall-dev/cloudfall#164. The engine directory the CLI is pointed at is a +stand-in whose playbooks only leave a marker file, so a test sees whether +``ansible-playbook`` ran at all without contacting any host. +""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import tarfile +from pathlib import Path +from typing import Any + +import pytest +from cloudfall.cli import main +from cloudfall.domain import ( + CommandArgv, + HealthCheckAttempts, + HealthCheckTimeout, + HealthCheckType, +) +from cloudfall.inventory import ( + ComponentCommandHealthCheck, + ComponentHealthCheck, + PlatformInventory, +) +from cloudfall.validation import validate_config + +ROOT = Path(__file__).parents[2] +SCHEMAS = ROOT / "config" / "schemas" / "v1" +EXAMPLES = ROOT / "config" / "examples" +ENGINE = ROOT / "engine" +RELEASE = "20260101T000000Z-abcdef0" +DECLARED_HTTP_CHECK = ( + " healthCheck:\n" + " type: http\n" + " scheme: http\n" + " port: 8100\n" + " path: /health\n" + " expectedStatuses:\n" + " - 200\n" + " timeoutSeconds: 5\n" + " attempts: 5\n" +) +COMMAND_CHECK: dict[str, object] = { + "type": "command", + "command": [".venv/bin/python", "-m", "app.smoke"], + "timeoutSeconds": 60, + "attempts": 3, +} + + +def _project(tmp_path: Path, health_check: object) -> Path: + """Copy the examples with crm-backend's ``healthCheck`` replaced.""" + project = tmp_path / "config" + shutil.copytree(EXAMPLES, project) + path = project / "components" / "crm-backend.yaml" + text = path.read_text(encoding="utf-8") + assert DECLARED_HTTP_CHECK in text + path.write_text( + text.replace( + DECLARED_HTTP_CHECK, f" healthCheck: {json.dumps(health_check)}\n" + ), + encoding="utf-8", + ) + return project + + +def _validate(project: Path, capsys: pytest.CaptureFixture[str]) -> tuple[int, Any]: + exit_code = main( + ["config", "validate", "--project", str(project), "--schemas", str(SCHEMAS)] + ) + return exit_code, json.loads(capsys.readouterr().out) + + +def test_s009_1_config_validate_accepts_a_command_health_check( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + exit_code, payload = _validate(_project(tmp_path, COMMAND_CHECK), capsys) + + assert exit_code == 0, payload + assert payload["ok"] is True + + +@pytest.mark.parametrize( + "health_check", + [ + { + **COMMAND_CHECK, + "command": ["/usr/bin/true"], + "timeoutSeconds": 1, + "attempts": 1, + }, + {**COMMAND_CHECK, "timeoutSeconds": 300, "attempts": 20}, + ], + ids=["lower-bounds", "upper-bounds"], +) +def test_s009_1_config_validate_accepts_the_bounds( + tmp_path: Path, capsys: pytest.CaptureFixture[str], health_check: object +) -> None: + exit_code, payload = _validate(_project(tmp_path, health_check), capsys) + + assert exit_code == 0, payload + + +def _without(key: str) -> dict[str, object]: + return {name: value for name, value in COMMAND_CHECK.items() if name != key} + + +@pytest.mark.parametrize( + "health_check", + [ + {**COMMAND_CHECK, "command": []}, + {**COMMAND_CHECK, "command": [""]}, + {**COMMAND_CHECK, "command": [".venv/bin/python", ""]}, + {**COMMAND_CHECK, "command": "app.smoke"}, + {**COMMAND_CHECK, "command": ["bad\u0000arg"]}, + _without("command"), + _without("timeoutSeconds"), + _without("attempts"), + {**COMMAND_CHECK, "timeoutSeconds": 0}, + {**COMMAND_CHECK, "timeoutSeconds": 301}, + {**COMMAND_CHECK, "timeoutSeconds": "60"}, + {**COMMAND_CHECK, "attempts": 0}, + {**COMMAND_CHECK, "attempts": 21}, + {**COMMAND_CHECK, "attempts": True}, + {**COMMAND_CHECK, "port": 8100}, + {**COMMAND_CHECK, "scheme": "http"}, + {**COMMAND_CHECK, "path": "/health"}, + {**COMMAND_CHECK, "expectedStatuses": [200]}, + {**COMMAND_CHECK, "workingDirectory": "services/api"}, + ], + ids=[ + "empty-command", + "empty-argument", + "empty-later-argument", + "string-command", + "nul-in-argument", + "missing-command", + "missing-timeout", + "missing-attempts", + "timeout-0", + "timeout-301", + "timeout-string", + "attempts-0", + "attempts-21", + "attempts-boolean", + "http-port", + "http-scheme", + "http-path", + "http-expected-statuses", + "unknown-key", + ], +) +def test_s009_1_config_validate_refuses_a_malformed_command_check( + tmp_path: Path, capsys: pytest.CaptureFixture[str], health_check: object +) -> None: + exit_code, payload = _validate(_project(tmp_path, health_check), capsys) + + assert exit_code == 80, payload + assert payload["error"]["code"] == "CONFIG_INVALID" + assert payload["error"]["context"]["code"] == "schema_validation_failed" + + +def test_s009_1_the_command_check_parses_into_value_objects(tmp_path: Path) -> None: + inventory = PlatformInventory.from_state( + validate_config(_project(tmp_path, COMMAND_CHECK), SCHEMAS) + ) + health_check = inventory.components[0].health_check + + assert health_check == ComponentHealthCheck( + check_type=HealthCheckType.COMMAND, + http=None, + command=ComponentCommandHealthCheck( + command=CommandArgv((".venv/bin/python", "-m", "app.smoke")), + timeout_seconds=HealthCheckTimeout(60), + attempts=HealthCheckAttempts(3), + ), + ) + assert health_check.as_dict() == COMMAND_CHECK + + +@pytest.mark.parametrize("value", [0, 301, True]) +def test_s009_1_the_timeout_refuses_values_outside_1_to_300(value: object) -> None: + with pytest.raises((ValueError, TypeError)): + HealthCheckTimeout.from_boundary(value) + + +@pytest.mark.parametrize("value", [0, 21, True, None]) +def test_s009_1_attempts_refuse_values_outside_1_to_20(value: object) -> None: + with pytest.raises((ValueError, TypeError)): + HealthCheckAttempts.from_boundary(value) + + +def test_s009_1_a_check_carries_exactly_the_contract_of_its_type() -> None: + command = ComponentCommandHealthCheck( + command=CommandArgv(("/usr/bin/true",)), + timeout_seconds=HealthCheckTimeout(1), + attempts=HealthCheckAttempts(1), + ) + with pytest.raises(ValueError, match="command contract"): + ComponentHealthCheck(check_type=HealthCheckType.COMMAND, http=None) + with pytest.raises(ValueError, match="command contract"): + ComponentHealthCheck( + check_type=HealthCheckType.NONE, http=None, command=command + ) + + +def test_s009_1_http_and_none_checks_keep_their_shape(tmp_path: Path) -> None: + """Q-3: the existing checks parse and serialize exactly as before.""" + examples = PlatformInventory.from_state(validate_config(EXAMPLES, SCHEMAS)) + http = examples.components[0].health_check + none = PlatformInventory.from_state( + validate_config(_project(tmp_path, {"type": "none"}), SCHEMAS) + ).components[0].health_check + + assert http.command is None + assert http.as_dict() == { + "type": "http", + "scheme": "http", + "port": 8100, + "path": "/health", + "expectedStatuses": [200], + "timeoutSeconds": 5, + "attempts": 5, + } + assert none.command is None + assert none.as_dict() == {"type": "none"} + + +def _marker_engine(tmp_path: Path) -> Path: + """Lay out an engine whose playbooks only write a marker.""" + engine = tmp_path / "engine" + playbooks = engine / "ansible" / "playbooks" + playbooks.mkdir(parents=True) + shutil.copy(ENGINE / "ansible" / "ansible.cfg", engine / "ansible") + for name in ("deploy.yml", "rollback.yml", "restart.yml"): + marker = tmp_path / f"ran-{name}" + (playbooks / name).write_text( + "---\n- name: Leave a marker\n hosts: localhost\n" + " connection: local\n gather_facts: false\n tasks:\n" + " - name: Write the marker\n ansible.builtin.copy:\n" + f" dest: {marker}\n content: ran\n mode: '0600'\n", + encoding="utf-8", + ) + return engine + + +def _write_artifact(tmp_path: Path) -> Path: + artifact_directory = tmp_path / "artifacts" + component_directory = artifact_directory / "crm-backend" + component_directory.mkdir(parents=True) + source = tmp_path / "source" + source.mkdir() + (source / "app.py").write_text("print('crm')\n", encoding="utf-8") + archive_path = component_directory / f"{RELEASE}.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(source, arcname=".") + metadata = { + "apiVersion": "cloudfall/v1", + "kind": "Artifact", + "metadata": {"id": "crm-backend"}, + "spec": { + "component": "crm-backend", + "release": RELEASE, + "gitRef": "main", + "gitCommit": "a" * 40, + "builtAt": "2026-01-01T00:00:00Z", + "archive": archive_path.name, + "archiveSha256": hashlib.sha256(archive_path.read_bytes()).hexdigest(), + "sizeBytes": archive_path.stat().st_size, + }, + } + (component_directory / f"{RELEASE}.json").write_text( + json.dumps(metadata), encoding="utf-8" + ) + return artifact_directory + + +def _plan( + action: str, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + health_check: object, +) -> dict[str, Any]: + arguments = { + "deploy": [ + "--release", + RELEASE, + "--artifacts", + str(_write_artifact(tmp_path)), + ], + "rollback": ["--release", RELEASE], + "restart": [], + }[action] + exit_code = main( + [ + action, + "crm-backend", + *arguments, + "--project", + str(_project(tmp_path, health_check)), + "--schemas", + str(SCHEMAS), + "--engine", + str(_marker_engine(tmp_path)), + "--inventory-file", + str(tmp_path / "inventory.json"), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert exit_code == 0, payload + assert not any(tmp_path.glob("ran-*")) + assert not (tmp_path / "inventory.json").exists() + data = payload["data"] + assert isinstance(data, dict) + assert data["status"] == "plan" + return data + + +@pytest.mark.parametrize("action", ["deploy", "rollback", "restart"]) +def test_s009_13_the_plan_names_the_command_health_check( + action: str, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + data = _plan(action, tmp_path, capsys, COMMAND_CHECK) + + assert ( + "command health check .venv/bin/python -m app.smoke runs on each server " + "after its restart" + ) in data["would_run"] + + +@pytest.mark.parametrize("action", ["deploy", "rollback", "restart"]) +@pytest.mark.parametrize( + "health_check", + [ + { + "type": "http", + "scheme": "http", + "port": 8100, + "path": "/health", + "expectedStatuses": [200], + "timeoutSeconds": 5, + "attempts": 5, + }, + {"type": "none"}, + ], + ids=["http", "none"], +) +def test_s009_13_the_plan_gains_no_field( + action: str, + health_check: object, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """The command check changes only ``would_run``; other checks change nothing.""" + other = _plan(action, tmp_path / "other", capsys, health_check) + command = _plan(action, tmp_path / "command", capsys, COMMAND_CHECK) + + assert set(command) == set(other) + assert "command health check" not in other["would_run"]