diff --git a/.github/workflows/aot.yml b/.github/workflows/aot.yml index 967c882f..17dddb47 100644 --- a/.github/workflows/aot.yml +++ b/.github/workflows/aot.yml @@ -6,15 +6,14 @@ on: - main - dev push: - branches: - - main - - dev + # branch pushes (merges) are not re-tested here; the pull_request run above is + # the merge gate. Tag pushes still trigger explicit on-demand analysis. tags: - aot* env: IS_TAG: ${{ github.ref_type == 'tag' }} - GO_VERSION: '~1.22' + GO_VERSION: 'stable' # stackql-core (built from main) tracks recent Go; stable always satisfies its go.mod STACKQL_CORE_REPOSITORY: ${{ vars.STACKQL_CORE_REPOSITORY != '' && vars.STACKQL_CORE_REPOSITORY || 'stackql/stackql' }} STACKQL_CORE_REF: ${{ vars.STACKQL_CORE_REF != '' && vars.STACKQL_CORE_REF || 'main' }} STACKQL_ANY_SDK_REPOSITORY: ${{ vars.STACKQL_ANY_SDK_REPOSITORY != '' && vars.STACKQL_ANY_SDK_REPOSITORY || 'stackql/any-sdk' }} @@ -42,13 +41,13 @@ jobs: REG_DENO_DEPLOY_API_PROD: stackql-registry steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v7 name: "[SETUP] checkout repo" with: fetch-depth: 0 - name: Set up Go 1.x - uses: actions/setup-go@v5.0.0 + uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} check-latest: true @@ -56,7 +55,7 @@ jobs: id: go - name: Download core - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v7 with: repository: ${{ env.STACKQL_CORE_REPOSITORY }} ref: ${{ env.STACKQL_CORE_REF }} @@ -64,7 +63,7 @@ jobs: path: stackql-core - name: Download any-sdk - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v7 with: repository: ${{ env.STACKQL_ANY_SDK_REPOSITORY }} ref: ${{ env.STACKQL_ANY_SDK_REF }} @@ -72,7 +71,7 @@ jobs: path: stackql-any-sdk - name: Setup Python - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v6 with: python-version: '3.12' @@ -143,7 +142,7 @@ jobs: fi - name: Upload AOT analysis logs - uses: actions/upload-artifact@v4.3.1 + uses: actions/upload-artifact@v7 if: always() with: name: aot_analysis_logs_${{ github.event.repository.name }}_${{ github.run_id }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 908c2d4e..3d9488f4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,7 +30,7 @@ jobs: REG_DENO_DEPLOY_API_PROD: stackql-registry steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v7 name: "[SETUP] checkout repo" with: fetch-depth: 0 @@ -67,7 +67,7 @@ jobs: - name: '[PACKAGE] set up golang' if: env.NUM_PROVIDERS > 0 - uses: actions/setup-go@v5.0.0 + uses: actions/setup-go@v6 with: go-version: ^1.19 check-latest: true @@ -162,6 +162,73 @@ jobs: run: | python scripts/deploy/pull-additional-docs-from-artifact-repo.py + # + # Cloudflare (green) dual-publish. Runs here, BEFORE clean-deploy-dir.py + # flattens/destroys the working tree (which removes the origin/ Worker + # source). The full docs tree (changed providers + everything pulled from + # the artifact repo, plus the freshly generated providers.yaml) lives at + # ${REG_WEBSITE_DIR}/${REG_PROVIDER_PATH} at this point, byte-identical to + # what the Deno origin is about to deploy. Same push/branch gating as the + # Deno steps, so blue and green stay in sync during the transition window. + # + + - name: "[DEPLOY-CF] install worker deps" + if: env.REG_EVENT == 'push' + run: | + cd origin && npm install + + - name: "[DEPLOY-CF] sync docs to R2 (dev)" + if: env.REG_TARGET_BRANCH == 'dev' && env.REG_EVENT == 'push' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: "" + AWS_DEFAULT_REGION: auto + AWS_REGION: auto + R2_BUCKET: stackql-provider-registry-dev + run: | + # version-pinned .tgz are immutable -> --size-only keeps R2 ops low and skips re-uploads + aws s3 sync "${REG_WEBSITE_DIR}/${REG_PROVIDER_PATH}" "s3://${R2_BUCKET}/${REG_PROVIDER_PATH}" \ + --endpoint-url "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com" \ + --region auto --size-only --delete --no-progress + # providers.yaml can change without changing size -> always overwrite it + aws s3 cp "${REG_WEBSITE_DIR}/${REG_PROVIDER_PATH}/providers.yaml" "s3://${R2_BUCKET}/${REG_PROVIDER_PATH}/providers.yaml" \ + --endpoint-url "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com" \ + --region auto --no-progress + + - name: "[DEPLOY-CF] sync docs to R2 (prod)" + if: env.REG_TARGET_BRANCH == 'main' && env.REG_EVENT == 'push' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: "" + AWS_DEFAULT_REGION: auto + AWS_REGION: auto + R2_BUCKET: stackql-provider-registry + run: | + aws s3 sync "${REG_WEBSITE_DIR}/${REG_PROVIDER_PATH}" "s3://${R2_BUCKET}/${REG_PROVIDER_PATH}" \ + --endpoint-url "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com" \ + --size-only --delete --no-progress + aws s3 cp "${REG_WEBSITE_DIR}/${REG_PROVIDER_PATH}/providers.yaml" "s3://${R2_BUCKET}/${REG_PROVIDER_PATH}/providers.yaml" \ + --endpoint-url "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com" \ + --no-progress + + - name: "[DEPLOY-CF] deploy worker (dev)" + if: env.REG_TARGET_BRANCH == 'dev' && env.REG_EVENT == 'push' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + cd origin && npx wrangler deploy --env dev + + - name: "[DEPLOY-CF] deploy worker (prod)" + if: env.REG_TARGET_BRANCH == 'main' && env.REG_EVENT == 'push' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + cd origin && npx wrangler deploy --env production + - name: "[DEPLOY] install deno" if: env.REG_EVENT == 'push' uses: denoland/setup-deno@main diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 5ed2dc10..da1282eb 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -6,9 +6,8 @@ on: - main - dev push: - branches: - - main - - dev + # branch pushes (merges) are not re-tested here; the pull_request run above is + # the merge gate. Tag pushes still trigger explicit on-demand analysis. tags: - robot* - regression* @@ -16,7 +15,7 @@ on: env: IS_TAG: ${{ github.ref_type == 'tag' }} - GO_VERSION: '~1.22' + GO_VERSION: 'stable' # stackql-core (built from main) tracks recent Go; stable always satisfies its go.mod STACKQL_CORE_REPOSITORY: ${{ vars.STACKQL_CORE_REPOSITORY != '' && vars.STACKQL_CORE_REPOSITORY || 'stackql/stackql' }} STACKQL_CORE_REF: ${{ vars.STACKQL_CORE_REF != '' && vars.STACKQL_CORE_REF || 'main' }} STACKQL_ANY_SDK_REPOSITORY: ${{ vars.STACKQL_ANY_SDK_REPOSITORY != '' && vars.STACKQL_ANY_SDK_REPOSITORY || 'stackql/any-sdk' }} @@ -32,7 +31,7 @@ jobs: steps: - name: Check out code into the Go module directory - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v7 with: repository: ${{ env.STACKQL_CORE_REPOSITORY }} ref: ${{ env.STACKQL_CORE_REF }} @@ -40,7 +39,7 @@ jobs: path: stackql-core-pkg - name: Setup Python - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v6 with: cache: pip python-version: '3.12' @@ -61,7 +60,7 @@ jobs: cicd/util/01-build-robot-lib.sh - name: Upload python package artifact - uses: actions/upload-artifact@v4.3.1 + uses: actions/upload-artifact@v7 with: name: python-package-dist-folder path: stackql-core-pkg/test/dist @@ -86,13 +85,13 @@ jobs: REG_DENO_DEPLOY_API_PROD: stackql-registry steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v7 name: "[SETUP] checkout repo" with: fetch-depth: 0 - name: Set up Go 1.x - uses: actions/setup-go@v5.0.0 + uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} check-latest: true @@ -100,7 +99,7 @@ jobs: id: go - name: Download core - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v7 with: repository: ${{ env.STACKQL_CORE_REPOSITORY }} ref: ${{ env.STACKQL_CORE_REF }} @@ -108,7 +107,7 @@ jobs: path: stackql-core - name: Download any-sdk - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v7 with: repository: ${{ env.STACKQL_ANY_SDK_REPOSITORY }} ref: ${{ env.STACKQL_ANY_SDK_REF }} @@ -116,7 +115,7 @@ jobs: path: stackql-any-sdk - name: Setup Python - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v6 with: python-version: '3.12' @@ -276,7 +275,7 @@ jobs: python3 scripts/cicd/python/robot-parse.py --robot-output-file stackql-core/test/robot/reports/output.xml > stackql-core/test/robot/reports/proxied_parsed_output.json - name: Upload core traffic lights - uses: actions/upload-artifact@v4.3.1 + uses: actions/upload-artifact@v7 if: success() with: name: proxied-core-traffic-lights @@ -315,7 +314,7 @@ jobs: python3 scripts/cicd/python/robot-parse.py --robot-output-file test/robot/reports/mocked/output.xml > test/robot/reports/mocked/parsed_output.json - name: Upload local registry mocked traffic lights - uses: actions/upload-artifact@v4.3.1 + uses: actions/upload-artifact@v7 if: success() with: name: local-registry-mocked-traffic-lights @@ -352,7 +351,7 @@ jobs: python3 scripts/cicd/python/robot-parse.py --robot-output-file test/robot/reports/readonly/output.xml > test/robot/reports/readonly/parsed_output.json - name: Upload readonly traffic lights - uses: actions/upload-artifact@v4.3.1 + uses: actions/upload-artifact@v7 if: success() with: name: local-registry-readonly-traffic-lights @@ -381,7 +380,7 @@ jobs: python3 scripts/cicd/python/robot-parse.py --robot-output-file test/robot/reports/readwrite/output.xml > test/robot/reports/readwrite/parsed_output.json - name: Upload readonly traffic lights - uses: actions/upload-artifact@v4.3.1 + uses: actions/upload-artifact@v7 if: success() with: name: local-registry-readwrite-traffic-lights diff --git a/origin/.gitignore b/origin/.gitignore new file mode 100644 index 00000000..6ae80a6a --- /dev/null +++ b/origin/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +package-lock.json +.wrangler/ +.dev.vars +dist/ diff --git a/origin/MIGRATION.md b/origin/MIGRATION.md new file mode 100644 index 00000000..7c98ffd7 --- /dev/null +++ b/origin/MIGRATION.md @@ -0,0 +1,213 @@ +# Registry origin migration: Deno Deploy -> Cloudflare (blue-green) + +Runbook for moving the registry origin from Deno Deploy (blue) to Cloudflare +Workers + R2 + D1 (green) without changing the client URL contract. + +## URL contract (must not change) + +- Production: `https://registry.stackql.app` +- Development: `https://registry-dev.stackql.app` +- Paths: `/providers/dist/providers.yaml`, `/providers/dist//.tgz`, + `/ping`, `/analytics`, `/analytics/last24hours` + +Green is stood up on parallel hostnames first, then the real hostnames are +repointed by DNS (Phase 4). Rollback is reverting that DNS change. + +## What is already in the repo (Phases 1-2, code complete) + +- `origin/src/index.ts` - Worker port of the Deno origin, validated locally + against all acceptance checks (routing, R2 serving, D1 logging). +- `origin/wrangler.toml` - `dev` and `production` environments, R2 + D1 bindings. + Cutover hostnames are commented custom-domain routes; until the Phase 4 cutover + the Worker is reachable on its `workers.dev` route (used for validation). +- `origin/schema.sql` - D1 `downloads` table + index. +- `.github/workflows/main.yml` - dual-publish steps (`[DEPLOY-CF] ...`) that sync + docs to R2 and `wrangler deploy` the Worker, alongside the unchanged Deno steps. + +Build / sign / package / test steps in `main.yml` are untouched. + +## Phase 0: confirm inputs (do before deploying) + +1. **DNS control of `stackql.app`.** Determine whether the zone is already on + Cloudflare DNS. + - If yes: Phase 4 is a custom-domain attach on the Worker (Cloudflare creates + the proxied record automatically). + - If no: either migrate the zone to Cloudflare, or keep DNS where it is and + point the hostnames at the Worker via a CNAME to the Worker's + `*.workers.dev` route / a Cloudflare custom domain. Custom domains require + the zone to be on Cloudflare, so a zone migration is the clean path. +2. **S3 artifact bucket (`stackql-registry-artifacts`).** The Worker reads only + from R2. Decide: keep S3 as the build archive (recommended - CI still + publishes to it and the R2 sync is sourced from the assembled tree), or retire + it later. No Worker change either way. +3. **Client default registry URL.** Confirm the deployed StackQL binaries already + resolve to `https://registry.stackql.app/providers` (the `deno-deploy-registry` + README noted this as a planned change). If clients already use the contract + hostnames, the cutover is zero-client-change. If any client still points at + `cdn.statically.io/...`, that is a separate client change and out of scope + here - but note those clients will not follow the cutover. + +## Phase 1: create Cloudflare resources + +Authenticate wrangler (`wrangler login`, or set `CLOUDFLARE_API_TOKEN`). + +```bash +cd origin +npm install + +npx wrangler r2 bucket create stackql-provider-registry-dev +npx wrangler r2 bucket create stackql-provider-registry + +npx wrangler d1 create stackql-registry-analytics-dev # copy database_id +npx wrangler d1 create stackql-registry-analytics # copy database_id + +npx wrangler d1 execute stackql-registry-analytics-dev --remote --file=./schema.sql +npx wrangler d1 execute stackql-registry-analytics --remote --file=./schema.sql +``` + +Then edit `wrangler.toml`: replace `` and `` with the +returned database IDs. CI cannot deploy until these are real. + +Local acceptance (optional, already verified): see `origin/README.md`. + +## GitHub Actions secrets required by the dual-path CI + +Add these repo secrets (Settings -> Secrets and variables -> Actions): + +| Secret | Purpose | +| ----------------------- | ------------------------------------------------------------- | +| `CLOUDFLARE_API_TOKEN` | `wrangler deploy`. Scope: Workers Scripts edit, D1 edit, Workers R2 Storage edit, Account/Zone read. | +| `CLOUDFLARE_ACCOUNT_ID` | R2 S3 endpoint host and wrangler account. | +| `R2_ACCESS_KEY_ID` | R2 S3 API token (R2 -> Manage API tokens) for `aws s3 sync`. | +| `R2_SECRET_ACCESS_KEY` | R2 S3 API secret. | + +These are independent of the existing AWS S3 credentials; the R2 sync steps +override the AWS env vars locally so they do not collide. + +## Phase 2: dual-path CI (in place) + +The `[DEPLOY-CF]` steps in `main.yml` run on push, gated on the same `dev`/`main` +split as the Deno steps: + +- `dev` branch -> R2 bucket `stackql-provider-registry-dev`, `wrangler deploy --env dev` +- `main` branch -> R2 bucket `stackql-provider-registry`, `wrangler deploy --env production` + +Acceptance: a push to `dev` updates both the dev Deno origin and the dev Worker +with byte-identical content (both sourced from the same assembled +`_deno_website/providers/dist` tree). + +If you want to seed R2 once before the first CI push (so green is immediately +servable), run a one-off sync from a local checkout that has the full tree, or +trigger a `workflow_dispatch` / no-op push. + +## Phase 3: validate green on the workers.dev URL + +No temporary green DNS records are used. Each deployed Worker is reachable at its +`*.workers.dev` URL (dev: `stackql-provider-registry-dev..workers.dev`, +prod: `stackql-provider-registry-prod..workers.dev`). Validate there. + +Run against the Worker URL: + +```bash +BASE=https://stackql-provider-registry-dev..workers.dev +curl -i $BASE/providers/dist/providers.yaml # 200 text/plain +curl -i $BASE/providers/dist/aws/v0.1.3.tgz # 200 application/gzip +curl -i $BASE/providers/dist/fred # 404 +curl -i $BASE/ping # 202 pong +curl -i $BASE/analytics # 200 text/html +curl -i $BASE/analytics/last24hours # 200 application/json +``` + +Byte parity vs blue (Deno): + +```bash +GREEN=https://stackql-provider-registry-dev..workers.dev +diff <(curl -s https://registry-dev.stackql.app/providers/dist/providers.yaml) \ + <(curl -s $GREEN/providers/dist/providers.yaml) +# repeat for a sample of .tgz using sha256sum +for v in aws/v0.1.3 ...; do + a=$(curl -s https://registry-dev.stackql.app/providers/dist/$v.tgz | sha256sum) + b=$(curl -s $GREEN/providers/dist/$v.tgz | sha256sum) + [ "$a" = "$b" ] && echo "$v OK" || echo "$v MISMATCH" +done +``` + +Live pull from a StackQL client pointed at the Worker URL: + +```bash +export STACKQL_REGISTRY='{"url": "https://stackql-provider-registry-dev..workers.dev/providers"}' +stackql exec "REGISTRY PULL aws" +``` + +Analytics: confirm a `.tgz` pull writes one D1 row and `providers.yaml` writes +none, and that `/analytics` renders the three windows plus the 12-month matrix. + +```bash +npx wrangler d1 execute stackql-registry-analytics-dev --remote \ + --command "SELECT count(*), provider FROM downloads GROUP BY provider" +``` + +Acceptance: full endpoint + pull parity, analytics writing correctly. + +## Phase 4: DNS last mile (gated cutover) + +Cut dev first, soak, then prod. + +1. **Dev.** Attach `registry-dev.stackql.app` as a custom domain on the dev + Worker. Either add it to `[env.dev]` routes in `wrangler.toml` (commented + block is ready) and `wrangler deploy --env dev`, or attach via the dashboard + (Workers -> the dev worker -> Settings -> Domains & Routes -> Add custom + domain). Cloudflare repoints the proxied DNS record to the Worker. + - Verify endpoint parity and a live `REGISTRY PULL` against + `https://registry-dev.stackql.app`. + - Soak. +2. **Prod.** Repeat for `registry.stackql.app` on the production Worker + (`[env.production]` routes block, or dashboard). Verify parity + live pull. + +**Rollback (either hostname):** detach the custom domain from the Worker / revert +the DNS record to the Deno origin. Blue is still live and still receiving CI +updates throughout the transition window, so rollback is immediate with no data +loss. + +Acceptance: both production hostnames serve from the Worker with passing pull +tests; this rollback step is the documented procedure. + +## Phase 5: decommission blue + +Only after the soak window passes with green stable on the production hostnames: + +1. In `main.yml`, remove the Deno steps: + - `[DEPLOY] setup SSH`, `[DEPLOY] pull deno deploy assets`, + `[DEPLOY] install deno`, `[DEPLOY] clean deploy dir`, + `[DEPLOY] deploy to deno deploy (dev)`, `[DEPLOY] deploy to deno deploy (prod)`. + - Keep `[DEPLOY] pull additional docs from artifact repo` (it assembles the + full tree and regenerates `providers.yaml` that the R2 sync consumes). + - Once `clean-deploy-dir.py` is no longer in the pipeline, `origin/` survives + to the end of the job, so the `[DEPLOY-CF]` steps can move later if desired; + no functional change needed. + - Remove the `REG_DENO_DEPLOY_*` env vars. +2. Delete the Deno Deploy projects `stackql-registry` and `stackql-dev-registry`. +3. Revoke `DENO_KV_ACCESS_TOKEN` (the token in the old `deno-deploy-registry/env.sh`). +4. Cancel the Deno Deploy subscription. +5. Retire the `deno-deploy-registry` repo (archive). + +Acceptance: CI deploys only to Cloudflare, no Deno dependency remains, the +registry serves entirely from the Worker. + +## Notes / guardrails honored + +- No secrets in the repo. `deno-deploy-registry/env.sh` is not carried across; + `DENO_KV_ACCESS_TOKEN` is revoked in Phase 5. All credentials are GitHub + Actions secrets / `wrangler secret`. +- Build/sign/package/test steps in `main.yml` are unchanged. +- Response paths, status codes, and content types match the Deno origin exactly + (verified locally). Caching adds `Cache-Control` only: `immutable` long max-age + on `.tgz`, `max-age=60` on `providers.yaml`. +- `dev` -> dev origin, `main` -> prod origin split preserved. + +## Cost expectation + +Within Workers free (100k req/day), R2 free (10GB storage, zero egress), and D1 +free (5GB) at current volume. Moves to the $5/mo Workers plan plus R2 storage +overage only once those limits are exceeded. Immutable `.tgz` edge caching keeps +R2 read ops low. diff --git a/origin/README.md b/origin/README.md new file mode 100644 index 00000000..74282fe9 --- /dev/null +++ b/origin/README.md @@ -0,0 +1,107 @@ +# StackQL Provider Registry origin (Cloudflare Worker) + +Origin server for the public StackQL provider registry, served from Cloudflare +Workers + R2 (docs) + D1 (download analytics). This is the "green" origin in the +blue-green migration away from Deno Deploy. It preserves the existing URL +contract exactly: + +| Request | Response | +| ------------------------------------------ | ----------------------------------------------------- | +| `GET /providers/dist/providers.yaml` | 200 `text/plain`, not logged | +| `GET /providers/dist//.tgz` | 200 `application/gzip`, one download row written to D1 | +| `GET */ping` | 202 `pong` | +| `GET /analytics` | 200 `text/html` dashboard (24h / 7d / 30d + 12-month matrix) | +| `GET /analytics/last24hours` | 200 `application/json` | +| any other path | 404 | +| any non-GET method | 405 | + +Docs are read from the R2 binding `REGISTRY_BUCKET` using the request path with +the leading slash stripped (the same layout the Deno origin read from disk). +Analytics are written one row per `.tgz` pull to the D1 binding `ANALYTICS_DB` +inside `ctx.waitUntil`, so logging never adds latency to a pull. + +## Layout + +``` +origin/ + wrangler.toml two envs: dev (dev branch) and production (main branch) + schema.sql D1 downloads table + index + src/index.ts the Worker (port of deno-deploy-registry/website/index.ts) + package.json wrangler + types +``` + +## One-time resource bootstrap (Phase 1) + +Run with a Cloudflare account that has Workers Paid or Free, R2, and D1 enabled. +Authenticate first with `wrangler login` (or set `CLOUDFLARE_API_TOKEN`). + +```bash +cd origin +npm install + +# R2 buckets (dev + prod) +npx wrangler r2 bucket create stackql-provider-registry-dev +npx wrangler r2 bucket create stackql-provider-registry + +# D1 databases (dev + prod). Copy each returned database_id into wrangler.toml. +npx wrangler d1 create stackql-registry-analytics-dev +npx wrangler d1 create stackql-registry-analytics + +# Apply the schema to both +npx wrangler d1 execute stackql-registry-analytics-dev --remote --file=./schema.sql +npx wrangler d1 execute stackql-registry-analytics --remote --file=./schema.sql +``` + +After `d1 create`, replace `` and `` in `wrangler.toml` +with the returned database IDs. + +## Local development + +`wrangler dev` uses the top-level bindings (the dev R2 bucket and a local D1). +Seed a couple of objects into R2 (or local R2) and apply the schema locally: + +```bash +cd origin +npm install +npx wrangler d1 execute stackql-registry-analytics-dev --local --file=./schema.sql + +# seed a known object pair into the dev bucket so the endpoint checks pass +npx wrangler r2 object put stackql-provider-registry-dev/providers/dist/providers.yaml \ + --file=../tmp/deno-deploy-registry/website/providers/dist/providers.yaml +npx wrangler r2 object put stackql-provider-registry-dev/providers/dist/aws/v0.1.3.tgz \ + --file=../tmp/deno-deploy-registry/website/providers/dist/aws/v0.1.3.tgz + +npm run dev +``` + +## Acceptance checks (Phase 1) + +Against `http://localhost:8787` (wrangler dev) or a deployed hostname: + +```bash +curl -i http://localhost:8787/providers/dist/providers.yaml # 200 text/plain +curl -i http://localhost:8787/providers/dist/aws/v0.1.3.tgz # 200 application/gzip +curl -i http://localhost:8787/providers/dist/fred # 404 +curl -i http://localhost:8787/ping # 202 pong +curl -i http://localhost:8787/analytics # 200 text/html +curl -i http://localhost:8787/analytics/last24hours # 200 application/json +curl -i -X POST http://localhost:8787/ping # 405 +``` + +Note: `localhost` Host headers are intentionally not logged to D1 (matches the +Deno origin). Test analytics writes against a deployed hostname. + +## Deploy + +CI deploys automatically (see `.github/workflows/main.yml`): pushes to `dev` +deploy `--env dev`, pushes to `main` deploy `--env production`. Manual deploys: + +```bash +npm run deploy:dev # wrangler deploy --env dev +npm run deploy:prod # wrangler deploy --env production +``` + +## Migration + +See [MIGRATION.md](MIGRATION.md) for the full blue-green cutover runbook +(parallel-hostname validation, DNS last mile, and Deno decommission). diff --git a/origin/RUNSHEET.md b/origin/RUNSHEET.md new file mode 100644 index 00000000..e454bf63 --- /dev/null +++ b/origin/RUNSHEET.md @@ -0,0 +1,100 @@ +# Cutover run sheet + +Ordered steps to deploy the Cloudflare green origin alongside the live Deno blue +origin, validate, and (later) cut over by DNS. The `stackql.app` zone is on +Cloudflare (account `4132d7d5587ee99b9d482ecfc2c1853c`). + +## Safety invariant (holds until the Phase 4 cutover) + +Merging this PR does NOT touch production traffic: + +- The Deno steps in `main.yml` are unchanged - blue keeps deploying and serving. +- `wrangler.toml` route blocks are commented out, so `wrangler deploy` attaches + no custom domains and makes no DNS changes. The green Worker is validated on its + `*.workers.dev` URL - no temporary green DNS records are used. +- The existing DNS records are untouched: `registry.stackql.app` and + `registry-dev.stackql.app` keep their A/AAAA records pointing at the Deno + origin (`34.120.54.55` / `2600:1901:0:6d85::`). + +So after merge, green runs fully in parallel; production still serves from Deno. +Cutover is a separate, deliberate DNS change in Phase 4, instantly reversible. + +## A. Provision (local, on the feature branch) - me/you + +1. `cd origin && npm install` +2. `npx wrangler login` +3. `bash bootstrap.sh` + - creates R2 buckets `stackql-provider-registry-dev` + `stackql-provider-registry` + - creates D1 `stackql-registry-analytics-dev` + `stackql-registry-analytics` + - patches `wrangler.toml` with the real D1 IDs, applies `schema.sql` to both + - (D1 IDs are not secrets; committing them to the public repo is expected) +4. `git --no-pager diff origin/wrangler.toml` - confirm `` / + `` are replaced with UUIDs. + +## B. Secrets (GitHub repo -> Settings -> Secrets and variables -> Actions) + +| Secret | Value / source | +| --- | --- | +| `CLOUDFLARE_ACCOUNT_ID` | `4132d7d5587ee99b9d482ecfc2c1853c` | +| `CLOUDFLARE_API_TOKEN` | API token, scopes below | +| `R2_ACCESS_KEY_ID` | from R2 -> Manage R2 API Tokens | +| `R2_SECRET_ACCESS_KEY` | from R2 -> Manage R2 API Tokens | + +CLOUDFLARE_API_TOKEN scopes (one token, covers through Phase 4): + +- Account / Workers Scripts / Edit +- Account / D1 / Edit +- Account / Workers R2 Storage / Edit +- Account / Account Settings / Read +- Zone / Workers Routes / Edit (stackql.app) - needed when routes are uncommented +- Zone / DNS / Edit (stackql.app) - custom-domain attach writes DNS +- Zone / Zone / Read (stackql.app) + +R2 API token (for `aws s3 sync`): R2 -> Manage R2 API Tokens -> Create -> permission +"Object Read & Write", scoped to the two buckets (or account-wide). Use the +returned Access Key ID / Secret Access Key as the two `R2_*` secrets. + +## C. PR and merge + +5. Commit `origin/` + the `main.yml` change, push the feature branch, open the PR. + - PR CI runs build/sign/package/test only. Both the Deno deploy steps and the + `[DEPLOY-CF]` steps are push-gated (`REG_EVENT == 'push'`), so nothing + deploys on the PR itself. +6. Merge to `dev`. The push to `dev` runs, in the same job: + - blue: deploy to `stackql-dev-registry` (Deno) - unchanged + - green: `aws s3 sync` -> `stackql-provider-registry-dev` R2, then + `wrangler deploy --env dev` + +## D. Phase 3 validation (dev green, on workers.dev) + +7. Find the dev Worker URL (`stackql-provider-registry-dev..workers.dev`). + Run the endpoint checks, byte-parity diff vs `https://registry-dev.stackql.app`, + and a live pull (commands in `MIGRATION.md` Phase 3). Confirm D1 rows are + written for `.tgz` and not for `providers.yaml`. +8. Merge `dev` -> `main`. The push to `main` deploys blue prod (Deno) and green + prod (R2 `stackql-provider-registry` + `wrangler deploy --env production`). + Validate prod green the same way on its workers.dev URL. + +## E. Phase 4 cutover (separate change, after soak) - DNS last mile + +Cut dev first, soak, then prod. For each hostname: + +9. Dev: in Cloudflare DNS, delete the existing `registry-dev.stackql.app` A and + AAAA records (they point at Deno), then attach `registry-dev.stackql.app` as a + custom domain on the dev Worker - uncomment the dev route in `wrangler.toml` + and `wrangler deploy --env dev`, or use the dashboard (Worker -> Settings -> + Domains & Routes -> Add custom domain). Cloudflare creates the managed proxied + record. Verify endpoint parity + live `REGISTRY PULL` against + `https://registry-dev.stackql.app`. Soak. +10. Prod: repeat step 9 for `registry.stackql.app` on the production Worker. + +Rollback (either hostname): detach the Worker custom domain and re-create the A +record `34.120.54.55` + AAAA `2600:1901:0:6d85::` (proxied) pointing back at Deno. +Blue is still deploying and serving throughout, so rollback is immediate with no +data loss. + +## F. Phase 5 decommission (later, after stable soak) + +See `MIGRATION.md` Phase 5: remove the Deno steps from `main.yml`, delete the +`_acme-challenge.registry*` CNAMEs, delete the Deno Deploy projects, revoke +`DENO_KV_ACCESS_TOKEN`, cancel the Deno subscription, archive `deno-deploy-registry`. diff --git a/origin/bootstrap.sh b/origin/bootstrap.sh new file mode 100644 index 00000000..dcf33398 --- /dev/null +++ b/origin/bootstrap.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# One-time Cloudflare resource bootstrap for the registry origin Worker. +# +# Creates the R2 buckets and D1 databases, captures the D1 database IDs, +# patches wrangler.toml (replacing /), and applies the +# schema to both databases. +# +# Prereqs: +# - run from the origin/ directory +# - `npx wrangler login` (or export CLOUDFLARE_API_TOKEN) +# - Git Bash / any bash with sed + grep (no jq required) +# +# Safe to re-run: bucket creation is tolerated if the bucket already exists, and +# D1 IDs are looked up via `d1 info` if the database already exists. + +set -euo pipefail + +UUID_RE='[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + +get_d1_id () { + local name="$1" id + # try to create; pull the uuid out of the printed [[d1_databases]] snippet + id=$(npx wrangler d1 create "$name" 2>&1 | grep -oiE "$UUID_RE" | head -1 || true) + if [ -z "$id" ]; then + # already exists (or output not parseable) -> look it up + id=$(npx wrangler d1 info "$name" 2>&1 | grep -oiE "$UUID_RE" | head -1 || true) + fi + echo "$id" +} + +echo "==> Creating R2 buckets (tolerated if they already exist)..." +npx wrangler r2 bucket create stackql-provider-registry-dev || true +npx wrangler r2 bucket create stackql-provider-registry || true + +echo "==> Creating / resolving D1 databases..." +DEV_ID=$(get_d1_id stackql-registry-analytics-dev) +PROD_ID=$(get_d1_id stackql-registry-analytics) + +[ -n "$DEV_ID" ] || { echo "ERROR: could not resolve dev D1 id"; exit 1; } +[ -n "$PROD_ID" ] || { echo "ERROR: could not resolve prod D1 id"; exit 1; } +echo " dev D1 id: $DEV_ID" +echo " prod D1 id: $PROD_ID" + +echo "==> Patching wrangler.toml..." +sed -i.bak "s//$DEV_ID/g; s//$PROD_ID/g" wrangler.toml +rm -f wrangler.toml.bak + +echo "==> Applying schema to both D1 databases (remote)..." +npx wrangler d1 execute stackql-registry-analytics-dev --remote --file=./schema.sql +npx wrangler d1 execute stackql-registry-analytics --remote --file=./schema.sql + +echo "==> Done. wrangler.toml changes:" +git --no-pager diff -- wrangler.toml || true +echo +echo "Next: commit the patched wrangler.toml, set the GitHub Actions secrets," +echo "then push the branch and open the PR (see origin/RUNSHEET.md)." diff --git a/origin/package.json b/origin/package.json new file mode 100644 index 00000000..1095a207 --- /dev/null +++ b/origin/package.json @@ -0,0 +1,17 @@ +{ + "name": "stackql-provider-registry-origin", + "version": "1.0.0", + "private": true, + "description": "Cloudflare Worker origin for the StackQL provider registry (R2 + D1).", + "scripts": { + "dev": "wrangler dev", + "deploy:dev": "wrangler deploy --env dev", + "deploy:prod": "wrangler deploy --env production", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250101.0", + "typescript": "^5.7.2", + "wrangler": "^3.99.0" + } +} diff --git a/origin/schema.sql b/origin/schema.sql new file mode 100644 index 00000000..32faa859 --- /dev/null +++ b/origin/schema.sql @@ -0,0 +1,14 @@ +-- StackQL Provider Registry analytics (D1). +-- One row per .tgz download. providers.yaml requests are NOT logged. + +CREATE TABLE IF NOT EXISTS downloads ( + ts TEXT NOT NULL, -- ISO8601 timestamp of the download + provider TEXT NOT NULL, -- e.g. aws, google, github + version TEXT, -- e.g. v0.1.3 (or v0.1.3-dev on the dev origin) + pathname TEXT NOT NULL, -- full request path, e.g. /providers/dist/aws/v0.1.3.tgz + host TEXT, -- request Host header + ip_addr TEXT, -- CF-Connecting-IP + user_agent TEXT +); + +CREATE INDEX IF NOT EXISTS idx_downloads_ts_provider ON downloads (ts, provider); diff --git a/origin/src/index.ts b/origin/src/index.ts new file mode 100644 index 00000000..e26a42ac --- /dev/null +++ b/origin/src/index.ts @@ -0,0 +1,400 @@ +/** + * StackQL Provider Registry origin - Cloudflare Worker (green). + * + * Port of the Deno Deploy origin (deno-deploy-registry/website/index.ts). + * The URL contract is preserved exactly: + * + * GET (anything).tgz -> 200 application/gzip, log one download event + * GET (anything)providers.yaml -> 200 text/plain, not logged + * GET (anything)/ping -> 202 "pong" + * GET /analytics -> 200 text/html dashboard (24h / 7d / 30d + 12-month matrix) + * GET /analytics/last24hours -> 200 application/json + * any other path -> 404 + * any non-GET method -> 405 + * + * Provider docs are served from R2 (binding REGISTRY_BUCKET). Download analytics + * are written one row per .tgz pull to D1 (binding ANALYTICS_DB) inside + * ctx.waitUntil so logging never adds latency to a pull. + */ + +export interface Env { + REGISTRY_BUCKET: R2Bucket; + ANALYTICS_DB: D1Database; +} + +interface RequestMetadata { + ipAddr: string; + ts: string; + userAgent: string; + host: string; +} + +function extractRequestMetadata(request: Request): RequestMetadata { + return { + // Deno used conn.remoteAddr.hostname; on Cloudflare the real client IP is here. + ipAddr: request.headers.get('CF-Connecting-IP') || '', + ts: new Date().toISOString(), + userAgent: request.headers.get('user-agent') || '', + host: request.headers.get('host') || '', + }; +} + +interface DownloadRow { + ts: string; + provider: string; + version: string; + pathname: string; + host: string; + ipAddr: string; + userAgent: string; +} + +function constructDownloadRow(pathname: string, meta: RequestMetadata): DownloadRow { + // mirrors constructKvEntry: provider/version are parsed off /providers/dist//.tgz + const document = pathname.replace('/providers/dist/', ''); + const provider = document.split('/')[0]; + const version = document.split('/')[1] || ''; + + return { + ts: meta.ts, + provider, + version, + pathname, + host: meta.host, + ipAddr: meta.ipAddr, + userAgent: meta.userAgent, + }; +} + +async function logDownload(env: Env, row: DownloadRow): Promise { + try { + await env.ANALYTICS_DB.prepare( + `INSERT INTO downloads (ts, provider, version, pathname, host, ip_addr, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind(row.ts, row.provider, row.version, row.pathname, row.host, row.ipAddr, row.userAgent) + .run(); + } catch (err) { + console.error(`Error logging download to D1: ${err}`); + } +} + +async function getProviderDownloads(env: Env, days: number): Promise> { + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + const { results } = await env.ANALYTICS_DB.prepare( + `SELECT provider, COUNT(*) AS cnt FROM downloads WHERE ts >= ? GROUP BY provider`, + ) + .bind(cutoff) + .all<{ provider: string; cnt: number }>(); + + const providers = new Map(); + for (const row of results ?? []) { + providers.set(row.provider, row.cnt); + } + return providers; +} + +async function getYearlyMatrix(env: Env): Promise<{ + providers: string[]; + months: string[]; + data: Record>; +}> { + // last 12 months, grouped by provider and YYYY-MM + const start = new Date(); + start.setMonth(start.getMonth() - 11); + const startStr = start.toISOString(); + + const { results } = await env.ANALYTICS_DB.prepare( + `SELECT provider, substr(ts, 1, 7) AS month, COUNT(*) AS cnt + FROM downloads + WHERE ts >= ? + GROUP BY provider, month`, + ) + .bind(startStr) + .all<{ provider: string; month: string; cnt: number }>(); + + const providerCounts: Record> = {}; + const providers = new Set(); + const months = new Set(); + + for (const row of results ?? []) { + providers.add(row.provider); + months.add(row.month); + if (!providerCounts[row.provider]) { + providerCounts[row.provider] = {}; + } + providerCounts[row.provider][row.month] = row.cnt; + } + + return { + providers: Array.from(providers).sort(), + months: Array.from(months).sort(), + data: providerCounts, + }; +} + +async function handleAnalytics(env: Env, pathname: string): Promise { + try { + if (pathname === '/analytics/last24hours') { + try { + const last24h = await getProviderDownloads(env, 1); + return new Response(JSON.stringify(Object.fromEntries(last24h), null, 2), { + headers: { + 'content-type': 'application/json', + }, + }); + } catch (error: any) { + return new Response(`Error getting 24h stats: ${error.message}`, { status: 500 }); + } + } + + const [last24h, last7d, last30d, yearlyMatrix] = await Promise.all([ + getProviderDownloads(env, 1), + getProviderDownloads(env, 7), + getProviderDownloads(env, 30), + getYearlyMatrix(env), + ]); + + const html = ` + + + + + StackQL Registry Analytics + + + +

StackQL Registry Analytics

+ +

Downloads by Provider (Last 24 Hours)

+
+ + + + + + ${Array.from(last24h.entries()) + .sort(([, a], [, b]) => b - a) + .map( + ([provider, count]) => ` + + + + + `, + ) + .join('')} +
ProviderDownloads
${provider}${count}
+
+ +

Downloads by Provider (Last 7 Days)

+
+ + + + + + ${Array.from(last7d.entries()) + .sort(([, a], [, b]) => b - a) + .map( + ([provider, count]) => ` + + + + + `, + ) + .join('')} +
ProviderDownloads
${provider}${count}
+
+ +

Downloads by Provider (Last 30 Days)

+
+ + + + + + ${Array.from(last30d.entries()) + .sort(([, a], [, b]) => b - a) + .map( + ([provider, count]) => ` + + + + + `, + ) + .join('')} +
ProviderDownloads
${provider}${count}
+
+ +

Provider Downloads by Month (Last 12 Months)

+
+ + + + ${yearlyMatrix.months + .map( + (month) => ` + + `, + ) + .join('')} + + ${yearlyMatrix.providers + .map( + (provider) => ` + + + ${yearlyMatrix.months + .map( + (month) => ` + + `, + ) + .join('')} + + `, + ) + .join('')} +
Provider${month}
${provider}${yearlyMatrix.data[provider]?.[month] || 0}
+
+ +`; + + return new Response(html, { + headers: { + 'content-type': 'text/html', + }, + }); + } catch (error: any) { + return new Response(`Error generating analytics: ${error.message}`, { status: 500 }); + } +} + +async function handleRequest(request: Request, env: Env, ctx: ExecutionContext): Promise { + const { pathname, href } = new URL(request.url); + + let isProviderListReq = false; + + console.info(`request: ${request.method} ${href}`); + + let contentType: string; + + // do not accept any other method than GET + if (request.method !== 'GET') { + return new Response(null, { + status: 405, + statusText: 'Method Not Allowed', + }); + } + + // route to analytics + if (pathname.startsWith('/analytics')) { + return await handleAnalytics(env, pathname); + } + + // route to ping + if (pathname.endsWith('ping')) { + return new Response('pong', { + status: 202, + statusText: 'OK', + }); + } + + // is a provider download or listing request? + let cacheControl: string; + if (pathname.endsWith('tgz')) { + contentType = 'application/gzip'; + // version-pinned artifacts are immutable; keep repeat pulls off R2 + cacheControl = 'public, max-age=31536000, immutable'; + } else if (pathname.endsWith('providers.yaml')) { + isProviderListReq = true; + contentType = 'text/plain'; + // providers.yaml changes on every publish; short cache only + cacheControl = 'public, max-age=60'; + } else { + return new Response(null, { + status: 404, + statusText: 'Not Found', + }); + } + + // R2 key mirrors the Deno on-disk layout: `.${pathname}` -> strip the leading slash + const key = pathname.replace(/^\//, ''); + + const obj = await env.REGISTRY_BUCKET.get(key); + if (obj === null) { + return new Response(null, { + status: 404, + statusText: 'Not Found', + }); + } + + // get request metadata and log the download (not provider list, not localhost) + const metadata = extractRequestMetadata(request); + if (!metadata.host.startsWith('localhost')) { + if (!isProviderListReq) { + const row = constructDownloadRow(pathname, metadata); + // never add latency to the pull + ctx.waitUntil(logDownload(env, row)); + } + } else { + console.info('skipping analytics insert for localhost'); + } + + return new Response(obj.body, { + status: 200, + statusText: 'OK', + headers: { + 'content-type': contentType, + 'cache-control': cacheControl, + }, + }); +} + +export default { + fetch: handleRequest, +}; diff --git a/origin/tsconfig.json b/origin/tsconfig.json new file mode 100644 index 00000000..502e47c4 --- /dev/null +++ b/origin/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/origin/wrangler.toml b/origin/wrangler.toml new file mode 100644 index 00000000..f32bf070 --- /dev/null +++ b/origin/wrangler.toml @@ -0,0 +1,75 @@ +# +# StackQL Provider Registry origin Worker (green). +# +# Two environments mirror the existing dev/prod Deno Deploy split: +# - `dev` -> deployed from the `dev` branch (cutover host: registry-dev.stackql.app) +# - `production` -> deployed from the `main` branch (cutover host: registry.stackql.app) +# +# Named environments do NOT inherit top-level bindings, so each environment +# declares its own R2 + D1 bindings explicitly. The top-level block below is +# used by `wrangler dev` for local development only. +# +# Placeholders to fill once the Cloudflare resources exist (Phase 1): +# / from `wrangler d1 create ...` +# account_id may be set here or via CLOUDFLARE_ACCOUNT_ID in CI. +# + +name = "stackql-provider-registry" +main = "src/index.ts" +compatibility_date = "2025-01-01" + +# account_id = "" # or set CLOUDFLARE_ACCOUNT_ID env var in CI + +# --------------------------------------------------------------------------- +# Local dev defaults (wrangler dev). Points at the dev resources. +# --------------------------------------------------------------------------- +[[r2_buckets]] +binding = "REGISTRY_BUCKET" +bucket_name = "stackql-provider-registry-dev" + +[[d1_databases]] +binding = "ANALYTICS_DB" +database_name = "stackql-registry-analytics-dev" +database_id = "fba87ccb-e0c7-42d3-80fd-51a25798b9d8" + +# --------------------------------------------------------------------------- +# dev environment -> `wrangler deploy --env dev` +# --------------------------------------------------------------------------- +[env.dev] +name = "stackql-provider-registry-dev" + +[[env.dev.r2_buckets]] +binding = "REGISTRY_BUCKET" +bucket_name = "stackql-provider-registry-dev" + +[[env.dev.d1_databases]] +binding = "ANALYTICS_DB" +database_name = "stackql-registry-analytics-dev" +database_id = "fba87ccb-e0c7-42d3-80fd-51a25798b9d8" + +# Until uncommented the Worker is reachable only at its workers.dev route, which +# is what Phase 3 validation uses. Uncomment at the Phase 4 cutover to attach the +# real dev hostname as a custom domain (writes DNS on the stackql.app zone). +# [[env.dev.routes]] +# pattern = "registry-dev.stackql.app" +# custom_domain = true + +# --------------------------------------------------------------------------- +# production environment -> `wrangler deploy --env production` +# --------------------------------------------------------------------------- +[env.production] +name = "stackql-provider-registry-prod" + +[[env.production.r2_buckets]] +binding = "REGISTRY_BUCKET" +bucket_name = "stackql-provider-registry" + +[[env.production.d1_databases]] +binding = "ANALYTICS_DB" +database_name = "stackql-registry-analytics" +database_id = "46c0557d-be94-4866-b56a-765db804880c" + +# Uncomment at the Phase 4 cutover to attach the real prod hostname (see dev block). +# [[env.production.routes]] +# pattern = "registry.stackql.app" +# custom_domain = true