From 7d6cef1ad5f7397ea4b8a9e0b9bfcd74c6cd82c1 Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Wed, 5 Aug 2026 16:45:24 +0300 Subject: [PATCH 1/3] refactor(skills): adopt infra-copilot plugin, drop in-repo infra-setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The infra-setup skill and /infra-setup command were extracted into a standalone, org-agnostic Claude Code plugin published at hasansezertasan/infra-copilot (v0.1.0). This repo now consumes the plugin instead of carrying the skill itself — config, not logic. - Enable the plugin via .claude/settings.json (extraKnownMarketplaces + enabledPlugins). autoUpdate is OFF — plugin updates are pulled only when someone explicitly updates, pinning this infra repo to reviewed plugin versions (the plugin lives under a personal account, so we don't auto-run unreviewed changes here). - Add .claude/infra-copilot.local.md with perish.dev's non-secret identifiers (org, apex domain, Cloudflare account/zone IDs, managed repos, HCP status-check id). No secrets — those remain in HCP workspace variables. - Remove .claude/skills/infra-setup/ and .claude/commands/infra-setup.md; /infra-setup now resolves from the plugin. --- .claude/commands/infra-setup.md | 26 -- .claude/infra-copilot.local.md | 17 ++ .claude/settings.json | 14 + .claude/skills/infra-setup/SKILL.md | 192 ------------- .../infra-setup/references/cloudflare.md | 93 ------- .claude/skills/infra-setup/references/gcp.md | 94 ------- .../skills/infra-setup/references/github.md | 95 ------- .claude/skills/infra-setup/references/hcp.md | 110 -------- .../infra-setup/references/migration.md | 87 ------ .../skills/infra-setup/references/steps.yaml | 262 ------------------ 10 files changed, 31 insertions(+), 959 deletions(-) delete mode 100644 .claude/commands/infra-setup.md create mode 100644 .claude/infra-copilot.local.md create mode 100644 .claude/settings.json delete mode 100644 .claude/skills/infra-setup/SKILL.md delete mode 100644 .claude/skills/infra-setup/references/cloudflare.md delete mode 100644 .claude/skills/infra-setup/references/gcp.md delete mode 100644 .claude/skills/infra-setup/references/github.md delete mode 100644 .claude/skills/infra-setup/references/hcp.md delete mode 100644 .claude/skills/infra-setup/references/migration.md delete mode 100644 .claude/skills/infra-setup/references/steps.yaml diff --git a/.claude/commands/infra-setup.md b/.claude/commands/infra-setup.md deleted file mode 100644 index aaf38b9..0000000 --- a/.claude/commands/infra-setup.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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/infra-copilot.local.md b/.claude/infra-copilot.local.md new file mode 100644 index 0000000..94f1b4f --- /dev/null +++ b/.claude/infra-copilot.local.md @@ -0,0 +1,17 @@ +--- +hcp_org: perishdev +github_org: perishdev +apex_domain: perish.dev +cloudflare_account_id: d8a72309e747515805b614574ea7f323 +cloudflare_zone_id: 78ff9bdc9f1a38c01a935d3d079b1e7b +managed_repos: + - perishdev/infra + - perishdev/perishdev.github.io +hcp_status_check_id: repo-id-CffUfWW6H1x6Bauq +--- + +# infra-copilot config — perishdev / perish.dev + +Public identifiers for this repo, read by the `infra-copilot` plugin's `infra-setup` +skill at startup (Step 0). Sensitive data lives in HCP workspace variables, never here. +See the plugin's `skills/infra-setup/references/config.md` for the schema. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..448fbb7 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "extraKnownMarketplaces": { + "infra-copilot": { + "source": { + "source": "github", + "repo": "hasansezertasan/infra-copilot" + }, + "autoUpdate": false + } + }, + "enabledPlugins": { + "infra-copilot@infra-copilot": true + } +} diff --git a/.claude/skills/infra-setup/SKILL.md b/.claude/skills/infra-setup/SKILL.md deleted file mode 100644 index fcb641a..0000000 --- a/.claude/skills/infra-setup/SKILL.md +++ /dev/null @@ -1,192 +0,0 @@ ---- -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 deleted file mode 100644 index c756e4c..0000000 --- a/.claude/skills/infra-setup/references/cloudflare.md +++ /dev/null @@ -1,93 +0,0 @@ -# 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 deleted file mode 100644 index 30d7ba0..0000000 --- a/.claude/skills/infra-setup/references/gcp.md +++ /dev/null @@ -1,94 +0,0 @@ -# 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 deleted file mode 100644 index 4e3d3b9..0000000 --- a/.claude/skills/infra-setup/references/github.md +++ /dev/null @@ -1,95 +0,0 @@ -# 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 deleted file mode 100644 index f267c85..0000000 --- a/.claude/skills/infra-setup/references/hcp.md +++ /dev/null @@ -1,110 +0,0 @@ -# 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 deleted file mode 100644 index 89db414..0000000 --- a/.claude/skills/infra-setup/references/migration.md +++ /dev/null @@ -1,87 +0,0 @@ -# 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 deleted file mode 100644 index 7ed4c63..0000000 --- a/.claude/skills/infra-setup/references/steps.yaml +++ /dev/null @@ -1,262 +0,0 @@ -# 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 From 2bcd88372b36419f8cbc26a1aebc314de9581976 Mon Sep 17 00:00:00 2001 From: hasansezertasan <hasansezertasan@gmail.com> Date: Thu, 6 Aug 2026 17:57:28 +0300 Subject: [PATCH 2/3] docs: move generic procedure to infra-copilot plugin, keep perish.dev-specific ops --- AGENTS.md | 5 +- CLAUDE.md | 14 +-- CONTRIBUTING.md | 16 ++-- README.md | 17 ++-- docs/README.md | 25 ++---- docs/ci.md | 63 -------------- docs/decisions.md | 17 ++++ docs/hcp-api.md | 179 -------------------------------------- docs/import.md | 127 --------------------------- docs/rollback.md | 4 +- docs/secrets.md | 89 ------------------- docs/setup.md | 119 ------------------------- docs/state.md | 60 ------------- docs/worktree-workflow.md | 67 -------------- terraform/README.md | 8 +- 15 files changed, 55 insertions(+), 755 deletions(-) delete mode 100644 docs/ci.md create mode 100644 docs/decisions.md delete mode 100644 docs/hcp-api.md delete mode 100644 docs/import.md delete mode 100644 docs/secrets.md delete mode 100644 docs/setup.md delete mode 100644 docs/state.md delete mode 100644 docs/worktree-workflow.md diff --git a/AGENTS.md b/AGENTS.md index b984d21..d194e52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,5 +37,6 @@ protection on `main` needs four green checks), secrets never touch the repo (the 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`. +Operational reference lives in [`docs/`](./docs/): `decisions.md`, `recipes.md`, +`rollback.md`, `limits.md`. Generic setup/import/CI/HCP-API/secrets/state procedure +lives in the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot). diff --git a/CLAUDE.md b/CLAUDE.md index 4dd1e60..d7f1466 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ The repo is inspired by [`pypi/infra`](https://github.com/pypi/infra) but does n ## Conventions specific to this repo -- **Secrets**: never commit plaintext secrets, and never commit encrypted secrets either — sensitive data lives outside the repo entirely. See [`docs/secrets.md`](./docs/secrets.md). +- **Secrets**: never commit plaintext secrets, and never commit encrypted secrets either — sensitive data lives outside the repo entirely. See the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s secrets reference. - **Environments**: single Cloudflare account, single apex domain today. If/when a staging surface is added, keep it separated at the Terraform workspace level (not just the resource level) so a change to one environment can't silently apply to another. - **Plan before apply**: every Terraform change goes through `terraform plan` (locally or as an HCP speculative run on a PR) and is reviewed before `apply`. Production applies are never auto-applied. @@ -27,12 +27,12 @@ These are the contracts the repo is built on. Don't re-derive; if changing, upda | Concern | Decision | Doc | |---|---|---| -| Terraform-time secrets store | HCP Terraform workspace variables (sensitive) | [`docs/secrets.md`](./docs/secrets.md) | -| CI-time secrets store | GitHub Actions encrypted secrets (only `TF_API_TOKEN`) | [`docs/secrets.md`](./docs/secrets.md) | -| Terraform state backend | HCP Terraform (managed) | [`docs/state.md`](./docs/state.md) | -| GitHub auth from Terraform | GitHub App (not PAT) | [`docs/secrets.md`](./docs/secrets.md) | -| CI plan/apply policy | Plan-on-PR (collaborator auto, fork PRs require `safe-to-plan` label); apply gated to `main` + manual confirmation in HCP | [`docs/ci.md`](./docs/ci.md) | -| At-rest encryption in repo | None — nothing encrypted committed; anything sensitive lives in HCP workspace variables or external stores | [`docs/secrets.md`](./docs/secrets.md) | +| Terraform-time secrets store | HCP Terraform workspace variables (sensitive) | [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot) secrets reference | +| CI-time secrets store | GitHub Actions encrypted secrets (only `TF_API_TOKEN`) | infra-copilot plugin secrets reference | +| Terraform state backend | HCP Terraform (managed) | infra-copilot plugin state reference | +| GitHub auth from Terraform | GitHub App (not PAT) | infra-copilot plugin secrets reference | +| CI plan/apply policy | Plan-on-PR (collaborator auto, fork PRs require `safe-to-plan` label); apply gated to `main` + manual confirmation in HCP | infra-copilot plugin CI reference | +| At-rest encryption in repo | None — nothing encrypted committed; anything sensitive lives in HCP workspace variables or external stores | infra-copilot plugin secrets reference | ## Inherited from the user's global CLAUDE.md (highlights) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c7a76c..fa33128 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,9 +12,9 @@ There are no servers, no containers, no Kubernetes manifests. Every resource map 1. **[`CLAUDE.md`](./CLAUDE.md)** — design decisions table. Authoritative. Don't re-derive any decision listed there; if you want to change one, update the doc first. 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. **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. +3. **The [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s state reference** — how Terraform state is structured and how to read HCP via API. +4. **The infra-copilot plugin's CI reference** — the required checks for any PR to land. +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: the infra-copilot plugin's setup reference, which the skill orchestrates. ## The PR workflow @@ -45,7 +45,7 @@ The first three run on every PR (including from forks). The HCP check fires per- ### Fork PRs -A maintainer applies the `safe-to-plan` label to authorise HCP speculative plans on a fork PR. Without it, only the fork-safe GH Actions checks run. See [`docs/ci.md`](./docs/ci.md) for the threat model and what to scan for before labelling. +A maintainer applies the `safe-to-plan` label to authorise HCP speculative plans on a fork PR. Without it, only the fork-safe GH Actions checks run. See the infra-copilot plugin's CI reference for the threat model and what to scan for before labelling. ## Where things go @@ -95,7 +95,7 @@ curl -sL "https://app.terraform.io/api/v2/plans/$PLAN_ID/json-output-redacted" \ | jq '.resource_changes[] | {address, actions: .change.actions}' ``` -Workspace IDs are in [`docs/state.md`](./docs/state.md). +Workspace IDs are in [`docs/decisions.md`](./docs/decisions.md). ### Confirm apply via API @@ -110,9 +110,9 @@ This counts as the manual confirm — same gate, scripted. ## Things to know that aren't in the design decisions -- **The HCP required-status-check name (`Terraform Cloud/perishdev/repo-id-CffUfWW6H1x6Bauq`) embeds a per-installation VCS ID.** If the GitHub-HCP OAuth connection is ever rebuilt, that string changes and every PR is silently blocked until [`terraform/github/branch_protection.tf`](./terraform/github/branch_protection.tf) is updated. Lives in three places (this file, [`docs/ci.md`](./docs/ci.md), inline in the resource) so future-you finds it from any angle. -- **GitHub Pages cert provisioning can wedge.** Fix: `gh api -X PUT repos/<owner>/<repo>/pages -f 'cname='` then re-set the CNAME. See troubleshooting in [`docs/setup.md`](./docs/setup.md). -- **cf-terraforming is for one-time onboarding**, not steady-state. New Cloudflare resources should be written in Terraform directly, not discovered after the fact. See [`docs/import.md`](./docs/import.md). +- **The HCP required-status-check name (`Terraform Cloud/perishdev/repo-id-CffUfWW6H1x6Bauq`) embeds a per-installation VCS ID.** If the GitHub-HCP OAuth connection is ever rebuilt, that string changes and every PR is silently blocked until [`terraform/github/branch_protection.tf`](./terraform/github/branch_protection.tf) is updated. Lives in three places (this file, [`docs/decisions.md`](./docs/decisions.md), inline in the resource) so future-you finds it from any angle. +- **GitHub Pages cert provisioning can wedge.** Fix: `gh api -X PUT repos/<owner>/<repo>/pages -f 'cname='` then re-set the CNAME. See troubleshooting in the infra-copilot plugin's setup reference. +- **cf-terraforming is for one-time onboarding**, not steady-state. New Cloudflare resources should be written in Terraform directly, not discovered after the fact. See the infra-copilot plugin's import reference, and [`docs/decisions.md`](./docs/decisions.md) for what was actually imported here. - **`tfe_workspace.*` doesn't exist yet.** HCP itself isn't Terraform-managed today; settings clicked into the UI. See [Issue #8](https://github.com/perishdev/infra/issues/8) for the planned arc. ## What not to do diff --git a/README.md b/README.md index 1f2628e..d91a87a 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ a human must sign up, mint a credential, or paste a secret. 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. +first incomplete step. For a human-driven bootstrap instead, the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s +setup reference is the canonical runbook the skill orchestrates. ## Where things live @@ -38,15 +38,14 @@ terraform/ .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 human bootstrap runbook (the skill orchestrates this) - import.md cf-terraforming runbook for adopting existing Cloudflare state + decisions.md perish.dev-specific decisions + live resource IDs + recipes.md common-task recipes + rollback.md what to do when an apply made things worse + limits.md vendor free-tier limits 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. +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. The generic setup/import/CI/HCP-API/secrets/state procedure lives in the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot). ## Contributing @@ -57,7 +56,7 @@ Branch protection requires four green checks before any merge to `main`: - `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. +Fork PRs only get GitHub Actions; HCP plans require a maintainer to apply the `safe-to-plan` label first. See the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s CI reference for the full policy, and [`CLAUDE.md`](./CLAUDE.md) for the locked policy decision. Conventional Commits, Conventional Branches, Conventional PR titles. diff --git a/docs/README.md b/docs/README.md index a304b0e..c2b130f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,37 +2,23 @@ Operational documentation for the `perishdev/infra` repo. The [top-level `CLAUDE.md`](../CLAUDE.md) holds the locked design decisions; the docs below explain how those decisions play out in day-to-day use. +Generic setup/import/CI/HCP-API/secrets/state **procedure** lives in the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot). This directory holds perish.dev-specific operations and decisions. + ## Reading order for first contact 1. [`../CLAUDE.md`](../CLAUDE.md) — design decisions table. Authoritative. 2. [`../README.md`](../README.md) — what this repo is. 3. [`../CONTRIBUTING.md`](../CONTRIBUTING.md) — five-minute orientation for contributors and AI agents. -4. [`state.md`](./state.md) — how HCP state is structured + the API access pattern. -5. [`ci.md`](./ci.md) — the required-checks contract for any merge. +4. [`decisions.md`](./decisions.md) — perish.dev-specific decisions and live resource facts. The rest is reference, dipped into as needed. ## Reference -### Setup and onboarding - -- **[`.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. - -### Design contracts - -- [`secrets.md`](./secrets.md) — where every secret lives, how rotation works, what isn't a secret. -- [`state.md`](./state.md) — HCP backend, workspace layout, API access from CLI. -- [`ci.md`](./ci.md) — workflow contract, fork-PR policy, branch protection requirements. - -### Operations - +- [`decisions.md`](./decisions.md) — perish.dev-specific decisions (Cloudflare import scope) and live resource IDs (HCP org/workspace IDs, status-check ID). +- [`limits.md`](./limits.md) — vendor free-tier limits and where the cliffs are. - [`recipes.md`](./recipes.md) — common-task recipes: add a DNS record, add a repo, add a label, bump a provider, cross-workspace changes. - [`rollback.md`](./rollback.md) — six options when an apply made things worse, ranked from cheapest to last-resort. -- [`hcp-api.md`](./hcp-api.md) — HCP REST API toolkit. Read plan summaries, confirm applies, find runs, all from `curl`. -- [`limits.md`](./limits.md) — vendor free-tier limits and where the cliffs are. ## When to add a new doc @@ -46,6 +32,7 @@ Don't add a new file for: - One-off tasks (PR description suffices). - Things that duplicate provider documentation (link to the vendor instead). +- Generic procedure that applies to any infra-copilot-managed repo — that belongs in the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot), not here. - Things that contradict [`../CLAUDE.md`](../CLAUDE.md) — fix the design decisions table first, then write the doc. ## When to delete a doc diff --git a/docs/ci.md b/docs/ci.md deleted file mode 100644 index 6fb6432..0000000 --- a/docs/ci.md +++ /dev/null @@ -1,63 +0,0 @@ -# CI - -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 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 | - -## What runs on a PR - -| 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 | - -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. - -## What runs on merge to `main` - -- 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. - -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. - -## 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`. 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 - -Before applying `safe-to-plan`, scan the diff for: - -- New `local_file` / `null_resource` / `external` data sources — these can exfiltrate values during a plan. -- New providers or modules pulled from untrusted sources — they execute code during `terraform init`. -- Changes to the workflow files themselves — fork-PR workflows run from the PR branch, so a malicious workflow edit is just as dangerous as malicious Terraform. -- Anything that reads a sensitive variable and writes it somewhere observable (a resource attribute, an output, a log). - -If anything looks off, decline the label and ask for changes. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..adc4d82 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,17 @@ +# Operational decisions & live resource facts + +Decisions and concrete resource facts specific to the perish.dev deployment. +The *procedure* for how any of this is done lives in the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot); this file records what was decided and what exists for THIS infra. + +## Cloudflare import scope (first bootstrap) + +- The six Email-Routing DNS records on `perish.dev` were imported under Terraform via `cf-terraforming` (see the plugin's import reference for the how-to). +- The four GitHub Pages apex records and the `www` CNAME were written directly into [`terraform/cloudflare/dns.tf`](../terraform/cloudflare/dns.tf) — they had no prior existence to import. +- `cloudflare_pages_project` and `cloudflare_r2_bucket` were discovered during the first run but **deliberately excluded** from import: the Pages projects were unrelated personal repos and the R2 bucket was empty. **Scope decision: this repo manages perish.dev infra, not the whole Cloudflare account.** + +## Live resource IDs (re-derivable via the HCP API; cached here for convenience) + +- HCP org: `perishdev` +- Workspace `cloudflare`: `ws-WWKeFPiCAjV4STNX` +- Workspace `github-org`: `ws-iZFBsNEsUfJPRJ1Q` +- HCP status-check id (embedded in branch protection): `repo-id-CffUfWW6H1x6Bauq` — also set in [`.claude/infra-copilot.local.md`](../.claude/infra-copilot.local.md) and [`terraform/github/branch_protection.tf`](../terraform/github/branch_protection.tf). If the GitHub↔HCP connection is ever rebuilt this string changes and branch protection will silently block PRs until it is updated in both places. diff --git a/docs/hcp-api.md b/docs/hcp-api.md deleted file mode 100644 index 9253350..0000000 --- a/docs/hcp-api.md +++ /dev/null @@ -1,179 +0,0 @@ -# HCP Terraform API toolkit - -The HCP REST API is the same surface the UI uses. Everything in this repo's day-to-day operations can be scripted against it, often more precisely than clicking through the web app. - -## Authentication - -`terraform login` deposits a user API token at `~/.terraform.d/credentials.tfrc.json`. That token is what authenticates every snippet below. - -```sh -HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) -H_AUTH="Authorization: Bearer $HCP_TOKEN" -H_TYPE="Content-Type: application/vnd.api+json" -``` - -For org-level operations (managing teams, oauth clients), the user must be an org admin in HCP. For most read operations a workspace member token is enough. - -## Identifiers you'll need often - -| Name | Value | How to find | -|---|---|---| -| Organization | `perishdev` | the URL `app.terraform.io/app/perishdev/...` | -| `cloudflare` workspace | `ws-WWKeFPiCAjV4STNX` | `GET /organizations/perishdev/workspaces/cloudflare` | -| `github-org` workspace | `ws-iZFBsNEsUfJPRJ1Q` | `GET /organizations/perishdev/workspaces/github-org` | -| `infra` project | (find via API) | `GET /organizations/perishdev/projects` | -| GitHub OAuth client | (find via API) | `GET /organizations/perishdev/oauth-clients` | - -## Find a workspace - -```sh -WS_ID=$(curl -s "https://app.terraform.io/api/v2/organizations/perishdev/workspaces/cloudflare" \ - -H "$H_AUTH" | jq -r '.data.id') -echo "$WS_ID" -``` - -## List recent runs on a workspace - -```sh -curl -s "https://app.terraform.io/api/v2/workspaces/$WS_ID/runs?page%5Bsize%5D=10" \ - -H "$H_AUTH" \ - | jq '[.data[] | { - id, - status: .attributes.status, - plan_only: .attributes."plan-only", - message: (.attributes.message[:80]) - }]' -``` - -Note: **speculative runs are filtered out of the default list.** To see them, add `&filter[status]=planned_and_finished,planning,planned`. That's how you find a PR's speculative plan run. - -## Read a plan summary (creates / updates / destroys / imports) - -```sh -RUN_ID=run-xxxxxxxxxxxxxxxx -PLAN_ID=$(curl -s "https://app.terraform.io/api/v2/runs/$RUN_ID" \ - -H "$H_AUTH" | jq -r '.data.relationships.plan.data.id') - -curl -sL "https://app.terraform.io/api/v2/plans/$PLAN_ID/json-output-redacted" \ - -H "$H_AUTH" \ - | jq '{ - summary: { - creates: ([.resource_changes[]? | select(.change.actions[]? == "create" and (.change.importing // null) == null)] | length), - imports: ([.resource_changes[]? | select(.change.importing != null)] | length), - updates: ([.resource_changes[]? | select(.change.actions[]? == "update")] | length), - destroys: ([.resource_changes[]? | select(.change.actions[]? == "delete")] | length) - }, - by_action: [.resource_changes[] | "\(.change.actions | join(",")) \(.address)"] - }' -``` - -This is faster and more precise than reading the HCP UI's plan output. Use it before merging any PR. - -## Confirm an apply - -After merge, HCP plans the run and sits at "needs confirmation." Confirm: - -```sh -curl -s -X POST "https://app.terraform.io/api/v2/runs/$RUN_ID/actions/apply" \ - -H "$H_AUTH" -H "$H_TYPE" \ - -d '{"comment":"plan reviewed via API"}' -``` - -The HTTP 202 is the confirmation. Status transitions to `applying` then `applied` (or `errored`). - -## Discard / cancel a run - -```sh -# Plan reviewed, not what we wanted — discard before confirming apply -curl -s -X POST "https://app.terraform.io/api/v2/runs/$RUN_ID/actions/discard" \ - -H "$H_AUTH" -H "$H_TYPE" -d '{"comment":"discarding — see revert PR"}' - -# Apply currently running and we want to stop -curl -s -X POST "https://app.terraform.io/api/v2/runs/$RUN_ID/actions/cancel" \ - -H "$H_AUTH" -``` - -Cancel is the harder of the two — partial state is possible mid-apply. - -## Wait for a run to reach a terminal state - -```sh -poll_run() { - local rid=$1 - while :; do - local st - st=$(curl -s "https://app.terraform.io/api/v2/runs/$rid" \ - -H "$H_AUTH" | jq -r '.data.attributes.status') - echo "$st" - case "$st" in - applied|errored|canceled|discarded|force_canceled|planned_and_finished) - return 0 - ;; - esac - sleep 15 - done -} -``` - -## List variables on a workspace - -```sh -curl -s "https://app.terraform.io/api/v2/workspaces/$WS_ID/vars" \ - -H "$H_AUTH" \ - | jq '[.data[] | { - id, - key: .attributes.key, - category: .attributes.category, - sensitive: .attributes.sensitive - }]' -``` - -Sensitive variable values are never returned by the API — only `"sensitive": true` and no `value` field. This is how it should be. - -## Find the speculative plan run for a specific commit - -```sh -COMMIT_SHA=... -curl -s "https://app.terraform.io/api/v2/workspaces/$WS_ID/runs?page%5Bsize%5D=20&filter%5Bstatus%5D=planned_and_finished,planning,planned" \ - -H "$H_AUTH" \ - | jq --arg sha "$COMMIT_SHA" ' - [.data[] - | select(.attributes."plan-only" == true) - | select(.attributes.message | startswith("Merge") or contains($sha[:8])) - | {id, message: (.attributes.message[:80])}]' -``` - -(The PR's merge commit message contains the PR title, so the easiest match is by message substring.) - -## Trigger a manual plan from the API - -Useful for "the workspace's auto-trigger didn't fire" debugging. - -```sh -curl -s -X POST "https://app.terraform.io/api/v2/runs" \ - -H "$H_AUTH" -H "$H_TYPE" \ - -d "{ - \"data\": { - \"attributes\": { \"message\": \"manual diagnostic\" }, - \"type\": \"runs\", - \"relationships\": { - \"workspace\": { \"data\": { \"type\": \"workspaces\", \"id\": \"$WS_ID\" } } - } - } - }" -``` - -## Things the API can do that the UI can't - -- **Read the full structured plan JSON** (`plans/<id>/json-output-redacted`). The UI shows a rendered diff; the API gives you the underlying data to script against. -- **Atomic operations across multiple workspaces** in a script — the UI is one workspace at a time. -- **Audit / activity scripting** — list every run by every user, filter by date, dump to CSV. - -## What still needs the UI - -- The "Update VCS settings" button that re-registers the webhook with GitHub. The API equivalent exists but is fiddly; UI click is fastest. -- Inspecting workspace settings interactively when you don't know what you're looking for. - -## Full API reference - -<https://developer.hashicorp.com/terraform/cloud-docs/api-docs> diff --git a/docs/import.md b/docs/import.md deleted file mode 100644 index 62ac8ab..0000000 --- a/docs/import.md +++ /dev/null @@ -1,127 +0,0 @@ -# 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. - -## Discovery token - -Before you run cf-terraforming, generate a separate, short-lived Cloudflare token with **Read** scopes on every resource type you're discovering. Don't reuse the HCP token (which has Edit scopes — broader than discovery needs). - -1. <https://dash.cloudflare.com/profile/api-tokens> → **Create Token** → **Custom token**. -2. Permissions: pick **Read** on each resource type you're about to discover. Examples: - - Zone — DNS — Read - - Zone — Email Routing Rules — Read - - Account — Email Routing Addresses — Read -3. **Account Resources**: Include — your account. **Zone Resources**: Include — Specific zone — `perish.dev`. -4. **TTL**: set to a day or a few hours. This is throwaway. -5. **Continue to summary** → **Create token** → copy the value (shown once). -6. Park it on your laptop until the run is done: - ```sh - read -s "?Paste Cloudflare discovery token: " CF_TOK - echo - echo "$CF_TOK" > /tmp/cf_token - chmod 600 /tmp/cf_token - unset CF_TOK - ``` -7. After cf-terraforming finishes: `rm /tmp/cf_token`, and revoke the token in the dashboard. - -## 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 - -# Zone-scoped resources (DNS records) — --account and --zone are mutually exclusive. -cf-terraforming generate \ - --terraform-binary-path "$(which terraform)" \ - --resource-type "cloudflare_dns_record" \ - --zone "$CLOUDFLARE_ZONE_ID" \ - > generated.tf - -# Account-scoped resources. -cf-terraforming generate \ - --terraform-binary-path "$(which terraform)" \ - --resource-type "cloudflare_r2_bucket,cloudflare_pages_project,cloudflare_workers_kv_namespace" \ - --account "$CLOUDFLARE_ACCOUNT_ID" \ - >> generated.tf -``` - -Without `--terraform-binary-path`, cf-terraforming downloads its own terraform binary into the current directory. The flag tells it to reuse the one already on PATH. - -`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 \ - --terraform-binary-path "$(which terraform)" \ - --resource-type "cloudflare_dns_record" \ - --zone "$CLOUDFLARE_ZONE_ID" \ - >> generated.tf - -cf-terraforming import \ - --modern-import-block \ - --terraform-binary-path "$(which terraform)" \ - --resource-type "cloudflare_r2_bucket,cloudflare_pages_project,cloudflare_workers_kv_namespace" \ - --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 - -```sh -terraform fmt generated.tf -terraform validate -terraform plan # expect: every existing resource shown as "will import", nothing as "will create" -``` - -`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. - -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. diff --git a/docs/rollback.md b/docs/rollback.md index ed49f39..6c1d0bf 100644 --- a/docs/rollback.md +++ b/docs/rollback.md @@ -81,9 +81,9 @@ Use when: there's a real fire and the gates can't be opened normally. If state is corrupted beyond surgery, the nuclear option is to throw away the workspace, recreate it, and re-import everything via `cf-terraforming` (Cloudflare) or by hand (GitHub). 1. In HCP UI → workspace → Settings → Delete. Confirm. -2. Recreate the workspace per [`setup.md`](./setup.md) step 2. +2. Recreate the workspace per the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s setup reference. 3. Reload sensitive variables. -4. Run [`cf-terraforming`](./import.md) against the live Cloudflare state. +4. Run `cf-terraforming` (see the infra-copilot plugin's import reference) against the live Cloudflare state. 5. Build new `import` blocks for everything, plan, apply. This is multi-hour work and is a real outage for any apply-gated change in flight. Avoid unless options 1–5 are all impossible. diff --git a/docs/secrets.md b/docs/secrets.md deleted file mode 100644 index 62ef022..0000000 --- a/docs/secrets.md +++ /dev/null @@ -1,89 +0,0 @@ -# Secrets - -This repo is **public**. Plaintext secrets must never be committed — not in code, not in CI logs, not in `terraform plan` output. A secret that lands in git history is compromised forever; rotation is the only fix. - -## Where secrets live - -| Secret | Store | Consumed by | -|---|---|---| -| 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. -- 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: 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 - -Rotate yearly or on any suspected exposure. The procedures below are zero-downtime — the new credential is in place and exercised before the old one is revoked. - -### Cloudflare API token - -1. <https://dash.cloudflare.com/profile/api-tokens> → **Create Token** → duplicate the scopes of the existing token. -2. Copy the new value. -3. HCP → workspace `cloudflare` → Variables → `cloudflare_api_token` → click the edit icon → paste new value → Save. -4. Trigger a no-op plan to confirm the new token works: - ```sh - cd terraform/cloudflare - terraform plan # expect: no changes; speculative plan in HCP authenticates with the new token - ``` -5. If the plan succeeds, return to the dashboard and **revoke the old token**. -6. If the plan fails, paste the old value back into the HCP variable; the old token is still live. Investigate. - -### GitHub App private key - -GitHub Apps support multiple active private keys, so rotation is overlap-then-cutover. - -1. GitHub → org Settings → Developer settings → GitHub Apps → your app → **Private keys → Generate a private key**. A `.pem` downloads. Both old and new keys are now active. -2. HCP → workspace `github-org` → Variables → `github_app_pem` → paste the full new PEM contents (including `-----BEGIN/END-----` lines) → Save. -3. Trigger a no-op plan to confirm: - ```sh - cd terraform/github - terraform plan - ``` -4. If the plan succeeds, return to the GitHub App's Private keys page and **delete the old key**. -5. If the plan fails — likely PEM newline mangling on paste — paste again carefully and retry. - -### HCP API token (user or team) - -- **User token**: each maintainer regenerates via `terraform login` after the previous token is revoked. Coordinate so the local CLI keeps working. -- **Team token** (`infra-meta-bot` or similar, when HCP-as-code arc lands): HCP UI → org Settings → Teams → token regenerate. Paste the new value into the meta-workspace's `TFE_TOKEN` env var. - -In both cases: generate the new one *first*, paste it in, verify by triggering a plan against any workspace, then revoke the old. - -### When NOT to rotate - -- Routine "I forgot when I last rotated" — schedule a yearly calendar reminder, but don't rotate ad-hoc just to feel busy. Each rotation is a chance for a typo or PEM mangling to wedge a workspace. -- On a maintainer leaving — only rotate credentials they actually had access to. The HCP API token they used dies when their HCP user is removed; the underlying Cloudflare / GitHub App credentials aren't tied to a specific maintainer and don't need rotating unless they were ever exposed to that maintainer's local environment. - -## Things that look like secrets but aren't - -- 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. diff --git a/docs/setup.md b/docs/setup.md deleted file mode 100644 index 38c5e78..0000000 --- a/docs/setup.md +++ /dev/null @@ -1,119 +0,0 @@ -# Setup - -One-time, out-of-band steps to wire up the systems this repo manages. The 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, in Settings → Version Control: - -- VCS provider: GitHub (connect via OAuth, scope to this repo only). -- **Terraform Working Directory**: set as above. -- **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. -- **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. - -In Settings → General: - -- **Execution mode**: Remote. -- **Auto-apply**: disabled (manual confirmation required). - -## 3. Cloudflare API token - -1. Go to <https://dash.cloudflare.com/profile/api-tokens> → **Create Token**. -2. **Custom token** with these permissions for the `perish.dev` zone and the account it belongs to: - - Zone — DNS — Edit - - Zone — Zone Settings — Edit -3. Account Resources: **Include — your account only**. Zone Resources: **Include — Specific zone — `perish.dev`**. -4. Copy the token (shown once). -5. In HCP → workspace **`cloudflare`** → Variables → add `cloudflare_api_token` as a **Terraform variable**, mark **Sensitive**, paste the token. - -## Adding scopes to the Cloudflare token later - -When you start managing a new Cloudflare resource type (e.g. Email Routing rules, Rulesets), the existing HCP token needs more permissions. **Edit, don't regenerate** — the value stays the same and HCP keeps working without re-pasting. - -1. <https://dash.cloudflare.com/profile/api-tokens> → find the existing token → **⋯ → Edit**. -2. Under **Permissions**, click **+ Add more** and add the new rows. Examples: - - Zone — Email Routing Rules — Edit - - Account — Email Routing Addresses — Edit - - Account — Rulesets — Edit (for redirect rules) -3. Confirm **Account Resources** and **Zone Resources** still match what they were. -4. **Continue to summary** → **Update token**. The token value does not change on a permission edit. - -If the dashboard *does* regenerate the value (which happens if you click "Roll" or recreate instead of edit), copy the new value into HCP → workspace `cloudflare` → Variables → `cloudflare_api_token`. - -## 4. GitHub App - -The `github-org` workspace authenticates as a GitHub App, not a PAT. - -1. Go to `https://github.com/organizations/perishdev/settings/apps/new` (replace `perishdev` if your org slug differs). -2. Permissions (start narrow, widen on demand): - - Repository: Administration (R/W), Contents (R), Metadata (R), Pull requests (R/W). - - Organization: Members (R), Administration (R/W). -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 the repos Terraform manages (`perishdev/infra` and `perishdev/perishdev.github.io`). Note the **Installation ID** from the install URL (`.../installations/<INSTALLATION_ID>`). -7. In HCP → workspace **`github-org`** → Variables → add four 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 **full PEM contents**, including the `-----BEGIN/END RSA PRIVATE KEY-----` lines (sensitive) - -## 5. HCP Terraform API token for CI - -Not currently used. The CI workflow does only fork-safe `fmt` and `validate`; HCP triggers plans and applies via its own VCS integration, not from GitHub Actions. - -Set this up only if a future CI workflow needs to call the HCP API directly: - -1. HCP → User settings → Tokens → Create API token, scope minimal. -2. GitHub → repo settings → Secrets → New repository secret → `TF_API_TOKEN`. - -## 6. Local development - -1. Install Terraform ≥ 1.9. -2. `terraform login` — opens a browser, asks HCP for a user API token. Token lands in `~/.terraform.d/credentials.tfrc.json`. The same token is what authenticates HCP API calls if you want to script anything (see [`state.md`](./state.md#api-access)). -3. From any leaf directory: `terraform init` (authenticates to HCP automatically), then `terraform plan`. A VCS-connected workspace allows `plan` from CLI but blocks `apply` — that gate is intentional. - -## 7. Importing existing Cloudflare resources - -See [`import.md`](./import.md) for the `cf-terraforming` runbook. It uses Cloudflare's own CLI to generate HCL and Terraform 1.5+ `import` blocks for the existing zone, DNS records, R2 buckets, and Pages projects. - -## Troubleshooting - -### HCP doesn't post a status check on a PR - -Usually one of three things: - -1. The workspace's "Automatic speculative plans" master toggle is off (see step 2). -2. The path filter excludes the PR's diff. Check Settings → Version Control → Trigger Patterns. A docs-only PR correctly produces no per-workspace check; HCP rolls up to a single SUCCESS aggregated status. -3. The workspace's VCS webhook subscription drifted. Open Settings → Version Control and click **Update VCS settings** with no changes — that re-registers the webhook with GitHub. - -### GitHub Pages cert stuck at `null` - -If `gh api repos/<owner>/<repo>/pages` shows `https_certificate: null` and `protected_domain_state: null` for more than ~15 min after the DNS resolves, the cert provisioning flow has wedged. Fix: - -```sh -gh api -X PUT repos/<owner>/<repo>/pages -f 'cname=' # remove -gh api -X PUT repos/<owner>/<repo>/pages -f 'cname=<domain>' # re-add -``` - -Removing and re-adding the custom domain re-emits the event that kicks Let's Encrypt. Cert usually issues within a few minutes after that. - -### Branch protection blocks a PR with no HCP check - -If a PR's diff doesn't match any workspace's path filter, HCP posts a single SUCCESS for the aggregated commit status (we tested this — it works). If it doesn't, the workspace VCS webhook is probably the issue (see above). diff --git a/docs/state.md b/docs/state.md deleted file mode 100644 index 9cebdc9..0000000 --- a/docs/state.md +++ /dev/null @@ -1,60 +0,0 @@ -# Terraform state - -State lives in **HCP Terraform** (formerly Terraform Cloud). It is never stored locally, never committed, and never exposed to public CI logs. - -## Why HCP Terraform - -- Managed remote backend with built-in locking — no S3 + DynamoDB to operate. -- Runs execute on HCP infrastructure, so the Cloudflare token and GitHub App key never leave HCP and never appear in GitHub Actions logs. -- Free tier covers small teams. Easy to migrate off later (state is a JSON blob). - -## Organization - -- **HCP org**: `perishdev`. -- **Project**: `infra` (one project per repo). - -## Workspaces - -One workspace per concern. We split per environment only when a change to one environment must not affect another (different account, different apex domain, or a real isolation requirement). Today neither concern needs that. - -| Workspace | Manages | VCS path filter | Auto-apply? | -|---|---|---|---| -| `cloudflare` | the `perish.dev` zone + all DNS records (Email Routing + GitHub Pages) | `terraform/cloudflare/**` | no — manual apply | -| `github-org` | `perishdev/infra` and `perishdev/perishdev.github.io` repo settings, `main` branch protection on both, `safe-to-plan` label | `terraform/github/**` | no — manual apply | - -Both are VCS-linked to this repo's `main` branch. Speculative plans run on PRs (see [`ci.md`](./ci.md)); applies stop at "needs confirmation" until a human (or authenticated API call) approves them. - -There is no `cloudflare-staging` workspace because we manage a single apex domain in a single Cloudflare account. Staging surfaces (`staging.<domain>`, `app-staging` Worker, etc.) would live as additional resources inside the `cloudflare` workspace. Revisit if we ever add a second apex domain or move staging into a separate account. - -## Variables - -Each workspace declares: - -- **Terraform variables (non-sensitive)** — set in HCP UI: Cloudflare account ID is a *local* in `terraform/cloudflare/main.tf` rather than a variable, because it's not a credential; zone ID likewise. The GitHub workspace's `github_owner` is set as a non-sensitive var. -- **Sensitive variables** — Cloudflare API token, GitHub App credentials (App ID, installation ID, PEM). Marked `sensitive`; HCP redacts from logs and never echoes back. -- **Environment variables** — none today. Used only for provider-level config that has to live in env (`TF_LOG`, etc.). - -Variable values live in HCP, not in the repo. The repo declares `variable "x" {}` blocks; HCP supplies the values at run time. - -## Access - -- **Read state**: workspace members in HCP. -- **Trigger speculative plan**: anyone who can push a branch (own-repo) or open a PR with the `safe-to-plan` label (fork). -- **Confirm apply**: workspace admins, or an authenticated `POST /api/v2/runs/<id>/actions/apply` with a personal HCP API token. -- **Manage workspace settings**: org admins. - -## API access - -A `terraform login` writes an HCP user API token to `~/.terraform.d/credentials.tfrc.json`. The same token authenticates every HCP REST endpoint, so anything you can do in the UI you can script via: - -```sh -HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) -curl -s "https://app.terraform.io/api/v2/organizations/perishdev/workspaces/cloudflare" \ - -H "Authorization: Bearer $HCP_TOKEN" | jq '.data.id' -``` - -Useful for reading plan summaries before merging a PR, confirming applies after a clean plan, and inspecting runs that didn't post status back to GitHub. - -## Migrating away - -If HCP ever stops being the right choice: `terraform state pull` from each workspace, `terraform state push` to the new backend. Code stays unchanged except for the `cloud {}` block (replace with `backend "..." {}`). This is the main reason for picking a managed backend now rather than self-hosting state on day one. diff --git a/docs/worktree-workflow.md b/docs/worktree-workflow.md deleted file mode 100644 index 5929672..0000000 --- a/docs/worktree-workflow.md +++ /dev/null @@ -1,67 +0,0 @@ -# Worktree workflow - -Optional but recommended for any maintainer doing more than one branch of work at a time. - -## What `git worktree` does - -A normal `git clone` gives you one checkout — one directory, one branch at a time. Switching branches means stashing or committing work-in-progress, then `git checkout`. Painful when you have a long-running branch and need to peek at `main`. - -`git worktree` lets a single repository have **multiple checked-out branches in different directories**, all sharing the same `.git` (objects, refs, hooks). Switching branches becomes `cd ../other-worktree`. - -## How this repo is laid out (one convention) - -``` -~/.superset/projects/infra main (the primary worktree) -~/.superset/worktrees/infra/feat/<topic> feat/... (one per active branch) -~/.superset/worktrees/infra/chore/<topic> chore/... -~/.superset/worktrees/infra/docs/<topic> docs/... -``` - -The session you're reading was done in `~/.superset/worktrees/infra/feat/useful-gull/`. The primary worktree at `~/.superset/projects/infra` stayed on `main`, useful as a "what does main look like right now?" reference. - -This layout is a convention, not enforced by anything. Use whatever directory shape fits. - -## Quick reference - -```sh -# Create a worktree on a new branch off main -git fetch origin -git worktree add -b feat/<topic> ~/.superset/worktrees/infra/feat/<topic> origin/main - -# Switch into it -cd ~/.superset/worktrees/infra/feat/<topic> - -# Normal git workflow from here - -# When the branch is merged and the worktree is no longer useful -git worktree remove ~/.superset/worktrees/infra/feat/<topic> -# (or if files were changed: `--force`) - -# List all worktrees attached to this repo -git worktree list - -# Prune stale worktree refs after manual rm of a worktree dir -git worktree prune -``` - -## When the convention earns its keep - -- **Two unrelated changes at the same time** — you can flip between them by `cd`-ing, no stash dance. -- **Reviewing a PR locally while your branch is dirty** — `git worktree add /tmp/review origin/<their-branch>`, do the review, `git worktree remove /tmp/review`. -- **Running tests on `main` while developing on a branch** — primary worktree on `main`, secondary on `feat/...`, both can have a `terraform plan` going. - -## When it doesn't - -- For a one-shot small PR, the cognitive cost of `cd`-ing isn't worth the worktree setup. Just branch in your normal checkout. -- If you don't use multiple branches concurrently, worktrees add nothing. - -## Caveats - -- Each worktree has its own working directory but they share git objects. A `git fetch` in one updates refs for all. That's the feature. -- You can't have two worktrees on the same branch — git enforces this so you can't accidentally commit conflicting changes to the same ref from two places. -- Some IDEs and tools assume "one working dir per project." If your editor or LSP gets confused, the workaround is usually opening each worktree as a separate project window. -- `gitignored` files in `.terraform/`, `node_modules/`, etc. are per-worktree, not shared. Each worktree's tooling state is independent. - -## When this doc earns its keep - -If a contributor doesn't already know `git worktree`, they don't need to learn it to contribute — a plain clone is fine. This doc exists for the contributor who's juggling two branches and wishing the dance were easier. diff --git a/terraform/README.md b/terraform/README.md index e4ce479..196c2dc 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -37,10 +37,10 @@ There is no per-environment split today: 1. Create `terraform/<concern>/` with `versions.tf`, `providers.tf`, `variables.tf`, and a resource file (`main.tf` or per-category `*.tf`). 2. Set the `cloud { workspaces { name = "<workspace-name>" } }` block in `versions.tf`. -3. Create the matching workspace in HCP under the `perishdev` org / `infra` project, VCS-linked to this repo, working directory set to the leaf dir, path filter `terraform/<concern>/**` (see [`../docs/setup.md`](../docs/setup.md) step 2 for the full per-workspace settings). +3. Create the matching workspace in HCP under the `perishdev` org / `infra` project, VCS-linked to this repo, working directory set to the leaf dir, path filter `terraform/<concern>/**` (see the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s setup reference for the full per-workspace settings). 4. Populate sensitive variables (provider tokens, credentials) in HCP. 5. If protected status checks live on `main`, add the new workspace's check name to [`github/branch_protection.tf`](./github/branch_protection.tf) in the same PR. -6. Open a PR; the `safe-to-plan` label gates fork-PR plans (see [`../docs/ci.md`](../docs/ci.md)). +6. Open a PR; the `safe-to-plan` label gates fork-PR plans (see the infra-copilot plugin's CI reference). ## Conventions @@ -84,6 +84,6 @@ Direct `data "..." "..." {}` between workspaces isn't supported until the `tfe` ## What's not here - `terraform/modules/` — reusable building blocks. Add when the same shape is needed in two places. Don't pre-build. -- An HCP-managing workspace (i.e. Terraform managing the HCP workspaces themselves via the `tfe` provider). Considered, deferred — the manual workspace setup is documented in [`../docs/setup.md`](../docs/setup.md) and runs once. See [Issue #8](https://github.com/perishdev/infra/issues/8). +- An HCP-managing workspace (i.e. Terraform managing the HCP workspaces themselves via the `tfe` provider). Considered, deferred — the manual workspace setup is documented in the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s setup reference and runs once. See [Issue #8](https://github.com/perishdev/infra/issues/8). -See also: [`../docs/secrets.md`](../docs/secrets.md), [`../docs/state.md`](../docs/state.md), [`../docs/ci.md`](../docs/ci.md), [`../docs/import.md`](../docs/import.md), [`../docs/recipes.md`](../docs/recipes.md). +See also: the infra-copilot plugin's secrets, state, CI, and import references; [`../docs/decisions.md`](../docs/decisions.md); [`../docs/recipes.md`](../docs/recipes.md). From 9a4cb2b4d175226754007b54e33701d23266f838 Mon Sep 17 00:00:00 2001 From: hasansezertasan <hasansezertasan@gmail.com> Date: Thu, 6 Aug 2026 18:01:08 +0300 Subject: [PATCH 3/3] docs: repoint skill references to the plugin, refresh .claude tree Follow-up to the cutover: README/AGENTS/CONTRIBUTING still linked the in-repo .claude/skills/infra-setup/SKILL.md and command that were removed. Repoint those to the infra-copilot plugin, and update README's directory tree to show .claude/ now holding settings.json + infra-copilot.local.md. --- AGENTS.md | 16 ++++++++-------- CONTRIBUTING.md | 2 +- README.md | 11 ++++++----- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d194e52..e04f84c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,18 +12,18 @@ No servers, no config management — everything is SaaS-shaped. See [`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, +There is an **agent-first setup skill** in the +[infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot) (`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. +- **Other harnesses:** open and follow the + [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s + `skills/infra-setup/SKILL.md` directly. It's plain Markdown plus a machine-readable + manifest (that skill's `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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa33128..02ebc6a 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. **The [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s state reference** — how Terraform state is structured and how to read HCP via API. 4. **The infra-copilot plugin's CI reference** — the required checks for any PR to land. -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: the infra-copilot plugin's setup reference, which the skill orchestrates. +5. **Bootstrap** (skip if HCP + GitHub App are already wired; you'd know). Agent-first path: run the **`infra-setup`** skill from the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot) (Claude Code: `/infra-setup`; other harnesses: follow the plugin's SKILL.md — see [`AGENTS.md`](./AGENTS.md)). By-hand path: the infra-copilot plugin's setup reference, which the skill orchestrates. ## The PR workflow diff --git a/README.md b/README.md index d91a87a..3799f66 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ Cloudflare token, GitHub App, importing existing resources, first plan — pausi 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). +- **Other harnesses (opencode, Codex, …):** open and follow the + [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s + `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, the [infra-copilot plugin](https://github.com/hasansezertasan/infra-copilot)'s @@ -33,8 +34,8 @@ 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 + settings.json enables the infra-copilot plugin (marketplace + plugin) + infra-copilot.local.md this repo's non-secret config the plugin reads at startup .github/ workflows/ fork-safe terraform fmt + validate gates docs/