Skip to content
Closed
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,17 @@ terraform/
.github/
workflows/ fork-safe terraform fmt + validate gates
docs/
wizard.md agent-first setup wizard (hand to an AI agent; humans only mint/paste creds)
providers/ per-provider agent-first deep dives (cloudflare, github, gcp*, migration)
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
setup.md out-of-band bootstrap runbook (canonical detail behind the wizard)
import.md cf-terraforming runbook for adopting existing Cloudflare state
```

To bootstrap this repo (or a fork of it for another org), hand [`docs/wizard.md`](./docs/wizard.md) to an AI agent: it runs the setup end-to-end and pauses only when a human must sign up, mint a credential, or paste a secret.

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
Expand Down
5 changes: 4 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ The rest is reference, dipped into as needed.

### Setup and onboarding

- [`setup.md`](./setup.md) — one-time bootstrap of HCP, Cloudflare token, GitHub App, local dev. Done once per maintainer's laptop.
- [`wizard.md`](./wizard.md) — **agent-first** setup wizard. Hand it to an AI agent to run the whole bootstrap end-to-end, pausing only for the steps a human must do (signups, minting credentials, pasting secrets). Orchestrates the docs below; includes a "porting to another org" section.
- [`setup.steps.yaml`](./setup.steps.yaml) — machine-readable manifest the wizard walks: each step tagged `AGENT`/`HUMAN` with a `check` command for resume.
- [`setup.md`](./setup.md) — one-time bootstrap of HCP, Cloudflare token, GitHub App, local dev. The human-readable canonical detail the wizard links into. Done once per maintainer's laptop.
- [`providers/`](./providers/) — per-provider agent-first deep dives: [`cloudflare.md`](./providers/cloudflare.md), [`github.md`](./providers/github.md), [`gcp.md`](./providers/gcp.md) *(template — not active)*, [`migration.md`](./providers/migration.md).
- [`import.md`](./import.md) — `cf-terraforming` runbook for adopting existing Cloudflare state into Terraform.
- [`worktree-workflow.md`](./worktree-workflow.md) — optional `git worktree` convention for maintainers juggling multiple branches.

Expand Down
93 changes: 93 additions & 0 deletions docs/providers/cloudflare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Provider: Cloudflare (agent-first)

Deep dive for [Phase 2](../wizard.md#phase-2--cloudflare) of the setup wizard. What the
agent does, what the human must do, and how to prove it. Canonical bootstrap detail:
[`setup.md#3`](../setup.md#3-cloudflare-api-token). Token scopes + rotation:
[`secrets.md`](../secrets.md#cloudflare-api-token-scopes).

## What this repo manages via Cloudflare

The `perish.dev` zone and all its DNS records (Email Routing + GitHub Pages), through the
`cloudflare/cloudflare` v5 provider. State + token live in the HCP `cloudflare` workspace.

## The actor split

| Action | Actor | Why |
|---|---|---|
| Mint the scoped API token | **HUMAN** | No API to bootstrap the *first* token; it's a dashboard-only action. |
| Paste token into HCP (sensitive var) | **HUMAN** | The agent must never see the plaintext. |
| Verify var presence + shape | **AGENT** | HCP API, no secret exposure. |
| Prove the token works (`plan`) | **AGENT** | Speculative plan runs in HCP. |
| Read account/zone IDs (for porting) | **AGENT** | Cloudflare API, once a token exists. |

## HUMAN — mint + paste (`cf-token`)

The agent emits a handoff block; the human does exactly this:

1. <https://dash.cloudflare.com/profile/api-tokens> → **Create Token** → **Custom token**.
2. Permissions for the `perish.dev` zone and its account:
- Zone — DNS — **Edit**
- Zone — Zone Settings — **Edit**
- *(add a row per new resource type before Terraform manages it — Email Routing Rules,
Rulesets, etc. Edit the token later, don't regenerate — see
[`setup.md`](../setup.md#adding-scopes-to-the-cloudflare-token-later).)*
3. **Account Resources**: Include — your account. **Zone Resources**: Include —
Specific zone — `perish.dev`.
4. Copy the token (shown once).
5. HCP → workspace **`cloudflare`** → Variables → add `cloudflare_api_token` as a
**Terraform** variable, mark **Sensitive**, paste.

> The token needs **Edit** because the persistent workspace *manages* resources. For
> one-time *discovery* during migration, a separate **Read** token is used and thrown
> away — see [`migration.md`](./migration.md) and [`import.md`](../import.md#discovery-token).

## AGENT — verify

The value is redacted, so the agent verifies presence + `sensitive == true`, then proves
the token with a plan:

```sh
HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json)
WS_ID=$(curl -sf "https://app.terraform.io/api/v2/organizations/perishdev/workspaces/cloudflare" \
-H "Authorization: Bearer $HCP_TOKEN" | jq -r '.data.id')

curl -sf "https://app.terraform.io/api/v2/workspaces/$WS_ID/vars" \
-H "Authorization: Bearer $HCP_TOKEN" \
| jq -e '.data[] | select(.attributes.key=="cloudflare_api_token") | .attributes.sensitive==true' \
&& echo "✓ token present + sensitive"

cd terraform/cloudflare && terraform init && terraform plan # green = token works
```

## AGENT — reading account + zone IDs (porting)

These are **not secrets** (they're `local`s in `terraform/cloudflare/main.tf`:
`account_id = d8a72309…`, `zone_id = 78ff9bdc…`), so reading them doesn't need the
persistent Edit token — and the agent should **not** touch that token (the
never-see-the-plaintext rule holds). Two clean ways to get the IDs when porting:

- **Human reads them off the dashboard** — account ID is in the URL / right-hand
sidebar; zone ID is on the domain's Overview page. Paste them back to the agent.
- **Agent uses the short-lived *read-only* discovery token** (the same throwaway token
minted for migration — see [`migration.md`](./migration.md)), never the Edit token:

```sh
export CF_READ_TOKEN=... # throwaway READ token, deleted right after — NOT the HCP Edit token

# Account ID
curl -sf "https://api.cloudflare.com/client/v4/accounts" \
-H "Authorization: Bearer $CF_READ_TOKEN" | jq -r '.result[0].id'

# Zone ID for the new domain
curl -sf "https://api.cloudflare.com/client/v4/zones?name=<new-domain>" \
-H "Authorization: Bearer $CF_READ_TOKEN" | jq -r '.result[0].id'
```

Write the results into `terraform/cloudflare/main.tf` `locals`. See the Porting table in
[`wizard.md`](../wizard.md#porting-to-another-organization).

## Rotation

Zero-downtime, agent-assisted: human mints the new token and pastes it; agent runs a no-op
`plan` to prove it; only then does the human revoke the old one. Steps in
[`secrets.md`](../secrets.md#cloudflare-api-token).
89 changes: 89 additions & 0 deletions docs/providers/gcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Provider: GCP (TEMPLATE — not active)

> **Status: TEMPLATE. No GCP resources are managed by this repo today.**
>
> GCP is **not** a managed provider. There is no `terraform/gcp/` leaf, no `gcp` HCP
> workspace, and no GCP entry in the [locked-decisions table](../../CLAUDE.md). Adopting
> GCP is a **design decision that must be made first** — per `CLAUDE.md`, update the
> decisions table and [`terraform/README.md`](../../terraform/README.md) **before** any
> code or provisioning. This file is the forward-looking template for *when* that day
> comes, written in the same agent-first shape as the live providers.

## Prerequisite: make the decision (HUMAN + docs)

`gcp-decision` in [`setup.steps.yaml`](../setup.steps.yaml) stays **red** until GCP is
intentionally adopted (`test -d terraform/gcp`). Before writing anything:

1. Add a row to `CLAUDE.md`'s locked-decisions table (what GCP is for, auth method, state).
2. Note the new leaf in `terraform/README.md`.
3. Then, and only then, follow the phases below.

## Recommended auth: Workload Identity Federation (keyless)

Prefer **WIF** over a downloaded service-account JSON key. WIF lets HCP present a
short-lived OIDC token that GCP exchanges for temporary credentials — **no long-lived key
to store, paste, or rotate.** A service-account key is the fallback only if WIF can't be
arranged; it would live as a sensitive HCP var exactly like the Cloudflare token, with all
the rotation burden that implies.

## The actor split (projected)

| Action | Actor | Why |
|---|---|---|
| Create GCP project, link billing | **HUMAN** | Billing consent is browser + payment; irreducibly human. |
| Enable APIs, create SA / WIF pool | **AGENT** | `gcloud` / GCP API, once auth exists. |
| Approve the WIF trust / OAuth consent | **HUMAN** | One browser consent for the federation trust. |
| Paste SA key into HCP *(only if not using WIF)* | **HUMAN** | Agent must never see the key. |
| Create the `gcp` HCP workspace | **AGENT** | HCP API (same as Phase 1). |
| First `plan` | **AGENT** | Speculative run in HCP. |

## Phases (projected)

### HUMAN — project + billing
1. Create a GCP project (`gcloud projects create <id>` is possible, but billing linkage
and the initial org/consent are browser steps). Note the **project ID**.
2. Link a billing account (browser).

### AGENT — enable APIs + set up auth
```sh
gcloud config set project <PROJECT_ID>
gcloud services enable cloudresourcemanager.googleapis.com iam.googleapis.com <needed-apis>

# WIF (preferred): create a workload identity pool + provider trusting HCP's OIDC issuer,
# and a service account with least-privilege roles that HCP may impersonate.
gcloud iam workload-identity-pools create hcp-pool --location=global ...
```
Exact WIF config depends on how HCP is configured to emit OIDC; capture it in this file
when implemented. Provider block goes in `terraform/gcp/providers.tf`, using
`google`/`google-beta`, with impersonation rather than a key file.

### AGENT — HCP workspace
Create a `gcp` workspace (working dir `terraform/gcp`, path filter `terraform/gcp/**`,
remote execution, auto-apply **off**) exactly like Phase 1. If using a SA key instead of
WIF, that's where the sensitive var lives.

### AGENT — first plan
```sh
cd terraform/gcp && terraform init && terraform plan
```

## Migrating existing GCP resources

Same pattern as every other provider: **import, don't recreate**. GCP resources are
adopted with Terraform 1.5+ `import` blocks and either handwritten HCL or
`terraform plan -generate-config-out`. There is no first-party equivalent to
`cf-terraforming`; `gcloud ... list` + import blocks is the path. See
[`migration.md`](./migration.md#gcp).

## Leaf skeleton (for when it lands)

```
terraform/gcp/
versions.tf # required_providers { google }, cloud { organization=perishdev, workspaces{name="gcp"} }
providers.tf # google provider, WIF impersonation (no key file)
main.tf # project-level locals (project_id, region — non-secret, like cloudflare/main.tf)
*.tf # one file per resource concern
```

Nothing here ships until the decision is recorded. YAGNI — the repo deliberately carries
no provider it doesn't use.
95 changes: 95 additions & 0 deletions docs/providers/github.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Provider: GitHub (agent-first)

Deep dive for [Phase 3](../wizard.md#phase-3--github) of the setup wizard. Canonical
bootstrap detail: [`setup.md#4`](../setup.md#4-github-app). Auth rationale + rotation:
[`secrets.md`](../secrets.md#github-app-setup).

## What this repo manages via GitHub

`perishdev/infra` and `perishdev/perishdev.github.io` repo settings, `main` branch
protection on both, and the `safe-to-plan` label — through the `integrations/github` v6
provider. State + App credentials live in the HCP `github-org` workspace.

## Why a GitHub App (not a PAT)

Apps aren't tied to a user, support fine-grained permissions, and rotate cleanly (multiple
active keys → overlap-then-cutover). A PAT dies with its owner and is coarse-grained. This
is a [locked decision](../../CLAUDE.md).

## The actor split

| Action | Actor | Why |
|---|---|---|
| Create the App + generate private key | **HUMAN** | Browser flow; the `.pem` downloads once. |
| Install the App on the org, scoped to repos | **HUMAN** | Browser install + consent. |
| Paste App ID / installation ID / PEM into HCP | **HUMAN** | Agent must never see the PEM. |
| Verify the four vars exist | **AGENT** | HCP API. |
| Prove the App works (`plan`) | **AGENT** | Speculative plan in HCP. |
| Repo/Pages introspection (`gh api`) | **AGENT** | Read-only, agent-owned. |

## HUMAN — create, install, paste (`gh-app`)

1. Create the App: `https://github.com/organizations/perishdev/settings/apps/new`
(replace `perishdev` if the org slug differs). Permissions — start narrow:
- **Repository**: Administration (R/W), Contents (R), Metadata (R), Pull requests (R/W).
- **Organization**: Members (R), Administration (R/W).
- "Where can this app be installed": **Only on this account**.
2. Create → note the **App ID**. Generate a **private key** (`.pem` downloads — treat like
a password; include the full `-----BEGIN/END RSA PRIVATE KEY-----` lines when pasting).
3. **Install** on `perishdev`, scoped to `perishdev/infra` +
`perishdev/perishdev.github.io`. Note the **Installation ID** from the install URL
(`.../installations/<INSTALLATION_ID>`).
4. HCP → workspace **`github-org`** → Variables → four **Terraform** vars:
- `github_owner` = `perishdev` *(not sensitive)*
- `github_app_id` *(sensitive)*
- `github_app_installation_id` *(sensitive)*
- `github_app_pem` = full PEM contents *(sensitive)*

> **Shortcut worth offering the human — the GitHub App manifest flow.** Instead of
> clicking every permission, the agent can generate an App *manifest* (a JSON blob of
> the permissions above) and hand the human a tiny local HTML page that POSTs it to
> `https://github.com/organizations/perishdev/settings/apps/new`. GitHub then shows a
> single "Create GitHub App" confirmation with permissions pre-filled — trimming ~a
> dozen clicks to one. Caveat that keeps us on the manual path by default: the flow
> redirects back with a temporary `code` that must be exchanged
> (`POST /app-manifests/{code}/conversions`) within **one hour** to retrieve the App ID
> and PEM — and that exchange hands the **PEM to whoever runs it**. Letting the agent do
> the exchange would break the never-see-the-plaintext rule, so if you use the manifest
> flow, the *human* performs the code exchange. Details:
> <https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest>.
> Installation + Installation-ID capture is a browser step either way.

## AGENT — verify

```sh
HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json)
WS_ID=$(curl -sf "https://app.terraform.io/api/v2/organizations/perishdev/workspaces/github-org" \
-H "Authorization: Bearer $HCP_TOKEN" | jq -r '.data.id')

curl -sf "https://app.terraform.io/api/v2/workspaces/$WS_ID/vars" \
-H "Authorization: Bearer $HCP_TOKEN" | jq -e '
[.data[].attributes.key] as $k
| ("github_owner"|IN($k[])) and ("github_app_id"|IN($k[]))
and ("github_app_installation_id"|IN($k[])) and ("github_app_pem"|IN($k[]))' \
&& echo "✓ all four github vars present"

cd terraform/github && terraform init && terraform plan # green = App auth works
```

## Watch-outs the agent should flag

- **PEM newline mangling** on paste is the most common failure — it works for the first
request then breaks on rotation. If `plan` fails with an auth error, re-paste the PEM
carefully (full BEGIN/END lines).
- **The branch-protection status-check name embeds a per-installation VCS ID**
(`Terraform Cloud/perishdev/repo-id-CffUfWW6H1x6Bauq`). If the GitHub↔HCP OAuth
connection is ever rebuilt, that string changes and **every PR is silently blocked**
until `terraform/github/branch_protection.tf` is updated. See [`ci.md`](../ci.md).
- **Pages cert can wedge** (`https_certificate: null` for >15 min). Fix by removing +
re-adding the custom domain via `gh api` — see [`setup.md`](../setup.md#github-pages-cert-stuck-at-null).

## Rotation

GitHub Apps support multiple active keys, so rotation is overlap-then-cutover: human
generates a new key + pastes it, agent proves it with a no-op `plan`, then human deletes
the old key. Steps in [`secrets.md`](../secrets.md#github-app-private-key).
Loading
Loading