diff --git a/.github/workflows/github-r2-fallback.yml b/.github/workflows/github-r2-fallback.yml index ca47d53c..13ebfbc0 100644 --- a/.github/workflows/github-r2-fallback.yml +++ b/.github/workflows/github-r2-fallback.yml @@ -1,10 +1,11 @@ -name: GitHub R2 fallback and typed RPC canary +name: Cloudflare R2-first and GitHub fallback canary on: pull_request: paths: - ".github/workflows/github-r2-fallback.yml" - "scripts/github_r2_fallback.py" + - "scripts/cloudflare_github_fallback_contract.mjs" - "docs/github-r2-fallback-canary.md" workflow_dispatch: inputs: @@ -16,6 +17,11 @@ on: description: Exact 40-character zed-pkg/zed-interfaces commit required: false type: string + zed_infra_ref: + description: Exact 40-character zed-pkg/zed-infra commit + required: false + type: string + default: "2150c08b94ae64bb6a30ef7d2465462b6d65cd48" workflow_call: inputs: zed_cli_ref: @@ -26,6 +32,11 @@ on: description: Exact 40-character zed-pkg/zed-interfaces commit required: true type: string + zed_infra_ref: + description: Exact 40-character zed-pkg/zed-infra commit + required: false + type: string + default: "2150c08b94ae64bb6a30ef7d2465462b6d65cd48" permissions: contents: read @@ -41,13 +52,14 @@ defaults: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: "1" + DEFAULT_ZED_INFRA_REF: "2150c08b94ae64bb6a30ef7d2465462b6d65cd48" jobs: - syntax: - name: Canary script compiles + contract: + name: Exact Cloudflare Worker fallback contract if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Check out harness uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -55,11 +67,35 @@ jobs: fetch-depth: 1 persist-credentials: false show-progress: false - - name: py_compile - run: python3 -m py_compile scripts/github_r2_fallback.py + + - name: Check out exact zed-infra Worker + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-infra + ref: ${{ env.DEFAULT_ZED_INFRA_REF }} + path: product/zed-infra + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v6.0.0 + with: + node-version: "22" + + - name: Verify immutable Worker identity + run: test "$(git -C product/zed-infra rev-parse HEAD)" = "$DEFAULT_ZED_INFRA_REF" + + - name: Compile harnesses and execute the real Worker + run: | + set -euo pipefail + python3 -m py_compile scripts/github_r2_fallback.py + node --check scripts/cloudflare_github_fallback_contract.mjs + node scripts/cloudflare_github_fallback_contract.mjs \ + --infra-root product/zed-infra canary: - name: Registry-down CDN install + name: Registry-down R2-first install with Cloudflare GitHub proxy if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 45 @@ -76,10 +112,12 @@ jobs: env: CLI_IN: ${{ inputs.zed_cli_ref || '' }} INTERFACES_IN: ${{ inputs.zed_interfaces_ref || '' }} + INFRA_IN: ${{ inputs.zed_infra_ref || '' }} run: | set -euo pipefail cli="${CLI_IN:-}" interfaces="${INTERFACES_IN:-}" + infra="${INFRA_IN:-$DEFAULT_ZED_INFRA_REF}" if [[ -z "$cli" ]]; then echo "zed_cli_ref is required (workflow_call or workflow_dispatch)" >&2 exit 1 @@ -90,8 +128,10 @@ jobs: fi [[ "$cli" =~ ^[0-9a-f]{40}$ ]] [[ "$interfaces" =~ ^[0-9a-f]{40}$ ]] + [[ "$infra" =~ ^[0-9a-f]{40}$ ]] printf 'cli=%s\n' "$cli" >> "$GITHUB_OUTPUT" printf 'interfaces=%s\n' "$interfaces" >> "$GITHUB_OUTPUT" + printf 'infra=%s\n' "$infra" >> "$GITHUB_OUTPUT" - name: Check out exact zed-cli candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -113,6 +153,16 @@ jobs: persist-credentials: false show-progress: false + - name: Check out exact zed-infra Worker + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-infra + ref: ${{ steps.pins.outputs.infra }} + path: product/zed-infra + fetch-depth: 1 + persist-credentials: false + show-progress: false + - name: Check out zed-clients contract uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -136,6 +186,7 @@ jobs: set -euo pipefail test "$(git -C product/zed-cli rev-parse HEAD)" = "${{ steps.pins.outputs.cli }}" test "$(git -C product/zed-interfaces rev-parse HEAD)" = "${{ steps.pins.outputs.interfaces }}" + test "$(git -C product/zed-infra rev-parse HEAD)" = "${{ steps.pins.outputs.infra }}" - name: Install Rust uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 @@ -148,10 +199,18 @@ jobs: with: node-version: "22" + - name: Cloudflare R2 and GitHub proxy contract + run: | + set -euo pipefail + node --check scripts/cloudflare_github_fallback_contract.mjs + node scripts/cloudflare_github_fallback_contract.mjs \ + --infra-root product/zed-infra + - name: Contract tests (interfaces, clients, lib-core) run: | set -euo pipefail cargo test --manifest-path product/zed-interfaces/src/rust/Cargo.toml --lib zed_api + cargo test --manifest-path product/zed-interfaces/src/rust/Cargo.toml --lib locators_try_registry_then_r2_then_github cargo test --manifest-path product/zed-lib-core/src/rust-orm/Cargo.toml \ --features read-write \ publication::tests::canonical_artifact_key_is_content_addressed @@ -165,7 +224,7 @@ jobs: install -Dm755 product/zed-cli/target/release/zed "$RUNNER_TEMP/zed-bin/zed" "$RUNNER_TEMP/zed-bin/zed" --version - - name: Run registry-down CDN canary + - name: Run registry-down R2-first and GitHub-proxy canary run: | set -euo pipefail python3 -m py_compile scripts/github_r2_fallback.py diff --git a/docs/github-r2-fallback-canary.md b/docs/github-r2-fallback-canary.md index b0000334..eba4aaa2 100644 --- a/docs/github-r2-fallback-canary.md +++ b/docs/github-r2-fallback-canary.md @@ -1,34 +1,67 @@ -# GitHub / R2 registry-fallback canary +# Cloudflare R2-first and GitHub-fallback canary -This repository proves that `zed install --frozen` still materializes a -package when the HTTP registry is down, as long as the packed tarball is at a -guessable public CDN key (`packages/{org}/{name}/{version}/…` or -`github/{owner}/{repo}/{tag}/…`). That is the `cdn.zpkg.net` contract. +This repository certifies the public artifact retrieval order used by Zed: -The canary is credential-free and uses only loopback HTTP: +1. the owned registry and API servers are authoritative; +2. immutable packed artifacts are read from the owned Cloudflare R2 bucket through + `cdn.zpkg.net/artifacts/{sha256}.{ext}`; +3. only after that R2 object is unavailable may the Cloudflare CDN Worker proxy an + anonymously readable GitHub Release through `cdn.zpkg.net/github/{owner}/{repo}/{tag}/{filename}`; +4. direct GitHub Release, GitHub Packages, and source-archive locators remain later + client fallbacks. -1. publish a disposable package to a `file://` registry; -2. copy the tarball onto the guessable CDN layout; -3. install once so a lock exists, then uninstall and drop the store; -4. point `--registry` at a closed `127.0.0.1` port; -5. set `ZED_PKG_SOURCE_FALLBACK_ALLOW_LOOPBACK=true` (production loopback stays - hermetic) and `--r2-public-base http://127.0.0.1:`; -6. `zed install --frozen` must restore the packed payload from the CDN mock. +The `/github/*` route is a Cloudflare proxy contract, not a public read oracle for +private R2 coordinate aliases. Only content-addressed `artifacts/` objects +are read directly from the private bucket. This keeps public exposure bounded by an +immutable digest while still allowing Cloudflare to serve GitHub bytes when the +owned registry and R2 copy are unavailable. -It also round-trips the `get_version` JSON call/receipt frame over TCP NDJSON, -using the same key as `zed-interfaces/route-maps/zed-api.route-map.json` -(generated by `github.com/oresoftware/api-docs`). zed-cli itself still speaks -HTTP; the TCP check is the typed RPC wire, not a second CLI transport. +## Exact Worker contract -When the TypeScript copies are passed in, the generated `zed-clients` routes -file must match `zed-interfaces/generated/typescript/zed_api.ts` (banner -comments excluded). +`scripts/cloudflare_github_fallback_contract.mjs` imports the real +`zed-pkg/zed-infra` CDN Worker at an immutable commit and executes it with fake R2 +and network adapters. It proves: -## Local run +- an R2 hit returns `x-zed-source: r2`, exact bytes, and performs zero upstream + requests; +- an R2 miss on a digest path does not guess a GitHub identity; +- the subsequent `/github/*` request follows only the allowlisted GitHub release + redirect chain and returns `x-zed-source: github-release`; +- caller authorization and cookie headers are never forwarded to GitHub; +- the Worker streams the result from the Cloudflare hostname instead of redirecting + the Zed client to GitHub. -From a sibling checkout of `zed-pkg`: +## Black-box CLI canary + +`scripts/github_r2_fallback.py` publishes a disposable package through a `file://` +registry, creates a frozen lock, and performs three clean installs while recording +the actual HTTP paths requested by the CLI: + +1. a healthy loopback stand-in for the owned registry serves + `/v1/artifacts/{sha256}`; the canary requires that path first and requires zero + CDN requests; +2. with the registry unavailable and `artifacts/{sha256}.tar.gz` present in the CDN + stand-in, the content-addressed R2 path must be first and `/github/*` must not be + touched; +3. after removing the digest object and using a fresh Zed home, the observed CDN + request prefix must be `artifacts/{sha256}.tar.gz` followed by + `github/{owner}/{repo}/{tag}/{filename}`. + +All three installs verify the packed payload, and the artifact digest is checked +against the frozen metadata. The canary also round-trips the `get_version` JSON +call/receipt frame over TCP NDJSON using the same key as +`zed-interfaces/route-maps/zed-api.route-map.json`, and compares generated +TypeScript route contracts between `zed-interfaces` and `zed-clients` when those +files are supplied. + +## Local checks + +From a sibling checkout containing `zed-pkg/zed-infra` and `zed-pkg/zed-cli`: ```bash +node scripts/cloudflare_github_fallback_contract.mjs \ + --infra-root ../zed-pkg/zed-infra + cargo build --release --manifest-path ../zed-pkg/zed-cli/Cargo.toml --bin zed python3 scripts/github_r2_fallback.py \ --zed ../zed-pkg/zed-cli/target/release/zed \ @@ -38,4 +71,5 @@ python3 scripts/github_r2_fallback.py \ --clients-generated-ts ../zed-pkg/zed-clients/clients/typescript/src/generated/zed-api.routes.ts ``` -The work root must not already exist. +The work root must not already exist. Production does not enable the loopback-only +fallback escape hatch used by the hermetic CLI test. diff --git a/scripts/cloudflare_github_fallback_contract.mjs b/scripts/cloudflare_github_fallback_contract.mjs new file mode 100644 index 00000000..f4018311 --- /dev/null +++ b/scripts/cloudflare_github_fallback_contract.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +function parseArgs(argv) { + const args = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + if (!key.startsWith('--')) throw new Error(`unexpected argument: ${key}`); + const value = argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`missing value for ${key}`); + args.set(key.slice(2), value); + index += 1; + } + const infraRoot = args.get('infra-root'); + if (!infraRoot) throw new Error('--infra-root is required'); + return { infraRoot: path.resolve(infraRoot) }; +} + +function attestCloudflareConfig(infraRoot) { + const wranglerPath = path.join(infraRoot, 'workers', 'cdn-proxy', 'wrangler.toml'); + const wrangler = fs.readFileSync(wranglerPath, 'utf8'); + assert.match(wrangler, /^name = "zpkg-cdn"$/m); + assert.match(wrangler, /^main = "src\/index\.js"$/m); + assert.match(wrangler, /^REGISTRY_URL = "https:\/\/registry\.zpkg\.net"$/m); + assert.match( + wrangler, + /\[\[r2_buckets\]\][\s\S]*?binding = "ARTIFACTS"[\s\S]*?bucket_name = "zed-pkg-artifacts"/, + 'production CDN Worker must bind the owned ARTIFACTS R2 bucket', + ); + assert.match( + wrangler, + /\[\[routes\]\][\s\S]*?pattern = "cdn\.zpkg\.net\/\*"[\s\S]*?zone_name = "zpkg\.net"/, + 'production CDN Worker must intercept the cdn.zpkg.net zone route', + ); + return { + worker_name: 'zpkg-cdn', + route: 'cdn.zpkg.net/*', + r2_binding: 'ARTIFACTS', + r2_bucket: 'zed-pkg-artifacts', + }; +} + +function fakeR2(objects, reads) { + return { + async get(key) { + reads.push(key); + const bytes = objects.get(key); + if (!bytes) return null; + return { + body: new Uint8Array(bytes), + size: bytes.byteLength, + httpEtag: `"${crypto.createHash('sha256').update(bytes).digest('hex')}"`, + writeHttpMetadata(headers) { + headers.set('content-type', key.endsWith('.zip') ? 'application/zip' : 'application/gzip'); + }, + }; + }, + }; +} + +function githubFixture(payload, upstreamRequests) { + const releasePath = '/acme/widget/releases/download/v1.2.3/widget-1.2.3.tar.gz'; + const assetUrl = + 'https://release-assets.githubusercontent.com/acme/widget/widget-1.2.3.tar.gz?sig=test'; + return async (input, init = {}) => { + const url = typeof input === 'string' ? input : input.url; + const method = init.method || (typeof input === 'string' ? 'GET' : input.method); + const headers = new Headers( + init.headers || (typeof input === 'string' ? undefined : input.headers), + ); + upstreamRequests.push({ url, method, headers: Object.fromEntries(headers.entries()) }); + assert.equal( + headers.get('authorization'), + null, + 'Cloudflare must never send auth to public GitHub fallback', + ); + assert.equal( + headers.get('cookie'), + null, + 'Cloudflare must never send cookies to public GitHub fallback', + ); + + const parsed = new URL(url); + if (parsed.hostname === 'github.com' && parsed.pathname === releasePath) { + return new Response(null, { status: 302, headers: { location: assetUrl } }); + } + if (url === assetUrl) { + return new Response(new Uint8Array(payload), { + status: 200, + headers: { + 'content-length': String(payload.byteLength), + 'content-type': 'application/gzip', + etag: '"github-fixture"', + }, + }); + } + throw new Error(`unexpected upstream request: ${method} ${url}`); + }; +} + +async function responseBytes(response) { + return Buffer.from(await response.arrayBuffer()); +} + +async function run() { + const { infraRoot } = parseArgs(process.argv.slice(2)); + const cloudflareConfig = attestCloudflareConfig(infraRoot); + const workerPath = path.join(infraRoot, 'workers', 'cdn-proxy', 'src', 'index.js'); + const workerUrl = `${pathToFileURL(workerPath).href}?contract=${Date.now()}`; + const { default: worker } = await import(workerUrl); + assert.equal(typeof worker?.fetch, 'function', 'cdn-proxy worker must export fetch()'); + + const payload = Buffer.from('cloudflare-r2-before-github\n', 'utf8'); + const sha256 = crypto.createHash('sha256').update(payload).digest('hex'); + const contentPath = `/artifacts/${sha256}.tar.gz`; + const githubPath = '/github/acme/widget/v1.2.3/widget-1.2.3.tar.gz'; + const originalFetch = globalThis.fetch; + + try { + // Phase 1: an immutable content-addressed object in our R2 bucket must win. + const r2Reads = []; + const forbiddenUpstream = []; + globalThis.fetch = async (...args) => { + forbiddenUpstream.push(args); + throw new Error('upstream fetch must not run while the R2 object exists'); + }; + const r2Response = await worker.fetch( + new Request(`https://cdn.zpkg.net${contentPath}`), + { + ARTIFACTS: fakeR2(new Map([[contentPath.slice(1), payload]]), r2Reads), + FALLBACK_TIMEOUT_MS: '1000', + }, + ); + assert.equal(r2Response.status, 200); + assert.equal(r2Response.headers.get('x-zed-edge'), 'cdn'); + assert.equal(r2Response.headers.get('x-zed-source'), 'r2'); + assert.deepEqual(await responseBytes(r2Response), payload); + assert.deepEqual(r2Reads, [contentPath.slice(1)]); + assert.equal(forbiddenUpstream.length, 0); + + // Phase 2: after an R2 miss, the client moves to the Cloudflare /github path. + // Cloudflare performs the credential-free, allowlisted GitHub fetch and + // returns the bytes from cdn.zpkg.net rather than redirecting the client. + const missReads = []; + const upstreamRequests = []; + globalThis.fetch = githubFixture(payload, upstreamRequests); + const missResponse = await worker.fetch( + new Request(`https://cdn.zpkg.net${contentPath}`), + { + ARTIFACTS: fakeR2(new Map(), missReads), + FALLBACK_TIMEOUT_MS: '1000', + }, + ); + assert.equal(missResponse.status, 404); + assert.deepEqual(missReads, [contentPath.slice(1)]); + assert.equal(upstreamRequests.length, 0, 'a digest alone must not guess a GitHub identity'); + + const githubResponse = await worker.fetch( + new Request(`https://cdn.zpkg.net${githubPath}`), + { + ARTIFACTS: fakeR2(new Map(), missReads), + FALLBACK_TIMEOUT_MS: '1000', + }, + ); + assert.equal(githubResponse.status, 200); + assert.equal(githubResponse.headers.get('x-zed-edge'), 'cdn'); + assert.equal(githubResponse.headers.get('x-zed-source'), 'github-release'); + assert.deepEqual(await responseBytes(githubResponse), payload); + assert.equal(upstreamRequests.length, 2, 'GitHub release plus one allowlisted redirect'); + assert.equal(new URL(upstreamRequests[0].url).hostname, 'github.com'); + assert.equal(new URL(upstreamRequests[1].url).hostname, 'release-assets.githubusercontent.com'); + + // Coordinate aliases remain non-R2 read oracles: /github is a public proxy, + // while only the immutable digest path can read the private bucket directly. + assert.deepEqual(missReads, [contentPath.slice(1)]); + + console.log( + JSON.stringify( + { + ok: true, + order: ['owned-registry', 'cloudflare-r2', 'cloudflare-github-proxy'], + cloudflare_config: cloudflareConfig, + content_path: contentPath, + github_path: githubPath, + r2_source: r2Response.headers.get('x-zed-source'), + fallback_source: githubResponse.headers.get('x-zed-source'), + upstream_hosts: upstreamRequests.map(({ url }) => new URL(url).hostname), + }, + null, + 2, + ), + ); + } finally { + globalThis.fetch = originalFetch; + } +} + +run().catch((error) => { + console.error(error?.stack || String(error)); + process.exitCode = 1; +}); diff --git a/scripts/github_r2_fallback.py b/scripts/github_r2_fallback.py index 80419787..3a9a98e4 100644 --- a/scripts/github_r2_fallback.py +++ b/scripts/github_r2_fallback.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 -"""Black-box canary: registry down, public CDN still installs. - -Publishes a disposable package through a file:// registry, copies the packed -tarball onto the guessable R2 keys (`packages/…` and `github/…`), takes the -HTTP registry down, and proves `zed install --frozen` fetches from a loopback -stand-in for `https://cdn.zpkg.net`. Also round-trips the oresoftware/api-docs -JSON call/receipt frame for `get_version` over TCP NDJSON. +"""Black-box canary: owned registry, R2, then Cloudflare GitHub proxy. + +Publishes a disposable package through a file:// registry, takes the HTTP +registry down, and proves `zed install --frozen` first fetches the immutable +content-addressed R2 object. A second clean install removes that object and +proves the next request stays on the Cloudflare custom domain at `/github/*`. +The exact Worker proxy behavior is independently exercised by the companion +Node contract. The canary also round-trips the typed get_version TCP receipt. """ from __future__ import annotations @@ -149,18 +150,42 @@ def write_consumer(directory: Path) -> None: ) -def start_cdn(root: Path, port: int) -> http.server.ThreadingHTTPServer: +def start_cdn( + root: Path, port: int +) -> tuple[http.server.ThreadingHTTPServer, list[tuple[str, str]]]: + requests: list[tuple[str, str]] = [] + class BoundHandler(http.server.SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(root), **kwargs) + def _record(self) -> None: + requests.append((self.command, self.path.partition("?")[0])) + + def do_GET(self) -> None: # noqa: N802 — http.server hook + self._record() + super().do_GET() + + def do_HEAD(self) -> None: # noqa: N802 — http.server hook + self._record() + super().do_HEAD() + def log_message(self, format: str, *args) -> None: # noqa: A003 print(f"cdn: {format % args}", flush=True) server = http.server.ThreadingHTTPServer(("127.0.0.1", port), BoundHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() - return server + return server, requests + + +def unique_request_paths(requests: Sequence[tuple[str, str]]) -> list[str]: + paths: list[str] = [] + for _, path in requests: + if not paths or paths[-1] != path: + paths.append(path) + return paths + class VersionRpcHandler(socketserver.StreamRequestHandler): @@ -334,7 +359,10 @@ def main() -> int: consumer = root / "consumer" publish_home = root / "publish-home" install_home = root / "install-home" + owned_registry_home = root / "owned-registry-home" fallback_home = root / "fallback-home" + github_fallback_home = root / "github-fallback-home" + owned_registry_root = root / "owned-registry-http" cdn_root = root / "cdn" cdn_root.mkdir() @@ -359,6 +387,9 @@ def zed_cmd(*command: str | Path, cwd: Path, home: Path, extra_env: Mapping[str, content_copy = cdn_root / "artifacts" / f"{sha256}.tar.gz" content_copy.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(artifact, content_copy) + owned_artifact = owned_registry_root / "v1" / "artifacts" / sha256 + owned_artifact.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(artifact, owned_artifact) zed_cmd( "install", @@ -383,9 +414,13 @@ def zed_cmd(*command: str | Path, cwd: Path, home: Path, extra_env: Mapping[str, raise AssertionError("uninstall left materialization behind") dead_port = free_port() + owned_registry_port = free_port() cdn_port = free_port() tcp_port = free_port() - cdn = start_cdn(cdn_root, cdn_port) + owned_registry_server, owned_registry_requests = start_cdn( + owned_registry_root, owned_registry_port + ) + cdn, cdn_requests = start_cdn(cdn_root, cdn_port) rpc = start_tcp_rpc(tcp_port) try: receipt = tcp_get_version(tcp_port) @@ -394,46 +429,109 @@ def zed_cmd(*command: str | Path, cwd: Path, home: Path, extra_env: Mapping[str, if receipt["transport"] != "tcp": raise AssertionError(receipt) + owned_registry_base = f"http://127.0.0.1:{owned_registry_port}" fallback_registry = f"http://127.0.0.1:{dead_port}" cdn_base = f"http://127.0.0.1:{cdn_port}" - run( - [ - zed, - "--registry", - fallback_registry, - "--home", - fallback_home, - "--r2-public-base", - cdn_base, - "install", - "--frozen", - "--install-mode", - "copy", - "--adapter", - "none", - "--allow-ecosystem-mismatch", - ], - cwd=consumer, - extra_env={ - "ZED_PKG_SOURCE_FALLBACK": "true", - "ZED_PKG_SOURCE_FALLBACK_ALLOW_LOOPBACK": "true", - "ZED_PKG_R2_PUBLIC_BASE": cdn_base, - }, - ) + registry_artifact_path = f"/v1/artifacts/{sha256}" + content_path = f"/artifacts/{sha256}.tar.gz" + github_path = f"/github/{ORG}/{NAME}/{TAG}/{FILENAME}" + + def install_with_registry(home: Path, registry_base: str) -> None: + run( + [ + zed, + "--registry", + registry_base, + "--home", + home, + "--r2-public-base", + cdn_base, + "install", + "--frozen", + "--install-mode", + "copy", + "--adapter", + "none", + "--allow-ecosystem-mismatch", + ], + cwd=consumer, + extra_env={ + "ZED_PKG_SOURCE_FALLBACK": "true", + "ZED_PKG_SOURCE_FALLBACK_ALLOW_LOOPBACK": "true", + "ZED_PKG_R2_PUBLIC_BASE": cdn_base, + }, + ) + + # Phase 0: a healthy owned registry must serve the digest and + # prevent any request from reaching the CDN or GitHub. + owned_registry_requests.clear() + cdn_requests.clear() + install_with_registry(owned_registry_home, owned_registry_base) restored = consumer / "zed_modules" / ORG / NAME / "src" / "payload.txt" if restored.read_text(encoding="utf-8") != "cdn-fallback\n": - raise AssertionError("CDN fallback did not restore the packed payload") + raise AssertionError("owned registry did not restore the packed payload") + owned_registry_request_order = unique_request_paths(owned_registry_requests) + if ( + not owned_registry_request_order + or owned_registry_request_order[0] != registry_artifact_path + ): + raise AssertionError( + "owned registry digest endpoint must be first; " + f"saw {owned_registry_request_order!r}" + ) + if cdn_requests: + raise AssertionError( + f"CDN was touched while owned registry was healthy: {cdn_requests!r}" + ) + shutil.rmtree(consumer / "zed_modules") + + # Phase 1: after registry failure, immutable R2 must win. + cdn_requests.clear() + install_with_registry(fallback_home, fallback_registry) + restored = consumer / "zed_modules" / ORG / NAME / "src" / "payload.txt" + if restored.read_text(encoding="utf-8") != "cdn-fallback\n": + raise AssertionError("R2 fallback did not restore the packed payload") + r2_request_order = unique_request_paths(cdn_requests) + if not r2_request_order or r2_request_order[0] != content_path: + raise AssertionError( + f"content-addressed R2 must be first, saw {r2_request_order!r}" + ) + if github_path in r2_request_order: + raise AssertionError( + "Cloudflare GitHub proxy was reached even though the R2 object existed" + ) + digest = hashlib.sha256(artifact.read_bytes()).hexdigest() if digest != sha256: raise AssertionError("packed artifact digest drifted") + + # Phase 2: after removing the R2 digest, Cloudflare may proxy GitHub. + shutil.rmtree(consumer / "zed_modules") + content_copy.unlink() + cdn_requests.clear() + install_with_registry(github_fallback_home, fallback_registry) + restored = consumer / "zed_modules" / ORG / NAME / "src" / "payload.txt" + if restored.read_text(encoding="utf-8") != "cdn-fallback\n": + raise AssertionError("Cloudflare GitHub proxy did not restore the payload") + github_request_order = unique_request_paths(cdn_requests) + expected_prefix = [content_path, github_path] + if github_request_order[:2] != expected_prefix: + raise AssertionError( + "R2 miss must fall through to the Cloudflare /github proxy; " + f"expected {expected_prefix!r}, saw {github_request_order!r}" + ) print( json.dumps( { "ok": True, - "registry": fallback_registry, + "owned_registry": owned_registry_base, + "fallback_registry": fallback_registry, + "owned_registry_request_order": owned_registry_request_order, "cdn": cdn_base, "tcp_rpc": f"127.0.0.1:{tcp_port}", "sha256": sha256, + "r2_request_order": r2_request_order, + "github_proxy_request_order": github_request_order, "cdn_keys": [ f"packages/{ORG}/{NAME}/{VERSION}/{FILENAME}", f"github/{ORG}/{NAME}/{TAG}/{FILENAME}", @@ -445,6 +543,7 @@ def zed_cmd(*command: str | Path, cwd: Path, home: Path, extra_env: Mapping[str, flush=True, ) finally: + owned_registry_server.shutdown() cdn.shutdown() rpc.shutdown() return 0