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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,46 @@
# infra

There is dust in the clouds, infrastructure as code for perishdev.

## What this repo manages

- **Cloudflare** — the `perish.dev` zone, all DNS records (Email Routing + GitHub Pages), via the [`cloudflare/cloudflare`](https://registry.terraform.io/providers/cloudflare/cloudflare/latest) v5 Terraform provider.
- **GitHub** — `perishdev/infra` and `perishdev/perishdev.github.io` repo settings, branch protection on `main`, the `safe-to-plan` label, via the [`integrations/github`](https://registry.terraform.io/providers/integrations/github/latest) v6 Terraform provider.
- **HCP Terraform** — remote state, runs, and the workspace variables that hold the API credentials. The repo's own `terraform/cloud {}` block lives in each leaf's `versions.tf`.

No hosts of our own (no Salt, no Ansible). Everything in scope is SaaS-shaped.

## Where things live

```
terraform/
cloudflare/ one HCP workspace (cloudflare), zone + DNS
github/ one HCP workspace (github-org), repos + protection + labels
.github/
workflows/ fork-safe terraform fmt + validate gates
docs/
secrets.md secrets store, GitHub App, rotation
state.md HCP backend, workspace layout
ci.md workflow contract, fork-PR policy
setup.md out-of-band bootstrap runbook
import.md cf-terraforming runbook for adopting existing Cloudflare state
```

For the contracts the repo is built on — secrets, state, CI — see [`CLAUDE.md`](./CLAUDE.md). For the live design decisions table, look there first.

## Contributing

Branch protection requires four green checks before any merge to `main`:

- `terraform fmt`
- `terraform validate (terraform/cloudflare)`
- `terraform validate (terraform/github)`
- `Terraform Cloud/perishdev/...` (the HCP aggregated commit status)

Fork PRs only get GitHub Actions; HCP plans require a maintainer to apply the `safe-to-plan` label first. See [`docs/ci.md`](./docs/ci.md) for the full policy.

Conventional Commits, Conventional Branches, Conventional PR titles.

## License

[MIT](./LICENSE)
55 changes: 32 additions & 23 deletions docs/ci.md
Original file line number Diff line number Diff line change
@@ -1,46 +1,55 @@
# CI

This repo is **public**. Anyone can fork it and open a PR. The CI workflow must assume a hostile PR body and protect every secret accordingly.
This repo is **public**. Anyone can fork it and open a PR. The CI workflow assumes a hostile PR body and protects every secret accordingly.

## Trust boundary

| Surface | Visibility | Holds secrets? |
|---|---|---|
| Repo source, Issues, PRs, Actions logs | Public | no |
| HCP Terraform workspace | Private (HCP) | yes — all Terraform-time secrets |
| GitHub Actions encrypted secrets | Private (org/repo settings) | yes — only `TF_API_TOKEN` |
| `terraform plan` output | Public (posted to PR by HCP VCS integration) | redacted by HCP |
| HCP Terraform workspaces | Private (HCP) | yes — all Terraform-time secrets |
| GitHub Actions encrypted secrets | Private (repo settings) | none today (placeholder for `TF_API_TOKEN` if needed later) |
| Speculative `terraform plan` output | Linked from PR; lives in HCP UI | redacted by HCP |

## Workflows
## What runs on a PR

### `plan` — runs on PRs
| Job | Where it runs | Triggered for fork PRs? | Secrets in scope |
|---|---|---|---|
| `terraform fmt` | GitHub Actions | yes | none |
| `terraform validate (terraform/cloudflare)` | GitHub Actions | yes | none (`init -backend=false`) |
| `terraform validate (terraform/github)` | GitHub Actions | yes | none (`init -backend=false`) |
| HCP speculative plan per workspace | HCP Terraform via VCS integration | **only when maintainer labels `safe-to-plan`** | yes — full workspace variables |

- **Trigger**: `pull_request` against `main`.
- **PRs from branches in this repo** (collaborators): plan runs automatically.
- **PRs from forks**: plan runs only when a maintainer applies the `safe-to-plan` label. Until labeled, CI runs lint/validate only (no secrets, no HCP API token).
- **What it does**: calls the HCP Terraform API to start a speculative plan in the relevant workspace. HCP posts the plan summary back to the PR.
- **What it does NOT do**: never runs `terraform apply`, never echoes secret values, never reads `${{ secrets.* }}` into shell variables.
The first three are defined in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml). The HCP plan is triggered by HCP's own VCS integration when it detects a push to a watched branch — not by a GitHub Actions job. There is no `TF_API_TOKEN` in use today.

### `apply` — runs on main
## What runs on merge to `main`

- **Trigger**: `push` to `main` (i.e. merged PR).
- HCP creates a run for each VCS-watched workspace.
- **Manual confirmation required** in HCP UI by a workspace admin. No auto-apply for the prod workspaces.
- Apply logs are visible in HCP, not in GitHub Actions.
- HCP creates a real run for each workspace whose path filter matches the merged commit (`terraform/cloudflare/**` for the `cloudflare` workspace, `terraform/github/**` for `github-org`).
- The run plans then **stops at "needs confirmation"** — applies require a human click in HCP UI (or an authenticated `POST /runs/<id>/actions/apply`).
- Apply logs live in HCP, not GitHub Actions.

### `lint` — runs on every PR including forks
A docs-only push to `main` triggers no workspace runs. HCP still posts an aggregated commit status (success), so branch protection treats it as a passing rollup.

- `terraform fmt -check`, `terraform validate`, `tflint` if adopted.
- No secrets, no network calls beyond provider schema downloads.
- Safe to run unconditionally on fork PRs.
## Branch protection on `main`

Enforced via [`terraform/github/branch_protection.tf`](../terraform/github/branch_protection.tf). All four required status checks must be green before merge:

- `terraform fmt`
- `terraform validate (terraform/cloudflare)`
- `terraform validate (terraform/github)`
- `Terraform Cloud/perishdev/repo-id-CffUfWW6H1x6Bauq` — HCP's aggregated commit status

Plus: linear history, no force-push, no branch deletion, conversation resolution required. Admins can bypass for emergencies (`enforce_admins = false`).

> ⚠️ The HCP check name embeds a per-installation VCS-repo ID (`repo-id-CffUfWW6H1x6Bauq`). If the GitHub–HCP OAuth/App connection is ever rebuilt, that string changes and branch protection silently blocks every PR until [`terraform/github/branch_protection.tf`](../terraform/github/branch_protection.tf) is updated to match.

## Rules

1. **Never use `pull_request_target`** unless the workflow is reviewed line-by-line for fork-PR safety. The default trigger is `pull_request`, which gives fork PRs no access to secrets.
2. **Never `echo` secrets**, never pass them as command-line args (visible in `ps`). Use env vars and let the tool read them.
3. **Sensitive Terraform outputs**: mark `sensitive = true` on any output that could leak a value. HCP redacts these from plan output.
4. **Fork-PR plans are opt-in.** A maintainer reviews the diff, decides whether it's safe to run against the real Cloudflare/GitHub account, then applies the label.
5. **Apply requires a human.** No automated apply to a production workspace, ever.
3. **Sensitive Terraform outputs**: mark `sensitive = true`. HCP redacts these from plan output.
4. **Fork-PR plans are opt-in.** A maintainer reviews the diff, decides whether it's safe to run against the real Cloudflare/GitHub account, then applies the `safe-to-plan` label.
5. **Apply requires a human.** No automated apply, ever. (The exception during this repo's bootstrap was deliberate API-driven applies after the speculative plan had been read; see commit history.)

## What to watch for in PR diffs from forks

Expand Down
11 changes: 8 additions & 3 deletions docs/import.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,20 +81,25 @@ cf-terraforming import \

`--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
## Verify

```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.
`terraform plan` from the CLI is allowed against a VCS-connected HCP workspace (it runs as a speculative plan in HCP); `terraform apply` from the CLI is intentionally blocked. The plan output streams back to your terminal with a link to the HCP run.

Once the plan is clean, commit `generated.tf` and let HCP Terraform run the apply (manual confirmation per `docs/ci.md`).
If `plan` shows any `create` for a resource that already exists, the resource name or import ID in `generated.tf` is wrong — fix before opening a PR. The real apply happens when the PR merges and a maintainer confirms in HCP (or scripts the confirm via `POST /api/v2/runs/<id>/actions/apply`).

## 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.

## What we've actually used this for

- **`cloudflare_dns_record`** — the six email-routing DNS records on `perish.dev` were imported via this flow. The four GitHub Pages apex records and the `www` CNAME were written directly into [`terraform/cloudflare/dns.tf`](../terraform/cloudflare/dns.tf) (no prior existence to import).
- **`cloudflare_pages_project`**, **`cloudflare_r2_bucket`** — discovered during the first run but explicitly **excluded** from import. The Pages projects were unrelated personal repos; the R2 bucket was empty. Scope decision: this repo manages `perish.dev` infra, not the whole account.
37 changes: 24 additions & 13 deletions docs/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,47 @@ This repo is **public**. Plaintext secrets must never be committed — not in co

| Secret | Store | Consumed by |
|---|---|---|
| Cloudflare API token | HCP Terraform workspace variable (sensitive) | `terraform` runs |
| GitHub App ID, installation ID, private key | HCP Terraform workspace variable (sensitive) | `terraform` runs |
| HCP Terraform API token | GitHub Actions encrypted secret (`TF_API_TOKEN`) | CI workflow, to trigger HCP runs |
| Cloudflare API token | HCP Terraform workspace variable on `cloudflare`, marked sensitive | `terraform` runs in HCP |
| GitHub App ID, installation ID, private key (PEM) | HCP Terraform workspace variables on `github-org`, marked sensitive | `terraform` runs in HCP |
| HCP Terraform user API token | `~/.terraform.d/credentials.tfrc.json` (set by `terraform login`), per maintainer | local Terraform CLI; scripted HCP API calls |

**Nothing encrypted is committed to the repo.** No SOPS, no `git-crypt`. If we ever run our own hosts and need runtime secrets, we pick an out-of-band store then; until that day, all secrets in scope live in HCP workspace variables.

## Why HCP Terraform as the vault

- Workspace variables marked `sensitive` are encrypted at rest and redacted from run logs.
- A leaked CI token can trigger a plan but can't read the underlying secret values — they're injected into the run environment, not exposed to the workflow.
- Runs execute on HCP infrastructure; sensitive values are injected into the run environment, never echoed back to the laptop or to GitHub Actions logs.
- One place to rotate Terraform-time credentials.
- HCP's own state encryption covers everything Terraform writes during a run.

## Cloudflare API token scopes

The token loaded into the `cloudflare` workspace needs **Edit** on every kind of resource we manage. For the current set:

- Zone — DNS — Edit
- Zone — Zone Settings — Edit

If/when we add more resource types, add scope before declaring the resource. For one-shot discovery via [`cf-terraforming`](./import.md), a separate token with **Read** scopes works fine — the user generates it locally, uses it to populate `generated.tf`, then deletes it. The persistent HCP token only needs the scopes that match the resources Terraform actively manages.

## GitHub App setup

The GitHub provider authenticates as a GitHub App, not a PAT. Apps are not tied to a user, support fine-grained permissions, and rotate cleanly.

1. Create the app under the `perishdev` org (Settings → Developer settings → GitHub Apps → New).
2. Permissions: only what Terraform needs to manage (start narrow — repos, teams, secrets — expand on demand).
3. Install the app on the org, scoped to the repos Terraform will touch.
4. Generate a private key, store the App ID, installation ID, and PEM in the HCP Terraform workspace as three sensitive variables.
5. Configure the [`integrations/github`](https://registry.terraform.io/providers/integrations/github/latest/docs#github-app-installation) provider with those three values.
2. Permissions: Repository — Administration (read/write), Contents (read), Metadata (read), Pull requests (read/write). Organization — Members (read), Administration (read/write).
3. Install the app on the org, scoped to the repos Terraform manages (currently `perishdev/infra` and `perishdev/perishdev.github.io`).
4. Generate a private key, store the **App ID**, **installation ID**, and **PEM** in the `github-org` HCP workspace as three sensitive variables (`github_app_id`, `github_app_installation_id`, `github_app_pem`).
5. The PEM must include the full `-----BEGIN/END RSA PRIVATE KEY-----` lines. Pasting just the base64 body works for the first request, then breaks on key rotation.

## Rotation

- **Cloudflare token**: rotate yearly or on any suspected exposure. Generate the replacement before deleting the old one; update the HCP workspace variable; next run uses the new token.
- **GitHub App private key**: rotate yearly or on any suspected exposure. GitHub Apps support multiple active keys — generate a new one, swap the workspace variable, delete the old key.
- **HCP Terraform API token**: rotate when any maintainer with access leaves; update the GH Actions secret.
- **Cloudflare token**: rotate yearly or on suspected exposure. Generate the replacement before deleting the old one; update the HCP workspace variable; next run uses the new token.
- **GitHub App private key**: rotate yearly or on suspected exposure. GitHub Apps support multiple active keys — generate the new one, swap the HCP variable, delete the old key.
- **HCP user API token**: rotated when a maintainer leaves. Each maintainer regenerates their own via `terraform login` after revocation.

## Things that look like secrets but aren't

- Cloudflare account ID, zone IDs — not secret, but reconnaissance signal. Fine to commit.
- GitHub org name, repo names — public anyway.
- Cloudflare account ID, zone ID — not secret, but reconnaissance signal. Fine to commit. Both live as `local`s in `terraform/cloudflare/main.tf`.
- GitHub org / repo names, GitHub App ID, installation ID — public anyway. App ID and installation ID are *marked* sensitive in the HCP variables for defense-in-depth, but they're not credentials.
- Resource IDs in Terraform state — state itself lives in HCP, never in the repo.
- The HCP organization name, workspace names — non-secret identifiers.
Loading
Loading