From c767ea9b72a98d975d09b2b63c2a4f85613fdf6b Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:19:27 +0200 Subject: [PATCH] Make deploy enforce approval: manual with the release approval record A manual application's deploy --yes now deploys a release only when a release approve record counts for it: the same archive SHA-256, exactly the component's declared servers, and not expired. Without one it exits PRECONDITION deployment_approval_missing before any playbook runs, naming the command a person runs and each record that doesn't count with its reason. The plan and the result carry approval, a broken approvals file stops a manual deploy with RECORD_INVALID, an automatic application never reads the folder, and mcp serve binds the approvals folder so a deploy call can't name its own (spec S-008). Fixes #163 Co-Authored-By: Claude Opus 5.5 --- changelog.d/106-enforce-manual-approval.md | 3 + changelog.d/160-release-approve.md | 2 +- docs/ci-deploy-guide.md | 105 +++- docs/operator-guide.md | 7 +- sdk/src/cloudfall/agent_server.py | 16 + sdk/src/cloudfall/app.py | 77 ++- sdk/src/cloudfall/commands.py | 22 +- sdk/src/cloudfall/lifecycle.py | 9 + sdk/src/cloudfall/release_approval.py | 289 ++++++++++- sdk/tests/test_agent_server.py | 83 +++ sdk/tests/test_deploy_approval.py | 556 +++++++++++++++++++++ 11 files changed, 1125 insertions(+), 44 deletions(-) create mode 100644 changelog.d/106-enforce-manual-approval.md create mode 100644 sdk/tests/test_deploy_approval.py diff --git a/changelog.d/106-enforce-manual-approval.md b/changelog.d/106-enforce-manual-approval.md new file mode 100644 index 0000000..ad21b0a --- /dev/null +++ b/changelog.d/106-enforce-manual-approval.md @@ -0,0 +1,3 @@ +### Breaking + +- `deploy --yes` and `migrate --yes` on an application that declares `approval: manual` now need a person's approval of the release: without a `cloudfall release approve` record that counts (the same archive SHA-256, exactly the component's declared servers, not expired), `deploy --yes` exits 4 `PRECONDITION` with `deployment_approval_missing` before any playbook runs, naming the `release approve` command, and lists each record that doesn't count with `digest_mismatch`, `servers_changed` or `expired`. This hits every project that declares `manual` and deploys today, the example `crm.yaml` and every Render import included. Two ways out: keep `manual` and approve each release first (build it, run `cloudfall release approve` at a terminal, commit the record under `decisions/releases/` when CI deploys, then deploy), or switch the application to `approval: automatic` when another gate, such as GitHub's required reviewers, is the real one. The `deploy` plan and result gain `approval` (`policy`, and for `manual` the `status`, the counting record's `path`, `approver` and `expires_at` or `approved_at`, the records that don't count and the `command` to run), `deploy` gains `--approvals` (default `decisions/releases`) and the `RECORD_INVALID` exit for an approvals file that isn't a valid `ReleaseApproval`, and `cloudfall mcp serve` binds the approvals folder (its own `--approvals`) so a `deploy` call can't name its own; an `automatic` application's deploy never reads the folder (#163) diff --git a/changelog.d/160-release-approve.md b/changelog.d/160-release-approve.md index 69509e3..288a36b 100644 --- a/changelog.d/160-release-approve.md +++ b/changelog.d/160-release-approve.md @@ -1,3 +1,3 @@ ### Added -- `cloudfall release approve --release ` records a person's approval of one built release as a `ReleaseApproval` (new `release-approval.schema.json`) under `decisions/releases/`: the application, component, release, archive SHA-256, git commit, servers, approver, `approvedAt`, `expiresAt` (`--valid-for` hours, 1 to 168, default 24) and `via: terminal`. It verifies the artifact first, refuses a component of an `automatic` application with `release_approval_not_required`, and writes the record only once a person typed the release id at a terminal; `--yes` never answers it, and it is no MCP tool. It deploys nothing, and `deploy` does not read the record yet (#160) +- `cloudfall release approve --release ` records a person's approval of one built release as a `ReleaseApproval` (new `release-approval.schema.json`) under `decisions/releases/`: the application, component, release, archive SHA-256, git commit, servers, approver, `approvedAt`, `expiresAt` (`--valid-for` hours, 1 to 168, default 24) and `via: terminal`. It verifies the artifact first, refuses a component of an `automatic` application with `release_approval_not_required`, and writes the record only once a person typed the release id at a terminal; `--yes` never answers it, and it is no MCP tool. It deploys nothing (#160) diff --git a/docs/ci-deploy-guide.md b/docs/ci-deploy-guide.md index 329c141..529396d 100644 --- a/docs/ci-deploy-guide.md +++ b/docs/ci-deploy-guide.md @@ -319,25 +319,92 @@ naming `production` still waits for its reviewers. ## Approval: who presses the button -Every `Application` declares `spec.approval: manual` or `automatic`. -Cloudfall validates the field and writes it into the rendered inventory, but -no command acts on it today: `cloudfall deploy --yes` deploys an application -marked `manual` exactly as it deploys one marked `automatic`. The only gate -inside `deploy` is `--yes`, and a workflow that passes `--yes` has approved -its own deploy. - -So in CI, the approval comes from GitHub, not from Cloudfall: - -- Give every environment that holds an application marked `manual` required - reviewers, and turn on "Prevent self-review", so the person who pushed the - change cannot approve its deploy -- The job waits before its first step until a reviewer approves, so it never - sees the SSH key or the age key of that environment without approval -- Leave `staging` without reviewers if its applications are `automatic` - -Read `approval: manual` as your declaration that such a gate must exist; the -environment's reviewers are what enforce it. The approval itself lands in -GitHub's deployment history, not in Cloudfall's record. +Every `Application` declares `spec.approval: manual` or `automatic`, and +`cloudfall deploy` enforces it: + +- `automatic`: `deploy --yes` deploys, as the workflow above does. Keep + staging's applications `automatic` and its one job stays as it is +- `manual`: `deploy --yes` deploys a release only when a person approved + that release with `cloudfall release approve`. Without an approval that + counts it exits 4 `PRECONDITION` with `deployment_approval_missing`, before + any playbook runs, and names the command to run. `deploy` without `--yes` + shows the approval in its plan: `approval.status` is `approved` or + `missing` + +An approval is a record under `decisions/releases/` in the project. It names +the archive's SHA-256, the component's servers and when it expires (24 hours +by default, `--valid-for` up to a week). It counts only while the archive +being deployed has that digest, the component is declared on exactly those +servers, and it has not expired. A failed health gate does not use it up, so +the same release can be retried until it expires. + +A person approves at a terminal by typing the release id back; no flag, and +no CI job, answers that prompt. So a production deploy of a `manual` +application is two jobs with a person between them, and approval needs the +exact archive the deploy ships: + +1. A build job builds the release and uploads `tmp/artifacts//` + as a workflow artifact +2. The person downloads that artifact into `tmp/artifacts//` of + their checkout and approves it: + + ```sh + gh run download --name cloudfall-artifact-crm-backend --dir tmp/artifacts/crm-backend + uv run cloudfall release approve crm-backend --release 20260101T000000Z-abcdef0 + git add decisions/releases + git commit -m "Approve crm-backend 20260101T000000Z-abcdef0" + git push + ``` + +3. They dispatch the deploy job with the build's run id and the release. It + checks out the commit holding the record, downloads the same artifact and + deploys it: + +```yaml + build: + runs-on: ubuntu-24.04 + steps: + # checkout, uv, uv sync --locked, as in the workflow above + - name: Build the artifact + id: build + run: | + uv run cloudfall-engine artifact build "$COMPONENT" --ref "$REF" --format json > "$RUNNER_TEMP/build.json" + echo "release=$(jq -r '.data.release' "$RUNNER_TEMP/build.json")" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v7 + with: + name: cloudfall-artifact-${{ env.COMPONENT }} + path: tmp/artifacts/${{ env.COMPONENT }}/ +``` + +```yaml + deploy: + runs-on: ubuntu-24.04 + environment: production + steps: + # checkout, uv, sops, the SSH and age keys, secrets render, as above + - uses: actions/download-artifact@v7 + with: + name: cloudfall-artifact-${{ env.COMPONENT }} + path: tmp/artifacts/${{ env.COMPONENT }}/ + run-id: ${{ inputs.build_run_id }} + github-token: ${{ github.token }} + - name: Deploy the approved release + run: uv run cloudfall deploy "$COMPONENT" --release "$RELEASE" --env-file "tmp/env/$COMPONENT.env" --yes +``` + +The deploy job reads the record from its checkout, so dispatch it on the +commit that holds the approval. A rebuilt release has a new archive, and +its old approval no longer counts. + +GitHub's required reviewers remain a useful outer gate: give the +`production` environment reviewers and turn on "Prevent self-review", and +the deploy job never sees that environment's SSH key or age key until a +reviewer lets it start. Their approval lands in GitHub's deployment +history; the `release approve` record is the one in Cloudfall's. + +When the reviewers are the real gate and nobody will run `release approve`, +switch the application to `approval: automatic`, so the project no longer +claims a gate Cloudfall does not hold. ## Keeping the receipts diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 4eefa92..3c0c310 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -192,8 +192,11 @@ proposals it executed and which it withheld, with reasons. Approval is not a tool. A person approves a proposal by running `cloudfall operator approve` from a shell, as in [Reviewing and approving](#reviewing-and-approving); an agent that could -call it would run and verify the change it proposed itself. The watch -loop is not a tool either: it runs until stopped, as the service above. +call it would run and verify the change it proposed itself. The same holds +for `cloudfall release approve`: the `deploy` tool deploys a release of an +application that declares `approval: manual` only once a person approved +it at a terminal, and no tool writes that approval. The watch loop is not a +tool either: it runs until stopped, as the service above. The agent is the brain here and the operator is the record. The agent can read the proposals, relay the diagnosis and recommend one; a person diff --git a/sdk/src/cloudfall/agent_server.py b/sdk/src/cloudfall/agent_server.py index 876faa6..06a93d4 100644 --- a/sdk/src/cloudfall/agent_server.py +++ b/sdk/src/cloudfall/agent_server.py @@ -40,6 +40,7 @@ is_project, resolve_project_directory, ) +from cloudfall.release_approval import APPROVALS_DIRECTORY from cloudfall.render_api import RENDER_API_URL from cloudfall.resources import default_engine_directory, default_schema_directory @@ -145,6 +146,14 @@ class McpServeArgs: f"Decision records in the repository (default: {DECISION_DIRECTORY})" ), ) + approvals: Path = Flag( + default=Path(APPROVALS_DIRECTORY), + description=( + "Release approval records deploy reads in the project (default: " + f"{APPROVALS_DIRECTORY})" + ), + ) + def __post_init__(self) -> None: """Refuse two fleets: a server serves a project or a repository.""" if self.project is not None and self.repository is not None: @@ -381,6 +390,9 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]: "env_dir": _IMPORT_ENV, "plan_file": _MIGRATE_PLAN, "proposals": str(args.proposals), + # deploy reads the approvals a person recorded; a call can't point it + # at a folder of its own choosing. + "approvals": str(args.approvals), "secrets_dir": str(args.secrets_dir), # A chosen URL would send any file the call names as the key to it. "api_url": RENDER_API_URL, @@ -429,6 +441,10 @@ def bound_arguments(args: McpServeArgs) -> dict[str, object]: operator_list and operator_show read the proposals that loop records. Approving one is `cloudfall operator approve`, a command a person runs from a shell; no tool here can do it, whatever reason is given for asking. +Deploying a release of an application that declares approval: manual +needs `cloudfall release approve` first, also a command a person runs at a +terminal; deploy's plan says whether an approval counts, and without one +deploy with yes refuses with deployment_approval_missing. Every result is an envelope: read ok, then data, else error. """ diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 532fecf..490dad9 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -248,16 +248,23 @@ from cloudfall.recording import Recording, RecordingError from cloudfall.release_approval import ( APPROVALS_DIRECTORY, + AUTOMATIC_PLAN, VALID_FOR_DEFAULT, VALID_FOR_MAXIMUM, VALID_FOR_MINIMUM, + ApprovalCheck, Approver, ReleaseApprovalError, ValidityHours, approve_release, + check_approval, + deployment_candidate, release_candidate, write_approval, ) +from cloudfall.release_approval import ( + ERROR_RECORD_INVALID as ERROR_APPROVAL_RECORD_INVALID, +) from cloudfall.render_api import ( ERROR_API_UNREACHABLE, RENDER_API_URL, @@ -397,7 +404,10 @@ def _serve_tools(args: McpServeArgs, ctx: Ctx) -> list[McpTool]: app.exit_code( "RECORD_INVALID", 86, - description="A decision record or proposal receipt could not be read", + description=( + "A decision record, proposal receipt or release approval record could " + "not be read" + ), retryable=False, side_effects="none", ) @@ -3872,12 +3882,20 @@ class DeployArgs(ComponentArgs): default=Path("tmp/releases"), description="Release receipt directory (default: tmp/releases)", ) + approvals: Path = Flag( + default=Path(APPROVALS_DIRECTORY), + description=( + "Directory of release approval records, read for an application " + f"that declares approval: manual (default: {APPROVALS_DIRECTORY})" + ), + ) def __post_init__(self) -> None: """Keep relative paths inside the project.""" ComponentArgs.__post_init__(self) _inside_project(self.artifacts, "artifacts") _inside_project(self.releases, "releases") + _inside_project(self.approvals, "approvals") @dataclass(frozen=True, slots=True, kw_only=True) @@ -3985,14 +4003,43 @@ def _plan(preview: LifecyclePreview) -> dict[str, object]: ] +def _deploy_approval( + args: DeployArgs, fleet: Fleet, artifacts: Path +) -> ApprovalCheck | None: + """Check the release's approval; ``None`` for an ``automatic`` application. + + An ``automatic`` application's approvals folder is never read. + """ + candidate = deployment_candidate( + fleet.inventory, args.component, args.release, artifacts, args.schemas + ) + if candidate is None: + return None + try: + return check_approval( + candidate, + fleet.path(args.approvals), + SchemaCatalog(args.schemas), + datetime.now(UTC), + ) + except ReleaseApprovalError as error: + if error.code == ERROR_APPROVAL_RECORD_INVALID: + raise Exit.RECORD_INVALID( + error.detail, context={"code": error.code} + ) from error + raise Exit.PRECONDITION(error.detail, context={"code": error.code}) from error + + @app.command( "deploy", description=( "Deploy one built component release behind its health gate; without " - "--yes it shows the plan" + "--yes it shows the plan. A release of an application that declares " + "approval: manual deploys only once a person approved it with " + "cloudfall release approve" ), danger_level="mutating", - exit_codes=_LIFECYCLE_EXIT_CODES, + exit_codes=[*_LIFECYCLE_EXIT_CODES, "RECORD_INVALID"], timeout=None, subprocess=Subprocess("ansible-playbook"), required_tools={"ansible-playbook": "2.21.0"}, @@ -4013,10 +4060,21 @@ def deploy_command(args: DeployArgs, ctx: Ctx, fleet: Fleet) -> DeployPayload: context = args.context(fleet, ctx) artifacts = fleet.path(args.artifacts) try: + approval = _deploy_approval(args, fleet, artifacts) if not args.yes: + preview = preview_deploy(context, args.component, args.release, artifacts) return DeployPayload( - _plan(preview_deploy(context, args.component, args.release, artifacts)) + { + **_plan(preview), + "approval": ( + dict(AUTOMATIC_PLAN) if approval is None else approval.plan() + ), + } ) + # Refused before the inventory render or any playbook: nothing on a + # server changes and no receipt is written. + if approval is not None and approval.counting is None: + raise approval.refusal() result = deploy( context, args.component, @@ -4027,11 +4085,20 @@ def deploy_command(args: DeployArgs, ctx: Ctx, fleet: Fleet) -> DeployPayload: fleet.path(args.env_file) if args.env_file is not None else None ), receipt_directory=fleet.path(args.releases), + approved_sha256=( + None if approval is None else approval.candidate.artifact_sha256 + ), ), ) except LifecycleError as error: raise _lifecycle_failed(error) from error - return DeployPayload({**result.as_dict(), "effect": "updated"}) + return DeployPayload( + { + **result.as_dict(), + "effect": "updated", + "approval": dict(AUTOMATIC_PLAN) if approval is None else approval.used(), + } + ) @app.command( diff --git a/sdk/src/cloudfall/commands.py b/sdk/src/cloudfall/commands.py index 0602852..225fa43 100644 --- a/sdk/src/cloudfall/commands.py +++ b/sdk/src/cloudfall/commands.py @@ -102,7 +102,7 @@ class OutputKey: ), **dict.fromkeys( ( - "ansible", "byKind", "byRisk", "certbotTimer", "certificate", + "ansible", "approval", "byKind", "byRisk", "certbotTimer", "certificate", "dashboard", "decision", "error", "inventory", "operation", "proposal", "query", "releaseCommand", "summary", "vhost", ), @@ -486,10 +486,24 @@ def _plan(subject: str, *extra: str) -> OutputShape: "deploy", CommandEffect.SERVERS, "activate one built release behind its health check", - gate=_YES, + gate=( + f"{_YES}; a release of an application that declares " + "`approval: manual` deploys only once a person approved it with " + "`cloudfall release approve`, and the plan's `approval` says whether " + "one counts" + ), output=( - _plan("component", "release", "releaseCommand"), - _shape(*_EXECUTED, "release", "receipt", when="with `--yes`"), + _plan("component", "release", "releaseCommand", "approval"), + _shape( + *_EXECUTED, + "release", + "receipt", + "approval", + when=( + "with `--yes`; `approval` names the record the deploy went on " + "when the application declares `approval: manual`" + ), + ), ), ), CommandContract( diff --git a/sdk/src/cloudfall/lifecycle.py b/sdk/src/cloudfall/lifecycle.py index 20c00b5..b940ca1 100644 --- a/sdk/src/cloudfall/lifecycle.py +++ b/sdk/src/cloudfall/lifecycle.py @@ -178,6 +178,8 @@ class DeployOptions: environment_file: Path | None = None receipt_directory: Path | None = None + approved_sha256: Sha256Digest | None = None + """The archive digest an approval names; the deploy refuses any other.""" @dataclass(frozen=True, slots=True) @@ -1201,6 +1203,13 @@ def deploy( artifact = verify_artifact( artifact_directory, component_id, release, context.schema_directory ) + approved = resolved_options.approved_sha256 + if approved is not None and artifact.sha256 != approved.value: + detail = ( + f"artifact digest {artifact.sha256} is not the approved digest " + f"{approved.value}: the archive changed after the approval was checked" + ) + raise LifecycleError(_ERROR_ARTIFACT_DIGEST, detail) plan = plan_deploy( context, component_id, diff --git a/sdk/src/cloudfall/release_approval.py b/sdk/src/cloudfall/release_approval.py index 5ec4dbf..85e889b 100644 --- a/sdk/src/cloudfall/release_approval.py +++ b/sdk/src/cloudfall/release_approval.py @@ -3,8 +3,10 @@ ``cloudfall release approve`` writes one ``ReleaseApproval`` record once a person typed the release id at a terminal. The record names the archive digest, the git commit and the servers it approves, and when it expires; it -holds no secret. This module builds and writes the record; nothing here -deploys, and nothing here reads the records back. +holds no secret. This module builds and writes the record, and reads the +records back for ``deploy``, which counts one only when it still names the +archive and the servers being deployed and has not expired. Nothing here +deploys. """ from __future__ import annotations @@ -13,18 +15,20 @@ import json import re from dataclasses import dataclass -from datetime import UTC, timedelta -from typing import TYPE_CHECKING +from datetime import UTC, datetime, timedelta +from enum import StrEnum +from typing import TYPE_CHECKING, cast + +from jsonschema.exceptions import ValidationError from cloudfall.decision import ApprovalChannel -from cloudfall.domain import DeploymentApproval, ResourceId, Sha256Digest -from cloudfall.lifecycle import find_component, verify_artifact +from cloudfall.domain import DeploymentApproval, ReleaseId, ResourceId, Sha256Digest +from cloudfall.lifecycle import LifecycleError, find_component, verify_artifact if TYPE_CHECKING: - from datetime import datetime + from collections.abc import Mapping from pathlib import Path - from cloudfall.domain import ReleaseId from cloudfall.inventory import PlatformInventory from cloudfall.validation import SchemaCatalog @@ -40,6 +44,10 @@ """Two approvals of one release in the same second share an id.""" ERROR_CHANNEL_UNKNOWN = "release_approval_channel_unknown" ERROR_VALIDITY_INVALID = "release_approval_validity_invalid" +ERROR_RECORD_INVALID = "release_approval_record_invalid" +"""A file in the approvals folder is not a valid ``ReleaseApproval``.""" +ERROR_DEPLOYMENT_APPROVAL_MISSING = "deployment_approval_missing" +"""A ``manual`` application's release has no approval that counts.""" VALID_FOR_MINIMUM = 1 VALID_FOR_MAXIMUM = 168 @@ -159,6 +167,33 @@ def release_candidate( that does not verify, and ``ReleaseApprovalError`` for a component of an ``automatic`` application. """ + candidate = deployment_candidate( + inventory, component_id, release, artifacts, schemas + ) + if candidate is None: + component = find_component(inventory, component_id) + message = ( + f"application {component.application_id} deploys automatic; an " + f"approval of {component_id} would gate nothing" + ) + raise ReleaseApprovalError(ERROR_APPROVAL_NOT_REQUIRED, message) + return candidate + + +def deployment_candidate( + inventory: PlatformInventory, + component_id: ResourceId, + release: ReleaseId, + artifacts: Path, + schemas: Path, +) -> ReleaseCandidate | None: + """Return what a deploy of ``release`` would need approved now. + + ``None`` when the component's application deploys ``automatic``: then + nothing needs approving and the artifact is left to the deploy itself. + Raises ``LifecycleError`` for an undeclared component or an artifact + that does not verify. + """ component = find_component(inventory, component_id) applications = { application.resource_id: application @@ -166,11 +201,7 @@ def release_candidate( } application = applications[component.application_id] if application.approval is DeploymentApproval.AUTOMATIC: - message = ( - f"application {application.resource_id} deploys automatic; an " - f"approval of {component_id} would gate nothing" - ) - raise ReleaseApprovalError(ERROR_APPROVAL_NOT_REQUIRED, message) + return None artifact = verify_artifact(artifacts, component_id, release, schemas) return ReleaseCandidate( application_id=application.resource_id, @@ -284,3 +315,235 @@ def _spec( "expiresAt": expires_at.astimezone(UTC).strftime(_TIME_FORMAT), "via": via.value, } + + +def _time(value: datetime) -> str: + return value.astimezone(UTC).strftime(_TIME_FORMAT) + + +# Which approval counts: deploy reads the records back (S-008). + +AUTOMATIC_PLAN: Mapping[str, object] = { + "policy": DeploymentApproval.AUTOMATIC.value +} +"""The plan's ``approval`` for an ``automatic`` application: nothing to approve.""" + + +class NotCountingReason(StrEnum): + """Why a record for the release being deployed does not count.""" + + DIGEST_MISMATCH = "digest_mismatch" + """The archive being deployed is not the one approved.""" + SERVERS_CHANGED = "servers_changed" + """The component is declared on other servers than the ones approved.""" + EXPIRED = "expired" + + +@dataclass(frozen=True, slots=True) +class StoredApproval: + """One approval record as read back from the approvals folder.""" + + path: Path + application_id: ResourceId + component_id: ResourceId + release: ReleaseId + artifact_sha256: Sha256Digest + server_ids: frozenset[ResourceId] + approver: Approver + approved_at: datetime + expires_at: datetime + + def names(self, candidate: ReleaseCandidate) -> bool: + """Whether the record names the candidate's application, component, release.""" + return ( + self.application_id == candidate.application_id + and self.component_id == candidate.component_id + and self.release == candidate.release + ) + + def reason_against( + self, candidate: ReleaseCandidate, now: datetime + ) -> NotCountingReason | None: + """Return why the record does not count for the candidate, if it doesn't.""" + if self.artifact_sha256 != candidate.artifact_sha256: + return NotCountingReason.DIGEST_MISMATCH + if self.server_ids != frozenset(candidate.server_ids): + return NotCountingReason.SERVERS_CHANGED + if self.expires_at <= now: + return NotCountingReason.EXPIRED + return None + + +@dataclass(frozen=True, slots=True) +class NotCounting: + """A record for the release that does not count, and why.""" + + approval: StoredApproval + reason: NotCountingReason + + def as_dict(self) -> dict[str, object]: + """Serialize the record's path and the reason.""" + return {"path": str(self.approval.path), "reason": self.reason.value} + + +@dataclass(frozen=True, slots=True) +class ApprovalCheck: + """What the approvals folder says about one release of a ``manual`` application.""" + + candidate: ReleaseCandidate + counting: StoredApproval | None + """The latest approval that counts, if any.""" + not_counting: tuple[NotCounting, ...] + + @property + def command(self) -> str: + """The command a person runs to approve the release.""" + return ( + f"cloudfall release approve {self.candidate.component_id} " + f"--release {self.candidate.release.value}" + ) + + def plan(self) -> dict[str, object]: + """Serialize the plan's ``approval``.""" + result: dict[str, object] = { + "policy": DeploymentApproval.MANUAL.value, + "status": "missing" if self.counting is None else "approved", + } + if self.counting is not None: + result["path"] = str(self.counting.path) + result["approver"] = self.counting.approver.value + result["expiresAt"] = _time(self.counting.expires_at) + result["notCounting"] = [entry.as_dict() for entry in self.not_counting] + if self.counting is None: + result["command"] = self.command + return result + + def used(self) -> dict[str, object]: + """Serialize the deploy result's ``approval``: the record it went on.""" + if self.counting is None: + message = "no approval counts, so the deploy used none" + raise TypeError(message) + return { + "policy": DeploymentApproval.MANUAL.value, + "path": str(self.counting.path), + "approver": self.counting.approver.value, + "approvedAt": _time(self.counting.approved_at), + } + + def refusal(self) -> LifecycleError: + """Return the ``deployment_approval_missing`` error naming the way out.""" + candidate = self.candidate + detail = ( + f"application {candidate.application_id} deploys only approved " + f"releases (approval: manual), and no approval counts for release " + f"{candidate.release.value} of component {candidate.component_id}; " + f"a person approves it at a terminal with `{self.command}`" + ) + if self.not_counting: + detail += "; not counting: " + ", ".join( + f"{entry.approval.path} ({entry.reason.value})" + for entry in self.not_counting + ) + return LifecycleError( + ERROR_DEPLOYMENT_APPROVAL_MISSING, + detail, + { + "application": candidate.application_id.value, + "component": candidate.component_id.value, + "release": candidate.release.value, + "command": self.command, + "notCounting": [entry.as_dict() for entry in self.not_counting], + }, + ) + + +def check_approval( + candidate: ReleaseCandidate, + directory: Path, + catalog: SchemaCatalog, + now: datetime, +) -> ApprovalCheck: + """Find the approval that counts for ``candidate`` at ``now``, an aware time. + + Every record in ``directory`` is read and validated first, so a broken + one raises ``ReleaseApprovalError`` (``release_approval_record_invalid``) + naming the file. Records about another application, component or release + are left out; the others either count or are listed with their reason. + """ + if now.utcoffset() is None: + message = f"the check time has no time zone: {now!r}" + raise ValueError(message) + counting: list[StoredApproval] = [] + not_counting: list[NotCounting] = [] + for stored in load_approvals(directory, catalog): + if not stored.names(candidate): + continue + reason = stored.reason_against(candidate, now) + if reason is None: + counting.append(stored) + else: + not_counting.append(NotCounting(stored, reason)) + latest = max(counting, key=lambda stored: stored.approved_at, default=None) + return ApprovalCheck(candidate, latest, tuple(not_counting)) + + +def load_approvals( + directory: Path, catalog: SchemaCatalog +) -> tuple[StoredApproval, ...]: + """Read every ``*.json`` record at the top of ``directory``, by file name.""" + if not directory.is_dir(): + return () + return tuple( + _load_approval(path, catalog) for path in sorted(directory.glob("*.json")) + ) + + +def _load_approval(path: Path, catalog: SchemaCatalog) -> StoredApproval: + try: + document = cast("object", json.loads(path.read_text(encoding="utf-8"))) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + message = f"release approval record is not valid JSON: {path}: {error}" + raise ReleaseApprovalError(ERROR_RECORD_INVALID, message) from error + except IsADirectoryError as error: + message = f"release approval record is a directory: {path}" + raise ReleaseApprovalError(ERROR_RECORD_INVALID, message) from error + if not isinstance(document, dict): + message = f"release approval record is not a JSON object: {path}" + raise ReleaseApprovalError(ERROR_RECORD_INVALID, message) + record = cast("Mapping[str, object]", document) + try: + catalog.validate_named(RELEASE_APPROVAL_SCHEMA, record) + except ValidationError as error: + field = ".".join(str(part) for part in error.absolute_path) or "(root)" + message = ( + f"release approval record does not match its schema: {path}: " + f"{field}: {error.message}" + ) + raise ReleaseApprovalError(ERROR_RECORD_INVALID, message) from error + spec = cast("Mapping[str, object]", record["spec"]) + try: + return StoredApproval( + path=path, + application_id=ResourceId.from_boundary(spec["application"]), + component_id=ResourceId.from_boundary(spec["component"]), + release=ReleaseId(cast("str", spec["release"])), + artifact_sha256=Sha256Digest(cast("str", spec["artifactSha256"])), + server_ids=frozenset( + ResourceId.from_boundary(server) + for server in cast("list[object]", spec["servers"]) + ), + approver=Approver(cast("str", spec["approver"])), + approved_at=_record_time(cast("str", spec["approvedAt"])), + expires_at=_record_time(cast("str", spec["expiresAt"])), + ) + except (ValueError, ReleaseApprovalError) as error: + message = f"release approval record is invalid: {path}: {error}" + raise ReleaseApprovalError(ERROR_RECORD_INVALID, message) from error + + +def _record_time(value: str) -> datetime: + moment = datetime.fromisoformat(value) + if moment.utcoffset() is None: + message = f"time without a time zone: {value!r}" + raise ValueError(message) + return moment.astimezone(UTC) diff --git a/sdk/tests/test_agent_server.py b/sdk/tests/test_agent_server.py index 9738c60..1a1715e 100644 --- a/sdk/tests/test_agent_server.py +++ b/sdk/tests/test_agent_server.py @@ -3,9 +3,12 @@ from __future__ import annotations import asyncio +import hashlib import json +import shutil import subprocess import sys +import tarfile import textwrap from pathlib import Path from typing import Any @@ -341,6 +344,86 @@ def test_a_project_call_cannot_approve_a_release(tmp_path: Path) -> None: assert approved["error"]["code"] == "UNKNOWN_TOOL" +def _project_with_artifact(tmp_path: Path, release: str) -> Path: + """Copy the examples (crm declares approval: manual) and build one release.""" + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + directory = project / "tmp" / "artifacts" / "crm-backend" + directory.mkdir(parents=True) + archive = directory / f"{release}.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + tar.add(project / "components", 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.name, + "archiveSha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "sizeBytes": archive.stat().st_size, + }, + } + (directory / f"{release}.json").write_text(json.dumps(metadata), encoding="utf-8") + return project + + +def test_s008_5_the_deploy_tool_refuses_an_unapproved_manual_release( + tmp_path: Path, +) -> None: + release = "20260101T000000Z-abcdef0" + project = _project_with_artifact(tmp_path, release) + call = {"component": "crm-backend", "release": release} + + tools, (planned, redirected, refused) = _talk( + _serve("--project", str(project), cwd=tmp_path), + ("deploy", call), + # The server binds the project's approvals folder: a call can't name one. + ("deploy", {**call, "yes": True, "approvals": str(tmp_path / "mine")}), + ("deploy", {**call, "yes": True}), + ) + + assert not any(name.startswith("release") for name in tools) + assert "approvals" not in tools["deploy"].input_schema["properties"] + assert planned["ok"] is True, planned + assert planned["data"]["approval"]["policy"] == "manual" + assert planned["data"]["approval"]["status"] == "missing" + assert redirected["error"]["code"] == "ARG_ERROR" + assert redirected["error"]["context"]["field"] == "approvals" + assert refused["ok"] is False + assert refused["error"]["code"] == "PRECONDITION", refused + assert refused["error"]["context"]["code"] == "deployment_approval_missing" + assert refused["error"]["context"]["command"] == ( + f"cloudfall release approve crm-backend --release {release}" + ) + assert not (project / "tmp" / "ansible-inventory.json").exists() + assert not (project / "tmp" / "releases").exists() + + +def test_s008_5_the_project_instructions_name_release_approve( + tmp_path: Path, +) -> None: + async def instructions() -> str | None: + async with ( + stdio_client(_serve("--project", str(EXAMPLES), cwd=tmp_path)) as ( + read, + write, + ), + ClientSession(read, write) as session, + ): + return (await session.initialize()).instructions + + told = asyncio.run(instructions()) + + assert told is not None + assert "cloudfall release approve" in told + assert "a person runs" in told + + def test_a_project_call_cannot_send_a_file_elsewhere_or_write_secrets_out( tmp_path: Path, ) -> None: diff --git a/sdk/tests/test_deploy_approval.py b/sdk/tests/test_deploy_approval.py new file mode 100644 index 0000000..59cbeea --- /dev/null +++ b/sdk/tests/test_deploy_approval.py @@ -0,0 +1,556 @@ +"""``deploy`` enforces an application's ``approval: manual`` (S-008). + +The engine the CLI is pointed at is a stand-in whose deploy playbook only +leaves a marker and a release receipt on the controller, so a test sees +whether ``ansible-playbook`` ran without contacting any host. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import shutil +import tarfile +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest +from cloudfall.app import app +from cloudfall.cli import main +from cloudfall.decision import ApprovalChannel +from cloudfall.domain import ReleaseId, ResourceId, Sha256Digest +from cloudfall.lifecycle import DeployOptions, EngineContext, LifecycleError, deploy +from cloudfall.release_approval import ( + Approver, + GitCommit, + ReleaseCandidate, + ValidityHours, + approve_release, + write_approval, +) +from cloudfall.validation import SchemaCatalog + +ROOT = Path(__file__).parents[2] +SCHEMAS = ROOT / "config" / "schemas" / "v1" +EXAMPLES = ROOT / "config" / "examples" +ENGINE = ROOT / "engine" +RELEASE = "20260101T000000Z-abcdef0" +OTHER_RELEASE = "20260102T000000Z-bcdef01" +COMMIT = "a" * 40 +COMMAND = f"cloudfall release approve crm-backend --release {RELEASE}" + +_RECEIPT_PLAY = """\ +--- +- name: Stand in for a deploy + hosts: localhost + connection: local + gather_facts: false + tasks: + - name: Write the marker + ansible.builtin.copy: + dest: {marker} + content: ran + mode: '0600' +{outcome} +""" + +_SUCCEED = """\ + - name: Make the receipt directory + ansible.builtin.file: + path: "{{ cloudfall_deploy_receipt_directory }}" + state: directory + mode: '0700' + - name: Write the release receipt + ansible.builtin.copy: + dest: "{{ cloudfall_deploy_receipt_directory }}/crm-backend.json" + content: '{}' + mode: '0600' +""" + +_FAIL_HEALTH_GATE = """\ + - name: Fail the health gate + ansible.builtin.fail: + msg: health check failed; rolled back to the previous release +""" + + +class _Terminal(io.StringIO): + """A stream that says it is a terminal, as a person's shell is.""" + + def isatty(self) -> bool: + return True + + +def _project(tmp_path: Path, approval: str = "manual") -> Path: + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + application = project / "applications" / "crm.yaml" + text = application.read_text(encoding="utf-8") + application.write_text( + text.replace("approval: manual", f"approval: {approval}"), encoding="utf-8" + ) + return project + + +def _write_artifact(project: Path, release: str = RELEASE) -> str: + """Build a consistent artifact under the project's default folder.""" + directory = project / "tmp" / "artifacts" / "crm-backend" + directory.mkdir(parents=True, exist_ok=True) + source = project / "tmp" / "source" + source.mkdir(exist_ok=True) + (source / "app.py").write_text(f"print('{release}')\n", encoding="utf-8") + archive = directory / f"{release}.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + tar.add(source, arcname=".") + _write_metadata(archive, release) + return hashlib.sha256(archive.read_bytes()).hexdigest() + + +def _write_metadata(archive: Path, release: str = RELEASE) -> None: + metadata = { + "apiVersion": "cloudfall/v1", + "kind": "Artifact", + "metadata": {"id": "crm-backend"}, + "spec": { + "component": "crm-backend", + "release": release, + "gitRef": "main", + "gitCommit": COMMIT, + "builtAt": "2026-01-01T00:00:00Z", + "archive": archive.name, + "archiveSha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "sizeBytes": archive.stat().st_size, + }, + } + archive.with_name(f"{release}.json").write_text( + json.dumps(metadata), encoding="utf-8" + ) + + +def _engine(tmp_path: Path, *, healthy: bool = True) -> Path: + """Lay out an engine whose deploy playbook only writes a marker and a receipt.""" + engine = tmp_path / ("engine" if healthy else "failing-engine") + playbooks = engine / "ansible" / "playbooks" + playbooks.mkdir(parents=True, exist_ok=True) + shutil.copy(ENGINE / "ansible" / "ansible.cfg", engine / "ansible") + (playbooks / "deploy.yml").write_text( + _RECEIPT_PLAY.format( + marker=tmp_path / "ran-deploy.yml", + outcome=_SUCCEED if healthy else _FAIL_HEALTH_GATE, + ), + encoding="utf-8", + ) + return engine + + +def _deploy( + project: Path, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + *flags: str, + healthy: bool = True, + release: str = RELEASE, +) -> tuple[int, dict[str, Any]]: + exit_code = main( + [ + *("deploy", "crm-backend", "--release", release), + *("--project", str(project), "--schemas", str(SCHEMAS)), + *("--engine", str(_engine(tmp_path, healthy=healthy))), + *("--inventory-file", str(tmp_path / "inventory.json")), + *flags, + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, dict) + return exit_code, payload + + +def _approve_at_terminal(project: Path, release: str = RELEASE) -> Path: + """Run ``release approve`` as a person at a terminal (S-008-2).""" + stdout = _Terminal() + exit_code = app.run( + [ + *("release", "approve", "crm-backend", "--release", release), + *("--project", str(project), "--schemas", str(SCHEMAS)), + *("--approver", "roman", "--format", "json"), + ], + stdin=_Terminal(f"{release}\n"), + stdout=stdout, + stderr=io.StringIO(), + ) + payload = json.loads(stdout.getvalue()) + assert exit_code == 0, payload + return Path(payload["data"]["path"]) + + +def _record( # noqa: PLR0913 - one record's every counted field. + project: Path, + *, + digest: str, + servers: tuple[str, ...] = ("h1", "h2"), + approved_at: datetime | None = None, + valid_for: int = 24, + component: str = "crm-backend", + release: str = RELEASE, + approver: str = "roman", +) -> Path: + """Write one approval record straight into the approvals folder.""" + approval = approve_release( + ReleaseCandidate( + application_id=ResourceId("crm"), + component_id=ResourceId(component), + release=ReleaseId(release), + artifact_sha256=Sha256Digest(digest), + git_commit=GitCommit(COMMIT), + server_ids=tuple(ResourceId(server) for server in servers), + ), + Approver(approver), + ApprovalChannel.TERMINAL, + ValidityHours(valid_for), + approved_at if approved_at is not None else datetime.now(UTC), + ) + return write_approval( + approval, project / "decisions" / "releases", SchemaCatalog(SCHEMAS) + ) + + +def _ran_nothing(project: Path, tmp_path: Path) -> bool: + return ( + not (tmp_path / "ran-deploy.yml").exists() + and not (tmp_path / "inventory.json").exists() + and not (project / "tmp" / "releases").exists() + ) + + +def _refusal(payload: dict[str, Any]) -> dict[str, Any]: + error = payload["error"] + assert isinstance(error, dict) + assert error["code"] == "PRECONDITION", error + assert error["context"]["code"] == "deployment_approval_missing" + return error + + +def test_s008_1_a_manual_application_without_approval_is_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path) + _write_artifact(project) + + exit_code, payload = _deploy(project, tmp_path, capsys, "--yes") + + assert exit_code == 4 + error = _refusal(payload) + for named in ("crm", "crm-backend", RELEASE, COMMAND): + assert named in error["message"] + context = error["context"] + assert context["command"] == COMMAND + assert context["application"] == "crm" + assert context["component"] == "crm-backend" + assert context["release"] == RELEASE + assert context["notCounting"] == [] + assert _ran_nothing(project, tmp_path) + + +def test_s008_3_an_approved_release_deploys_and_names_its_approval( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path) + _write_artifact(project) + path = _approve_at_terminal(project) + record = json.loads(path.read_text(encoding="utf-8"))["spec"] + + exit_code, payload = _deploy(project, tmp_path, capsys, "--yes") + + assert exit_code == 0, payload + data = payload["data"] + assert data["status"] == "ok" + assert data["effect"] == "updated" + assert data["approval"] == { + "policy": "manual", + "path": str(path), + "approver": record["approver"], + "approved_at": record["approvedAt"], + } + assert (tmp_path / "ran-deploy.yml").read_text(encoding="utf-8") == "ran" + assert (project / "tmp" / "releases" / "crm-backend.json").is_file() + + +def test_s008_6_a_record_for_another_archive_does_not_count( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path) + _write_artifact(project) + path = _approve_at_terminal(project) + # The release is rebuilt: the archive and its metadata agree, on new bytes. + archive = project / "tmp" / "artifacts" / "crm-backend" / f"{RELEASE}.tar.gz" + archive.write_bytes(archive.read_bytes() + b"rebuilt") + _write_metadata(archive) + + exit_code, payload = _deploy(project, tmp_path, capsys, "--yes") + + assert exit_code == 4 + error = _refusal(payload) + assert error["context"]["notCounting"] == [ + {"path": str(path), "reason": "digest_mismatch"} + ] + assert str(path) in error["message"] + assert _ran_nothing(project, tmp_path) + + +def test_s008_6_a_record_for_other_servers_does_not_count( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path) + digest = _write_artifact(project) + # Approved while the component ran on h1 alone; it is declared on h1, h2 now. + path = _record(project, digest=digest, servers=("h1",)) + + exit_code, payload = _deploy(project, tmp_path, capsys, "--yes") + + assert exit_code == 4 + assert _refusal(payload)["context"]["notCounting"] == [ + {"path": str(path), "reason": "servers_changed"} + ] + assert _ran_nothing(project, tmp_path) + + +def test_s008_6_an_expired_record_does_not_count( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path) + digest = _write_artifact(project) + path = _record( + project, + digest=digest, + approved_at=datetime.now(UTC) - timedelta(hours=2), + valid_for=1, + ) + + exit_code, payload = _deploy(project, tmp_path, capsys, "--yes") + + assert exit_code == 4 + assert _refusal(payload)["context"]["notCounting"] == [ + {"path": str(path), "reason": "expired"} + ] + assert _ran_nothing(project, tmp_path) + + +def test_s008_6_the_servers_are_compared_as_a_set( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path) + digest = _write_artifact(project) + path = _record(project, digest=digest, servers=("h2", "h1")) + + exit_code, payload = _deploy(project, tmp_path, capsys) + + assert exit_code == 0, payload + assert payload["data"]["approval"]["path"] == str(path) + + +def test_s008_7_a_record_for_another_release_or_component_does_not_count( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path) + digest = _write_artifact(project) + _record(project, digest=digest, release=OTHER_RELEASE) + _record(project, digest=digest, component="billing-backend") + + exit_code, payload = _deploy(project, tmp_path, capsys, "--yes") + + assert exit_code == 4 + # Neither is about this release, so neither is listed as not counting. + assert _refusal(payload)["context"]["notCounting"] == [] + assert _ran_nothing(project, tmp_path) + + +def test_s008_8_the_plan_shows_the_approval_before_and_after_it_is_given( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path) + _write_artifact(project) + + exit_code, before = _deploy(project, tmp_path, capsys) + + assert exit_code == 0, before + assert before["meta"]["dry_run"] is True + assert before["data"]["status"] == "plan" + assert before["data"]["approval"] == { + "policy": "manual", + "status": "missing", + "not_counting": [], + "command": COMMAND, + } + assert _ran_nothing(project, tmp_path) + + path = _approve_at_terminal(project) + record = json.loads(path.read_text(encoding="utf-8"))["spec"] + exit_code, after = _deploy(project, tmp_path, capsys) + + assert exit_code == 0, after + assert after["data"]["approval"] == { + "policy": "manual", + "status": "approved", + "path": str(path), + "approver": record["approver"], + "expires_at": record["expiresAt"], + "not_counting": [], + } + assert _ran_nothing(project, tmp_path) + + +def test_s008_8_the_latest_counting_approval_is_shown( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path) + digest = _write_artifact(project) + now = datetime.now(UTC) + _record(project, digest=digest, approved_at=now - timedelta(hours=2)) + latest = _record(project, digest=digest, approved_at=now, approver="amy") + stale = _record(project, digest="b" * 64, approved_at=now) + + exit_code, payload = _deploy(project, tmp_path, capsys) + + assert exit_code == 0, payload + approval = payload["data"]["approval"] + assert approval["path"] == str(latest) + assert approval["approver"] == "amy" + assert approval["not_counting"] == [ + {"path": str(stale), "reason": "digest_mismatch"} + ] + + +@pytest.mark.parametrize("yes", [False, True], ids=["plan", "yes"]) +def test_s008_9_an_automatic_application_needs_no_record( + tmp_path: Path, capsys: pytest.CaptureFixture[str], *, yes: bool +) -> None: + project = _project(tmp_path, approval="automatic") + _write_artifact(project) + approvals = project / "decisions" / "releases" + approvals.mkdir(parents=True) + (approvals / "junk.json").write_text("{not json", encoding="utf-8") + + exit_code, payload = _deploy(project, tmp_path, capsys, *(["--yes"] * yes)) + + assert exit_code == 0, payload + assert payload["data"]["approval"] == {"policy": "automatic"} + if yes: + assert payload["data"]["status"] == "ok" + assert (tmp_path / "ran-deploy.yml").is_file() + else: + assert payload["data"]["status"] == "plan" + assert _ran_nothing(project, tmp_path) + + +@pytest.mark.parametrize( + ("content", "says"), + [ + ("{not json", "not valid JSON"), + ('{"kind": "ReleaseApproval"}', "does not match its schema"), + ("[]", "not a JSON object"), + ], + ids=["not-json", "not-the-schema", "not-an-object"], +) +@pytest.mark.parametrize("yes", [False, True], ids=["plan", "yes"]) +def test_s008_10_a_broken_record_stops_a_manual_deploy( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + content: str, + says: str, + *, + yes: bool, +) -> None: + project = _project(tmp_path) + _write_artifact(project) + _approve_at_terminal(project) + broken = project / "decisions" / "releases" / "broken.json" + broken.write_text(content, encoding="utf-8") + + exit_code, payload = _deploy(project, tmp_path, capsys, *(["--yes"] * yes)) + + error = payload["error"] + assert error["code"] == "RECORD_INVALID", error + assert exit_code == 86 + assert error["context"]["code"] == "release_approval_record_invalid" + assert str(broken) in error["message"] + assert says in error["message"] + assert _ran_nothing(project, tmp_path) + + +def test_s008_15_a_failed_health_gate_keeps_the_approval_for_a_retry( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path) + _write_artifact(project) + path = _approve_at_terminal(project) + recorded = path.read_bytes() + + exit_code, failed = _deploy(project, tmp_path, capsys, "--yes", healthy=False) + + assert exit_code != 0 + assert failed["error"]["code"] == "ENGINE_STEP_FAILED", failed + assert path.read_bytes() == recorded + assert sorted((project / "decisions" / "releases").iterdir()) == [path] + + exit_code, retried = _deploy(project, tmp_path, capsys, "--yes") + + assert exit_code == 0, retried + assert retried["data"]["approval"]["path"] == str(path) + assert path.read_bytes() == recorded + + +def test_the_deploy_refuses_an_archive_swapped_after_the_check( + tmp_path: Path, +) -> None: + project = _project(tmp_path) + _write_artifact(project) + + with pytest.raises(LifecycleError) as error: + deploy( + EngineContext( + project_directory=project, + schema_directory=SCHEMAS, + engine_directory=_engine(tmp_path), + inventory_file=tmp_path / "inventory.json", + ), + ResourceId("crm-backend"), + ReleaseId(RELEASE), + project / "tmp" / "artifacts", + DeployOptions(approved_sha256=Sha256Digest("b" * 64)), + ) + + assert error.value.code == "lifecycle_artifact_digest_mismatch" + assert _ran_nothing(project, tmp_path) + + +def test_s008_10_deploy_declares_record_invalid( + capsys: pytest.CaptureFixture[str], +) -> None: + assert main(["deploy", "--schema"]) == 0 + described = json.loads(capsys.readouterr().out)["data"] + + assert "RECORD_INVALID" in json.dumps(described["exit_codes"]) + output = described["output_schema"] + assert output["properties"]["approval"]["type"] == "object" + assert "approval" in output["required"] + + +def test_s008_16_the_changelog_and_ci_guide_say_deploy_enforces_manual() -> None: + fragment = (ROOT / "changelog.d" / "106-enforce-manual-approval.md").read_text( + encoding="utf-8" + ) + guide = (ROOT / "docs" / "ci-deploy-guide.md").read_text(encoding="utf-8") + + breaking = fragment.split("### Breaking", 1)[1] + for named in ( + "`deploy --yes`", + "`migrate --yes`", + "`approval: manual`", + "`cloudfall release approve`", + "`approval: automatic`", + ): + assert named in breaking, named + assert "no command acts on" not in " ".join(guide.split()) + assert "cloudfall release approve" in guide