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..82b429d --- /dev/null +++ b/engine/ansible/roles/cloudfall_deploy/tasks/release_command.yml @@ -0,0 +1,88 @@ +--- +# 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:] + }} systemd-run reported: {{ + cloudfall_deploy_release_run.stderr[-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..c75904a --- /dev/null +++ b/engine/tests/test_release_command.py @@ -0,0 +1,644 @@ +"""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" +REFUSED_MARKER = "cloudfall-test-refused" + +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 +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 + 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, + 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", + "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_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"]) + + 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