diff --git a/.claude/commands/infra-setup.md b/.claude/commands/infra-setup.md new file mode 100644 index 0000000..aaf38b9 --- /dev/null +++ b/.claude/commands/infra-setup.md @@ -0,0 +1,26 @@ +--- +description: "Shortcut that loads the infra-setup skill — agent-first, human-in-the-loop bootstrap of this Terraform + HCP infra repo." +allowed-tools: Read, Bash, Edit, Write, Glob, Grep, AskUserQuestion +--- + +# /infra-setup + +Explicit entry point for the [`infra-setup`](../skills/infra-setup/SKILL.md) skill. This +command and the skill are **the same procedure, two surfaces** — the command is the +tab-completable trigger; the skill is what auto-loads and holds the logic. Either way you +end up executing `SKILL.md`, so there's no divergent behaviour to reconcile. + +Load `../skills/infra-setup/SKILL.md` and drive it end-to-end: + +1. **Resume first.** Walk `../skills/infra-setup/references/steps.yaml` and run each step's + `check` to find where setup already is. Report `✓` for green steps; resume at the first + red one. Never assume state from a previous session. +2. **Respect the actor split.** Execute `AGENT` steps yourself. On a `HUMAN` step, stop and + emit the handoff block, wait for `done`, then re-run the `check` before continuing — + never fake a signup, a dashboard click, or a secret paste. +3. **Route to the deep-dives** under `../skills/infra-setup/references/` and to the canonical + `docs/` sections for fine print. Don't duplicate them. +4. **Stop at the done signal** in SKILL.md and hand back to `CONTRIBUTING.md`. + +If `$ARGUMENTS` names a phase or provider (e.g. `cloudflare`, `phase 3`), jump straight to +that phase after the resume scan. diff --git a/.claude/skills/infra-setup/SKILL.md b/.claude/skills/infra-setup/SKILL.md new file mode 100644 index 0000000..fcb641a --- /dev/null +++ b/.claude/skills/infra-setup/SKILL.md @@ -0,0 +1,192 @@ +--- +name: infra-setup +description: "Agent-first, human-in-the-loop bootstrap for this Terraform + HCP infra repo. Use when setting up perishdev/infra (or a fork of it for another org) from scratch — wiring HCP Terraform state, the Cloudflare token, and the GitHub App, importing existing resources, and running the first plan. The agent drives end-to-end and pauses only for signups, credential minting, and secret pasting. Trigger on: 'set up infra', 'bootstrap this repo', 'run infra-setup', 'onboard a new org', '/infra-setup'." +--- + +# infra-setup + +Agent-runnable bootstrap for this repo. You (the agent) execute it end-to-end, pausing +only for the steps a human irreducibly must do. Keep this file as the **router**: it +carries the protocol (actor model, handoff, resume) and routes to `references/` for the +per-provider detail and to the repo's canonical `docs/` for the fine print. + +> **This is a Skill, not a script.** There is no `infra-setup` executable. Drive it by +> reading this file and the manifest, then running each step. A human invokes it with +> `/infra-setup` or by saying "set up the infra." + +> **Orchestrates, does not duplicate.** Authoritative per-step detail lives in +> [`docs/setup.md`](../../../docs/setup.md) (bootstrap) and [`docs/import.md`](../../../docs/import.md) +> (Cloudflare migration). This skill adds the *actor split* and *resume protocol* on top. +> When a step says "see `setup.md#3`", read that section. + +## The one idea + +**The human is the browser and the keyholder. Everything scriptable is the agent's.** + +There is exactly one thing a human must do that an agent cannot: sit at a browser, sign +up for a SaaS, and mint the first credential. Once an HCP token exists (from +`terraform login`), the agent creates workspaces, sets variables, reads plans, and +confirms applies **over the HCP API** — no more clicking. So the human surface shrinks to +three action kinds: + +1. **Sign up** for a service (browser-only). +2. **Mint a credential** in a dashboard (browser-only — no API bootstraps the first token). +3. **Paste a secret** into HCP (browser-only — the agent must never see the plaintext). + +Everything else — verifying, creating workspaces, importing resources, first plan — is yours. + +## Actors + +Every step in [`references/steps.yaml`](references/steps.yaml) is tagged with who performs it: + +| Tag | Meaning | Your behaviour | +|---|---|---| +| **`AGENT`** | You run it (shell, `gh`, `terraform`, HCP API). | Execute. Verify with the step's `check`. Continue on green. | +| **`HUMAN`** | Irreducibly human (signup, dashboard, secret paste). | **Stop.** Emit the handoff block. Wait for the human to reply `done`. Then run the `check` before continuing. | + +### The handoff block + +When a step is `HUMAN`, do **not** guess or fake it. Stop and print exactly this shape, then wait: + +``` +┌─ HUMAN ACTION NEEDED ───────────────────────────── +│ Step: — +│ Why: <one line — what this unblocks> +│ Do this: +│ 1. <precise, copy-pasteable instruction, with URL> +│ 2. … +│ When done, reply "done" and I'll verify. +└─────────────────────────────────────────────────── +``` + +After the human replies, run the step's `check`. If it fails, re-emit the handoff with +what you observed — never silently proceed past a red check. + +## Resume protocol + +This skill is **idempotent and resumable**. On every run, before doing anything, walk +[`references/steps.yaml`](references/steps.yaml) top to bottom and run each step's `check` +to discover *where setup already is*. Resume at the first step whose check is red. An +all-green repo means "already set up, nothing to do." + +```text +for step in steps.yaml: + if run(step.check) is green: skip, print "✓ {step.id}" + else: resume here +``` + +Never assume state from memory or a prior session — always re-check. +See [`references/steps.yaml`](references/steps.yaml) for the runtime contract (which shell +vars to export before running any check). + +## Preflight (AGENT) + +Confirm your toolbox. All of these are yours to install if missing — none need a human. + +```sh +terraform version # ≥ 1.9 — provisioning + import blocks +gh --version # GitHub CLI — repo ops, Pages, App install checks +jq --version # JSON wrangling for HCP/Cloudflare/GitHub APIs +curl --version # HCP + Cloudflare REST +``` + +These are the human-readable checks; the manifest's `preflight` block in +[`references/steps.yaml`](references/steps.yaml) enforces the version floor (Terraform ≥ 1.9) +programmatically — run those to gate, not just `terraform version`. + +Then detect the credential the whole flow pivots on: + +```sh +jq -re '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json \ + && echo "HCP token present — agent can drive the API" \ + || echo "No HCP token yet — first HUMAN step will mint one" +``` + +## Phases + +Run in order. Each routes to a `references/` deep-dive and the canonical `docs/` section. + +| # | Phase | Actors | Deep dive | +|---|---|---|---| +| 0 | **HCP bootstrap** — sign up, `terraform login`, get the pivot token | `HUMAN` then `AGENT` | [`references/hcp.md`](references/hcp.md), [`docs/setup.md#1`](../../../docs/setup.md#1-hcp-terraform--organization) | +| 1 | **HCP workspaces** — create `cloudflare` + `github-org`, set VCS + safety toggles | `AGENT` (API) + `HUMAN` VCS OAuth | [`references/hcp.md`](references/hcp.md), [`docs/setup.md#2`](../../../docs/setup.md#2-hcp-terraform--workspaces) | +| 2 | **Cloudflare** — mint scoped token, paste into HCP, verify | `HUMAN` mint/paste, `AGENT` verify | [`references/cloudflare.md`](references/cloudflare.md) | +| 3 | **GitHub** — create + install the GitHub App, paste creds into HCP | `HUMAN` create/install/paste, `AGENT` verify | [`references/github.md`](references/github.md) | +| 4 | **First plan** — `init` + speculative `plan` per leaf, read via API | `AGENT` | [`docs/setup.md#6`](../../../docs/setup.md#6-local-development) | +| 5 | **Migration** — adopt existing resources with import blocks (no re-create) | `AGENT` run, `HUMAN` discovery token | [`references/migration.md`](references/migration.md) | +| 6 | **GCP** *(optional, template only)* — not provisioned today | design decision first | [`references/gcp.md`](references/gcp.md) | + +### Phase 0 — HCP bootstrap +The only unavoidable cold-start; produces the token that lets you script the rest. +`HUMAN` signs up + runs `terraform login`; then you own the HCP API. Commands and verify: +[`references/hcp.md`](references/hcp.md#phase-0--bootstrap). + +### Phase 1 — HCP workspaces +Two workspaces (`cloudflare`, `github-org`), one per leaf. A human does the one-time +GitHub↔HCP OAuth (browser); you create both workspaces via the API with the right working +dir, path-scoped triggers, remote execution, and auto-apply **off**. Ready-to-run +`create_ws` helper + the safety-toggle rationale: [`references/hcp.md`](references/hcp.md#phase-1--workspaces). + +### Phase 2 — Cloudflare +You can't mint a scoped Cloudflare token from nothing, and must never see the plaintext — +so minting/pasting are `HUMAN`, verifying is `AGENT`. Full walkthrough: +[`references/cloudflare.md`](references/cloudflare.md). + +### Phase 3 — GitHub +The `github-org` workspace authenticates as a **GitHub App**, not a PAT. Creation + +install are browser flows; three creds get pasted into HCP. Walkthrough incl. the +manifest-flow shortcut: [`references/github.md`](references/github.md). + +### Phase 4 — First plan (AGENT) +Prove every credential end-to-end. Per leaf: `terraform init` then `terraform plan` +(speculative, runs in HCP). A VCS-connected workspace **allows `plan` but blocks `apply`** +from the CLI — intentional. Read plans without the UI via +[`docs/hcp-api.md`](../../../docs/hcp-api.md). Green on both leaves = credentials proven. + +### Phase 5 — Migration (adopt existing resources) +If the domain/repos already exist (they do for `perish.dev`), **import** them so Terraform +manages them without recreating. Canonical runbook: [`docs/import.md`](../../../docs/import.md); +cross-provider pattern: [`references/migration.md`](references/migration.md). Success signal: +`terraform plan` shows every existing resource as **"will import"**, nothing as **"will create"**. + +### Phase 6 — GCP (template, optional) +**Not provisioned today.** GCP is not a managed provider here. Adopting it is a +**locked-design-decision change** — update [`CLAUDE.md`](../../../CLAUDE.md) and +[`terraform/README.md`](../../../terraform/README.md) first, then follow the same actor +split. Template: [`references/gcp.md`](references/gcp.md). + +## Done signal + +Setup is complete when you can report: + +- ✓ HCP org `perishdev` + project `infra` reachable via API. +- ✓ Workspaces `cloudflare` and `github-org` exist, VCS-linked, auto-apply off, fork speculative plans off. +- ✓ All sensitive vars present (`cloudflare_api_token`; `github_app_id`, `github_app_installation_id`, `github_app_pem`). +- ✓ `terraform plan` green on both leaves. +- ✓ Existing resources imported (plan shows imports, no creates) — if migrating. + +Then day-to-day work follows [`CONTRIBUTING.md`](../../../CONTRIBUTING.md). + +## Porting to another organization + +Written concretely for **`perishdev` / `perish.dev`**. To reuse as groundwork for another +org, change these — and nothing else: + +| Token | Meaning | Where it appears | +|---|---|---| +| `perishdev` | HCP org **and** GitHub org slug | `terraform/*/versions.tf` (`organization`), every HCP API URL, App install URL, workspace `-org` suffix | +| `perish.dev` | apex domain | `terraform/cloudflare/main.tf` (`local.domain`), Cloudflare token/zone scoping | +| `d8a72309…` | Cloudflare **account ID** | `terraform/cloudflare/main.tf` (`local.account_id`) | +| `78ff9bdc…` | Cloudflare **zone ID** | `terraform/cloudflare/main.tf` (`local.zone_id`), migration commands | +| `perishdev/infra`, `perishdev/perishdev.github.io` | managed repos | `terraform/github/repos.tf`, App install scope | +| `repo-id-CffUfWW6H1x6Bauq` | per-installation HCP status-check ID | `terraform/github/branch_protection.tf` — **regenerated by HCP**, not chosen; see [`docs/ci.md`](../../../docs/ci.md) | + +Porting checklist: + +1. `grep -rn 'perishdev\|perish\.dev\|d8a72309\|78ff9bdc' terraform/ docs/ .claude/` to enumerate every literal. +2. Replace org/domain/repo literals with the new org's values. +3. Re-derive the two Cloudflare IDs from the new account (read via the Cloudflare API with a + read-only token — see [`references/cloudflare.md`](references/cloudflare.md)). +4. Leave `repo-id-…` alone — HCP emits it when the new VCS connection is made; copy it into + `branch_protection.tf` *after* Phase 1. Never invent it. +5. Re-run this skill from Phase 0. The resume protocol handles the rest. diff --git a/.claude/skills/infra-setup/references/cloudflare.md b/.claude/skills/infra-setup/references/cloudflare.md new file mode 100644 index 0000000..c756e4c --- /dev/null +++ b/.claude/skills/infra-setup/references/cloudflare.md @@ -0,0 +1,93 @@ +# Provider: Cloudflare (agent-first) + +Deep dive for [Phase 2](../SKILL.md#phase-2--cloudflare) of the infra-setup skill. What the +agent does, what the human must do, and how to prove it. Canonical bootstrap detail: +[`setup.md#3`](../../../../docs/setup.md#3-cloudflare-api-token). Token scopes + rotation: +[`secrets.md`](../../../../docs/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`](../../../../docs/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`](../../../../docs/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 +[`SKILL.md`](../SKILL.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`](../../../../docs/secrets.md#cloudflare-api-token). diff --git a/.claude/skills/infra-setup/references/gcp.md b/.claude/skills/infra-setup/references/gcp.md new file mode 100644 index 0000000..30d7ba0 --- /dev/null +++ b/.claude/skills/infra-setup/references/gcp.md @@ -0,0 +1,94 @@ +# 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 [`steps.yaml`](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 + +> ⚠ **Illustrative, not runnable as-is.** The `...` below is a real gap — the WIF pool, +> provider, attribute mapping, and trust condition must be completed against HCP's actual +> OIDC issuer/audience at adoption time. Treat this as the shape, not the procedure; fill +> it in (and replace this warning) when GCP is actually adopted. + +```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 ... +``` +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. diff --git a/.claude/skills/infra-setup/references/github.md b/.claude/skills/infra-setup/references/github.md new file mode 100644 index 0000000..4e3d3b9 --- /dev/null +++ b/.claude/skills/infra-setup/references/github.md @@ -0,0 +1,95 @@ +# Provider: GitHub (agent-first) + +Deep dive for [Phase 3](../SKILL.md#phase-3--github) of the infra-setup skill. Canonical +bootstrap detail: [`setup.md#4`](../../../../docs/setup.md#4-github-app). Auth rationale + rotation: +[`secrets.md`](../../../../docs/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`](../../../../docs/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`](../../../../docs/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`](../../../../docs/secrets.md#github-app-private-key). diff --git a/.claude/skills/infra-setup/references/hcp.md b/.claude/skills/infra-setup/references/hcp.md new file mode 100644 index 0000000..f267c85 --- /dev/null +++ b/.claude/skills/infra-setup/references/hcp.md @@ -0,0 +1,110 @@ +# HCP bootstrap + workspaces (agent-first) + +Deep dive for Phases 0–1 of the [`infra-setup`](../SKILL.md) skill. Canonical detail: +[`docs/setup.md#1`](../../../../docs/setup.md#1-hcp-terraform--organization), +[`docs/setup.md#2`](../../../../docs/setup.md#2-hcp-terraform--workspaces), +[`docs/state.md`](../../../../docs/state.md). + +## Phase 0 — bootstrap + +The only unavoidable cold-start. Produces the HCP token that lets the agent script everything after. + +- **`HUMAN` — hcp-signup.** Sign up at <https://app.terraform.io/>, create org **`perishdev`**, + create project **`infra`**. (Org name must equal the `organization` field in every + `terraform/*/versions.tf` — it does: `perishdev`.) +- **`HUMAN` — hcp-login.** Run `terraform login` locally (opens a browser, asks HCP for a + user API token). `HUMAN` because it needs an interactive browser — but it's the *last* + time a human touches the terminal for auth. Token lands in + `~/.terraform.d/credentials.tfrc.json`. +- **`AGENT` — hcp-verify.** From here you own the HCP API: + ```sh + HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) + curl -sf "https://app.terraform.io/api/v2/organizations/perishdev" \ + -H "Authorization: Bearer $HCP_TOKEN" | jq -e '.data.id' \ + && echo "✓ HCP org reachable" + ``` + +## Phase 1 — workspaces + +Two workspaces, one per leaf. You create them via API; a human does the one-time +GitHub↔HCP OAuth connection (browser). + +| Workspace | Leaf | VCS working dir | Path filter | Auto-apply | +|---|---|---|---|---| +| `cloudflare` | `terraform/cloudflare/` | `terraform/cloudflare` | `terraform/cloudflare/**` | **no** | +| `github-org` | `terraform/github/` | `terraform/github` | `terraform/github/**` | **no** | + +- **`HUMAN` — vcs-connect.** In HCP → org Settings → VCS Providers, connect GitHub via + OAuth, scoped to this repo only. (Browser-only OAuth handshake.) +- **`AGENT` — workspaces-create.** Create both workspaces with the correct working + directory, path-based run triggering, remote execution, and auto-apply **off**. The + safety toggles that matter — and *why each bites if wrong* — are in + [`docs/setup.md#2`](../../../../docs/setup.md#2-hcp-terraform--workspaces): speculative + plans **on**, path-scoped triggers, and — set in the UI — **fork** speculative plans + **off**. Ready-to-run and **genuinely idempotent** — re-running reports an existing + workspace as `✓ … already exists` (HCP answers a duplicate name with `422`); any other + HTTP status surfaces as an error instead of a silent `jq` crash: + + ```sh + export HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) + ORG=perishdev + REPO=perishdev/infra # the repo HCP watches via VCS + + # oauth-token-id from the VCS connection created in the vcs-connect step + OAUTH_TOKEN_ID=$(curl -sf "https://app.terraform.io/api/v2/organizations/$ORG/oauth-clients" \ + -H "Authorization: Bearer $HCP_TOKEN" \ + | jq -r '.data[0].relationships["oauth-tokens"].data[0].id // empty') + + # jq -n builds the payload (correct quoting for free); curl -w captures the HTTP status + # so we can tell "created" (201) from "already exists" (422 name-taken) from a real error. + create_ws () { # $1 = workspace name $2 = working directory + local resp code body + resp=$(jq -n --arg name "$1" --arg dir "$2" --arg repo "$REPO" --arg tok "$OAUTH_TOKEN_ID" ' + {data:{type:"workspaces",attributes:{ + name:$name, "working-directory":$dir, "execution-mode":"remote", + "auto-apply":false, "speculative-enabled":true, "file-triggers-enabled":true, + "trigger-patterns":[$dir+"/**"], "queue-all-runs":false, "global-remote-state":false, + "vcs-repo":{identifier:$repo, "oauth-token-id":$tok, branch:"main"}}}}' \ + | curl -s -w '\n%{http_code}' -X POST "https://app.terraform.io/api/v2/organizations/$ORG/workspaces" \ + -H "Authorization: Bearer $HCP_TOKEN" \ + -H "Content-Type: application/vnd.api+json" -d @-) + code=${resp##*$'\n'}; body=${resp%$'\n'*} # split trailing "\n<status>" (var 'code' — zsh reserves 'status') + case "$code" in + 201) echo "✓ $1 created" ;; + 422) echo "$body" | grep -qi 'already been taken' \ + && echo "✓ $1 already exists" \ + || { echo "✗ $1: HTTP 422 — $(echo "$body" | jq -r '.errors[0].detail // .errors[0].title')" >&2; return 1; } ;; + *) echo "✗ $1: HTTP $code — $(echo "$body" | jq -r '.errors[0].detail // .errors[0].title // .')" >&2; return 1 ;; + esac + } + + # Gate with if/else, NOT `return`/`exit`: this block is run as a script by the agent, + # where a top-level `return` errors AND falls through (creating a broken workspace), + # and `exit` would kill an interactive shell if pasted. if/else is correct in every context. + if [ -z "$OAUTH_TOKEN_ID" ]; then + echo "No VCS oauth-token found — finish the vcs-connect step first; not creating workspaces." >&2 + else + create_ws cloudflare terraform/cloudflare + create_ws github-org terraform/github + fi + ``` + + > `trigger-patterns` (glob) requires `file-triggers-enabled: true` — that pair is the + > path-scoping toggle. `speculative-enabled: true` is the master switch for plans on PRs. + > The **fork** speculative-plan toggle is *separate* and has no clean create-time + > attribute — confirm it's **off** in the workspace's UI → Settings → Version Control + > (it defaults off; the label-gated flow in [`docs/ci.md`](../../../../docs/ci.md) replaces it for forks). + + Verify: + ```sh + for ws in cloudflare github-org; do + curl -sf "https://app.terraform.io/api/v2/organizations/perishdev/workspaces/$ws" \ + -H "Authorization: Bearer $HCP_TOKEN" \ + | jq -e '.data.attributes["auto-apply"] == false' >/dev/null \ + && echo "✓ $ws exists, auto-apply off" + done + ``` + +> The end state of Phases 0–1 (HCP-as-clickops today) is tracked for future +> Terraform-ification in [Issue #8](https://github.com/perishdev/infra/issues/8). Until +> then these steps are API calls, not `.tf` files. diff --git a/.claude/skills/infra-setup/references/migration.md b/.claude/skills/infra-setup/references/migration.md new file mode 100644 index 0000000..89db414 --- /dev/null +++ b/.claude/skills/infra-setup/references/migration.md @@ -0,0 +1,87 @@ +# Migration: adopting existing resources (agent-first) + +Deep dive for [Phase 5](../SKILL.md#phase-5--migration-adopt-existing-resources) of the +infra-setup skill. How to bring resources that **already exist** under Terraform management +without recreating them — across providers. The Cloudflare specifics are canonical in +[`import.md`](../../../../docs/import.md); this file is the cross-provider pattern and the actor split. + +## The universal pattern + +Every migration, whatever the provider, is the same five moves: + +1. **Discover** what exists (read-only credential). +2. **Generate HCL** for each resource. +3. **Emit `import` blocks** (Terraform 1.5+ `import { to = … id = "…" }`). +4. **Plan** — the success signal is *"will be imported"*, and crucially **nothing + *"will be created"*.** +5. **Commit + apply** on merge (human/API-confirmed, per [`ci.md`](../../../../docs/ci.md)). + +> The single check that catches a botched import: `terraform plan` must show the resource +> as **imported**, never **created**. A `create` for something that already exists means +> the resource address or import ID is wrong — fix before opening the PR. + +## The actor split + +| Action | Actor | Why | +|---|---|---| +| Mint a short-lived **read-only** discovery token | **HUMAN** | Dashboard-only; scoped narrower than the HCP edit token. | +| Run the discovery tool / API | **AGENT** | `cf-terraforming`, `gcloud`, `gh` — read-only. | +| Generate HCL + import blocks | **AGENT** | Deterministic transformation. | +| Review/rename generated HCL, drop unwanted resources | **AGENT** (human confirms scope) | Scope decisions may need a human nod. | +| `terraform plan` to confirm imports-only | **AGENT** | Speculative run in HCP. | +| Delete the discovery token when done | **HUMAN** | Revoke in dashboard. | + +## Cloudflare — canonical + +Use Cloudflare's own [`cf-terraforming`](https://github.com/cloudflare/cf-terraforming), +maintained alongside the provider so import IDs and schemas track provider changes. Full +runbook — install, discovery token, `generate`, `import --modern-import-block`, the +supported-resource matrix, and the script/route gaps — is in +[`import.md`](../../../../docs/import.md). Don't duplicate it; the agent should read and follow it. + +What this repo actually imported: the six Email-Routing DNS records on `perish.dev`. Pages +projects and R2 buckets were discovered but **deliberately excluded** (out of scope). That +scope call is the human-confirmed part of "review generated HCL". + +> `cf-terraforming` is for **one-time onboarding**, not steady state, and **not for CI**. +> New resources should be written in Terraform directly from the start. + +## GitHub + +The `integrations/github` provider has no `cf-terraforming` equivalent; adopt existing +repos/settings with `import` blocks + handwritten (or `-generate-config-out`) HCL. + +```sh +# AGENT discovers via gh (read-only) — e.g. repos to adopt: +gh repo list perishdev --json name,visibility,defaultBranchRef + +# Then, per resource, an import block in terraform/github/*.tf: +# import { to = github_repository.infra id = "infra" } +# import { to = github_branch_protection.infra id = "infra:main" } # provider-specific id format +cd terraform/github && terraform plan # expect: imported, not created +``` + +Import ID formats are provider-specific (a repo is its name; branch protection is +`repo:pattern`; a membership is `org:username`). Check the resource's registry docs for +the exact `id` string before writing the block. + +## GCP + +**Only after GCP is an adopted provider** — see [`gcp.md`](./gcp.md). No first-party +generator; discover with `gcloud ... list`, then Terraform 1.5+ `import` blocks with HCL +either handwritten or via `terraform plan -generate-config-out=generated.tf`: + +```sh +gcloud projects list +gcloud storage buckets list # etc., per resource type +# import { to = google_storage_bucket.assets id = "projects/<p>/buckets/<name>" } +cd terraform/gcp && terraform plan # expect: imported, not created +``` + +## After a clean import plan + +Open a PR (Conventional title). The four required checks run +([`ci.md`](../../../../docs/ci.md)); a maintainer reads the plan (UI or the +[HCP API toolkit](../../../../docs/hcp-api.md)) and confirms the apply on merge. The import executes as a +real run — after which the `import` blocks can be removed in a follow-up (they're one-shot; +the resources are managed by their addresses thereafter). diff --git a/.claude/skills/infra-setup/references/steps.yaml b/.claude/skills/infra-setup/references/steps.yaml new file mode 100644 index 0000000..7ed4c63 --- /dev/null +++ b/.claude/skills/infra-setup/references/steps.yaml @@ -0,0 +1,262 @@ +# Machine-readable manifest for the infra-setup skill. +# +# Read this alongside the router in ../SKILL.md. An agent walks these steps +# top-to-bottom. On every run it FIRST executes each step's `check` to find where +# setup already is (resume protocol), then resumes at the first red check. +# +# Field contract +# id stable slug, referenced by SKILL.md +# phase 0..6, matches SKILL.md phases +# provider hcp | cloudflare | github | gcp | repo +# actor AGENT -> agent executes directly +# HUMAN -> agent STOPS, emits the handoff block (see SKILL.md), waits +# for "done", then runs `check` before continuing +# title one line +# why what this unblocks (goes in the handoff block's "Why:") +# run AGENT steps: the command(s) the agent runs +# HUMAN steps: the human_action text (browser instructions) +# check command whose success (exit 0) means the step is DONE. Idempotent. +# `null` means "no scriptable check — prove it via the next plan". +# produces the artifact/credential this step yields +# docs canonical section to read for the fine print — a REPO-ROOT-RELATIVE path +# (e.g. docs/setup.md#... or .claude/skills/infra-setup/references/...), +# not relative to this file +# +# RUNNER SETUP — the fields below (org, hcp_api, …) are YAML metadata, NOT shell +# variables. Before running any `check`/`run`, export the two the commands actually +# reference as shell vars: +# export hcp_api=https://app.terraform.io/api/v2 +# export HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) +# The org slug `perishdev` is inlined literally in each command (not $org) — change it +# there, and in hcp_api if self-hosting, when porting. See the "Porting" table in SKILL.md. + +org: perishdev +domain: perish.dev +hcp_api: https://app.terraform.io/api/v2 +managed_repos: + - perishdev/infra + - perishdev/perishdev.github.io + +preflight: + - tool: terraform + min: "1.9" + check: "terraform version -json | jq -e '.terraform_version | split(\".\") | ((.[0]|tonumber) > 1) or ((.[0]|tonumber)==1 and (.[1]|tonumber) >= 9)'" + - tool: gh + check: "gh --version" + - tool: jq + check: "jq --version" + - tool: curl + check: "curl --version | head -1" + +steps: + # ── Phase 0 — HCP bootstrap ───────────────────────────────────────────── + - id: hcp-signup + phase: 0 + provider: hcp + actor: HUMAN + title: Create HCP Terraform org and project + why: HCP holds all state and secrets; nothing plans without it. + run: | + 1. Sign up at https://app.terraform.io/ (free tier is enough to start). + 2. Create an organization named exactly `perishdev`. + 3. Inside it, create a project named `infra`. + # NOTE: this check needs $HCP_TOKEN, which is only minted by the NEXT step + # (hcp-login) — an HCP org can't be verified without a token. On a cold run it + # reads red until hcp-login completes; that ordering is intentional, not a bug. + check: 'test -n "$HCP_TOKEN" && curl -sf "$hcp_api/organizations/perishdev" -H "Authorization: Bearer $HCP_TOKEN" | jq -e ".data.id"' + produces: HCP org `perishdev`, project `infra` + docs: docs/setup.md#1-hcp-terraform--organization + + - id: hcp-login + phase: 0 + provider: hcp + actor: HUMAN + title: terraform login (mint the pivot HCP token) + why: This browser login yields the API token that lets the AGENT drive everything after this. + run: | + Run locally: terraform login + Approve in the browser tab it opens. The token is written to + ~/.terraform.d/credentials.tfrc.json. This is the last interactive auth a human does. + check: 'jq -re ".credentials[\"app.terraform.io\"].token" ~/.terraform.d/credentials.tfrc.json' + produces: HCP user API token at ~/.terraform.d/credentials.tfrc.json + docs: docs/setup.md#6-local-development + + - id: hcp-verify + phase: 0 + provider: hcp + actor: AGENT + title: Confirm HCP API reachable + why: Gate — the rest of this skill is HCP API calls. + run: | + export HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) + curl -sf "$hcp_api/organizations/perishdev" -H "Authorization: Bearer $HCP_TOKEN" | jq -e '.data.id' + check: 'curl -sf "$hcp_api/organizations/perishdev" -H "Authorization: Bearer $HCP_TOKEN" | jq -e ".data.id"' + produces: verified API access + docs: docs/state.md#api-access + + # ── Phase 1 — HCP workspaces ──────────────────────────────────────────── + - id: vcs-connect + phase: 1 + provider: hcp + actor: HUMAN + title: Connect GitHub to HCP via OAuth + why: Workspaces need a VCS provider to run speculative plans on PRs. + run: | + In HCP -> org Settings -> Version Control / VCS Providers, connect GitHub via OAuth, + scoped to this repo only. Note the oauth-token id if the agent will script workspace creation. + # Assert a GitHub-family VCS provider specifically, not just "any oauth client" — + # otherwise a pre-existing GitLab/Bitbucket connection would false-green this step. + check: 'curl -sf "$hcp_api/organizations/perishdev/oauth-clients" -H "Authorization: Bearer $HCP_TOKEN" | jq -e "[.data[] | select(.attributes[\"service-provider\"] | test(\"github\"))] | length > 0"' + produces: GitHub<->HCP OAuth connection + docs: docs/setup.md#2-hcp-terraform--workspaces + + - id: workspaces-create + phase: 1 + provider: hcp + actor: AGENT + title: Create cloudflare + github-org workspaces + why: One workspace per leaf; carries working dir, path filter, safety toggles. + run: | + SOURCE OF TRUTH: the `create_ws` helper in references/hcp.md (Phase 1). Run that. + The summary below is a reminder, not a second copy to maintain — if it disagrees + with hcp.md, hcp.md wins. A POST to + $hcp_api/organizations/perishdev/workspaces per workspace with: + working-directory: terraform/cloudflare (and terraform/github) + file-triggers-enabled: true + trigger-patterns: ["terraform/cloudflare/**"] (and terraform/github/**) + execution-mode: remote + auto-apply: false + speculative-enabled: true # plans on PRs + vcs-repo: identifier perishdev/infra, oauth-token-id from vcs-connect, branch main + Then in each workspace's UI -> Version Control, confirm FORK speculative plans are OFF + (separate toggle, no clean create-time attribute; defaults off). + check: | + for ws in cloudflare github-org; do + curl -sf "$hcp_api/organizations/perishdev/workspaces/$ws" -H "Authorization: Bearer $HCP_TOKEN" \ + | jq -e '.data.attributes["auto-apply"]==false' >/dev/null || exit 1 + done + produces: workspaces `cloudflare`, `github-org` + docs: docs/state.md#workspaces + + # ── Phase 2 — Cloudflare ──────────────────────────────────────────────── + - id: cf-token + phase: 2 + provider: cloudflare + actor: HUMAN + title: Mint Cloudflare token and paste into HCP + why: The cloudflare workspace authenticates to Cloudflare with this token. + run: | + 1. https://dash.cloudflare.com/profile/api-tokens -> Create Token -> Custom token. + 2. Permissions: Zone·DNS·Edit, Zone·Zone Settings·Edit (add more as resources grow). + 3. Account Resources: your account. Zone Resources: 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 value. + check: | + WS_ID=$(curl -sf "$hcp_api/organizations/perishdev/workspaces/cloudflare" -H "Authorization: Bearer $HCP_TOKEN" | jq -r '.data.id') + curl -sf "$hcp_api/workspaces/$WS_ID/vars" -H "Authorization: Bearer $HCP_TOKEN" \ + | jq -e '.data[] | select(.attributes.key=="cloudflare_api_token") | .attributes.sensitive==true' + produces: sensitive var `cloudflare_api_token` + docs: .claude/skills/infra-setup/references/cloudflare.md + + # ── Phase 3 — GitHub App ──────────────────────────────────────────────── + - id: gh-app + phase: 3 + provider: github + actor: HUMAN + title: Create + install GitHub App, paste creds into HCP + why: The github-org workspace authenticates as a GitHub App (not a PAT). + run: | + 1. Create the App under perishdev: https://github.com/organizations/perishdev/settings/apps/new + Permissions: Repo Administration(RW), Contents(R), Metadata(R), Pull requests(RW); + Org Members(R), Administration(RW). Install: only on this account. + 2. Note the App ID. Generate a private key (.pem). + 3. Install on perishdev scoped to perishdev/infra + perishdev/perishdev.github.io; + note the Installation ID from the install URL. + 4. HCP -> workspace `github-org` -> Variables, add as TERRAFORM vars: + github_owner=perishdev (not sensitive), + github_app_id (sensitive), github_app_installation_id (sensitive), + github_app_pem = full PEM incl. BEGIN/END lines (sensitive). + check: | + WS_ID=$(curl -sf "$hcp_api/organizations/perishdev/workspaces/github-org" -H "Authorization: Bearer $HCP_TOKEN" | jq -r '.data.id') + curl -sf "$hcp_api/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[]))' + produces: sensitive vars github_app_id / _installation_id / _pem, plus github_owner + docs: .claude/skills/infra-setup/references/github.md + + # ── Phase 4 — First plan ──────────────────────────────────────────────── + - id: plan-cloudflare + phase: 4 + provider: cloudflare + actor: AGENT + title: init + speculative plan (terraform/cloudflare) + why: Proves the Cloudflare token end-to-end. + run: "cd terraform/cloudflare && terraform init && terraform plan" + check: "cd terraform/cloudflare && terraform init -input=false >/dev/null && terraform plan -input=false >/dev/null" + produces: green speculative plan for cloudflare + docs: docs/hcp-api.md + + - id: plan-github + phase: 4 + provider: github + actor: AGENT + title: init + speculative plan (terraform/github) + why: Proves the GitHub App credentials end-to-end. + run: "cd terraform/github && terraform init && terraform plan" + check: "cd terraform/github && terraform init -input=false >/dev/null && terraform plan -input=false >/dev/null" + produces: green speculative plan for github-org + docs: docs/hcp-api.md + + # ── Phase 5 — Migration (only if resources pre-exist) ─────────────────── + - id: migrate-discovery-token + phase: 5 + provider: cloudflare + actor: HUMAN + title: Mint short-lived read-only Cloudflare discovery token + why: cf-terraforming reads existing resources; use a throwaway READ token, not the HCP edit token. + run: | + https://dash.cloudflare.com/profile/api-tokens -> Custom token -> READ scopes on each + resource type to discover (DNS·Read, etc.), zone perish.dev, TTL a few hours. Copy the value. + check: ~ # YAML null — ephemeral credential, no scriptable check (see header contract) + produces: throwaway read token (deleted after import) + docs: docs/import.md#discovery-token + + - id: migrate-import + phase: 5 + provider: cloudflare + actor: AGENT + title: Generate HCL + import blocks, verify plan imports (not creates) + why: Bring existing zone/records under management without recreating them. + run: | + Follow docs/import.md: cf-terraforming generate + import (--modern-import-block) + into terraform/cloudflare/generated.tf, then terraform plan. + # One plan, captured once to a private mktemp file (0600, auto-removed), not a fixed + # world-readable /tmp path. Success = at least one import AND zero creates. (If a change + # legitimately adds a new resource alongside the import, review by hand — this check + # intentionally treats any 'will be created' as not-done.) + check: | + cd terraform/cloudflare + p=$(mktemp) + trap 'rm -f "$p"' EXIT + terraform plan -input=false -no-color >"$p" 2>&1 \ + && grep -q 'will be imported' "$p" \ + && ! grep -q 'will be created' "$p" + produces: generated.tf with import blocks; plan shows imports only + docs: .claude/skills/infra-setup/references/migration.md + + # ── Phase 6 — GCP (template only; NOT active) ─────────────────────────── + - id: gcp-decision + phase: 6 + provider: gcp + actor: HUMAN + title: (Template) Decide to adopt GCP — update design docs first + why: GCP is not a managed provider yet; adopting it changes a locked decision. + run: | + GCP is out of scope today. To adopt it: update CLAUDE.md's locked-decisions table + and terraform/README.md, THEN follow .claude/skills/infra-setup/references/gcp.md. + Do not provision ahead of the decision. + check: "test -d terraform/gcp" # red until GCP is intentionally adopted + produces: (none until adopted) + docs: .claude/skills/infra-setup/references/gcp.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b984d21 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md + +Guidance for any AI coding agent (Claude Code, opencode, Codex, Cursor, …) working in +this repo. Claude Code also reads [`CLAUDE.md`](./CLAUDE.md); the design decisions and +conventions there are authoritative for every agent — read it first. + +## What this repo is + +Infrastructure-as-code for `perish.dev`: Terraform against **HCP Terraform**, managing +**Cloudflare** (DNS, Email Routing) and **GitHub** (org repos, branch protection, labels). +No servers, no config management — everything is SaaS-shaped. See [`README.md`](./README.md). + +## Bootstrapping the repo (or a fork for another org) + +There is an **agent-first setup skill** at +[`.claude/skills/infra-setup/SKILL.md`](./.claude/skills/infra-setup/SKILL.md). It drives +the whole bootstrap — HCP, Cloudflare token, GitHub App, importing existing resources, +first plan — and pauses only for the steps a human must do (signups, minting credentials, +pasting secrets). + +- **Claude Code:** run `/infra-setup`, or just ask to "set up the infra." +- **Other harnesses:** open and follow + [`.claude/skills/infra-setup/SKILL.md`](./.claude/skills/infra-setup/SKILL.md) directly. + It's plain Markdown plus a machine-readable manifest + ([`references/steps.yaml`](./.claude/skills/infra-setup/references/steps.yaml)) — no + Claude-Code-specific runtime is required to walk it. + +The skill is **idempotent and resumable**: on every run it re-checks state and resumes at +the first incomplete step, so it's safe to re-invoke. + +## Working conventions + +Conventions and the locked design decisions are **authoritative in +[`CLAUDE.md`](./CLAUDE.md)** — read it; this file deliberately does not restate them (one +source of truth, no drift). The essentials it covers: every change is a PR (branch +protection on `main` needs four green checks), secrets never touch the repo (they live in +HCP workspace variables), applies are never automatic (a human or authenticated API call +confirms in HCP), and commits / branches / PR titles follow the Conventional * specs. + +Operational reference lives in [`docs/`](./docs/): `state.md`, `secrets.md`, `ci.md`, +`recipes.md`, `rollback.md`, `hcp-api.md`, `limits.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 746fbe1..7c7a76c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ There are no servers, no containers, no Kubernetes manifests. Every resource map 2. **[`README.md`](./README.md)** — what's where. 3. **[`docs/state.md`](./docs/state.md)** — how Terraform state is structured and how to read HCP via API. 4. **[`docs/ci.md`](./docs/ci.md)** — the required checks for any PR to land. -5. **[`docs/setup.md`](./docs/setup.md)** — bootstrap (skip this if HCP + GitHub App are already wired; you'd know). +5. **Bootstrap** (skip if HCP + GitHub App are already wired; you'd know). Agent-first path: run the **[`infra-setup`](./.claude/skills/infra-setup/SKILL.md)** skill (Claude Code: `/infra-setup`; other harnesses: follow the SKILL.md — see [`AGENTS.md`](./AGENTS.md)). By-hand path: **[`docs/setup.md`](./docs/setup.md)**, which the skill orchestrates. ## The PR workflow diff --git a/README.md b/README.md index f242a56..1f2628e 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,40 @@ There is dust in the clouds, infrastructure as code for perishdev. No hosts of our own (no Salt, no Ansible). Everything in scope is SaaS-shaped. +## Setup — let an agent do it + +Bootstrapping (or forking this repo as groundwork for another org) is **agent-first**. +Point any AI coding agent at the setup skill and it drives the whole thing — HCP, +Cloudflare token, GitHub App, importing existing resources, first plan — pausing only when +a human must sign up, mint a credential, or paste a secret. + +- **Claude Code:** run `/infra-setup` (or just say "set up the infra"). +- **Other harnesses (opencode, Codex, …):** open and follow + [`.claude/skills/infra-setup/SKILL.md`](./.claude/skills/infra-setup/SKILL.md) — plain + Markdown plus a machine-readable manifest; see [`AGENTS.md`](./AGENTS.md). + +It's idempotent and resumable: re-invoke any time and it re-checks state, resuming at the +first incomplete step. For a human-driven bootstrap instead, [`docs/setup.md`](./docs/setup.md) +is the canonical runbook the skill orchestrates. + ## Where things live ``` terraform/ cloudflare/ one HCP workspace (cloudflare), zone + DNS github/ one HCP workspace (github-org), repos + protection + labels +.claude/ + skills/infra-setup/ agent-first bootstrap skill (SKILL.md + references/ + steps.yaml) + commands/infra-setup.md /infra-setup entry point .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 + setup.md human bootstrap runbook (the skill orchestrates this) import.md cf-terraforming runbook for adopting existing Cloudflare state +AGENTS.md cross-harness pointer for non-Claude agents ``` 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. diff --git a/docs/README.md b/docs/README.md index a3ec0a2..a304b0e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,8 @@ 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. +- **[`.claude/skills/infra-setup/`](../.claude/skills/infra-setup/SKILL.md) — agent-first bootstrap.** The recommended path: point an AI agent at it (Claude Code: `/infra-setup`) and it drives the whole setup, pausing only for signups, credential minting, and secret pasting. Orchestrates the two docs below. See [`../AGENTS.md`](../AGENTS.md) for non-Claude harnesses. +- [`setup.md`](./setup.md) — the human-driven bootstrap runbook the skill orchestrates: HCP, Cloudflare token, GitHub App, local dev. Read it directly if you'd rather do it by hand. - [`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.