From b1fb486b617375898d2a802e703f941ef8842710 Mon Sep 17 00:00:00 2001 From: Dylan Steele Date: Mon, 8 Jun 2026 21:25:34 -0400 Subject: [PATCH 1/2] Add Cloudflare OpenTofu scaffold --- .gitignore | 7 ++ docs/20-hosting-and-security.md | 7 ++ docs/21-hosting-ui-setup.md | 2 + docs/22-infrastructure-as-code.md | 67 +++++++++++++ docs/README.md | 1 + infra/opentofu/.terraform.lock.hcl | 19 ++++ infra/opentofu/README.md | 70 +++++++++++++ infra/opentofu/main.tf | 128 ++++++++++++++++++++++++ infra/opentofu/outputs.tf | 60 +++++++++++ infra/opentofu/terraform.tfvars.example | 26 +++++ infra/opentofu/variables.tf | 116 +++++++++++++++++++++ package.json | 2 + 12 files changed, 505 insertions(+) create mode 100644 docs/22-infrastructure-as-code.md create mode 100644 infra/opentofu/.terraform.lock.hcl create mode 100644 infra/opentofu/README.md create mode 100644 infra/opentofu/main.tf create mode 100644 infra/opentofu/outputs.tf create mode 100644 infra/opentofu/terraform.tfvars.example create mode 100644 infra/opentofu/variables.tf diff --git a/.gitignore b/.gitignore index 7260785..1b5cb8b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,13 @@ build/ .wrangler/ .astro/ +# OpenTofu / Terraform local state and provider installs. +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example + # Codex local integration links. These point at user-specific AI Central # checkouts and are recreated with `npm run codex:links`. .codex/skills/ diff --git a/docs/20-hosting-and-security.md b/docs/20-hosting-and-security.md index b920107..bba0ff7 100644 --- a/docs/20-hosting-and-security.md +++ b/docs/20-hosting-and-security.md @@ -41,12 +41,14 @@ Already implemented: - Pages uses Astro CSP support for script/style hashes and Worker `connect-src`. - Pages static headers include HSTS, frame denial, referrer policy, nosniff, and a restrictive permissions policy. - Repository data, analysis results, reports, and exports stay out of Cloudflare. +- Initial OpenTofu scaffold for Cloudflare Pages, KV, domains, and environment output wiring. Not production-ready yet: - Production `SETTINGS_KV` namespace IDs are not configured in `wrangler.toml`. - Pages preview deployment CORS behavior is not finalized. - Production Cloudflare resource names and custom domains are not finalized. - Production GitHub App callback URLs are still placeholders. +- OpenTofu has not been applied against the real Cloudflare account yet. ## Production Storage Decision @@ -88,6 +90,11 @@ Token posture: ## Cloudflare Configuration +Infrastructure: +- OpenTofu configuration lives in `infra/opentofu`. +- Keep secret values out of OpenTofu state. +- Use OpenTofu outputs for `SETTINGS_KV`, Pages env vars, Worker env vars, and GitHub App URLs. + Pages production config: - Build command: `pnpm --filter @forage/web build` - Build output directory: `apps/web/dist` diff --git a/docs/21-hosting-ui-setup.md b/docs/21-hosting-ui-setup.md index 48fcfac..7daba81 100644 --- a/docs/21-hosting-ui-setup.md +++ b/docs/21-hosting-ui-setup.md @@ -74,6 +74,7 @@ Custom domains: Important: - `PUBLIC_WORKER_ORIGIN` is compiled into the Astro build and into CSP `connect-src`. - If the Worker API hostname changes, update the Pages environment variable and redeploy Pages. +- If using OpenTofu, confirm the Pages project, build variables, and custom domains match `infra/opentofu` outputs. ## Cloudflare Worker UI @@ -125,6 +126,7 @@ KV namespace: - Create production namespace: `forage-production-settings` - Create staging namespace: `forage-staging-settings` - Bind each namespace to the Worker as `SETTINGS_KV` +- If using OpenTofu, the namespace ids come from `tofu output settings_kv_namespaces`. Do not create or bind storage for repository lists, analysis results, reports, or exports. diff --git a/docs/22-infrastructure-as-code.md b/docs/22-infrastructure-as-code.md new file mode 100644 index 0000000..cbfeaaa --- /dev/null +++ b/docs/22-infrastructure-as-code.md @@ -0,0 +1,67 @@ +# Infrastructure As Code + +Status: +Initial OpenTofu scaffold + +Forage uses OpenTofu-compatible Terraform configuration for Cloudflare resources under `infra/opentofu`. + +The current split is intentional: + +- OpenTofu owns stable account/project resources and hostname wiring. +- Wrangler still owns Worker code upload and Durable Object migrations. +- GitHub App creation and OAuth secrets stay manual/operator-controlled. +- Repository data remains browser-local and is not represented in Cloudflare infrastructure. + +## Managed Resources + +Current OpenTofu scope: + +- Cloudflare Pages project for `apps/web`. +- Pages production and preview build environment variables. +- Pages custom domains. +- Cloudflare KV namespaces for `SETTINGS_KV`. +- Optional Worker custom domains after the Worker service exists. +- Outputs for GitHub App homepage/callback URL values. +- Outputs for Worker and Pages environment variables. + +Current manual scope: + +- GitHub App creation and permissions. +- GitHub App client id/client secret storage. +- Worker secret values. +- First Worker code deployment with Wrangler. +- Any Cloudflare Git integration that requires a connected GitHub account. + +## Privacy Boundary + +Do not add server-side storage resources for: + +- Starred repository lists. +- GitHub repository metadata imported by the user. +- Analysis results. +- Generated reports. +- User exports. + +Allowed server-side resources remain limited to auth/session coordination, non-obvious user hashes, settings/preferences, and aggregate analytics if the user opts in. + +## First Run + +```sh +cd infra/opentofu +cp terraform.tfvars.example terraform.tfvars +tofu init +tofu plan +``` + +Set `manage_worker_custom_domains = false` until the Worker service has been deployed at least once. + +After apply, copy the `settings_kv_namespaces` output into the Worker binding configuration as `SETTINGS_KV` for each environment. + +## Sources Checked + +- [Cloudflare Terraform overview](https://developers.cloudflare.com/terraform/) +- [Cloudflare Workers infrastructure-as-code guidance](https://developers.cloudflare.com/workers/platform/infrastructure-as-code/) +- [Cloudflare Terraform Pages resources](https://developers.cloudflare.com/api/terraform/resources/pages) +- [Cloudflare Terraform Workers resources](https://developers.cloudflare.com/api/terraform/resources/workers/) +- [Cloudflare Terraform KV resources](https://developers.cloudflare.com/api/terraform/resources/kv/) +- [Cloudflare Terraform Workers custom domain resources](https://developers.cloudflare.com/api/terraform/resources/workers/subresources/domains/) diff --git a/docs/README.md b/docs/README.md index 3a4eed1..edd779b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,7 @@ This directory contains the planning and technical decision material for Forage. - [Code Audit](./19-code-audit.md) - [Hosting And Security Plan](./20-hosting-and-security.md) - [Hosting UI Setup](./21-hosting-ui-setup.md) +- [Infrastructure As Code](./22-infrastructure-as-code.md) ## Architecture Decisions diff --git a/infra/opentofu/.terraform.lock.hcl b/infra/opentofu/.terraform.lock.hcl new file mode 100644 index 0000000..73b5e87 --- /dev/null +++ b/infra/opentofu/.terraform.lock.hcl @@ -0,0 +1,19 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "5.9.0" + constraints = "5.9.0" + hashes = [ + "h1:Wz1g6VGQE26xKhON1jMmJtpxpI2rgZS6BQ6Bssr2NoE=", + "zh:3ae6f70f4e2961e84b89b337d268db0537c6dd0e66d4ccf32cbacc950f7a2807", + "zh:4472d1d1629c3c3a6a23672691ed0852bea9fcd9e39a213d388616f93adaaeb0", + "zh:4680cb586233b4702abb9fc69615bca6ebe9547ddaceaa0d0439bccc7c773905", + "zh:51fd157b544644438ab827e288c8173ecbf31cd92b3b75a8446569db35c00cab", + "zh:66aaeb1cb991b982f81564ea52a18ba962b43e86d9e5c76ac591fa522a4a0db6", + "zh:d271308040efe8324633810d8c58142310da5f88eb223422fd13daa73e944316", + "zh:e56c66588135878081ffa8c2aa5da969d56ff96d4ecb4b0ab6b8b496830cf7c2", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", + "zh:fde7a7d2bda2784e78c0bcc39155e0b09c31dd4a4fa65c26e0e8fe858445ecfc", + ] +} diff --git a/infra/opentofu/README.md b/infra/opentofu/README.md new file mode 100644 index 0000000..08958eb --- /dev/null +++ b/infra/opentofu/README.md @@ -0,0 +1,70 @@ +# Forage Cloudflare OpenTofu + +This directory manages the hosted Cloudflare infrastructure that can be safely represented as code for the Forage MVP. + +Managed here: +- Cloudflare KV namespaces for small settings/preferences records. +- Cloudflare Pages project build settings and non-secret environment variables. +- Cloudflare Pages custom domains. +- Optional Cloudflare Worker custom domains after the Worker service exists. +- Outputs for GitHub App callback/homepage URLs and Worker/Page environment wiring. + +Not managed here: +- GitHub App creation or OAuth client secrets. +- Secret values such as `GITHUB_CLIENT_SECRET`, `SETTINGS_HASH_SALT`, or `SESSION_ENCRYPTION_KEY`. +- Browser-local repository data, analysis results, reports, or exports. +- Worker code uploads for MVP; `apps/worker` still deploys through Wrangler so Durable Object migrations stay aligned with `wrangler.toml`. + +## Usage + +```sh +cd infra/opentofu +cp terraform.tfvars.example terraform.tfvars +tofu init +tofu plan +tofu apply +``` + +Use environment variables for Cloudflare authentication: + +```sh +export CLOUDFLARE_API_TOKEN=... +``` + +The token needs enough access to manage Pages, Workers custom domains, and Workers KV in the selected account and zone. + +Formatting: + +```sh +pnpm infra:fmt:check +pnpm infra:fmt +``` + +## First Apply + +For a new account, keep `manage_worker_custom_domains = false` until `pnpm --filter @forage/worker exec wrangler deploy --env ` or equivalent Wrangler deployment has created the Worker service. + +After the Worker exists: + +1. Set `manage_worker_custom_domains = true`. +2. Re-run `tofu plan`. +3. Re-run `tofu apply`. + +## Secrets + +OpenTofu intentionally does not accept secret variables for the GitHub App or Worker session crypto. Configure these with Wrangler or Cloudflare UI/CI secret handling: + +```sh +pnpm --filter @forage/worker exec wrangler secret put GITHUB_CLIENT_ID --env production +pnpm --filter @forage/worker exec wrangler secret put GITHUB_CLIENT_SECRET --env production +pnpm --filter @forage/worker exec wrangler secret put SETTINGS_HASH_SALT --env production +pnpm --filter @forage/worker exec wrangler secret put SESSION_ENCRYPTION_KEY --env production +``` + +Use separate values for staging and production. + +## Wrangler Bindings + +After apply, read `settings_kv_namespaces` from `tofu output`. Bind each namespace as `SETTINGS_KV` in `apps/worker/wrangler.toml` or equivalent deployment automation. + +Do not add KV namespaces for repositories, analysis, reports, or exports. diff --git a/infra/opentofu/main.tf b/infra/opentofu/main.tf new file mode 100644 index 0000000..1f351c6 --- /dev/null +++ b/infra/opentofu/main.tf @@ -0,0 +1,128 @@ +terraform { + required_version = ">= 1.8.0" + + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = "5.9.0" + } + } +} + +provider "cloudflare" {} + +locals { + environment_config = { + for name, environment in var.environments : name => merge(environment, { + github_redirect_uri = coalesce( + environment.github_redirect_uri, + "https://${environment.api_hostname}/auth/github/callback", + ) + settings_kv_title = coalesce( + environment.settings_kv_title, + "${var.project_slug}-${name}-settings", + ) + worker_service_name = coalesce( + environment.worker_service_name, + name == "production" ? var.worker_service_name : "${var.worker_service_name}-${name}", + ) + }) + } + + pages_environment_variables = { + for name, environment in local.environment_config : name => { + NODE_VERSION = var.node_version + PUBLIC_WORKER_ORIGIN = "https://${environment.api_hostname}" + } + } + + worker_environment_variables = { + for name, environment in local.environment_config : name => { + ENVIRONMENT = name + GITHUB_API_VERSION = var.github_api_version + GITHUB_REDIRECT_URI = environment.github_redirect_uri + WEB_ORIGIN = "https://${environment.web_hostname}" + SETTINGS_KV_ID = try(cloudflare_workers_kv_namespace.settings[name].id, null) + } + } +} + +resource "cloudflare_workers_kv_namespace" "settings" { + for_each = { + for name, environment in local.environment_config : name => environment + if environment.create_settings_kv + } + + account_id = var.cloudflare_account_id + title = each.value.settings_kv_title +} + +resource "cloudflare_pages_project" "web" { + count = var.manage_pages_project ? 1 : 0 + + account_id = var.cloudflare_account_id + name = var.pages_project_name + production_branch = var.production_branch + + build_config = { + build_caching = true + build_command = "pnpm --filter @forage/web build" + destination_dir = "apps/web/dist" + root_dir = "/" + } + + deployment_configs = { + preview = { + env_vars = { + NODE_VERSION = { + type = "plain_text" + value = var.node_version + } + PUBLIC_WORKER_ORIGIN = { + type = "plain_text" + value = "https://${local.environment_config[var.preview_environment_name].api_hostname}" + } + } + fail_open = false + } + production = { + env_vars = { + NODE_VERSION = { + type = "plain_text" + value = var.node_version + } + PUBLIC_WORKER_ORIGIN = { + type = "plain_text" + value = "https://${local.environment_config["production"].api_hostname}" + } + } + fail_open = false + } + } +} + +resource "cloudflare_pages_domain" "web" { + for_each = { + for name, environment in local.environment_config : name => environment + if var.manage_pages_domains && environment.manage_pages_domain + } + + account_id = var.cloudflare_account_id + project_name = var.pages_project_name + name = each.value.web_hostname + + depends_on = [cloudflare_pages_project.web] +} + +resource "cloudflare_workers_custom_domain" "api" { + for_each = { + for name, environment in local.environment_config : name => environment + if var.manage_worker_custom_domains && environment.manage_worker_custom_domain + } + + account_id = var.cloudflare_account_id + hostname = each.value.api_hostname + service = each.value.worker_service_name + zone_id = var.cloudflare_zone_id + zone_name = var.cloudflare_zone_name +} diff --git a/infra/opentofu/outputs.tf b/infra/opentofu/outputs.tf new file mode 100644 index 0000000..b575227 --- /dev/null +++ b/infra/opentofu/outputs.tf @@ -0,0 +1,60 @@ +output "settings_kv_namespaces" { + description = "KV namespace ids to bind as SETTINGS_KV in apps/worker/wrangler.toml or Worker deployment automation." + value = { + for name, namespace in cloudflare_workers_kv_namespace.settings : name => { + title = namespace.title + id = namespace.id + } + } +} + +output "pages_environment_variables" { + description = "Plain-text Cloudflare Pages environment variables per environment." + value = local.pages_environment_variables +} + +output "worker_environment_variables" { + description = "Plain-text Cloudflare Worker environment variables per environment. SETTINGS_KV_ID is an output reference, not a secret." + value = local.worker_environment_variables +} + +output "github_app_urls" { + description = "Values to copy into GitHub App settings for each environment." + value = { + for name, environment in local.environment_config : name => { + homepage_url = "https://${environment.web_hostname}" + callback_url = environment.github_redirect_uri + } + } +} + +output "required_worker_secret_names" { + description = "Secret names that must be configured outside OpenTofu state." + value = [ + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "SETTINGS_HASH_SALT", + "SESSION_ENCRYPTION_KEY", + ] +} + +output "worker_custom_domains" { + description = "Worker API domains managed by this configuration when manage_worker_custom_domains is true." + value = { + for name, domain in cloudflare_workers_custom_domain.api : name => { + hostname = domain.hostname + service = domain.service + id = domain.id + } + } +} + +output "pages_custom_domains" { + description = "Pages web domains managed by this configuration." + value = { + for name, domain in cloudflare_pages_domain.web : name => { + hostname = domain.name + status = domain.status + } + } +} diff --git a/infra/opentofu/terraform.tfvars.example b/infra/opentofu/terraform.tfvars.example new file mode 100644 index 0000000..192e23c --- /dev/null +++ b/infra/opentofu/terraform.tfvars.example @@ -0,0 +1,26 @@ +# Copy to terraform.tfvars or pass with -var-file. Do not commit real .tfvars files. + +cloudflare_account_id = "replace-with-account-id" +cloudflare_zone_id = "replace-with-zone-id" +cloudflare_zone_name = "example.com" + +pages_project_name = "forage-web" +worker_service_name = "forage-worker" + +# Worker custom domains require the target Worker services to exist first. +# Leave false for the first apply if Wrangler has not deployed the Worker yet. +manage_worker_custom_domains = false + +environments = { + staging = { + web_hostname = "staging.forage.example.com" + api_hostname = "api-staging.forage.example.com" + worker_service_name = "forage-worker-staging" + } + + production = { + web_hostname = "forage.example.com" + api_hostname = "api.forage.example.com" + worker_service_name = "forage-worker" + } +} diff --git a/infra/opentofu/variables.tf b/infra/opentofu/variables.tf new file mode 100644 index 0000000..d1f3722 --- /dev/null +++ b/infra/opentofu/variables.tf @@ -0,0 +1,116 @@ +variable "cloudflare_account_id" { + description = "Cloudflare account id that owns Pages, Workers, Durable Objects, and KV." + type = string +} + +variable "cloudflare_zone_id" { + description = "Cloudflare zone id for Forage custom domains." + type = string +} + +variable "cloudflare_zone_name" { + description = "Cloudflare zone name for Forage custom domains." + type = string +} + +variable "project_slug" { + description = "Short stable project slug used in managed Cloudflare resource names." + type = string + default = "forage" +} + +variable "pages_project_name" { + description = "Cloudflare Pages project name for apps/web." + type = string + default = "forage-web" +} + +variable "worker_service_name" { + description = "Base Cloudflare Worker service name. Staging defaults to this name suffixed with the environment name." + type = string + default = "forage-worker" +} + +variable "production_branch" { + description = "Git branch Cloudflare Pages treats as production." + type = string + default = "main" +} + +variable "preview_environment_name" { + description = "Environment config used for Cloudflare Pages preview builds." + type = string + default = "staging" +} + +variable "node_version" { + description = "Node.js version used by Cloudflare Pages builds." + type = string + default = "22" +} + +variable "github_api_version" { + description = "GitHub REST API version passed to the Worker." + type = string + default = "2022-11-28" +} + +variable "manage_pages_project" { + description = "Whether OpenTofu should create and configure the Cloudflare Pages project." + type = bool + default = true +} + +variable "manage_pages_domains" { + description = "Whether OpenTofu should attach custom domains to the Pages project." + type = bool + default = true +} + +variable "manage_worker_custom_domains" { + description = "Whether OpenTofu should attach custom domains to deployed Worker services." + type = bool + default = false +} + +variable "environments" { + description = "Hosted environment topology. Keep repository data out of all server-side resources." + type = map(object({ + web_hostname = string + api_hostname = string + github_redirect_uri = optional(string) + worker_service_name = optional(string) + settings_kv_title = optional(string) + create_settings_kv = optional(bool, true) + manage_pages_domain = optional(bool, true) + manage_worker_custom_domain = optional(bool, true) + })) + + default = { + staging = { + web_hostname = "staging.forage.example.com" + api_hostname = "api-staging.forage.example.com" + create_settings_kv = true + manage_pages_domain = true + manage_worker_custom_domain = true + } + production = { + web_hostname = "forage.example.com" + api_hostname = "api.forage.example.com" + create_settings_kv = true + manage_pages_domain = true + manage_worker_custom_domain = true + worker_service_name = "forage-worker" + } + } + + validation { + condition = contains(keys(var.environments), "production") + error_message = "The environments map must include a production environment." + } + + validation { + condition = contains(keys(var.environments), var.preview_environment_name) + error_message = "preview_environment_name must match a key in environments." + } +} diff --git a/package.json b/package.json index 7645f04..f945ef3 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "format": "npm run format:astro && biome format --write .", "format:astro": "prettier --write \"**/*.astro\"", "format:astro:check": "prettier --check \"**/*.astro\"", + "infra:fmt": "tofu fmt -recursive infra/opentofu", + "infra:fmt:check": "tofu fmt -check -recursive infra/opentofu", "lint": "biome check .", "lint:fix": "biome check --write .", "lint-staged": "lint-staged", From f72ebb4023dbe7353cec6946dee47beffddc3d12 Mon Sep 17 00:00:00 2001 From: Dylan Steele Date: Mon, 8 Jun 2026 21:30:20 -0400 Subject: [PATCH 2/2] Fix Worker custom domain environment config --- infra/opentofu/main.tf | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/infra/opentofu/main.tf b/infra/opentofu/main.tf index 1f351c6..a986858 100644 --- a/infra/opentofu/main.tf +++ b/infra/opentofu/main.tf @@ -120,9 +120,10 @@ resource "cloudflare_workers_custom_domain" "api" { if var.manage_worker_custom_domains && environment.manage_worker_custom_domain } - account_id = var.cloudflare_account_id - hostname = each.value.api_hostname - service = each.value.worker_service_name - zone_id = var.cloudflare_zone_id - zone_name = var.cloudflare_zone_name + account_id = var.cloudflare_account_id + environment = each.key + hostname = each.value.api_hostname + service = each.value.worker_service_name + zone_id = var.cloudflare_zone_id + zone_name = var.cloudflare_zone_name }