diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml new file mode 100644 index 000000000000..eed067adfd77 --- /dev/null +++ b/.github/workflows/relay-preview-cleanup.yml @@ -0,0 +1,203 @@ +name: Clean up relay preview + +# Tears down stage `pr-` when `relay:preview` is removed or the PR closes, +# and retries teardown for closed labeled PRs on a schedule. A label stays on a +# PR only while its teardown is outstanding, so the scheduled retry lists +# exactly the failures. +# +# This runs under pull_request_target, so the workflow and the Alchemy program +# come from the default branch. No PR code runs here, which is why it needs no +# reviewer approval and why a PR cannot edit its own teardown to reach secrets. +on: + pull_request_target: + types: [unlabeled, closed] + schedule: + - cron: "17 7 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + cleanup_pr: + name: Tear down relay preview + if: >- + github.ref == 'refs/heads/main' && + github.event_name == 'pull_request_target' && + ( + (github.event.action == 'unlabeled' && github.event.label.name == 'relay:preview') || + (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'relay:preview')) + ) + # Job-level so that events which skip this job, such as a closed PR without + # the label, never take the group's pending slot. GitHub replaces a pending + # run in a group with a newer one, which would drop a queued teardown. + concurrency: + group: relay-preview-${{ github.event.pull_request.number }} + cancel-in-progress: false + permissions: + contents: read + issues: write + pull-requests: write + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 + environment: + name: relay-preview-cleanup + env: + GH_TOKEN: ${{ github.token }} + STAGE: pr-${{ github.event.pull_request.number }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} + AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} + RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} + RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} + APNS_ENABLED: "false" + ALCHEMY_TELEMETRY_DISABLED: "1" + steps: + - name: Checkout default branch + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3code-relay... + + # Destroy also runs the preview tunnel sweep, which is part of the stack. + - name: Destroy preview stage + run: vp run --filter t3code-relay destroy --stage "$STAGE" --yes --no-input + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} + PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} + AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + + # Only reached after a successful destroy. A failed one keeps the label, + # so the scheduled retry picks the PR up again. + - name: Clear the teardown marker + if: github.event.action == 'closed' + run: gh pr edit "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" --remove-label relay:preview + + # An unlabeled event has already removed the label, so a failed teardown + # must put it back or no retry will ever see the stage. Labels added with + # GITHUB_TOKEN do not start new runs, so this does not redeploy. + - name: Restore the teardown marker after a failed destroy + if: failure() && github.event.action == 'unlabeled' + run: gh pr edit "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" --add-label relay:preview + + list_closed_previews: + name: List closed relay previews + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request_target' + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + outputs: + numbers: ${{ steps.list.outputs.numbers }} + steps: + # The issues endpoint is paginated by gh, so there is no listing cap. Pull + # requests appear there with a `pull_request` key. + - name: List closed PRs labeled relay:preview + id: list + env: + GH_TOKEN: ${{ github.token }} + run: | + numbers=$(gh api --paginate --method GET "repos/$GITHUB_REPOSITORY/issues" \ + -f labels=relay:preview -f state=closed -f per_page=100 \ + --jq '.[] | select(.pull_request) | .number') \ + || { echo "Could not list closed relay:preview PRs" >&2; exit 1; } + json=$(printf '%s\n' "$numbers" | jq -Rsc 'split("\n") | map(select(length > 0) | tonumber)') + echo "Closed relay:preview PRs: $json" + echo "numbers=$json" >> "$GITHUB_OUTPUT" + + sweep_pr: + name: Retry teardown for PR ${{ matrix.number }} + needs: list_closed_previews + if: github.ref == 'refs/heads/main' && needs.list_closed_previews.outputs.numbers != '[]' + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + number: ${{ fromJSON(needs.list_closed_previews.outputs.numbers) }} + # Same group as the deploy and teardown for this PR, so a reopened PR's + # deploy cannot run concurrently with a teardown of its previous stage. + concurrency: + group: relay-preview-${{ matrix.number }} + cancel-in-progress: false + permissions: + contents: read + issues: write + pull-requests: write + environment: + name: relay-preview-cleanup + env: + GH_TOKEN: ${{ github.token }} + STAGE: pr-${{ matrix.number }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} + AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} + RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} + RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} + APNS_ENABLED: "false" + ALCHEMY_TELEMETRY_DISABLED: "1" + steps: + # The lock above is taken before this runs, so the state read here is + # current. An open PR, including one reopened since the listing, is left + # alone. GitHub reports merged PRs as MERGED, so anything not OPEN counts. + - name: Confirm the PR is not open + id: eligible + run: | + state=$(gh pr view "${{ matrix.number }}" --repo "$GITHUB_REPOSITORY" \ + --json state --jq 'if .state == "OPEN" then "no" else "yes" end') \ + || { echo "Could not read PR ${{ matrix.number }}" >&2; exit 1; } + echo "eligible=$state" >> "$GITHUB_OUTPUT" + [ "$state" = yes ] || echo "PR ${{ matrix.number }} is open again; leaving $STAGE alone." + + - name: Checkout default branch + if: steps.eligible.outputs.eligible == 'yes' + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + if: steps.eligible.outputs.eligible == 'yes' + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3code-relay... + + - name: Destroy preview stage + if: steps.eligible.outputs.eligible == 'yes' + run: vp run --filter t3code-relay destroy --stage "$STAGE" --yes --no-input + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} + PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} + AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + + - name: Clear the teardown marker + if: steps.eligible.outputs.eligible == 'yes' + run: gh pr edit "${{ matrix.number }}" --repo "$GITHUB_REPOSITORY" --remove-label relay:preview diff --git a/.github/workflows/relay-preview.yml b/.github/workflows/relay-preview.yml new file mode 100644 index 000000000000..53a71110cef1 --- /dev/null +++ b/.github/workflows/relay-preview.yml @@ -0,0 +1,81 @@ +name: Deploy relay preview + +# Labeling a same-repository PR `relay:preview` deploys Alchemy stage `pr-`. +# This job runs the PR's own code with account credentials, so it waits for an +# approval in the `relay-preview` environment on every run. Teardown is in +# relay-preview-cleanup.yml and never runs PR code. +on: + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: relay-preview-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + deploy_preview: + name: Deploy relay preview + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'relay:preview') + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 20 + environment: + name: relay-preview + env: + STAGE: pr-${{ github.event.pull_request.number }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} + AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} + RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} + RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} + # Previews take no alert notifier, no tunnel cleanup, and no custom + # domain, so they stay silent and reap nothing outside their own stage. + APNS_ENABLED: "false" + ALCHEMY_TELEMETRY_DISABLED: "1" + steps: + - name: Confirm the PR is still open and labeled + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + state=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,labels \ + --jq 'if .state == "OPEN" and any(.labels[]; .name == "relay:preview") then "ok" else "stale" end') + if [ "$state" != "ok" ]; then + echo "PR #$PR_NUMBER is no longer open with relay:preview; not deploying." >&2 + exit 1 + fi + + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3code-relay... + + - name: Deploy preview stage + run: vp run --filter t3code-relay deploy --stage "$STAGE" --yes --no-input + env: + T3CODE_RELAY_CLIENT_CONFIG_ENV: ${{ runner.temp }}/relay-client-config.env + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} + PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} + AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} diff --git a/docs/operations/release.md b/docs/operations/release.md index a55643e4ef34..6f1c39539748 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -160,12 +160,18 @@ database. Local personal stages provision isolated branches from it and are neve Production adopts the configured relay API and tunnel DNS zones as retained Cloudflare resources. Personal stages reference the production-owned zones. -Developers deploy personal stages locally rather than through pull-request automation: +Developers deploy personal stages locally: ```sh vp run --filter t3code-relay deploy -- --stage "$USER" --env-file .env.local ``` +A same-repository PR labeled `relay:preview` deploys stage `pr-` through +`.github/workflows/relay-preview.yml`, after approval in the `relay-preview` environment. Removing the +label or closing the PR runs `.github/workflows/relay-preview-cleanup.yml`, which destroys the stage +and sweeps its managed tunnels and DNS records. A nightly run destroys stages of closed labeled PRs +whose teardown failed. Fork PRs get no preview. + ### Managed tunnel cleanup rollout Keep `RELAY_TUNNEL_CLEANUP_MODE=off` for the first production deploy. That deploy applies the diff --git a/infra/relay/README.md b/infra/relay/README.md index edcd73415477..5fe620a835ec 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -120,7 +120,9 @@ one of those outputs changed, and `T3CODE_RELAY_CLIENT_CONFIG_ENV` redirects it The relay is versioned separately from client releases. `.github/workflows/deploy-relay.yml` deploys the shared Alchemy `prod` stage on every push to `main`. Stable and nightly release builds both resolve their static public config from the same -`production` GitHub environment. Pull requests do not deploy relay stages. Developers can +`production` GitHub environment. Labeling a same-repository PR `relay:preview` deploys a `pr-` +stage, and the cleanup workflow tears it down; see +[`relay-preview.yml`](../../.github/workflows/relay-preview.yml). Developers can deploy personal non-production stages locally with any stage name other than `prod`. The repository must define these Actions variables shared by relay deployments: diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index 3de655b89c4d..29d2a9cc6016 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -11,7 +11,9 @@ import * as Planetscale from "alchemy/Planetscale"; import { PublishClientConfig, tokenDigest } from "./src/clientConfig.ts"; import * as RelayDb from "./src/db.ts"; import { RelayObservability } from "./src/observability.ts"; -import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts"; +import { isPreviewStage } from "./src/previewStages.ts"; +import { PreviewTunnelSweep, PreviewTunnelSweepProvider } from "./src/previewTunnelSweep.ts"; +import { ManagedEndpointZone, RelayApiZone, RelayDeploymentConfig } from "./src/zone.ts"; import * as RelayWorker from "./src/worker.ts"; export default Alchemy.Stack( @@ -22,16 +24,26 @@ export default Alchemy.Stack( Cloudflare.providers(), Drizzle.providers(), Planetscale.providers(), + PreviewTunnelSweepProvider(), ), state: Cloudflare.state(), }, Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; const db = yield* RelayDb.PlanetscaleDatabase; const hyperdrive = yield* RelayDb.RelayHyperdrive; const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie); const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie); const observability = yield* RelayObservability; const api = yield* RelayWorker.Api; + if (isPreviewStage(stage)) { + const { managedEndpointZoneName } = yield* RelayDeploymentConfig; + yield* PreviewTunnelSweep("PreviewTunnelSweep", { + stage, + tunnelZoneName: managedEndpointZoneName, + workerName: api.workerName, + }); + } yield* PublishClientConfig({ url: api.url, mobileTracingUrl: observability.traces.otelTracesEndpoint, diff --git a/infra/relay/src/previewStages.test.ts b/infra/relay/src/previewStages.test.ts new file mode 100644 index 000000000000..0c9270e4b603 --- /dev/null +++ b/infra/relay/src/previewStages.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isPreviewStage, + isPreviewTunnelName, + isPreviewTunnelRecordName, + previewSweepTargets, + tunnelCnameContent, +} from "./previewStages.ts"; + +const KEY = "0123456789abcdef"; + +describe("preview stages", () => { + it("accepts only pull-request stages", () => { + expect(isPreviewStage("pr-42")).toBe(true); + expect(isPreviewStage("prod")).toBe(false); + expect(isPreviewStage("dev_julius")).toBe(false); + expect(isPreviewStage("pr-0")).toBe(false); + expect(isPreviewStage("pr-42/../prod")).toBe(false); + }); + + it("matches only tunnels of the exact stage", () => { + expect(isPreviewTunnelName(`t3coderelay-managedendpoint-pr-42-${KEY}`, "pr-42")).toBe(true); + expect(isPreviewTunnelName(`t3coderelay-managedendpoint-pr-42-${KEY}`, "pr-4")).toBe(false); + expect(isPreviewTunnelName(`t3coderelay-managedendpoint-prod-${KEY}`, "pr-42")).toBe(false); + expect(isPreviewTunnelName(`t3coderelay-managedendpoint-pr-42-xyz`, "pr-42")).toBe(false); + }); + + it("matches only hostnames of the exact stage under the tunnel zone", () => { + const zone = "tunnels.example.com"; + expect(isPreviewTunnelRecordName(`pr-42-${KEY}.${zone}`, "pr-42", zone)).toBe(true); + expect(isPreviewTunnelRecordName(`PR-42-${KEY}.Tunnels.example.com`, "pr-42", zone)).toBe(true); + expect(isPreviewTunnelRecordName(`pr-4-${KEY}.${zone}`, "pr-42", zone)).toBe(false); + expect(isPreviewTunnelRecordName(`pr-42-${KEY}.other.com`, "pr-42", zone)).toBe(false); + expect(isPreviewTunnelRecordName(`relay.${zone}`, "pr-42", zone)).toBe(false); + }); + + it("targets only this stage's tunnels and the records that point at them", () => { + const zone = "tunnels.example.com"; + const mine = `t3coderelay-managedendpoint-pr-42-${KEY}`; + const otherStage = `t3coderelay-managedendpoint-pr-7-${KEY}`; + const otherKey = "fedcba9876543210"; + const targets = previewSweepTargets({ + stage: "pr-42", + tunnelZoneName: zone, + tunnels: [ + { id: "t-mine", name: mine }, + { id: "t-other", name: otherStage }, + ], + records: [ + { id: "r-mine", name: `pr-42-${KEY}.${zone}`, content: tunnelCnameContent("t-mine") }, + { id: "r-other", name: `pr-7-${KEY}.${zone}`, content: tunnelCnameContent("t-other") }, + // Right label, but it targets a tunnel outside this sweep. + { id: "r-stranger", name: `pr-42-${otherKey}.${zone}`, content: "x.cfargotunnel.com" }, + ], + }); + expect(targets.tunnels.map((tunnel) => tunnel.id)).toEqual(["t-mine"]); + expect(targets.records.map((record) => record.id)).toEqual(["r-mine"]); + }); +}); diff --git a/infra/relay/src/previewStages.ts b/infra/relay/src/previewStages.ts new file mode 100644 index 000000000000..f42f93064aa2 --- /dev/null +++ b/infra/relay/src/previewStages.ts @@ -0,0 +1,74 @@ +import { + MANAGED_ENDPOINT_KEY_PATTERN, + managedEndpointTunnelNamePrefix, +} from "./deploymentConfig.ts"; + +// Only pull-request stages may be torn down by the cleanup job. Anchoring the +// pattern keeps `prod`, personal `dev_` stages, and `pr-0` out of reach. +const PREVIEW_STAGE_PATTERN = /^pr-[1-9][0-9]*$/u; + +export function isPreviewStage(stage: string): boolean { + return PREVIEW_STAGE_PATTERN.test(stage); +} + +// The prefix ends with a dash, so stage `pr-4` never matches tunnels of `pr-42`. +export function isPreviewTunnelName(name: string, stage: string): boolean { + if (!isPreviewStage(stage)) return false; + const prefix = managedEndpointTunnelNamePrefix(stage); + return name.startsWith(prefix) && MANAGED_ENDPOINT_KEY_PATTERN.test(name.slice(prefix.length)); +} + +// Managed endpoint hostnames are `-.`. +export function isPreviewTunnelRecordName( + recordName: string, + stage: string, + tunnelZoneName: string, +): boolean { + if (!isPreviewStage(stage)) return false; + const name = recordName.toLowerCase(); + const suffix = `.${tunnelZoneName.trim().toLowerCase()}`; + if (!name.endsWith(suffix)) return false; + const label = name.slice(0, -suffix.length); + const labelPrefix = `${stage}-`; + return ( + label.startsWith(labelPrefix) && + MANAGED_ENDPOINT_KEY_PATTERN.test(label.slice(labelPrefix.length)) + ); +} + +export function tunnelCnameContent(tunnelId: string): string { + return `${tunnelId}.cfargotunnel.com`; +} + +export interface SweepTunnel { + readonly id: string; + readonly name?: string | null | undefined; +} + +export interface SweepRecord { + readonly id: string; + readonly name: string; + readonly content?: string | null | undefined; +} + +// Records are matched through the tunnel they point at, not just by name, so a +// record that happens to share the label is never deleted unless it targets one +// of this stage's tunnels. +export function previewSweepTargets(input: { + readonly stage: string; + readonly tunnelZoneName: string; + readonly tunnels: ReadonlyArray; + readonly records: ReadonlyArray; +}): { readonly tunnels: ReadonlyArray; readonly records: ReadonlyArray } { + const tunnels = input.tunnels.filter( + (tunnel) => tunnel.name != null && isPreviewTunnelName(tunnel.name, input.stage), + ); + const tunnelContents = new Set(tunnels.map((tunnel) => tunnelCnameContent(tunnel.id))); + const records = input.records.filter( + (record) => + isPreviewTunnelRecordName(record.name, input.stage, input.tunnelZoneName) && + record.content != null && + tunnelContents.has(record.content), + ); + return { tunnels, records }; +} diff --git a/infra/relay/src/previewTunnelSweep.test.ts b/infra/relay/src/previewTunnelSweep.test.ts new file mode 100644 index 000000000000..ce0415ed86db --- /dev/null +++ b/infra/relay/src/previewTunnelSweep.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +import { sweepPreviewStage } from "./previewTunnelSweep.ts"; + +const KEY = "0123456789abcdef"; +const TUNNEL_NAME = `t3coderelay-managedendpoint-pr-42-${KEY}`; + +// Answers the Cloudflare endpoints the sweep calls and records each request. +const fakeCloudflare = (options: { + readonly tunnels: ReadonlyArray<{ readonly id: string; readonly name: string }>; + readonly zoneExists: boolean; + readonly deleteStatus?: number; +}) => { + const seen: Array = []; + const client = HttpClient.make((request) => { + const url = new URL(request.url); + seen.push(`${request.method} ${url.pathname}`); + const reply = (status: number, body: unknown) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ), + ); + if (request.method === "DELETE") { + return reply(options.deleteStatus ?? 200, { success: true }); + } + if (url.pathname.endsWith("/cfd_tunnel")) return reply(200, { result: options.tunnels }); + if (url.pathname.endsWith("/zones")) { + return reply(200, { result: options.zoneExists ? [{ id: "zone-1" }] : [] }); + } + if (url.pathname.endsWith("/dns_records")) return reply(200, { result: [] }); + return reply(404, {}); + }); + return { client, seen }; +}; + +const sweep = (cloudflare: ReturnType) => + sweepPreviewStage({ + stage: "pr-42", + tunnelZoneName: "tunnels.example.com", + accountId: "account-1", + token: Redacted.make("token"), + }).pipe(Effect.provide(Layer.succeed(HttpClient.HttpClient, cloudflare.client))); + +describe("sweepPreviewStage", () => { + it.effect("does not need the zone once no tunnels remain", () => + Effect.gen(function* () { + const cloudflare = fakeCloudflare({ tunnels: [], zoneExists: false }); + yield* sweep(cloudflare); + expect(cloudflare.seen.some((request) => request.includes("/zones"))).toBe(false); + }), + ); + + it.effect("removes tunnels even when their zone is already gone", () => + Effect.gen(function* () { + const cloudflare = fakeCloudflare({ + tunnels: [{ id: "tunnel-1", name: TUNNEL_NAME }], + zoneExists: false, + }); + yield* sweep(cloudflare); + expect(cloudflare.seen).toContain( + "DELETE /client/v4/accounts/account-1/cfd_tunnel/tunnel-1/connections", + ); + expect(cloudflare.seen).toContain("DELETE /client/v4/accounts/account-1/cfd_tunnel/tunnel-1"); + expect(cloudflare.seen.some((request) => request.includes("/dns_records"))).toBe(false); + }), + ); + + it.effect("treats an already-deleted tunnel as swept", () => + Effect.gen(function* () { + const cloudflare = fakeCloudflare({ + tunnels: [{ id: "tunnel-1", name: TUNNEL_NAME }], + zoneExists: false, + deleteStatus: 404, + }); + const exit = yield* Effect.exit(sweep(cloudflare)); + expect(Exit.isSuccess(exit)).toBe(true); + }), + ); + + it.effect("fails when Cloudflare rate-limits a delete so the next run retries it", () => + Effect.gen(function* () { + const cloudflare = fakeCloudflare({ + tunnels: [{ id: "tunnel-1", name: TUNNEL_NAME }], + zoneExists: false, + deleteStatus: 429, + }); + const exit = yield* Effect.exit(sweep(cloudflare)); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); +}); diff --git a/infra/relay/src/previewTunnelSweep.ts b/infra/relay/src/previewTunnelSweep.ts new file mode 100644 index 000000000000..57f83ca09eac --- /dev/null +++ b/infra/relay/src/previewTunnelSweep.ts @@ -0,0 +1,183 @@ +import { Resource } from "alchemy"; +import * as Provider from "alchemy/Provider"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +import { managedEndpointTunnelNamePrefix } from "./deploymentConfig.ts"; +import { isPreviewStage, previewSweepTargets } from "./previewStages.ts"; + +// Managed endpoint tunnels and their CNAMEs are created by the Worker at +// runtime, so Alchemy never tracks them and `alchemy destroy` leaves them behind. +// This resource exists only so its delete runs during a preview stage's destroy. + +export interface PreviewTunnelSweepProps { + readonly stage: string; + readonly tunnelZoneName: string; + // Depending on the Worker makes Alchemy delete this sweep before the Worker, + // because dependents go first. A live Worker could create a tunnel during + // that gap; the relay accepts that window. + readonly workerName: string; +} + +export interface PreviewTunnelSweepAttributes { + readonly stage: string; + readonly tunnelZoneName: string; + readonly workerName: string; +} + +export type PreviewTunnelSweep = Resource< + "T3CodeRelay.PreviewTunnelSweep", + PreviewTunnelSweepProps, + PreviewTunnelSweepAttributes +>; + +export const PreviewTunnelSweep = Resource("T3CodeRelay.PreviewTunnelSweep"); + +const API = "https://api.cloudflare.com/client/v4"; +const PAGE_SIZE = 100; + +const TunnelPage = Schema.Struct({ + result: Schema.Array( + Schema.Struct({ id: Schema.String, name: Schema.optional(Schema.NullOr(Schema.String)) }), + ), +}); +const ZonePage = Schema.Struct({ result: Schema.Array(Schema.Struct({ id: Schema.String })) }); +const DnsRecordPage = Schema.Struct({ + result: Schema.Array( + Schema.Struct({ + id: Schema.String, + name: Schema.String, + content: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); + +const collectPages = Effect.fn("PreviewTunnelSweep.collectPages")(function* ( + fetchPage: (page: number) => Effect.Effect, E, R>, +) { + const items: Array = []; + for (let page = 1; ; page += 1) { + const batch = yield* fetchPage(page); + items.push(...batch); + if (batch.length < PAGE_SIZE) return items; + } +}); + +// Idempotent: a second run lists nothing and deletes nothing. +export const sweepPreviewStage = Effect.fn("PreviewTunnelSweep.sweep")(function* (input: { + readonly stage: string; + readonly tunnelZoneName: string; + readonly accountId: string; + readonly token: Redacted.Redacted; +}) { + const client = yield* HttpClient.HttpClient; + const send = (request: HttpClientRequest.HttpClientRequest) => + request.pipe( + HttpClientRequest.bearerToken(Redacted.value(input.token)), + client.execute, + Effect.flatMap(HttpClientResponse.filterStatusOk), + ); + const getJson = (url: string, params: Record, schema: Schema.Codec) => + send(HttpClientRequest.get(url, { urlParams: params })).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + ); + // A 404 means the object is already gone, which is the outcome teardown wants. + // Any other failure, including 429, fails the step so the next run retries it. + const remove = (url: string) => + HttpClientRequest.make("DELETE")(url).pipe( + HttpClientRequest.bearerToken(Redacted.value(input.token)), + client.execute, + Effect.flatMap((response) => + response.status === 404 + ? Effect.void + : HttpClientResponse.filterStatusOk(response).pipe(Effect.asVoid), + ), + ); + + const listedTunnels = yield* collectPages((page) => + getJson( + `${API}/accounts/${input.accountId}/cfd_tunnel`, + { + is_deleted: "false", + include_prefix: managedEndpointTunnelNamePrefix(input.stage), + per_page: String(PAGE_SIZE), + page: String(page), + }, + TunnelPage, + ).pipe(Effect.map((body) => body.result)), + ); + + // No tunnels means nothing to clean, so the zone is not needed and may be gone. + if (listedTunnels.length === 0) return; + + const zone = yield* getJson(`${API}/zones`, { name: input.tunnelZoneName }, ZonePage); + const zoneId = zone.result[0]?.id; + // Without the zone there are no CNAMEs to remove, but the tunnels still must go. + const listedRecords = + zoneId === undefined + ? [] + : yield* collectPages((page) => + getJson( + `${API}/zones/${zoneId}/dns_records`, + { type: "CNAME", per_page: String(PAGE_SIZE), page: String(page) }, + DnsRecordPage, + ).pipe(Effect.map((body) => body.result)), + ); + + const targets = previewSweepTargets({ + stage: input.stage, + tunnelZoneName: input.tunnelZoneName, + tunnels: listedTunnels, + records: listedRecords, + }); + + // Records first, so no hostname points at a tunnel that is about to vanish. + if (zoneId !== undefined) { + for (const record of targets.records) { + yield* remove(`${API}/zones/${zoneId}/dns_records/${record.id}`); + } + } + // Cloudflare refuses to delete a tunnel that still has connectors attached. + for (const tunnel of targets.tunnels) { + yield* remove(`${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}/connections`); + yield* remove(`${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}`); + } +}); + +export const PreviewTunnelSweepProvider = () => + Provider.succeed( + PreviewTunnelSweep, + PreviewTunnelSweep.Provider.of({ + reconcile: ({ news }) => + Effect.succeed({ + stage: news.stage, + tunnelZoneName: news.tunnelZoneName, + workerName: news.workerName, + }), + // Nothing in Cloudflare is created for this resource, so there is nothing to list. + list: () => Effect.succeed([]), + delete: Effect.fn(function* ({ output }) { + // The sweep runs only on preview stages. This guard keeps a mistaken + // stage from reaching the production relay's tunnels. + if (!isPreviewStage(output.stage)) { + return yield* Effect.die(`Refusing to sweep non-preview stage '${output.stage}'`); + } + // Read from the environment, not the Cloudflare service, so the provider + // needs no extra service in its layer. CI sets both for the destroy step. + const accountId = yield* Config.String("CLOUDFLARE_ACCOUNT_ID"); + const token = yield* Config.Redacted("CLOUDFLARE_API_TOKEN"); + yield* sweepPreviewStage({ + stage: output.stage, + tunnelZoneName: output.tunnelZoneName, + accountId, + token, + }).pipe(Effect.provide(FetchHttpClient.layer)); + }), + }), + );