diff --git a/README.md b/README.md index d5e96ac..a332ca7 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ The shared modules are: - `portfolio-foundation`: exact source identity, full-history repository guard, deterministic artifact envelopes, envelope verification, and exact-current-`main` GitHub evidence; -- `cloudflare-pages`: fail-closed Pages preflight, one pinned Wrangler direct upload, and - deployment/live convergence bound to the created deployment ID. +- `cloudflare-pages`: fail-closed Pages preflight, one pinned Wrangler direct upload, + deployment/live convergence bound to the created deployment ID, and an opt-in compiler for + authenticated Pages Functions sources. - `python-package`: frozen dependency audit, non-root pure-Python wheel and sdist build, metadata-derived tag verification, and a Foundation envelope for a separate source-free official PyPA publisher job. The module never publishes to a registry. diff --git a/docs/dagger-modules.md b/docs/dagger-modules.md index aec5bb3..f7e2985 100644 --- a/docs/dagger-modules.md +++ b/docs/dagger-modules.md @@ -232,6 +232,42 @@ from JSON. The returned deployment ID forces evaluation of the typed provider re The provider resolves exact-current-`main` green Dagger evidence internally. A caller cannot authorize a deployment with stale or caller-authored evidence. +### Opt in to Pages Functions + +Static consumers keep the call above unchanged. A Functions consumer authenticates exactly two +ordered roots, sets the deploy root to `dist`, and opts in on the same deploy transaction: + +```python +ALLOWED_ROOTS = ["dist", "functions"] + +evidence = dag.cloudflare_pages().deploy( + # The other required arguments are identical to the complete flow above. + envelope=envelope, + deploy_root="dist", + allowed_roots=ALLOWED_ROOTS, + pages_functions=True, +) +evidence_id = await evidence.id() +reloaded = dag.load_cloudflare_pages_deployment_evidence_from_id(evidence_id) +return await reloaded.deployment_id() +``` + +Materialize the typed evidence ID once and reload that object for downstream fields; do not add +separate caller-side `preflight` or `verify` transactions. TypeScript uses the generated final +option `{ pagesFunctions: true }` and reloads with +`dag.loadCloudflarePagesDeploymentEvidenceFromID(evidenceId)`. + +The authenticated `functions` root must be self-contained. Before any Cloudflare API request or +upload, the provider removes consumer package-manager inputs, `node_modules`, and Wrangler config; +compiles with pinned Wrangler 4.103.0 from fixed `/project/functions`; and rejects missing imports, +build failures, or a pre-existing `dist/_worker.js` or `dist/_routes.json`. Wrangler emits esbuild +metadata into private scratch space; the provider rejects any resolved input outside authenticated +`dist` and `functions`, its private generated-route scratch directory, and the one fixed Wrangler +template plus its exact pinned router input. It stages only the derived `_worker.js` and +`_routes.json` into authenticated `dist`, then performs the same single direct upload and +deployment-ID convergence used for static sites. Static mode retains its exact arguments and +ordering. + ## Secrets and the production environment GitHub Actions injects credentials into Dagger as typed `Secret` arguments. After a repository @@ -338,6 +374,8 @@ file alone is not release evidence. Shipped in this central change: - reusable foundation and Pages module implementations; +- opt-in authenticated Pages Functions compilation within the existing one-upload Pages + transaction; - reusable Python package candidate implementation with source-free official PyPA boundary; - exact-SHA dependency and production-environment policy; - deterministic Python and TypeScript composition fixtures; diff --git a/modules/cloudflare-pages/.dagger/src/cloudflare_pages/main.py b/modules/cloudflare-pages/.dagger/src/cloudflare_pages/main.py index 6984ec8..304f962 100644 --- a/modules/cloudflare-pages/.dagger/src/cloudflare_pages/main.py +++ b/modules/cloudflare-pages/.dagger/src/cloudflare_pages/main.py @@ -4,7 +4,9 @@ import asyncio import json +import posixpath from dataclasses import dataclass +from pathlib import PurePosixPath from typing import Final, Literal from uuid import uuid4 @@ -28,6 +30,7 @@ GitHubEvidence, PagesTarget, ProviderDeploymentEvidence, + WranglerBuildMetadata, WranglerOutput, ) @@ -51,6 +54,7 @@ REQUEST_PATH: Final = "/work/cloudflare-request.json" CURL_DEADLINE_SECONDS: Final = 20 WRANGLER_PREFLIGHT_SECONDS: Final = 60 +WRANGLER_FUNCTIONS_SECONDS: Final = 120 WRANGLER_UPLOAD_SECONDS: Final = 300 HTTP_STATUS_LENGTH: Final = 3 API_RESPONSE_BYTES: Final = 262_144 @@ -64,6 +68,40 @@ "--no-bundle", "--skip-caching", ) +WRANGLER_FUNCTIONS_REQUIRED_FLAGS: Final = ( + "--outfile", + "--output-routes-path", + "--project-directory", + "--build-output-directory", + "--metafile", +) +FUNCTIONS_METADATA_NAME: Final = "_build-metadata.json" +FUNCTIONS_STAGED_ENTRIES: Final = frozenset({"_routes.json", "_worker.js"}) +FUNCTIONS_DERIVED_ENTRIES: Final = FUNCTIONS_STAGED_ENTRIES | {FUNCTIONS_METADATA_NAME} +FUNCTIONS_METADATA_BYTES: Final = 1_048_576 +FUNCTIONS_AUTHENTICATED_ROOTS: Final = ( + PurePosixPath("/project/dist"), + PurePosixPath("/project/functions"), +) +FUNCTIONS_GENERATED_ROOT: Final = PurePosixPath("/project/.wrangler/tmp") +FUNCTIONS_TOOLCHAIN_INPUTS: Final = frozenset( + { + PurePosixPath( + "/usr/local/lib/node_modules/wrangler/node_modules/path-to-regexp/dist.es2015/index.js" + ), + PurePosixPath("/usr/local/lib/node_modules/wrangler/templates/pages-template-worker.ts"), + } +) +FUNCTIONS_SOURCE_EXCLUDES: Final = [ + "**/node_modules", + "**/package.json", + "**/package-lock.json", + "**/pnpm-lock.yaml", + "**/yarn.lock", + "**/wrangler.toml", + "**/wrangler.json", + "**/wrangler.jsonc", +] @dataclass(frozen=True) @@ -85,6 +123,7 @@ class TargetInputs: live_domain: str deploy_root: str domains: tuple[str, ...] + pages_functions: bool = False @object_type @@ -125,12 +164,11 @@ async def disable_git(self) -> str: return await self._request("PATCH", self._project_suffix(), body) async def wrangler_preflight(self) -> None: - container = _wrangler_base().with_exec(["wrangler", "pages", "deploy", "--help"]) - try: - output = await asyncio.wait_for(container.stdout(), WRANGLER_PREFLIGHT_SECONDS) - except (TimeoutError, dagger.QueryError): - raise CloudflarePolicyError("Pinned Wrangler preflight failed") from None + output = await _wrangler_help(["wrangler", "pages", "deploy", "--help"]) _require_wrangler_help(output) + if self.target.pages_functions: + command = ["wrangler", "pages", "functions", "build", "--help"] + _require_functions_help(await _wrangler_help(command)) async def upload(self, artifact: dagger.Directory, source_sha: str) -> CreatedDeployment: container = self._upload_container(artifact, source_sha) @@ -189,189 +227,302 @@ def _project_suffix(self) -> str: class CloudflarePages: """Deploy only foundation-verified artifacts to one bound Pages target.""" + # fmt: off @function(cache="never") # type: ignore[call-overload,untyped-decorator] # SDK stub gap async def preflight( - self, - envelope: dagger.Directory, - github_token: dagger.Secret, - cloudflare_api_token: dagger.Secret, - cloudflare_account_id: dagger.Secret, - workflow_run_id: str, - run_attempt: int, - repository: str, - project: str, - production_branch: str, - live_domain: str, - deploy_root: str, - domains: list[str], - consumer_identity: str, - producing_identity: str, - allowed_roots: list[str], + self, envelope: dagger.Directory, github_token: dagger.Secret, + cloudflare_api_token: dagger.Secret, cloudflare_account_id: dagger.Secret, + workflow_run_id: str, run_attempt: int, repository: str, project: str, + production_branch: str, live_domain: str, deploy_root: str, domains: list[str], + consumer_identity: str, producing_identity: str, allowed_roots: list[str], + pages_functions: bool = False, ) -> str: """Verify the envelope and run read-only project, deployment, and CLI checks.""" - inputs = TargetInputs( - repository, project, production_branch, live_domain, deploy_root, tuple(domains) - ) - _, context = await _verified_context( - envelope, - github_token, - workflow_run_id, - run_attempt, - inputs, - consumer_identity, - producing_identity, - allowed_roots, - ) - operations = CurlPagesOperations( - cloudflare_api_token, cloudflare_account_id, context.target - ) - await preflight_provider(operations, context.target) - return "Cloudflare Pages preflight passed" - + inputs = _target_inputs(repository, project, production_branch, live_domain, + deploy_root, domains, pages_functions) + return await _preflight(envelope, github_token, cloudflare_api_token, + cloudflare_account_id, workflow_run_id, run_attempt, + inputs, consumer_identity, producing_identity, allowed_roots) + # fmt: on + + # fmt: off @function(cache="never") # type: ignore[call-overload,untyped-decorator] # SDK stub gap async def deploy( - self, - envelope: dagger.Directory, - github_token: dagger.Secret, - cloudflare_api_token: dagger.Secret, - cloudflare_account_id: dagger.Secret, - workflow_run_id: str, - run_attempt: int, - repository: str, - project: str, - production_branch: str, - live_domain: str, - deploy_root: str, - domains: list[str], - consumer_identity: str, - producing_identity: str, - allowed_roots: list[str], + self, envelope: dagger.Directory, github_token: dagger.Secret, + cloudflare_api_token: dagger.Secret, cloudflare_account_id: dagger.Secret, + workflow_run_id: str, run_attempt: int, repository: str, project: str, + production_branch: str, live_domain: str, deploy_root: str, domains: list[str], + consumer_identity: str, producing_identity: str, allowed_roots: list[str], + pages_functions: bool = False, ) -> DeploymentEvidence: """Direct-upload one verified artifact and return exact deployment evidence.""" - inputs = TargetInputs( - repository, project, production_branch, live_domain, deploy_root, tuple(domains) - ) - artifact, context = await _verified_context( - envelope, - github_token, - workflow_run_id, - run_attempt, - inputs, - consumer_identity, - producing_identity, - allowed_roots, - ) - operations = CurlPagesOperations( - cloudflare_api_token, cloudflare_account_id, context.target - ) - evidence = await deploy_verified_artifact( - operations, artifact, context.target, context.github, context.attempt - ) - return _public_evidence(evidence) - + inputs = _target_inputs(repository, project, production_branch, live_domain, + deploy_root, domains, pages_functions) + return await _deploy(envelope, github_token, cloudflare_api_token, + cloudflare_account_id, workflow_run_id, run_attempt, + inputs, consumer_identity, producing_identity, allowed_roots) + # fmt: on + + # fmt: off @function(cache="never") # type: ignore[call-overload,untyped-decorator] # SDK stub gap async def verify( - self, - envelope: dagger.Directory, - github_token: dagger.Secret, - cloudflare_api_token: dagger.Secret, - cloudflare_account_id: dagger.Secret, - workflow_run_id: str, - run_attempt: int, - repository: str, - project: str, - production_branch: str, - live_domain: str, - deploy_root: str, - domains: list[str], - consumer_identity: str, - producing_identity: str, - allowed_roots: list[str], + self, envelope: dagger.Directory, github_token: dagger.Secret, + cloudflare_api_token: dagger.Secret, cloudflare_account_id: dagger.Secret, + workflow_run_id: str, run_attempt: int, repository: str, project: str, + production_branch: str, live_domain: str, deploy_root: str, domains: list[str], + consumer_identity: str, producing_identity: str, allowed_roots: list[str], + pages_functions: bool = False, ) -> DeploymentEvidence: """Converge read-only production evidence for an exact source attempt.""" - inputs = TargetInputs( - repository, project, production_branch, live_domain, deploy_root, tuple(domains) - ) - _, context = await _verified_context( - envelope, - github_token, - workflow_run_id, - run_attempt, - inputs, - consumer_identity, - producing_identity, - allowed_roots, - ) - operations = CurlPagesOperations( - cloudflare_api_token, cloudflare_account_id, context.target - ) - evidence = await verify_current_deployment( - operations, context.target, context.github, context.attempt - ) - return _public_evidence(evidence) + inputs = _target_inputs(repository, project, production_branch, live_domain, + deploy_root, domains, pages_functions) + return await _verify(envelope, github_token, cloudflare_api_token, + cloudflare_account_id, workflow_run_id, run_attempt, + inputs, consumer_identity, producing_identity, allowed_roots) + # fmt: on + + +def _target_inputs( + repository: str, + project: str, + branch: str, + domain: str, + deploy_root: str, + domains: list[str], + pages_functions: bool, +) -> TargetInputs: + return TargetInputs( + repository, project, branch, domain, deploy_root, tuple(domains), pages_functions + ) + + +# fmt: off +async def _preflight( + envelope: dagger.Directory, github_token: dagger.Secret, api_token: dagger.Secret, + account_id: dagger.Secret, run_id: str, attempt: int, inputs: TargetInputs, + consumer: str, producer: str, roots: list[str], +) -> str: + artifact, context = await _verified_context( + envelope, github_token, run_id, attempt, inputs, consumer, producer, roots) + operations = CurlPagesOperations(api_token, account_id, context.target) + await _prepare_deploy_artifact(artifact, context.target) + await preflight_provider(operations, context.target) + return "Cloudflare Pages preflight passed" +# fmt: on + + +# fmt: off +async def _deploy( + envelope: dagger.Directory, github_token: dagger.Secret, api_token: dagger.Secret, + account_id: dagger.Secret, run_id: str, attempt: int, inputs: TargetInputs, + consumer: str, producer: str, roots: list[str], +) -> DeploymentEvidence: + artifact, context = await _verified_context( + envelope, github_token, run_id, attempt, inputs, consumer, producer, roots) + operations = CurlPagesOperations(api_token, account_id, context.target) + artifact = await _prepare_deploy_artifact(artifact, context.target) + evidence = await deploy_verified_artifact( + operations, artifact, context.target, context.github, context.attempt) + return _public_evidence(evidence) +# fmt: on + + +# fmt: off +async def _verify( + envelope: dagger.Directory, github_token: dagger.Secret, api_token: dagger.Secret, + account_id: dagger.Secret, run_id: str, attempt: int, inputs: TargetInputs, + consumer: str, producer: str, roots: list[str], +) -> DeploymentEvidence: + _, context = await _verified_context( + envelope, github_token, run_id, attempt, inputs, consumer, producer, roots) + operations = CurlPagesOperations(api_token, account_id, context.target) + evidence = await verify_current_deployment( + operations, context.target, context.github, context.attempt) + return _public_evidence(evidence) +# fmt: on async def _provider_context( github_token: dagger.Secret, workflow_run_id: str, run_attempt: int, inputs: TargetInputs ) -> ProviderContext: - target = PagesTarget( - inputs.repository, - inputs.project, - inputs.branch, - inputs.live_domain, - inputs.deploy_root, - inputs.domains, - ) + target = _pages_target(inputs) attempt = AttemptIdentity(workflow_run_id, run_attempt) + github = await _green_evidence(github_token, inputs.repository) + require_evidence_binding(target, github, attempt) + return ProviderContext(target, github, attempt) + + +async def _green_evidence(token: dagger.Secret, repository: str) -> GitHubEvidence: value = ( - await dag.foundation() - .green_main(github_token=github_token, repository=inputs.repository) - .serialization() + await dag.foundation().green_main(github_token=token, repository=repository).serialization() ) try: - github = GitHubEvidence.model_validate_json(value) + return GitHubEvidence.model_validate_json(value) except ValidationError: raise CloudflarePolicyError("Foundation GitHub evidence schema differs") from None - require_evidence_binding(target, github, attempt) - return ProviderContext(target, github, attempt) -async def _verified_context( - envelope: dagger.Directory, - github_token: dagger.Secret, - workflow_run_id: str, - run_attempt: int, - inputs: TargetInputs, - consumer_identity: str, - producing_identity: str, - allowed_roots: list[str], -) -> tuple[dagger.Directory, ProviderContext]: - target = PagesTarget( +def _pages_target(inputs: TargetInputs) -> PagesTarget: + return PagesTarget( inputs.repository, inputs.project, inputs.branch, inputs.live_domain, inputs.deploy_root, inputs.domains, + inputs.pages_functions, ) + + +# fmt: off +async def _verified_context( + envelope: dagger.Directory, github_token: dagger.Secret, workflow_run_id: str, + run_attempt: int, inputs: TargetInputs, consumer_identity: str, + producing_identity: str, allowed_roots: list[str], +) -> tuple[dagger.Directory, ProviderContext]: + target = _pages_target(inputs) _require_deploy_root(target, allowed_roots) verified = await _verify_envelope( - envelope, consumer_identity, producing_identity, allowed_roots - ) + envelope, consumer_identity, producing_identity, allowed_roots) + await _require_pages_functions_source(verified, target) context = await _provider_context(github_token, workflow_run_id, run_attempt, inputs) - expected_consumer = f"{inputs.repository}@{context.github.commit_sha}" - if consumer_identity != expected_consumer: - raise CloudflarePolicyError("Envelope source identity differs from GitHub evidence") - artifact = verified.directory(target.deploy_root) + _require_consumer_binding(consumer_identity, inputs.repository, context.github) + artifact = verified if target.pages_functions else verified.directory(target.deploy_root) await artifact.digest() return artifact, context +# fmt: on + + +def _require_consumer_binding(consumer: str, repository: str, github: GitHubEvidence) -> None: + if consumer != f"{repository}@{github.commit_sha}": + raise CloudflarePolicyError("Envelope source identity differs from GitHub evidence") def _require_deploy_root(target: PagesTarget, allowed_roots: list[str]) -> None: + if target.pages_functions: + _require_functions_roots(target.deploy_root, allowed_roots) + return if allowed_roots != [target.deploy_root]: raise CloudflarePolicyError("Pages deploy root must be the only envelope root") +def _require_functions_roots(deploy_root: str, allowed_roots: list[str]) -> None: + if deploy_root != "dist" or allowed_roots != ["dist", "functions"]: + raise CloudflarePolicyError("Pages Functions roots must be exactly dist then functions") + + +async def _require_pages_functions_source(verified: dagger.Directory, target: PagesTarget) -> None: + if not target.pages_functions: + return + try: + static = await verified.directory(target.deploy_root).entries() + functions = await verified.directory("functions").entries() + except dagger.QueryError: + raise CloudflarePolicyError("Pages Functions roots could not be read") from None + conflicts = FUNCTIONS_STAGED_ENTRIES.intersection(static) + if conflicts: + name = sorted(conflicts)[0] + raise CloudflarePolicyError(f"{name} conflicts with Pages functions delivery") + if not functions: + raise CloudflarePolicyError("Pages functions root must not be empty") + + +async def _prepare_deploy_artifact( + artifact: dagger.Directory, target: PagesTarget +) -> dagger.Directory: + if not target.pages_functions: + return artifact + container = _functions_build_container(artifact) + return await _compiled_pages_artifact(artifact, container, target.deploy_root) + + +async def _compiled_pages_artifact( + source: dagger.Directory, container: dagger.Container, deploy_root: str +) -> dagger.Directory: + derived = container.directory("/derived") + try: + entries = await asyncio.wait_for(derived.entries(), WRANGLER_FUNCTIONS_SECONDS) + except (TimeoutError, dagger.QueryError): + raise CloudflarePolicyError("Pages Functions build failed") from None + if frozenset(entries) != FUNCTIONS_DERIVED_ENTRIES: + raise CloudflarePolicyError("Pages Functions derived outputs differ") + await _require_closed_functions_build(derived) + staged = source.directory(deploy_root).with_file("_worker.js", derived.file("_worker.js")) + staged = staged.with_file("_routes.json", derived.file("_routes.json")) + await staged.digest() + return staged + + +def _functions_build_container(artifact: dagger.Directory) -> dagger.Container: + base = _wrangler_base().with_mounted_directory( + "/project", _functions_source(artifact), read_only=True + ) + base = base.with_mounted_temp("/project/.wrangler/tmp") + base = base.with_directory("/derived", _empty_directory()) + base = base.with_mounted_temp("/run/functions-cache") + base = base.with_mounted_temp("/run/functions-config") + base = base.with_env_variable("WRANGLER_CACHE_DIR", "/run/functions-cache") + base = base.with_env_variable("XDG_CONFIG_HOME", "/run/functions-config") + return base.with_workdir("/project").with_exec(functions_build_args()) + + +def _functions_source(artifact: dagger.Directory) -> dagger.Directory: + source = artifact.filter(exclude=FUNCTIONS_SOURCE_EXCLUDES) + return source.with_new_directory(".wrangler/tmp") + + +def _empty_directory() -> dagger.Directory: + return dag.directory() + + +async def _require_closed_functions_build(derived: dagger.Directory) -> None: + metadata = await _functions_build_metadata(derived) + inputs = tuple(_resolved_functions_input(value) for value in metadata.inputs) + _require_closed_functions_inputs(inputs) + + +def _require_closed_functions_inputs(inputs: tuple[PurePosixPath, ...]) -> None: + _require_only_closed_functions_inputs(inputs) + _require_authenticated_function_input(inputs) + + +def _require_only_closed_functions_inputs(inputs: tuple[PurePosixPath, ...]) -> None: + if not inputs or not all(_closed_functions_input(value) for value in inputs): + raise CloudflarePolicyError("Pages Functions inputs escaped authenticated roots") + + +def _require_authenticated_function_input(inputs: tuple[PurePosixPath, ...]) -> None: + functions = FUNCTIONS_AUTHENTICATED_ROOTS[1] + if not any(value.is_relative_to(functions) for value in inputs): + raise CloudflarePolicyError("Pages Functions metadata omitted authenticated source") + + +async def _functions_build_metadata(derived: dagger.Directory) -> WranglerBuildMetadata: + file = derived.file(FUNCTIONS_METADATA_NAME) + try: + size = await asyncio.wait_for(file.size(), WRANGLER_FUNCTIONS_SECONDS) + except (TimeoutError, ValidationError, dagger.QueryError): + raise CloudflarePolicyError("Pages Functions build metadata differs") from None + if size > FUNCTIONS_METADATA_BYTES: + raise CloudflarePolicyError("Pages Functions build metadata differs") + try: + value = await asyncio.wait_for(file.contents(), WRANGLER_FUNCTIONS_SECONDS) + return WranglerBuildMetadata.model_validate_json(value) + except (TimeoutError, ValidationError, dagger.QueryError): + raise CloudflarePolicyError("Pages Functions build metadata differs") from None + + +def _resolved_functions_input(value: str) -> PurePosixPath: + path = PurePosixPath(value) + candidate = path if path.is_absolute() else PurePosixPath("/project/functions", path) + return PurePosixPath(posixpath.normpath(candidate.as_posix())) + + +def _closed_functions_input(value: PurePosixPath) -> bool: + roots = (*FUNCTIONS_AUTHENTICATED_ROOTS, FUNCTIONS_GENERATED_ROOT) + return any(value.is_relative_to(root) for root in roots) or value in FUNCTIONS_TOOLCHAIN_INPUTS + + async def _verify_envelope( envelope: dagger.Directory, consumer_identity: str, @@ -404,6 +555,22 @@ def wrangler_deploy_args(target: PagesTarget, source_sha: str) -> list[str]: ] +def functions_build_args() -> list[str]: + """Build Functions from only the authenticated closed project mount.""" + return [ + "wrangler", + "pages", + "functions", + "build", + "functions", + "--outfile=/derived/_worker.js", + "--output-routes-path=/derived/_routes.json", + "--project-directory=/project", + "--build-output-directory=/project/dist", + "--metafile=/derived/_build-metadata.json", + ] + + def _wrangler_base() -> dagger.Container: install = [ "npm", @@ -449,6 +616,19 @@ def _require_wrangler_help(output: str) -> None: raise CloudflarePolicyError("Pinned Wrangler Pages flags differ") +def _require_functions_help(output: str) -> None: + if not all(flag in output for flag in WRANGLER_FUNCTIONS_REQUIRED_FLAGS): + raise CloudflarePolicyError("Pinned Wrangler Pages Functions flags differ") + + +async def _wrangler_help(command: list[str]) -> str: + container = _wrangler_base().with_exec(command) + try: + return await asyncio.wait_for(container.stdout(), WRANGLER_PREFLIGHT_SECONDS) + except (TimeoutError, dagger.QueryError): + raise CloudflarePolicyError("Pinned Wrangler preflight failed") from None + + def _parse_wrangler_output(raw: str, target: PagesTarget) -> CreatedDeployment: records = _wrangler_records(raw) matching = tuple(record for record in records if record.pages_project == target.project) diff --git a/modules/cloudflare-pages/.dagger/src/cloudflare_pages/models.py b/modules/cloudflare-pages/.dagger/src/cloudflare_pages/models.py index 13f8f83..3ae0dd9 100644 --- a/modules/cloudflare-pages/.dagger/src/cloudflare_pages/models.py +++ b/modules/cloudflare-pages/.dagger/src/cloudflare_pages/models.py @@ -17,6 +17,7 @@ r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}" ) CLOSED_MODEL: Final = ConfigDict(extra="forbid", frozen=True, strict=True) +BUILD_METADATA_MODEL: Final = ConfigDict(extra="ignore", 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" @@ -30,6 +31,20 @@ class ClosedModel(BaseModel): # type: ignore[explicit-any] # Pydantic v2 base model_config = CLOSED_MODEL +class WranglerBuildInput(BaseModel): # type: ignore[explicit-any] # Pydantic v2 base stub + """One esbuild-resolved input emitted by pinned Wrangler.""" + + model_config = BUILD_METADATA_MODEL + bytes: int = Field(ge=0) + + +class WranglerBuildMetadata(BaseModel): # type: ignore[explicit-any] # Pydantic v2 base stub + """Typed subset of pinned Wrangler's esbuild metadata.""" + + model_config = BUILD_METADATA_MODEL + inputs: dict[str, WranglerBuildInput] + + class GitHubEvidence(ClosedModel): # type: ignore[explicit-any] # Pydantic v2 base stub """The complete non-secret exact-green serialization from foundation.""" @@ -242,6 +257,7 @@ class PagesTarget: live_domain: str deploy_root: str domains: tuple[str, ...] + pages_functions: bool def __init__( self, @@ -251,6 +267,7 @@ def __init__( live_domain: str, deploy_root: str, domains: tuple[str, ...] = (), + pages_functions: bool = False, ) -> None: identity = RepositoryIdentity.parse(repository) required_domains = _canonical_domains(live_domain, domains) @@ -263,6 +280,7 @@ def __init__( object.__setattr__(self, "live_domain", live_domain) object.__setattr__(self, "deploy_root", deploy_root) object.__setattr__(self, "domains", required_domains) + object.__setattr__(self, "pages_functions", pages_functions) @dataclass(frozen=True) diff --git a/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py b/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py index 9341034..13c0769 100644 --- a/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py +++ b/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py @@ -120,9 +120,10 @@ def do_PATCH(self) -> None: import json import dagger from dagger import dag, function, object_type -from cloudflare_pages.api import deploy_verified_artifact +from cloudflare_pages.api import CloudflarePolicyError, deploy_verified_artifact from cloudflare_pages.main import (CurlPagesOperations, WRANGLER_OUTPUT_PATH, _jq_binary, - _uncached, _verify_envelope, _wrangler_script, wrangler_deploy_args) + _prepare_deploy_artifact, _uncached, _verify_envelope, _wrangler_script, + wrangler_deploy_args) from cloudflare_pages.models import AttemptIdentity, CreatedDeployment, GitHubEvidence, PagesTarget PYTHON_IMAGE = "python:3.13.14-slim@sha256:9662417aace5ae7b8e2609cce472b72a8958e134ba372808abe9cc1a0c0125e6" @@ -133,8 +134,16 @@ def do_PATCH(self) -> None: FAKE_WRANGLER = r'''#!/bin/sh set -eu [ "$1 $2 $3" = "pages deploy /artifact" ] -[ "$(find /artifact -mindepth 1 -maxdepth 1 -printf '%f\n')" = "index.html" ] -[ "$(cat /artifact/index.html)" = "verified artifact" ] +entries=$(find /artifact -mindepth 1 -maxdepth 1 -printf '%f\n' | sort) +if [ -f /artifact/_worker.js ]; then + [ "$entries" = "_routes.json +_worker.js +index.html" ] + grep -q hello-from-functions /artifact/_worker.js +else + [ "$entries" = "index.html" ] + [ "$(cat /artifact/index.html)" = "verified artifact" ] +fi cat > "$WRANGLER_OUTPUT_FILE_PATH" <<'EOF' {"type":"pages-deploy","version":1,"pages_project":"edge-reco","deployment_id":"f64788e9-fccd-4d4a-a28a-cb84f88f6","url":"https://f64788e9.edge-reco.pages.dev","timestamp":"2026-08-27T20:00:00Z"} {"type":"pages-deploy-detailed","version":1,"pages_project":"edge-reco","deployment_id":"f64788e9-fccd-4d4a-a28a-cb84f88f6","url":"https://f64788e9.edge-reco.pages.dev","timestamp":"2026-08-27T20:00:00Z"} @@ -181,6 +190,46 @@ def fixture_files() -> dagger.Directory: source = source.with_new_file("ca.pem", CA_CERT) return source.with_new_file("key.pem", PRIVATE_KEY) +async def functions_transaction(envelope: dagger.Directory, operations: MockOperations, + target: PagesTarget) -> CreatedDeployment: + verified = await _verify_envelope(envelope, "hseshadr/edge-reco@" + SHA, + "b" * 40 + ":44", ["dist", "functions"]) + prepared = await _prepare_deploy_artifact(verified, target) + return await deploy_verified_artifact(operations, prepared, target, evidence(), AttemptIdentity("44", 2)) + +async def reject_functions_escape(source: dagger.Directory, target: PagesTarget, + specifier: str) -> None: + code = f'import pkg from "{specifier}"; export const onRequest=()=>new Response(pkg.name)' + escaped = source.with_new_file("functions/api/hello.js", code) + try: await _prepare_deploy_artifact(escaped, target) + except CloudflarePolicyError as error: + assert "escaped authenticated roots" in str(error); return + raise ValueError("outside import reached provider transport") + +async def functions_contract(token: dagger.Secret, account: dagger.Secret, + mock: dagger.Service, cert: dagger.File) -> None: + target = PagesTarget("hseshadr/edge-reco", "edge-reco", "main", "edge-reco.com", "dist", pages_functions=True) + source = dag.directory().with_new_file("dist/index.html", "verified artifact") + source = source.with_new_file("functions/api/hello.js", 'export const onRequest=()=>new Response("hello-from-functions")') + missing = source.with_new_file("functions/api/hello.js", 'import value from "not-present"; export const onRequest=()=>new Response(value)') + try: await _prepare_deploy_artifact(missing, target) + except CloudflarePolicyError: pass + else: raise ValueError("missing bare import reached provider transport") + await reject_functions_escape(source, target, "/usr/local/lib/node_modules/wrangler/package.json") + await reject_functions_escape(source, target, "../../../usr/local/lib/node_modules/wrangler/package.json") + operations = MockOperations(token, account, target, mock, cert) + envelope = dag.foundation().envelope(source, "hseshadr/edge-reco@" + SHA, + "b" * 40 + ":44", ["dist", "functions"]) + for path in ("artifact/dist/index.html", "artifact/functions/api/hello.js"): + tampered = envelope.with_new_file(path, "tampered") + try: await functions_transaction(tampered, operations, target) + except dagger.QueryError: pass + else: raise ValueError("tampered Functions envelope reached provider transport") + result = await functions_transaction(envelope, operations, target) + assert result.source_sha == SHA + events = json.loads(await operations._request("GET", "/__mock/events"))["result"]["domains"] + assert events.count("upload") == 2 + @object_type class ProviderContract: @function @@ -195,15 +244,23 @@ async def contract(self) -> str: events = json.loads(await operations._request("GET", "/__mock/events"))["result"]["domains"] assert events == ["wrangler-preflight", "get-project", "get-deployments", "disable-git", "get-project", "upload", "get-deployments"] assert result.source_sha == SHA + await functions_contract(token, account, mock, fixture_files().file("ca.pem")) tampered = envelope.with_new_file("artifact/dist/index.html", "tampered") try: await _verify_envelope(tampered, "hseshadr/edge-reco@" + SHA, "b" * 40 + ":44", ["dist"]) - except dagger.QueryError: return "mock provider order and envelope tamper rejection passed" + except dagger.QueryError: return "provider order, functions route, missing import, and tamper rejection passed" raise ValueError("tampered envelope was accepted") """ -def _target() -> PagesTarget: - return PagesTarget("hseshadr/edge-reco", "edge-reco", "main", "edge-reco.com", "dist") +def _target(*, pages_functions: bool = False) -> PagesTarget: + return PagesTarget( + "hseshadr/edge-reco", + "edge-reco", + "main", + "edge-reco.com", + "dist", + pages_functions=pages_functions, + ) def _github_evidence() -> GitHubEvidence: @@ -426,19 +483,134 @@ async def __aexit__(self, error_type: object, error: object, traceback: object) return None +@dataclass +class FakeFile: + contents_value: str + + async def contents(self) -> str: + return self.contents_value + + async def size(self) -> int: + return len(self.contents_value.encode()) + + +@dataclass +class OversizedMetadataFile: + contents_read: bool = False + + async def size(self) -> int: + return main_module.FUNCTIONS_METADATA_BYTES + 1 + + async def contents(self) -> str: + self.contents_read = True + raise AssertionError("CONTENTS_READ_BEFORE_LIMIT") + + +@dataclass(frozen=True) +class OversizedMetadataDirectory: + metadata: OversizedMetadataFile + + def file(self, path: str) -> OversizedMetadataFile: + assert path == main_module.FUNCTIONS_METADATA_NAME + return self.metadata + + @dataclass class FakeDirectory: selected: str = "" + entries_by_path: dict[str, tuple[str, ...]] = field(default_factory=dict) + contents_by_path: dict[str, str] = field(default_factory=dict) digested: bool = False + added_files: list[tuple[str, object]] = field(default_factory=list) + filters: list[tuple[str, ...]] = field(default_factory=list) + added_directories: list[str] = field(default_factory=list) def directory(self, path: str) -> FakeDirectory: - return FakeDirectory(path) + selected = f"{self.selected}/{path}".strip("/") + return FakeDirectory( + selected, + self.entries_by_path, + self.contents_by_path, + added_files=self.added_files, + ) + + def file(self, path: str) -> object: + selected = f"{self.selected}/{path}".strip("/") + if selected in self.contents_by_path: + return FakeFile(self.contents_by_path[selected]) + return (self.selected, path) + + def with_file(self, path: str, value: object) -> FakeDirectory: + self.added_files.append((path, value)) + return self + + def filter(self, *, exclude: list[str]) -> FakeDirectory: + self.filters.append(tuple(exclude)) + return self + + def with_new_directory(self, path: str) -> FakeDirectory: + self.added_directories.append(path) + return self + + async def entries(self) -> list[str]: + return list(self.entries_by_path.get(self.selected, ())) async def digest(self) -> str: self.digested = True return "sha256:fixture" +@dataclass +class FakeFunctionsContainer: + """Record the closed Pages Functions compiler boundary.""" + + events: list[tuple[str, object]] = field(default_factory=list) + derived: FakeDirectory = field( + default_factory=lambda: FakeDirectory(entries_by_path={"": ("_routes.json", "_worker.js")}) + ) + + def with_mounted_directory(self, path: str, value: object, *, read_only: bool = False) -> Self: + self.events.append(("mount", (path, value, read_only))) + return self + + def with_mounted_temp(self, path: str) -> Self: + self.events.append(("temp", path)) + return self + + def with_directory(self, path: str, value: object) -> Self: + self.events.append(("seed-directory", (path, value))) + return self + + def with_workdir(self, path: str) -> Self: + self.events.append(("workdir", path)) + return self + + def with_env_variable(self, name: str, value: str) -> Self: + self.events.append(("env", (name, value))) + return self + + def with_exec(self, command: list[str]) -> Self: + self.events.append(("exec", command)) + return self + + def directory(self, path: str) -> FakeDirectory: + self.events.append(("directory", path)) + return self.derived + + +class FailingFunctionsDirectory(FakeDirectory): + async def entries(self) -> list[str]: + raise TimeoutError("consumer source detail must stay private") + + +def _derived_with_metadata(*inputs: str) -> FakeDirectory: + metadata = json.dumps({"inputs": {path: {"bytes": 1} for path in inputs}, "outputs": {}}) + return FakeDirectory( + entries_by_path={"": ("_build-metadata.json", "_routes.json", "_worker.js")}, + contents_by_path={"_build-metadata.json": metadata}, + ) + + @dataclass(frozen=True) class RecordingCurlOperations(CurlPagesOperations): """Curl adapter double that records the exact method, suffix, and body.""" @@ -649,6 +821,88 @@ async def test_should_deploy_existing_direct_upload_without_git_patch() -> None: ] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("project", "expected"), + ( + ( + _project_payload(), + [ + "compile-functions", + "wrangler-preflight", + "get-project", + "get-deployments", + "disable-git", + "get-project", + f"upload:{FULL_SHA}", + "get-deployments", + ], + ), + ( + _direct_upload_project_payload(), + [ + "compile-functions", + "wrangler-preflight", + "get-project", + "get-deployments", + "get-project", + f"upload:{FULL_SHA}", + "get-deployments", + ], + ), + ), +) +async def test_should_compile_functions_before_git_or_direct_provider_transport( + monkeypatch: pytest.MonkeyPatch, project: str, expected: list[str] +) -> None: + artifact = cast(dagger.Directory, object()) + target = _target(pages_functions=True) + context = main_module.ProviderContext(target, _github_evidence(), AttemptIdentity("44", 2)) + operations = FakeOperations( + [_deployment_payload("absent"), _deployment_payload()], project=project + ) + + async def verified(*_: object) -> tuple[dagger.Directory, main_module.ProviderContext]: + return artifact, context + + async def prepare(source: dagger.Directory, _: PagesTarget) -> dagger.Directory: + operations.events.append("compile-functions") + return source + + monkeypatch.setattr(main_module, "_verified_context", verified) + monkeypatch.setattr(main_module, "_prepare_deploy_artifact", prepare) + monkeypatch.setattr(main_module, "CurlPagesOperations", lambda *_: operations) + pages = main_module.CloudflarePages.__new__(main_module.CloudflarePages) + + await pages.deploy( + artifact, + cast(dagger.Secret, object()), + cast(dagger.Secret, object()), + cast(dagger.Secret, object()), + "44", + 2, + "hseshadr/edge-reco", + "edge-reco", + "main", + "edge-reco.com", + "dist", + [], + f"hseshadr/edge-reco@{FULL_SHA}", + "b" * 40 + ":44", + ["dist", "functions"], + True, + ) + + assert operations.events == expected + + +@pytest.mark.asyncio +async def test_should_keep_static_artifact_preparation_as_exact_identity() -> None: + artifact = cast(dagger.Directory, object()) + + assert await main_module._prepare_deploy_artifact(artifact, _target()) is artifact + + @pytest.mark.asyncio async def test_should_reject_direct_upload_identity_drift_before_upload() -> None: # Given @@ -1113,6 +1367,29 @@ async def test_should_validate_pinned_wrangler_help( assert container.commands == [["wrangler", "pages", "deploy", "--help"]] +@pytest.mark.asyncio +async def test_should_validate_pinned_functions_build_help( + monkeypatch: pytest.MonkeyPatch, +) -> None: + output = " ".join( + (*main_module.WRANGLER_REQUIRED_FLAGS, *main_module.WRANGLER_FUNCTIONS_REQUIRED_FLAGS) + ) + container = FakeContainer(output) + monkeypatch.setattr(main_module, "_wrangler_base", lambda: container) + operations = CurlPagesOperations( + cast(dagger.Secret, object()), + cast(dagger.Secret, object()), + _target(pages_functions=True), + ) + + await operations.wrangler_preflight() + + assert container.commands == [ + ["wrangler", "pages", "deploy", "--help"], + ["wrangler", "pages", "functions", "build", "--help"], + ] + + @pytest.mark.asyncio async def test_should_reject_changed_wrangler_help( monkeypatch: pytest.MonkeyPatch, @@ -1184,12 +1461,196 @@ async def test_should_reject_unframed_provider_response() -> None: await main_module._request_result(cast(dagger.Container, FakeContainer("200"))) -def test_should_require_exactly_one_verified_deploy_root() -> None: +def test_should_keep_static_envelope_root_contract_unchanged() -> None: main_module._require_deploy_root(_target(), ["dist"]) with pytest.raises(CloudflarePolicyError, match="only envelope root"): main_module._require_deploy_root(_target(), ["dist", "reports"]) +@pytest.mark.parametrize( + "roots", + ( + ["dist"], + ["functions", "dist"], + ["dist", "functions", "reports"], + ["dist", "Functions"], + ), +) +def test_should_require_exact_pages_functions_root_matrix(roots: list[str]) -> None: + target = _target(pages_functions=True) + with pytest.raises(CloudflarePolicyError, match=r"dist.*functions"): + main_module._require_deploy_root(target, roots) + + +def test_should_accept_only_ordered_pages_functions_roots() -> None: + main_module._require_deploy_root(_target(pages_functions=True), ["dist", "functions"]) + + +def test_should_build_pages_functions_from_one_read_only_project_mount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = FakeFunctionsContainer() + artifact = FakeDirectory() + empty_directory = object() + monkeypatch.setattr(main_module, "_wrangler_base", lambda: container) + monkeypatch.setattr(main_module, "_empty_directory", lambda: empty_directory) + + result = cast( + FakeFunctionsContainer, + main_module._functions_build_container(cast(dagger.Directory, artifact)), + ) + + assert result is container + assert container.events == [ + ("mount", ("/project", artifact, True)), + ("temp", "/project/.wrangler/tmp"), + ("seed-directory", ("/derived", empty_directory)), + ("temp", "/run/functions-cache"), + ("temp", "/run/functions-config"), + ("env", ("WRANGLER_CACHE_DIR", "/run/functions-cache")), + ("env", ("XDG_CONFIG_HOME", "/run/functions-config")), + ("workdir", "/project"), + ("exec", main_module.functions_build_args()), + ] + + +def test_should_remove_consumer_packages_and_configs_from_compiler_input() -> None: + source = FakeDirectory() + + result = cast(FakeDirectory, main_module._functions_source(cast(dagger.Directory, source))) + + assert result is source + assert source.filters == [ + ( + "**/node_modules", + "**/package.json", + "**/package-lock.json", + "**/pnpm-lock.yaml", + "**/yarn.lock", + "**/wrangler.toml", + "**/wrangler.json", + "**/wrangler.jsonc", + ) + ] + assert source.added_directories == [".wrangler/tmp"] + + +def test_should_use_fixed_pinned_functions_build_without_dependency_inputs() -> None: + assert main_module.functions_build_args() == [ + "wrangler", + "pages", + "functions", + "build", + "functions", + "--outfile=/derived/_worker.js", + "--output-routes-path=/derived/_routes.json", + "--project-directory=/project", + "--build-output-directory=/project/dist", + "--metafile=/derived/_build-metadata.json", + ] + + +@pytest.mark.asyncio +async def test_should_stage_only_compiled_worker_and_routes() -> None: + source = FakeDirectory(entries_by_path={"dist": ("index.html",)}) + derived = _derived_with_metadata( + "api/hello.js", + "../.wrangler/tmp/functionsRoutes-fixed.mjs", + "../../usr/local/lib/node_modules/wrangler/node_modules/path-to-regexp/dist.es2015/index.js", + "../../usr/local/lib/node_modules/wrangler/templates/pages-template-worker.ts", + ) + container = FakeFunctionsContainer(derived=derived) + + staged = await main_module._compiled_pages_artifact( + cast(dagger.Directory, source), cast(dagger.Container, container), "dist" + ) + + assert cast(FakeDirectory, staged).selected == "dist" + assert cast(FakeDirectory, staged).added_files == [ + ("_worker.js", ("", "_worker.js")), + ("_routes.json", ("", "_routes.json")), + ] + + +@pytest.mark.asyncio +async def test_should_reject_changed_functions_build_outputs() -> None: + source = FakeDirectory(entries_by_path={"dist": ("index.html",)}) + derived = FakeDirectory(entries_by_path={"": ("_worker.js", "foreign.txt")}) + container = FakeFunctionsContainer(derived=derived) + + with pytest.raises(CloudflarePolicyError, match="derived outputs differ"): + await main_module._compiled_pages_artifact( + cast(dagger.Directory, source), cast(dagger.Container, container), "dist" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("outside", ("/outside.mjs", "../outside.mjs")) +async def test_should_reject_resolved_inputs_outside_authenticated_roots(outside: str) -> None: + derived = _derived_with_metadata("api/hello.js", outside) + + with pytest.raises(CloudflarePolicyError, match="escaped authenticated roots"): + await main_module._require_closed_functions_build(cast(dagger.Directory, derived)) + + +@pytest.mark.asyncio +async def test_should_accept_only_authenticated_and_fixed_compiler_inputs() -> None: + derived = _derived_with_metadata( + "api/hello.js", + "../dist/shared.js", + "../.wrangler/tmp/functionsRoutes-fixed.mjs", + "../../usr/local/lib/node_modules/wrangler/node_modules/path-to-regexp/dist.es2015/index.js", + "../../usr/local/lib/node_modules/wrangler/templates/pages-template-worker.ts", + ) + + await main_module._require_closed_functions_build(cast(dagger.Directory, derived)) + + +@pytest.mark.asyncio +async def test_should_reject_unlisted_pinned_image_input() -> None: + derived = _derived_with_metadata( + "api/hello.js", + "../../usr/local/lib/node_modules/wrangler/package.json", + ) + + with pytest.raises(CloudflarePolicyError, match="escaped authenticated roots"): + await main_module._require_closed_functions_build(cast(dagger.Directory, derived)) + + +@pytest.mark.asyncio +async def test_should_reject_oversized_build_metadata_before_reading_contents() -> None: + metadata = OversizedMetadataFile() + derived = OversizedMetadataDirectory(metadata) + + with pytest.raises(CloudflarePolicyError, match="build metadata differs"): + await main_module._functions_build_metadata(cast(dagger.Directory, derived)) + assert metadata.contents_read is False + + +def test_should_resolve_metadata_from_fixed_functions_working_directory() -> None: + assert main_module._resolved_functions_input("api/hello.js").as_posix() == ( + "/project/functions/api/hello.js" + ) + + +@pytest.mark.asyncio +async def test_should_sanitize_functions_build_failure_before_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = FakeFunctionsContainer(derived=FailingFunctionsDirectory()) + monkeypatch.setattr( + main_module, + "_functions_build_container", + lambda _: cast(dagger.Container, container), + ) + + with pytest.raises(CloudflarePolicyError, match="Pages Functions build failed") as error: + await main_module._prepare_deploy_artifact( + cast(dagger.Directory, FakeDirectory()), _target(pages_functions=True) + ) + assert "consumer source detail" not in str(error.value) + + @pytest.mark.asyncio async def test_should_upload_with_bounded_adapter( monkeypatch: pytest.MonkeyPatch, @@ -1234,12 +1695,16 @@ async def test_should_sleep_only_requested_backoff() -> None: @pytest.mark.asyncio -@pytest.mark.parametrize("consumer_matches", (True, False)) +@pytest.mark.parametrize( + ("consumer_matches", "pages_functions"), + ((True, False), (False, False), (True, True)), +) async def test_should_bind_verified_envelope_to_internal_green_evidence( - monkeypatch: pytest.MonkeyPatch, consumer_matches: bool + monkeypatch: pytest.MonkeyPatch, consumer_matches: bool, pages_functions: bool ) -> None: - verified = FakeDirectory() - context = main_module.ProviderContext(_target(), _github_evidence(), AttemptIdentity("44", 2)) + verified = FakeDirectory(entries_by_path={"dist": ("index.html",), "functions": ("api",)}) + target = _target(pages_functions=pages_functions) + context = main_module.ProviderContext(target, _github_evidence(), AttemptIdentity("44", 2)) async def verify(*_: object) -> FakeDirectory: return verified @@ -1251,8 +1716,15 @@ async def provider(*_: object) -> main_module.ProviderContext: monkeypatch.setattr(main_module, "_provider_context", provider) consumer = f"hseshadr/edge-reco@{FULL_SHA}" if consumer_matches else "foreign" inputs = main_module.TargetInputs( - "hseshadr/edge-reco", "edge-reco", "main", "edge-reco.com", "dist", () + "hseshadr/edge-reco", + "edge-reco", + "main", + "edge-reco.com", + "dist", + (), + pages_functions, ) + allowed_roots = ["dist", "functions"] if pages_functions else ["dist"] arguments = ( cast(dagger.Directory, object()), cast(dagger.Secret, object()), @@ -1261,17 +1733,73 @@ async def provider(*_: object) -> main_module.ProviderContext: inputs, consumer, "b" * 40 + ":44", - ["dist"], + allowed_roots, ) if not consumer_matches: with pytest.raises(CloudflarePolicyError, match="Envelope source identity"): await main_module._verified_context(*arguments) return artifact, result = await main_module._verified_context(*arguments) - assert cast(FakeDirectory, artifact).selected == "dist" + expected = "" if pages_functions else "dist" + assert cast(FakeDirectory, artifact).selected == expected assert result == context +@pytest.mark.asyncio +@pytest.mark.parametrize("conflict", ("_worker.js", "_routes.json")) +async def test_should_reject_derived_conflict_before_green_main( + monkeypatch: pytest.MonkeyPatch, + conflict: str, +) -> None: + verified = FakeDirectory( + entries_by_path={"dist": (conflict, "index.html"), "functions": ("api",)} + ) + green_called = False + + async def verify(*_: object) -> FakeDirectory: + return verified + + async def provider(*_: object) -> main_module.ProviderContext: + nonlocal green_called + green_called = True + raise AssertionError("green-main must not run for a conflicting envelope") + + monkeypatch.setattr(main_module, "_verify_envelope", verify) + monkeypatch.setattr(main_module, "_provider_context", provider) + inputs = main_module.TargetInputs( + "hseshadr/edge-reco", + "edge-reco", + "main", + "edge-reco.com", + "dist", + (), + True, + ) + + with pytest.raises(CloudflarePolicyError, match=rf"{conflict}.*functions"): + await main_module._verified_context( + cast(dagger.Directory, object()), + cast(dagger.Secret, object()), + "44", + 2, + inputs, + f"hseshadr/edge-reco@{FULL_SHA}", + "b" * 40 + ":44", + ["dist", "functions"], + ) + assert green_called is False + + +@pytest.mark.asyncio +async def test_should_reject_empty_functions_root_before_provider_transport() -> None: + verified = FakeDirectory(entries_by_path={"dist": ("index.html",), "functions": ()}) + + with pytest.raises(CloudflarePolicyError, match="functions root must not be empty"): + await main_module._require_pages_functions_source( + cast(dagger.Directory, verified), _target(pages_functions=True) + ) + + def test_should_reject_malformed_wrangler_output() -> None: with pytest.raises(CloudflarePolicyError, match="output schema"): main_module._parse_wrangler_output("{}", _target()) @@ -1355,6 +1883,22 @@ def test_should_not_accept_forgeable_public_github_json() -> None: ) +def test_should_expose_pages_functions_as_one_default_false_option() -> None: + tree = ast.parse(MAIN.read_text()) + methods = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.AsyncFunctionDef) and node.name in {"preflight", "deploy", "verify"} + ] + + for method in methods: + names = [argument.arg for argument in method.args.args] + assert names[-1] == "pages_functions" + default = method.args.defaults[-1] + assert isinstance(default, ast.Constant) + assert default.value is False + + def test_should_verify_envelope_before_internal_green_main() -> None: tree = ast.parse(MAIN.read_text()) helper = next( @@ -1395,6 +1939,19 @@ def test_should_reject_public_url_or_command_escape_hatches() -> None: assert not arguments.intersection({"url", "origin", "command", "cmd", "script"}) +def test_real_fixture_should_cover_closed_two_root_functions_transaction() -> None: + required = ( + '["dist", "functions"]', + "artifact/dist/index.html", + "artifact/functions/api/hello.js", + "/usr/local/lib/node_modules/wrangler/package.json", + "deploy_verified_artifact(", + 'events.count("upload") == 2', + ) + + assert all(value in FIXTURE_MAIN for value in required) + + def _cache_value(method: ast.AsyncFunctionDef) -> str | None: decorator = next(item for item in method.decorator_list if isinstance(item, ast.Call)) cache = next(item.value for item in decorator.keywords if item.arg == "cache") @@ -1483,4 +2040,7 @@ def test_should_run_real_dagger_mock_provider_contract(tmp_path: Path) -> None: # Then _require_success(result) - assert "mock provider order and envelope tamper rejection passed" in result.stdout + assert ( + "provider order, functions route, missing import, and tamper rejection passed" + in result.stdout + ) diff --git a/modules/cloudflare-pages/.dagger/tests/test_models.py b/modules/cloudflare-pages/.dagger/tests/test_models.py index d8ce359..0e0f08f 100644 --- a/modules/cloudflare-pages/.dagger/tests/test_models.py +++ b/modules/cloudflare-pages/.dagger/tests/test_models.py @@ -11,6 +11,7 @@ PagesProject, PagesTarget, RepositoryIdentity, + WranglerBuildMetadata, ) @@ -27,6 +28,34 @@ def test_should_bind_repository_project_branch_and_domain() -> None: # Then assert target.repository.name == target.project assert target.deploy_root == "dist" + assert target.pages_functions is False + + +def test_should_opt_into_pages_functions_without_changing_static_default() -> None: + target = PagesTarget( + "hseshadr/edge-reco", + "edge-reco", + "main", + "edge-reco.com", + "dist", + pages_functions=True, + ) + + assert target.pages_functions is True + + +def test_should_parse_only_typed_wrangler_build_inputs() -> None: + metadata = WranglerBuildMetadata.model_validate_json( + '{"inputs":{"functions/api/hello.js":{"bytes":42}},"outputs":{}}' + ) + + assert tuple(metadata.inputs) == ("functions/api/hello.js",) + + +@pytest.mark.parametrize("value", ('{"inputs":[]}', '{"inputs":{"route.js":{"bytes":-1}}}')) +def test_should_reject_malformed_wrangler_build_metadata(value: str) -> None: + with pytest.raises(ValidationError): + WranglerBuildMetadata.model_validate_json(value) @pytest.mark.parametrize("root", ("", ".", "../dist", "dist/", "dist/site")) diff --git a/tests/dagger/typescript_consumer/src/index.ts b/tests/dagger/typescript_consumer/src/index.ts index 762a1d0..717f2fc 100644 --- a/tests/dagger/typescript_consumer/src/index.ts +++ b/tests/dagger/typescript_consumer/src/index.ts @@ -14,6 +14,7 @@ const COMMIT_SHA = "842187d3b9e549867375a37011cc75a520dc74a9" const CONSUMER = `${REPOSITORY}@${COMMIT_SHA}` const PRODUCER = `${"b".repeat(40)}:7` const ALLOWED_ROOTS = ["dist"] +const FUNCTIONS_ROOTS = ["dist", "functions"] const CACHE_NAMESPACE = "fixture-typescript-v1" const SECRET_CANARY = ["typescript", "private", "canary"].join("-") const ARTIFACT_NAME = "typescript-artifact.txt" @@ -33,6 +34,7 @@ export class TypescriptConsumer { const envelope = await verifiedEnvelope() const secret = dag.setSecret("typescript-fixture-secret", SECRET_CANARY) typedEvidence(secret) + typedPagesEvidence(envelope, secret) await providerRejectsTamper(envelope, secret) await packageBuild() await dag.cacheVolume(CACHE_NAMESPACE).id() @@ -63,6 +65,29 @@ function typedEvidence(secret: Secret): void { if (evidence === undefined) throw new Error("generated evidence type was unavailable") } +function typedPagesEvidence(envelope: Directory, secret: Secret): void { + void pagesEvidenceRoundTrip + void envelope + void secret +} + +async function pagesEvidenceRoundTrip(envelope: Directory, secret: Secret): Promise { + const evidence = dag.cloudflarePages().deploy( + envelope, secret, secret, secret, "7", 1, REPOSITORY, "ci", "main", + "example.invalid", "dist", [], CONSUMER, PRODUCER, FUNCTIONS_ROOTS, + { pagesFunctions: true }, + ) + const evidenceId = await evidence.id() + const reloaded = dag.loadCloudflarePagesDeploymentEvidenceFromID(evidenceId) + const [deploymentId, deploymentUrl] = await Promise.all([ + reloaded.deploymentId(), + reloaded.deploymentUrl(), + ]) + if (deploymentId.length === 0 || deploymentUrl.length === 0) { + throw new Error("reloaded Pages evidence was incomplete") + } +} + async function packageBuild(): Promise { const history = dag.git(PACKAGE_URL).commit(PACKAGE_SHA).tree({ depth: 0, includeTags: true }) const built = dag.pythonPackage().build(