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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
cache-dependency-path: infra/package-lock.json

- name: Setup Java 17
uses: actions/setup-java@v6
Expand All @@ -54,7 +56,7 @@ jobs:
/o:"thomasluizon" \
/d:sonar.token="${SONAR_TOKEN}" \
/d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" \
/d:sonar.javascript.lcov.reportPaths="coverage/web-plan.lcov,coverage/staging-pinger.lcov" \
/d:sonar.javascript.lcov.reportPaths="coverage/web-plan.lcov,coverage/staging-pinger.lcov,coverage/web-routing.lcov" \
/d:sonar.exclusions="tools/**,**/Migrations/**,**/bin/**,**/obj/**,**/OAuth/OAuthLoginPage.cs,**/.claude/**,**/Email/Templates/**,**/load-tests/**,**/bench/**" \
/d:sonar.cpd.exclusions="**/Chat/Tools/Implementations/CreateHabitTool.cs,**/Chat/Tools/Implementations/UpdateHabitTool.cs,**/Services/AgentCatalogService.UserDataCatalog.cs,**/Services/SlipAlertSchedulerService.cs,**/Services/ProactiveCheckinSchedulerService.cs,**/Accountability/Commands/AcceptAccountabilityPairCommand.cs,**/Social/Commands/AcceptFriendRequestCommand.cs" \
/d:sonar.coverage.exclusions="**/Program.cs,**/Extensions/ServiceCollectionExtensions*.cs,**/Extensions/WebApplicationExtensions.cs,**/Middleware/**,**/OpenApi/**,**/OAuth/**,**/Persistence/OrbitDbContext.cs,**/Persistence/OrbitDbContextFactory.cs,**/Persistence/GenericRepository.cs,**/Persistence/UnitOfWork.cs,**/Persistence/AccountResetRepository.cs,**/Configuration/**,**/Services/ReminderSchedulerService.cs,**/Services/SlipAlertSchedulerService.cs,**/Services/GoalDeadlineNotificationService.cs,**/Services/HabitDueDateAdvancementService.cs,**/Services/DataEncryptionMigrationService.cs,**/Services/PushNotificationService.cs,**/Services/AccountDeletionService.cs,**/Services/GoogleTokenService.cs,**/Services/BackgroundServiceHealthCheck.cs,**/Services/StripeCouponRewardService.cs,**/Services/StripeBillingService.cs,**/Services/GooglePlayBillingService.cs,**/Services/Calendar/**,**/AI/AiCompletionClient.cs,**/AI/AiBatchClient.cs,**/AI/AudioTranscriptionService.cs,**/Services/GeoLocationService.cs,**/BackgroundJobs/HangfireRecurringJobRegistrar.cs,**/Orbit.Analyzers/**,**/Orbit.Analyzers.CodeFixes/**,**/.github/**,**/bench/**"
Expand All @@ -70,9 +72,11 @@ jobs:

- name: Test infra scripts with coverage
run: |
npm ci --prefix infra --ignore-scripts
mkdir -p coverage
node --experimental-test-coverage --test --test-coverage-include=infra/check-web-plan.mjs --test-reporter=lcov --test-reporter-destination=coverage/web-plan.lcov infra/check-web-plan.test.mjs
node --experimental-test-coverage --test --test-coverage-include=infra/workers/staging-pinger.mjs --test-reporter=lcov --test-reporter-destination=coverage/staging-pinger.lcov infra/staging-pinger.test.mjs
node --experimental-test-coverage --test --test-coverage-include=infra/workers/web-routing.mjs --test-reporter=lcov --test-reporter-destination=coverage/web-routing.lcov infra/web-routing.test.mjs

- name: SonarScanner End
env:
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/terraform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: infra/package-lock.json
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e
with:
terraform_version: 1.16.4
- run: terraform fmt -check -recursive infra
- run: terraform -chdir=infra init -backend=false -input=false
- run: terraform -chdir=infra validate
- run: node --test infra/check-web-plan.test.mjs infra/ses-isolation.test.mjs infra/google-play-isolation.test.mjs infra/staging-pinger.test.mjs
- run: npm ci --prefix infra --ignore-scripts
- run: npm test --prefix infra
1 change: 1 addition & 0 deletions infra/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
*.tfplan
*.tfvars
!example.tfvars
node_modules/
29 changes: 28 additions & 1 deletion infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Terraform creates `/orbit/production/api/BotProtection__SecretKey` and `/orbit/s

## DNS cutover

The Cloudflare zone uses full DNS setup on the Free plan. The existing records have automatic TTL and remain unproxied. At cutover, use these values in `infra/local.tfvars`:
The Cloudflare zone uses full DNS setup on the Free plan. The existing records have automatic TTL. Only `app.useorbit.org` is proxied for production web design routing; the other existing records remain unproxied. At cutover, use these values in `infra/local.tfvars`:

```hcl
dns_apex_target = "orbit-landing-aaa7.onrender.com"
Expand Down Expand Up @@ -241,3 +241,30 @@ The initial plan must keep `srv-d6tc2isr85hc739bf75g`, its URL, and `api.useorbi
For the production database cutover, first confirm the production data is present in the Render database shown on the Render Postgres Connections screen. Set `api_database = "render"` in `infra/local.tfvars` and run a fresh plan. Confirm both `ConnectionStrings__DefaultConnection` and `ConnectionStrings__SessionConnection` in the production API environment group name `orbit_production_9g8l`, as derived from that Render connection URL. Confirm `render_postgres.production` is unchanged and neither web service nor the imported API service changes in place. Apply the plan only after those checks. Verify the linked environment group and production API health, then remove the production API service's duplicated direct variables in the Render service Environment screen, leaving `ORBIT_TERRAFORM_ENV_GROUP`. Confirm the service still uses the Render database after the direct variables are removed.

Staging reads Stripe test-mode product and price IDs from SSM and uses staging return URLs. `Supabase__Url` points at an invalid host so staging cannot write to production storage. The web image must exist at both selected digests before the first apply. Set production web and landing custom domains during cutover. DNS and certificate verification follow the domain changes.

## Production web design routing

`web-routing.tf` attaches `orbit-web-routing` to `app.useorbit.org/*`. Only the `app_cname` record is proxied. Its DNS target and the current service's `app.useorbit.org` custom domain remain the fallback: removing the Worker route sends every request to the current build, including requests carrying the design cookie. Keep `dns_app_target = "orbit-web-3qmv.onrender.com"` and `web_custom_domains = ["app.useorbit.org"]` in the existing ignored `local.tfvars`.

An exact `orbit_design=redesign` cookie selects `orbit-web-redesign`; every other value selects the current service. The current route forwards the original request to the DNS origin without changing its public Host. The redesign route uses the redesign Render hostname for its URL and Host, sets `X-Forwarded-Host: app.useorbit.org` and `X-Forwarded-Proto: https`, and preserves the path, query, method, streaming body, and other headers. Responses pass through with their status, body, cookies, and redirect headers. Each origin has its own fetch URL and cache key; the Worker adds no shared response cache. The cookie selects a UI build and grants no API permissions.

Both production web services link to `orbit-production-web`, including `NEXT_PUBLIC_SITE_URL=https://app.useorbit.org`. The new service uses the current service's image repository, plan, region, health path, and digest lifecycle policy. Image-backed services do not support automatic Git deploys; the Render provider's image schema has no `auto_deploy` field. Terraform creates the initial service from `production_redesign_web_digest`, which must name an already published production-configured image built from `orbit-ui-mobile`'s `redesign/main`. Later releases use that repository's manual production `release.yml` with the branch and service selection supplied by thomasluizon/orbit-tickets#1499. Terraform ignores subsequent digest changes. The web plan guard also covers the redesign service.

Before applying, the orchestrator must:

1. In Cloudflare > My Profile > API Tokens, confirm the token used for `CLOUDFLARE_API_TOKEN` has account-level **Workers Scripts Write** for the Orbit account and zone-level **Workers Routes Write** and **DNS Write** for `useorbit.org`. Refresh also needs access to read those resources; the provider lists **Workers Scripts Read**, **Workers Routes Read**, and **DNS Read** as accepted read permissions. If an operation is denied, report the exact missing permission, especially **Workers Routes Write**, which is distinct from script access. A token's successful script upload does not prove route access.
2. In the existing Terraform execution environment, set `production_redesign_web_digest` in ignored `infra/local.tfvars` to the approved published redesign image digest. Check the production public host settings above. Review a saved plan and run `check-web-plan.mjs` against it. Expected scope is creation of `render_web_service.production_redesign_web`, `cloudflare_workers_script.web_routing`, and `cloudflare_workers_route.web_routing`, an update of `cloudflare_dns_record.existing["app_cname"]` to proxied, and the necessary addition to `render_env_group_link.production_web.service_ids`. Neither existing web service should change. This is an expected scope, not a recorded live plan result.
3. Apply through the existing infrastructure process. In Render > orbit-web-redesign > Environment, confirm the linked `orbit-production-web` group and public site URL. In Settings > Deploy, confirm the approved redesign image digest. Read Terraform's `production_redesign_web_service_id` and `production_redesign_web_host` outputs for #1499; that ticket owns the exact repository variable key and release workflow changes in `orbit-ui-mobile`.
4. With the UI design-cookie ticket and #1499 deployed, request `https://app.useorbit.org/api/health` with no cookie and with `Cookie: orbit_design=redesign`. Compare each actual health response with the approved `main` and `redesign/main` release commits. Remove the cookie and reload to verify the current build returns. In the public app, test email sign-in, Google sign-in, `/auth-callback`, all other configured callbacks, session renewal, and a Server Action submission on both routes. Confirm the browser stays on `app.useorbit.org` and sessions survive reloads. Local unit tests cover forwarding, not the live auth providers or Next.js runtime.
5. Measure added latency after deployment at the same Cloudflare location. Compare the routed public host with the same origin and public TLS/Host using an origin-resolved request that bypasses Cloudflare. Take interleaved repeated samples and report median time to first byte for each, then subtract direct-origin from routed latency. Use a public immutable asset after warming it for the cached case and confirm cache hits from the actual responses; use `/api/health` with a unique query for the uncached case and confirm it is not cached. Record sample count, cache evidence, and both added latency numbers. Miniflare timings do not measure deployed edge overhead.
6. In Cloudflare > Workers & Pages > orbit-web-routing > Settings > Domains & Routes, delete only the `app.useorbit.org/*` route to roll back immediately. Verify both cookie variants now return the current build's health commit. Preserve the proxied DNS record and current service's custom domain. Remove the route from Terraform configuration before a subsequent apply that should retain the rollback.

The DNS cutover script checks unproxied CNAME answers and is intended for the registrar cutover before this routing change. A proxied `app` returns Cloudflare addresses; use the health and fallback checks above after enabling the proxy.

### Interface evidence

The pinned Cloudflare provider's live [Worker script schema](https://github.com/cloudflare/terraform-provider-cloudflare/blob/v5.26.0/docs/resources/workers_script.md), [Worker route schema](https://github.com/cloudflare/terraform-provider-cloudflare/blob/v5.26.0/docs/resources/workers_route.md), and [DNS record schema](https://github.com/cloudflare/terraform-provider-cloudflare/blob/v5.26.0/docs/resources/dns_record.md) confirm the resource names, plain-text bindings, route `script` and `pattern`, and `proxied` fields. Cloudflare's [route documentation](https://developers.cloudflare.com/workers/configuration/routing/routes/) requires a proxied DNS record; its [Request contract](https://developers.cloudflare.com/workers/runtime-apis/request/) describes cloning a request while changing the URL and headers. Render's [image deployment documentation](https://render.com/docs/deploying-an-image) confirms image-backed services have no automatic image redeploys.

A real `terraform providers schema -json` invocation against the installed pinned providers confirmed that `render_web_service.id` and `.url` are computed strings and `runtime_source.image` accepts `image_url`, `digest`, `tag`, and `registry_credential_id`, with no auto-deploy field. Reproduce in a backend-disabled scratch root containing `versions.tf`'s provider declarations without its S3 backend, or against the initialized backend. No Render API response fields are read by this change.

Run `npm ci --prefix infra` and `npm test --prefix infra` for the infrastructure tests. The routing tests execute the deployed module in Miniflare with outbound requests intercepted and Cloudflare metadata fetching disabled. The [Miniflare testing documentation](https://developers.cloudflare.com/workers/testing/miniflare/) confirms the simulator's test API. Installed `miniflare/dist/src/index.d.ts` confirms `outboundService` accepts a Request callback returning a Response, `bindings` supplies environment values, and `dispatchFetch` invokes the Worker. Its implementation in `dist/src/index.js` dispatches outbound requests to that callback. These requests, rather than invented API fixtures, supply the assertions. The locked test dependencies override `undici` and `sharp` to patched versions; no testing dependency is bundled into the Worker uploaded by Terraform.
1 change: 1 addition & 0 deletions infra/check-web-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"

const guardedAddresses = new Set([
"render_web_service.production_web",
"render_web_service.production_redesign_web",
"render_web_service.staging_web",
])

Expand Down
20 changes: 11 additions & 9 deletions infra/check-web-plan.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@ test("allows an unrelated service update", () => {
assert.deepEqual(checkPlan(fixture("unrelated-update.json")), [])
})

test("guards the production address and rejects replacement", () => {
const update = fixture("web-update.json")
update.resource_changes[0].address = "render_web_service.production_web"
assert.match(checkPlan(update)[0], /render_web_service\.production_web/)
update.resource_changes[0].change.actions = ["delete", "create"]
assert.match(checkPlan(update)[0], /replacement/)
update.resource_changes[0].change.actions = ["create", "delete"]
assert.match(checkPlan(update)[0], /replacement/)
})
for (const address of ["render_web_service.production_web", "render_web_service.production_redesign_web"]) {
test(`guards ${address} and rejects replacement`, () => {
const update = fixture("web-update.json")
update.resource_changes[0].address = address
assert.ok(checkPlan(update)[0].includes(address))
update.resource_changes[0].change.actions = ["delete", "create"]
assert.match(checkPlan(update)[0], /replacement/)
update.resource_changes[0].change.actions = ["create", "delete"]
assert.match(checkPlan(update)[0], /replacement/)
})
}

test("rejects deletion of a web service", () => {
const deletion = fixture("web-update.json")
Expand Down
2 changes: 1 addition & 1 deletion infra/cloudflare.tf
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ resource "cloudflare_dns_record" "existing" {
content = each.value.content
priority = try(each.value.priority, null)
ttl = 1
proxied = false
proxied = each.key == "app_cname"
}

resource "cloudflare_dns_record" "staging" {
Expand Down
1 change: 1 addition & 0 deletions infra/example.tfvars
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
production_web_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
production_redesign_web_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
staging_web_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
production_ses_dlq_alert_email = ""
staging_ses_dlq_alert_email = ""
Expand Down
Loading
Loading