diff --git a/modules/cloudflare-pages/.dagger/src/cloudflare_pages/api.py b/modules/cloudflare-pages/.dagger/src/cloudflare_pages/api.py index b76a6df..8e9764f 100644 --- a/modules/cloudflare-pages/.dagger/src/cloudflare_pages/api.py +++ b/modules/cloudflare-pages/.dagger/src/cloudflare_pages/api.py @@ -16,6 +16,7 @@ CreatedDeployment, DeploymentsResponse, GitHubEvidence, + ListedPagesDeployment, PagesDeployment, PagesProject, PagesTarget, @@ -25,6 +26,7 @@ API_ORIGIN: Final = "https://api.cloudflare.com/client/v4" DEPLOYMENT_PAGE_SIZE: Final = 10 +PRE_DEPLOYMENT_STAGES: Final = frozenset({"queued", "initialize", "clone_repo", "build"}) ACCOUNT_REF_PATTERN: Final = re.compile(r"[A-Za-z0-9]{1,32}") CONTROL_PATTERN: Final = re.compile(r"[\x00-\x1f\x7f]+") BEARER_PATTERN: Final = re.compile(r"(?i)bearer\s+\S+") @@ -141,14 +143,10 @@ def select_deployment( ) -> PagesDeployment | None: """Select one exact successful direct upload or signal convergence.""" _require_pagination(response) - matching = tuple( - item - for item in response.result - if _deployment_matches(item, expected_sha, expected_deployment_id) - ) - if not matching: + candidate = _matching_candidate(response.result, expected_sha, expected_deployment_id) + if candidate is None: return None - return _qualified_deployment(matching[0], target, expected_project_id) + return _qualified_deployment(_strict_deployment(candidate), target, expected_project_id) def sanitize_error(error: ApiProblem) -> str: @@ -356,13 +354,13 @@ def _qualified_deployment( ) -> PagesDeployment | None: _require_deployment_identity(deployment, target, project_id) stage = deployment.latest_stage + if stage.status in ("failure", "canceled"): + raise CloudflarePolicyError("Cloudflare deployment failed") + if stage.name in PRE_DEPLOYMENT_STAGES: + return None if stage.name != "deploy": raise CloudflarePolicyError("Cloudflare deployment identity differs") - if stage.status in ("idle", "active"): - return None - if stage.status != "success": - raise CloudflarePolicyError("Cloudflare deployment failed") - return deployment + return deployment if stage.status == "success" else None def _require_deployment_identity( @@ -395,20 +393,38 @@ def _valid_deployment_url(value: str, target: PagesTarget, short_id: str) -> boo parsed.hostname, parsed.username, parsed.password, + parsed.path, parsed.query, parsed.fragment, parsed.port, ) - return identity == ("https", hostname, None, None, "", "", None) + return identity == ("https", hostname, None, None, "", "", "", None) def _deployment_matches( - deployment: PagesDeployment, source_sha: str, deployment_id: str | None + deployment: ListedPagesDeployment, source_sha: str, deployment_id: str | None ) -> bool: sha_matches = _deployment_sha(deployment) == source_sha return sha_matches and (deployment_id is None or deployment.id == deployment_id) +def _matching_candidate( + deployments: tuple[ListedPagesDeployment, ...], source_sha: str, deployment_id: str | None +) -> ListedPagesDeployment | None: + matching = tuple( + item for item in deployments if _deployment_matches(item, source_sha, deployment_id) + ) + return _one_matching_deployment(matching, deployment_id) if matching else None + + +def _one_matching_deployment( + matching: tuple[ListedPagesDeployment, ...], deployment_id: str | None +) -> ListedPagesDeployment: + if deployment_id is not None and len(matching) != 1: + raise CloudflarePolicyError("Cloudflare deployment identity differs") + return matching[0] + + def _require_source_binding(owner: str, repository: str, target: PagesTarget) -> None: expected = (target.repository.owner, target.repository.name) if (owner, repository) != expected: @@ -423,10 +439,17 @@ def _require_pagination(response: DeploymentsResponse) -> None: raise CloudflarePolicyError("Cloudflare deployment pagination differs") -def _deployment_sha(deployment: PagesDeployment) -> str: +def _deployment_sha(deployment: ListedPagesDeployment) -> str: return deployment.deployment_trigger.metadata.commit_hash +def _strict_deployment(deployment: ListedPagesDeployment) -> PagesDeployment: + try: + return PagesDeployment.model_validate_json(deployment.model_dump_json()) + except (ValidationError, ValueError, TypeError): + raise CloudflarePolicyError("Cloudflare response schema mismatch") from None + + def _require_account_ref(value: str) -> None: if ACCOUNT_REF_PATTERN.fullmatch(value) is None: raise CloudflarePolicyError("Cloudflare account identity is malformed") diff --git a/modules/cloudflare-pages/.dagger/src/cloudflare_pages/models.py b/modules/cloudflare-pages/.dagger/src/cloudflare_pages/models.py index 46c4341..13f8f83 100644 --- a/modules/cloudflare-pages/.dagger/src/cloudflare_pages/models.py +++ b/modules/cloudflare-pages/.dagger/src/cloudflare_pages/models.py @@ -18,6 +18,7 @@ ) CLOSED_MODEL: Final = ConfigDict(extra="forbid", frozen=True, strict=True) FULL_SHA_TEXT: Final = r"\A[0-9a-f]{40}\z" +PAGES_COMMIT_SHA_TEXT: Final = r"\A(?:[0-9a-f]{40})?\z" NUMERIC_ID_TEXT: Final = r"\A[1-9][0-9]*\z" TIMESTAMP_TEXT: Final = r"\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\z" DEPLOY_ROOT_PATTERN: Final = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") @@ -124,6 +125,14 @@ class DeploymentMetadata(ClosedModel): # type: ignore[explicit-any] # Pydantic commit_dirty: bool +class ListedDeploymentMetadata(ClosedModel): # type: ignore[explicit-any] # Pydantic v2 base stub + """Provider-list metadata including Cloudflare's historical empty hash.""" + + branch: str + commit_hash: str = Field(pattern=PAGES_COMMIT_SHA_TEXT) + commit_dirty: bool + + class DeploymentTrigger(ClosedModel): # type: ignore[explicit-any] # Pydantic v2 base stub """Documented direct-upload trigger and source metadata.""" @@ -131,6 +140,13 @@ class DeploymentTrigger(ClosedModel): # type: ignore[explicit-any] # Pydantic metadata: DeploymentMetadata +class ListedDeploymentTrigger(ClosedModel): # type: ignore[explicit-any] # Pydantic v2 base stub + """Projected trigger for a Pages deployment-list row.""" + + type: str + metadata: ListedDeploymentMetadata + + class PagesDeployment(ClosedModel): # type: ignore[explicit-any] # Pydantic v2 base stub """Projected raw deployment fields needed for exact verification.""" @@ -144,6 +160,19 @@ class PagesDeployment(ClosedModel): # type: ignore[explicit-any] # Pydantic v2 deployment_trigger: DeploymentTrigger +class ListedPagesDeployment(ClosedModel): # type: ignore[explicit-any] # Pydantic v2 base stub + """Strict provider-list row awaiting exact-SHA candidate promotion.""" + + id: str + short_id: str = Field(pattern=r"\A[a-f0-9]{8}\z") + url: str + project_id: str + project_name: str + environment: Literal["production", "preview"] + latest_stage: DeploymentStage + deployment_trigger: ListedDeploymentTrigger + + class ResultInfo(ClosedModel): # type: ignore[explicit-any] # Pydantic v2 base stub """Cloudflare list pagination returned for the fixed first page.""" @@ -168,7 +197,7 @@ class DeploymentsResponse(ClosedModel): # type: ignore[explicit-any] # Pydanti errors: tuple[ApiProblem, ...] messages: tuple[ApiProblem, ...] - result: tuple[PagesDeployment, ...] + result: tuple[ListedPagesDeployment, ...] success: bool result_info: ResultInfo diff --git a/modules/cloudflare-pages/.dagger/tests/test_api.py b/modules/cloudflare-pages/.dagger/tests/test_api.py index cf5d076..ce591d4 100644 --- a/modules/cloudflare-pages/.dagger/tests/test_api.py +++ b/modules/cloudflare-pages/.dagger/tests/test_api.py @@ -196,7 +196,7 @@ def test_should_wait_for_exact_full_sha_and_successful_deploy_stage() -> None: ( ("environment", "preview"), ("project_name", "almamesh"), - ("latest_stage", {"name": "build", "status": "success"}), + ("latest_stage", {"name": "promote", "status": "success"}), ( "deployment_trigger", { @@ -225,6 +225,63 @@ def test_should_ignore_wrong_sha_while_provider_converges() -> None: assert select_deployment(response, _target(), FULL_SHA, PROJECT_ID) is None +@pytest.mark.parametrize( + ("name", "status"), + (("queued", "idle"), ("initialize", "active"), ("clone_repo", "success"), ("build", "active")), +) +def test_should_wait_for_recognized_predeploy_stage(name: str, status: str) -> None: + # Given + deployment = _deployment() + deployment["latest_stage"] = {"name": name, "status": status} + response = parse_deployments_response(_deployments_payload(deployment)) + + # When / Then + assert select_deployment(response, _target(), FULL_SHA, PROJECT_ID) is None + + +def test_should_ignore_historical_empty_sha_even_for_requested_deployment_id() -> None: + # Given + legacy = _deployment("") + deployment_id = legacy["id"] + assert isinstance(deployment_id, str) + response = parse_deployments_response(_deployments_payload(legacy)) + + # When + selected = select_deployment(response, _target(), FULL_SHA, PROJECT_ID, deployment_id) + + # Then + assert selected is None + + +@pytest.mark.parametrize("commit_sha", (None, " ", "a" * 39, "A" * 40, "g" * 40)) +def test_should_reject_noncanonical_deployment_commit_sha(commit_sha: object) -> None: + # Given + deployment = _deployment() + trigger = deployment["deployment_trigger"] + assert isinstance(trigger, dict) + metadata = trigger["metadata"] + assert isinstance(metadata, dict) + metadata["commit_hash"] = commit_sha + + # When / Then + with pytest.raises(CloudflarePolicyError, match="schema"): + parse_deployments_response(_deployments_payload(deployment)) + + +def test_should_reject_missing_deployment_commit_sha() -> None: + # Given + deployment = _deployment() + trigger = deployment["deployment_trigger"] + assert isinstance(trigger, dict) + metadata = trigger["metadata"] + assert isinstance(metadata, dict) + metadata.pop("commit_hash") + + # When / Then + with pytest.raises(CloudflarePolicyError, match="schema"): + parse_deployments_response(_deployments_payload(deployment)) + + def test_should_reject_wrong_page_size() -> None: # Given payload = json.loads(_deployments_payload(_deployment())) @@ -268,6 +325,17 @@ def test_should_reject_credential_bearing_deployment_url() -> None: select_deployment(response, _target(), FULL_SHA, PROJECT_ID) +def test_should_reject_deployment_url_with_path() -> None: + # Given + deployment = _deployment() + deployment["url"] = "https://f64788e9.edge-reco.pages.dev/foreign" + response = parse_deployments_response(_deployments_payload(deployment)) + + # When / Then + with pytest.raises(CloudflarePolicyError, match="deployment identity"): + select_deployment(response, _target(), FULL_SHA, PROJECT_ID) + + def test_should_reject_hostname_not_equal_to_documented_short_id() -> None: deployment = _deployment() deployment["url"] = "https://foreign.edge-reco.pages.dev" @@ -286,6 +354,22 @@ def test_should_select_latest_when_same_sha_has_prior_deployments() -> None: assert selected.id == "f64788e9-fccd-4d4a-a28a-cb84f88f6" +def test_should_reject_duplicate_exact_created_deployment_id() -> None: + # Given + deployment = _deployment() + response = parse_deployments_response(_deployments_payload(deployment, deployment)) + + # When / Then + with pytest.raises(CloudflarePolicyError, match="deployment identity"): + select_deployment( + response, + _target(), + FULL_SHA, + PROJECT_ID, + "f64788e9-fccd-4d4a-a28a-cb84f88f6", + ) + + @pytest.mark.parametrize("status", ("failure", "canceled")) def test_should_reject_failed_deployment_stage(status: str) -> None: # Given diff --git a/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py b/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py index f9dcde2..9341034 100644 --- a/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py +++ b/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py @@ -334,6 +334,18 @@ def _deployment( } +def _with_commit_sha(deployment: dict[str, object], sha: str) -> dict[str, object]: + trigger = cast(dict[str, object], deployment["deployment_trigger"]) + metadata = cast(dict[str, object], trigger["metadata"]) + metadata["commit_hash"] = sha + return deployment + + +def _with_stage(deployment: dict[str, object], name: str, status: str) -> dict[str, object]: + deployment["latest_stage"] = {"name": name, "status": status} + return deployment + + def _provider_evidence() -> ProviderDeploymentEvidence: return ProviderDeploymentEvidence( "f64788e9-fccd-4d4a-a28a-cb84f88f6", @@ -770,6 +782,40 @@ async def test_should_ignore_old_same_sha_and_wait_for_created_id() -> None: assert operations.sleeps == [1] +@pytest.mark.asyncio +async def test_should_upload_when_historical_rows_have_empty_commit_sha() -> None: + # Given + legacy = _with_commit_sha(_deployment("success", "11111111-fccd-4d4a-a28a-cb84f88f6"), "") + current = _deployment("success") + operations = FakeOperations( + [_deployments_payload(legacy), _deployments_payload(legacy, current)] + ) + + # When + evidence = await deploy_verified_artifact( + operations, object(), _target(), _github_evidence(), AttemptIdentity("44", 2) + ) + + # Then + assert evidence.deployment_id == "f64788e9-fccd-4d4a-a28a-cb84f88f6" + assert f"upload:{FULL_SHA}" in operations.events + + +@pytest.mark.asyncio +async def test_should_timeout_when_created_deployment_has_empty_commit_sha() -> None: + # Given + empty_created = _with_commit_sha(_deployment("success"), "") + responses = [_deployments_payload()] + [_deployments_payload(empty_created)] * 5 + operations = FakeOperations(responses) + + # When / Then + with pytest.raises(CloudflarePolicyError, match="did not converge"): + await deploy_verified_artifact( + operations, object(), _target(), _github_evidence(), AttemptIdentity("44", 2) + ) + assert operations.sleeps == [1, 2, 4, 8] + + @pytest.mark.asyncio async def test_should_report_created_failure_despite_old_same_sha_success() -> None: old = _deployment("success", "11111111-fccd-4d4a-a28a-cb84f88f6") @@ -803,6 +849,27 @@ async def test_should_converge_with_bounded_exponential_delays() -> None: assert operations.sleeps == [1, 2] +@pytest.mark.asyncio +async def test_should_poll_through_documented_predeploy_stages() -> None: + # Given + operations = FakeOperations( + [ + _deployment_payload("absent"), + _deployments_payload(_with_stage(_deployment("active"), "queued", "idle")), + _deployments_payload(_with_stage(_deployment("active"), "build", "active")), + _deployment_payload(), + ] + ) + + # When + await deploy_verified_artifact( + operations, object(), _target(), _github_evidence(), AttemptIdentity("44", 2) + ) + + # Then + assert operations.sleeps == [1, 2] + + @pytest.mark.asyncio async def test_should_accept_success_after_final_bounded_delay() -> None: # Given