diff --git a/.github/workflows/terraform-lock.yml b/.github/workflows/terraform-lock.yml index 2d7d55e..dd0f334 100644 --- a/.github/workflows/terraform-lock.yml +++ b/.github/workflows/terraform-lock.yml @@ -23,7 +23,7 @@ jobs: fail-fast: false matrix: root: - - state-backend + - terraform-state-bucket - cluster/local - cluster/scaleway - github-ci @@ -39,6 +39,7 @@ jobs: - name: Re-lock providers for both platforms and assert no drift run: | + terraform -chdir=${{ matrix.root }} init -backend=false terraform -chdir=${{ matrix.root }} providers lock \ -platform=darwin_arm64 -platform=linux_amd64 git diff --exit-code -- ${{ matrix.root }}/.terraform.lock.hcl diff --git a/CLAUDE.md b/CLAUDE.md index 1345c76..3ef8a5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ mise run dev # Full local env: start minikube + terraform init + app mise run reset # Destroy minikube cluster # Provider lock files -mise run lock # Re-generate all 3 roots' .terraform.lock.hcl for darwin_arm64 + linux_amd64 +mise run lock # Re-generate all 4 roots' .terraform.lock.hcl for darwin_arm64 + linux_amd64 # Linting actionlint .github/workflows/*.yml # Lint GitHub Actions workflows (also runs as pre-push hook) @@ -34,36 +34,45 @@ The `.terraform.lock.hcl` in each root must cover **both** `darwin_arm64` (local `mise run lock` is equivalent to: ```bash -terraform -chdir=state-backend providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=github-ci providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=terraform-state-bucket providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=github-ci providers lock -platform=darwin_arm64 -platform=linux_amd64 ``` Commit the updated lock files alongside the version change. ## Architecture -Two cluster environments, each its own Terraform root module: +Several Terraform root modules. One (./terraform-state-bucket) manage the shared terraform state bucket; any +others cloud-based environments will store it's data on it. ``` +terraform-state-bucket/ # shared AWS S3 bucket holding every root's remote state cluster/ - local/ # minikube — local dev and debugging + local/ # minikube — local dev and debugging. Using local backend (e.g. local files) scaleway/ # Scaleway Kapsule cluster + ArgoCD bootstrap (homelab; WIP, not yet wired into mise) +github-ci/ # Scaleway IAM identity GitHub Actions authenticates with ``` +Otherwise, this repo, for now, is an agregate of terraform root modules without specific structure yet. + +### `./clusters/*` + +Terraform here is only a **one-time bootstrapper** — everything after ArgoCD is up lives in the `gitops` repo. The cluster internal state nor status will be reflected in the terraform state. + ### `cluster/local/` +Warning : This environment expect you an accessible local kubernetes cluster access, likely configured within your ~/.kube/config. This is automatically handled via `mise run dev` + Two-step, one-time bootstrap: 1. Fetch secrets from **Infisical** (universal auth machine identity). Credentials come from `nico.auto.tfvars` (per-developer, not shared). 2. Deploy **ArgoCD** via Helm with the admin bcrypt password hash from Infisical (pre-hashed to prevent Terraform drift). -3. Deploy the **argocd-apps bootstrap** chart, pointing ArgoCD at `https://github.com/IntegratedDynamic/gitops.git`. ArgoCD then self-manages all further cluster state from that separate GitOps repo. - -Terraform here is only a **one-time bootstrapper** — everything after ArgoCD is up lives in the `gitops` repo. +3. Deploy the **argocd-apps bootstrap** Application, pointing ArgoCD at `https://github.com/IntegratedDynamic/gitops.git`. ArgoCD then self-manages all further cluster state from that separate GitOps repo. ### `cluster/scaleway/` -Same bootstrap pattern as `local/`, plus the Kapsule cluster + node pool (`DEV1-M`, min=0/max=3) in one consolidated module. Writes the kubeconfig to `~/.kube/scaleway-homelab.yaml`. Scaleway credentials are read from the `scw` CLI config (`~/.config/scw/config.yaml`), not from tfvars. Still early — intentionally undocumented in the commands above for now. +Same bootstrap pattern as `local/`, but with the Kapsule cluster + node pool (`DEV1-M`, min=0/max=3) instead. ### `github-ci/` diff --git a/README.md b/README.md index f7f4ee9..fd8a8c6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # Infrastructure +This infrastructure contains the actual ScalePack infrastructure. + +All remote based environment states are stored under `./terraform-state-bucket` bucket. Check `version.tf` files from any root module in this repository for an example, stored under eu-west-3 region. + +## Forking + +Forking support process coming soon. + ## Onboarding ### Installation des dépendances @@ -17,3 +25,49 @@ Cette commande installe toutes les dépendances du projet définies dans `mise.t ``` Cela configure les git hooks locaux pour automatiser les vérifications avant les commits/pushes. + +### Login with your credentials + +> Skip 2 and more if you only need local environment. 1 is kept because right now, Infisical SSO features are PayGated, but allow long lived credentials within your environment. All hail long lived credentials! (Don't forget to look into your own local secret encryption solution to avoid storing them in clear. In my case, macOS built-in's `security` binary is enough. See https://dev.to/alsaheem/how-to-store-secrets-in-the-mac-keychain-and-use-them-like-environment-variables-1aj7). + +1. Infisical + + Why Infisical? It's a free SSO organization solution for External Infrastructure Secrets. Will ultimately rely on External Secret Operator to synchronize over things. Somehow. + 1. Login to : https://app.infisical.com/login with SSO + 2. Create API_KEYS for your local environment. + 3. Create other long lived credentials you want for your infrastructure in this External Storage SaaS solution. + +2. AWS as terraform remote backend solution : + + Why AWS S3? It's extremely cheap, great DevX for mono storage management (fine grained IAM policies) and AWS includes free SSO setup for devops without friction. And Scaleway doesn't provide OIDC yet, which I do favor when it comes to CI runners, especially for such high-risk files. + + Run `aws sso login`. This needs to be done daily and at bootstrap before running any terraform command locally, or state fetch will fail. + + #### AWS SSO first setup: + + 1. Go to https://d-806761a6d4.awsapps.com/start/#/ + + 2. Login with your credentials. Contact your manager or an admin if you didn't get one yet via email. + + 3. Follow instructions for an AccessKey, and follow SSO setup instructions. + + Public documentation available here : https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html#sso-configure-profile-token-auto-sso + + +2. Scaleway as cloud provider + > Scaleway is the current provider I want to use, because it offers : + > 1. A cheap way to operate Kubernetes clusters without too many issues. I mean, I want to play with Kubernetes, and this GitHub org is my homelab. And I can have as many clusters as I want; as long as there's no node within, I won't pay. It's a great FinOps safeguard which I do want to take advantage of. Deal with it. + > 2. As an EU citizen, I disapprove of the Cloud Act and any of those attacks against security, which in the end create a backdoor. I see those legalized actions as one. And yes, my supply chain includes storing extremely sensitive data in some of those companies (Infisical in free mode, Google, AWS...). At least I intend to use Scaleway, and have I told you about their DevX around Kubernetes managed service? It's within the EU, and not exposed to that, at least. That's my attempt toward sovereign data. With love <3 + + #### Scaleway CLI/terraform bootstrap + 1. Log in with SSO with Scaleway : https://console.scaleway.com/organization + + 2. Go to https://console.scaleway.com/iam/users + + 3. Create an API KEY. + + 4. [Optional] : I recommend one dedicated API KEY per tool. But unlike AWS, there is no rich default option with the Scaleway CLI that works out of the box, which is one of my requirements (passwordless/secretless CLI access for humans via SSO + passwordless/secretless for machines via OIDC). But, free kube, okay? Just grab your API keys — one for AWS, another for Terraform in external runtime contexts (kube, GitHub Actions, ...). This at least distinguishes local usage from "safe/legit" remote usage such as organization trusted runtimes. In the end, the more fine-grained, the better, but I'm not at that level yet :D + +### Start playing + +[WIP] This section will be added soon, and will explain how to start/stop the cluster nodes (and whatever makes me pay for something), in order to stop paying the bill, such as whenever I should sleep, or should not work. But right now, it's manual, and I will not explain it. Homelab, remember? diff --git a/cluster/scaleway/version.tf b/cluster/scaleway/version.tf index ba2d478..da0e2c5 100644 --- a/cluster/scaleway/version.tf +++ b/cluster/scaleway/version.tf @@ -1,22 +1,12 @@ terraform { # Remote state in the org-wide bucket (state-backend/). Creds: see mise.toml. backend "s3" { - bucket = "id-terraform-state" - key = "cluster/scaleway/terraform.tfstate" - region = "fr-par" - - # Root-specific prefix so this root's workspaces don't mix with others'. + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" workspace_key_prefix = "cluster/scaleway" - - endpoints = { s3 = "https://s3.fr-par.scw.cloud" } - - # Disable the backend's AWS-only preflight checks (IMDS, STS account-id, - # region allowlist): Scaleway speaks the S3 API but isn't AWS itself. - skip_credentials_validation = true - skip_region_validation = true - skip_requesting_account_id = true - skip_metadata_api_check = true - skip_s3_checksum = true + key = "terraform.tfstate" + encrypt = true + use_lockfile = true } required_providers { diff --git a/github-ci/version.tf b/github-ci/version.tf index 994ed72..ade146e 100644 --- a/github-ci/version.tf +++ b/github-ci/version.tf @@ -1,24 +1,15 @@ terraform { - # Remote state in the org-wide bucket (state-backend/). Creds: see mise.toml. backend "s3" { - bucket = "id-terraform-state" - key = "github-ci/terraform.tfstate" - region = "fr-par" - - # Root-specific prefix so this root's workspaces don't mix with others'. + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" workspace_key_prefix = "github-ci" - - endpoints = { s3 = "https://s3.fr-par.scw.cloud" } - - # Disable the backend's AWS-only preflight checks (IMDS, STS account-id, - # region allowlist): Scaleway speaks the S3 API but isn't AWS itself. - skip_credentials_validation = true - skip_region_validation = true - skip_requesting_account_id = true - skip_metadata_api_check = true - skip_s3_checksum = true + key = "terraform.tfstate" + encrypt = true + use_lockfile = true } + + required_providers { scaleway = { source = "scaleway/scaleway" diff --git a/mise.toml b/mise.toml index 590e56e..fc22d02 100644 --- a/mise.toml +++ b/mise.toml @@ -6,15 +6,6 @@ actionlint = "1.7" helm = "4.1.3" argocd = "3.3.6" -# The s3 state backend (state-backend/) needs AWS-style creds — the Scaleway -# provider reads the scw CLI config, the backend does not. Derive them here so -# terraform just works; `|| true` keeps the local/minikube workflow usable -# without scw configured (empty vars, which the local backend ignores). -# https://registry.terraform.io/providers/scaleway/scaleway/2.75.0/docs/guides/backend_guide -[env] -AWS_ACCESS_KEY_ID = "{{ exec(command='scw config get access-key 2>/dev/null || true') }}" -AWS_SECRET_ACCESS_KEY = "{{ exec(command='scw config get secret-key 2>/dev/null || true') }}" - # ── Local (minikube) ──────────────────────────────────────────────────────── [tasks.minikube] @@ -117,8 +108,8 @@ run = "terraform apply" [tasks.lock] description = "Re-generate all .terraform.lock.hcl files for darwin_arm64 + linux_amd64" run = """ -terraform -chdir=state-backend providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=github-ci providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=terraform-state-bucket providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=github-ci providers lock -platform=darwin_arm64 -platform=linux_amd64 """ diff --git a/state-backend/.terraform.lock.hcl b/state-backend/.terraform.lock.hcl deleted file mode 100644 index f4eb038..0000000 --- a/state-backend/.terraform.lock.hcl +++ /dev/null @@ -1,24 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/scaleway/scaleway" { - version = "2.76.0" - constraints = "~> 2.0" - hashes = [ - "h1:3RImo3Jf88dXcIqmBRuO/A85jAxrAQsvYa4zhVqW1tI=", - "h1:ktRogBsJlCf3JTOTYPm9hKaPUzaps0aLwxsNfP155ns=", - "zh:13b6790dca2c91c7478d6e9cd03d84713e0b0ec001c9923d3c93bd12a1f4152f", - "zh:219582ef77ec6f27d2928684b95603b5a921001631f9eec68c14819fdbc1efea", - "zh:26bc09c7aadc49fe83a9d6af9c1d0951f93de129f90d04e5e65e1d82c8ac1914", - "zh:4015a970be3669ee009344afb269a09eaf9fe1363a12a10d3311326ba769463a", - "zh:4c1c5997ef4182e49e46ff6563581a90b5cfa30f0ec8d7d919b3dc0603ed3043", - "zh:58d91e08a8fe38ddda2c03013d1ba77ff4e21a91fef0a425575b5af7bf7f4ebc", - "zh:644a61f963483c8eba7f8427650c4956e1d8c59710a11bb2691ad8996ee14a9c", - "zh:6745d5d80006c375b104a005cfc007f90e2cb547cf9e96c886d453be3307a399", - "zh:8dcac392ca182af40b823c6721833de1325c279b1e5bdcddf1a1cf2789f3558a", - "zh:9eb284d3e9a14d4d64e2a7a865be45ec0eee32ec16c51bcc1722c0449bfb9fab", - "zh:a4eb4973cc1d9ac4911733d62f5b0e53fbc7b90112efa312918c0320966e276e", - "zh:ce58ae8014b2981bbc875bc7ad4cdeb93957370bfa4308eeb193155ee988fbec", - "zh:d3f0f3bec0a6f4759948749cdb0eb64e4415507a82c79659f1ede894f96f4976", - ] -} diff --git a/state-backend/README.md b/state-backend/README.md deleted file mode 100644 index c636a1f..0000000 --- a/state-backend/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# state-backend - -A standalone Terraform root that provisions a single **Scaleway Object Storage -bucket** to hold the Terraform remote state for the **whole org**. Every other -root (`cluster/local/`, `cluster/scaleway/`, and future ones) points its -`backend "s3"` at this bucket — Scaleway's Object Storage is S3-compatible. - -This is intentionally **not** under `cluster/` because it provisions no cluster; -it is the shared substrate the cluster roots depend on. It lives at the repo -root as its own root module. - -## What it creates - -- `scaleway_object_bucket.tfstate` — the state bucket (default name - `id-terraform-state`, override with `-var bucket_name=...`). Bucket names are - globally unique across Scaleway. - - **Versioning enabled** — lets you recover from a corrupt/truncated state push. - - **Lifecycle rule** — expires *noncurrent* (superseded) versions after 10 days - (override with `-var noncurrent_version_expiration_days=...`); the current - version is never expired. - - **`prevent_destroy`** — guards against accidentally deleting everyone's state. -- `scaleway_object_bucket_acl.tfstate` — `private` ACL, which keeps the bucket - and all its objects unreachable by anonymous/public requests. - -## Credentials - -Like `cluster/scaleway/`, the Scaleway provider reads credentials and the -default region/project from the **scw CLI config** (`~/.config/scw/config.yaml`). -Nothing is set in tfvars. - -## Bootstrapping (chicken-and-egg) - -This root creates the very bucket it then stores its state in. Bootstrap order: - -1. Apply once with **local state** (comment out / remove the `backend "s3"` block - in `version.tf`) so the bucket gets created: - ```bash - terraform -chdir=state-backend init - terraform -chdir=state-backend apply # creates the bucket (billable) - ``` -2. Re-add the `backend "s3"` block (already present in `version.tf`) and migrate - the local state into the bucket it now manages: - ```bash - terraform -chdir=state-backend init -migrate-state - ``` - -After that, this root's own state lives at `state-backend/terraform.tfstate` -inside the bucket, just like every other root. - -## Pointing another root at this bucket - -Add a `backend "s3"` block to the consuming root and run `terraform init` -(`-migrate-state` if it already has local state). Give each root a distinct -`key` *and* `workspace_key_prefix` so its state (and workspaces) stay separate -inside the one shared bucket. - -```hcl -terraform { - backend "s3" { - bucket = "id-terraform-state" - key = "cluster/scaleway/terraform.tfstate" # per-root; pick a unique path - region = "fr-par" - - # Per-root prefix; non-default workspaces land under //. - workspace_key_prefix = "cluster/scaleway" - - endpoints = { s3 = "https://s3.fr-par.scw.cloud" } - - # Disable the backend's AWS-only preflight checks (IMDS, STS account-id, - # region allowlist): Scaleway speaks the S3 API but isn't AWS itself. - skip_credentials_validation = true - skip_region_validation = true - skip_requesting_account_id = true - skip_metadata_api_check = true - skip_s3_checksum = true - } -} -``` - -> **Note — `bucket_name` and `region` do not flow into the backend.** Terraform -> backend blocks cannot reference variables, so `bucket`, `region` and the s3 -> `endpoints` URL are hardcoded in every `backend "s3"` block (including this -> root's own `version.tf`). The `bucket_name` / `region` variables only affect -> the bucket *resource*. If you ever change the bucket name or region, you must -> update those backend blocks by hand — otherwise Terraform reads/writes state -> against the old location. In practice you don't rename the state bucket often, -> so this is a minor day-to-day caveat. - -The S3 backend authenticates with AWS-style env vars (it does **not** read the -scw CLI config, unlike the Scaleway provider). Derive them from your scw config -before `init`/`plan`/`apply`: - -```bash -export AWS_ACCESS_KEY_ID="$(scw config get access-key)" -export AWS_SECRET_ACCESS_KEY="$(scw config get secret-key)" -``` - -The repo's `mise.toml` already injects these via an `[env]` block, so under mise -(`mise run ...`, or any shell with `mise activate`) it's handled automatically. diff --git a/state-backend/main.tf b/state-backend/main.tf deleted file mode 100644 index 90bad0e..0000000 --- a/state-backend/main.tf +++ /dev/null @@ -1,38 +0,0 @@ -# Holds the Terraform remote state for the whole org (see README.md). -resource "scaleway_object_bucket" "tfstate" { - name = var.bucket_name - region = var.region - - # Recover a corrupt/truncated state push by rolling back to a prior version. - versioning { - enabled = true - } - - lifecycle_rule { - id = "expire-noncurrent-state-versions" - enabled = true - - # Only noncurrent versions expire; the current state is always kept. - noncurrent_version_expiration { - noncurrent_days = var.noncurrent_version_expiration_days - } - } - - # Deleting this would orphan every root's state. - lifecycle { - prevent_destroy = true - } - - tags = { - purpose = "terraform-remote-state" - terraform = "true" - } -} - -# Separate resource (inline `acl` is deprecated). `private` keeps the bucket and -# its objects unreadable anonymously — state can hold sensitive values. -resource "scaleway_object_bucket_acl" "tfstate" { - bucket = scaleway_object_bucket.tfstate.id - acl = "private" - region = var.region -} diff --git a/state-backend/outputs.tf b/state-backend/outputs.tf deleted file mode 100644 index 35eab49..0000000 --- a/state-backend/outputs.tf +++ /dev/null @@ -1,14 +0,0 @@ -output "bucket_name" { - description = "State bucket name." - value = scaleway_object_bucket.tfstate.name -} - -output "region" { - description = "State bucket region." - value = var.region -} - -output "endpoint" { - description = "S3-compatible endpoint for the bucket's region." - value = "https://s3.${var.region}.scw.cloud" -} diff --git a/state-backend/variables.tf b/state-backend/variables.tf deleted file mode 100644 index 32130d5..0000000 --- a/state-backend/variables.tf +++ /dev/null @@ -1,17 +0,0 @@ -variable "bucket_name" { - description = "Globally-unique name of the Object Storage bucket holding the org's Terraform remote state. Bucket names are unique across all of Scaleway, so override this if it collides." - type = string - default = "id-terraform-state" -} - -variable "region" { - description = "Scaleway region the state bucket lives in. Drives the S3-compatible endpoint (https://s3..scw.cloud) other roots point their backend at." - type = string - default = "fr-par" -} - -variable "noncurrent_version_expiration_days" { - description = "Number of days after which NONCURRENT (superseded) state versions are expired. The current version is never expired — this only trims the history so it doesn't grow unbounded." - type = number - default = 10 -} diff --git a/state-backend/version.tf b/state-backend/version.tf deleted file mode 100644 index 91cfd07..0000000 --- a/state-backend/version.tf +++ /dev/null @@ -1,31 +0,0 @@ -terraform { - # Remote state in the bucket this root itself manages. Creds: see mise.toml. - backend "s3" { - bucket = "id-terraform-state" - key = "state-backend/terraform.tfstate" - region = "fr-par" - - # Root-specific prefix so this root's workspaces don't mix with others'. - workspace_key_prefix = "state-backend" - - endpoints = { s3 = "https://s3.fr-par.scw.cloud" } - - # Disable the backend's AWS-only preflight checks (IMDS, STS account-id, - # region allowlist): Scaleway speaks the S3 API but isn't AWS itself. - skip_credentials_validation = true - skip_region_validation = true - skip_requesting_account_id = true - skip_metadata_api_check = true - skip_s3_checksum = true - } - - required_providers { - scaleway = { - source = "scaleway/scaleway" - version = "~> 2.0" - } - } -} - -# Creds, region and project_id come from the scw CLI config (like cluster/scaleway/). -provider "scaleway" {} diff --git a/terraform-state-bucket/.terraform.lock.hcl b/terraform-state-bucket/.terraform.lock.hcl new file mode 100644 index 0000000..247235c --- /dev/null +++ b/terraform-state-bucket/.terraform.lock.hcl @@ -0,0 +1,27 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.50.0" + constraints = "~> 6.0, >= 6.42.0" + hashes = [ + "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", + "h1:mNg4vBXXqbO0hY2jCxhOyKVrnjEO0viTG2EY4oAlWaQ=", + "zh:0072806bb262c6d86bc25b4a75750e469881144c14818afdba7b82db840e1588", + "zh:1ebc2dae335dad7a8b16a1985b69a63a14954282bb44fdba7d5103f77551ac7b", + "zh:2dab48fe8f3193b8216d578ac1e3674fa566435cc7dbce2953d55b72e31d0241", + "zh:2fc3d3029c2b7429472391ef339672e1fca8e6ff32c8a519bf3acedafa7e24fe", + "zh:38a36e64e7212f6cedac861ea4d449cce07131b3378de601bf9d49a99e000208", + "zh:3ac70758ed251ce78b7f541a5a79cc6fe56474412783ae1decef719bdd0f30bf", + "zh:4385d3903e685bddb2b8005b4eb7db89f030267d4d03c7d792d2f5e739cc874a", + "zh:4cce0760b87fbafd51f30faec2a737f4183b7c615f4a86557f7d3c893a610dc5", + "zh:4feaeed18694239b896c6415d9a1e5ef89e1da4f4ad60924aa0522adeb1f6599", + "zh:502fca2be1c95f443c3e67d0555601d1de65b4ca82d197c059e9c868360e3a0a", + "zh:57d037f6fdd045f2660909c3bdface9622d81165ce647479cba98d1f353c5eab", + "zh:5dc5a0b915c2ac5256d909458f5c8e40b35f78b3a36ea893c86624eaf6c54e37", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b84c87c58a320adbb2c74a4cad03ae5aac7f2eae21db26f00fdde98c8c4d4523", + "zh:c895f1d5cbcbeff77850ac99efd36bde0048d4e909b296882331b9b9ebf48cfa", + "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", + ] +} diff --git a/terraform-state-bucket/README.md b/terraform-state-bucket/README.md new file mode 100644 index 0000000..5ca8999 --- /dev/null +++ b/terraform-state-bucket/README.md @@ -0,0 +1,102 @@ +# terraform-state-bucket + +A standalone Terraform root that provisions a single **AWS S3 bucket** to hold +the Terraform remote state for the **whole org**. Every other root +(`cluster/local/`, `cluster/scaleway/`, `github-ci/`, and future ones) points +its `backend "s3"` at this bucket. + +This is intentionally **not** under `cluster/` because it provisions no cluster; +it is the shared substrate the other roots depend on. It lives at the repo root +as its own root module. + +## What it creates + +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) +module, configured for state storage: + +- **Versioning enabled** — recover from a corrupt/truncated state push. +- **SSE-S3 encryption** (AES256) — every object encrypted at rest, no KMS key to + manage. +- **`BucketOwnerEnforced`** + **public-access block** — ACLs disabled and all + public access blocked; the bucket and its objects are unreachable anonymously. +- **TLS-only bucket policy** — non-HTTPS requests are denied. +- **Lifecycle rule** — expires *noncurrent* (superseded) versions after 10 days + (override with `-var noncurrent_version_expiration_days=...`); the current + version is never expired. +- **`force_destroy = false`** — guards against deleting everyone's state. + +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. + +## Credentials + +The AWS provider **and** the S3 backend resolve credentials through the standard +AWS SDK chain — nothing is hardcoded in the `.tf`. The same code authenticates +two different ways depending on where it runs: + +| | Source | Setup | +|---|---|---| +| **Local** | AWS SSO | `aws sso login --profile infrastructure` then `export AWS_PROFILE=infrastructure` | +| **CI** | GitHub OIDC | `aws-actions/configure-aws-credentials` exchanges the OIDC token for STS creds and exports them as env vars | + +Because STS env vars sit at the top of the SDK chain, CI needs no provider +changes — only the `id-token: write` permission and the configure-credentials +step in the workflow. + +## Bootstrapping (chicken-and-egg) + +This root creates the very bucket it then stores its state in. Bootstrap order: + +1. Log in and select the profile: + ```bash + aws sso login --profile infrastructure + export AWS_PROFILE=infrastructure + ``` +2. Apply once with **local state** — temporarily comment out the `backend "s3"` + block in `version.tf` so the bucket gets created: + ```bash + terraform -chdir=terraform-state-bucket init + terraform -chdir=terraform-state-bucket 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=terraform-state-bucket init -migrate-state + ``` + +After that, this root's own state lives at `state-backend/terraform.tfstate` +inside the bucket, just like every other root. + +## Pointing another root at this bucket + +Add a `backend "s3"` block to the consuming root and run `terraform init` +(`-migrate-state` if it already has local state). Give each root a distinct +`key` *and* `workspace_key_prefix` so its state (and workspaces) stay separate +inside the one shared bucket. + +```hcl +terraform { + backend "s3" { + bucket = "id-terraform-state" + key = "cluster/scaleway/terraform.tfstate" # per-root; pick a unique path + region = "eu-west-3" + + # Per-root prefix; non-default workspaces land under //. + workspace_key_prefix = "cluster/scaleway" + + encrypt = true + use_lockfile = true + } +} +``` + +> **Note — `bucket_name` and `region` do not flow into the backend.** Terraform +> backend blocks cannot reference variables, so `bucket` and `region` are +> hardcoded in every `backend "s3"` block (including this root's own +> `version.tf`). The variables only affect the bucket *resource*. If you ever +> change the bucket name or region, you must update those backend blocks by +> hand. In practice you don't rename the state bucket often, so this is a minor +> day-to-day caveat. diff --git a/terraform-state-bucket/main.tf b/terraform-state-bucket/main.tf new file mode 100644 index 0000000..7416255 --- /dev/null +++ b/terraform-state-bucket/main.tf @@ -0,0 +1,60 @@ +# Holds the Terraform remote state for the whole org (see README.md). +# Uses the community terraform-aws-modules/s3-bucket module. +module "tfstate_bucket" { + source = "terraform-aws-modules/s3-bucket/aws" + version = "~> 5.0" + + bucket_prefix = var.bucket_prefix + + # Recover a corrupt/truncated state push by rolling back to a prior version. + versioning = { + enabled = true + } + + # Encrypt every object at rest (SSE-S3, AES256 — no KMS key to manage). + server_side_encryption_configuration = { + rule = { + apply_server_side_encryption_by_default = { + sse_algorithm = "AES256" + } + } + } + + # ACLs disabled, bucket owner owns everything — the modern replacement for a + # `private` ACL. Combined with the public-access block below, the bucket and + # its objects are unreachable anonymously (state can hold sensitive values). + control_object_ownership = true + object_ownership = "BucketOwnerEnforced" + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + + # Reject any non-TLS request to the state — defence in depth for secrets. + attach_deny_insecure_transport_policy = true + + # Guard against accidentally deleting everyone's state: force_destroy stays + # off, so `terraform destroy` fails while objects remain. + force_destroy = false + + lifecycle_rule = [ + { + id = "expire-noncurrent-state-versions" + status = "Enabled" + + # Empty filter == applies to the whole bucket. + filter = {} + + # Only noncurrent (superseded) versions expire; the current state is kept. + noncurrent_version_expiration = { + days = var.noncurrent_version_expiration_days + } + } + ] + + tags = { + purpose = "terraform-remote-state" + terraform = "true" + } +} diff --git a/terraform-state-bucket/outputs.tf b/terraform-state-bucket/outputs.tf new file mode 100644 index 0000000..5d48210 --- /dev/null +++ b/terraform-state-bucket/outputs.tf @@ -0,0 +1,14 @@ +output "bucket_name" { + description = "State bucket name." + value = module.tfstate_bucket.s3_bucket_id +} + +output "bucket_arn" { + description = "State bucket ARN." + value = module.tfstate_bucket.s3_bucket_arn +} + +output "region" { + description = "State bucket region." + value = var.region +} diff --git a/terraform-state-bucket/variables.tf b/terraform-state-bucket/variables.tf new file mode 100644 index 0000000..87280d1 --- /dev/null +++ b/terraform-state-bucket/variables.tf @@ -0,0 +1,17 @@ +variable "bucket_prefix" { + description = "prefix of the S3 bucket holding the org's Terraform remote state." + type = string + default = "id-terraform-state" +} + +variable "region" { + description = "AWS region the state bucket lives in. Must match the `region` hardcoded in every consuming root's backend \"s3\" block." + type = string + default = "eu-west-3" +} + +variable "noncurrent_version_expiration_days" { + description = "Number of days after which NONCURRENT (superseded) state versions are expired. The current version is never expired — this only trims the history so it doesn't grow unbounded." + type = number + default = 10 +} diff --git a/terraform-state-bucket/version.tf b/terraform-state-bucket/version.tf new file mode 100644 index 0000000..d40a006 --- /dev/null +++ b/terraform-state-bucket/version.tf @@ -0,0 +1,28 @@ +terraform { + # Remote state lives in the very bucket this root manages (chicken-and-egg: + # see README.md for the one-time local-state bootstrap). + backend "s3" { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + workspace_key_prefix = "state-backend" + key = "terraform.tfstate" + encrypt = true + use_lockfile = true + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +# Credentials are resolved by the AWS SDK chain — NOT hardcoded here: +# - local: `aws sso login` -> The provider will default on your sso session +# - CI: GitHub OIDC -> aws-actions/configure-aws-credentials sets env vars, which will default there too. + +# Any environment with ~/.aws or AWS_* env vars properly configured will work +provider "aws" { + region = var.region +}