Infrastructure as Code¶
Spec: 087-infrastructure-as-code · Issue: #1177
The FairWins cloud estate is described declaratively: Terraform provisions cloud resources, Ansible converges node interiors. The goal is that the environment can be reconstructed or audited from repository contents alone — no manual console changes, no undocumented drift.
Read this first¶
Three facts shape every decision here. Each has already caused, or nearly caused, a real incident.
1. The GCP project is shared¶
chippr-bots-site-wp hosts a public WordPress VM on the default network, plus clearpath-*,
fukuii-* and kings-edge-*. Nothing in this repository may describe, reference, or authoritatively
overwrite anything outside its own inventory.
The concrete trap:
| Resource | Semantics | Effect here |
|---|---|---|
google_project_iam_member |
additive — one (role, member) pair | safe |
google_project_iam_binding |
authoritative for that role | strips the role from every other principal, project-wide |
google_project_iam_policy |
authoritative for the whole policy | removes every grant not declared here, including the owners' own |
The names differ by one word and the resulting plan diff looks small and ordinary. Under automatic
apply, nobody has to click anything for it to happen. npm run check:iac rejects both
authoritative forms (G-01, G-02), and the CI identity is not granted project IAM admin so the same
mistake also fails at the API. Two layers, because either alone has a way to be wrong.
2. Some resources cannot be recreated¶
KMS key versions, secret payloads, and the static IPs pinned in Cloudflare DNS. These carry
prevent_destroy and sit outside the CI identity's permissions. prevent_destroy is a
configuration guard — a change that deletes the resource block deletes the guard with it — so the
permission layer is what holds in that case.
3. One service must stay decommissioned¶
cloudbuild.yaml no longer builds or deploys the Cloud Run alto bundler. That deletion is
load-bearing: re-arming it alongside the VM bundler puts two executors on one EOA — colliding
nonces, stuck bundles, both instances reporting healthy, and no in-band detection.
single-alto-gate.sh detects the condition within 60s and refuses to start the VM's alto, but it
cannot prevent it. Guardrail G-11 rejects any attempt to declare the service.
Layout¶
infra/terraform/
bootstrap/ run-once trust root. Local state, committed. Not in the apply workflow.
modules/ network, edge-node, cloud-run-service, cloudflare-zone, monitoring
environments/prod/ state prefix: prod
environments/staging/ state prefix: staging
infra/ansible/ roles, playbooks, dynamic inventory over IAP
scripts/infra/ the guardrail gate, the plan renderer, the prober generator
.github/workflows/ infra-plan.yml, infra-apply.yml, infra-drift.yml
One root per environment, separate state prefixes: a prod apply cannot reach staging state at all.
That is a harder boundary than -target, which applies a subgraph without refreshing the rest and
so leaves recorded state no longer describing reality (guardrail G-14 rejects it).
Who owns what¶
Every managed attribute has exactly one owner. Two owners on one attribute produces permanent false drift, and false drift is how real drift gets ignored.
| Attribute class | Owner |
|---|---|
| VPC, subnet, firewall rules, static IPs | Terraform |
| VM existence, machine type, labels, tags, attached SA, shielded settings | Terraform |
| Packages, OS hardening, nginx config, systemd units, container stack, runtime secrets | Ansible |
| Cloud Run scaling, CPU, ingress, SA, secret wiring, invoker IAM, domain mapping | Terraform |
| Cloud Run image tag and revision | Cloud Build |
| Secret containers + access bindings | Terraform |
| Secret payloads and rotation | out of band |
| KMS rings, keys, IAM | Terraform |
| KMS key versions | out of band — never managed |
| DNS, WAF ruleset, transform ruleset | Terraform |
| Monitoring channels, checks, policies, log metrics | Terraform |
Full table with rationale: specs/087-infrastructure-as-code/contracts/ownership-boundary.md.
Tooling¶
Terraform and Ansible are not part of the npm toolchain — install them separately.
# Terraform 1.15.x
curl -fsSL -o tf.zip https://releases.hashicorp.com/terraform/1.15.8/terraform_1.15.8_linux_amd64.zip
unzip tf.zip && sudo mv terraform /usr/local/bin/
# Ansible
pip install ansible-core ansible-lint
cd infra/ansible && ansible-galaxy collection install -r requirements.yml
npm run check:iac needs only Node, so the guardrail gate runs anywhere.
Everyday tasks¶
Change a service's configuration¶
- Edit the relevant module call in
infra/terraform/environments/<env>/main.tf. - Open a PR.
infra-plan.ymlposts a redacted plan summary showing the blast radius, with replacements called out separately from in-place updates. - Merge.
infra-apply.ymlapplies the plan that was reviewed — not a fresh one.
Add a resource¶
Declare it in the appropriate module (or add a module), then:
npm run check:iac
terraform -chdir=infra/terraform/environments/prod fmt -check
terraform -chdir=infra/terraform/environments/prod validate
If the resource already exists in the cloud, add an import block instead of letting Terraform
create a second one — see below.
Adopt an existing resource¶
import {
to = module.network.google_compute_address.origin["fairwins-gateway-ip"]
id = "projects/chippr-bots-site-wp/regions/us-central1/addresses/fairwins-gateway-ip"
}
Then:
terraform plan -generate-config-out=generated.tf # a STARTING POINT, never committed raw
# review, reshape into the module, delete generated.tf
terraform plan # must say "will be imported", never "must be replaced"
terraform apply
terraform plan # must say "No changes."
The last step is the definition of done. If the plan is not clean, the configuration is wrong — fix it in the repository. Never apply to force live infrastructure to match a generated body.
import blocks stay in the repository after adoption. They are the audit record of how each
resource came under management, and the recovery path if state is ever lost.
Converge a node¶
cd infra/ansible
ansible-playbook site.yml --check --diff # dry run
ansible-playbook site.yml # both nodes, one at a time
The nodes have no public SSH — the inventory tunnels through IAP. If a playbook cannot connect, fix the tunnel, never the firewall.
A second consecutive run must report changed=0. If it does not, something in a role is
non-idempotent — usually state: latest or a shell task without a truthful changed_when.
Secrets¶
Terraform manages secret containers and who may read them. It never manages payloads: a
google_secret_manager_secret_version resource writes the value into state in plaintext, and
guardrail G-04 rejects it.
There is one accepted exception, documented rather than hidden: the origin-lock header value is
read through a google_secret_manager_secret_version data source so the Cloudflare transform rule
and the origin nginx carry the same value. Data-source results are written to state —
sensitive = true hides a value from output, not from state. The alternative was a placeholder plus
a manual dashboard step, which reintroduces exactly the drift this feature removes. The gate warns
on every data-source use so the exception stays countable.
Gates¶
| Gate | Proves |
|---|---|
npm run check:iac |
the fifteen repository-specific rules in contracts/guardrails.md |
npm run test:iac-guardrails |
that each rule still fires — a gate that passes everything gets cited as evidence |
terraform fmt -check / validate |
syntax and provider schema |
tflint |
invalid arguments, deprecated usage, dead declarations |
ansible-lint + syntax check |
playbook correctness |
| double-run idempotency | a converge run changes nothing the second time |
| scheduled drift | the running estate still matches the repository |
All fail the pipeline on error. continue-on-error is forbidden on them (constitution IV).
Shared modules¶
Modules live in chippr-robotics/chippr-tf-modules
(private) and are consumed by pinned commit SHA:
source = "git::https://github.com/chippr-robotics/chippr-tf-modules.git//modules/network?ref=70498e2a2860f2e65cd2ce3919ca85d29678a1e3"
A SHA rather than a tag, because a tag can be repointed and a commit cannot. Guardrail G-16 rejects any external module source that is unpinned or pinned to a branch.
Because the repo is private, terraform init needs a credential — the workflows rewrite git's config
with TF_MODULES_TOKEN before init. A missing token shows up as repository not found, not as a
permission error, because GitHub returns 404 for private repos the caller cannot see.
The token must be owned by the ORGANISATION¶
A fine-grained PAT's resource owner is chosen when the token is created and cannot be edited
afterwards. A token owned by a personal account can never see chippr-robotics repositories — no
scope setting, no organisation policy, and no approval changes that.
GitHub reports this as 404 Not Found, not 403, because a repository you cannot see is indistinguishable from one that does not exist. That reads as "the repo is missing" or "the scope is wrong", so it sends you to fix things that were never broken. It cost four rounds here.
When creating the token, the Resource owner dropdown must be set to chippr-robotics. The token
page then shows "Access on the chippr-robotics organization". If it does not say that, the token
cannot be repaired — generate a new one.
Required settings:
| Field | Value |
|---|---|
| Resource owner | chippr-robotics |
| Repository access | Only select repositories → chippr-tf-modules |
| Repository permissions | Contents: Read-only |
The organisation must also permit fine-grained PATs, and may hold the token in a pending-approval queue. Verify before storing the secret:
It should print the commit the modules are pinned to.
Never paste a token into a screenshot or a chat¶
Treat any token that has appeared in an image, a transcript, or a log as compromised and revoke it. The secret store is the only place a token belongs.
Add new modules there, not to infra/terraform/modules/. A module kept locally is invisible to
the other Chippr projects sharing this estate and drifts from its shared twin. That directory now
holds only a pointer.
See specs/087-infrastructure-as-code/contracts/module-interfaces.md.
Related¶
docs/runbooks/infrastructure-operations.md— bootstrap, recovery, drift response, rotationinfra/ansible/README.md— node configuration specificsdocs/runbooks/vm-migration.md— how the nodes came to bespecs/087-infrastructure-as-code/— spec, research, plan, contracts, quickstart