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