From 920ae3cca95c7cb047222a8f5a88f597f5d3939a Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Thu, 18 Jun 2026 17:28:35 +0200 Subject: [PATCH 1/2] chore: restructure terraform roots by domain with strata prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize all Terraform roots into a domain/strata layout: - state/ → 00-remote_state/ (backend S3 bucket + S3 lister IAM role) - aws-github-oidc/ → 01-iam/aws/ (GitHub OIDC + role-creator) - github-ci/ → 01-iam/scaleway/ (Scaleway IAM identity for CI) - cluster/local/ → 02-cluster/local/ - cluster/scaleway/ → 02-cluster/scaleway/ (rewritten with ArgoCD bootstrap) Each root gets an env/ folder replacing the old workspace/ convention. Consolidate CI workflows (drop aws-auth-check, scaleway-auth-check, s3-lister-role; add iam_terraform-backend-role). Update CLAUDE.md, README.md, and mise.toml to reflect the new layout. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/aws-auth-check.yml | 46 ----- .../workflows/iam_terraform-backend-role.yml | 75 ++++++++ .github/workflows/s3-lister-role.yml | 50 ----- .github/workflows/scaleway-auth-check.yml | 48 ----- .github/workflows/scaleway-plan.yml | 46 +++-- .github/workflows/terraform-lock.yml | 11 +- .../0-backend}/.terraform.lock.hcl | 0 .../0-backend}/README.md | 15 +- .../env/00-remote-state-backend.tfvars | 3 + .../0-backend}/main.tf | 0 .../0-backend}/outputs.tf | 0 .../0-backend}/variables.tf | 0 .../0-backend}/version.tf | 9 +- .../1-iam}/.terraform.lock.hcl | 0 .../1-iam/env/00-remote-state-iam.tfvars | 2 +- .../1-iam}/main.tf | 12 +- .../1-iam}/outputs.tf | 0 .../1-iam}/variables.tf | 8 +- .../1-iam}/version.tf | 9 +- .../aws}/.terraform.lock.hcl | 0 {aws-github-oidc => 01-iam/aws}/README.md | 16 +- {aws-github-oidc => 01-iam/aws}/iam-ci.tf | 0 {aws-github-oidc => 01-iam/aws}/main.tf | 2 +- {aws-github-oidc => 01-iam/aws}/outputs.tf | 0 {aws-github-oidc => 01-iam/aws}/variables.tf | 0 {aws-github-oidc => 01-iam/aws}/version.tf | 7 +- .../scaleway}/.terraform.lock.hcl | 0 {github-ci => 01-iam/scaleway}/README.md | 14 +- 01-iam/scaleway/env/01-iam-scaleway.tfvars | 6 + {github-ci => 01-iam/scaleway}/main.tf | 24 +-- {github-ci => 01-iam/scaleway}/outputs.tf | 4 +- {github-ci => 01-iam/scaleway}/variables.tf | 11 -- 01-iam/scaleway/version.tf | 42 +++++ .../local/.terraform.lock.hcl | 0 02-cluster/local/env/02-cluster-local.tfvars | 8 + {cluster => 02-cluster}/local/main.tf | 0 {cluster => 02-cluster}/local/variables.tf | 0 {cluster => 02-cluster}/local/version.tf | 0 .../scaleway/.terraform.lock.hcl | 0 02-cluster/scaleway/argocd.tf | 82 +++++++++ .../scaleway/env/02-cluster-staging.tfvars | 1 + 02-cluster/scaleway/main.tf | 61 +++++++ 02-cluster/scaleway/outputs.tf | 3 + 02-cluster/scaleway/variables.tf | 46 +++++ 02-cluster/scaleway/version.tf | 64 +++++++ CLAUDE.md | 63 ++++--- README.md | 2 +- cluster/scaleway/main.tf | 171 ------------------ cluster/scaleway/outputs.tf | 7 - cluster/scaleway/variables.tf | 45 ----- cluster/scaleway/version.tf | 67 ------- github-ci/version.tf | 39 ---- mise.toml | 104 ++--------- modules/README.md | 13 ++ 54 files changed, 563 insertions(+), 673 deletions(-) delete mode 100644 .github/workflows/aws-auth-check.yml create mode 100644 .github/workflows/iam_terraform-backend-role.yml delete mode 100644 .github/workflows/s3-lister-role.yml delete mode 100644 .github/workflows/scaleway-auth-check.yml rename {terraform-state-bucket => 00-remote_state/0-backend}/.terraform.lock.hcl (100%) rename {terraform-state-bucket => 00-remote_state/0-backend}/README.md (88%) create mode 100644 00-remote_state/0-backend/env/00-remote-state-backend.tfvars rename {terraform-state-bucket => 00-remote_state/0-backend}/main.tf (100%) rename {terraform-state-bucket => 00-remote_state/0-backend}/outputs.tf (100%) rename {terraform-state-bucket => 00-remote_state/0-backend}/variables.tf (100%) rename {terraform-state-bucket => 00-remote_state/0-backend}/version.tf (70%) rename {s3-lister-role => 00-remote_state/1-iam}/.terraform.lock.hcl (100%) rename s3-lister-role/workspace/infrastructure.tfvars => 00-remote_state/1-iam/env/00-remote-state-iam.tfvars (90%) rename {s3-lister-role => 00-remote_state/1-iam}/main.tf (83%) rename {s3-lister-role => 00-remote_state/1-iam}/outputs.tf (100%) rename {s3-lister-role => 00-remote_state/1-iam}/variables.tf (87%) rename {s3-lister-role => 00-remote_state/1-iam}/version.tf (56%) rename {aws-github-oidc => 01-iam/aws}/.terraform.lock.hcl (100%) rename {aws-github-oidc => 01-iam/aws}/README.md (91%) rename {aws-github-oidc => 01-iam/aws}/iam-ci.tf (100%) rename {aws-github-oidc => 01-iam/aws}/main.tf (96%) rename {aws-github-oidc => 01-iam/aws}/outputs.tf (100%) rename {aws-github-oidc => 01-iam/aws}/variables.tf (100%) rename {aws-github-oidc => 01-iam/aws}/version.tf (73%) rename {github-ci => 01-iam/scaleway}/.terraform.lock.hcl (100%) rename {github-ci => 01-iam/scaleway}/README.md (91%) create mode 100644 01-iam/scaleway/env/01-iam-scaleway.tfvars rename {github-ci => 01-iam/scaleway}/main.tf (72%) rename {github-ci => 01-iam/scaleway}/outputs.tf (76%) rename {github-ci => 01-iam/scaleway}/variables.tf (82%) create mode 100644 01-iam/scaleway/version.tf rename {cluster => 02-cluster}/local/.terraform.lock.hcl (100%) create mode 100644 02-cluster/local/env/02-cluster-local.tfvars rename {cluster => 02-cluster}/local/main.tf (100%) rename {cluster => 02-cluster}/local/variables.tf (100%) rename {cluster => 02-cluster}/local/version.tf (100%) rename {cluster => 02-cluster}/scaleway/.terraform.lock.hcl (100%) create mode 100644 02-cluster/scaleway/argocd.tf create mode 100644 02-cluster/scaleway/env/02-cluster-staging.tfvars create mode 100644 02-cluster/scaleway/main.tf create mode 100644 02-cluster/scaleway/outputs.tf create mode 100644 02-cluster/scaleway/variables.tf create mode 100644 02-cluster/scaleway/version.tf delete mode 100644 cluster/scaleway/main.tf delete mode 100644 cluster/scaleway/outputs.tf delete mode 100644 cluster/scaleway/variables.tf delete mode 100644 cluster/scaleway/version.tf delete mode 100644 github-ci/version.tf create mode 100644 modules/README.md diff --git a/.github/workflows/aws-auth-check.yml b/.github/workflows/aws-auth-check.yml deleted file mode 100644 index 49f4b72..0000000 --- a/.github/workflows/aws-auth-check.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: AWS Auth Check - -# Validates the aws-github-oidc/ IAM role end to end: assume it via GitHub OIDC -# (no static AWS keys) and list S3 buckets. This is a smoke test, not part of any -# pipeline yet. -# -# Runs on PRs that touch the AWS OIDC identity so it can be validated before merge. - -on: - pull_request: - paths: - - '.github/workflows/aws-auth-check.yml' - - 'aws-github-oidc/**' - -permissions: - contents: read - -jobs: - list-buckets: - runs-on: ubuntu-latest - permissions: - id-token: write # mint the OIDC token exchanged for temporary AWS creds - contents: read - steps: - # Fail fast and clearly if the role ARN variable isn't set, instead of - # letting configure-aws-credentials emit a confusing assume-role error. - - name: Assert role ARN is configured - env: - ROLE_ARN: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} - run: | - if [ -z "$ROLE_ARN" ]; then - echo "::error::vars.AWS_GITHUB_ACTIONS_ROLE_ARN is not set. Apply aws-github-oidc/ and set it (see aws-github-oidc/README.md)." - exit 1 - fi - - - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 - with: - role-to-assume: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} - aws-region: eu-west-3 - - # List the state bucket specifically (s3:ListBucket on that bucket) rather - # than `aws s3 ls` with no arg, which needs account-wide s3:ListAllMyBuckets - # — the CI grant is intentionally scoped to the state bucket only. - - name: List the Terraform state bucket - run: aws s3 ls s3://id-terraform-state20260612164136440800000001 diff --git a/.github/workflows/iam_terraform-backend-role.yml b/.github/workflows/iam_terraform-backend-role.yml new file mode 100644 index 0000000..7681912 --- /dev/null +++ b/.github/workflows/iam_terraform-backend-role.yml @@ -0,0 +1,75 @@ +name: Apply Terraform Backend Role + +# Terraform GitOps for the 00-remote_state/1-iam/ root: the org-wide S3 state +# access role is created/synchronised from remote. Plans on PR, applies on push +# to main. Credentials come from the identity/00-ci-trust CI role assumed via +# OIDC — no static AWS keys. + +on: + push: + branches: [main] + paths: + - '00-remote_state/1-iam/**' + - '.github/workflows/iam_terraform-backend-role.yml' + pull_request: + paths: + - '00-remote_state/1-iam/**' + - '.github/workflows/iam_terraform-backend-role.yml' + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + permissions: + id-token: write # mint the OIDC token exchanged for temporary AWS creds + contents: read + steps: + # Fail fast and clearly if creds aren't set, instead of letting the + # tooling emit a confusing assume-role error. + - name: Assert credentials are present + env: + ROLE_ARN: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} + run: | + if [ -z "$ROLE_ARN" ]; then + echo "::error::vars.AWS_GITHUB_ACTIONS_ROLE_ARN is not set. Set it to the github-actions-terraform role ARN (provisioned by identity/00-ci-trust/)." + exit 1 + fi + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + # Keep in sync with mise.toml [tools] terraform version. + terraform_version: "1.14" + + - name: Assume the CI role via OIDC + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + with: + role-to-assume: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} + aws-region: eu-west-3 + + - name: Terraform plan + if: github.event_name == 'pull_request' + env: + TF_CHDIR: "00-remote_state/1-iam" + TF_ENVIRONMENT: "00-remote-state-iam.tfvars" + run: | + set -euo pipefail + terraform -chdir="${TF_CHDIR}" init -input=false + terraform -chdir="${TF_CHDIR}" workspace select "${TF_ENVIRONMENT%.*}" + terraform -chdir="${TF_CHDIR}" plan -input=false -var-file="env/${TF_ENVIRONMENT}" + + - name: Terraform apply + if: github.event_name == 'push' + env: + TF_CHDIR: "00-remote_state/1-iam" + TF_ENVIRONMENT: "00-remote-state-iam.tfvars" + run: | + set -euo pipefail + terraform -chdir="${TF_CHDIR}" init -input=false + terraform -chdir="${TF_CHDIR}" workspace select "${TF_ENVIRONMENT%.*}" + terraform -chdir="${TF_CHDIR}" apply -auto-approve -input=false -var-file="env/${TF_ENVIRONMENT}" diff --git a/.github/workflows/s3-lister-role.yml b/.github/workflows/s3-lister-role.yml deleted file mode 100644 index d057c80..0000000 --- a/.github/workflows/s3-lister-role.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Apply s3-lister-role - -# Terraform GitOps for the s3-lister-role/ root: the role is created/synchronised -# from the remote. Runs `apply` on every trigger (PRs included) so the CI role -# actually creates the role via OIDC. Credentials come from the aws-github-oidc -# CI role assumed via OIDC — no static AWS keys. - -on: - push: - branches: [main] - paths: - - 's3-lister-role/**' - - '.github/workflows/s3-lister-role.yml' - pull_request: - paths: - - 's3-lister-role/**' - - '.github/workflows/s3-lister-role.yml' - -permissions: - contents: read - -jobs: - terraform: - runs-on: ubuntu-latest - permissions: - id-token: write # mint the OIDC token exchanged for temporary AWS creds - contents: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 - with: - # Keep in sync with mise.toml [tools] terraform version. - terraform_version: "1.14" - - - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 - with: - role-to-assume: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} - aws-region: eu-west-3 - - # Workspace + variables are derived from s3-lister-role/workspace/*.tfvars. - # apply on every run (PRs included) — the point of this PR is to watch the - # CI role actually create the role via OIDC, not just plan it. - - uses: ./.github/actions/terraform - with: - root: s3-lister-role - apply: "true" diff --git a/.github/workflows/scaleway-auth-check.yml b/.github/workflows/scaleway-auth-check.yml deleted file mode 100644 index b6b0d1e..0000000 --- a/.github/workflows/scaleway-auth-check.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Scaleway Auth Check - -# Validates the github-ci/ Scaleway IAM key end to end: authenticate with the -# scoped API key from GitHub secrets and list the fr-par Object Storage buckets. -# This is a smoke test, not part of any pipeline yet. -# -# Runs on PRs that touch the CI identity so it can be validated before merge. - -on: - pull_request: - paths: - - '.github/workflows/scaleway-auth-check.yml' - - 'github-ci/**' - -permissions: - contents: read - -jobs: - list-buckets: - runs-on: ubuntu-latest - # Reference a dedicated environment so secret usage is scoped to it - # (satisfies zizmor's secrets-outside-env audit, and gives a place to attach - # protection rules later). Auto-created on first run with no protection. - environment: scaleway - steps: - # Fail fast and clearly if the secrets aren't set, instead of letting the - # CLI emit a confusing auth error (or worse, appear to pass). - - name: Assert credentials are present - env: - SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }} - SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }} - run: | - if [ -z "$SCW_ACCESS_KEY" ] || [ -z "$SCW_SECRET_KEY" ]; then - echo "::error::SCW_ACCESS_KEY and/or SCW_SECRET_KEY are not set. Run 'gh secret set' (see github-ci/README.md)." - exit 1 - fi - - - name: List fr-par Object Storage buckets - uses: scaleway/action-scw@2e34a1eb35cf3cac627f24643a101fea269cbd83 # v0.0.3 - with: - version: v2.41.0 - access-key: ${{ secrets.SCW_ACCESS_KEY }} - secret-key: ${{ secrets.SCW_SECRET_KEY }} - # Public identifiers (not credentials) the scw CLI wants even for a - # project-scoped key. Repo variables, not secrets. - default-organization-id: ${{ vars.SCW_DEFAULT_ORGANIZATION_ID }} - default-project-id: ${{ vars.SCW_DEFAULT_PROJECT_ID }} - args: object bucket list region=fr-par diff --git a/.github/workflows/scaleway-plan.yml b/.github/workflows/scaleway-plan.yml index d6ade5b..daec1e9 100644 --- a/.github/workflows/scaleway-plan.yml +++ b/.github/workflows/scaleway-plan.yml @@ -18,13 +18,14 @@ name: Scaleway Plan (via s3-lister role) on: pull_request: paths: - - 'cluster/scaleway/**' - - 's3-lister-role/**' + - '02-cluster/scaleway/**' + - '01-iam/aws/**' - '.github/workflows/scaleway-plan.yml' workflow_dispatch: permissions: contents: read + id-token: write # This is required for requesting the JWT jobs: plan: @@ -36,10 +37,8 @@ jobs: id-token: write # mint the OIDC token exchanged for temporary AWS creds contents: read env: - # Select the existing remote workspace without `workspace new` (a write the - # read-only role can't perform). The default workspace would collide with - # the state bucket's own state at the bucket root. - TF_WORKSPACE: infrastructure + INFISICAL_MACHINE_IDENTITY_ID: ${{ vars.INFISICAL_MACHINE_IDENTITY_ID }} + # Scaleway provider creds for the refresh. Keys are secrets; the public # org/project IDs are repo variables (not secrets). SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }} @@ -48,10 +47,10 @@ jobs: SCW_DEFAULT_PROJECT_ID: ${{ vars.SCW_DEFAULT_PROJECT_ID }} # The infisical provider authenticates eagerly even with bootstrap_argocd # off (the gated data source still pulls the provider into the graph), so - # it needs valid machine-identity creds just to configure. With - # bootstrap_argocd=false no secret is actually read. - TF_VAR_infisical_client_id: ${{ secrets.INFISICAL_CLIENT_ID }} - TF_VAR_infisical_client_secret: ${{ secrets.INFISICAL_CLIENT_SECRET }} + # it needs to configure successfully — here via keyless GitHub-OIDC (see + # the "Mint Infisical OIDC token" step). With bootstrap_argocd=false no + # secret is actually read. + steps: # Fail fast and clearly if creds/vars aren't set, instead of letting the # tooling emit a confusing assume-role or provider auth error. @@ -60,11 +59,11 @@ jobs: ROLE_ARN: ${{ vars.AWS_S3_LISTER_ROLE_ARN }} run: | if [ -z "$ROLE_ARN" ]; then - echo "::error::vars.AWS_S3_LISTER_ROLE_ARN is not set. Set it to the s3-lister role ARN (see s3-lister-role/README.md)." + echo "::error::vars.AWS_S3_LISTER_ROLE_ARN is not set. Set it to the s3-lister role ARN (provisioned by state/10-access)." exit 1 fi if [ -z "$SCW_ACCESS_KEY" ] || [ -z "$SCW_SECRET_KEY" ]; then - echo "::error::SCW_ACCESS_KEY and/or SCW_SECRET_KEY are not set (see github-ci/README.md)." + echo "::error::SCW_ACCESS_KEY and/or SCW_SECRET_KEY are not set (see ci/10-scaleway/README.md)." exit 1 fi @@ -85,8 +84,25 @@ jobs: role-to-assume: ${{ vars.AWS_S3_LISTER_ROLE_ARN }} aws-region: eu-west-3 + # Mint a GitHub OIDC JWT for the Infisical machine identity and stash it in + # $INFISICAL_AUTH_JWT (the provider's default token env var). audience must + # match the identity's boundAudiences. This is the keyless counterpart to + # the universal-auth client_id/secret used locally — no static Infisical + # secret in CI. core.getIDToken is the official @actions/core way to mint + # the token (it masks the value automatically). + - name: Mint Infisical OIDC token + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const jwt = await core.getIDToken('https://github.com/IntegratedDynamic') + core.exportVariable('INFISICAL_AUTH_JWT', jwt) + - name: Terraform plan (read-only role -> no lock, no apply) + env: + TF_CHDIR: "02-cluster/scaleway/" + TF_ENVIRONMENT: "02-cluster-staging.tfvars" run: | - terraform -chdir=cluster/scaleway init -input=false - terraform -chdir=cluster/scaleway plan \ - -lock=false -input=false -var bootstrap_argocd=false + set -euo pipefail + terraform -chdir="${TF_CHDIR}" init -input=false + terraform -chdir="${TF_CHDIR}" workspace select "${TF_ENVIRONMENT%.*}" + terraform -chdir="${TF_CHDIR}" destroy -auto-approve -input=false -var-file="env/${TF_ENVIRONMENT}" diff --git a/.github/workflows/terraform-lock.yml b/.github/workflows/terraform-lock.yml index 435069e..0eaee50 100644 --- a/.github/workflows/terraform-lock.yml +++ b/.github/workflows/terraform-lock.yml @@ -23,12 +23,11 @@ jobs: fail-fast: false matrix: root: - - terraform-state-bucket - - cluster/local - - cluster/scaleway - - github-ci - - aws-github-oidc - - s3-lister-role + - 00-remote_state/0-backend + - 00-remote_state/1-iam + - 01-iam/aws + - 01-iam/scaleway + - 02-cluster/scaleway steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/terraform-state-bucket/.terraform.lock.hcl b/00-remote_state/0-backend/.terraform.lock.hcl similarity index 100% rename from terraform-state-bucket/.terraform.lock.hcl rename to 00-remote_state/0-backend/.terraform.lock.hcl diff --git a/terraform-state-bucket/README.md b/00-remote_state/0-backend/README.md similarity index 88% rename from terraform-state-bucket/README.md rename to 00-remote_state/0-backend/README.md index 5ca8999..de13e06 100644 --- a/terraform-state-bucket/README.md +++ b/00-remote_state/0-backend/README.md @@ -1,13 +1,12 @@ -# terraform-state-bucket +# state/00-backend — 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 +(`cluster/local/`, `cluster/scaleway/`, `ci/10-scaleway/`, 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. +It is the shared substrate every other root depends on — strata `00` of the +`state/` domain, applied by an admin. ## What it creates @@ -58,13 +57,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=terraform-state-bucket init - terraform -chdir=terraform-state-bucket apply # creates the bucket (billable) + terraform -chdir=state/00-backend init + terraform -chdir=state/00-backend 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 + terraform -chdir=state/00-backend init -migrate-state ``` After that, this root's own state lives at `state-backend/terraform.tfstate` diff --git a/00-remote_state/0-backend/env/00-remote-state-backend.tfvars b/00-remote_state/0-backend/env/00-remote-state-backend.tfvars new file mode 100644 index 0000000..b400580 --- /dev/null +++ b/00-remote_state/0-backend/env/00-remote-state-backend.tfvars @@ -0,0 +1,3 @@ +bucket_prefix = "id-terraform-state" +region = "eu-west-3" +noncurrent_version_expiration_days = 10 diff --git a/terraform-state-bucket/main.tf b/00-remote_state/0-backend/main.tf similarity index 100% rename from terraform-state-bucket/main.tf rename to 00-remote_state/0-backend/main.tf diff --git a/terraform-state-bucket/outputs.tf b/00-remote_state/0-backend/outputs.tf similarity index 100% rename from terraform-state-bucket/outputs.tf rename to 00-remote_state/0-backend/outputs.tf diff --git a/terraform-state-bucket/variables.tf b/00-remote_state/0-backend/variables.tf similarity index 100% rename from terraform-state-bucket/variables.tf rename to 00-remote_state/0-backend/variables.tf diff --git a/terraform-state-bucket/version.tf b/00-remote_state/0-backend/version.tf similarity index 70% rename from terraform-state-bucket/version.tf rename to 00-remote_state/0-backend/version.tf index d40a006..c54997f 100644 --- a/terraform-state-bucket/version.tf +++ b/00-remote_state/0-backend/version.tf @@ -4,10 +4,13 @@ terraform { backend "s3" { bucket = "id-terraform-state20260612164136440800000001" region = "eu-west-3" + # Prefix kept as "state-backend" (≠ this root's path state/00-backend/) on + # purpose: the state key is decoupled from the directory, so the repo + # restructure was a pure move with zero state migration. workspace_key_prefix = "state-backend" - key = "terraform.tfstate" - encrypt = true - use_lockfile = true + key = "terraform.tfstate" + encrypt = true + use_lockfile = true } required_providers { diff --git a/s3-lister-role/.terraform.lock.hcl b/00-remote_state/1-iam/.terraform.lock.hcl similarity index 100% rename from s3-lister-role/.terraform.lock.hcl rename to 00-remote_state/1-iam/.terraform.lock.hcl diff --git a/s3-lister-role/workspace/infrastructure.tfvars b/00-remote_state/1-iam/env/00-remote-state-iam.tfvars similarity index 90% rename from s3-lister-role/workspace/infrastructure.tfvars rename to 00-remote_state/1-iam/env/00-remote-state-iam.tfvars index 139f287..d3d9450 100644 --- a/s3-lister-role/workspace/infrastructure.tfvars +++ b/00-remote_state/1-iam/env/00-remote-state-iam.tfvars @@ -8,7 +8,7 @@ region = "eu-west-3" role_name = "s3-lister" -# Repo-scoped path + boundary required by the CI grant (aws-github-oidc/). Must +# Repo-scoped path + boundary required by the CI grant (identity/00-ci-trust/). Must # match the grant's pins exactly or the apply is denied. role_path = "/tf-managed/IntegratedDynamic/infrastructure/" permissions_boundary_arn = "arn:aws:iam::503577850357:policy/tf-managed-boundary" diff --git a/s3-lister-role/main.tf b/00-remote_state/1-iam/main.tf similarity index 83% rename from s3-lister-role/main.tf rename to 00-remote_state/1-iam/main.tf index d63fade..51041b2 100644 --- a/s3-lister-role/main.tf +++ b/00-remote_state/1-iam/main.tf @@ -1,10 +1,10 @@ # An org-wide, read-only S3-lister IAM role — the first role created BY the CI # rather than by a human. It is created/updated when a push to main touches this # root (see .github/workflows/s3-lister-role.yml): GitHub Actions assumes the -# aws-github-oidc CI role and runs `terraform apply`. +# identity/00-ci-trust CI role and runs `terraform apply`. # # Two things are mandatory for the CI role to be ALLOWED to create this (see -# aws-github-oidc/iam-ci.tf): the role must sit under the managed `path` and +# identity/00-ci-trust/iam-ci.tf): the role must sit under the managed `path` and # carry the `permissions_boundary`. Omit either and the apply is denied. # # Trust: two doors, both org-scoped, that the module ORs together. @@ -27,7 +27,7 @@ module "s3_lister" { name = var.role_name use_name_prefix = false path = var.role_path - description = "Org-wide read-only S3 lister. Created by CI; capped by the tf-managed permissions boundary." + description = "Org-wide Terraform-state S3 access (read/write + lock). Created by CI; capped by the tf-managed permissions boundary." permissions_boundary = var.permissions_boundary_arn @@ -51,11 +51,9 @@ module "s3_lister" { }] } } - - # Same access the original GitHub Action had: list/read S3. The boundary clamps - # it anyway, but S3 read is well within the ceiling. + policies = { - S3ReadOnly = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" + S3FullAccess = "arn:aws:iam::aws:policy/AmazonS3FullAccess" } tags = { diff --git a/s3-lister-role/outputs.tf b/00-remote_state/1-iam/outputs.tf similarity index 100% rename from s3-lister-role/outputs.tf rename to 00-remote_state/1-iam/outputs.tf diff --git a/s3-lister-role/variables.tf b/00-remote_state/1-iam/variables.tf similarity index 87% rename from s3-lister-role/variables.tf rename to 00-remote_state/1-iam/variables.tf index d23ca96..23b0668 100644 --- a/s3-lister-role/variables.tf +++ b/00-remote_state/1-iam/variables.tf @@ -10,7 +10,7 @@ variable "role_name" { default = "s3-lister" } -# IAM path every CI-managed role must sit under. The CI grant (aws-github-oidc/) +# IAM path every CI-managed role must sit under. The CI grant (identity/00-ci-trust/) # only allows role creation under this path WITH the boundary below — so both # must be set or the apply is rejected. In CI this is fed automatically from the # repo slug: TF_VAR_role_path=/tf-managed/${{ github.repository }}/ (see the @@ -22,10 +22,10 @@ variable "role_path" { } # The permissions boundary that caps this role. Value is the -# `permissions_boundary_arn` output of the aws-github-oidc/ root. Required by the -# CI grant's conditions. +# `permissions_boundary_arn` output of the identity/00-ci-trust/ root. Required by +# the CI grant's conditions. variable "permissions_boundary_arn" { - description = "ARN of the permissions boundary to attach (aws-github-oidc output)." + description = "ARN of the permissions boundary to attach (identity/00-ci-trust output)." type = string default = "arn:aws:iam::503577850357:policy/tf-managed-boundary" } diff --git a/s3-lister-role/version.tf b/00-remote_state/1-iam/version.tf similarity index 56% rename from s3-lister-role/version.tf rename to 00-remote_state/1-iam/version.tf index 7cd85b6..bf41b20 100644 --- a/s3-lister-role/version.tf +++ b/00-remote_state/1-iam/version.tf @@ -1,10 +1,13 @@ terraform { # Remote state on the shared org S3 bucket, under this root's own key prefix. - # In CI the GitHub OIDC role (aws-github-oidc/) provides the credentials; it is + # In CI the GitHub OIDC role (identity/00-ci-trust/) provides the credentials; it is # granted R/W on this bucket, so `init`/`plan`/`apply` work without static keys. backend "s3" { - bucket = "id-terraform-state20260612164136440800000001" - region = "eu-west-3" + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + # Prefix kept as "s3-lister-role" (≠ this root's path state/10-access/) on + # purpose: the state key is decoupled from the directory, so the repo + # restructure was a pure move with zero state migration. workspace_key_prefix = "s3-lister-role" key = "terraform.tfstate" encrypt = true diff --git a/aws-github-oidc/.terraform.lock.hcl b/01-iam/aws/.terraform.lock.hcl similarity index 100% rename from aws-github-oidc/.terraform.lock.hcl rename to 01-iam/aws/.terraform.lock.hcl diff --git a/aws-github-oidc/README.md b/01-iam/aws/README.md similarity index 91% rename from aws-github-oidc/README.md rename to 01-iam/aws/README.md index 018e42c..ec4d196 100644 --- a/aws-github-oidc/README.md +++ b/01-iam/aws/README.md @@ -1,4 +1,4 @@ -# aws-github-oidc +# identity/00-ci-trust — CI identity & governance A standalone Terraform root that provisions **keyless GitHub-OIDC → AWS** access for this repo's CI: an IAM OIDC identity provider, plus an IAM role GitHub @@ -18,8 +18,8 @@ GitHub secrets. Scaleway resources (Kapsule, VPC) are a different story: **Scaleway IAM is not an OIDC relying party** ([feature request](https://feature-request.scaleway.com/posts/761/oidc-provider-for-external-ci-cd)), -so those keep using a scoped, static Scaleway API key (see `terraform-ci/` / -`github-ci/`). OIDC here covers **only** the AWS/S3 side. Revisit Scaleway OIDC +so those keep using a scoped, static Scaleway API key (see `ci/10-scaleway/`). +OIDC here covers **only** the AWS/S3 side. Revisit Scaleway OIDC if/when Scaleway ships it. All resources are built from the [`terraform-aws-modules/iam`](https://registry.terraform.io/modules/terraform-aws-modules/iam/aws/latest) @@ -79,8 +79,8 @@ inline in [`iam-ci.tf`](./iam-ci.tf)): Verify the guardrails with the IAM policy simulator, e.g.: ```bash -ROLE=$(terraform -chdir=aws-github-oidc output -raw role_arn) -B=$(terraform -chdir=aws-github-oidc output -raw permissions_boundary_arn) +ROLE=$(terraform -chdir=identity/00-ci-trust output -raw role_arn) +B=$(terraform -chdir=identity/00-ci-trust output -raw permissions_boundary_arn) # CreateRole without our boundary -> explicitDeny aws iam simulate-principal-policy --policy-source-arn "$ROLE" \ --action-names iam:CreateRole \ @@ -99,8 +99,8 @@ aws iam simulate-principal-policy --policy-source-arn "$ROLE" \ ## Apply ```bash -mise run aws-github-oidc-plan # terraform init && plan — review first -mise run aws-github-oidc-apply # terraform apply (creates IAM resources) +mise run ci-trust-plan # terraform init && plan — review first +mise run ci-trust-apply # terraform apply (creates IAM resources) ``` > Never `terraform apply`/`destroy` here without explicit approval. @@ -113,7 +113,7 @@ variable (an ARN is a public identifier, not a secret). Set it once: ```bash gh variable set AWS_GITHUB_ACTIONS_ROLE_ARN \ --repo IntegratedDynamic/infrastructure \ - --body "$(terraform -chdir=aws-github-oidc output -raw role_arn)" + --body "$(terraform -chdir=identity/00-ci-trust output -raw role_arn)" ``` Workflows then assume the role with `aws-actions/configure-aws-credentials`: diff --git a/aws-github-oidc/iam-ci.tf b/01-iam/aws/iam-ci.tf similarity index 100% rename from aws-github-oidc/iam-ci.tf rename to 01-iam/aws/iam-ci.tf diff --git a/aws-github-oidc/main.tf b/01-iam/aws/main.tf similarity index 96% rename from aws-github-oidc/main.tf rename to 01-iam/aws/main.tf index bedc664..feab64a 100644 --- a/aws-github-oidc/main.tf +++ b/01-iam/aws/main.tf @@ -4,7 +4,7 @@ # GitHub secrets for the S3 backend. # # This covers ONLY the AWS/S3 side. Scaleway resources (Kapsule, VPC) still use a -# static Scaleway API key (github-ci/), because Scaleway IAM is not an OIDC +# static Scaleway API key (ci/10-scaleway/), because Scaleway IAM is not an OIDC # relying party — see README.md. # OIDC identity provider for GitHub Actions. One per AWS account; this is the diff --git a/aws-github-oidc/outputs.tf b/01-iam/aws/outputs.tf similarity index 100% rename from aws-github-oidc/outputs.tf rename to 01-iam/aws/outputs.tf diff --git a/aws-github-oidc/variables.tf b/01-iam/aws/variables.tf similarity index 100% rename from aws-github-oidc/variables.tf rename to 01-iam/aws/variables.tf diff --git a/aws-github-oidc/version.tf b/01-iam/aws/version.tf similarity index 73% rename from aws-github-oidc/version.tf rename to 01-iam/aws/version.tf index f60c11b..8434a9b 100644 --- a/aws-github-oidc/version.tf +++ b/01-iam/aws/version.tf @@ -2,8 +2,11 @@ terraform { # Remote state on the shared org S3 bucket (same bucket every other root uses), # under this root's own key so its state/blast-radius stay isolated. backend "s3" { - bucket = "id-terraform-state20260612164136440800000001" - region = "eu-west-3" + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + # Prefix kept as "aws-github-oidc" (≠ this root's path identity/00-ci-trust/) + # on purpose: the state key is decoupled from the directory, so the repo + # restructure was a pure move with zero state migration. workspace_key_prefix = "aws-github-oidc" key = "terraform.tfstate" encrypt = true diff --git a/github-ci/.terraform.lock.hcl b/01-iam/scaleway/.terraform.lock.hcl similarity index 100% rename from github-ci/.terraform.lock.hcl rename to 01-iam/scaleway/.terraform.lock.hcl diff --git a/github-ci/README.md b/01-iam/scaleway/README.md similarity index 91% rename from github-ci/README.md rename to 01-iam/scaleway/README.md index 770288a..36b348d 100644 --- a/github-ci/README.md +++ b/01-iam/scaleway/README.md @@ -1,4 +1,4 @@ -# github-ci +# ci/10-scaleway — Scaleway CI identity A standalone Terraform root that provisions the **Scaleway identity GitHub Actions uses to authenticate to Scaleway**. First real consumer: a smoke-test @@ -58,8 +58,8 @@ Same as the other roots: ## Apply ```bash -mise run github-ci-plan # terraform init && plan — review first -mise run github-ci-apply # terraform apply (billable: creates an IAM key) +mise run scaleway-ci-plan # terraform init && plan — review first +mise run scaleway-ci-apply # terraform apply (billable: creates an IAM key) ``` > Never `terraform apply`/`destroy` here without explicit approval. @@ -77,12 +77,12 @@ them into your shell history or echo them**: # SCW_ACCESS_KEY is a public identifier, exposed as a Terraform output: gh secret set SCW_ACCESS_KEY \ --repo IntegratedDynamic/infrastructure \ - --body "$(terraform -chdir=github-ci output -raw access_key)" + --body "$(terraform -chdir=ci/10-scaleway output -raw access_key)" # SCW_SECRET_KEY is sensitive — pipe it from the API key resource without printing: gh secret set SCW_SECRET_KEY \ --repo IntegratedDynamic/infrastructure \ - --body "$(terraform -chdir=github-ci state show -no-color scaleway_iam_api_key.github_ci \ + --body "$(terraform -chdir=ci/10-scaleway state show -no-color scaleway_iam_api_key.github_ci \ | awk '/secret_key/ {print $3; exit}' | tr -d '\"')" ``` @@ -94,7 +94,7 @@ from stdin.) The smoke-test workflow (`.github/workflows/scaleway-auth-check.yml`) runs `scw object bucket list region=fr-par` against the key. It triggers on any PR -that touches `github-ci/**` or the workflow itself, so you can validate before +that touches `ci/10-scaleway/**` or the workflow itself, so you can validate before merge. It needs two **repo variables** (public identifiers, not secrets — the scw CLI @@ -122,7 +122,7 @@ The API key lives entirely in this root's state. - **On demand** — force it early with: ```bash - terraform -chdir=github-ci apply -replace=scaleway_iam_api_key.github_ci + terraform -chdir=ci/10-scaleway apply -replace=scaleway_iam_api_key.github_ci ``` Either way the key material changes, so **re-run the `gh secret set` steps above** diff --git a/01-iam/scaleway/env/01-iam-scaleway.tfvars b/01-iam/scaleway/env/01-iam-scaleway.tfvars new file mode 100644 index 0000000..654e22e --- /dev/null +++ b/01-iam/scaleway/env/01-iam-scaleway.tfvars @@ -0,0 +1,6 @@ +infisical_workspace_id="7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" +infisical_env_slug="staging" +infisical_folder_path="/ci" + +project_id="6283c05b-a4c7-4f83-a75f-83adad236d54" +api_key_rotation_days=365 diff --git a/github-ci/main.tf b/01-iam/scaleway/main.tf similarity index 72% rename from github-ci/main.tf rename to 01-iam/scaleway/main.tf index 58584d8..c878efc 100644 --- a/github-ci/main.tf +++ b/01-iam/scaleway/main.tf @@ -1,23 +1,17 @@ -# Dedicated, least-privilege identity for GitHub Actions to authenticate to -# Scaleway. The keyless GitHub-OIDC -> Scaleway flow isn't possible yet (Scaleway -# IAM is not an OIDC relying party — see README), so we use Scaleway's supported -# pattern: a scoped, independently-revocable API key consumed from GH secrets. - -resource "scaleway_iam_application" "github_ci" { +resource "scaleway_iam_application" "this" { name = "github-ci" description = "GitHub Actions CI for the IntegratedDynamic/infrastructure repo (managed by terraform: github-ci/)." } -# Least privilege: read-only Object Storage, scoped to a single project. The -# future Terraform-CI identity gets its own broader policy (out of scope here). -resource "scaleway_iam_policy" "github_ci" { +# Dummy permission for the IAM Scaleway User. TBD +resource "scaleway_iam_policy" "this" { name = "github-ci-object-storage-ro" description = "Read-only Object Storage for the GitHub Actions CI application, project-scoped." - application_id = scaleway_iam_application.github_ci.id + application_id = scaleway_iam_application.this.id rule { project_ids = [var.project_id] - permission_set_names = ["ObjectStorageReadOnly"] + permission_set_names = ["VPCFullAccess", "KubernetesFullAccess", "PrivateNetworksFullAccess", "IPAMReadOnly"] } } @@ -30,8 +24,8 @@ resource "time_rotating" "api_key" { rotation_days = var.api_key_rotation_days } -resource "scaleway_iam_api_key" "github_ci" { - application_id = scaleway_iam_application.github_ci.id +resource "scaleway_iam_api_key" "this" { + application_id = scaleway_iam_application.this.id description = "Consumed from GitHub Actions secrets (SCW_ACCESS_KEY / SCW_SECRET_KEY)." # Bakes the project into the key so `scw object bucket list` resolves the right @@ -57,7 +51,7 @@ resource "infisical_secret_folder" "ci" { resource "infisical_secret" "scw_access_key" { name = "SCW_ACCESS_KEY" - value = scaleway_iam_api_key.github_ci.access_key + value = scaleway_iam_api_key.this.access_key env_slug = var.infisical_env_slug workspace_id = var.infisical_workspace_id folder_path = infisical_secret_folder.ci.path @@ -65,7 +59,7 @@ resource "infisical_secret" "scw_access_key" { resource "infisical_secret" "scw_secret_key" { name = "SCW_SECRET_KEY" - value = scaleway_iam_api_key.github_ci.secret_key + value = scaleway_iam_api_key.this.secret_key env_slug = var.infisical_env_slug workspace_id = var.infisical_workspace_id folder_path = infisical_secret_folder.ci.path diff --git a/github-ci/outputs.tf b/01-iam/scaleway/outputs.tf similarity index 76% rename from github-ci/outputs.tf rename to 01-iam/scaleway/outputs.tf index 45e42cf..aa51c35 100644 --- a/github-ci/outputs.tf +++ b/01-iam/scaleway/outputs.tf @@ -1,11 +1,11 @@ output "application_id" { description = "IAM application ID backing the GitHub Actions CI identity." - value = scaleway_iam_application.github_ci.id + value = scaleway_iam_application.this.id } # The access key is a public identifier (like an AWS access key ID), so it's safe # to surface. The secret half is never output — read it from Infisical or state. output "access_key" { description = "SCW_ACCESS_KEY for the CI identity (public identifier)." - value = scaleway_iam_api_key.github_ci.access_key + value = scaleway_iam_api_key.this.access_key } diff --git a/github-ci/variables.tf b/01-iam/scaleway/variables.tf similarity index 82% rename from github-ci/variables.tf rename to 01-iam/scaleway/variables.tf index 5a3c3b5..e14d310 100644 --- a/github-ci/variables.tf +++ b/01-iam/scaleway/variables.tf @@ -1,14 +1,3 @@ -# Infisical universal-auth machine identity (per-developer, from nico.auto.tfvars). -# Used to write the generated API key back into Infisical. -variable "infisical_client_id" { - type = string -} - -variable "infisical_client_secret" { - type = string - sensitive = true -} - variable "infisical_workspace_id" { description = "Infisical project (workspace) ID the CI secrets are written to." type = string diff --git a/01-iam/scaleway/version.tf b/01-iam/scaleway/version.tf new file mode 100644 index 0000000..8b19c08 --- /dev/null +++ b/01-iam/scaleway/version.tf @@ -0,0 +1,42 @@ +terraform { + backend "s3" { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + # Prefix kept as "github-ci" (≠ this root's path ci/10-scaleway/) on purpose: + # the state key is decoupled from the directory, so the repo restructure was + # a pure move with zero state migration. + workspace_key_prefix = "github-ci" + key = "terraform.tfstate" + encrypt = true + use_lockfile = true + } + + + + required_providers { + scaleway = { + source = "scaleway/scaleway" + version = "~> 2.0" + } + infisical = { + source = "infisical/infisical" + version = "~> 0.16" + } + time = { + source = "hashicorp/time" + version = "~> 0.12" + } + } +} + +# Creds, region and project_id come from the scw CLI config (like the other roots). +provider "scaleway" {} +# Without `auth.oidc`, infisical will not try to consume OIDC environment variables, even if present, and will only look for generic auth environment variables. +provider "infisical" { + auth = { + ## Uncomment `universal` and comment `oidc` when running terraform locally, . + ## By default, even with `INFISICAL_UNIVERSAL_AUTH_CLIENT_XXXX` environment variable, due to `auth.oidc` being present, infisical provider expect OIDC configuration, and nothing else. + # universal = {} + oidc = {} + } +} diff --git a/cluster/local/.terraform.lock.hcl b/02-cluster/local/.terraform.lock.hcl similarity index 100% rename from cluster/local/.terraform.lock.hcl rename to 02-cluster/local/.terraform.lock.hcl diff --git a/02-cluster/local/env/02-cluster-local.tfvars b/02-cluster/local/env/02-cluster-local.tfvars new file mode 100644 index 0000000..bd4c3c1 --- /dev/null +++ b/02-cluster/local/env/02-cluster-local.tfvars @@ -0,0 +1,8 @@ +# This repo follow GitOps convention & standard. +# Which means even `local` environment do get it's own official environment file. +# Treat this file as an example file for any local environment + + +# infisical_client_id = "" +# infisical_client_secret = "" +gitops_revision = "main" diff --git a/cluster/local/main.tf b/02-cluster/local/main.tf similarity index 100% rename from cluster/local/main.tf rename to 02-cluster/local/main.tf diff --git a/cluster/local/variables.tf b/02-cluster/local/variables.tf similarity index 100% rename from cluster/local/variables.tf rename to 02-cluster/local/variables.tf diff --git a/cluster/local/version.tf b/02-cluster/local/version.tf similarity index 100% rename from cluster/local/version.tf rename to 02-cluster/local/version.tf diff --git a/cluster/scaleway/.terraform.lock.hcl b/02-cluster/scaleway/.terraform.lock.hcl similarity index 100% rename from cluster/scaleway/.terraform.lock.hcl rename to 02-cluster/scaleway/.terraform.lock.hcl diff --git a/02-cluster/scaleway/argocd.tf b/02-cluster/scaleway/argocd.tf new file mode 100644 index 0000000..460d37c --- /dev/null +++ b/02-cluster/scaleway/argocd.tf @@ -0,0 +1,82 @@ +data "infisical_secrets" "this" { + env_slug = "staging" + workspace_id = "7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" // project ID + folder_path = "/" +} + +resource "helm_release" "argocd" { + name = "argocd" + namespace = "argocd" + create_namespace = true + + repository = "https://argoproj.github.io/argo-helm" + chart = "argo-cd" + version = "9.4.17" + + # Fail fast (under the 5m default) if ArgoCD doesn't come up. Transient blips + # (e.g. quay.io 502s) are absorbed by retrying the apply (see mise scaleway-up). + timeout = 240 + + depends_on = [scaleway_k8s_pool.default] + + set_sensitive = [{ + name = "configs.secret.argocdServerAdminPassword" + # ArgoCD expects a bcrypt() hash here. bcrypt() would regenerate on every + # run, so we store the hash directly in Infisical to avoid spurious diffs. + value = data.infisical_secrets.this.secrets["ArgoCD_admin_encrypted"].value + }] + + values = [</dev/null || true + kubectl config rename-context "${scaleway_k8s_cluster.this.name}-${local.cluster_uuid}" "${var.cluster_name}" + EOT + } +} diff --git a/02-cluster/scaleway/outputs.tf b/02-cluster/scaleway/outputs.tf new file mode 100644 index 0000000..9bac614 --- /dev/null +++ b/02-cluster/scaleway/outputs.tf @@ -0,0 +1,3 @@ +output "cluster_id" { + value = scaleway_k8s_cluster.this.id +} diff --git a/02-cluster/scaleway/variables.tf b/02-cluster/scaleway/variables.tf new file mode 100644 index 0000000..6d64d53 --- /dev/null +++ b/02-cluster/scaleway/variables.tf @@ -0,0 +1,46 @@ +# Infisical provide many way to authenticate. +# You can use `infisical_client_id` and `infisical_client_secret` when running terraform locally +# Or use `infisical_oidc_identity_id` when OIDC integration is available. +variable "infisical_client_id" { + type = string + default = "" +} + +variable "infisical_client_secret" { + type = string + default = "" + sensitive = true +} + +variable "infisical_oidc_identity_id" { + description = "Infisical OIDC machine-identity ID. When set, the provider authenticates via GitHub-OIDC; when empty, via universal auth." + type = string + default = "" +} + +variable "k8s_version" { + type = string + default = "1.35" +} + +variable "node_count" { + type = number + default = 1 +} + +variable "cluster_name" { + description = "The cluster name" + type = string + default = "scaleway-homelab" +} + +variable "gitops_revision" { + type = string + default = "main" +} + +variable "update_kubeconfig" { + type = bool + default = false + description = "Set to true when using locally to automatically update you ~/.kube/config. Require `kubectl` and `scw` installed & configured." +} diff --git a/02-cluster/scaleway/version.tf b/02-cluster/scaleway/version.tf new file mode 100644 index 0000000..22034b8 --- /dev/null +++ b/02-cluster/scaleway/version.tf @@ -0,0 +1,64 @@ +terraform { + # Remote state in the org-wide bucket (state-backend/). Creds: see mise.toml. + backend "s3" { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + workspace_key_prefix = "cluster/scaleway" + key = "terraform.tfstate" + encrypt = true + use_lockfile = true + } + + required_providers { + scaleway = { + source = "scaleway/scaleway" + version = "~> 2.0" + } + local = { + source = "hashicorp/local" + version = "~> 2.0" + } + null = { + source = "hashicorp/null" + version = "~> 3.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.0" + } + helm = { + source = "hashicorp/helm" + version = "~> 3.0" + } + infisical = { + source = "infisical/infisical" + version = "~> 0.16" + } + } +} + +provider "scaleway" {} + +provider "infisical" { + + auth = { + ## Uncomment `universal` and comment `oidc` when running terraform locally. + ## By default, even with `INFISICAL_UNIVERSAL_AUTH_CLIENT_XXXX` environment variable, due to `auth.oidc` being present, infisical provider expect OIDC configuration, and nothing else. + # universal = {} + oidc = {} + } +} + +provider "kubernetes" { + host = scaleway_k8s_cluster.this.kubeconfig[0].host + token = scaleway_k8s_cluster.this.kubeconfig[0].token + cluster_ca_certificate = base64decode(scaleway_k8s_cluster.this.kubeconfig[0].cluster_ca_certificate) +} + +provider "helm" { + kubernetes = { + host = scaleway_k8s_cluster.this.kubeconfig[0].host + token = scaleway_k8s_cluster.this.kubeconfig[0].token + cluster_ca_certificate = base64decode(scaleway_k8s_cluster.this.kubeconfig[0].cluster_ca_certificate) + } +} diff --git a/CLAUDE.md b/CLAUDE.md index faf3896..684d372 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,33 +34,44 @@ The `.terraform.lock.hcl` in each root must cover **both** `darwin_arm64` (local `mise run lock` is equivalent to: ```bash -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 -terraform -chdir=aws-github-oidc providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=s3-lister-role providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=state/00-backend providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=state/10-access providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=identity/00-ci-trust providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=ci/10-scaleway 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 ``` Commit the updated lock files alongside the version change. ## Architecture -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 roots are organized by **domain** (top-level folder = what the root is +responsible for) with a **numeric strata prefix** on each root (apply order / +blast-radius / who applies it: `00` = admin-applied foundation, `10`+ = built on +top). The shared S3 state bucket (`state/00-backend`) holds every root's remote +state. ``` -terraform-state-bucket/ # shared AWS S3 bucket holding every root's remote state -cluster/ - 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 -aws-github-oidc/ # keyless GitHub-OIDC -> AWS role for CI (Terraform state + bounded IAM) +modules/ # reusable Terraform modules (empty for now) +state/ # domain: Terraform state + 00-backend/ # shared AWS S3 bucket holding every root's remote state (admin-applied) + 10-access/ # org-wide read-only state-access IAM role (created BY CI) +identity/ # domain: CI identity & governance + 00-ci-trust/ # GitHub OIDC provider + role-creator role + permissions boundary + CI grant +ci/ # domain: CI access + 10-scaleway/ # Scaleway IAM identity (static API key) GitHub Actions authenticates with +cluster/ # domain: the Kubernetes platform (de-facto strata 20) + local/ # minikube — local dev and debugging. Local backend (local files) + scaleway/ # Scaleway Kapsule cluster + ArgoCD bootstrap (homelab; WIP) ``` -Otherwise, this repo, for now, is an agregate of terraform root modules without specific structure yet. +**Backend keys are decoupled from paths.** Each root keeps its original +`workspace_key_prefix` (e.g. `state/10-access` still uses prefix `s3-lister-role`) +so the by-domain restructure was a pure move with no state migration. Don't +"fix" a prefix to match its path unless you also migrate the state. -### `./clusters/*` +### `cluster/*` 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. @@ -77,15 +88,23 @@ Two-step, one-time bootstrap: Same bootstrap pattern as `local/`, but with the Kapsule cluster + node pool (`DEV1-M`, min=0/max=3) instead. -### `github-ci/` +### `state/00-backend/` -Standalone root (not under `cluster/` — provisions no cluster) that stands up the **Scaleway IAM identity GitHub Actions uses to authenticate to Scaleway**: a dedicated IAM application + a least-privilege policy (`ObjectStorageReadOnly`, project-scoped) + an API key, with the key written into Infisical. GitHub secrets (`SCW_ACCESS_KEY` / `SCW_SECRET_KEY`) are still set manually via `gh secret set`. Keyless GitHub-OIDC → Scaleway is a non-goal — blocked upstream (Scaleway IAM is not an OIDC relying party). See `github-ci/README.md`. +The shared org S3 bucket holding **every** root's remote state (built on `terraform-aws-modules/s3-bucket`: versioning, SSE, public-access block, TLS-only). Chicken-and-egg: its own state lives in the bucket it creates (one-time local-state bootstrap — see its README). Applied by an admin. -### `aws-github-oidc/` +### `state/10-access/` -Standalone root (provisions no cluster) for **keyless GitHub-OIDC → AWS** CI access, built on the `terraform-aws-modules/iam` modules: an OIDC provider + a role (`github-actions-terraform`) GitHub Actions assumes via short-lived tokens (trust scoped to `repo:IntegratedDynamic/infrastructure:*`). The role grant (`tf-managed-ci`) gives Terraform-state R/W on the state bucket **plus privilege-escalation-safe IAM role management**. Applied locally by an admin; `role_arn` is wired to CI via `vars.AWS_GITHUB_ACTIONS_ROLE_ARN`. See `aws-github-oidc/README.md`. +Org-wide, read-only S3-lister IAM **role created BY the CI** (the first role minted by `identity/00-ci-trust`'s role-creator rather than by a human). Assumable org-wide via two trust doors: AWS principals in the org (`aws:PrincipalOrgID`) and GitHub Actions in the org via OIDC (`repo:IntegratedDynamic/*`). Its true responsibility is **read access to the Terraform state** — used e.g. by the `scaleway-plan` workflow. Applied by CI (`s3-lister-role.yml`). -**Permissions-boundary contract (repo-wide):** any `aws_iam_role` that the CI applies **must** set `permissions_boundary` (= the `permissions_boundary_arn` output, `tf-managed-boundary`) and `path` (= the `managed_path` output, `/tf-managed///`), or the apply is rejected by the CI grant's conditions. Set both via the root's `workspace/.tfvars` (see the Terraform workspaces convention below). The boundary caps every CI-created role to "admin minus a hardened deny-list" so a role-creating role can't escalate. Rationale is documented inline in `aws-github-oidc/iam-ci.tf`. +### `ci/10-scaleway/` + +Standalone root that stands up the **Scaleway IAM identity GitHub Actions uses to authenticate to Scaleway**: a dedicated IAM application + a least-privilege policy (`ObjectStorageReadOnly`, project-scoped) + an API key, with the key written into Infisical. GitHub secrets (`SCW_ACCESS_KEY` / `SCW_SECRET_KEY`) are still set manually via `gh secret set`. Keyless GitHub-OIDC → Scaleway is a non-goal — blocked upstream (Scaleway IAM is not an OIDC relying party). See `ci/10-scaleway/README.md`. + +### `identity/00-ci-trust/` + +The CI **identity & governance foundation**: **keyless GitHub-OIDC → AWS** access, built on the `terraform-aws-modules/iam` modules. An OIDC provider + a role (`github-actions-terraform`) GitHub Actions assumes via short-lived tokens (trust scoped to `repo:IntegratedDynamic/infrastructure:*`). The role grant (`tf-managed-ci`) gives Terraform-state R/W on the state bucket **plus privilege-escalation-safe IAM role management** — i.e. it is the role that **creates other CI roles** (e.g. `state/10-access`). Applied locally by an admin; `role_arn` is wired to CI via `vars.AWS_GITHUB_ACTIONS_ROLE_ARN`. See `identity/00-ci-trust/README.md`. + +**Permissions-boundary contract (repo-wide):** any `aws_iam_role` that the CI applies **must** set `permissions_boundary` (= the `permissions_boundary_arn` output, `tf-managed-boundary`) and `path` (= the `managed_path` output, `/tf-managed///`), or the apply is rejected by the CI grant's conditions. Set both via the root's `workspace/.tfvars` (see the Terraform workspaces convention below). The boundary caps every CI-created role to "admin minus a hardened deny-list" so a role-creating role can't escalate. Rationale is documented inline in `identity/00-ci-trust/iam-ci.tf`. ## Conventions @@ -95,4 +114,4 @@ Standalone root (provisions no cluster) for **keyless GitHub-OIDC → AWS** CI a **PRs**: After each commit + push on a branch, create a draft PR if none exists. Title: `: description`. Body: context, changes, linked issues (`Closes #123`), test instructions. Use [Conventional Comments](https://conventionalcomments.org/) in reviews (`praise`, `nitpick`, `suggestion`, `issue`, `todo`, `question`, `thought`). -**Terraform workspaces**: a root that runs through CI declares its workspaces declaratively in a `workspace/` folder — one `workspace/.tfvars` per workspace. The **filename is the terraform workspace name** (so state lands at `//`, isolated per root) and the **file contents are that workspace's variable values**. The reusable composite action `.github/actions/terraform` loops over these files, doing `workspace select -or-create ` + `plan`/`apply -var-file=workspace/.tfvars`. This replaces the old reliance on a local, gitignored `.terraform/environment` (invisible to CI — a `default`-workspace run collides on the state key). `s3-lister-role/` is the reference example. +**Terraform workspaces**: a root that runs through CI declares its workspaces declaratively in a `workspace/` folder — one `workspace/.tfvars` per workspace. The **filename is the terraform workspace name** (so state lands at `//`, isolated per root) and the **file contents are that workspace's variable values**. The reusable composite action `.github/actions/terraform` loops over these files, doing `workspace select -or-create ` + `plan`/`apply -var-file=workspace/.tfvars`. This replaces the old reliance on a local, gitignored `.terraform/environment` (invisible to CI — a `default`-workspace run collides on the state key). `state/10-access/` is the reference example. diff --git a/README.md b/README.md index fd8a8c6..4d00bd4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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. +All remote based environment states are stored in the S3 bucket provisioned by `./state/00-backend`. Check `version.tf` files from any root module in this repository for an example, stored under eu-west-3 region. ## Forking diff --git a/cluster/scaleway/main.tf b/cluster/scaleway/main.tf deleted file mode 100644 index cb2c7b0..0000000 --- a/cluster/scaleway/main.tf +++ /dev/null @@ -1,171 +0,0 @@ -resource "scaleway_vpc_private_network" "cluster" {} - -resource "scaleway_k8s_cluster" "homelab" { - name = "homelab" - version = "1.35" - - auto_upgrade { - enable = true - maintenance_window_start_hour = 2 - maintenance_window_day = "any" - } - cni = "cilium" - type = "kapsule" - private_network_id = scaleway_vpc_private_network.cluster.id - - # Alright for homelab, might not be true for production stuff - delete_additional_resources = true - - tags = ["homelab", "terraform"] -} - -resource "scaleway_k8s_pool" "default" { - cluster_id = scaleway_k8s_cluster.homelab.id - name = "default" - node_type = "DEV1-M" - size = var.node_count - min_size = 0 - max_size = 3 - autoscaling = false - autohealing = true - - lifecycle { - create_before_destroy = true - } -} - -# Without waiting for at least one pool, the cluster status is `pool_required` -# and the DNS entry for the API server is not yet resolvable. -resource "null_resource" "kubeconfig" { - depends_on = [scaleway_k8s_pool.default] - triggers = { - host = scaleway_k8s_cluster.homelab.kubeconfig[0].host - token = scaleway_k8s_cluster.homelab.kubeconfig[0].token - cluster_ca_certificate = scaleway_k8s_cluster.homelab.kubeconfig[0].cluster_ca_certificate - } -} - -resource "local_file" "kubeconfig" { - depends_on = [null_resource.kubeconfig] - content = scaleway_k8s_cluster.homelab.kubeconfig[0].config_file - filename = pathexpand("~/.kube/scaleway-homelab.yaml") - file_permission = "0600" -} - -# Scaleway regional IDs are "/"; the scw CLI wants the bare UUID, -# and the generated kubeconfig context is named "-". -locals { - cluster_uuid = split("/", scaleway_k8s_cluster.homelab.id)[1] -} - -# Optional local DevX: merge this cluster into ~/.kube/config via the Scaleway -# CLI (instead of juggling the standalone file above). Opt-in via -# install_kubeconfig = true in your *.auto.tfvars. `scw k8s kubeconfig install` -# has no rename flag, so we rename the context cleanly with kubectl afterwards. -resource "null_resource" "install_kubeconfig" { - count = var.install_kubeconfig ? 1 : 0 - - triggers = { - cluster_id = scaleway_k8s_cluster.homelab.id - context_name = var.kubeconfig_context_name - } - - depends_on = [null_resource.kubeconfig] - - provisioner "local-exec" { - command = <<-EOT - scw k8s kubeconfig install ${local.cluster_uuid} - # Override any pre-existing context with the target name, then rename. - kubectl config delete-context "${var.kubeconfig_context_name}" 2>/dev/null || true - kubectl config rename-context "${scaleway_k8s_cluster.homelab.name}-${local.cluster_uuid}" "${var.kubeconfig_context_name}" - EOT - } -} - -# ── ArgoCD bootstrap (toggle with var.bootstrap_argocd) ───────────────────── - -data "infisical_secrets" "this" { - count = var.bootstrap_argocd ? 1 : 0 - env_slug = "staging" - workspace_id = "7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" // project ID - folder_path = "/" -} - -resource "helm_release" "argocd" { - count = var.bootstrap_argocd ? 1 : 0 - name = "argocd" - namespace = "argocd" - create_namespace = true - - repository = "https://argoproj.github.io/argo-helm" - chart = "argo-cd" - version = "9.4.17" - - # Fail fast (under the 5m default) if ArgoCD doesn't come up. Transient blips - # (e.g. quay.io 502s) are absorbed by retrying the apply (see mise scaleway-up). - timeout = 240 - - depends_on = [local_file.kubeconfig] - - set_sensitive = [{ - name = "configs.secret.argocdServerAdminPassword" - # ArgoCD expects a bcrypt() hash here. bcrypt() would regenerate on every - # run, so we store the hash directly in Infisical to avoid spurious diffs. - value = data.infisical_secrets.this[0].secrets["ArgoCD_admin_encrypted"].value - }] - - values = [<)" >&2 + exit 1 +fi +terraform apply -auto-approve -var-file="env/${ws}.tfvars" +""" [tasks.dev] description = "Full local dev environment (minikube + ArgoCD)" @@ -45,90 +54,17 @@ mise run local-apply description = "Reset local minikube cluster" run = "minikube delete" -# ── Scaleway (homelab) ────────────────────────────────────────────────────── - -[tasks.scaleway-provision] -description = "Provision the Scaleway cluster only (no ArgoCD). Use for the first from-scratch apply." -dir = "cluster/scaleway" -run = "terraform init && terraform apply -auto-approve -var bootstrap_argocd=false" - -[tasks.scaleway-up] -description = "Provision the Scaleway cluster + bootstrap ArgoCD" -dir = "cluster/scaleway" -run = """ -terraform init -n=0 -until terraform apply -auto-approve; do - n=$((n + 1)) - if [ "$n" -ge 3 ]; then echo "terraform apply failed after $n attempts"; exit 1; fi - echo "apply attempt $n failed (likely a transient registry/API blip); retrying in 15s..." - sleep 15 -done -""" - -[tasks.scaleway-pause] -description = "Scale Scaleway node pool to 0 (free tier)" -dir = "cluster/scaleway" -run = "terraform apply -auto-approve -var='node_count=0'" - -[tasks.scaleway-resume] -description = "Scale Scaleway node pool back to 1" -dir = "cluster/scaleway" -run = "terraform apply -auto-approve -var='node_count=1'" - -[tasks.scaleway-apply] -description = "Plain terraform apply on the Scaleway root" -dir = "cluster/scaleway" -run = "terraform apply" - -[tasks.scaleway-destroy] -description = "Tear down the Scaleway cluster (terraform destroy)" -dir = "cluster/scaleway" -run = "terraform destroy" - -# ── GitHub CI identity (github-ci/) ───────────────────────────────────────── - -[tasks.github-ci-init] -description = "terraform init on the github-ci root" -dir = "github-ci" -run = "terraform init" - -[tasks.github-ci-plan] -description = "terraform plan on the github-ci root" -dir = "github-ci" -run = "terraform init && terraform plan" - -[tasks.github-ci-apply] -description = "terraform apply on the github-ci root (provisions the Scaleway CI IAM key)" -dir = "github-ci" -run = "terraform apply" - -# ── AWS GitHub OIDC identity (aws-github-oidc/) ────────────────────────────── - -[tasks.aws-github-oidc-init] -description = "terraform init on the aws-github-oidc root" -dir = "aws-github-oidc" -run = "terraform init" - -[tasks.aws-github-oidc-plan] -description = "terraform plan on the aws-github-oidc root" -dir = "aws-github-oidc" -run = "terraform init && terraform plan" - -[tasks.aws-github-oidc-apply] -description = "terraform apply on the aws-github-oidc root (provisions the GitHub OIDC provider + IAM role)" -dir = "aws-github-oidc" -run = "terraform apply" # ── Provider locks ─────────────────────────────────────────────────────────── [tasks.lock] description = "Re-generate all .terraform.lock.hcl files for darwin_arm64 + linux_amd64" run = """ -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 -terraform -chdir=aws-github-oidc providers lock -platform=darwin_arm64 -platform=linux_amd64 -terraform -chdir=s3-lister-role providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=00-remote_state/0-backend providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=00-remote_state/1-iam providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=01-iam/aws providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=01-iam/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=01-iam/infisical providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=02-cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64 +terraform -chdir=02-cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 """ diff --git a/modules/README.md b/modules/README.md new file mode 100644 index 0000000..44f177f --- /dev/null +++ b/modules/README.md @@ -0,0 +1,13 @@ +# modules/ + +Reusable Terraform modules shared across the roots in this repo. + +Empty for now — the roots currently consume community modules +(`terraform-aws-modules/*`) directly. When a pattern is duplicated across roots +(e.g. a Scaleway CI IAM identity used by both `ci/10-scaleway/` and a future +`ci/11-scaleway-tf/`), factor it out into a module here and reference it with a +relative `source = "../../modules/"`. + +Roots (the deployable configs) live under the domain folders `state/`, +`identity/`, `ci/`, and `cluster/`; this folder holds only non-deployable, +reusable building blocks. From 719f16b7fcc7b5f065d66a4757f2bd74a7e6c23e Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Thu, 18 Jun 2026 17:28:42 +0200 Subject: [PATCH 2/2] feat(01-iam/infisical): add Infisical IAM terraform root New root that provisions the Infisical machine identity used by other Terraform roots to fetch secrets (ArgoCD admin password hash, API keys). Outputs the machine identity ID for downstream consumption. Co-Authored-By: Claude Sonnet 4.6 --- 01-iam/infisical/.terraform.lock.hcl | 26 ++++++ 01-iam/infisical/README.md | 87 ++++++++++++++++++++ 01-iam/infisical/env/01-iam-infisical.tfvars | 8 ++ 01-iam/infisical/main.tf | 56 +++++++++++++ 01-iam/infisical/outputs.tf | 11 +++ 01-iam/infisical/variables.tf | 43 ++++++++++ 01-iam/infisical/version.tf | 26 ++++++ 7 files changed, 257 insertions(+) create mode 100644 01-iam/infisical/.terraform.lock.hcl create mode 100644 01-iam/infisical/README.md create mode 100644 01-iam/infisical/env/01-iam-infisical.tfvars create mode 100644 01-iam/infisical/main.tf create mode 100644 01-iam/infisical/outputs.tf create mode 100644 01-iam/infisical/variables.tf create mode 100644 01-iam/infisical/version.tf diff --git a/01-iam/infisical/.terraform.lock.hcl b/01-iam/infisical/.terraform.lock.hcl new file mode 100644 index 0000000..38031e0 --- /dev/null +++ b/01-iam/infisical/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/infisical/infisical" { + version = "0.16.30" + constraints = "~> 0.16" + hashes = [ + "h1:6XTllezN1ZbgFyxQfLumAwonXMyQl4KdgQLQx+vdWOc=", + "h1:MxCBGCIQUhX/FTSuJsxAwFR5ybakkSxORv259Fbl5Wc=", + "zh:07924c8210300cf3e58c044ca820188644c648c9777d0f3e5ebdf87ae3529aa1", + "zh:0d728a71c0b47358815b5cf13e6070f512ebda61268c6d6e89906d6c169cab53", + "zh:1f8a327f819382d2d0d59f6d4c24fb1a87038efab9f62934519ac3474633b65a", + "zh:397e937a685a68a72779d1ba123f5b291bea5aa61a0fe60adbf56ce3ef4c387c", + "zh:405ee9b531ef8b3402fea060970bea1c31c274d1ff0e1da075721466ca0999fd", + "zh:4ca7f0182b0f7ac08f91803bdb7dd3996e98c2e46a1f14caef8c02c4f54fdf66", + "zh:6c3ed4b716899ca321fdf0a687df24519f5872343cb651af234c2ccfc0a26be8", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:a04c0a7a3f2daf2e940d3c53f15fc5d923ae9a9e916d0cdfb30c7c4e810d1df0", + "zh:aed0de407ef288a606552c719f517296292c9044d8e314c5b162cb31baf744d9", + "zh:b559fbb532566b9baa3ac06c7061405d274e6d4708cf18180d0b66cee0e79179", + "zh:cd284e2b0e037d124d25ed2f4a5d57279fa39864314a35b06e36888ef78d5511", + "zh:e3b549abcbb2f6d5a03f8244826facce647a67d8ce0cb6a65dd19f7b6cf2a227", + "zh:e9258e0fb4ae3ad7fab80609ba3cf46d9a890ecf1619f54eaa661ebb4f61ffdd", + "zh:ff5e17795828ff8f5c32eec0ae7c9746b26b51e803e8fb28f7131a98cc1fc301", + ] +} diff --git a/01-iam/infisical/README.md b/01-iam/infisical/README.md new file mode 100644 index 0000000..1745ee1 --- /dev/null +++ b/01-iam/infisical/README.md @@ -0,0 +1,87 @@ +# 01-iam/infisical — GitHub Actions → Infisical (OIDC) + +A standalone Terraform root that sets up **keyless GitHub-OIDC → Infisical**: a +dedicated Infisical machine identity that GitHub Actions authenticates as by +presenting its short-lived OIDC token — no long-lived Infisical secret stored in +CI. + +This is the keyless counterpart to [`01-iam/scaleway`](../scaleway) (which has to +use a static API key because Scaleway IAM isn't an OIDC relying party). Infisical +**is**, so we get the real keyless flow here. + +## How the flow works + +1. A workflow run requests an OIDC token from GitHub (`permissions: id-token: write`). +2. The workflow logs in to Infisical with that token + the identity ID. +3. Infisical verifies the token against GitHub's JWKS (`oidc_discovery_url`), + checks `iss` (`bound_issuer`), `aud` (`bound_audiences`) and `sub` + (`bound_claims.sub` = `repo:IntegratedDynamic/infrastructure:*`), then returns + a short-lived Infisical access token (`access_token_ttl`, default 600s). +4. With that token the workflow reads secrets from the **Platform** project (the + identity is granted the read-only `viewer` project role). + +## What it creates + +- `infisical_identity.github_actions` — the CI identity (org-level role + `no-access`; real permissions come from the project membership, not org-wide). +- `infisical_identity_oidc_auth.github_actions` — the OIDC trust: GitHub issuer + + discovery URL, bound audience, and `sub` scoped to this one repo (any + branch/PR/tag). +- `infisical_project_identity.github_actions` — grants the identity the `viewer` + role on the Platform project so CI can read its secrets. + +## The bootstrap chicken-and-egg + +Creating an OIDC identity still requires an authenticated provider, so this root +authenticates with a **universal-auth machine identity** (`client_id` / +`client_secret` in `default.auto.tfvars`, gitignored, per-developer). That +bootstrap identity is the seed; once this OIDC identity exists, CI authenticates +with it instead of any static secret. + +## Credentials + +- **Infisical** provider — universal-auth machine identity from + `*.auto.tfvars`. Host defaults to `https://app.infisical.com`. +- **S3 state backend** — AWS-style env vars (injected by `mise.toml`'s `[env]` + block like the other roots). + +## Apply + +```bash +terraform -chdir=01-iam/infisical init +terraform -chdir=01-iam/infisical plan -var-file=env/01-iam-infisical.tfvars +terraform -chdir=01-iam/infisical apply -var-file=env/01-iam-infisical.tfvars +``` + +> Never `apply`/`destroy` here without explicit approval. + +After apply, `terraform output identity_id` is the principal GitHub Actions logs +in as — wire it into the workflow (e.g. as a repo variable). + +## Wiring a workflow (example) + +```yaml +permissions: + id-token: write + contents: read +steps: + - uses: Infisical/secrets-action@v1 + with: + method: oidc + identity-id: ${{ vars.INFISICAL_IDENTITY_ID }} # = output identity_id + project-slug: platform-p-qc1 + env-slug: staging + secret-path: /ci +``` + +The OIDC token's default `aud` is `https://github.com/IntegratedDynamic` +(matching `github_oidc_audience`); if the workflow requests a custom audience, +update that variable to match or login is rejected. + +## Trust scope / revocation + +- Trust is pinned to `repo:IntegratedDynamic/infrastructure:*` — only workflows + in this repo can authenticate. Narrow it further (e.g. a single branch) by + tightening `bound_claims.sub`. +- To revoke access, destroy the identity (or remove the project membership to + drop project access while keeping the identity). diff --git a/01-iam/infisical/env/01-iam-infisical.tfvars b/01-iam/infisical/env/01-iam-infisical.tfvars new file mode 100644 index 0000000..bece347 --- /dev/null +++ b/01-iam/infisical/env/01-iam-infisical.tfvars @@ -0,0 +1,8 @@ +org_id="73133541-f1c4-40d5-93f5-a5e073ab0264" +project_id="7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" +project_role_slug="viewer" + +github_org="IntegratedDynamic" +github_repo="infrastructure" +github_oidc_audience="https://github.com/IntegratedDynamic" +access_token_ttl=600 diff --git a/01-iam/infisical/main.tf b/01-iam/infisical/main.tf new file mode 100644 index 0000000..4455286 --- /dev/null +++ b/01-iam/infisical/main.tf @@ -0,0 +1,56 @@ +# GitHub Actions → Infisical, keyless via OIDC. +# +# A workflow run gets a short-lived OIDC token from GitHub; Infisical verifies it +# against GitHub's JWKS and trades it for an Infisical access token — so CI holds +# no long-lived Infisical credential. This is the keyless counterpart to +# 01-iam/scaleway's static API key. +# +# Bootstrap note: creating this identity still needs the universal-auth machine +# identity wired into the provider (default.auto.tfvars). That bootstrap identity +# is the chicken-and-egg seed; once this OIDC identity exists, CI uses it instead. + +resource "infisical_identity" "github_actions" { + name = "github-actions-oidc" + org_id = var.org_id + + # Org-level role is no-access on purpose: this identity draws its actual + # permissions from the project membership below, never org-wide. + role = "no-access" +} + +resource "infisical_identity_oidc_auth" "github_actions" { + identity_id = infisical_identity.github_actions.id + + # GitHub's OIDC provider. The discovery URL serves the JWKS Infisical uses to + # verify the token signature; bound_issuer must equal the token's `iss` claim. + oidc_discovery_url = "https://token.actions.githubusercontent.com" + bound_issuer = "https://token.actions.githubusercontent.com" + + # The `aud` GitHub stamps on the token. With no audience requested in the + # workflow, GitHub defaults it to the repository-owner URL + # (https://github.com/); set var.github_oidc_audience to match whatever + # the workflow requests. + bound_audiences = [var.github_oidc_audience] + + # Scope trust to any workflow in this one repo (any branch / PR / tag), + # mirroring the AWS root's repo:/:* trust. bound_claims values may be + # glob patterns — bound_subject is an exact match, which we don't want here. + bound_claims = { + sub = "repo:${var.github_org}/${var.github_repo}:*" + } + + # Short-lived CI token — long enough for a job, no longer. + access_token_ttl = var.access_token_ttl +} + +# Grant the identity access to the Platform project so CI can read its secrets. +resource "infisical_project_identity" "github_actions" { + project_id = var.project_id + identity_id = infisical_identity.github_actions.id + + roles = [ + { + role_slug = var.project_role_slug + }, + ] +} diff --git a/01-iam/infisical/outputs.tf b/01-iam/infisical/outputs.tf new file mode 100644 index 0000000..a8be3b0 --- /dev/null +++ b/01-iam/infisical/outputs.tf @@ -0,0 +1,11 @@ +# The identity ID is the principal GitHub Actions logs in as (the OIDC login call +# takes identityId). Public identifier — safe to surface and wire into CI. +output "identity_id" { + description = "Infisical identity ID GitHub Actions authenticates as via OIDC." + value = infisical_identity.github_actions.id +} + +output "oidc_auth_id" { + description = "ID of the OIDC auth configuration attached to the identity." + value = infisical_identity_oidc_auth.github_actions.id +} diff --git a/01-iam/infisical/variables.tf b/01-iam/infisical/variables.tf new file mode 100644 index 0000000..ecc5762 --- /dev/null +++ b/01-iam/infisical/variables.tf @@ -0,0 +1,43 @@ +variable "org_id" { + description = "Infisical organization ID the GitHub Actions identity is created in." + type = string + default = "73133541-f1c4-40d5-93f5-a5e073ab0264" +} + +variable "project_id" { + description = "Infisical project (workspace) ID the identity is granted access to. Defaults to the Platform project." + type = string + default = "7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" +} + +variable "project_role_slug" { + description = "Project role granted to the GitHub Actions identity. 'viewer' = read-only secret access." + type = string + default = "viewer" +} + +# Trust is scoped to exactly one repo: repo:/:* . Only workflows in +# this repo can present an OIDC token Infisical will accept. +variable "github_org" { + description = "GitHub organization that owns the repo allowed to authenticate." + type = string + default = "IntegratedDynamic" +} + +variable "github_repo" { + description = "GitHub repository whose workflows may authenticate (sub claim is scoped to it)." + type = string + default = "infrastructure" +} + +variable "github_oidc_audience" { + description = "Expected `aud` claim on the GitHub OIDC token. Defaults to GitHub's repository-owner default audience." + type = string + default = "https://github.com/IntegratedDynamic" +} + +variable "access_token_ttl" { + description = "Lifetime (seconds) of the Infisical access token CI receives after OIDC login." + type = number + default = 600 +} diff --git a/01-iam/infisical/version.tf b/01-iam/infisical/version.tf new file mode 100644 index 0000000..d105afd --- /dev/null +++ b/01-iam/infisical/version.tf @@ -0,0 +1,26 @@ +terraform { + # Remote state on the shared org S3 bucket (same bucket every other root uses), + # under this root's own key so its state/blast-radius stay isolated. Fresh + # prefix for a brand-new root — no state to migrate. + backend "s3" { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + workspace_key_prefix = "infisical-github-oidc" + key = "terraform.tfstate" + encrypt = true + use_lockfile = true + } + + required_providers { + infisical = { + source = "infisical/infisical" + version = "~> 0.16" + } + } +} + +# The provider authenticates with a universal-auth machine identity (the +# bootstrap identity). Its client_id / client_secret come from *.auto.tfvars +# (per-developer, gitignored — see default.auto.tfvars). Host defaults to +# https://app.infisical.com. +provider "infisical" {}