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

# Fork-safe lint and validate gates. No secrets, no state access.
# Real plans / applies run in HCP Terraform via its own VCS integration
# (see docs/ci.md) — they are intentionally not triggered from this workflow.

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

env:
TERRAFORM_VERSION: "1.9.8"

jobs:
fmt:
name: terraform fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TERRAFORM_VERSION }}
terraform_wrapper: false
- run: terraform fmt -check -recursive terraform/

validate:
name: terraform validate (${{ matrix.dir }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
dir:
- terraform/cloudflare
- terraform/github
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TERRAFORM_VERSION }}
terraform_wrapper: false
- name: terraform init (no backend)
working-directory: ${{ matrix.dir }}
run: terraform init -backend=false -input=false
- name: terraform validate
working-directory: ${{ matrix.dir }}
run: terraform validate
44 changes: 44 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Canonical entrypoint for lint/test/plan targets, modeled on pypi/infra.
#
# Each terraform/* leaf directory is a separate HCP Terraform workspace.
# Targets fan out across every leaf that has a versions.tf file.

TF_DIRS := $(shell find terraform -type f -name versions.tf -exec dirname {} \; | sort)

.PHONY: help fmt fmt-check validate init plan tf-dirs

help:
@echo "Targets:"
@echo " fmt - terraform fmt -recursive across terraform/"
@echo " fmt-check - terraform fmt -check -recursive (CI gate)"
@echo " init - terraform init in every workspace dir"
@echo " validate - terraform validate in every workspace dir"
@echo " plan - terraform plan in every workspace dir (requires HCP auth)"
@echo " tf-dirs - list discovered workspace dirs"

tf-dirs:
@printf '%s\n' $(TF_DIRS)

fmt:
terraform fmt -recursive terraform/

fmt-check:
terraform fmt -check -recursive terraform/

init:
@for d in $(TF_DIRS); do \
echo "==> init $$d"; \
(cd $$d && terraform init -input=false) || exit $$?; \
done

validate: init
@for d in $(TF_DIRS); do \
echo "==> validate $$d"; \
(cd $$d && terraform validate) || exit $$?; \
done

plan: init
@for d in $(TF_DIRS); do \
echo "==> plan $$d"; \
(cd $$d && terraform plan -input=false) || exit $$?; \
done
83 changes: 83 additions & 0 deletions docs/import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Importing existing Cloudflare resources

Use Cloudflare's own [`cf-terraforming`](https://github.com/cloudflare/cf-terraforming) CLI to bring resources that already exist in the `perish.dev` zone (and the parent Cloudflare account) under Terraform management without recreating them.

Why not a custom script: cf-terraforming is maintained by Cloudflare alongside the Terraform provider, so import ID formats and HCL schemas track provider changes automatically. A hand-rolled script would drift.

## Install

```sh
brew tap cloudflare/cloudflare
brew install cloudflare/cloudflare/cf-terraforming
# or:
go install github.com/cloudflare/cf-terraforming/cmd/cf-terraforming@latest
```

## What it supports

Coverage is **broad but not total** for the v5 provider. As of cf-terraforming v0.27 (May 2026):

| Resource | Supported by `generate` |
|---|---|
| `cloudflare_dns_record` | yes |
| `cloudflare_r2_bucket` | yes |
| `cloudflare_pages_project` | yes |
| `cloudflare_workers_kv_namespace` | yes |
| `cloudflare_workers_custom_domain` | yes |
| `cloudflare_workers_cron_trigger` | yes (needs `--resource-id`) |
| `cloudflare_workers_script` | **no** — bundle content can't be round-tripped; define in the app repo |
| `cloudflare_workers_route` | **no** — define manually alongside the script |
| Zone settings (`cloudflare_zone_setting`) | yes (needs `--resource-id` listing each setting) |

For the script/route gap: Worker code typically lives in the app repo it serves, not in this infra repo. Importing the resource here would tie its state to an empty `content` field. Define the route + script in the app's own Terraform once that app exists.

## Generate HCL for the zone

```sh
export CLOUDFLARE_API_TOKEN=... # token with read scopes for each resource type
export CLOUDFLARE_ZONE_ID=78ff9bdc9f1a38c01a935d3d079b1e7b
export CLOUDFLARE_ACCOUNT_ID=d8a72309e747515805b614574ea7f323

cd terraform/cloudflare

terraform init -backend=false # cf-terraforming requires an initialised dir

cf-terraforming generate \
--resource-type "cloudflare_dns_record,cloudflare_r2_bucket,cloudflare_pages_project,cloudflare_workers_kv_namespace" \
--zone "$CLOUDFLARE_ZONE_ID" \
--account "$CLOUDFLARE_ACCOUNT_ID" \
> generated.tf
```

`generated.tf` is the file the existing comment in `main.tf` references. Review it: rename resource labels to something readable (cf-terraforming uses `terraform_managed_resource` placeholders), drop anything you don't actually want managed, and commit.

## Emit `import` blocks

```sh
cf-terraforming import \
--modern-import-block \
--resource-type "cloudflare_dns_record,cloudflare_r2_bucket,cloudflare_pages_project,cloudflare_workers_kv_namespace" \
--zone "$CLOUDFLARE_ZONE_ID" \
--account "$CLOUDFLARE_ACCOUNT_ID" \
>> generated.tf
```

`--modern-import-block` emits Terraform 1.5+ `import { to = ... id = "..." }` blocks rather than the legacy CLI commands. Append to the same file so resources and their imports stay co-located.

## Verify and apply

```sh
terraform fmt generated.tf
terraform validate
terraform plan # expect: every existing resource shown as "will import", nothing as "will create"
```

If `plan` shows any `create` for a resource that already exists, the resource name or import ID in `generated.tf` is wrong — fix before applying.

Once the plan is clean, commit `generated.tf` and let HCP Terraform run the apply (manual confirmation per `docs/ci.md`).

## When to re-run

Re-run `cf-terraforming generate` whenever new resources appear in Cloudflare that you want Terraform to manage. The cleanest workflow is to write new resources directly in Terraform from the start; cf-terraforming is for one-time onboarding of legacy state, not steady-state operations.

> Note: cf-terraforming is **not** intended for use in CI. It runs locally during onboarding, output is reviewed by a human, then committed.
83 changes: 83 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Setup

One-time, out-of-band steps to wire up the systems this repo manages. The repo's Terraform code is speculative until these are done.

Run in order. Each step explains what it produces and where the value goes.

## 1. HCP Terraform — organization

1. Sign up at <https://app.terraform.io/> (free tier is sufficient to start).
2. Create an organization named **`perishdev`** (must match the `organization` field in every `terraform/*/versions.tf`).
3. Inside the org, create a project named **`infra`**.

## 2. HCP Terraform — workspaces

For each Terraform leaf directory, create a VCS-linked workspace under the `infra` project:

| Leaf | Workspace name | VCS working directory |
|---|---|---|
| `terraform/cloudflare/` | `cloudflare` | `terraform/cloudflare` |
| `terraform/github/` | `github-org` | `terraform/github` |

For each workspace:

- VCS provider: GitHub (connect via OAuth, scope to this repo only).
- **Working directory**: set as above. Settings → Version Control → "Terraform Working Directory".
- **Automatic Run Triggering**: set to **"Only trigger runs when files in specified paths change"**, path pattern `<working-dir>/**` (e.g. `terraform/cloudflare/**`). Without this, every push to `main` triggers every workspace — a docs-only commit will spuriously plan against Cloudflare and may fail on an unrelated change.
- **Automatic speculative plans**: **enabled**. This is the master toggle for plans on PRs; without it, PRs get no speculative plan and the GitHub status check never appears. Settings → Version Control → "Automatic speculative plans".
- **Speculative plans on PRs from forks**: **disabled**. This is a separate, fork-specific toggle. Without disabling it, anyone opening a fork PR can read the workspace's sensitive variables via a malicious `.tf` file. The label-gated plan flow in [`ci.md`](./ci.md) replaces it for fork PRs.
- Execution mode: **Remote**.
- Auto-apply: **disabled** (manual confirmation required).

## 3. Cloudflare API token

1. Cloudflare dashboard → My Profile → API Tokens → Create Token.
2. Use the **"Custom token"** template with these permissions for the `perish.dev` zone and the account it belongs to:
- Zone — DNS — Edit
- Zone — Zone Settings — Edit
- Zone — Workers Routes — Edit
- Account — Workers Scripts — Edit
- Account — Workers R2 Storage — Edit
- Account — Cloudflare Pages — Edit
3. Account Resources: **Include — your account only**. Zone Resources: **Include — Specific zone — `perish.dev`**.
4. Copy the token (shown once).
5. In HCP Terraform → workspace **`cloudflare`** → Variables → add `cloudflare_api_token` as a **Terraform variable**, mark **Sensitive**, paste the token.

## 4. GitHub App

The `github-org` workspace authenticates as a GitHub App, not a PAT.

1. Org settings → Developer settings → GitHub Apps → New GitHub App.
2. Permissions (start narrow, widen on demand):
- Repository permissions: Administration (read/write), Contents (read), Metadata (read), Pull requests (read/write).
- Organization permissions: Members (read), Administration (read/write).
3. Where can this app be installed: **Only on this account**.
4. Create. Note the **App ID**.
5. Generate a **private key** (downloads as a `.pem`). Treat it like a password.
6. Install the app on the `perishdev` org, scoped to "All repositories" (or to specific ones; can be widened later). Note the **Installation ID** from the install URL.
7. In HCP Terraform → workspace **`github-org`** → Variables → add three sensitive Terraform variables:
- `github_owner` = `perishdev` (not sensitive)
- `github_app_id` = the App ID (sensitive)
- `github_app_installation_id` = the Installation ID (sensitive)
- `github_app_pem` = paste the PEM contents (sensitive)

## 5. HCP Terraform API token for CI

Only needed if/when CI workflows trigger HCP runs (the current [`ci.yml`](../.github/workflows/ci.yml) doesn't — it only runs fmt/validate locally on the runner). Skip until needed.

When needed:

1. HCP Terraform → User settings → Tokens → Create API token. Scope: minimal.
2. GitHub → repo settings → Secrets → New repository secret → `TF_API_TOKEN`.

## 6. Local development

For running `make validate` / `make plan` from your laptop:

1. Install Terraform ≥ 1.9.
2. `terraform login` — opens a browser, asks HCP for an API token tied to your user. Token lands in `~/.terraform.d/credentials.tfrc.json`.
3. From any leaf dir: `terraform init` (will authenticate to HCP automatically).

## 7. Importing existing Cloudflare resources

See [`import.md`](./import.md) for the `cf-terraforming` runbook. It uses Cloudflare's own CLI to generate HCL + `import` blocks for the existing zone, R2 buckets, and Pages projects.
37 changes: 37 additions & 0 deletions terraform/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# terraform/

Terraform code, organized as **one leaf directory per HCP Terraform workspace**. Each leaf is a self-contained root module with its own `versions.tf` (declaring the `cloud {}` block), `providers.tf`, `variables.tf`, and `main.tf`.

## Layout

```
terraform/
cloudflare/ -> HCP workspace: cloudflare
github/ -> HCP workspace: github-org
```

There is no per-environment split today:

- **Cloudflare**: we manage a single apex domain in a single Cloudflare account. Staging surfaces (e.g. `staging.<domain>`, `app-staging` Worker) live as additional resources inside the same zone, in the same workspace. Split into `cloudflare/{production,staging}/` only when we add a second apex domain dedicated to staging or move staging into a separate Cloudflare account.
- **GitHub**: the org is the org. No staging variant exists.

## Why one workspace per leaf

- A `terraform apply` for one concern can never touch another concern's state, even by accident — different workspaces, different state, different (sensitive) credentials.
- HCP's VCS integration triggers exactly one run per workspace per push, so blast radius is bounded.
- Mirrors the convention in [`pypi/infra`](https://github.com/pypi/infra) and aligns with the environment separation rule in the top-level `CLAUDE.md`.

## Adding a new workspace

1. Create `terraform/<concern>/` (or `terraform/<concern>/<env>/` if a real isolation boundary justifies the split) with `versions.tf`, `providers.tf`, `variables.tf`, `main.tf`.
2. Set the `cloud { workspaces { name = "<workspace-name>" } }` block in `versions.tf`.
3. Create the matching workspace in HCP Terraform under the `perishdev` org, VCS-linked to this repo, working directory set to the leaf dir.
4. Populate sensitive variables (provider tokens, app credentials) as workspace variables in HCP, marked `sensitive`.
5. Open a PR; the `safe-to-plan` label gates fork-PR plans (see [`../docs/ci.md`](../docs/ci.md)).

## What's not here yet

- `terraform/modules/` — reusable building blocks. Add when the second resource needs the same shape in two places. Don't pre-build.
- Real resources. The current files are scaffolding; they declare providers and variables but create nothing.

See also: [`../docs/secrets.md`](../docs/secrets.md), [`../docs/state.md`](../docs/state.md), [`../docs/ci.md`](../docs/ci.md).
19 changes: 19 additions & 0 deletions terraform/cloudflare/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions terraform/cloudflare/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Cloudflare resources.
#
# Identifiers (account_id, zone_id, apex domain) are non-credentials and live
# here as locals for readability — see docs/secrets.md "Things that look like
# secrets but aren't." The sensitive API token lives in HCP Terraform as a
# workspace variable and is consumed via var.cloudflare_api_token.

locals {
domain = "perish.dev"
account_id = "d8a72309e747515805b614574ea7f323"
zone_id = "78ff9bdc9f1a38c01a935d3d079b1e7b"
}

# The zone already exists; import it so Terraform manages it without
# attempting to create a new one on the first apply.
import {
to = cloudflare_zone.this
id = local.zone_id
}

resource "cloudflare_zone" "this" {
account = { id = local.account_id }
name = local.domain
type = "full"
}

# Records, R2 buckets, Pages projects, and other resources are brought
# under Terraform management via Cloudflare's `cf-terraforming` CLI —
# see docs/import.md for the runbook. Generated HCL lands in
# terraform/cloudflare/generated.tf for review and commit.
3 changes: 3 additions & 0 deletions terraform/cloudflare/providers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
5 changes: 5 additions & 0 deletions terraform/cloudflare/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
variable "cloudflare_api_token" {
description = "Cloudflare API token. Supplied as a sensitive workspace variable in HCP Terraform; never set in this file."
type = string
sensitive = true
}
18 changes: 18 additions & 0 deletions terraform/cloudflare/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
terraform {
required_version = ">= 1.9.0"

required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.0"
}
}

cloud {
organization = "perishdev"

workspaces {
name = "cloudflare"
}
}
}
5 changes: 5 additions & 0 deletions terraform/github/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# GitHub org resources.
#
# Empty on purpose. Add github_repository, github_team, github_branch_protection,
# github_actions_organization_secret, etc. here. There is no staging variant —
# the org is the org.
9 changes: 9 additions & 0 deletions terraform/github/providers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
provider "github" {
owner = var.github_owner

app_auth {
id = var.github_app_id
installation_id = var.github_app_installation_id
pem_file = var.github_app_pem
}
}
Loading
Loading