Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 38 additions & 15 deletions modules/cloudflare-pages/.dagger/src/cloudflare_pages/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
CreatedDeployment,
DeploymentsResponse,
GitHubEvidence,
ListedPagesDeployment,
PagesDeployment,
PagesProject,
PagesTarget,
Expand All @@ -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+")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand Down
31 changes: 30 additions & 1 deletion modules/cloudflare-pages/.dagger/src/cloudflare_pages/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -124,13 +125,28 @@ 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."""

type: str
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."""

Expand All @@ -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."""

Expand All @@ -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

Expand Down
86 changes: 85 additions & 1 deletion modules/cloudflare-pages/.dagger/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down Expand Up @@ -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()))
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
67 changes: 67 additions & 0 deletions modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down