diff --git a/README.md b/README.md index 73ed417..fa13855 100644 --- a/README.md +++ b/README.md @@ -224,14 +224,19 @@ and drift audit to the live dashboard, is in ```console uv run cloudfall import render render.yaml --application acme --server h1 uv run cloudfall migrate --build web=main # show the plan -uv run cloudfall migrate --build web=main --yes # run it +uv run cloudfall migrate --build web=main --yes # run it; pauses at deploy:web +uv run cloudfall release approve web --release # a person, at a terminal +uv run cloudfall migrate --yes # resume: deploy, routes, TLS ``` The importer never guesses silently: `IMPORT-REPORT.md` records what was not imported, every assumption and every step required before cutover. The migration runs baseline, services, builds, health-gated deploys, routes, a DNS-verification pause, TLS and a final evidence pass, and resumes exactly -where it stopped. Databases move with `cloudfall data copy`, which verifies +where it stopped. An imported application declares `approval: manual`, so +the run pauses before its deploy until a person approves the built release +with `cloudfall release approve`; the resumed run deploys that release +without building again. Databases move with `cloudfall data copy`, which verifies per-table row counts and writes a receipt. See the [Render migration guide](docs/render-migration-guide.md) and the [Neon migration guide](docs/neon-migration-guide.md). diff --git a/changelog.d/165-migrate-approval-pause.md b/changelog.d/165-migrate-approval-pause.md new file mode 100644 index 0000000..1de337f --- /dev/null +++ b/changelog.d/165-migrate-approval-pause.md @@ -0,0 +1,3 @@ +### Changed + +- `migrate --yes` checks a `deploy:` step's approval as `deploy --yes` does: for an application that declares `approval: manual` and no approval that counts, it pauses at that step with `PARTIAL_FAILURE`, `context.code: deployment_approval_missing`, the built `release` and the `cloudfall release approve` `command`, and once a person approved it, `migrate --yes` resumes there with the release the completed `build:` step recorded, without building again and without needing `--build` again. The deploy step's detail gains `approval` (the record it went on), `migrate` gains `--approvals` (default `decisions/releases`, bound by `cloudfall mcp serve`) and the `RECORD_INVALID` exit for an approvals file that isn't a valid `ReleaseApproval`. `rollback` needs no approval: its plan gains `approval`, `{"policy": "manual", "required": false, "reason": ...}` for a `manual` application and `{"policy": "automatic"}` otherwise (#165) diff --git a/docs/render-migration-guide.md b/docs/render-migration-guide.md index 23bceaf..001946a 100644 --- a/docs/render-migration-guide.md +++ b/docs/render-migration-guide.md @@ -127,6 +127,8 @@ and validation rejects them if they do. ```console uv run cloudfall migrate --build acme-api=main uv run cloudfall migrate --build acme-api=main --yes +uv run cloudfall release approve acme-api --release +uv run cloudfall migrate --yes ``` Without `--yes` the command shows the plan. With it, the plan executes step @@ -134,7 +136,19 @@ by step: server baseline and firewall, infrastructure services, artifact builds, health-gated deployments, HTTP routes, a DNS-verification pause at the cutover moment, TLS issuance, and a final evidence pass requiring a compliant audit and healthy routes. Progress persists after every step, so -a failure or the DNS pause resumes exactly where it stopped. +a failure or a pause resumes exactly where it stopped. + +The importer writes `approval: manual` for every application, so the run +pauses at `deploy:acme-api` (exit 3, `PARTIAL_FAILURE`, with +`context.code: deployment_approval_missing`) once the release is built. The +answer names the built release and the exact `cloudfall release approve` +command. A person runs it at a terminal: it shows the git commit, the archive +SHA-256 and the servers, and records the approval under +`decisions/releases/` once the release id is typed back. `migrate --yes` then +resumes at the deploy step with the release the build step recorded, +without building again. When another gate, such as GitHub's required +reviewers, is the real one, switch the application to `approval: automatic` +and the run deploys without stopping. ## Step 6: migrate the data diff --git a/docs/specs/008-enforce-manual-approval.md b/docs/specs/008-enforce-manual-approval.md index aaefadd..34b3fc9 100644 --- a/docs/specs/008-enforce-manual-approval.md +++ b/docs/specs/008-enforce-manual-approval.md @@ -1,6 +1,6 @@ # S-008: Enforce manual approval -status: approved +status: built serves: G-2 ## Problem @@ -186,3 +186,22 @@ Touches `sdk/src/cloudfall/app.py` (`deploy`, `rollback`, `migrate`, new `releas - cloudfall-dev/cloudfall#165: S-008-13, S-008-14 ## Verification + +Every criterion ran through the CLI (or `cloudfall mcp serve`'s tool call) against a copy of the example project, with an engine whose playbooks stand in for the real ones: they run `ansible-playbook` on localhost and leave a marker and a release receipt, so no host was contacted. The S-008-14 artifact was built for real by `cloudfall-engine artifact build` from a local git repository. The terminal attestation ran with a stream that reports itself a terminal; no person typed at a real one, and no deploy reached a real server. + +- S-008-1: `test_s008_1_a_manual_application_without_approval_is_refused`, exit 4 `PRECONDITION` with `deployment_approval_missing`, the message named `crm`, `crm-backend`, the release and the command, `context.command` was the exact `release approve` command, and no marker, inventory or receipt was written +- S-008-2: `test_s008_2_a_person_approves_a_release_by_typing_its_id` and `test_s008_2_the_record_id_is_an_approval_time_and_16_hex`, exit 0 and one file under `decisions/releases/` that validated against `release-approval.schema.json` with every named field, `expiresAt` 24 hours after `approvedAt` and `via: terminal` +- S-008-3: `test_s008_3_an_approved_release_deploys_and_names_its_approval`, the deploy playbook ran and the result's `approval` carried the record's `path`, `approver` and `approved_at` +- S-008-4: `test_s008_4_off_a_terminal_nothing_is_approved` and `test_s008_4_a_mistyped_release_id_approves_nothing`, exit 4 `PERSON_REQUIRED` and `ATTESTATION_MISMATCH` with and without `--yes`, and the approvals folder stayed empty +- S-008-5: `test_s008_5_the_deploy_tool_refuses_an_unapproved_manual_release` and `test_s008_5_the_project_instructions_name_release_approve`, no `release_approve` tool was listed and the `deploy` tool with `yes: true` returned `PRECONDITION` with `deployment_approval_missing` +- S-008-6: `test_s008_6_a_record_for_another_archive_does_not_count`, `test_s008_6_a_record_for_other_servers_does_not_count`, `test_s008_6_an_expired_record_does_not_count` and `test_s008_6_the_servers_are_compared_as_a_set`, each refusal listed the record with `digest_mismatch`, `servers_changed` or `expired`, and servers in another order still counted +- S-008-7: `test_s008_7_a_record_for_another_release_or_component_does_not_count`, refused with `deployment_approval_missing` and neither record listed +- S-008-8: `test_s008_8_the_plan_shows_the_approval_before_and_after_it_is_given` and `test_s008_8_the_latest_counting_approval_is_shown`, exit 0 with no playbook run, `status: missing` with `command` before the approval and `status: approved` with `path`, `approver` and `expires_at` after it, the latest of two counting records shown +- S-008-9: `test_s008_9_an_automatic_application_needs_no_record`, plan and `--yes` both exit 0 with `approval: {"policy": "automatic"}` beside an approvals file that isn't JSON, and the deploy ran +- S-008-10: `test_s008_10_a_broken_record_stops_a_manual_deploy` and `test_s008_10_deploy_declares_record_invalid`, exit 86 `RECORD_INVALID` naming the file for invalid JSON, a schema mismatch and a non-object, plan and `--yes` alike, and `deploy --schema` declares the exit +- S-008-11: `test_s008_11_an_automatic_application_needs_no_approval`, `test_s008_11_a_missing_artifact_is_refused_before_asking` and `test_s008_11_a_tampered_artifact_is_refused_before_asking`, `PRECONDITION` with `release_approval_not_required`, `lifecycle_artifact_missing` and `lifecycle_artifact_digest_mismatch`, no prompt and no file +- S-008-12: `test_s008_12_a_validity_out_of_range_is_a_usage_error` and `test_s008_12_a_validity_of_two_hours_expires_two_hours_later`, `--valid-for 0` and `169` were usage errors naming `--valid-for`, and `2` wrote `expiresAt` two hours after `approvedAt` +- S-008-13: `test_s008_13_rollback_needs_no_approval`, for a `manual` application the plan showed `approval` with `policy: manual`, `required: false` and a reason, and `rollback --yes` ran the rollback playbook with no approvals folder at all; an `automatic` application's plan showed `{"policy": "automatic"}` +- S-008-14: `test_s008_14_migrate_pauses_for_an_approval_and_resumes_without_a_rebuild`, `migrate --build crm-backend=main --yes` exited 3 `PARTIAL_FAILURE` at `deploy:crm-backend` with `deployment_approval_missing`, the release the completed build step recorded and the `release approve` command, no deploy playbook ran; after `release approve`, with the source repository deleted so a second build could not succeed, `migrate --yes` completed `deploy:crm-backend` with that release and the record as its `approval`, and the artifacts folder held the same files. `test_s008_14_a_broken_approval_record_stops_the_deploy_step` (exit 86 `RECORD_INVALID`) and `test_s008_14_an_automatic_application_migrates_without_a_record` cover the step's other answers +- S-008-15: `test_s008_15_a_failed_health_gate_keeps_the_approval_for_a_retry`, the failed deploy exited `ENGINE_STEP_FAILED`, the record's bytes were unchanged and the retry deployed on the same record +- S-008-16: `test_s008_16_the_changelog_and_ci_guide_say_deploy_enforces_manual`, the fragment's `### Breaking` entry names `deploy --yes`, `migrate --yes`, `approval: manual`, `cloudfall release approve` and `approval: automatic`, and the CI guide names `cloudfall release approve` and no longer says no command acts on `approval` diff --git a/sdk/README.md b/sdk/README.md index 94f1a2b..3e1d5e9 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -51,6 +51,7 @@ engine's playbook contract for execution — never Ansible internals. Each operation is split into a pure, testable execution plan and a thin executor: ```console +uv run cloudfall release approve --project config/examples crm-backend --release uv run cloudfall deploy --project config/examples crm-backend --release --yes uv run cloudfall rollback --project config/examples crm-backend --release --yes uv run cloudfall restart --project config/examples crm-backend --yes @@ -63,6 +64,16 @@ servers only with `--yes`. Without it they run every controller-side check DNS for a certificate) and print a `plan` naming the target servers, exit `0`, so a request can be reviewed before it runs. +The example application `crm` declares `approval: manual`, so `deploy --yes` +first needs a person's approval of that release: `release approve` asks them +to type the release id at a terminal and records it under +`decisions/releases/`, naming the archive SHA-256 and the servers. Without a +record that counts, `deploy --yes` exits `PRECONDITION` with +`deployment_approval_missing` and `migrate --yes` pauses at the deploy step; +`deploy`'s plan shows `approval` with the command to run. `rollback` and +`restart` ship no new code and need no approval. An application that +declares `approval: automatic` deploys without a record. + `domains apply ` runs `domains.yml` on the Domain's proxy server alone and renders only that Domain, so no baseline, firewall or service task runs; it is safe on a server whose baseline another project owns. With diff --git a/sdk/src/cloudfall/agent_tools.py b/sdk/src/cloudfall/agent_tools.py index 8307fbd..d350a4d 100644 --- a/sdk/src/cloudfall/agent_tools.py +++ b/sdk/src/cloudfall/agent_tools.py @@ -15,6 +15,7 @@ StepRunner, subprocess_step, ) +from cloudfall.release_approval import APPROVALS_DIRECTORY from cloudfall.secrets import SopsSecretProvider if TYPE_CHECKING: @@ -40,6 +41,8 @@ class AgentConfig: environment_directory: Path = Path("tmp/env") environment_receipts_directory: Path = Path("tmp/env-receipts") proposals_directory: Path = Path("tmp/operator/proposals") + approvals_directory: Path = Path(APPROVALS_DIRECTORY) + """Release approvals a ``manual`` application's deploy step reads.""" gateway_ca_path: Path | None = None gateway_certificate_path: Path | None = None gateway_key_path: Path | None = None diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 490dad9..4744c4e 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -249,6 +249,7 @@ from cloudfall.release_approval import ( APPROVALS_DIRECTORY, AUTOMATIC_PLAN, + ERROR_DEPLOYMENT_APPROVAL_MISSING, VALID_FOR_DEFAULT, VALID_FOR_MAXIMUM, VALID_FOR_MINIMUM, @@ -257,9 +258,9 @@ ReleaseApprovalError, ValidityHours, approve_release, - check_approval, - deployment_candidate, + deployment_approval, release_candidate, + rollback_approval, write_approval, ) from cloudfall.release_approval import ( @@ -3118,6 +3119,14 @@ class MigrateArgs(EngineArgs): default=Path("tmp/migrate/plan.json"), description="Persisted migration plan (default: tmp/migrate/plan.json)", ) + approvals: Path = Flag( + default=Path(APPROVALS_DIRECTORY), + description=( + "Directory of release approval records a deploy step reads for an " + f"application that declares approval: manual (default: " + f"{APPROVALS_DIRECTORY})" + ), + ) build: tuple[str, ...] = Flag( default=(), description="Build a component from a git ref, as COMPONENT=REF (repeatable)", @@ -3176,6 +3185,7 @@ def __post_init__(self) -> None: "releases", "artifacts", "plan_file", + "approvals", "secrets_dir", ): _inside_project(getattr(self, flag), flag.replace("_", "-")) @@ -3233,6 +3243,7 @@ def config(self, fleet: Fleet, ctx: Ctx) -> AgentConfig: releases_directory=fleet.path(self.releases), artifacts_directory=fleet.path(self.artifacts), secrets_directory=fleet.path(self.secrets_dir), + approvals_directory=fleet.path(self.approvals), run=_step_runner(ctx, fleet.directory), decrypt=_sops_decrypt(ctx), ) @@ -3286,6 +3297,7 @@ class Migration: "PRECONDITION", "PARTIAL_FAILURE", "ENGINE_STEP_FAILED", + "RECORD_INVALID", ], timeout=None, supports_raw_payload=True, @@ -3332,7 +3344,21 @@ def _migration_failure( cause = {"step": migration.step, **error} if migration.status == "paused": message = f"the migration paused at {migration.step}; resume it with --yes" + if cause.get("code") == ERROR_DEPLOYMENT_APPROVAL_MISSING: + # A person approves the built release first (S-008-14). + message = ( + f"the migration paused at {migration.step}: release " + f"{cause['release']} needs a person's approval; run " + f"`{cause['command']}` at a terminal, then resume it with --yes" + ) return Exit.PARTIAL_FAILURE(message, context=cause, data=migration) + if ( + migration.status == "error" + and cause.get("code") == ERROR_APPROVAL_RECORD_INVALID + ): + # A broken approval record stops the deploy step as it stops deploy. + message = f"the migration stopped at {migration.step}: {cause['message']}" + return Exit.RECORD_INVALID(message, context=cause, data=migration) if ( migration.status == "error" and cause.get("code") == ERROR_DATA_COPY_PREFLIGHT_REFUSED @@ -4010,17 +4036,14 @@ def _deploy_approval( 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, + return deployment_approval( + fleet.inventory, + args.component, + args.release, + artifacts, fleet.path(args.approvals), - SchemaCatalog(args.schemas), - datetime.now(UTC), + args.schemas, ) except ReleaseApprovalError as error: if error.code == ERROR_APPROVAL_RECORD_INVALID: @@ -4125,8 +4148,13 @@ def rollback_command(args: RollbackArgs, ctx: Ctx, fleet: Fleet) -> RollbackPayl context = args.context(fleet, ctx) try: if not args.yes: + # A rollback never waits for an approval, even for a manual + # application; the plan says so and why (S-008-13). return RollbackPayload( - _plan(preview_rollback(context, args.component, args.release)) + { + **_plan(preview_rollback(context, args.component, args.release)), + "approval": rollback_approval(fleet.inventory, args.component), + } ) result = rollback(context, args.component, args.release) except LifecycleError as error: diff --git a/sdk/src/cloudfall/commands.py b/sdk/src/cloudfall/commands.py index 225fa43..1e5d654 100644 --- a/sdk/src/cloudfall/commands.py +++ b/sdk/src/cloudfall/commands.py @@ -511,9 +511,12 @@ def _plan(subject: str, *extra: str) -> OutputShape: "rollback", CommandEffect.SERVERS, "switch one component back to an existing release", - gate=_YES, + gate=( + f"{_YES}; it needs no approval, and for an application that declares " + "`approval: manual` the plan's `approval` says so and why" + ), output=( - _plan("component", "release", "releaseCommandSkipped?"), + _plan("component", "release", "releaseCommandSkipped?", "approval"), _shape(*_EXECUTED, "release", when="with `--yes`"), ), ), @@ -599,7 +602,11 @@ def _plan(subject: str, *extra: str) -> OutputShape: "migrate", CommandEffect.SERVERS, "run the resumable end-to-end migration plan", - gate=_YES, + gate=( + f"{_YES}; a `deploy:` step of an application that declares " + "`approval: manual` pauses until a person runs `cloudfall release " + "approve` for the built release" + ), output=( _shape( "status", diff --git a/sdk/src/cloudfall/migrate.py b/sdk/src/cloudfall/migrate.py index dda44c6..8bea3fc 100644 --- a/sdk/src/cloudfall/migrate.py +++ b/sdk/src/cloudfall/migrate.py @@ -44,6 +44,11 @@ UtcTimestamp, build_operations_view, ) +from cloudfall.release_approval import ( + ERROR_DEPLOYMENT_APPROVAL_MISSING, + ReleaseApprovalError, + deployment_approval, +) from cloudfall.service_evidence import ( DeploymentReceiptSet, DomainObservationSet, @@ -61,6 +66,7 @@ from cloudfall.agent_tools import AgentConfig from cloudfall.inventory import DomainInventory, ServerInventory + from cloudfall.release_approval import ApprovalCheck from cloudfall.secrets import OwnedRolePassword Runner = Callable[[], dict[str, object]] @@ -77,7 +83,10 @@ _ERROR_PARALLEL_RUN = "migrate_parallel_run_failed" _MAX_CUTOVER_TTL_SECONDS = 300 _ROLLBACK_WINDOW_HOURS = 24 -_PAUSE_CODES = frozenset({_ERROR_DNS_UNVERIFIED, _ERROR_TTL_HIGH}) +_PAUSE_CODES = frozenset( + {_ERROR_DNS_UNVERIFIED, _ERROR_TTL_HIGH, ERROR_DEPLOYMENT_APPROVAL_MISSING} +) +"""Step codes that wait for a person: the run pauses there, it did not fail.""" _ERROR_AUDIT_DRIFT = "migrate_audit_drift" _ERROR_ROUTES_UNHEALTHY = "migrate_routes_unhealthy" _ERROR_DATABASE_UNKNOWN = "migrate_database_unknown" @@ -157,6 +166,7 @@ def execute_migration( inventory = PlatformInventory.from_state(state) _require_known_components(inventory, options) steps = _load_plan(options, _computed_steps(inventory, options)) + _require_built_components(inventory, options, steps) if not options.execute: return _envelope("plan", steps) @@ -186,11 +196,7 @@ def execute_migration( except (MigrateError, LifecycleError) as error: _save_plan(options.plan_file, steps) envelope = _envelope( - "paused" - if isinstance(error, MigrateError) - and error.code in _PAUSE_CODES - else "error", - steps, + "paused" if error.code in _PAUSE_CODES else "error", steps ) envelope["step"] = step.step_id envelope["error"] = error.as_dict()["error"] @@ -321,16 +327,27 @@ def _require_known_components( f"{', '.join(unknown)}" ) raise MigrateError(_ERROR_COMPONENT_UNKNOWN, detail) - unbuilt = sorted( - declared - set(options.builds) - set(options.releases) - ) + _require_known_databases(inventory, options) + + +def _require_built_components( + inventory: PlatformInventory, options: MigrateOptions, steps: list[PlanStep] +) -> None: + """Refuse a component with no ref, no release, and no completed build. + + A resumed run needs no ``--build`` for a component whose build step the + saved plan already completed: it deploys the release that step recorded + (S-008-14). + """ + declared = {component.resource_id.value for component in inventory.components} + built = set(_recover_releases(steps, options)) | set(options.builds) + unbuilt = sorted(declared - built) if unbuilt: detail = ( "declare a git ref (--build component=ref) or a pinned release " f"(--release component=id) for: {', '.join(unbuilt)}" ) raise MigrateError(_ERROR_COMPONENT_UNBUILT, detail) - _require_known_databases(inventory, options) def _require_known_databases( @@ -508,19 +525,13 @@ def _run() -> dict[str, object]: f"no built or pinned release recorded for {component_id}" ) raise MigrateError(_ERROR_RELEASE_MISSING, detail) - result = deploy( - context, + return _deploy_step( + config, + inventory, ResourceId(component_id), ReleaseId(release), - config.artifacts_directory, - DeployOptions( - environment_file=options.environment_files.get( - component_id - ), - receipt_directory=config.releases_directory, - ), + options.environment_files.get(component_id), ) - return result.as_dict() return _run @@ -543,6 +554,63 @@ def _run() -> dict[str, object]: return runners +def _deploy_step( + config: AgentConfig, + inventory: PlatformInventory, + component_id: ResourceId, + release: ReleaseId, + environment_file: Path | None, +) -> dict[str, object]: + """Deploy one release, once a ``manual`` application's release is approved.""" + approval = _deploy_approval(config, inventory, component_id, release) + # A manual application's release deploys only once a person approved + # it; the run pauses here and resumes once they have. + if approval is not None and approval.counting is None: + raise approval.refusal() + result = deploy( + config.context(), + component_id, + release, + config.artifacts_directory, + DeployOptions( + environment_file=environment_file, + receipt_directory=config.releases_directory, + # The deploy refuses an archive swapped after the check. + approved_sha256=( + None if approval is None else approval.candidate.artifact_sha256 + ), + ), + ) + if approval is None: + return result.as_dict() + return {**result.as_dict(), "approval": approval.used()} + + +def _deploy_approval( + config: AgentConfig, + inventory: PlatformInventory, + component_id: ResourceId, + release: ReleaseId, +) -> ApprovalCheck | None: + """Check a deploy step's approval as ``deploy`` does (S-008-14). + + A broken approval record fails the step with its own code, so the + command answers it as ``deploy`` does, with ``RECORD_INVALID``. + """ + try: + return deployment_approval( + inventory, + component_id, + release, + config.artifacts_directory, + # The default is relative: it is the project's folder. + config.project_directory / config.approvals_directory, + config.schema_directory, + ) + except ReleaseApprovalError as error: + raise LifecycleError(error.code, error.detail) from error + + def _data_runner( config: AgentConfig, inventory: PlatformInventory, diff --git a/sdk/src/cloudfall/release_approval.py b/sdk/src/cloudfall/release_approval.py index 85e889b..ba8e460 100644 --- a/sdk/src/cloudfall/release_approval.py +++ b/sdk/src/cloudfall/release_approval.py @@ -24,13 +24,13 @@ from cloudfall.decision import ApprovalChannel from cloudfall.domain import DeploymentApproval, ReleaseId, ResourceId, Sha256Digest from cloudfall.lifecycle import LifecycleError, find_component, verify_artifact +from cloudfall.validation import SchemaCatalog if TYPE_CHECKING: from collections.abc import Mapping from pathlib import Path from cloudfall.inventory import PlatformInventory - from cloudfall.validation import SchemaCatalog RELEASE_APPROVAL_SCHEMA = "release-approval.schema.json" APPROVALS_DIRECTORY = "decisions/releases" @@ -195,16 +195,11 @@ def deployment_candidate( that does not verify. """ component = find_component(inventory, component_id) - applications = { - application.resource_id: application - for application in inventory.applications - } - application = applications[component.application_id] - if application.approval is DeploymentApproval.AUTOMATIC: + if application_approval(inventory, component_id) is DeploymentApproval.AUTOMATIC: return None artifact = verify_artifact(artifacts, component_id, release, schemas) return ReleaseCandidate( - application_id=application.resource_id, + application_id=component.application_id, component_id=component_id, release=release, artifact_sha256=Sha256Digest(artifact.sha256), @@ -213,6 +208,21 @@ def deployment_candidate( ) +def application_approval( + inventory: PlatformInventory, component_id: ResourceId +) -> DeploymentApproval: + """Return the ``approval`` the component's application declares. + + Raises ``LifecycleError`` for an undeclared component. + """ + component = find_component(inventory, component_id) + return next( + application.approval + for application in inventory.applications + if application.resource_id == component.application_id + ) + + @dataclass(frozen=True, slots=True) class ReleaseApproval: """One person's approval of one release, valid until it expires.""" @@ -487,6 +497,56 @@ def check_approval( return ApprovalCheck(candidate, latest, tuple(not_counting)) +def deployment_approval( # noqa: PLR0913 - one deploy's whole approval question. + inventory: PlatformInventory, + component_id: ResourceId, + release: ReleaseId, + artifacts: Path, + approvals: Path, + schemas: Path, +) -> ApprovalCheck | None: + """Check the release's approval now; ``None`` for an ``automatic`` application. + + An ``automatic`` application's approvals folder is never read. Raises + ``LifecycleError`` for an undeclared component or an artifact that does + not verify, and ``ReleaseApprovalError`` for a broken approval record. + """ + candidate = deployment_candidate( + inventory, component_id, release, artifacts, schemas + ) + if candidate is None: + return None + return check_approval( + candidate, approvals, SchemaCatalog(schemas), datetime.now(UTC) + ) + + +# rollback needs no approval (S-008-13). + +ROLLBACK_NOT_REQUIRED_REASON = ( + "a rollback only activates a release still retained on the servers, which " + "went live through a deploy; it is the recovery path the health gate takes " + "on its own, so it does not wait for a person's approval" +) + + +def rollback_approval( + inventory: PlatformInventory, component_id: ResourceId +) -> dict[str, object]: + """Serialize a rollback plan's ``approval``: never required. + + Raises ``LifecycleError`` for an undeclared component. + """ + policy = application_approval(inventory, component_id) + if policy is DeploymentApproval.AUTOMATIC: + return dict(AUTOMATIC_PLAN) + return { + "policy": policy.value, + "required": False, + "reason": ROLLBACK_NOT_REQUIRED_REASON, + } + + def load_approvals( directory: Path, catalog: SchemaCatalog ) -> tuple[StoredApproval, ...]: diff --git a/sdk/tests/test_agent_server.py b/sdk/tests/test_agent_server.py index 1a1715e..aeca94b 100644 --- a/sdk/tests/test_agent_server.py +++ b/sdk/tests/test_agent_server.py @@ -389,6 +389,8 @@ def test_s008_5_the_deploy_tool_refuses_an_unapproved_manual_release( assert not any(name.startswith("release") for name in tools) assert "approvals" not in tools["deploy"].input_schema["properties"] + # migrate's deploy steps read the same bound folder (S-008-14). + assert "approvals" not in tools["migrate"].input_schema["properties"] assert planned["ok"] is True, planned assert planned["data"]["approval"]["policy"] == "manual" assert planned["data"]["approval"]["status"] == "missing" diff --git a/sdk/tests/test_migrate_approval.py b/sdk/tests/test_migrate_approval.py new file mode 100644 index 0000000..d548f51 --- /dev/null +++ b/sdk/tests/test_migrate_approval.py @@ -0,0 +1,320 @@ +"""``migrate`` waits for a person's approval; ``rollback`` never does (S-008). + +The engine the CLI is pointed at is a stand-in whose playbooks only leave a +marker (and, for a deploy, a release receipt) on the controller, so a test +sees which ``ansible-playbook`` ran without contacting any host. The artifact +is built for real, by the engine's ``artifact build``, from a local git +repository. +""" + +from __future__ import annotations + +import io +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest +from cloudfall.app import app +from cloudfall.cli import main + +ROOT = Path(__file__).parents[2] +SCHEMAS = ROOT / "config" / "schemas" / "v1" +EXAMPLES = ROOT / "config" / "examples" +ENGINE = ROOT / "engine" +RELEASE = "20260101T000000Z-abcdef0" + +_STAND_IN_PLAY = """\ +--- +- name: Stand in for {playbook} + hosts: localhost + connection: local + gather_facts: false + tasks: + - name: Write the marker + ansible.builtin.copy: + dest: {marker} + content: ran + mode: '0600' +{receipt} +""" + +_RECEIPT = """\ + - 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' +""" + +_PLAYBOOKS = ( + "baseline.yml", + "services.yml", + "deploy.yml", + "inspect.yml", + "rollback.yml", +) + + +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 _git(*arguments: str) -> None: + subprocess.run( # noqa: S603 - fixture repository setup. + [ + shutil.which("git") or "git", + "-c", + "user.email=test@example.test", + "-c", + "user.name=Cloudfall Test", + *arguments, + ], + capture_output=True, + check=True, + ) + + +def _repository(tmp_path: Path) -> Path: + repository = tmp_path / "repository" + repository.mkdir() + _git("-C", str(repository), "-c", "init.defaultBranch=main", "init", "--quiet") + (repository / "app.py").write_text("print('crm')\n", encoding="utf-8") + _git("-C", str(repository), "add", ".") + _git("-C", str(repository), "commit", "--quiet", "--message", "initial") + return repository + + +def _project(tmp_path: Path, repository: Path | None = None) -> Path: + """Copy the example (``approval: manual``) without its domains. + + Without domains the plan has no DNS steps, so nothing here leaves the + machine; the component builds from the local repository. + """ + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + shutil.rmtree(project / "domains") + if repository is not None: + component = project / "components" / "crm-backend.yaml" + component.write_text( + component.read_text(encoding="utf-8").replace( + "https://github.com/example/crm-backend.git", f"file://{repository}" + ), + encoding="utf-8", + ) + return project + + +def _engine(tmp_path: Path) -> Path: + """Lay out an engine whose playbooks only write a marker each.""" + engine = tmp_path / "engine" + playbooks = engine / "ansible" / "playbooks" + playbooks.mkdir(parents=True) + shutil.copy(ENGINE / "ansible" / "ansible.cfg", engine / "ansible") + for playbook in _PLAYBOOKS: + (playbooks / playbook).write_text( + _STAND_IN_PLAY.format( + playbook=playbook, + marker=_marker(tmp_path, playbook), + receipt=_RECEIPT if playbook == "deploy.yml" else "", + ), + encoding="utf-8", + ) + return engine + + +def _marker(tmp_path: Path, playbook: str) -> Path: + return tmp_path / f"ran-{playbook}" + + +def _run( + project: Path, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + *arguments: str, +) -> tuple[int, dict[str, Any]]: + exit_code = main( + [ + *arguments, + *("--project", str(project), "--schemas", str(SCHEMAS)), + *("--engine", str(_engine_once(tmp_path))), + *("--inventory-file", str(tmp_path / "inventory.json")), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, dict) + return exit_code, payload + + +def _engine_once(tmp_path: Path) -> Path: + engine = tmp_path / "engine" + return engine if engine.is_dir() else _engine(tmp_path) + + +def _approve_at_terminal(project: Path, release: str) -> 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 _saved_step(project: Path, step_id: str) -> dict[str, Any]: + """Return one step as the plan file saved it. + + The run goes on past the deploy step to ``inspect`` and ``audit``, which + the stand-in engine leaves without observations, so the saved plan, not + the run's answer, says how the deploy step ended. + """ + plan = json.loads( + (project / "tmp" / "migrate" / "plan.json").read_text(encoding="utf-8") + ) + return next(step for step in plan["steps"] if step["id"] == step_id) + + +def _step(payload: dict[str, Any], step_id: str) -> dict[str, Any]: + """Return one step of the plan, from a result or beside an error.""" + assert payload["data"] is not None, payload["error"] + return next(step for step in payload["data"]["steps"] if step["id"] == step_id) + + +def test_s008_14_migrate_pauses_for_an_approval_and_resumes_without_a_rebuild( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + project = _project(tmp_path, repository) + + exit_code, paused = _run( + project, tmp_path, capsys, "migrate", "--build", "crm-backend=main", "--yes" + ) + + error = paused["error"] + assert error["code"] == "PARTIAL_FAILURE", error + assert exit_code == 3 + context = error["context"] + assert context["step"] == "deploy:crm-backend" + assert context["code"] == "deployment_approval_missing" + built = _step(paused, "build:crm-backend") + assert built["status"] == "completed" + release = built["detail"]["release"] + assert context["release"] == release + command = f"cloudfall release approve crm-backend --release {release}" + assert context["command"] == command + assert command in error["message"] + assert paused["data"]["status"] == "paused" + assert _step(paused, "deploy:crm-backend")["status"] == "pending" + assert not _marker(tmp_path, "deploy.yml").exists() + assert not (project / "tmp" / "releases").exists() + + path = _approve_at_terminal(project, release) + # A second build has nothing to clone from: the resumed run must reuse + # the release the completed build step recorded. + shutil.rmtree(repository) + archives = sorted((project / "tmp" / "artifacts" / "crm-backend").iterdir()) + + _run(project, tmp_path, capsys, "migrate", "--yes") + + deployed = _saved_step(project, "deploy:crm-backend") + assert deployed["status"] == "completed", deployed + assert deployed["detail"]["release"] == release + assert deployed["detail"]["approval"]["path"] == str(path) + assert _marker(tmp_path, "deploy.yml").read_text(encoding="utf-8") == "ran" + assert (project / "tmp" / "releases" / "crm-backend.json").is_file() + assert sorted((project / "tmp" / "artifacts" / "crm-backend").iterdir()) == archives + + +def test_s008_14_a_broken_approval_record_stops_the_deploy_step( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + project = _project(tmp_path, repository) + approvals = project / "decisions" / "releases" + approvals.mkdir(parents=True) + broken = approvals / "broken.json" + broken.write_text("{not json", encoding="utf-8") + + exit_code, payload = _run( + project, tmp_path, capsys, "migrate", "--build", "crm-backend=main", "--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 not _marker(tmp_path, "deploy.yml").exists() + + +def test_s008_14_an_automatic_application_migrates_without_a_record( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + project = _project(tmp_path, repository) + application = project / "applications" / "crm.yaml" + application.write_text( + application.read_text(encoding="utf-8").replace( + "approval: manual", "approval: automatic" + ), + encoding="utf-8", + ) + + _run(project, tmp_path, capsys, "migrate", "--build", "crm-backend=main", "--yes") + + deployed = _saved_step(project, "deploy:crm-backend") + assert deployed["status"] == "completed", deployed + assert "approval" not in deployed["detail"] + assert _marker(tmp_path, "deploy.yml").read_text(encoding="utf-8") == "ran" + + +@pytest.mark.parametrize("approval", ["manual", "automatic"]) +def test_s008_13_rollback_needs_no_approval( + tmp_path: Path, capsys: pytest.CaptureFixture[str], approval: str +) -> None: + project = _project(tmp_path) + application = project / "applications" / "crm.yaml" + application.write_text( + application.read_text(encoding="utf-8").replace( + "approval: manual", f"approval: {approval}" + ), + encoding="utf-8", + ) + rollback = ("rollback", "crm-backend", "--release", RELEASE) + + exit_code, plan = _run(project, tmp_path, capsys, *rollback) + + assert exit_code == 0, plan + assert plan["data"]["status"] == "plan" + if approval == "manual": + assert plan["data"]["approval"]["policy"] == "manual" + assert plan["data"]["approval"]["required"] is False + assert "recovery" in plan["data"]["approval"]["reason"] + else: + assert plan["data"]["approval"] == {"policy": "automatic"} + assert not _marker(tmp_path, "rollback.yml").exists() + + exit_code, done = _run(project, tmp_path, capsys, *rollback, "--yes") + + assert exit_code == 0, done + assert done["data"]["status"] == "ok" + assert _marker(tmp_path, "rollback.yml").read_text(encoding="utf-8") == "ran" + assert not (project / "decisions" / "releases").exists()