Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 0 additions & 58 deletions .github/workflows/iam_terraform-backend-role.yml

This file was deleted.

21 changes: 11 additions & 10 deletions .github/workflows/scaleway.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
name: Terraform — Scaleway cluster

# Drives 02-cluster/scaleway through the .github/actions/terraform composite action:
# Drives 10-cluster/scaleway through the .github/actions/terraform composite action:
# - pull_request → plan
# - push to main → apply
# - schedule (daily 18h) → destroy (cost-control teardown of the homelab)
# - workflow_dispatch → up/down on demand (plan | apply | destroy)
#
# State R/W + lock uses the org state role (vars.AWS_TF_STATE_ROLE_ARN —
# AmazonS3FullAccess, see 01-iam/ci-managed/aws-state-access). Scaleway cluster
# lifecycle uses the CI API key from the `scaleway` environment (Kubernetes/VPC/
# State R/W + lock uses the org's single Terraform AWS role
# (vars.AWS_TERRAFORM_ROLE_ARN — S3 list/get/put/delete on the state bucket
# only, see 00-foundation/aws/ci-role.tf). Scaleway cluster lifecycle uses
# the CI API key from the `scaleway` environment (Kubernetes/VPC/
# PrivateNetwork FullAccess, see 01-iam/bootstrap/scaleway). Provider creds are set as job env (read by the
# action) — never passed as plain action inputs. No static AWS keys.

on:
pull_request:
paths:
- '02-cluster/scaleway/**'
- '10-cluster/scaleway/**'
- '.github/actions/terraform/**'
- '.github/workflows/scaleway.yml'
push:
branches: [main]
paths:
- '02-cluster/scaleway/**'
- '10-cluster/scaleway/**'
- '.github/actions/terraform/**'
- '.github/workflows/scaleway.yml'
schedule:
Expand Down Expand Up @@ -67,10 +68,10 @@ jobs:
steps:
- name: Assert state role ARN is present
env:
ROLE_ARN: ${{ vars.AWS_TF_STATE_ROLE_ARN }}
ROLE_ARN: ${{ vars.AWS_TERRAFORM_ROLE_ARN }}
run: |
if [ -z "$ROLE_ARN" ]; then
echo "::error::vars.AWS_TF_STATE_ROLE_ARN is not set (provisioned by 01-iam/ci-managed/aws-state-access)."
echo "::error::vars.AWS_TERRAFORM_ROLE_ARN is not set (provisioned by 00-foundation/aws)."
exit 1
fi

Expand All @@ -96,7 +97,7 @@ jobs:

- uses: ./.github/actions/terraform
with:
root: 02-cluster/scaleway
root: 10-cluster/scaleway
tfvars-file: 02-cluster-staging.tfvars
command: ${{ steps.cmd.outputs.command }}
aws-role-arn: ${{ vars.AWS_TF_STATE_ROLE_ARN }}
aws-role-arn: ${{ vars.AWS_TERRAFORM_ROLE_ARN }}
8 changes: 5 additions & 3 deletions .github/workflows/terraform-lock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ jobs:
fail-fast: false
matrix:
root:
- 00-remote_state
- 00-foundation/aws
- 01-iam/bootstrap/aws
- 01-iam/bootstrap/scaleway
- 01-iam/ci-managed/aws-state-access
- 02-cluster/scaleway
- 01-iam/workload/scaleway
- 02-encryption/aws
- 03-storage/scaleway
- 10-cluster/scaleway
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
Expand Down
70 changes: 70 additions & 0 deletions 00-foundation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 00-foundation — what this domain is for

This root module is the foundation for all the infrastructure managed by
this repository. Concretely, it requires exactly two things:

- A remote state solution (e.g. S3)
- Whatever setup lets Terraform, running from your CI/CD, access and operate
that state

That state is the foundation for everything else your infrastructure
manages — every resource you've deployed, your backups, your encryption
keys, all of it lives there.

## Why this domain matters more than its size suggests

Not just "holds a Terraform state bucket." This repo bootstraps its entire
infrastructure — including the cluster and ArgoCD, which then takes over
everything downstream — via Terraform. That choice makes remote state the
only trusted source of truth for what's actually deployed. It's also why
this domain has to be the first thing applied: every other root's backend
points at the bucket this one creates, so nothing else can even exist yet
until this does.

## The contract

A `00-foundation/<provider>` root must provide exactly these things, and
nothing that isn't required to provide them:

1. **A durable, versioned store for Terraform state**, reachable by every
other root's backend configuration. It must survive the loss of any single
developer's machine or CI runner, support recovering a corrupted/truncated
write (versioning or equivalent), and deny non-encrypted-in-transit access.
2. **Protection against concurrent writes** to the same root's state, so two
`plan`/`apply` runs racing each other can't corrupt it. A lock is the
common shape this takes; other [locking strategies](https://developer.hashicorp.com/terraform/language/state/locking)
exist.
3. **One CI identity**, trusted keylessly if the provider supports it (OIDC
or equivalent — a strong machine-to-machine auth pattern, preferred over
long-lived static credentials wherever the provider supports it), scoped
to **read/write access on the state store from step 1, and nothing else**.
In particular: no general credential- or identity-management capability. A
domain that needs its own CI identity for provider-specific reasons beyond
state R/W (e.g. `02-encryption/aws` needing KMS/IAM rights) gets its
**own**, separately and narrowly scoped identity elsewhere —
`00-foundation` is not the place that mints capability for other domains.

**Known limitation:** this identity is scoped to the whole state bucket,
not per-root. The AWS implementation trusts OIDC from exactly this repo,
on the assumption that every Terraform root in the org lives here — but
within the repo, there is no isolation between roots at the state layer.
A workflow authorized to touch one root's state can, at the
IAM-permission level, touch any other root's state too. Deliberate
trade-off (per-root scoping is more machinery than a homelab-scale org
needs), not an oversight.

4. **Must be executable locally.** This root creates the very store its own
state ends up living in — a chicken-and-egg every implementation has to
solve explicitly, and the state bucket obviously can't be relied on yet
to do it. What matters is that it can be run once, by an admin, on their
own machine, to get past that bootstrap — and ideally never needs to be
touched again after.

## Adding a new provider

A domain with only one provider still nests under it (`00-foundation/aws/`),
so a second provider (e.g. `00-foundation/scaleway/` if a state backend ever
needs to exist there) can be added later without restructuring existing
roots. Each provider's root is independent — there's no requirement that they
share a state store; each just has to satisfy the contract above for its own
provider.
49 changes: 49 additions & 0 deletions 00-foundation/aws/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 46 additions & 11 deletions 00-remote_state/README.md → 00-foundation/aws/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
# 00-remote_state — Terraform state bucket
# 00-foundation/aws — Terraform state bucket + CI's AWS access

A standalone Terraform root that provisions a single **AWS S3 bucket** to hold
the Terraform remote state for the **whole org**. Every other root
(`02-cluster/local/`, `02-cluster/scaleway/`, `01-iam/bootstrap/scaleway/`, and
future ones) points its `backend "s3"` at this bucket.
The AWS implementation of the `00-foundation` contract — see
[`../README.md`](../README.md) for what that contract is and why this domain
is named after its role, not "remote_state" or "aws". Read that first; this
file is the how, not the why.

It is the shared substrate every other root depends on — the `00-remote_state`
domain (a single-root domain, so it is flattened: the domain folder *is* the
root), applied by an admin.
It provisions:

1. A single **AWS S3 bucket** holding the Terraform remote state for the
**whole org**. Every other root points its `backend "s3"` at this bucket.
2. The **GitHub OIDC provider** + the **one AWS IAM role**
(`terraform-state-access`) every GitHub Actions workflow in this repo
assumes to read/write that bucket — see [CI's AWS access](#cis-aws-access).

Merged from three former roots (`00-remote_state`, `01-iam/bootstrap/aws`,
`01-iam/ci-managed/aws-state-access`) that were all fundamentally the same
foundation concern living in separate places for historical reasons. Applied
by an admin (this root creates the very identity CI would otherwise need to
apply it).

## What it creates

### The state bucket

A single S3 bucket (default name `id-terraform-state`, override with
`-var bucket_name=...`) via the community
[`terraform-aws-modules/s3-bucket`](https://registry.terraform.io/modules/terraform-aws-modules/s3-bucket/aws/latest)
Expand All @@ -31,6 +43,29 @@ State **locking** uses Terraform's native S3 lockfile (`use_lockfile`, GA since
Terraform 1.10) — a `.tflock` object written next to the state. No DynamoDB lock
table is needed.

### CI's AWS access

`ci-role.tf` creates the GitHub OIDC provider and one role,
`terraform-state-access` — trusted via OIDC scoped to
`repo:IntegratedDynamic/infrastructure:*` only, with an inline policy granting
**exactly** `s3:ListBucket`/`GetBucketVersioning`/`GetBucketLocation` on the
bucket and `s3:GetObject`/`PutObject`/`DeleteObject` on its contents. Nothing
else — no IAM management capability, no ability to create or modify any other
role or policy.

This replaces two former roots that together built a much larger "CI can
safely mint further IAM roles" system: a permissions boundary ("admin minus a
hardened deny-list") plus a policy letting the CI role create/attach other
roles under a managed path, specifically so it could mint the one role that
actually did the state R/W. That entire guardrail had exactly one consumer.
Once the role's own job is narrowed to "read/write this bucket," there's no
IAM-management capability left to guard against escalating in the first
place, so the guardrail system is gone along with it.

Every workflow in `.github/workflows/` assumes this one role (via
`vars.AWS_TERRAFORM_ROLE_ARN`) for every root's `plan`/`apply`/`destroy` — see
the composite action in `.github/actions/terraform/`.

## Credentials

The AWS provider **and** the S3 backend resolve credentials through the standard
Expand Down Expand Up @@ -58,13 +93,13 @@ This root creates the very bucket it then stores its state in. Bootstrap order:
2. Apply once with **local state** — temporarily comment out the `backend "s3"`
block in `version.tf` so the bucket gets created:
```bash
terraform -chdir=00-remote_state init
terraform -chdir=00-remote_state apply # creates the bucket (billable)
terraform -chdir=00-foundation/aws init
terraform -chdir=00-foundation/aws apply # creates the bucket (billable)
```
3. Re-add the `backend "s3"` block and migrate the local state into the bucket
it now manages:
```bash
terraform -chdir=00-remote_state init -migrate-state
terraform -chdir=00-foundation/aws init -migrate-state
```

After that, this root's own state lives at `state-backend/terraform.tfstate`
Expand Down
Loading
Loading