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
56 changes: 56 additions & 0 deletions .github/workflows/infra-layout-validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: infra-layout-validate

on:
pull_request:
paths:
- ".ores-infra.toml"
- "modules/**"
- "environments/**"
- ".github/workflows/infra-layout-validate.yml"
push:
branches: [main]
paths: [".ores-infra.toml", "modules/**", "environments/**"]

permissions:
contents: read

concurrency:
group: infra-layout-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
terraform:
name: Terraform / ${{ matrix.environment }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
environment: [preview, staging, production]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: { persist-credentials: false }
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with: { terraform_version: 1.16.2, terraform_wrapper: false }
- name: Check Terraform formatting
run: |
terraform fmt -check -recursive modules/cloudflare/terraform
terraform fmt -check -recursive "environments/${{ matrix.environment }}"
- name: Initialize without remote backend
working-directory: environments/${{ matrix.environment }}
run: terraform init -backend=false -input=false
- name: Validate
working-directory: environments/${{ matrix.environment }}
run: terraform validate -no-color

cloudflare-worker:
name: Cloudflare RPC / dry run
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: { persist-credentials: false }
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with: { node-version: "22.22.1" }
- working-directory: modules/cloudflare/durable-coordinator
run: npm install --package-lock=false --ignore-scripts --no-audit --no-fund
- working-directory: modules/cloudflare/durable-coordinator
run: npm run check
30 changes: 27 additions & 3 deletions .ores-infra.toml
Original file line number Diff line number Diff line change
@@ -1,23 +1,47 @@
schema_version = 1
contract = "provider-sync-adapters-v1"
layout = "modules"
modules_root = "modules"
environments_root = "environments"

[layout]
modules_dir = "modules"
environments_dir = "environments"

[providers.supabase]
working_directory = "."
working_directory = "modules"
native_directory = "supabase"
native_config = "supabase/config.toml"
module_directory = "modules/supabase"
canonical_path = "modules/supabase"
native_working_directory = "modules"
native_expected_directory = "supabase"
native_sync = "working-directory"

[providers.cloudflare]
root_directory_strategy = "directory-containing-wrangler-config"
native_config_names = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]
root_candidates = ["cloudflare", "workers", "."]
root_candidates = ["modules/cloudflare", "cloudflare", "workers", "."]
module_directory = "modules/cloudflare"
canonical_path = "modules/cloudflare"
native_root_directory = "modules/cloudflare"
native_sync = "root-directory"
config_globs = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"]

[providers.neon]
working_directory = "."
working_directory = "modules/neon"
native_config = "neon.ts"
module_directory = "modules/neon"
canonical_path = "modules/neon"
project_root = "modules/neon"
config = "modules/neon/neon.ts"
native_sync = "project-root"
root_adapter = "optional"
root_adapter_path = "neon.ts"
root_adapter_target = "modules/neon/neon.ts"

[policy]
provider_native_paths_must_match = true
path_filtered_ci = true
state_isolation = "per-provider-per-environment"
secrets = "sops-age"
32 changes: 32 additions & 0 deletions environments/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Terraform environment roots

`preview/`, `staging/`, and `production/` are independent Terraform roots for **zed-pkg**. They share modules but never Terraform state.

Provider-native source remains canonical under `modules/`:

- Cloudflare Worker/Page source: `modules/cloudflare/<worker-or-site>`
- Neon native IaC: `modules/neon/neon.ts`
- Supabase native project: `modules/supabase/`

The Terraform roots compose account/environment resources; they are not a second copy of provider-native configuration.

This repo also contains `modules/cloudflare/durable-coordinator`. After the Cloudflare account/project root is wired, enable the shell explicitly with `-var=enable_cloudflare_worker=true`. Terraform owns the stable Worker shell; Wrangler owns versions, bindings, and the Durable Object `exports` lifecycle.

## R2 remote state

Supply a unique state key at init time:

```bash
cd environments/preview
terraform init \
-backend-config=../backend.r2.hcl.example \
-backend-config="key=zed-pkg/preview/terraform.tfstate"
terraform validate
terraform plan -var="cloudflare_account_id=$CLOUDFLARE_ACCOUNT_ID"
```

Use `zed-pkg/staging/terraform.tfstate` and `zed-pkg/production/terraform.tfstate` for the other roots.

R2 backend credentials come from `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`; Cloudflare provider authentication comes from `CLOUDFLARE_API_TOKEN`. Do not commit credentials.

CI runs `terraform init -backend=false` and `terraform validate`, so pull requests verify the provider/module graph without touching live state.
16 changes: 16 additions & 0 deletions environments/backend.r2.hcl.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Shared R2 backend settings. Supply a unique `key` at init time per root.
# Keep credentials out of git; Terraform's S3 backend reads
# AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
bucket = "CHANGE_ME_TERRAFORM_STATE_BUCKET"
region = "auto"

endpoints = {
s3 = "https://CHANGE_ME_ACCOUNT_ID.r2.cloudflarestorage.com"
}

use_path_style = true
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_s3_checksum = true
65 changes: 65 additions & 0 deletions environments/preview/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
terraform {
required_version = ">= 1.16.0, < 2.0.0"

required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = ">= 5.24.0, < 6.0.0"
}
}

backend "s3" {}
}

provider "cloudflare" {}

variable "cloudflare_account_id" {
description = "Cloudflare account ID. Authentication is read from CLOUDFLARE_API_TOKEN."
type = string
sensitive = true
default = ""

validation {
condition = var.cloudflare_account_id == "" || can(regex("^[0-9a-fA-F]{32}$", var.cloudflare_account_id))
error_message = "cloudflare_account_id must be empty or a 32-character hexadecimal Cloudflare account ID."
}
}

variable "enable_cloudflare_worker" {
description = "Create/manage the environment Cloudflare Worker shell. Keep false until the provider account/root is wired."
type = bool
default = false
}

variable "observability_head_sampling_rate" {
description = "Workers observability head sampling rate."
type = number
default = 1

validation {
condition = var.observability_head_sampling_rate >= 0 && var.observability_head_sampling_rate <= 1
error_message = "observability_head_sampling_rate must be between 0 and 1."
}
}

locals {
environment = "preview"
project = "zed-pkg"
worker_name = "${local.project}-coordinator-${local.environment}"
}

module "cloudflare_worker_shell" {
source = "../../modules/cloudflare/terraform/worker-shell"

enabled = var.enable_cloudflare_worker
account_id = var.cloudflare_account_id
worker_name = local.worker_name
workers_dev = false
preview_urls = true
observability_head_sampling_rate = var.observability_head_sampling_rate
tags = [
"managed-by:terraform",
"project:${local.project}",
"environment:${local.environment}",
]
}
65 changes: 65 additions & 0 deletions environments/production/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
terraform {
required_version = ">= 1.16.0, < 2.0.0"

required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = ">= 5.24.0, < 6.0.0"
}
}

backend "s3" {}
}

provider "cloudflare" {}

variable "cloudflare_account_id" {
description = "Cloudflare account ID. Authentication is read from CLOUDFLARE_API_TOKEN."
type = string
sensitive = true
default = ""

validation {
condition = var.cloudflare_account_id == "" || can(regex("^[0-9a-fA-F]{32}$", var.cloudflare_account_id))
error_message = "cloudflare_account_id must be empty or a 32-character hexadecimal Cloudflare account ID."
}
}

variable "enable_cloudflare_worker" {
description = "Create/manage the environment Cloudflare Worker shell. Keep false until the provider account/root is wired."
type = bool
default = false
}

variable "observability_head_sampling_rate" {
description = "Workers observability head sampling rate."
type = number
default = 1

validation {
condition = var.observability_head_sampling_rate >= 0 && var.observability_head_sampling_rate <= 1
error_message = "observability_head_sampling_rate must be between 0 and 1."
}
}

locals {
environment = "production"
project = "zed-pkg"
worker_name = "${local.project}-coordinator-${local.environment}"
}

module "cloudflare_worker_shell" {
source = "../../modules/cloudflare/terraform/worker-shell"

enabled = var.enable_cloudflare_worker
account_id = var.cloudflare_account_id
worker_name = local.worker_name
workers_dev = false
preview_urls = false
observability_head_sampling_rate = var.observability_head_sampling_rate
tags = [
"managed-by:terraform",
"project:${local.project}",
"environment:${local.environment}",
]
}
65 changes: 65 additions & 0 deletions environments/staging/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
terraform {
required_version = ">= 1.16.0, < 2.0.0"

required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = ">= 5.24.0, < 6.0.0"
}
}

backend "s3" {}
}

provider "cloudflare" {}

variable "cloudflare_account_id" {
description = "Cloudflare account ID. Authentication is read from CLOUDFLARE_API_TOKEN."
type = string
sensitive = true
default = ""

validation {
condition = var.cloudflare_account_id == "" || can(regex("^[0-9a-fA-F]{32}$", var.cloudflare_account_id))
error_message = "cloudflare_account_id must be empty or a 32-character hexadecimal Cloudflare account ID."
}
}

variable "enable_cloudflare_worker" {
description = "Create/manage the environment Cloudflare Worker shell. Keep false until the provider account/root is wired."
type = bool
default = false
}

variable "observability_head_sampling_rate" {
description = "Workers observability head sampling rate."
type = number
default = 1

validation {
condition = var.observability_head_sampling_rate >= 0 && var.observability_head_sampling_rate <= 1
error_message = "observability_head_sampling_rate must be between 0 and 1."
}
}

locals {
environment = "staging"
project = "zed-pkg"
worker_name = "${local.project}-coordinator-${local.environment}"
}

module "cloudflare_worker_shell" {
source = "../../modules/cloudflare/terraform/worker-shell"

enabled = var.enable_cloudflare_worker
account_id = var.cloudflare_account_id
worker_name = local.worker_name
workers_dev = false
preview_urls = false
observability_head_sampling_rate = var.observability_head_sampling_rate
tags = [
"managed-by:terraform",
"project:${local.project}",
"environment:${local.environment}",
]
}
32 changes: 32 additions & 0 deletions modules/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Infrastructure modules

This repository uses `modules/` as the canonical provider-module root and `environments/` for composition/state boundaries.

```text
modules/
cloudflare/
<worker-or-site>/
wrangler.toml | wrangler.json | wrangler.jsonc
neon/
neon.ts
supabase/
config.toml
migrations/
functions/
environments/
preview/
staging/
production/
```

## Provider-native Git sync

- **Supabase:** set the GitHub integration **Working directory** to `modules`. Supabase expects a `supabase/` child of that working directory, so it resolves `modules/supabase/` automatically.
- **Cloudflare Workers/Pages:** set the project **Root directory** to `modules/cloudflare` for a single project, or preferably `modules/cloudflare/<worker-or-site>` for each independently deployed Worker/Page. Use build watch paths to avoid unrelated monorepo changes triggering every project.
- **Neon:** treat `modules/neon` as the Neon project root. Run/link Neon from that directory (`working-directory: modules/neon` in CI) so `neon.ts` is discovered there. If a repo-level tool insists on starting at the Git root, add a thin root `neon.ts` adapter that re-exports the canonical `modules/neon/neon.ts`; do not duplicate configuration.

`.ores-infra.toml` records these canonical paths for `oresc`/automation, but it does not replace provider-native dashboard/Git settings.

## State and dependency rules

Keep state isolated per provider/environment rather than sharing one monolithic state. Environment roots may compose provider modules, but provider modules must remain independently plannable/testable. Apply database/control-plane infrastructure before schema migrations and edge/application routing that depends on it.
Loading
Loading