From 93e82a4c44f0a978b46676cec74799094a73e0fa Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:35:07 +0200 Subject: [PATCH] Show the release command in the deploy and rollback plans deploy without --yes now shows the component's release command as releaseCommand: its command, the working directory resolved under the new release directory, the timeout (300 by default) and the release server, or null when the component declares none, and its wouldRun says the command runs once on that server before any switch. A rollback never runs the command; its plan says so and adds releaseCommandSkipped: true. releaseCommand is declared in the output contract with its named properties, so treaty writes a missing one as null rather than as {}. This is S-005's last build issue, so the spec is now built, with its Verification filled in. Fixes #120 Co-Authored-By: Claude Opus 5.5 --- changelog.d/120-release-command-plan.md | 3 + docs/specs/005-release-command.md | 16 +- engine/README.md | 5 +- sdk/src/cloudfall/app.py | 43 +++- sdk/src/cloudfall/commands.py | 23 +- sdk/src/cloudfall/lifecycle.py | 104 +++++++- sdk/tests/test_release_command_plan.py | 323 ++++++++++++++++++++++++ 7 files changed, 491 insertions(+), 26 deletions(-) create mode 100644 changelog.d/120-release-command-plan.md create mode 100644 sdk/tests/test_release_command_plan.py diff --git a/changelog.d/120-release-command-plan.md b/changelog.d/120-release-command-plan.md new file mode 100644 index 0000000..9ee720f --- /dev/null +++ b/changelog.d/120-release-command-plan.md @@ -0,0 +1,3 @@ +### Added + +- `deploy` without `--yes` shows the component's release command as `release_command` (its command, the working directory resolved under the new release directory, `timeout_seconds` and the release server), or `null` for a component without one, and says in `would_run` that it runs once on that server before any switch; the `rollback` plan of a component with a release command shows `release_command_skipped: true`, since a rollback never runs it (#120) diff --git a/docs/specs/005-release-command.md b/docs/specs/005-release-command.md index 4440a12..4e9344c 100644 --- a/docs/specs/005-release-command.md +++ b/docs/specs/005-release-command.md @@ -1,6 +1,6 @@ # S-005: Release command -status: approved +status: built ## Problem @@ -129,3 +129,17 @@ Touches `config/schemas/v1/component.schema.json`, - cloudfall-dev/cloudfall#120: S-005-8, S-005-9 ## Verification + +No criterion has run against a live Debian host yet: the role's release command tasks ran under `ansible-playbook` on the controller with a stand-in for `systemd-run`, and `deploy.yml` ran against two local hosts with a recording role. The real `systemd-run` (exit code, the `Finished with result: timeout` line, `EnvironmentFile=` quoting) and `current`, unit and restart state on real servers are unexercised. + +- S-005-1: `test_s005_1_a_passing_release_command_records_its_run` and `test_s005_1_stderr_is_part_of_the_recorded_output`, the stand-in command created its table and the recorded run had the command, `exitCode: 0` and the combined output tail +- S-005-2: `test_s005_2_the_release_command_runs_between_materialize_and_the_switch` (task order in the role) and `test_s005_2_the_command_runs_in_its_working_directory_as_the_user` (working directory, `--uid`/`--gid` of the application user) +- S-005-3: `test_s005_3_the_command_loads_the_unit_environment_file`, the `EnvironmentFile=` line equals the unit template's +- S-005-4: `test_s005_4_a_failing_release_command_fails_the_play_naming_its_exit`, `test_s005_4_a_unit_that_never_starts_names_systemd_run_s_reason` and `test_s005_4_a_failed_release_server_stops_every_other_server`, the message named the command and exit code 1 and no other server ran +- S-005-5: `test_s005_5_a_timed_out_release_command_fails_naming_the_timeout` and `test_s005_5_the_timeout_is_the_unit_runtime_limit` (`RuntimeMaxSec=`) +- S-005-6: `test_s005_6_the_release_server_deploys_first_and_runs_the_command_once` with `deployment.servers: [h2, h1]`, plus `test_s005_6_only_the_release_server_runs_the_command`, `test_s005_6_a_server_whose_release_server_has_not_passed_does_not_switch`, `test_s005_6_every_receipt_names_the_release_server`, `test_s005_6_the_release_server_is_the_first_deployment_server` and `test_s005_6_a_release_renders_with_its_release_server` +- S-005-7: `test_s005_7_the_output_tail_keeps_the_last_2000_characters` and `test_s005_7_a_receipt_refuses_an_output_tail_over_2000_characters` +- S-005-8: `test_s005_8_the_deploy_plan_shows_the_release_command`, `test_s005_8_the_plan_resolves_a_declared_working_directory_and_timeout` and `test_s005_8_a_component_without_release_shows_a_null_release_command`, `deploy` without `--yes` exited 0 with `release_command` (`working_directory` resolved to `/releases/`, `timeout_seconds: 300` by default, `server: h2`) or `null`; the CLI pointed at an engine whose playbooks only leave a marker left none and rendered no inventory, while the same engine with `--yes` did leave one (`test_s005_8_the_marker_engine_does_record_a_playbook_run`) +- S-005-9: `test_s005_9_the_rollback_plan_skips_the_release_command` showed `release_command_skipped: true`, `test_s005_9_a_rollback_without_release_skips_nothing` showed `null`, and `test_s005_9_the_rollback_run_includes_no_release_command_task` found no release command task in `rollback.yml` or what it includes +- S-005-10: `test_s005_10_a_component_without_release_runs_no_new_task`, `test_s005_10_a_receipt_without_release_has_no_release_command`, `test_s005_10_a_component_without_release_deploys_in_inventory_order` and `test_s005_10_a_component_without_release_serializes_as_before` +- S-005-11: `test_s005_11_config_validate_refuses_a_malformed_release`, `test_s005_11_config_validate_accepts_a_declared_release` and `test_s005_11_release_values_are_refused_outside_their_bounds` diff --git a/engine/README.md b/engine/README.md index 8c03c71..0ad6577 100644 --- a/engine/README.md +++ b/engine/README.md @@ -192,7 +192,10 @@ new release directory (or its `workingDirectory`), with the component's 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 +safe while the old release still serves traffic. `deploy` without `--yes` shows +it as `release_command` (command, resolved working directory, timeout and +server), `null` for a component without one. Rollback never runs the release +command, and its plan says so with `release_command_skipped: true`. 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. diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index d9c1213..e80764f 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -89,7 +89,12 @@ RiskLevel, load_catalog, ) -from cloudfall.commands import CLI_COMMANDS, snake_case, snake_case_keys +from cloudfall.commands import ( + CLI_COMMANDS, + KEY_PROPERTIES, + snake_case, + snake_case_keys, +) from cloudfall.dashboard import RefreshInterval, build_dashboard from cloudfall.dashboard_server import ( DashboardHTTPServer, @@ -393,6 +398,34 @@ def __init__(self, body: Mapping[str, object]) -> None: self.body = dict(body) +_FIXED_SHAPES = { + snake_case(key): fields for key, fields in KEY_PROPERTIES.items() +} + + +def _key_schema(name: str, kind: str, *, optional: bool) -> dict[str, object]: + """Return the schema of one data key, by its snake_case name.""" + schema: dict[str, object] = {"type": [kind, "null"] if optional else kind} + properties = _FIXED_SHAPES.get(name) + if properties is None and kind in {"array", "object"}: + # The domain writes each array in the order it means: a ranking, + # a timeline, the order checks ran in, and a stored record keeps + # its own. treaty would sort them, nested ones included. + schema["x-ordered"] = True + if properties is not None: + # A fixed shape: treaty writes a missing one as null, not as {}, and + # orders only its own arrays, which carry x-ordered themselves. + schema["properties"] = { + snake_case(field): _key_schema( + snake_case(field), field_kind, optional=False + ) + for field, field_kind in properties.items() + } + schema["required"] = [snake_case(field) for field in properties] + schema["additionalProperties"] = False + return schema + + def _shape_keys(command: str) -> tuple[tuple[str, str, bool], ...]: """Return each data key of the command's output: name, JSON type, optional.""" contract = next( @@ -423,13 +456,7 @@ def _shape_keys(command: str) -> tuple[tuple[str, str, bool], ...]: def _payload_schema(cls: type[Payload]) -> dict[str, object]: properties: dict[str, object] = {} for name, kind, optional in _shape_keys(cls.command): - schema: dict[str, object] = {"type": [kind, "null"] if optional else kind} - if kind in {"array", "object"}: - # The domain writes each array in the order it means: a ranking, - # a timeline, the order checks ran in, and a stored record keeps - # its own. treaty would sort them, nested ones included. - schema["x-ordered"] = True - properties[name] = schema + properties[name] = _key_schema(name, kind, optional=optional) if cls.writes: properties["effect"] = {"type": "string"} return { diff --git a/sdk/src/cloudfall/commands.py b/sdk/src/cloudfall/commands.py index 83eb43a..d36f12a 100644 --- a/sdk/src/cloudfall/commands.py +++ b/sdk/src/cloudfall/commands.py @@ -87,7 +87,7 @@ class OutputKey: ("completed", "count", "exitCode", "openProposals", "resources", "tasks"), "integer", ), - "healthy": "boolean", + **dict.fromkeys(("healthy", "releaseCommandSkipped"), "boolean"), **dict.fromkeys( ( "added", "answers", "components", "decisions", "declaredKeys", @@ -102,13 +102,28 @@ class OutputKey: **dict.fromkeys( ( "ansible", "byKind", "byRisk", "dashboard", "decision", "error", - "inventory", "operation", "proposal", "query", "summary", + "inventory", "operation", "proposal", "query", "releaseCommand", + "summary", ), "object", ), } """The JSON type of each output key, by name; a shape overrides one as ``name:type``.""" +KEY_PROPERTIES: dict[str, dict[str, str]] = { + "releaseCommand": { + "command": "array", + "workingDirectory": "string", + "timeoutSeconds": "integer", + "server": "string", + }, +} +"""The named properties of an object key that has a fixed shape, by key name. + +Such an object is written as ``null`` when the command has none, where an +open object would be written as ``{}``. +""" + @dataclass(frozen=True, slots=True) class OutputShape: @@ -465,7 +480,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: "activate one built release behind its health check", gate=_YES, output=( - _plan("component", "release"), + _plan("component", "release", "releaseCommand"), _shape(*_EXECUTED, "release", "receipt", when="with `--yes`"), ), ), @@ -476,7 +491,7 @@ def _plan(subject: str, *extra: str) -> OutputShape: "switch one component back to an existing release", gate=_YES, output=( - _plan("component", "release"), + _plan("component", "release", "releaseCommandSkipped?"), _shape(*_EXECUTED, "release", when="with `--yes`"), ), ), diff --git a/sdk/src/cloudfall/lifecycle.py b/sdk/src/cloudfall/lifecycle.py index 76dddbb..b6e4583 100644 --- a/sdk/src/cloudfall/lifecycle.py +++ b/sdk/src/cloudfall/lifecycle.py @@ -16,10 +16,12 @@ import sys from dataclasses import dataclass from enum import StrEnum +from pathlib import PurePosixPath from typing import TYPE_CHECKING, Protocol, cast from jsonschema.exceptions import ValidationError +from cloudfall.domain import AbsolutePath from cloudfall.inventory import PlatformInventory from cloudfall.validation import SchemaCatalog, validate_config @@ -27,7 +29,12 @@ from collections.abc import Callable, Mapping from pathlib import Path - from cloudfall.domain import ReleaseId, ResourceId + from cloudfall.domain import ( + CommandArgv, + ReleaseCommandTimeout, + ReleaseId, + ResourceId, + ) from cloudfall.inventory import ( ComponentInventory, DomainInventory, @@ -176,6 +183,54 @@ def as_dict(self) -> dict[str, object]: return result +@dataclass(frozen=True, slots=True) +class ReleaseCommandPlan: + """The release command a deploy would run, resolved as the engine runs it. + + The working directory is the absolute path on the release server: the + new release directory, or the declared ``workingDirectory`` under it. + """ + + server: ResourceId + command: CommandArgv + working_directory: AbsolutePath + timeout_seconds: ReleaseCommandTimeout + + @classmethod + def for_release( + cls, component: ComponentInventory, release: ReleaseId + ) -> ReleaseCommandPlan | None: + """Resolve a component's release command, ``None`` when it has none.""" + declared = component.release + if declared is None: + return None + directory = PurePosixPath( + component.install_root.value, "releases", release.value + ) + if declared.working_directory is not None: + directory /= declared.working_directory.value + return cls( + server=component.release_server_id, + command=declared.command, + working_directory=AbsolutePath(str(directory)), + timeout_seconds=declared.timeout_seconds, + ) + + @property + def described(self) -> str: + """The command as one line, for a plan's ``wouldRun``.""" + return " ".join(self.command.value) + + def as_dict(self) -> dict[str, object]: + """Serialize the plan's ``releaseCommand``.""" + return { + "command": list(self.command.value), + "workingDirectory": self.working_directory.value, + "timeoutSeconds": self.timeout_seconds.value, + "server": self.server.value, + } + + class PreviewSubject(StrEnum): """Kind of declared resource a previewed action targets.""" @@ -197,6 +252,10 @@ class LifecyclePreview: release: ReleaseId | None servers: tuple[ResourceId, ...] effect: str + release_command: ReleaseCommandPlan | None = None + """The release command a deploy runs; set only on a deploy's plan.""" + release_command_skipped: bool = False + """A declared release command this action does not run (a rollback).""" def as_dict(self) -> dict[str, object]: """Serialize the preview for agent consumers.""" @@ -209,6 +268,10 @@ def as_dict(self) -> dict[str, object]: } if self.release is not None: result["release"] = self.release.value + if self.release_command is not None: + result["releaseCommand"] = self.release_command.as_dict() + if self.release_command_skipped: + result["releaseCommandSkipped"] = True return result @@ -557,17 +620,26 @@ def preview_deploy( verify_artifact( artifact_directory, component_id, release, context.schema_directory ) + release_command = ReleaseCommandPlan.for_release(component, release) + effect = ( + f"deploy release {release.value} of component {component_id} to " + f"{_server_list(component.server_ids)} behind its health check, " + "rolling back automatically if the check fails" + ) + if release_command is not None: + effect += ( + f"; first run its release command {release_command.described} " + f"once on {release_command.server}, before any server switches " + "to the release" + ) return LifecyclePreview( action="deploy", subject=PreviewSubject.COMPONENT, subject_id=component_id, release=release, servers=component.server_ids, - effect=( - f"deploy release {release.value} of component {component_id} to " - f"{_server_list(component.server_ids)} behind its health check, " - "rolling back automatically if the check fails" - ), + effect=effect, + release_command=release_command, ) @@ -577,20 +649,28 @@ def preview_rollback( """Validate a rollback without touching any server. Whether the release is still retained is known only to the servers; the - rollback itself fails if it is not. + rollback itself fails if it is not. A rollback never runs the release + command: a downgrade stays a human decision. """ component = _component(context, component_id) + effect = ( + f"switch component {component_id} on " + f"{_server_list(component.server_ids)} back to retained release " + f"{release.value} and restart it behind its health check" + ) + if component.release is not None: + effect += ( + "; its release command " + f"{' '.join(component.release.command.value)} is not run" + ) return LifecyclePreview( action="rollback", subject=PreviewSubject.COMPONENT, subject_id=component_id, release=release, servers=component.server_ids, - effect=( - f"switch component {component_id} on " - f"{_server_list(component.server_ids)} back to retained release " - f"{release.value} and restart it behind its health check" - ), + effect=effect, + release_command_skipped=component.release is not None, ) diff --git a/sdk/tests/test_release_command_plan.py b/sdk/tests/test_release_command_plan.py new file mode 100644 index 0000000..d62ab42 --- /dev/null +++ b/sdk/tests/test_release_command_plan.py @@ -0,0 +1,323 @@ +"""The release command in the ``deploy`` and ``rollback`` plans (S-005-8, S-005-9). + +The engine directory the CLI is pointed at is a stand-in whose playbooks +only leave a marker file on the controller, so a test sees whether +``ansible-playbook`` ran at all without contacting any host. +""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import tarfile +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from cloudfall.cli import main +from cloudfall.domain import ReleaseId, ResourceId +from cloudfall.lifecycle import EngineContext, preview_deploy, preview_rollback + +if TYPE_CHECKING: + import pytest + +ROOT = Path(__file__).parents[2] +SCHEMAS = ROOT / "config" / "schemas" / "v1" +EXAMPLES = ROOT / "config" / "examples" +ENGINE = ROOT / "engine" +RELEASE = "20260101T000000Z-abcdef0" +RELEASE_DIRECTORY = f"/srv/apps/crm/backend/releases/{RELEASE}" +ROLLBACK_RUN = ( + ENGINE / "ansible" / "roles" / "cloudfall_deploy" / "tasks" / "rollback.yml" +) + + +def _project(tmp_path: Path, release: str | None) -> Path: + """Copy the examples, declaring ``release`` on crm-backend when given.""" + project = tmp_path / "config" + shutil.copytree(EXAMPLES, project) + if release is not None: + 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", f"{release} healthCheck:\n") + path.write_text(text, encoding="utf-8") + return project + + +_DECLARED = " release:\n command: [.venv/bin/alembic, upgrade, head]\n" + + +def _marker_engine(tmp_path: Path) -> Path: + """Lay out an engine whose deploy and rollback playbooks only write a marker.""" + engine = tmp_path / "engine" + playbooks = engine / "ansible" / "playbooks" + playbooks.mkdir(parents=True) + shutil.copy(ENGINE / "ansible" / "ansible.cfg", engine / "ansible") + for name in ("deploy.yml", "rollback.yml"): + marker = tmp_path / f"ran-{name}" + (playbooks / name).write_text( + "---\n- name: Leave a marker\n hosts: localhost\n" + " connection: local\n gather_facts: false\n tasks:\n" + " - name: Write the marker\n ansible.builtin.copy:\n" + f" dest: {marker}\n content: ran\n mode: '0600'\n", + encoding="utf-8", + ) + return engine + + +def _write_artifact(tmp_path: Path) -> Path: + artifact_directory = tmp_path / "artifacts" + component_directory = artifact_directory / "crm-backend" + component_directory.mkdir(parents=True) + source = tmp_path / "source" + source.mkdir() + (source / "app.py").write_text("print('crm')\n", encoding="utf-8") + archive_path = component_directory / f"{RELEASE}.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(source, arcname=".") + metadata = { + "apiVersion": "cloudfall/v1", + "kind": "Artifact", + "metadata": {"id": "crm-backend"}, + "spec": { + "component": "crm-backend", + "release": RELEASE, + "gitRef": "main", + "gitCommit": "a" * 40, + "builtAt": "2026-01-01T00:00:00Z", + "archive": archive_path.name, + "archiveSha256": hashlib.sha256(archive_path.read_bytes()).hexdigest(), + "sizeBytes": archive_path.stat().st_size, + }, + } + (component_directory / f"{RELEASE}.json").write_text( + json.dumps(metadata), encoding="utf-8" + ) + return artifact_directory + + +def _cli( + argv: list[str], + project: Path, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> tuple[int, dict[str, Any]]: + exit_code = main( + [ + *argv, + "--project", + str(project), + "--schemas", + str(SCHEMAS), + "--engine", + str(_marker_engine(tmp_path)), + "--inventory-file", + str(tmp_path / "inventory.json"), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, dict) + return exit_code, payload + + +def _deploy_plan( + tmp_path: Path, capsys: pytest.CaptureFixture[str], release: str | None +) -> tuple[int, dict[str, Any]]: + return _cli( + [ + "deploy", + "crm-backend", + "--release", + RELEASE, + "--artifacts", + str(_write_artifact(tmp_path)), + ], + _project(tmp_path, release), + tmp_path, + capsys, + ) + + +def _ran_no_playbook(tmp_path: Path) -> bool: + return not any(tmp_path.glob("ran-*")) and not ( + tmp_path / "inventory.json" + ).exists() + + +def test_s005_8_the_deploy_plan_shows_the_release_command( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + exit_code, payload = _deploy_plan(tmp_path, capsys, _DECLARED) + + assert exit_code == 0, payload + data = payload["data"] + assert data["status"] == "plan" + assert payload["meta"]["dry_run"] is True + assert data["release_command"] == { + "command": [".venv/bin/alembic", "upgrade", "head"], + "working_directory": RELEASE_DIRECTORY, + "timeout_seconds": 300, + "server": "h2", + } + assert ( + "release command .venv/bin/alembic upgrade head once on h2, before any " + "server switches" + ) in data["would_run"] + assert _ran_no_playbook(tmp_path) + + +def test_s005_8_the_plan_resolves_a_declared_working_directory_and_timeout( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + declared = ( + f"{_DECLARED} workingDirectory: services/api/\n timeoutSeconds: 60\n" + ) + + exit_code, payload = _deploy_plan(tmp_path, capsys, declared) + + assert exit_code == 0, payload + release_command = payload["data"]["release_command"] + assert release_command["working_directory"] == f"{RELEASE_DIRECTORY}/services/api" + assert release_command["timeout_seconds"] == 60 + assert _ran_no_playbook(tmp_path) + + +def test_s005_8_a_component_without_release_shows_a_null_release_command( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + exit_code, payload = _deploy_plan(tmp_path, capsys, None) + + assert exit_code == 0, payload + assert "release_command" in payload["data"] + assert payload["data"]["release_command"] is None + assert "release command" not in payload["data"]["would_run"] + assert _ran_no_playbook(tmp_path) + + +def test_s005_9_the_rollback_plan_skips_the_release_command( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + exit_code, payload = _cli( + ["rollback", "crm-backend", "--release", RELEASE], + _project(tmp_path, _DECLARED), + tmp_path, + capsys, + ) + + assert exit_code == 0, payload + data = payload["data"] + assert data["status"] == "plan" + assert data["release_command_skipped"] is True + assert "release command .venv/bin/alembic upgrade head is not run" in ( + data["would_run"] + ) + assert _ran_no_playbook(tmp_path) + + +def test_s005_9_a_rollback_without_release_skips_nothing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + exit_code, payload = _cli( + ["rollback", "crm-backend", "--release", RELEASE], + _project(tmp_path, None), + tmp_path, + capsys, + ) + + assert exit_code == 0, payload + assert payload["data"]["release_command_skipped"] is None + assert "release command" not in payload["data"]["would_run"] + + +def test_s005_9_the_rollback_run_includes_no_release_command_task() -> None: + text = ROLLBACK_RUN.read_text(encoding="utf-8") + rollback_playbook = (ENGINE / "ansible" / "playbooks" / "rollback.yml").read_text( + encoding="utf-8" + ) + + assert "release_command" not in text + assert "systemd-run" not in text + assert "tasks_from: rollback" in rollback_playbook + assert "release_command" not in rollback_playbook + included = [ + line.split(":", 1)[1].strip() + for line in text.splitlines() + if "include_tasks" in line + ] + assert included == ["health_gate.yml"] + health_gate = ROLLBACK_RUN.with_name("health_gate.yml").read_text( + encoding="utf-8" + ) + assert "release_command" not in health_gate + + +def test_s005_8_the_marker_engine_does_record_a_playbook_run( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The control: the same stand-in engine, run with --yes, leaves its marker.""" + exit_code, payload = _cli( + ["rollback", "crm-backend", "--release", RELEASE, "--yes"], + _project(tmp_path, _DECLARED), + tmp_path, + capsys, + ) + + assert exit_code == 0, payload + assert (tmp_path / "ran-rollback.yml").read_text(encoding="utf-8") == "ran" + + +def test_s005_8_the_output_schemas_declare_the_new_plan_keys( + capsys: pytest.CaptureFixture[str], +) -> None: + assert main(["deploy", "--schema"]) == 0 + deploy = json.loads(capsys.readouterr().out)["data"]["output_schema"] + assert main(["rollback", "--schema"]) == 0 + rollback = json.loads(capsys.readouterr().out)["data"]["output_schema"] + + release_command = deploy["properties"]["release_command"] + assert release_command["type"] == ["object", "null"] + assert set(release_command["required"]) == { + "command", + "working_directory", + "timeout_seconds", + "server", + } + assert "release_command" in deploy["required"] + assert rollback["properties"]["release_command_skipped"] == { + "type": ["boolean", "null"] + } + assert "release_command" not in rollback["properties"] + + +def test_s005_8_the_sdk_preview_carries_the_typed_release_command( + tmp_path: Path, +) -> None: + context = EngineContext( + project_directory=_project(tmp_path, _DECLARED), + schema_directory=SCHEMAS, + engine_directory=ENGINE, + inventory_file=tmp_path / "inventory.json", + ) + + deploy = preview_deploy( + context, + ResourceId("crm-backend"), + ReleaseId(RELEASE), + _write_artifact(tmp_path), + ) + rollback = preview_rollback( + context, ResourceId("crm-backend"), ReleaseId(RELEASE) + ) + + assert deploy.release_command is not None + assert deploy.release_command.server == ResourceId("h2") + assert deploy.as_dict()["releaseCommand"] == { + "command": [".venv/bin/alembic", "upgrade", "head"], + "workingDirectory": RELEASE_DIRECTORY, + "timeoutSeconds": 300, + "server": "h2", + } + assert "releaseCommandSkipped" not in deploy.as_dict() + assert rollback.release_command is None + assert rollback.as_dict()["releaseCommandSkipped"] is True