From 675d9bdd53f8272fcb321b1914b34c3b8a536693 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:22:50 +0200 Subject: [PATCH 1/2] Run a component's release command once per deploy A component can declare release.command (argv, optional workingDirectory, timeoutSeconds 1..1800, default 300). The deploy runs it on the first server of deployment.servers, after the release's locked dependencies are materialized and before the unit file changes or current moves, in a transient systemd unit as the application user with the unit's own EnvironmentFile= line, so migrations no longer run by hand after the gate. deploy.yml now deploys that release server first, in its own play, so the command runs once and before any server switches; a non-zero exit or a timeout fails that play and the playbook stops before any other server. Every other server asserts the command passed before it switches, and every receipt of the deploy carries the release server's releaseCommand. A component without release runs no new task and writes the same receipt. Fixes #119 Co-Authored-By: Claude Opus 5.5 --- changelog.d/119-release-command.md | 3 + config/schemas/v1/component.schema.json | 28 + config/schemas/v1/release-receipt.schema.json | 38 ++ engine/README.md | 15 +- engine/ansible/playbooks/deploy.yml | 66 +- .../roles/cloudfall_deploy/defaults/main.yml | 7 + .../cloudfall_deploy/meta/argument_specs.yml | 24 + .../roles/cloudfall_deploy/tasks/main.yml | 37 +- .../tasks/release_command.yml | 86 +++ .../src/cloudfall_engine/ansible_inventory.py | 11 +- engine/tests/test_ansible_inventory.py | 32 + engine/tests/test_release_command.py | 619 ++++++++++++++++++ sdk/src/cloudfall/domain.py | 83 +++ sdk/src/cloudfall/inventory.py | 49 ++ sdk/tests/test_inventory.py | 53 ++ sdk/tests/test_validation.py | 67 ++ 16 files changed, 1206 insertions(+), 12 deletions(-) create mode 100644 changelog.d/119-release-command.md create mode 100644 engine/ansible/roles/cloudfall_deploy/tasks/release_command.yml create mode 100644 engine/tests/test_release_command.py diff --git a/changelog.d/119-release-command.md b/changelog.d/119-release-command.md new file mode 100644 index 0000000..c13d977 --- /dev/null +++ b/changelog.d/119-release-command.md @@ -0,0 +1,3 @@ +### Added + +- Components: a `release.command` (with optional `workingDirectory` and `timeoutSeconds`, 1 to 1800, default 300) runs once per deploy on the first server of `deployment.servers`, which now deploys first, after the release's dependencies are materialized and before the unit file changes or `current` moves; it runs as the application's user with the component's env file loaded under systemd's `EnvironmentFile=` rules. A non-zero exit or a timeout fails the deploy with `ENGINE_STEP_FAILED` before any server switches, and a passing run is recorded as `releaseCommand` (server, command, exit code, last 2000 characters of output) in every release receipt of that deploy (#119) diff --git a/config/schemas/v1/component.schema.json b/config/schemas/v1/component.schema.json index b419738..65d6fbe 100644 --- a/config/schemas/v1/component.schema.json +++ b/config/schemas/v1/component.schema.json @@ -124,6 +124,34 @@ ], "additionalProperties": false }, + "release": { + "type": "object", + "properties": { + "command": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "workingDirectory": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\u0000-\\u001f\\u007f]+$" + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 1800, + "default": 300 + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, "healthCheck": { "oneOf": [ { diff --git a/config/schemas/v1/release-receipt.schema.json b/config/schemas/v1/release-receipt.schema.json index 3d3489b..308150c 100644 --- a/config/schemas/v1/release-receipt.schema.json +++ b/config/schemas/v1/release-receipt.schema.json @@ -42,6 +42,44 @@ }, "healthCheckPassed": { "const": true + }, + "releaseCommand": { + "type": "object", + "properties": { + "server": { + "$ref": "common.schema.json#/$defs/resourceId" + }, + "command": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "workingDirectory": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "exitCode": { + "const": 0 + }, + "outputTail": { + "type": "string", + "maxLength": 2000 + } + }, + "required": [ + "server", + "command", + "workingDirectory", + "exitCode", + "outputTail" + ], + "additionalProperties": false } }, "required": [ diff --git a/engine/README.md b/engine/README.md index c548657..562026c 100644 --- a/engine/README.md +++ b/engine/README.md @@ -182,7 +182,17 @@ task health COMPONENT=crm-backend These tasks call the SDK's lifecycle commands, which verify the artifact and then execute the engine's `deploy.yml`, `rollback.yml`, `restart.yml`, and -`health.yml` playbook contracts. Rollback activates an already-retained +`health.yml` playbook contracts. + +A component's `release.command` (database migrations, say) runs once per +deploy, on the first server of `deployment.servers`, which deploys before the +others. It runs in a transient systemd unit as the application's user, in the +new release directory (or its `workingDirectory`), with the component's +`shared/env/.env` loaded by the unit's own `EnvironmentFile=` +rules, and is stopped after `timeoutSeconds`. A failure or a timeout fails the +deploy before any server's `current` moves; a success is recorded under +`releaseCommand` in every release receipt of that deploy. The command must be +safe while the old release still serves traffic. Rollback activates an already-retained release without re-transferring an artifact and requires the declared health check to pass; it deliberately does not auto-restore on failure, because a failed rollback is an incident, not a deployment. @@ -191,7 +201,8 @@ The deploy role transfers the artifact and refuses it if the digest does not match the build metadata, unpacks it under `releases//`, materializes locked dependencies with a pinned `uv` (`uv sync --frozen --no-dev`, with the managed Python toolchain and cache under the component's -`shared/` directory), optionally installs an environment file, renders the +`shared/` directory), optionally installs an environment file, runs the +component's `release.command` when it declares one, renders the systemd unit from the declared service command, and then activates behind a health gate: symlink switch, restart, and the declared HTTP health check. If the health check fails, the previous release is restored and restarted diff --git a/engine/ansible/playbooks/deploy.yml b/engine/ansible/playbooks/deploy.yml index af0a684..a2b4bbd 100644 --- a/engine/ansible/playbooks/deploy.yml +++ b/engine/ansible/playbooks/deploy.yml @@ -1,11 +1,14 @@ --- -- name: Deploy one Cloudfall component release - hosts: cloudfall_servers - become: true - gather_facts: true - any_errors_fatal: true - serial: 1 - pre_tasks: +# A component that declares a release command runs it once, on its release +# server (the first entry of deployment.servers). That server deploys first, +# in its own play, so the command has run before any server's current release +# moves, and a failed command stops the playbook before the other plays start. +- name: Select the release server of the deployed component + hosts: localhost + connection: local + become: false + gather_facts: false + tasks: - name: Require an explicit component and release selection ansible.builtin.assert: that: @@ -17,7 +20,54 @@ Deployment requires cloudfall_deploy_component_id, cloudfall_deploy_release, cloudfall_deploy_artifact_archive, and cloudfall_deploy_artifact_sha256 extra variables - run_once: true + + - name: Deploy the release server first + ansible.builtin.add_host: + name: "{{ cloudfall_release_server_item }}" + groups: cloudfall_release_servers + loop: >- + {{ + groups['cloudfall_servers'] | default([]) + | map('extract', hostvars, 'cloudfall_components') + | flatten + | selectattr('id', 'equalto', cloudfall_deploy_component_id) + | selectattr('release', 'defined') + | map(attribute='release') + | map(attribute='server') + | unique + | list + }} + loop_control: + loop_var: cloudfall_release_server_item + +- name: Deploy one Cloudfall component release on its release server + hosts: cloudfall_release_servers + become: true + gather_facts: true + any_errors_fatal: true + serial: 1 + tasks: + - name: Deploy the selected component and run its release command + ansible.builtin.include_role: + name: cloudfall_deploy + vars: + cloudfall_deploy_component: "{{ cloudfall_component_item }}" + loop: >- + {{ + cloudfall_components | default([]) + | selectattr('id', 'equalto', cloudfall_deploy_component_id) + | list + }} + loop_control: + loop_var: cloudfall_component_item + label: "{{ cloudfall_component_item.id }}" + +- name: Deploy one Cloudfall component release + hosts: cloudfall_servers:!cloudfall_release_servers + become: true + gather_facts: true + any_errors_fatal: true + serial: 1 tasks: - name: Deploy the selected component on its declared servers ansible.builtin.include_role: diff --git a/engine/ansible/roles/cloudfall_deploy/defaults/main.yml b/engine/ansible/roles/cloudfall_deploy/defaults/main.yml index 155d83f..6e070a2 100644 --- a/engine/ansible/roles/cloudfall_deploy/defaults/main.yml +++ b/engine/ansible/roles/cloudfall_deploy/defaults/main.yml @@ -4,6 +4,13 @@ cloudfall_deploy_receipt_directory: "" # A component without an HTTP health check passes its gate when its unit is # active right after the restart and still active this many seconds later. cloudfall_deploy_unit_settle_seconds: 10 +# A release command runs in a transient unit started by this binary. +cloudfall_deploy_systemd_run: /usr/bin/systemd-run +# The receipt keeps this much of the release command's output; a failure +# message keeps less, so it stays inside the end of the Ansible log the +# controller reports. +cloudfall_deploy_release_output_characters: 2000 +cloudfall_deploy_release_failure_characters: 500 cloudfall_deploy_uv_version: "0.12.10" cloudfall_deploy_uv_sha256: >- 173d95a0c32d18c896c46ba6fafbf3cf9c14ab74b033f81b76c883ef492a976b diff --git a/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml b/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml index b3bed2d..27b90fd 100644 --- a/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +++ b/engine/ansible/roles/cloudfall_deploy/meta/argument_specs.yml @@ -80,6 +80,21 @@ argument_specs: retainUntilCleanup: type: bool required: true + release: + type: dict + options: + command: + type: list + elements: str + required: true + workingDirectory: + type: str + timeoutSeconds: + type: int + required: true + server: + type: str + required: true cloudfall_deploy_release: type: str required: true @@ -98,6 +113,15 @@ argument_specs: cloudfall_deploy_unit_settle_seconds: type: int default: 10 + cloudfall_deploy_systemd_run: + type: str + default: /usr/bin/systemd-run + cloudfall_deploy_release_output_characters: + type: int + default: 2000 + cloudfall_deploy_release_failure_characters: + type: int + default: 500 cloudfall_deploy_uv_version: type: str default: "0.12.10" diff --git a/engine/ansible/roles/cloudfall_deploy/tasks/main.yml b/engine/ansible/roles/cloudfall_deploy/tasks/main.yml index 5469efb..a12a33f 100644 --- a/engine/ansible/roles/cloudfall_deploy/tasks/main.yml +++ b/engine/ansible/roles/cloudfall_deploy/tasks/main.yml @@ -143,6 +143,33 @@ UV_PYTHON_INSTALL_DIR: "{{ cloudfall_deploy_shared_directory }}/uv/python" changed_when: true +- name: Run the release command on the release server + ansible.builtin.include_tasks: release_command.yml + when: + - not ansible_check_mode + - cloudfall_deploy_component.release is defined + - cloudfall_deploy_component.release.server == cloudfall_server_id + +- name: Require the release command to have passed before switching + ansible.builtin.assert: + that: + - cloudfall_deploy_release_evidence is mapping + - cloudfall_deploy_release_evidence.exitCode == 0 + fail_msg: >- + The release command of {{ cloudfall_deploy_component.id }} has not run + on its release server {{ cloudfall_deploy_component.release.server }} + in this deploy, so {{ cloudfall_server_id }} does not switch + vars: + cloudfall_deploy_release_evidence: >- + {{ + hostvars[cloudfall_deploy_component.release.server] + .cloudfall_deploy_release_command_result + | default(none) + }} + when: + - not ansible_check_mode + - cloudfall_deploy_component.release is defined + - name: Inspect the currently active release ansible.builtin.stat: path: "{{ cloudfall_deploy_current_link }}" @@ -349,7 +376,15 @@ else none ), 'healthCheckPassed': true, - }, + } | combine( + { + 'releaseCommand': hostvars[ + cloudfall_deploy_component.release.server + ].cloudfall_deploy_release_command_result, + } + if cloudfall_deploy_component.release is defined + else {} + ), } | to_nice_json }} dest: >- diff --git a/engine/ansible/roles/cloudfall_deploy/tasks/release_command.yml b/engine/ansible/roles/cloudfall_deploy/tasks/release_command.yml new file mode 100644 index 0000000..76ecf79 --- /dev/null +++ b/engine/ansible/roles/cloudfall_deploy/tasks/release_command.yml @@ -0,0 +1,86 @@ +--- +# Runs the component's release command once against the unpacked release, +# before the unit file changes and before current moves. A transient systemd +# unit runs it, so the application user, the working directory, and the +# EnvironmentFile= parsing are the ones the service unit gets. +- name: Compute the release command invocation + ansible.builtin.set_fact: + cloudfall_deploy_release_working_directory: >- + {{ cloudfall_deploy_release_directory }}{{ + '/' ~ cloudfall_deploy_component.release.workingDirectory + if cloudfall_deploy_component.release.workingDirectory is defined + else '' + }} + cloudfall_deploy_release_executable: >- + {{ + cloudfall_deploy_component.release.command[0] + if cloudfall_deploy_component.release.command[0].startswith('/') + else cloudfall_deploy_release_directory ~ '/' + ~ cloudfall_deploy_component.release.command[0] + }} + cloudfall_deploy_release_timeout: >- + {{ cloudfall_deploy_component.release.timeoutSeconds }} + +- name: Run the release command against the new release + ansible.builtin.command: + argv: >- + {{ + [ + cloudfall_deploy_systemd_run, + '--unit=cloudfall-release-' ~ cloudfall_deploy_component.id, + '--wait', + '--pipe', + '--collect', + '--service-type=exec', + '--uid=' ~ cloudfall_deploy_component.application_user, + '--gid=' ~ cloudfall_deploy_component.application_user, + '--working-directory=' ~ cloudfall_deploy_release_working_directory, + '--property=EnvironmentFile=-' ~ cloudfall_deploy_shared_directory + ~ '/env/' ~ cloudfall_deploy_component.id ~ '.env', + '--property=RuntimeMaxSec=' ~ cloudfall_deploy_release_timeout, + '--property=TimeoutStopSec=10', + '--property=NoNewPrivileges=true', + '--property=PrivateTmp=true', + '--', + '/bin/sh', + '-c', + 'exec "$@" 2>&1', + 'cloudfall-release', + cloudfall_deploy_release_executable, + ] + + cloudfall_deploy_component.release.command[1:] + }} + register: cloudfall_deploy_release_run + changed_when: true + failed_when: false + +- name: Record the release command run + ansible.builtin.set_fact: + cloudfall_deploy_release_command_result: + server: "{{ cloudfall_server_id }}" + command: "{{ cloudfall_deploy_component.release.command }}" + workingDirectory: >- + {{ cloudfall_deploy_component.release.workingDirectory | default(none) }} + exitCode: "{{ cloudfall_deploy_release_run.rc }}" + outputTail: >- + {{ cloudfall_deploy_release_run.stdout[-cloudfall_deploy_release_output_characters:] }} + cloudfall_deploy_release_timed_out: >- + {{ 'Finished with result: timeout' in cloudfall_deploy_release_run.stderr }} + +- name: Fail the deployment when the release command failed + ansible.builtin.fail: + msg: >- + Release command {{ cloudfall_deploy_component.release.command | join(' ') }} + {{ + 'timed out after ' ~ cloudfall_deploy_release_timeout + ~ ' seconds and was stopped' + if cloudfall_deploy_release_timed_out + else 'exited with code ' ~ cloudfall_deploy_release_run.rc + }} + on {{ cloudfall_server_id }}; no server switched to release + {{ cloudfall_deploy_release }}. Output ends: {{ + cloudfall_deploy_release_run.stdout[-cloudfall_deploy_release_failure_characters:] + }} + when: >- + cloudfall_deploy_release_timed_out + or cloudfall_deploy_release_run.rc != 0 diff --git a/engine/src/cloudfall_engine/ansible_inventory.py b/engine/src/cloudfall_engine/ansible_inventory.py index 909e3c4..0ceee80 100644 --- a/engine/src/cloudfall_engine/ansible_inventory.py +++ b/engine/src/cloudfall_engine/ansible_inventory.py @@ -84,6 +84,9 @@ def render_ansible_inventory(inventory: PlatformInventory) -> dict[str, object]: "cloudfall_logging_backends": {"hosts": logging_backend_hosts}, "cloudfall_logging_collectors": {"hosts": logging_collector_hosts}, "cloudfall_domain_origins": {"hosts": domain_origin_hosts}, + # deploy.yml adds the deployed component's release server here, + # so the server that runs its release command deploys first. + "cloudfall_release_servers": {"hosts": {}}, } } } @@ -219,7 +222,7 @@ def _host_application(application: ApplicationInventory) -> dict[str, object]: def _host_component( component: ComponentInventory, application: ApplicationInventory ) -> dict[str, object]: - return { + variables: dict[str, object] = { "id": component.resource_id.value, "application": component.application_id.value, "application_user": application.linux_user.value, @@ -230,6 +233,12 @@ def _host_component( "healthCheck": component.health_check.as_dict(), "retainUntilCleanup": component.retain_until_cleanup, } + if component.release is not None: + variables["release"] = { + **component.release.as_dict(), + "server": component.release_server_id.value, + } + return variables def _application_is_on_server( diff --git a/engine/tests/test_ansible_inventory.py b/engine/tests/test_ansible_inventory.py index 2d80c9f..722a544 100644 --- a/engine/tests/test_ansible_inventory.py +++ b/engine/tests/test_ansible_inventory.py @@ -277,6 +277,38 @@ def test_inventory_renders_environment_application_and_component_groups() -> Non assert logging_collectors == {"hosts": {"h1": {}, "h2": {}}} assert domain_origins == {"hosts": {"h1": {}}} assert children["ungrouped"] == {"hosts": {}} + assert children["cloudfall_release_servers"] == {"hosts": {}} + + +def test_s005_6_a_release_renders_with_its_release_server(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") + text = text.replace(" - h1\n - h2\n", " - h2\n - h1\n") + text = text.replace( + " healthCheck:\n", + " release:\n command: [.venv/bin/alembic, upgrade, head]\n" + " workingDirectory: services/api\n timeoutSeconds: 60\n" + " healthCheck:\n", + ) + path.write_text(text, 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]["release"] == { + "command": [".venv/bin/alembic", "upgrade", "head"], + "workingDirectory": "services/api", + "timeoutSeconds": 60, + "server": "h2", + } def test_engine_cli_emits_ansible_json( diff --git a/engine/tests/test_release_command.py b/engine/tests/test_release_command.py new file mode 100644 index 0000000..afcf7ba --- /dev/null +++ b/engine/tests/test_release_command.py @@ -0,0 +1,619 @@ +"""A component's release command, run by the deploy role before it switches. + +The role needs a Debian host with systemd, so these tests take three routes +that run without one. The release command tasks run for real under +``ansible-playbook`` on the controller, with ``systemd-run`` swapped through the +role's ``cloudfall_deploy_systemd_run`` variable for a stand-in that honours +``--working-directory`` and runs the argv after ``--``. ``deploy.yml`` runs for +real against two local hosts with the deploy role replaced, through +``--roles``, by one that records what each host did. The rest replays the +role's own conditions and receipt template with Ansible's templating. +""" + +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path +from typing import cast + +import pytest +import yaml +from ansible.parsing.dataloader import DataLoader +from ansible.template import Templar, trust_as_template +from cloudfall.inventory import PlatformInventory +from cloudfall.validation import SchemaCatalog, validate_config +from cloudfall_engine.ansible_inventory import render_ansible_inventory +from cloudfall_engine.cli import main +from jsonschema.exceptions import ValidationError + +ROOT = Path(__file__).parents[2] +SCHEMAS = ROOT / "config" / "schemas" / "v1" +EXAMPLES = ROOT / "config" / "examples" +ENGINE = ROOT / "engine" +ROLE = ENGINE / "ansible" / "roles" / "cloudfall_deploy" +MATERIALIZE = "Materialize locked dependencies for the release" +RUN = "Run the release command on the release server" +REQUIRE = "Require the release command to have passed before switching" +INSTALL_UNIT = "Install the managed service unit" +ACTIVATE = "Activate the release behind the health gate" +SWITCH = "Switch the current release symlink" +RECEIPT = "Write the release receipt on the controller" +TIMEOUT_MARKER = "cloudfall-test-timeout" + +FAKE_SYSTEMD_RUN = f"""#!/bin/sh +# Stand-in for systemd-run --wait --pipe: chdir like --working-directory, +# then run the argv after "--" and pass on its exit code. +printf '%s\\n' "$@" > "$0.argv" +directory= +while [ "$1" != "--" ]; do + case "$1" in + --working-directory=*) directory="${{1#--working-directory=}}" ;; + esac + shift +done +shift +cd "$directory" || exit 200 +"$@" +status=$? +if [ -f "$directory/{TIMEOUT_MARKER}" ]; then + echo "Finished with result: timeout" >&2 + exit 1 +fi +exit $status +""" + +MIGRATE = """#!/bin/sh +# Creates the "table", reports where and as what it ran, then exits as asked. +echo "created" > "$PWD/table" +echo "cwd=$PWD" +echo "to-stderr" >&2 +if [ -n "$2" ]; then + i=0 + while [ "$i" -lt "$2" ]; do printf 'x'; i=$((i + 1)); done + echo +fi +echo "last-line" +exit "$1" +""" + + +def _component( + release: dict[str, object] | None, *, server: str = "b" +) -> dict[str, object]: + component: dict[str, object] = { + "id": "crm-backend", + "application_user": "crm", + "install_root": "/srv/apps/crm/backend", + "service": {"name": "crm-backend", "command": [".venv/bin/app"]}, + "healthCheck": {"type": "none"}, + } + if release is not None: + component["release"] = {"timeoutSeconds": 300, "server": server, **release} + return component + + +def _release_tree(tmp_path: Path) -> tuple[Path, Path, Path]: + release_directory = tmp_path / "releases" / "20261027T000000Z-abcdef1" + working_directory = release_directory / "services" / "api" + working_directory.mkdir(parents=True) + migrate = release_directory / "bin" / "migrate" + migrate.parent.mkdir() + migrate.write_text(MIGRATE, encoding="utf-8") + migrate.chmod(0o755) + systemd_run = tmp_path / "systemd-run" + systemd_run.write_text(FAKE_SYSTEMD_RUN, encoding="utf-8") + systemd_run.chmod(0o755) + return release_directory, working_directory, systemd_run + + +def _local_inventory(tmp_path: Path) -> Path: + inventory = tmp_path / "inventory.json" + inventory.write_text( + json.dumps( + { + "all": { + "hosts": { + "localhost": { + "ansible_connection": "local", + "ansible_python_interpreter": sys.executable, + } + } + } + } + ), + encoding="utf-8", + ) + return inventory + + +def _run_release_command( + tmp_path: Path, arguments: list[str], *, timed_out: bool = False +) -> tuple[int, Path]: + """Run the role's release command tasks; answer the exit code and result.""" + release_directory, working_directory, systemd_run = _release_tree(tmp_path) + if timed_out: + (working_directory / TIMEOUT_MARKER).write_text("", encoding="utf-8") + result_file = tmp_path / "result.json" + variables = { + "cloudfall_server_id": "b", + "cloudfall_deploy_release": release_directory.name, + "cloudfall_deploy_release_directory": str(release_directory), + "cloudfall_deploy_shared_directory": str(tmp_path / "shared"), + "cloudfall_deploy_systemd_run": str(systemd_run), + "cloudfall_deploy_component": _component( + { + "command": ["bin/migrate", *arguments], + "workingDirectory": "services/api", + } + ), + } + playbook = tmp_path / "release.yml" + playbook.write_text( + yaml.safe_dump( + [ + { + "name": "Run a release command", + "hosts": "localhost", + "gather_facts": False, + "vars": variables, + "tasks": [ + { + "name": "Run the role's release command tasks", + "ansible.builtin.include_role": { + "name": "cloudfall_deploy", + "tasks_from": "release_command", + }, + }, + { + "name": "Keep the recorded result", + "ansible.builtin.copy": { + "content": ( + "{{ cloudfall_deploy_release_command_result" + " | to_json }}" + ), + "dest": str(result_file), + "mode": "0644", + }, + }, + ], + } + ] + ), + encoding="utf-8", + ) + exit_code = main( + [ + "playbook", + "run", + str(playbook), + "--engine", + str(ENGINE), + "--inventory", + str(_local_inventory(tmp_path)), + ] + ) + return exit_code, result_file + + +def _argv(tmp_path: Path) -> list[str]: + return (tmp_path / "systemd-run.argv").read_text(encoding="utf-8").splitlines() + + +def test_s005_1_a_passing_release_command_records_its_run(tmp_path: Path) -> None: + """The command's effect stays and its result names it, exit 0 and output.""" + exit_code, result_file = _run_release_command(tmp_path, ["0"]) + + assert exit_code == 0 + working_directory = tmp_path / "releases" / "20261027T000000Z-abcdef1" + assert (working_directory / "services" / "api" / "table").is_file() + result = json.loads(result_file.read_text(encoding="utf-8")) + assert result["server"] == "b" + assert result["command"] == ["bin/migrate", "0"] + assert result["workingDirectory"] == "services/api" + assert result["exitCode"] == 0 + assert "last-line" in result["outputTail"] + + +def test_s005_2_the_command_runs_in_its_working_directory_as_the_user( + tmp_path: Path, +) -> None: + """A relative executable resolves against the release, not the work dir.""" + exit_code, result_file = _run_release_command(tmp_path, ["0"]) + + assert exit_code == 0 + release_directory = tmp_path / "releases" / "20261027T000000Z-abcdef1" + argv = _argv(tmp_path) + assert f"--working-directory={release_directory}/services/api" in argv + assert "--uid=crm" in argv + assert "--gid=crm" in argv + after = argv[argv.index("--") + 1 :] + assert after == [ + "/bin/sh", + "-c", + 'exec "$@" 2>&1', + "cloudfall-release", + f"{release_directory}/bin/migrate", + "0", + ] + result = json.loads(result_file.read_text(encoding="utf-8")) + assert f"cwd={release_directory}/services/api" in result["outputTail"] + + +def test_s005_3_the_command_loads_the_unit_environment_file( + tmp_path: Path, +) -> None: + """The transient unit gets the ``EnvironmentFile=-`` line the unit names.""" + exit_code, _ = _run_release_command(tmp_path, ["0"]) + + assert exit_code == 0 + unit = Templar( + loader=DataLoader(), + variables={ + "cloudfall_deploy_component": { + **_component(None), + "install_root": str(tmp_path), + } + }, + ).template( + trust_as_template( + (ROLE / "templates" / "component.service.j2").read_text(encoding="utf-8") + ) + ) + unit_line = next( + line for line in str(unit).splitlines() if line.startswith("EnvironmentFile=") + ) + assert f"--property={unit_line}" in _argv(tmp_path) + + +def test_s005_1_stderr_is_part_of_the_recorded_output(tmp_path: Path) -> None: + _, result_file = _run_release_command(tmp_path, ["0"]) + + result = json.loads(result_file.read_text(encoding="utf-8")) + assert "to-stderr" in result["outputTail"] + + +def test_s005_4_a_failing_release_command_fails_the_play_naming_its_exit( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + exit_code, result_file = _run_release_command(tmp_path, ["1"]) + + assert exit_code == 82 + output = _captured_output(capsys) + assert "Release command bin/migrate 1 exited with code 1" in output + assert "no server switched" in output + assert "last-line" in output + assert not result_file.exists() + + +def test_s005_5_a_timed_out_release_command_fails_naming_the_timeout( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + exit_code, _ = _run_release_command(tmp_path, ["0"], timed_out=True) + + assert exit_code == 82 + output = _captured_output(capsys) + assert "timed out after 300 seconds and was stopped" in output + + +def test_s005_5_the_timeout_is_the_unit_runtime_limit(tmp_path: Path) -> None: + _run_release_command(tmp_path, ["0"]) + + argv = _argv(tmp_path) + assert "--property=RuntimeMaxSec=300" in argv + assert "--wait" in argv + assert "--quiet" not in argv + + +def test_s005_7_the_output_tail_keeps_the_last_2000_characters( + tmp_path: Path, +) -> None: + exit_code, result_file = _run_release_command(tmp_path, ["0", "5000"]) + + assert exit_code == 0 + tail = json.loads(result_file.read_text(encoding="utf-8"))["outputTail"] + assert len(tail) == 2000 + assert tail.endswith("last-line") + + +def _captured_output(capsys: pytest.CaptureFixture[str]) -> str: + captured = capsys.readouterr() + return f"{captured.out}\n{captured.err}" + + +# Role order and conditions. + + +def _main_tasks() -> list[dict[str, object]]: + tasks = DataLoader().load_from_file(str(ROLE / "tasks" / "main.yml")) + assert isinstance(tasks, list) + return tasks + + +def _names(tasks: list[dict[str, object]]) -> list[str]: + names: list[str] = [] + for task in tasks: + names.append(str(task["name"])) + block = task.get("block") + if isinstance(block, list): + names.extend(str(inner["name"]) for inner in block) + return names + + +def _holds(expression: object, variables: dict[str, object]) -> bool: + templar = Templar(loader=DataLoader(), variables=variables) + return bool(templar.evaluate_conditional(trust_as_template(str(expression)))) + + +def _runs(name: str, variables: dict[str, object]) -> bool: + task = next(t for t in _main_tasks() if t["name"] == name) + conditions = task.get("when", []) + assert isinstance(conditions, list) + return all(_holds(c, variables) for c in conditions) + + +def test_s005_2_the_release_command_runs_between_materialize_and_the_switch() -> None: + names = _names(_main_tasks()) + + assert names.index(MATERIALIZE) < names.index(RUN) + assert names.index(RUN) < names.index(REQUIRE) + assert names.index(REQUIRE) < names.index(INSTALL_UNIT) + assert names.index(INSTALL_UNIT) < names.index(SWITCH) + run = next(t for t in _main_tasks() if t["name"] == RUN) + assert run["ansible.builtin.include_tasks"] == "release_command.yml" + + +def _variables( + component: dict[str, object], server: str, *, check_mode: bool = False +) -> dict[str, object]: + return { + "ansible_check_mode": check_mode, + "cloudfall_server_id": server, + "cloudfall_deploy_component": component, + } + + +def test_s005_6_only_the_release_server_runs_the_command() -> None: + component = _component({"command": ["bin/migrate"]}, server="b") + + assert _runs(RUN, _variables(component, "b")) + assert not _runs(RUN, _variables(component, "a")) + assert _runs(REQUIRE, _variables(component, "a")) + assert _runs(REQUIRE, _variables(component, "b")) + + +def test_s005_10_a_component_without_release_runs_no_new_task() -> None: + component = _component(None) + + for server in ("a", "b"): + assert not _runs(RUN, _variables(component, server)) + assert not _runs(REQUIRE, _variables(component, server)) + + +def test_s005_6_a_server_whose_release_server_has_not_passed_does_not_switch() -> None: + task = next(t for t in _main_tasks() if t["name"] == REQUIRE) + assertion = task["ansible.builtin.assert"] + assert isinstance(assertion, dict) + task_vars = task["vars"] + assert isinstance(task_vars, dict) + + def passes(hostvars: dict[str, object]) -> bool: + variables: dict[str, object] = { + "hostvars": hostvars, + "cloudfall_deploy_component": _component( + {"command": ["bin/migrate"]}, server="b" + ), + } + evidence = Templar(loader=DataLoader(), variables=variables).template( + trust_as_template(task_vars["cloudfall_deploy_release_evidence"]) + ) + variables["cloudfall_deploy_release_evidence"] = evidence + return all(_holds(c, variables) for c in assertion["that"]) + + assert passes({"b": {"cloudfall_deploy_release_command_result": {"exitCode": 0}}}) + assert not passes({"b": {}}) + assert not passes( + {"b": {"cloudfall_deploy_release_command_result": {"exitCode": 1}}} + ) + + +# The release receipt. + + +def _receipt( + component: dict[str, object], server: str, hostvars: dict[str, object] +) -> dict[str, object]: + task = next(t for t in _main_tasks() if t["name"] == RECEIPT) + copy = task["ansible.builtin.copy"] + assert isinstance(copy, dict) + variables: dict[str, object] = { + "hostvars": hostvars, + "cloudfall_server_id": server, + "cloudfall_deploy_component": component, + "cloudfall_deploy_release": "20261027T000000Z-abcdef1", + "cloudfall_deploy_artifact_sha256": "a" * 64, + "cloudfall_deploy_previous_directory": "", + "ansible_facts": {"date_time": {"iso8601": "2026-10-27T00:00:00Z"}}, + } + rendered = Templar(loader=DataLoader(), variables=variables).template( + trust_as_template(copy["content"]) + ) + receipt = cast("dict[str, object]", json.loads(str(rendered))) + _validate_receipt(receipt) + return receipt + + +def _validate_receipt(receipt: dict[str, object]) -> None: + SchemaCatalog(SCHEMAS).validate_named("release-receipt.schema.json", receipt) + + +RESULT_ON_B: dict[str, object] = { + "server": "b", + "command": ["bin/migrate"], + "workingDirectory": None, + "exitCode": 0, + "outputTail": "migrated", +} + + +def test_s005_6_every_receipt_names_the_release_server() -> None: + component = _component({"command": ["bin/migrate"]}, server="b") + hostvars: dict[str, object] = { + "a": {}, + "b": {"cloudfall_deploy_release_command_result": RESULT_ON_B}, + } + + for server in ("b", "a"): + spec = _receipt(component, server, hostvars)["spec"] + assert isinstance(spec, dict) + assert spec["server"] == server + assert spec["releaseCommand"] == RESULT_ON_B + + +def test_s005_10_a_receipt_without_release_has_no_release_command() -> None: + spec = _receipt(_component(None), "a", {"a": {}})["spec"] + + assert isinstance(spec, dict) + assert "releaseCommand" not in spec + assert set(spec) == { + "component", + "server", + "release", + "artifactSha256", + "deployedAt", + "previousRelease", + "healthCheckPassed", + } + + +def test_s005_7_a_receipt_refuses_an_output_tail_over_2000_characters() -> None: + component = _component({"command": ["bin/migrate"]}, server="b") + hostvars: dict[str, object] = { + "b": { + "cloudfall_deploy_release_command_result": { + **RESULT_ON_B, + "outputTail": "x" * 2001, + } + } + } + + with pytest.raises(ValidationError, match="too long"): + _receipt(component, "b", hostvars) + + +# deploy.yml's host order, run for real against two local hosts. + +STUB_ROLE = """--- +- name: Record what this host did + ansible.builtin.lineinfile: + path: "{{ stub_log }}" + line: >- + {{ cloudfall_server_id }}:{{ + 'release-command' + if cloudfall_deploy_component.release is defined + and cloudfall_deploy_component.release.server == cloudfall_server_id + else 'switch' + }} + create: true + mode: "0644" + delegate_to: localhost + +- name: Fail where the test asks + ansible.builtin.fail: + msg: stub failure on {{ cloudfall_server_id }} + when: cloudfall_server_id == stub_fail_on | default('') +""" + + +def _two_host_project(tmp_path: Path, *, release: bool) -> Path: + """Copy the example project with ``deployment.servers: [h2, h1]``.""" + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + path = project / "components" / "crm-backend.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["spec"]["deployment"]["servers"] = ["h2", "h1"] + if release: + document["spec"]["release"] = {"command": ["bin/migrate"]} + path.write_text(yaml.safe_dump(document), encoding="utf-8") + return project + + +def _deploy( + tmp_path: Path, *, release: bool, fail_on: str = "" +) -> tuple[int, list[str]]: + state = validate_config(_two_host_project(tmp_path, release=release), SCHEMAS) + rendered = render_ansible_inventory(PlatformInventory.from_state(state)) + hosts = rendered["all"]["children"]["cloudfall_servers"]["hosts"] # type: ignore[index] + assert list(hosts) == ["h1", "h2"] + for host in hosts.values(): + host.update( + { + "ansible_connection": "local", + "ansible_python_interpreter": sys.executable, + "ansible_become": False, + } + ) + inventory = tmp_path / "inventory.json" + inventory.write_text(json.dumps(rendered), encoding="utf-8") + roles = tmp_path / "roles" + (roles / "cloudfall_deploy" / "tasks").mkdir(parents=True) + (roles / "cloudfall_deploy" / "tasks" / "main.yml").write_text( + STUB_ROLE, encoding="utf-8" + ) + log = tmp_path / "hosts.log" + exit_code = main( + [ + "playbook", + "run", + "deploy", + "--engine", + str(ENGINE), + "--inventory", + str(inventory), + "--roles", + str(roles), + "--extra-vars", + json.dumps( + { + "cloudfall_deploy_component_id": "crm-backend", + "cloudfall_deploy_release": "20261027T000000Z-abcdef1", + "cloudfall_deploy_artifact_archive": "/dev/null", + "cloudfall_deploy_artifact_sha256": "a" * 64, + "stub_log": str(log), + "stub_fail_on": fail_on, + "ansible_become": False, + } + ), + ] + ) + lines = log.read_text(encoding="utf-8").splitlines() if log.exists() else [] + return exit_code, lines + + +def test_s005_6_the_release_server_deploys_first_and_runs_the_command_once( + tmp_path: Path, +) -> None: + """``deployment.servers: [h2, h1]`` against inventory order h1, h2.""" + exit_code, lines = _deploy(tmp_path, release=True) + + assert exit_code == 0 + assert lines == ["h2:release-command", "h1:switch"] + + +def test_s005_4_a_failed_release_server_stops_every_other_server( + tmp_path: Path, +) -> None: + exit_code, lines = _deploy(tmp_path, release=True, fail_on="h2") + + assert exit_code == 82 + assert lines == ["h2:release-command"] + + +def test_s005_10_a_component_without_release_deploys_in_inventory_order( + tmp_path: Path, +) -> None: + exit_code, lines = _deploy(tmp_path, release=False) + + assert exit_code == 0 + assert lines == ["h1:switch", "h2:switch"] diff --git a/sdk/src/cloudfall/domain.py b/sdk/src/cloudfall/domain.py index 50566ac..8a56c9f 100644 --- a/sdk/src/cloudfall/domain.py +++ b/sdk/src/cloudfall/domain.py @@ -708,6 +708,89 @@ def from_boundary(cls, value: object) -> RelativeRepositoryPath: return cls(_required_string(value, "relative repository path")) +@dataclass(frozen=True, slots=True) +class CommandArgv: + """Validated argv of a command run directly, not through a unit file.""" + + value: tuple[str, ...] + + def __post_init__(self) -> None: + """Reject empty commands, empty arguments, and NUL bytes.""" + if not self.value: + message = "command must not be empty" + raise ValueError(message) + for argument in self.value: + if not argument or "\x00" in argument: + message = f"invalid command argument: {argument!r}" + raise ValueError(message) + + @classmethod + def from_boundary(cls, value: object) -> CommandArgv: + """Coerce a boundary value into a command argv.""" + if not isinstance(value, list) or not all( + isinstance(item, str) for item in value + ): + message = "command must be a list of strings" + raise TypeError(message) + return cls(tuple(value)) + + +@dataclass(frozen=True, slots=True) +class RelativeDirectory: + """A relative path that stays inside the directory it is resolved against.""" + + value: str + + def __post_init__(self) -> None: + """Reject absolute paths, ``..`` segments, and control characters.""" + if ( + not self.value + or self.value.startswith("/") + or ".." in self.value.split("/") + or any(not character.isprintable() for character in self.value) + ): + message = f"invalid relative directory: {self.value!r}" + raise ValueError(message) + + @classmethod + def from_boundary(cls, value: object) -> RelativeDirectory: + """Coerce a boundary value into a relative directory.""" + return cls(_required_string(value, "relative directory")) + + +_RELEASE_TIMEOUT_MINIMUM = 1 +_RELEASE_TIMEOUT_MAXIMUM = 1800 +_RELEASE_TIMEOUT_DEFAULT = 300 + + +@dataclass(frozen=True, slots=True) +class ReleaseCommandTimeout: + """How long a release command may run before it is stopped, in seconds.""" + + value: int + + def __post_init__(self) -> None: + """Keep the bound well under the limit on a whole playbook run.""" + if isinstance(self.value, bool) or not ( + _RELEASE_TIMEOUT_MINIMUM <= self.value <= _RELEASE_TIMEOUT_MAXIMUM + ): + message = f"invalid release command timeout: {self.value!r}" + raise ValueError(message) + + @classmethod + def from_boundary(cls, value: object) -> ReleaseCommandTimeout: + """Coerce a boundary value, defaulting an absent one.""" + if value is None: + return cls(_RELEASE_TIMEOUT_DEFAULT) + if isinstance(value, bool) or not isinstance(value, int): + message = ( + f"release command timeout must be an integer, got " + f"{type(value).__name__}" + ) + raise TypeError(message) + return cls(value) + + @dataclass(frozen=True, slots=True) class BuildToolName: """Executable a component build step runs, looked up on ``PATH``.""" diff --git a/sdk/src/cloudfall/inventory.py b/sdk/src/cloudfall/inventory.py index 3dee5ff..557a4da 100644 --- a/sdk/src/cloudfall/inventory.py +++ b/sdk/src/cloudfall/inventory.py @@ -15,6 +15,7 @@ BuildToolName, BuildToolVersion, ByteSize, + CommandArgv, ConfigCapture, ConnectionAddress, DatacenterCode, @@ -49,7 +50,9 @@ PromqlExpression, ProviderServerId, RaidLevel, + RelativeDirectory, RelativeRepositoryPath, + ReleaseCommandTimeout, RepositoryUrl, RequiredServiceState, RequiredServiceStatus, @@ -444,6 +447,23 @@ def as_dict(self) -> dict[str, object]: } +@dataclass(frozen=True, slots=True) +class ComponentRelease: + """Command a deploy runs once against a new release before it goes live.""" + + command: CommandArgv + working_directory: RelativeDirectory | None + timeout_seconds: ReleaseCommandTimeout + + def as_dict(self) -> dict[str, object]: + """Serialize the release command contract.""" + result: dict[str, object] = {"command": list(self.command.value)} + if self.working_directory is not None: + result["workingDirectory"] = self.working_directory.value + result["timeoutSeconds"] = self.timeout_seconds.value + return result + + @dataclass(frozen=True, slots=True) class ComponentHttpHealthCheck: """HTTP endpoint contract for a component health gate.""" @@ -571,6 +591,12 @@ class ComponentInventory: secret_refs: tuple[SecretReference, ...] = () environment: tuple[tuple[str, str], ...] = () build: ComponentBuild | None = None + release: ComponentRelease | None = None + + @property + def release_server_id(self) -> ResourceId: + """The server a release command runs on: the first deployment server.""" + return self.server_ids[0] def as_dict(self) -> dict[str, object]: """Serialize the component for inventory consumers.""" @@ -589,6 +615,8 @@ def as_dict(self) -> dict[str, object]: result["environment"] = dict(self.environment) if self.build is not None: result.update(self.build.as_dict()) + if self.release is not None: + result["release"] = self.release.as_dict() return result @@ -1603,6 +1631,7 @@ def _component_inventory(document: ResourceDocument) -> ComponentInventory: secret_refs=_secret_references(spec.get("secretRefs")), environment=_declared_environment(spec.get("environment")), build=_component_build(spec), + release=_component_release(spec.get("release")), ) @@ -1645,6 +1674,26 @@ def _component_build(spec: Mapping[str, object]) -> ComponentBuild | None: ) +def _component_release(value: object) -> ComponentRelease | None: + if value is None: + return None + if not isinstance(value, dict): + message = "validated component release is not a mapping" + raise TypeError(message) + raw_directory = value.get("workingDirectory") + return ComponentRelease( + command=CommandArgv.from_boundary(value.get("command")), + working_directory=( + None + if raw_directory is None + else RelativeDirectory.from_boundary(raw_directory) + ), + timeout_seconds=ReleaseCommandTimeout.from_boundary( + value.get("timeoutSeconds") + ), + ) + + def _declared_environment(value: object) -> tuple[tuple[str, str], ...]: if value is None: return () diff --git a/sdk/tests/test_inventory.py b/sdk/tests/test_inventory.py index 40c2fa3..fd188b8 100644 --- a/sdk/tests/test_inventory.py +++ b/sdk/tests/test_inventory.py @@ -3,13 +3,17 @@ from __future__ import annotations import json +import shutil from pathlib import Path import pytest from cloudfall.cli import main from cloudfall.domain import ( + CommandArgv, FirewallPolicy, NetworkProtocol, + RelativeDirectory, + ReleaseCommandTimeout, ResourceId, TcpPort, ) @@ -72,6 +76,55 @@ def test_firewall_requirement_rejects_duplicate_rules() -> None: ) +def test_s005_11_release_values_are_refused_outside_their_bounds() -> None: + assert ReleaseCommandTimeout.from_boundary(None).value == 300 + assert ReleaseCommandTimeout.from_boundary(1800).value == 1800 + for timeout in (0, 1801, True): + with pytest.raises((ValueError, TypeError)): + ReleaseCommandTimeout.from_boundary(timeout) + assert RelativeDirectory.from_boundary("services/api").value == "services/api" + for directory in ("/srv/api", "../api", "a/../b", "a/..", "a\nb"): + with pytest.raises(ValueError, match="invalid relative directory"): + RelativeDirectory.from_boundary(directory) + with pytest.raises(TypeError, match="non-empty string"): + RelativeDirectory.from_boundary("") + for argv in ([], [""], ["a\x00b"]): + with pytest.raises(ValueError, match="command"): + CommandArgv.from_boundary(argv) + + +def test_s005_6_the_release_server_is_the_first_deployment_server( + tmp_path: Path, +) -> None: + project = tmp_path / "config" + shutil.copytree(EXAMPLES, project) + path = project / "components" / "crm-backend.yaml" + text = path.read_text(encoding="utf-8") + text = text.replace(" - h1\n - h2\n", " - h2\n - h1\n") + text = text.replace( + " healthCheck:\n", + " release:\n command: [.venv/bin/alembic, upgrade, head]\n" + " healthCheck:\n", + ) + path.write_text(text, encoding="utf-8") + + inventory = PlatformInventory.from_state(validate_config(project, SCHEMAS)) + component = inventory.components[0] + + assert component.release_server_id == ResourceId("h2") + assert component.as_dict()["release"] == { + "command": [".venv/bin/alembic", "upgrade", "head"], + "timeoutSeconds": 300, + } + + +def test_s005_10_a_component_without_release_serializes_as_before() -> None: + component = _inventory().components[0] + + assert component.release is None + assert "release" not in component.as_dict() + + def test_inventory_exposes_typed_placement_queries() -> None: inventory = _inventory() diff --git a/sdk/tests/test_validation.py b/sdk/tests/test_validation.py index fb14c50..42a370d 100644 --- a/sdk/tests/test_validation.py +++ b/sdk/tests/test_validation.py @@ -313,3 +313,70 @@ def test_cli_emits_structured_failure( assert payload["ok"] is False assert payload["error"]["code"] == "CONFIG_INVALID" assert payload["error"]["context"]["code"] == "resource_reference_missing" + + +def _project_with_release(tmp_path: Path, release: object) -> Path: + project_directory = tmp_path / "config" + shutil.copytree(ROOT / "config" / "examples", project_directory) + component_path = project_directory / "components" / "crm-backend.yaml" + component = component_path.read_text(encoding="utf-8").replace( + " healthCheck:\n", + f" release: {json.dumps(release)}\n healthCheck:\n", + ) + component_path.write_text(component, encoding="utf-8") + return project_directory + + +@pytest.mark.parametrize( + "release", + [ + {"command": [".venv/bin/alembic"], "workingDirectory": "/srv/api"}, + {"command": [".venv/bin/alembic"], "workingDirectory": "../api"}, + {"command": [".venv/bin/alembic"], "workingDirectory": "services/../.."}, + {"command": []}, + {"command": [""]}, + {"command": [".venv/bin/alembic"], "timeoutSeconds": 0}, + {"command": [".venv/bin/alembic"], "timeoutSeconds": 1801}, + {"workingDirectory": "services/api"}, + ], +) +def test_s005_11_config_validate_refuses_a_malformed_release( + tmp_path: Path, capsys: pytest.CaptureFixture[str], release: object +) -> None: + project_directory = _project_with_release(tmp_path, release) + + exit_code = main( + [ + "config", + "validate", + "--project", + str(project_directory), + "--schemas", + str(SCHEMAS), + ] + ) + + payload = json.loads(capsys.readouterr().out) + assert exit_code == 80 + assert payload["error"]["code"] == "CONFIG_INVALID" + assert payload["error"]["context"]["code"] == "schema_validation_failed" + + +@pytest.mark.parametrize( + "release", + [ + {"command": [".venv/bin/alembic", "upgrade", "head"]}, + { + "command": [".venv/bin/alembic", "upgrade", "head"], + "workingDirectory": "services/api", + "timeoutSeconds": 1800, + }, + {"command": ["/usr/bin/true"], "timeoutSeconds": 1}, + ], +) +def test_s005_11_config_validate_accepts_a_declared_release( + tmp_path: Path, release: object +) -> None: + state = validate_config(_project_with_release(tmp_path, release), SCHEMAS) + + assert state.counts_by_kind()["Component"] == 1 From 213203df895e270d71f268773b032b83e3fda5a1 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:38:30 +0200 Subject: [PATCH 2/2] Name systemd-run's own error when a release command unit fails A unit that never starts (it already exists, an unknown user) left the failure message with empty command output and no reason. The message now ends with the tail of systemd-run's stderr. Co-Authored-By: Claude Opus 5.5 --- .../tasks/release_command.yml | 2 ++ engine/tests/test_release_command.py | 27 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/engine/ansible/roles/cloudfall_deploy/tasks/release_command.yml b/engine/ansible/roles/cloudfall_deploy/tasks/release_command.yml index 76ecf79..82b429d 100644 --- a/engine/ansible/roles/cloudfall_deploy/tasks/release_command.yml +++ b/engine/ansible/roles/cloudfall_deploy/tasks/release_command.yml @@ -80,6 +80,8 @@ on {{ cloudfall_server_id }}; no server switched to release {{ cloudfall_deploy_release }}. Output ends: {{ cloudfall_deploy_release_run.stdout[-cloudfall_deploy_release_failure_characters:] + }} systemd-run reported: {{ + cloudfall_deploy_release_run.stderr[-cloudfall_deploy_release_failure_characters:] }} when: >- cloudfall_deploy_release_timed_out diff --git a/engine/tests/test_release_command.py b/engine/tests/test_release_command.py index afcf7ba..c75904a 100644 --- a/engine/tests/test_release_command.py +++ b/engine/tests/test_release_command.py @@ -41,6 +41,7 @@ SWITCH = "Switch the current release symlink" RECEIPT = "Write the release receipt on the controller" TIMEOUT_MARKER = "cloudfall-test-timeout" +REFUSED_MARKER = "cloudfall-test-refused" FAKE_SYSTEMD_RUN = f"""#!/bin/sh # Stand-in for systemd-run --wait --pipe: chdir like --working-directory, @@ -55,6 +56,10 @@ done shift cd "$directory" || exit 200 +if [ -f "$directory/{REFUSED_MARKER}" ]; then + echo "Failed to start transient service unit: Unit already exists." >&2 + exit 1 +fi "$@" status=$? if [ -f "$directory/{TIMEOUT_MARKER}" ]; then @@ -129,12 +134,18 @@ def _local_inventory(tmp_path: Path) -> Path: def _run_release_command( - tmp_path: Path, arguments: list[str], *, timed_out: bool = False + tmp_path: Path, + arguments: list[str], + *, + timed_out: bool = False, + refused: bool = False, ) -> tuple[int, Path]: """Run the role's release command tasks; answer the exit code and result.""" release_directory, working_directory, systemd_run = _release_tree(tmp_path) if timed_out: (working_directory / TIMEOUT_MARKER).write_text("", encoding="utf-8") + if refused: + (working_directory / REFUSED_MARKER).write_text("", encoding="utf-8") result_file = tmp_path / "result.json" variables = { "cloudfall_server_id": "b", @@ -297,6 +308,20 @@ def test_s005_5_a_timed_out_release_command_fails_naming_the_timeout( assert "timed out after 300 seconds and was stopped" in output +def test_s005_4_a_unit_that_never_starts_names_systemd_run_s_reason( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A unit that never starts fails with systemd-run's own message.""" + exit_code, result_file = _run_release_command(tmp_path, ["0"], refused=True) + + assert exit_code == 82 + output = _captured_output(capsys) + assert "exited with code 1" in output + assert "Failed to start transient service unit" in output + assert not (tmp_path / "releases" / "20261027T000000Z-abcdef1" / "table").exists() + assert not result_file.exists() + + def test_s005_5_the_timeout_is_the_unit_runtime_limit(tmp_path: Path) -> None: _run_release_command(tmp_path, ["0"])