From 1bf186b6f16a97611ebe554d38608eddcf009448 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:28:10 +0200 Subject: [PATCH 1/2] Run the command health check in restart, rollback and health (S-009-10) health_gate.yml, which restart, rollback and health already end on, now includes the deploy's own command_health_check.yml for a type: command check, so all three run it with the same transient unit, user, working directory, EnvironmentFile=, timeout, attempts and no_log redaction as the deploy gate. The include applies become: true, since systemd-run --uid needs root and the health play does not take it for http and none checks. health reports unhealthy when the check exits non-zero or times out; none of the three writes a receipt, and a failed check after restart or rollback restores nothing, as the spec says. http and none run the same tasks as before. The refusals added in #161 are gone, and spec 009 is built. Fixes #166 Co-Authored-By: Claude Opus 5.5 --- ...6-command-check-restart-rollback-health.md | 3 + docs/ci-deploy-guide.md | 4 +- docs/specs/009-command-health-check.md | 18 +- engine/README.md | 10 +- .../roles/cloudfall_deploy/tasks/health.yml | 10 - .../cloudfall_deploy/tasks/health_gate.yml | 13 + .../roles/cloudfall_deploy/tasks/restart.yml | 10 - .../roles/cloudfall_deploy/tasks/rollback.yml | 10 - engine/tests/test_command_health_gate.py | 252 +++++++++++++++++- engine/tests/test_deploy_role.py | 29 +- sdk/README.md | 4 +- 11 files changed, 300 insertions(+), 63 deletions(-) create mode 100644 changelog.d/166-command-check-restart-rollback-health.md diff --git a/changelog.d/166-command-check-restart-rollback-health.md b/changelog.d/166-command-check-restart-rollback-health.md new file mode 100644 index 0000000..d232a8c --- /dev/null +++ b/changelog.d/166-command-check-restart-rollback-health.md @@ -0,0 +1,3 @@ +### Added + +- `restart`, `rollback` and `health` run a component's `type: command` health check instead of refusing it: the same transient unit as `deploy`'s gate, with its user, working directory, env file, timeout, attempts and redacted output. `health` reports `unhealthy` when the check exits non-zero or times out, a failed check fails `restart` or `rollback` without restoring anything, as an `http` check does, and none of them writes a release receipt. The check starts its unit as root, so `health` of such a component needs the inventory SSH account to become root, as `restart` and `rollback` already do; `http` and `none` checks are unchanged (#166) diff --git a/docs/ci-deploy-guide.md b/docs/ci-deploy-guide.md index ec619a7..eafeefd 100644 --- a/docs/ci-deploy-guide.md +++ b/docs/ci-deploy-guide.md @@ -268,7 +268,9 @@ What each step does: declared as a `type: command` health check runs on each server after its restart, so it gates the deploy and needs no CI step of its own: when it fails, the server goes back to the previous release, the deploy fails, and - the receipt records the failure with `healthCheckPassed: false` + the receipt records the failure with `healthCheckPassed: false`. + `cloudfall restart`, `cloudfall rollback` and `cloudfall health` run the + same check, so it must be safe to run against the live service at any time - **Remove the decrypted files.** GitHub-hosted runners are thrown away after the job; on a self-hosted runner this step is what keeps the values off its disk diff --git a/docs/specs/009-command-health-check.md b/docs/specs/009-command-health-check.md index 608bf28..733bae8 100644 --- a/docs/specs/009-command-health-check.md +++ b/docs/specs/009-command-health-check.md @@ -1,6 +1,6 @@ # S-009: Command health check -status: approved +status: built serves: G-3 ## Problem @@ -165,3 +165,19 @@ Touches `config/schemas/v1/component.schema.json`, - cloudfall-dev/cloudfall#166: S-009-10 ## Verification + +No criterion has run against a live Debian host yet: the role's command check, receipt, `health` and shared gate tasks ran under `ansible-playbook` on the controller with a stand-in for `systemd-run`, and the gate block's order and rescue were replayed with Ansible's own templating. The real `systemd-run` (its exit code, the `Finished with result: timeout` line, `EnvironmentFile=` parsing, the journal), `become` on the `health` play, and `current`, unit and restart state on real servers are unexercised. + +- S-009-1: `test_s009_1_config_validate_accepts_a_command_health_check`, `test_s009_1_config_validate_accepts_the_bounds`, `test_s009_1_config_validate_refuses_a_malformed_command_check`, `test_s009_1_the_timeout_refuses_values_outside_1_to_300`, `test_s009_1_attempts_refuse_values_outside_1_to_20`, `test_s009_1_a_check_carries_exactly_the_contract_of_its_type`, `test_s009_1_a_command_health_check_renders_as_declared` and `test_s009_1_the_role_argument_spec_accepts_every_declared_check` +- S-009-2: `test_s009_2_the_check_runs_after_the_restart_inside_the_rescued_block` (task order and the rescue) and `test_s009_2_the_check_runs_as_the_user_in_current_with_the_unit_environment` (`--uid`/`--gid`, working directory, the unit's `EnvironmentFile=-` line, `RuntimeMaxSec=60`) +- S-009-3: `test_s009_3_a_relative_first_element_runs_from_current` and `test_s009_3_an_absolute_first_element_runs_unchanged` +- S-009-4: `test_s009_4_a_passing_check_receipts_its_run`, the receipt validated with `healthCheckPassed: true`, `attempts: 1`, `exitCode: 0` and `timedOut: false` +- S-009-5: `test_s009_5_a_check_exiting_3_fails_and_receipts_the_failed_release`, `test_s009_5_a_failed_check_rolls_back_receipts_then_fails` and `test_s009_5_the_deploy_error_names_the_check_its_exit_and_the_rollback` +- S-009-6: `test_s009_6_a_failed_first_release_receipts_no_previous_release` and `test_s009_6_a_failed_first_release_is_stopped_and_receipted` +- S-009-7: `test_s009_7_a_timed_out_check_fails_naming_the_timeout`, `test_s009_7_a_timed_out_run_counts_as_a_failed_attempt` and `test_s009_7_the_deploy_error_names_the_timeout` +- S-009-8: `test_s009_8_a_check_failing_twice_then_passing_records_three_attempts`, `test_s009_8_a_check_always_failing_runs_exactly_attempts_times_3s_apart` and `test_s009_8_a_passing_first_attempt_stops_there` +- S-009-9: `test_s009_9_the_output_reaches_neither_the_receipt_nor_the_ansible_output` (at `-vvvv`), `test_s009_9_the_run_is_no_log_and_pipes_nothing` and `test_s009_9_the_receipt_schema_refuses_any_other_health_check_key` +- S-009-10: `test_s009_10_restart_rollback_and_health_end_on_the_shared_gate`, `test_s009_10_the_shared_gate_runs_deploys_command_check_as_root`, `test_s009_10_a_passing_check_passes_with_deploys_invocation` and `test_s009_10_a_failing_check_fails_without_its_output_or_a_receipt` (the role's `health` and `health_gate` tasks), `test_s009_10_a_timed_out_check_fails_naming_the_timeout`, `test_s009_10_health_reports_unhealthy_when_the_check_exits_non_zero` and `test_s009_10_health_reports_ok_when_the_check_exits_0` (`cloudfall health` over the role's tasks), `test_s009_10_no_entry_point_includes_the_receipt` and `test_s009_10_no_entry_point_refuses_a_command_check` +- S-009-11: `test_s009_11_a_failed_receipt_needs_a_failed_command_check`, `test_s009_11_a_command_check_record_must_be_coherent`, `test_s009_11_a_passed_receipt_refuses_a_failed_command_check`, `test_s009_11_every_receipt_written_today_still_validates` and `test_s009_11_a_timed_out_failure_validates` +- S-009-12: `test_s009_12_http_and_none_run_the_same_gate_tasks`, `test_s009_12_a_failed_http_or_none_gate_writes_no_receipt`, `test_s009_12_http_and_none_receipts_have_no_health_check`, `test_s009_12_the_pass_receipt_is_written_where_it_was` and `test_s009_12_restart_rollback_and_health_keep_their_http_and_none_gates` +- S-009-13: `test_s009_13_the_plan_names_the_command_health_check` and `test_s009_13_the_plan_gains_no_field` diff --git a/engine/README.md b/engine/README.md index 76dad5b..1ae150d 100644 --- a/engine/README.md +++ b/engine/README.md @@ -260,8 +260,14 @@ restores the previous release (or stops a first one), writes the receipt with `previousRelease` naming the release that serves again, and fails the run. The receipt of a passing command check records it under `healthCheck` too; `http` and `none` checks keep their receipts and their gate as before. -`restart`, `rollback` and `health` still refuse a command check until -cloudfall-dev/cloudfall#166 runs it there. +`restart`, `rollback` and `health` run the same command check through +`health_gate.yml`, with the same unit, limits and redaction, and write no +receipt: `health` reports `unhealthy` when it exits non-zero, and a failed +check fails `restart` or `rollback` without restoring anything, as an `http` +check does. The check runs as root to start its unit, so `health` of a +component with a command check needs the inventory SSH account to become +root, as `restart` and `rollback` already do. The check must be safe to run +at any time against the live service. The first slice supports Python components managed by uv on x86_64 Debian hosts. diff --git a/engine/ansible/roles/cloudfall_deploy/tasks/health.yml b/engine/ansible/roles/cloudfall_deploy/tasks/health.yml index c00f408..7a8b453 100644 --- a/engine/ansible/roles/cloudfall_deploy/tasks/health.yml +++ b/engine/ansible/roles/cloudfall_deploy/tasks/health.yml @@ -1,13 +1,3 @@ --- -# Until cloudfall-dev/cloudfall#166 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#166); 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/health_gate.yml b/engine/ansible/roles/cloudfall_deploy/tasks/health_gate.yml index c15d6f5..079dc24 100644 --- a/engine/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +++ b/engine/ansible/roles/cloudfall_deploy/tasks/health_gate.yml @@ -20,6 +20,19 @@ - not ansible_check_mode - cloudfall_deploy_component.healthCheck.type == "http" +# The deploy's own command check tasks, so restart, rollback and health run +# it with the same user, directory, environment, limits and redaction. Its +# systemd-run --uid needs root, which the health play does not take for http +# and none checks. +- name: Gate the component on its declared command health check + ansible.builtin.include_tasks: + file: command_health_check.yml + apply: + become: true + when: + - not ansible_check_mode + - cloudfall_deploy_component.healthCheck.type == "command" + - name: Gate the component on an active service unit ansible.builtin.command: argv: diff --git a/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml b/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml index 84b5f41..89b9829 100644 --- a/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml +++ b/engine/ansible/roles/cloudfall_deploy/tasks/restart.yml @@ -1,14 +1,4 @@ --- -# Until cloudfall-dev/cloudfall#166 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#166); 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 3313498..fc7deb4 100644 --- a/engine/ansible/roles/cloudfall_deploy/tasks/rollback.yml +++ b/engine/ansible/roles/cloudfall_deploy/tasks/rollback.yml @@ -1,14 +1,4 @@ --- -# Until cloudfall-dev/cloudfall#166 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#166); 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_command_health_gate.py b/engine/tests/test_command_health_gate.py index ae35a3c..4365e2e 100644 --- a/engine/tests/test_command_health_gate.py +++ b/engine/tests/test_command_health_gate.py @@ -1,4 +1,4 @@ -"""A component's command health check gating a deploy (S-009-2..9, 11, 12). +"""A component's command health check gating a deploy (S-009-2..12). The role needs a Debian host with systemd, so these tests take the routes ``test_release_command.py`` takes. ``command_health_check.yml`` and @@ -26,6 +26,8 @@ import yaml from ansible.parsing.dataloader import DataLoader from ansible.template import Templar, trust_as_template +from cloudfall.domain import ResourceId +from cloudfall.lifecycle import EngineContext, ExecutionStep, StepRun, health from cloudfall.validation import SchemaCatalog from cloudfall_engine.cli import main from cloudfall_engine.playbook import PlaybookRun, playbook_command @@ -908,3 +910,251 @@ def test_s009_12_the_pass_receipt_is_written_where_it_was() -> None: assert receipt["ansible.builtin.include_tasks"] == "release_receipt.yml" assert receipt["vars"] == {"cloudfall_deploy_receipt_passed": True} assert "when" not in receipt + + +# restart, rollback and health run the same check and write no receipt. + +SHARED_GATE = "health_gate.yml" +SHARED_COMMAND_GATE = "Gate the component on its declared command health check" + + +def _tasks(name: str) -> list[dict[str, object]]: + tasks = DataLoader().load_from_file(str(ROLE / "tasks" / name)) + assert isinstance(tasks, list) + return tasks + + +def _entry_point( # noqa: PLR0913 - one knob per case. + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + tasks_from: str, + health_check: dict[str, object] | None = None, + *, + exit_code: int = 0, + attempts: int = 1, + timed_out: bool = False, +) -> _Run: + """Run one of the role's task files for real against a release in current. + + ``ansible_become: false`` stands in for the root the restart, rollback + and health plays reach on a server; the controller here has no sudo. + """ + install_root = tmp_path / "app" + release_directory = install_root / "releases" / RELEASE + release_directory.mkdir(parents=True) + (install_root / "current").symlink_to(release_directory) + _executable(release_directory / "bin" / "smoke", SMOKE) + if timed_out: + (release_directory / TIMEOUT_MARKER).write_text("", encoding="utf-8") + systemd_run = tmp_path / "systemd-run" + _executable(systemd_run, FAKE_SYSTEMD_RUN) + if health_check is None: + health_check = _command_check( + ["bin/smoke", str(exit_code), "0"], attempts=attempts + ) + playbook = tmp_path / "entry.yml" + playbook.write_text( + yaml.safe_dump( + [ + { + "name": f"Run the role's {tasks_from} tasks", + "hosts": "localhost", + "gather_facts": False, + "vars": { + "ansible_become": False, + "cloudfall_server_id": "h1", + "cloudfall_deploy_receipt_directory": str( + tmp_path / "receipts" + ), + "cloudfall_deploy_systemd_run": str(systemd_run), + "cloudfall_deploy_component": _component( + health_check, install_root + ), + }, + "tasks": [ + { + "name": "Include the role's tasks", + "ansible.builtin.include_role": { + "name": "cloudfall_deploy", + "tasks_from": tasks_from, + }, + } + ], + } + ] + ), + encoding="utf-8", + ) + code = main( + [ + "playbook", + "run", + str(playbook), + "--engine", + str(ENGINE), + "--inventory", + str(_local_inventory(tmp_path)), + ] + ) + captured = capsys.readouterr() + return _Run(tmp_path, code, f"{captured.out}\n{captured.err}") + + +@pytest.mark.parametrize("entry_point", ["restart.yml", "rollback.yml", "health.yml"]) +def test_s009_10_restart_rollback_and_health_end_on_the_shared_gate( + entry_point: str, +) -> None: + last = _tasks(entry_point)[-1] + + assert last["ansible.builtin.include_tasks"] == SHARED_GATE + + +def test_s009_10_the_shared_gate_runs_deploys_command_check_as_root() -> None: + gate = _task(SHARED_COMMAND_GATE, _tasks(SHARED_GATE)) + include = gate["ansible.builtin.include_tasks"] + variables: dict[str, object] = { + "ansible_check_mode": False, + "cloudfall_deploy_component": {"healthCheck": _command_check(["bin/smoke"])}, + } + + assert include == {"file": "command_health_check.yml", "apply": {"become": True}} + assert _runs(gate, variables) + assert not _runs(gate, {**variables, "ansible_check_mode": True}) + for other in (HTTP_CHECK, NO_CHECK): + assert not _runs( + gate, {**variables, "cloudfall_deploy_component": {"healthCheck": other}} + ) + + +def test_s009_12_restart_rollback_and_health_keep_their_http_and_none_gates() -> None: + def gated(health_check: dict[str, object]) -> list[str]: + variables: dict[str, object] = { + "ansible_check_mode": False, + "cloudfall_deploy_component": {"healthCheck": health_check}, + } + return [ + str(task["name"]) + for task in _tasks(SHARED_GATE) + if _runs(task, variables) + ] + + assert gated(HTTP_CHECK) == ["Gate the component on its declared HTTP health check"] + assert gated(NO_CHECK) == ["Gate the component on an active service unit"] + assert gated(_command_check(["bin/smoke"])) == [SHARED_COMMAND_GATE] + + +@pytest.mark.parametrize( + "name", + [ + "restart.yml", + "rollback.yml", + "health.yml", + SHARED_GATE, + "command_health_check.yml", + ], +) +def test_s009_10_no_entry_point_includes_the_receipt(name: str) -> None: + assert "release_receipt" not in (ROLE / "tasks" / name).read_text( + encoding="utf-8" + ) + + +@pytest.mark.parametrize("tasks_from", ["health", "health_gate"]) +def test_s009_10_a_passing_check_passes_with_deploys_invocation( + tasks_from: str, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + run = _entry_point(tmp_path, capsys, tasks_from) + + assert run.exit_code == 0, run.output + argv = run.argv + options = argv[: argv.index("--")] + assert "--unit=cloudfall-health-crm-backend" in options + assert "--uid=crm" in options + assert f"--working-directory={run.install_root}/current" in options + assert ( + "--property=EnvironmentFile=-" + f"{run.install_root}/shared/env/{COMPONENT_ID}.env" + ) in options + assert "--property=RuntimeMaxSec=60" in options + assert argv[argv.index("--") + 1 :] == [ + f"{run.install_root}/current/bin/smoke", + "0", + "0", + ] + assert not (tmp_path / "receipts").exists() + + +@pytest.mark.parametrize("tasks_from", ["health", "health_gate"]) +def test_s009_10_a_failing_check_fails_without_its_output_or_a_receipt( + tasks_from: str, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + run = _entry_point(tmp_path, capsys, tasks_from, exit_code=3, attempts=2) + + assert run.exit_code == 82 + assert ( + "The command health check of crm-backend failed on h1: bin/smoke 3 0" + " exited with code 3 on the last of 2 attempts" + ) in run.output + assert run.attempts == 2 + assert MARKER not in run.output + assert not (tmp_path / "receipts").exists() + + +def test_s009_10_a_timed_out_check_fails_naming_the_timeout( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + run = _entry_point(tmp_path, capsys, "health", timed_out=True) + + assert run.exit_code == 82 + assert "timed out after 60 seconds and was stopped" in run.output + assert not (tmp_path / "receipts").exists() + + +def _health( + tmp_path: Path, capsys: pytest.CaptureFixture[str], exit_code: int +) -> dict[str, object]: + """``cloudfall health`` with its playbook step running the role's tasks. + + The inventory step is skipped: the play targets this controller, not the + example project's servers. + """ + + def run(step: ExecutionStep) -> StepRun: + if step.description == "render Ansible inventory": + return subprocess.CompletedProcess(step.argv, 0, "", "") + assert step.argv[-1].endswith("health.yml") + done = _entry_point(tmp_path, capsys, "health", exit_code=exit_code) + return subprocess.CompletedProcess(step.argv, done.exit_code, done.output, "") + + context = EngineContext( + project_directory=ROOT / "config" / "examples", + schema_directory=SCHEMAS, + engine_directory=ENGINE, + inventory_file=tmp_path / "inventory.json", + run=run, + ) + return health(context, ResourceId(COMPONENT_ID)).as_dict() + + +def test_s009_10_health_reports_unhealthy_when_the_check_exits_non_zero( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + result = _health(tmp_path, capsys, exit_code=3) + + assert result["status"] == "unhealthy" + assert result["healthy"] is False + detail = str(result["detail"]) + assert "The command health check of crm-backend failed" in detail + assert MARKER not in detail + assert "receipt" not in result + + +def test_s009_10_health_reports_ok_when_the_check_exits_0( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + result = _health(tmp_path, capsys, exit_code=0) + + assert result["status"] == "ok" + assert result["healthy"] is True + assert "detail" not in result + assert "receipt" not in result diff --git a/engine/tests/test_deploy_role.py b/engine/tests/test_deploy_role.py index 722684a..cc6e4a8 100644 --- a/engine/tests/test_deploy_role.py +++ b/engine/tests/test_deploy_role.py @@ -496,35 +496,10 @@ def test_s009_2_a_deploy_no_longer_refuses_a_command_check() -> None: @pytest.mark.parametrize("entry_point", ["restart.yml", "rollback.yml", "health.yml"]) -def test_the_engine_refuses_a_command_check_it_does_not_run_yet( - entry_point: str, -) -> None: - """Until #166 runs it there, a command check fails first, never passes unrun.""" +def test_s009_10_no_entry_point_refuses_a_command_check(entry_point: str) -> None: 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#166" in message + assert REFUSE_COMMAND not in [str(task["name"]) for task in tasks] @pytest.mark.parametrize("health_check", [HTTP_CHECK, NO_CHECK, COMMAND_CHECK]) diff --git a/sdk/README.md b/sdk/README.md index 94f1a2b..7fb644a 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -82,7 +82,9 @@ 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 -component's declared health check and `1` otherwise. +component's declared health check and `1` otherwise. A `type: command` check +runs as on `deploy`, `restart` and `rollback`, and its output stays in the +server's journal; no command writes a release receipt but `deploy`. `cloudfall import render` maps a Render blueprint onto schema-validated state fragments plus environment files outside state, and returns a structured gap From 053cc2a1d8c4fd91e850c2bfcb365239fba51e55 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:29:08 +0200 Subject: [PATCH 2/2] Drop the stale refusal notes from the S-009 changelog fragments The #161 and #164 fragments are still unreleased and said restart, rollback and health refuse a command check, which #166 ends; they would contradict its fragment in the same release notes. Co-Authored-By: Claude Opus 5.5 --- changelog.d/161-command-health-check-schema.md | 2 +- changelog.d/164-command-health-gate.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/161-command-health-check-schema.md b/changelog.d/161-command-health-check-schema.md index a31cc82..5abca47 100644 --- a/changelog.d/161-command-health-check-schema.md +++ b/changelog.d/161-command-health-check-schema.md @@ -1,3 +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) +- 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` (#161) diff --git a/changelog.d/164-command-health-gate.md b/changelog.d/164-command-health-gate.md index f86286e..9813ceb 100644 --- a/changelog.d/164-command-health-gate.md +++ b/changelog.d/164-command-health-gate.md @@ -1,3 +1,3 @@ ### Added -- Deploy: a component's `healthCheck` of `type: command` now gates `deploy --yes`. After the switch and the restart it runs on each server in a transient systemd unit `cloudfall-health-`, as the application's user, in `/current`, with the service's env file and `timeoutSeconds` as its runtime limit, up to `attempts` times 3 seconds apart. When every attempt fails or times out, the previous release is restored (a first release is stopped), the deploy fails with `ENGINE_STEP_FAILED` naming the check and its exit code or timeout, and the release receipt is written with `healthCheckPassed: false`. Receipts of a command check carry `healthCheck` (command, attempts, exit code, timed out), never the command's output, which stays in the server's journal. The release receipt schema accepts `healthCheckPassed: false` only with a failed command `healthCheck`, and every receipt written before. `restart`, `rollback` and `health` still refuse a command check until #166 (#164) +- Deploy: a component's `healthCheck` of `type: command` now gates `deploy --yes`. After the switch and the restart it runs on each server in a transient systemd unit `cloudfall-health-`, as the application's user, in `/current`, with the service's env file and `timeoutSeconds` as its runtime limit, up to `attempts` times 3 seconds apart. When every attempt fails or times out, the previous release is restored (a first release is stopped), the deploy fails with `ENGINE_STEP_FAILED` naming the check and its exit code or timeout, and the release receipt is written with `healthCheckPassed: false`. Receipts of a command check carry `healthCheck` (command, attempts, exit code, timed out), never the command's output, which stays in the server's journal. The release receipt schema accepts `healthCheckPassed: false` only with a failed command `healthCheck`, and every receipt written before (#164)