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/161-command-health-check-schema.md
Original file line number Diff line number Diff line change
@@ -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)
34 changes: 34 additions & 0 deletions config/schemas/v1/component.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
},
Expand Down
4 changes: 4 additions & 0 deletions engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ argument_specs:
choices:
- http
- none
- command
required: true
command:
type: list
elements: str
scheme:
type: str
choices:
Expand Down
10 changes: 10 additions & 0 deletions engine/ansible/roles/cloudfall_deploy/tasks/health.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions engine/ansible/roles/cloudfall_deploy/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
10 changes: 10 additions & 0 deletions engine/ansible/roles/cloudfall_deploy/tasks/restart.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
10 changes: 10 additions & 0 deletions engine/ansible/roles/cloudfall_deploy/tasks/rollback.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
29 changes: 29 additions & 0 deletions engine/tests/test_ansible_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
60 changes: 60 additions & 0 deletions engine/tests/test_deploy_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 == []
59 changes: 59 additions & 0 deletions sdk/src/cloudfall/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,13 +621,72 @@ class HealthCheckType(StrEnum):

HTTP = "http"
NONE = "none"
COMMAND = "command"

@classmethod
def from_boundary(cls, value: object) -> HealthCheckType:
"""Coerce a boundary value into a health-check kind."""
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."""

Expand Down
42 changes: 41 additions & 1 deletion sdk/src/cloudfall/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
FileMode,
FilesystemName,
FirewallPolicy,
HealthCheckAttempts,
HealthCheckTimeout,
HealthCheckType,
Hostname,
HttpScheme,
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading