From 8e8294617fa210209ec94071983cc26c7ce9860d Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Thu, 18 Jun 2026 17:45:17 +0200 Subject: [PATCH 1/6] ci(actions): refactor terraform workflows into a reusable composite action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the boilerplate (setup-terraform + AWS OIDC + init/workspace/plan-apply) into .github/actions/terraform. Both iam_terraform-backend-role and scaleway-plan now call it — the latter keeping its SCW/Infisical env vars and Infisical OIDC token step before the action call. Also fixes a destroy→plan bug in scaleway-plan.yml and adds concurrency guard to iam_terraform-backend-role.yml. Co-Authored-By: Claude Sonnet 4.6 --- .github/actions/terraform/action.yml | 56 +++++++++++++++---- .../workflows/iam_terraform-backend-role.yml | 41 ++++---------- .github/workflows/scaleway-plan.yml | 32 +++-------- CLAUDE.md | 4 +- 4 files changed, 64 insertions(+), 69 deletions(-) diff --git a/.github/actions/terraform/action.yml b/.github/actions/terraform/action.yml index 4087eaf..0fb280b 100644 --- a/.github/actions/terraform/action.yml +++ b/.github/actions/terraform/action.yml @@ -1,33 +1,65 @@ -name: Terraform (workspace-driven) +name: Terraform plan/apply description: > - Run terraform for a root. The workspace and its variables come from the root's - workspace/.tfvars: the filename is the terraform workspace, the contents - are its variables. Keeps workspace selection declarative and version-controlled - (not a local, gitignored .terraform/environment). + Set up Terraform, assume an AWS role via OIDC, then run + init → workspace select → plan (or apply). + The repository must already be checked out before calling this action. + Extra provider credentials (SCW_*, INFISICAL_AUTH_JWT…) must be set in the calling job env. inputs: root: - description: Path to the terraform root (passed to -chdir). + description: Path to the Terraform root (passed to -chdir). required: true + tfvars-file: + description: > + Filename of the .tfvars under env/ (e.g. 02-cluster-staging.tfvars). + Workspace name is derived by stripping the .tfvars extension. + required: true + aws-role-arn: + description: ARN of the IAM role to assume via OIDC. + required: true + aws-region: + description: AWS region for the assumed role. + required: false + default: eu-west-3 apply: - description: 'When "true", apply; otherwise plan.' + description: 'When "true", terraform apply; otherwise terraform plan.' required: false default: "false" + lock: + description: 'When "false", pass -lock=false (for read-only roles that cannot acquire the S3 state lock).' + required: false + default: "true" runs: using: composite steps: - - shell: bash + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + # Keep in sync with mise.toml [tools] terraform version. + terraform_version: "1.14" + + - name: Assume AWS role via OIDC + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + with: + role-to-assume: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + + - name: Terraform plan/apply + shell: bash env: ROOT: ${{ inputs.root }} + TFVARS: ${{ inputs.tfvars-file }} APPLY: ${{ inputs.apply }} + LOCK: ${{ inputs.lock }} run: | set -euo pipefail - ws="$(basename "$ROOT"/workspace/*.tfvars .tfvars)" + ws="${TFVARS%.tfvars}" terraform -chdir="$ROOT" init -input=false - terraform -chdir="$ROOT" workspace select -or-create "$ws" + terraform -chdir="$ROOT" workspace select "$ws" if [ "$APPLY" = "true" ]; then - terraform -chdir="$ROOT" apply -auto-approve -input=false -var-file="workspace/$ws.tfvars" + terraform -chdir="$ROOT" apply -auto-approve -input=false -var-file="env/${TFVARS}" + elif [ "$LOCK" = "false" ]; then + terraform -chdir="$ROOT" plan -input=false -lock=false -var-file="env/${TFVARS}" else - terraform -chdir="$ROOT" plan -input=false -var-file="workspace/$ws.tfvars" + terraform -chdir="$ROOT" plan -input=false -var-file="env/${TFVARS}" fi diff --git a/.github/workflows/iam_terraform-backend-role.yml b/.github/workflows/iam_terraform-backend-role.yml index 7681912..09f273f 100644 --- a/.github/workflows/iam_terraform-backend-role.yml +++ b/.github/workflows/iam_terraform-backend-role.yml @@ -16,6 +16,10 @@ on: - '00-remote_state/1-iam/**' - '.github/workflows/iam_terraform-backend-role.yml' +concurrency: + group: tf-backend-role-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -40,36 +44,11 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + fetch-depth: 1 - - 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 + - uses: ./.github/actions/terraform 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}" + root: 00-remote_state/1-iam + tfvars-file: 00-remote-state-iam.tfvars + aws-role-arn: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} + apply: ${{ github.event_name == 'push' && 'true' || 'false' }} diff --git a/.github/workflows/scaleway-plan.yml b/.github/workflows/scaleway-plan.yml index daec1e9..598387b 100644 --- a/.github/workflows/scaleway-plan.yml +++ b/.github/workflows/scaleway-plan.yml @@ -7,8 +7,7 @@ name: Scaleway Plan (via s3-lister role) # The s3-lister role is READ-ONLY on S3, which shapes the whole job: # - state is read but never written -> plan only, never apply # - the S3-native lock can't be written -> -lock=false -# - the workspace already exists in S3 -> select it via TF_WORKSPACE; -# never `terraform workspace new` (a write the read-only role can't do) +# - the workspace already exists in S3 -> workspace select (never new) # - bootstrap_argocd=false -> skip the Infisical data source # and the in-cluster helm/kubernetes providers (no cluster, no secrets in CI) # @@ -70,19 +69,7 @@ jobs: - 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" - - # Direct OIDC -> s3-lister. Works because the role's trust now includes a - # GitHub-OIDC statement scoped org-wide (repo:IntegratedDynamic/*). - - name: Assume the s3-lister role via OIDC - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 - with: - role-to-assume: ${{ vars.AWS_S3_LISTER_ROLE_ARN }} - aws-region: eu-west-3 + fetch-depth: 1 # 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 @@ -97,12 +84,9 @@ jobs: 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: | - 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}" + - uses: ./.github/actions/terraform + with: + root: 02-cluster/scaleway + tfvars-file: 02-cluster-staging.tfvars + aws-role-arn: ${{ vars.AWS_S3_LISTER_ROLE_ARN }} + lock: "false" diff --git a/CLAUDE.md b/CLAUDE.md index 684d372..b169546 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ Standalone root that stands up the **Scaleway IAM identity GitHub Actions uses t 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`. +**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 `env/.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 @@ -114,4 +114,4 @@ The CI **identity & governance foundation**: **keyless GitHub-OIDC → AWS** acc **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). `state/10-access/` is the reference example. +**Terraform workspaces**: a root that runs through CI declares its workspace variables in an `env/` folder — one `env/.tfvars` per workspace. The **filename (without `.tfvars`) 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` takes `root` + `tfvars-file` inputs and runs `workspace select ` + `plan`/`apply -var-file=env/.tfvars` after assuming an AWS role via OIDC. The repo must be checked out before calling this action (it is a local action). This replaces the old reliance on a local, gitignored `.terraform/environment` (invisible to CI — a `default`-workspace run collides on the state key). From 84a33270da355242ffee3fd5d79c92f8d85418bc Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Fri, 19 Jun 2026 12:09:26 +0200 Subject: [PATCH 2/6] ci: reusable terraform workflow with first-class secret handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the .github/actions/terraform composite action in a reusable workflow (.github/workflows/terraform.yml) that owns the `secrets:` block — Scaleway keys are no longer passed as plain action inputs. The action declares NO secret inputs; provider creds flow through the job env / the `scaleway` environment. - Support plan | apply | destroy; the reusable workflow derives the command from the event (push→apply, schedule→destroy, else plan), overridable via `command`. - scaleway.yml: PR→plan, push→apply, daily 18h cron→destroy, workflow_dispatch up/down. Consolidates the former scaleway-plan.yml. Adds concurrency + timeout. - Rename the org state role s3-lister → tf-state-access (it grants AmazonS3FullAccess: R/W + lock — the old name was misleading). Wired via vars.AWS_TF_STATE_ROLE_ARN. - Fix stale docs/naming: the Scaleway CI policy (object-storage-ro → cluster-management; it actually grants Kubernetes/VPC/PrivateNetwork access) and the read-only descriptions in CLAUDE.md / 00-remote_state. Co-Authored-By: Claude Opus 4.8 --- .github/actions/terraform/action.yml | 75 +++++++--- .../workflows/iam_terraform-backend-role.yml | 47 ++---- .github/workflows/scaleway-plan.yml | 92 ------------ .github/workflows/scaleway.yml | 64 ++++++++ .github/workflows/terraform.yml | 138 ++++++++++++++++++ 00-remote_state/1-iam/main.tf | 12 +- 00-remote_state/1-iam/outputs.tf | 4 +- 00-remote_state/1-iam/variables.tf | 4 +- 01-iam/scaleway/main.tf | 7 +- CLAUDE.md | 6 +- 10 files changed, 290 insertions(+), 159 deletions(-) delete mode 100644 .github/workflows/scaleway-plan.yml create mode 100644 .github/workflows/scaleway.yml create mode 100644 .github/workflows/terraform.yml diff --git a/.github/actions/terraform/action.yml b/.github/actions/terraform/action.yml index 0fb280b..3bb46db 100644 --- a/.github/actions/terraform/action.yml +++ b/.github/actions/terraform/action.yml @@ -1,9 +1,15 @@ -name: Terraform plan/apply +name: Terraform description: > - Set up Terraform, assume an AWS role via OIDC, then run - init → workspace select → plan (or apply). - The repository must already be checked out before calling this action. - Extra provider credentials (SCW_*, INFISICAL_AUTH_JWT…) must be set in the calling job env. + Run Terraform for a workspace-driven root: set up Terraform, mint an Infisical + OIDC token, assume an AWS role via OIDC, then init → workspace select → + plan/apply/destroy. The repository must already be checked out. + + This action takes NO secret inputs. Provider credentials are read from the + calling job's environment (set them there — the reusable terraform.yml workflow + does, sourcing SCW_ACCESS_KEY/SCW_SECRET_KEY from its `secrets:` block): + - SCW_ACCESS_KEY / SCW_SECRET_KEY (Scaleway API key) + - SCW_DEFAULT_ORGANIZATION_ID / _PROJECT_ID + - INFISICAL_MACHINE_IDENTITY_ID (for the minted INFISICAL_AUTH_JWT) inputs: root: @@ -14,21 +20,28 @@ inputs: Filename of the .tfvars under env/ (e.g. 02-cluster-staging.tfvars). Workspace name is derived by stripping the .tfvars extension. required: true + command: + description: 'Terraform command to run: plan | apply | destroy.' + required: false + default: plan + lock: + description: 'When "false", pass -lock=false (read-only roles that cannot acquire the S3 state lock).' + required: false + default: "true" aws-role-arn: - description: ARN of the IAM role to assume via OIDC. + description: ARN of the IAM role to assume via OIDC (Terraform-state R/W). required: true aws-region: description: AWS region for the assumed role. required: false default: eu-west-3 - apply: - description: 'When "true", terraform apply; otherwise terraform plan.' - required: false - default: "false" - lock: - description: 'When "false", pass -lock=false (for read-only roles that cannot acquire the S3 state lock).' + infisical-oidc-audience: + description: > + OIDC audience for the Infisical machine identity. When set (the default), + mint a GitHub OIDC JWT into INFISICAL_AUTH_JWT. Set to "" to skip it for + roots that don't read Infisical. required: false - default: "true" + default: https://github.com/IntegratedDynamic runs: using: composite @@ -38,28 +51,48 @@ runs: # Keep in sync with mise.toml [tools] terraform version. terraform_version: "1.14" + # Keyless Infisical auth: mint a GitHub OIDC JWT into INFISICAL_AUTH_JWT (the + # provider's default token env var). The audience must match the identity's + # boundAudiences. getIDToken masks the value automatically. + - name: Mint Infisical OIDC token + if: inputs.infisical-oidc-audience != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + AUDIENCE: ${{ inputs.infisical-oidc-audience }} + with: + script: | + const jwt = await core.getIDToken(process.env.AUDIENCE) + core.exportVariable('INFISICAL_AUTH_JWT', jwt) + - name: Assume AWS role via OIDC uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 with: role-to-assume: ${{ inputs.aws-role-arn }} aws-region: ${{ inputs.aws-region }} - - name: Terraform plan/apply + - name: Terraform ${{ inputs.command }} shell: bash env: ROOT: ${{ inputs.root }} TFVARS: ${{ inputs.tfvars-file }} - APPLY: ${{ inputs.apply }} + COMMAND: ${{ inputs.command }} LOCK: ${{ inputs.lock }} + # SCW_* and INFISICAL_MACHINE_IDENTITY_ID are inherited from the job env. run: | set -euo pipefail ws="${TFVARS%.tfvars}" + lock_arg=""; [ "$LOCK" = "false" ] && lock_arg="-lock=false" terraform -chdir="$ROOT" init -input=false - terraform -chdir="$ROOT" workspace select "$ws" - if [ "$APPLY" = "true" ]; then - terraform -chdir="$ROOT" apply -auto-approve -input=false -var-file="env/${TFVARS}" - elif [ "$LOCK" = "false" ]; then - terraform -chdir="$ROOT" plan -input=false -lock=false -var-file="env/${TFVARS}" + # apply may target a brand-new env whose workspace doesn't exist yet; + # read paths (plan/destroy) select an existing one. + if [ "$COMMAND" = "apply" ]; then + terraform -chdir="$ROOT" workspace select -or-create "$ws" else - terraform -chdir="$ROOT" plan -input=false -var-file="env/${TFVARS}" + terraform -chdir="$ROOT" workspace select "$ws" fi + case "$COMMAND" in + plan) terraform -chdir="$ROOT" plan -input=false $lock_arg -var-file="env/${TFVARS}" ;; + apply) terraform -chdir="$ROOT" apply -input=false -auto-approve -var-file="env/${TFVARS}" ;; + destroy) terraform -chdir="$ROOT" destroy -input=false -auto-approve $lock_arg -var-file="env/${TFVARS}" ;; + *) echo "::error::unknown command '$COMMAND' (expected plan|apply|destroy)"; exit 1 ;; + esac diff --git a/.github/workflows/iam_terraform-backend-role.yml b/.github/workflows/iam_terraform-backend-role.yml index 09f273f..4ed07ca 100644 --- a/.github/workflows/iam_terraform-backend-role.yml +++ b/.github/workflows/iam_terraform-backend-role.yml @@ -1,19 +1,23 @@ 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. +# Terraform GitOps for the 00-remote_state/1-iam/ root: the org-wide state-access +# role is created/synchronised from remote, through the reusable terraform.yml +# workflow (plan on PR, apply 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/actions/terraform/**' + - '.github/workflows/terraform.yml' - '.github/workflows/iam_terraform-backend-role.yml' pull_request: paths: - '00-remote_state/1-iam/**' + - '.github/actions/terraform/**' + - '.github/workflows/terraform.yml' - '.github/workflows/iam_terraform-backend-role.yml' concurrency: @@ -22,33 +26,14 @@ concurrency: permissions: contents: read + id-token: write # passed down to the reusable workflow for AWS OIDC 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 - fetch-depth: 1 - - - uses: ./.github/actions/terraform - with: - root: 00-remote_state/1-iam - tfvars-file: 00-remote-state-iam.tfvars - aws-role-arn: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} - apply: ${{ github.event_name == 'push' && 'true' || 'false' }} + uses: ./.github/workflows/terraform.yml + with: + root: 00-remote_state/1-iam + tfvars-file: 00-remote-state-iam.tfvars + aws-role-arn: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} + # This root touches neither Infisical nor Scaleway — skip the OIDC mint. + infisical-oidc-audience: "" diff --git a/.github/workflows/scaleway-plan.yml b/.github/workflows/scaleway-plan.yml deleted file mode 100644 index 598387b..0000000 --- a/.github/workflows/scaleway-plan.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Scaleway Plan (via s3-lister role) - -# Plans cluster/scaleway using the org-wide s3-lister IAM role, assumed DIRECTLY -# via GitHub OIDC — no static AWS keys, and no routing through the bootstrap CI -# role (that role exists only to create/update roles, not to power pipelines). -# -# The s3-lister role is READ-ONLY on S3, which shapes the whole job: -# - state is read but never written -> plan only, never apply -# - the S3-native lock can't be written -> -lock=false -# - the workspace already exists in S3 -> workspace select (never new) -# - bootstrap_argocd=false -> skip the Infisical data source -# and the in-cluster helm/kubernetes providers (no cluster, no secrets in CI) -# -# Scaleway API access for the refresh still uses the static SCW key from secrets -# (Scaleway is not an OIDC relying party) — same creds as scaleway-auth-check.yml. - -on: - pull_request: - paths: - - '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: - runs-on: ubuntu-latest - # Scope SCW secret usage to a dedicated environment (matches - # scaleway-auth-check.yml; satisfies zizmor's secrets-outside-env audit). - environment: scaleway - permissions: - id-token: write # mint the OIDC token exchanged for temporary AWS creds - contents: read - env: - 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 }} - SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }} - SCW_DEFAULT_ORGANIZATION_ID: ${{ vars.SCW_DEFAULT_ORGANIZATION_ID }} - 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 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. - - name: Assert credentials are present - env: - 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 (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 ci/10-scaleway/README.md)." - exit 1 - fi - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - fetch-depth: 1 - - # 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) - - - uses: ./.github/actions/terraform - with: - root: 02-cluster/scaleway - tfvars-file: 02-cluster-staging.tfvars - aws-role-arn: ${{ vars.AWS_S3_LISTER_ROLE_ARN }} - lock: "false" diff --git a/.github/workflows/scaleway.yml b/.github/workflows/scaleway.yml new file mode 100644 index 0000000..9db9320 --- /dev/null +++ b/.github/workflows/scaleway.yml @@ -0,0 +1,64 @@ +name: Terraform — Scaleway cluster + +# Drives 02-cluster/scaleway through the reusable terraform.yml workflow: +# - pull_request → plan +# - push to main → apply +# - schedule (daily 18h) → destroy (cost-control teardown of the homelab) +# - workflow_dispatch → up/down on demand (plan | apply | destroy) +# +# State R/W + lock uses the org state role (vars.AWS_TF_STATE_ROLE_ARN — +# AmazonS3FullAccess, see 00-remote_state/1-iam). Scaleway cluster lifecycle uses +# the CI API key from the `scaleway` environment (Kubernetes/VPC/PrivateNetwork +# FullAccess, see 01-iam/scaleway). No static AWS or Infisical secrets. + +on: + pull_request: + paths: + - '02-cluster/scaleway/**' + - '.github/actions/terraform/**' + - '.github/workflows/terraform.yml' + - '.github/workflows/scaleway.yml' + push: + branches: [main] + paths: + - '02-cluster/scaleway/**' + - '.github/actions/terraform/**' + - '.github/workflows/terraform.yml' + - '.github/workflows/scaleway.yml' + schedule: + # 16:00 UTC = 18:00 Europe/Paris in summer (CEST). GitHub cron is UTC and does + # NOT follow DST, so in winter (CET) this fires at 17:00 Paris. + - cron: '0 16 * * *' + workflow_dispatch: + inputs: + command: + description: 'Terraform command (apply = up, destroy = down).' + type: choice + options: [plan, apply, destroy] + default: plan + +concurrency: + # Serialize everything that writes this root's state (apply/destroy never cancel + # mid-flight); only cancel superseded PR plans. schedule/push share ref=main. + group: scaleway-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + id-token: write # passed down to the reusable workflow for AWS + Infisical OIDC + +jobs: + terraform: + uses: ./.github/workflows/terraform.yml + with: + root: 02-cluster/scaleway + tfvars-file: 02-cluster-staging.tfvars + # workflow_dispatch sets this; on push/schedule/PR it's empty → derived. + command: ${{ inputs.command }} + aws-role-arn: ${{ vars.AWS_TF_STATE_ROLE_ARN }} + # The `scaleway` environment carries SCW_ACCESS_KEY / SCW_SECRET_KEY. It must + # keep NO required-reviewer rule, or the daily scheduled destroy would block. + environment: scaleway + infisical-identity-id: ${{ vars.INFISICAL_MACHINE_IDENTITY_ID }} + scw-organization-id: ${{ vars.SCW_DEFAULT_ORGANIZATION_ID }} + scw-project-id: ${{ vars.SCW_DEFAULT_PROJECT_ID }} diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..a57320b --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,138 @@ +name: Terraform (reusable) + +# Reusable wrapper around the .github/actions/terraform composite action. This is +# the layer that handles SECRETS properly: callers pass Scaleway keys via the +# `secrets:` keyword (or the job's `environment` provides them) instead of as +# plain action inputs. Invoke it at job level: +# +# jobs: +# terraform: +# uses: ./.github/workflows/terraform.yml +# with: { root: ..., tfvars-file: ..., aws-role-arn: ..., environment: scaleway } +# secrets: inherit # only needed for repo/org-level secrets +# +# Command resolution: the `command` input overrides; otherwise it is derived from +# the triggering event — push → apply, schedule → destroy, everything else → plan. + +on: + workflow_call: + inputs: + root: + description: Path to the Terraform root. + required: true + type: string + tfvars-file: + description: Filename of the .tfvars under env/ (workspace = name without .tfvars). + required: true + type: string + aws-role-arn: + description: ARN of the IAM role to assume via OIDC (Terraform-state R/W). + required: true + type: string + command: + description: 'Override: plan | apply | destroy. Empty = derive from the event.' + required: false + type: string + default: "" + aws-region: + required: false + type: string + default: eu-west-3 + lock: + description: When false, pass -lock=false. + required: false + type: boolean + default: true + environment: + description: GitHub environment to run in (scopes env secrets, e.g. the Scaleway keys). Empty = none. + required: false + type: string + default: "" + infisical-oidc-audience: + description: OIDC audience for the Infisical machine identity. Empty = skip the mint. + required: false + type: string + default: https://github.com/IntegratedDynamic + infisical-identity-id: + required: false + type: string + default: "" + scw-organization-id: + required: false + type: string + default: "" + scw-project-id: + required: false + type: string + default: "" + secrets: + SCW_ACCESS_KEY: + required: false + SCW_SECRET_KEY: + required: false + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + timeout-minutes: 20 + # Carrying the environment here lets env-scoped secrets resolve (they cannot + # be passed from the caller via `secrets:`/`inherit`). It also satisfies + # zizmor's secrets-in-environment audit. + environment: ${{ inputs.environment }} + permissions: + id-token: write # mint OIDC tokens for AWS + Infisical + contents: read + # Provider credentials handed to the composite action via the job env. Secrets + # come from the `secrets:` block above (or the environment); the rest are + # non-sensitive IDs passed as inputs. + env: + SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }} + SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }} + SCW_DEFAULT_ORGANIZATION_ID: ${{ inputs.scw-organization-id }} + SCW_DEFAULT_PROJECT_ID: ${{ inputs.scw-project-id }} + INFISICAL_MACHINE_IDENTITY_ID: ${{ inputs.infisical-identity-id }} + steps: + - name: Assert AWS role ARN is present + env: + ROLE_ARN: ${{ inputs.aws-role-arn }} + run: | + if [ -z "$ROLE_ARN" ]; then + echo "::error::aws-role-arn is empty — set the caller's vars.AWS_*_ROLE_ARN." + exit 1 + fi + + - name: Resolve terraform command + id: cmd + env: + EVENT: ${{ github.event_name }} + OVERRIDE: ${{ inputs.command }} + run: | + if [ -n "$OVERRIDE" ]; then + cmd="$OVERRIDE" + else + case "$EVENT" in + push) cmd=apply ;; + schedule) cmd=destroy ;; + *) cmd=plan ;; + esac + fi + echo "command=$cmd" >> "$GITHUB_OUTPUT" + echo "Resolved terraform command: $cmd (event: $EVENT, override: '${OVERRIDE:-}')" + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 1 + + - uses: ./.github/actions/terraform + with: + root: ${{ inputs.root }} + tfvars-file: ${{ inputs.tfvars-file }} + command: ${{ steps.cmd.outputs.command }} + lock: ${{ inputs.lock }} + aws-role-arn: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + infisical-oidc-audience: ${{ inputs.infisical-oidc-audience }} diff --git a/00-remote_state/1-iam/main.tf b/00-remote_state/1-iam/main.tf index 51041b2..bbe9359 100644 --- a/00-remote_state/1-iam/main.tf +++ b/00-remote_state/1-iam/main.tf @@ -1,6 +1,8 @@ -# 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 +# An org-wide Terraform-state access IAM role (read/write + state lock, via +# AmazonS3FullAccess) — the first role created BY the CI rather than by a human. +# Every state-touching workflow assumes it for plan AND apply/destroy alike. +# It is created/updated when a push to main touches this root (see +# .github/workflows/iam_terraform-backend-role.yml): GitHub Actions assumes the # 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 @@ -20,7 +22,7 @@ # # `use_name_prefix = false` keeps the role name EXACTLY `var.role_name` (no random # suffix) so its ARN is stable and a workflow can name it in configure-aws-credentials. -module "s3_lister" { +module "tf_state_access" { source = "terraform-aws-modules/iam/aws//modules/iam-role" version = "6.6.1" @@ -51,7 +53,7 @@ module "s3_lister" { }] } } - + policies = { S3FullAccess = "arn:aws:iam::aws:policy/AmazonS3FullAccess" } diff --git a/00-remote_state/1-iam/outputs.tf b/00-remote_state/1-iam/outputs.tf index 8bb9d54..8e9f392 100644 --- a/00-remote_state/1-iam/outputs.tf +++ b/00-remote_state/1-iam/outputs.tf @@ -1,4 +1,4 @@ output "role_arn" { - description = "ARN of the org-wide S3-lister role. Anyone in the org assumes it with `aws sts assume-role`." - value = module.s3_lister.arn + description = "ARN of the org-wide Terraform-state access role (R/W + lock; named `tf-state-access`). Anyone in the org assumes it with `aws sts assume-role`." + value = module.tf_state_access.arn } diff --git a/00-remote_state/1-iam/variables.tf b/00-remote_state/1-iam/variables.tf index 23b0668..f35cba7 100644 --- a/00-remote_state/1-iam/variables.tf +++ b/00-remote_state/1-iam/variables.tf @@ -5,9 +5,9 @@ variable "region" { } variable "role_name" { - description = "Name of the org-wide S3-lister role." + description = "Name of the org-wide Terraform-state access role (R/W + lock). Renaming it changes the ARN — keep vars.AWS_TF_STATE_ROLE_ARN in sync." type = string - default = "s3-lister" + default = "tf-state-access" } # IAM path every CI-managed role must sit under. The CI grant (identity/00-ci-trust/) diff --git a/01-iam/scaleway/main.tf b/01-iam/scaleway/main.tf index c878efc..d78075f 100644 --- a/01-iam/scaleway/main.tf +++ b/01-iam/scaleway/main.tf @@ -3,10 +3,11 @@ resource "scaleway_iam_application" "this" { description = "GitHub Actions CI for the IntegratedDynamic/infrastructure repo (managed by terraform: github-ci/)." } -# Dummy permission for the IAM Scaleway User. TBD +# Lets CI manage the Kapsule cluster end-to-end (create/destroy): the K8s cluster +# itself plus its VPC + private network + IPAM lookups. Project-scoped. resource "scaleway_iam_policy" "this" { - name = "github-ci-object-storage-ro" - description = "Read-only Object Storage for the GitHub Actions CI application, project-scoped." + name = "github-ci-cluster-management" + description = "Kubernetes/VPC/PrivateNetwork management for the GitHub Actions CI application, project-scoped." application_id = scaleway_iam_application.this.id rule { diff --git a/CLAUDE.md b/CLAUDE.md index b169546..d1f46d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,11 +94,11 @@ The shared org S3 bucket holding **every** root's remote state (built on `terraf ### `state/10-access/` -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`). +Org-wide Terraform-state **access** IAM **role created BY the CI** (the first role minted by `identity/00-ci-trust`'s role-creator rather than by a human). Named `tf-state-access`, it grants `AmazonS3FullAccess` — **read/write on the state bucket plus the state lock** — so every state-touching workflow assumes it for `plan` AND `apply`/`destroy` alike (e.g. the `scaleway` workflow), wired via `vars.AWS_TF_STATE_ROLE_ARN`. Assumable org-wide via two trust doors: AWS principals in the org (`aws:PrincipalOrgID`) and GitHub Actions in the org via OIDC (`repo:IntegratedDynamic/*`). Applied by CI (`iam_terraform-backend-role.yml`). ### `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`. +Standalone root that stands up the **Scaleway IAM identity GitHub Actions uses to authenticate to Scaleway**: a dedicated IAM application + a project-scoped policy (`Kubernetes`/`VPC`/`PrivateNetworks` FullAccess + `IPAMReadOnly`, enough for CI to create/destroy the Kapsule cluster) + 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/` @@ -114,4 +114,4 @@ The CI **identity & governance foundation**: **keyless GitHub-OIDC → AWS** acc **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 workspace variables in an `env/` folder — one `env/.tfvars` per workspace. The **filename (without `.tfvars`) 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` takes `root` + `tfvars-file` inputs and runs `workspace select ` + `plan`/`apply -var-file=env/.tfvars` after assuming an AWS role via OIDC. The repo must be checked out before calling this action (it is a local action). This replaces the old reliance on a local, gitignored `.terraform/environment` (invisible to CI — a `default`-workspace run collides on the state key). +**Terraform workspaces**: a root that runs through CI declares its workspace variables in an `env/` folder — one `env/.tfvars` per workspace. The **filename (without `.tfvars`) 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`** takes `root` + `tfvars-file` + `command` (`plan`/`apply`/`destroy`) + `aws-role-arn` (plus optional Infisical/Scaleway inputs) and runs `workspace select ` + the command `-var-file=env/.tfvars`, after minting an Infisical OIDC token (skippable) and assuming the AWS role via OIDC. The calling **workflow** owns the trigger→command mapping (push → apply, schedule → destroy, else plan), the `concurrency` guard, and the `environment` that scopes credentials (Scaleway keys live in the `scaleway` environment, passed to the action as inputs). The repo must be checked out before calling the action (it is a local action). This replaces the old reliance on a local, gitignored `.terraform/environment` (invisible to CI — a `default`-workspace run collides on the state key). From 78453d91492053f7bc1398315b64c7a8712dad10 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Fri, 19 Jun 2026 12:11:56 +0200 Subject: [PATCH 3/6] ci: drop the reusable-workflow wrapper, use the composite action directly The `secrets:` keyword only exists at the workflow level, so a reusable-workflow wrapper added solely to declare it is overengineering for now. Removed terraform.yml; scaleway.yml and iam_terraform-backend-role.yml use the composite action directly again. Secrets are still not passed as action inputs: the composite action declares no secret inputs and reads provider creds (SCW_*, INFISICAL_MACHINE_IDENTITY_ID) from the job env, which the calling workflow sets from the `scaleway` environment. Co-Authored-By: Claude Opus 4.8 --- .../workflows/iam_terraform-backend-role.yml | 44 ++++-- .github/workflows/scaleway.yml | 74 +++++++--- .github/workflows/terraform.yml | 138 ------------------ CLAUDE.md | 2 +- 4 files changed, 88 insertions(+), 170 deletions(-) delete mode 100644 .github/workflows/terraform.yml diff --git a/.github/workflows/iam_terraform-backend-role.yml b/.github/workflows/iam_terraform-backend-role.yml index 4ed07ca..0ec0eed 100644 --- a/.github/workflows/iam_terraform-backend-role.yml +++ b/.github/workflows/iam_terraform-backend-role.yml @@ -1,9 +1,9 @@ name: Apply Terraform Backend Role # Terraform GitOps for the 00-remote_state/1-iam/ root: the org-wide state-access -# role is created/synchronised from remote, through the reusable terraform.yml -# workflow (plan on PR, apply on push to main). Credentials come from the -# identity/00-ci-trust CI role assumed via OIDC — no static AWS keys. +# role is created/synchronised from remote via the .github/actions/terraform +# composite action. 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: @@ -11,13 +11,11 @@ on: paths: - '00-remote_state/1-iam/**' - '.github/actions/terraform/**' - - '.github/workflows/terraform.yml' - '.github/workflows/iam_terraform-backend-role.yml' pull_request: paths: - '00-remote_state/1-iam/**' - '.github/actions/terraform/**' - - '.github/workflows/terraform.yml' - '.github/workflows/iam_terraform-backend-role.yml' concurrency: @@ -26,14 +24,34 @@ concurrency: permissions: contents: read - id-token: write # passed down to the reusable workflow for AWS OIDC jobs: terraform: - uses: ./.github/workflows/terraform.yml - with: - root: 00-remote_state/1-iam - tfvars-file: 00-remote-state-iam.tfvars - aws-role-arn: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} - # This root touches neither Infisical nor Scaleway — skip the OIDC mint. - infisical-oidc-audience: "" + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + id-token: write # mint the OIDC token exchanged for temporary AWS creds + contents: read + steps: + - name: Assert state role ARN is 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 (provisioned by identity/00-ci-trust/)." + exit 1 + fi + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 1 + + - uses: ./.github/actions/terraform + with: + root: 00-remote_state/1-iam + tfvars-file: 00-remote-state-iam.tfvars + aws-role-arn: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} + command: ${{ github.event_name == 'push' && 'apply' || 'plan' }} + # This root touches neither Infisical nor Scaleway — skip the OIDC mint. + infisical-oidc-audience: "" diff --git a/.github/workflows/scaleway.yml b/.github/workflows/scaleway.yml index 9db9320..1cd407c 100644 --- a/.github/workflows/scaleway.yml +++ b/.github/workflows/scaleway.yml @@ -1,6 +1,6 @@ name: Terraform — Scaleway cluster -# Drives 02-cluster/scaleway through the reusable terraform.yml workflow: +# Drives 02-cluster/scaleway through the .github/actions/terraform composite action: # - pull_request → plan # - push to main → apply # - schedule (daily 18h) → destroy (cost-control teardown of the homelab) @@ -9,21 +9,20 @@ name: Terraform — Scaleway cluster # State R/W + lock uses the org state role (vars.AWS_TF_STATE_ROLE_ARN — # AmazonS3FullAccess, see 00-remote_state/1-iam). Scaleway cluster lifecycle uses # the CI API key from the `scaleway` environment (Kubernetes/VPC/PrivateNetwork -# FullAccess, see 01-iam/scaleway). No static AWS or Infisical secrets. +# FullAccess, see 01-iam/scaleway). Provider creds are set as job env (read by the +# action) — never passed as plain action inputs. No static AWS keys. on: pull_request: paths: - '02-cluster/scaleway/**' - '.github/actions/terraform/**' - - '.github/workflows/terraform.yml' - '.github/workflows/scaleway.yml' push: branches: [main] paths: - '02-cluster/scaleway/**' - '.github/actions/terraform/**' - - '.github/workflows/terraform.yml' - '.github/workflows/scaleway.yml' schedule: # 16:00 UTC = 18:00 Europe/Paris in summer (CEST). GitHub cron is UTC and does @@ -45,20 +44,59 @@ concurrency: permissions: contents: read - id-token: write # passed down to the reusable workflow for AWS + Infisical OIDC jobs: terraform: - uses: ./.github/workflows/terraform.yml - with: - root: 02-cluster/scaleway - tfvars-file: 02-cluster-staging.tfvars - # workflow_dispatch sets this; on push/schedule/PR it's empty → derived. - command: ${{ inputs.command }} - aws-role-arn: ${{ vars.AWS_TF_STATE_ROLE_ARN }} - # The `scaleway` environment carries SCW_ACCESS_KEY / SCW_SECRET_KEY. It must - # keep NO required-reviewer rule, or the daily scheduled destroy would block. - environment: scaleway - infisical-identity-id: ${{ vars.INFISICAL_MACHINE_IDENTITY_ID }} - scw-organization-id: ${{ vars.SCW_DEFAULT_ORGANIZATION_ID }} - scw-project-id: ${{ vars.SCW_DEFAULT_PROJECT_ID }} + runs-on: ubuntu-latest + timeout-minutes: 20 + # The `scaleway` environment carries SCW_ACCESS_KEY / SCW_SECRET_KEY (and + # satisfies zizmor's secrets-in-environment audit). It currently has no + # protection rule, so the daily scheduled destroy runs unattended — keep it + # that way (a required-reviewer rule would block the cron). + environment: scaleway + permissions: + id-token: write # mint OIDC tokens for AWS + Infisical + contents: read + # Provider creds exposed to the composite action via the job env (not inputs). + env: + SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }} + SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }} + SCW_DEFAULT_ORGANIZATION_ID: ${{ vars.SCW_DEFAULT_ORGANIZATION_ID }} + SCW_DEFAULT_PROJECT_ID: ${{ vars.SCW_DEFAULT_PROJECT_ID }} + INFISICAL_MACHINE_IDENTITY_ID: ${{ vars.INFISICAL_MACHINE_IDENTITY_ID }} + steps: + - name: Assert state role ARN is present + env: + ROLE_ARN: ${{ vars.AWS_TF_STATE_ROLE_ARN }} + run: | + if [ -z "$ROLE_ARN" ]; then + echo "::error::vars.AWS_TF_STATE_ROLE_ARN is not set (provisioned by 00-remote_state/1-iam)." + exit 1 + fi + + - name: Resolve terraform command + id: cmd + env: + EVENT: ${{ github.event_name }} + DISPATCH_CMD: ${{ github.event.inputs.command }} + run: | + case "$EVENT" in + schedule) cmd=destroy ;; + push) cmd=apply ;; + workflow_dispatch) cmd="${DISPATCH_CMD:-plan}" ;; + *) cmd=plan ;; + esac + echo "command=$cmd" >> "$GITHUB_OUTPUT" + echo "Resolved terraform command: $cmd (event: $EVENT)" + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 1 + + - uses: ./.github/actions/terraform + with: + root: 02-cluster/scaleway + tfvars-file: 02-cluster-staging.tfvars + command: ${{ steps.cmd.outputs.command }} + aws-role-arn: ${{ vars.AWS_TF_STATE_ROLE_ARN }} diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml deleted file mode 100644 index a57320b..0000000 --- a/.github/workflows/terraform.yml +++ /dev/null @@ -1,138 +0,0 @@ -name: Terraform (reusable) - -# Reusable wrapper around the .github/actions/terraform composite action. This is -# the layer that handles SECRETS properly: callers pass Scaleway keys via the -# `secrets:` keyword (or the job's `environment` provides them) instead of as -# plain action inputs. Invoke it at job level: -# -# jobs: -# terraform: -# uses: ./.github/workflows/terraform.yml -# with: { root: ..., tfvars-file: ..., aws-role-arn: ..., environment: scaleway } -# secrets: inherit # only needed for repo/org-level secrets -# -# Command resolution: the `command` input overrides; otherwise it is derived from -# the triggering event — push → apply, schedule → destroy, everything else → plan. - -on: - workflow_call: - inputs: - root: - description: Path to the Terraform root. - required: true - type: string - tfvars-file: - description: Filename of the .tfvars under env/ (workspace = name without .tfvars). - required: true - type: string - aws-role-arn: - description: ARN of the IAM role to assume via OIDC (Terraform-state R/W). - required: true - type: string - command: - description: 'Override: plan | apply | destroy. Empty = derive from the event.' - required: false - type: string - default: "" - aws-region: - required: false - type: string - default: eu-west-3 - lock: - description: When false, pass -lock=false. - required: false - type: boolean - default: true - environment: - description: GitHub environment to run in (scopes env secrets, e.g. the Scaleway keys). Empty = none. - required: false - type: string - default: "" - infisical-oidc-audience: - description: OIDC audience for the Infisical machine identity. Empty = skip the mint. - required: false - type: string - default: https://github.com/IntegratedDynamic - infisical-identity-id: - required: false - type: string - default: "" - scw-organization-id: - required: false - type: string - default: "" - scw-project-id: - required: false - type: string - default: "" - secrets: - SCW_ACCESS_KEY: - required: false - SCW_SECRET_KEY: - required: false - -permissions: - contents: read - -jobs: - terraform: - runs-on: ubuntu-latest - timeout-minutes: 20 - # Carrying the environment here lets env-scoped secrets resolve (they cannot - # be passed from the caller via `secrets:`/`inherit`). It also satisfies - # zizmor's secrets-in-environment audit. - environment: ${{ inputs.environment }} - permissions: - id-token: write # mint OIDC tokens for AWS + Infisical - contents: read - # Provider credentials handed to the composite action via the job env. Secrets - # come from the `secrets:` block above (or the environment); the rest are - # non-sensitive IDs passed as inputs. - env: - SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }} - SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }} - SCW_DEFAULT_ORGANIZATION_ID: ${{ inputs.scw-organization-id }} - SCW_DEFAULT_PROJECT_ID: ${{ inputs.scw-project-id }} - INFISICAL_MACHINE_IDENTITY_ID: ${{ inputs.infisical-identity-id }} - steps: - - name: Assert AWS role ARN is present - env: - ROLE_ARN: ${{ inputs.aws-role-arn }} - run: | - if [ -z "$ROLE_ARN" ]; then - echo "::error::aws-role-arn is empty — set the caller's vars.AWS_*_ROLE_ARN." - exit 1 - fi - - - name: Resolve terraform command - id: cmd - env: - EVENT: ${{ github.event_name }} - OVERRIDE: ${{ inputs.command }} - run: | - if [ -n "$OVERRIDE" ]; then - cmd="$OVERRIDE" - else - case "$EVENT" in - push) cmd=apply ;; - schedule) cmd=destroy ;; - *) cmd=plan ;; - esac - fi - echo "command=$cmd" >> "$GITHUB_OUTPUT" - echo "Resolved terraform command: $cmd (event: $EVENT, override: '${OVERRIDE:-}')" - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - fetch-depth: 1 - - - uses: ./.github/actions/terraform - with: - root: ${{ inputs.root }} - tfvars-file: ${{ inputs.tfvars-file }} - command: ${{ steps.cmd.outputs.command }} - lock: ${{ inputs.lock }} - aws-role-arn: ${{ inputs.aws-role-arn }} - aws-region: ${{ inputs.aws-region }} - infisical-oidc-audience: ${{ inputs.infisical-oidc-audience }} diff --git a/CLAUDE.md b/CLAUDE.md index d1f46d7..aba490e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,4 +114,4 @@ The CI **identity & governance foundation**: **keyless GitHub-OIDC → AWS** acc **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 workspace variables in an `env/` folder — one `env/.tfvars` per workspace. The **filename (without `.tfvars`) 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`** takes `root` + `tfvars-file` + `command` (`plan`/`apply`/`destroy`) + `aws-role-arn` (plus optional Infisical/Scaleway inputs) and runs `workspace select ` + the command `-var-file=env/.tfvars`, after minting an Infisical OIDC token (skippable) and assuming the AWS role via OIDC. The calling **workflow** owns the trigger→command mapping (push → apply, schedule → destroy, else plan), the `concurrency` guard, and the `environment` that scopes credentials (Scaleway keys live in the `scaleway` environment, passed to the action as inputs). The repo must be checked out before calling the action (it is a local action). This replaces the old reliance on a local, gitignored `.terraform/environment` (invisible to CI — a `default`-workspace run collides on the state key). +**Terraform workspaces**: a root that runs through CI declares its workspace variables in an `env/` folder — one `env/.tfvars` per workspace. The **filename (without `.tfvars`) 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`** takes `root` + `tfvars-file` + `command` (`plan`/`apply`/`destroy`) + `aws-role-arn` (all non-secret inputs) and runs `workspace select ` + the command `-var-file=env/.tfvars`, after minting an Infisical OIDC token (skippable) and assuming the AWS role via OIDC. The action takes **no secret inputs**: provider credentials (`SCW_*`, `INFISICAL_MACHINE_IDENTITY_ID`) are read from the job env. The calling **workflow** owns the trigger→command mapping (push → apply, schedule → destroy, else plan), the `concurrency` guard, and the `environment` that scopes credentials — Scaleway keys live in the `scaleway` environment and are exposed to the action as job `env:` (never as plain inputs). The repo must be checked out before calling the action (it is a local action). This replaces the old reliance on a local, gitignored `.terraform/environment` (invisible to CI — a `default`-workspace run collides on the state key). From 6ba8d23d30bc8e2387b3089fdcd445f5b85e0683 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Fri, 19 Jun 2026 12:22:32 +0200 Subject: [PATCH 4/6] ci: drop the -lock=false escape hatch from the terraform action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `lock` input only existed for the (mistaken) read-only-role assumption — a role that can't write S3 can't acquire the state lock. The state role is tf-state-access (AmazonS3FullAccess: R/W + lock) and no caller ever set lock=false, so the input was dead. Let Terraform manage the lock by default (the S3 backend has use_lockfile = true). Co-Authored-By: Claude Opus 4.8 --- .github/actions/terraform/action.yml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/actions/terraform/action.yml b/.github/actions/terraform/action.yml index 3bb46db..a2669b9 100644 --- a/.github/actions/terraform/action.yml +++ b/.github/actions/terraform/action.yml @@ -1,7 +1,7 @@ name: Terraform description: > Run Terraform for a workspace-driven root: set up Terraform, mint an Infisical - OIDC token, assume an AWS role via OIDC, then init → workspace select → + OIDC token, assume an AWS role via OIDC (for remote state access) and retrieve Scaleway Machine Identity credentials, then init → workspace select → plan/apply/destroy. The repository must already be checked out. This action takes NO secret inputs. Provider credentials are read from the @@ -24,10 +24,6 @@ inputs: description: 'Terraform command to run: plan | apply | destroy.' required: false default: plan - lock: - description: 'When "false", pass -lock=false (read-only roles that cannot acquire the S3 state lock).' - required: false - default: "true" aws-role-arn: description: ARN of the IAM role to assume via OIDC (Terraform-state R/W). required: true @@ -76,12 +72,10 @@ runs: ROOT: ${{ inputs.root }} TFVARS: ${{ inputs.tfvars-file }} COMMAND: ${{ inputs.command }} - LOCK: ${{ inputs.lock }} # SCW_* and INFISICAL_MACHINE_IDENTITY_ID are inherited from the job env. run: | set -euo pipefail ws="${TFVARS%.tfvars}" - lock_arg=""; [ "$LOCK" = "false" ] && lock_arg="-lock=false" terraform -chdir="$ROOT" init -input=false # apply may target a brand-new env whose workspace doesn't exist yet; # read paths (plan/destroy) select an existing one. @@ -91,8 +85,8 @@ runs: terraform -chdir="$ROOT" workspace select "$ws" fi case "$COMMAND" in - plan) terraform -chdir="$ROOT" plan -input=false $lock_arg -var-file="env/${TFVARS}" ;; + plan) terraform -chdir="$ROOT" plan -input=false -var-file="env/${TFVARS}" ;; apply) terraform -chdir="$ROOT" apply -input=false -auto-approve -var-file="env/${TFVARS}" ;; - destroy) terraform -chdir="$ROOT" destroy -input=false -auto-approve $lock_arg -var-file="env/${TFVARS}" ;; + destroy) terraform -chdir="$ROOT" destroy -input=false -auto-approve -var-file="env/${TFVARS}" ;; *) echo "::error::unknown command '$COMMAND' (expected plan|apply|destroy)"; exit 1 ;; esac From 236590a1d6e5cb9952d003535253bc1790eb0315 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Fri, 19 Jun 2026 13:35:56 +0200 Subject: [PATCH 5/6] t --- 00-remote_state/1-iam/env/00-remote-state-iam.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/00-remote_state/1-iam/env/00-remote-state-iam.tfvars b/00-remote_state/1-iam/env/00-remote-state-iam.tfvars index d3d9450..2294daa 100644 --- a/00-remote_state/1-iam/env/00-remote-state-iam.tfvars +++ b/00-remote_state/1-iam/env/00-remote-state-iam.tfvars @@ -6,7 +6,7 @@ # .github/actions/terraform — see that action and CLAUDE.md. region = "eu-west-3" -role_name = "s3-lister" +role_name = "tf_state_access" # 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. From 7ee2ac94a85fa3d73e28cdd904aa1f18915769ac Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Fri, 19 Jun 2026 13:37:46 +0200 Subject: [PATCH 6/6] fix(00-remote_state): set role_name in the tfvars to tf-state-access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role name is read from env/00-remote-state-iam.tfvars (which overrides the variables.tf default), and the rename only touched the default — so an apply still produced `s3-lister`. Set it in the tfvars so it matches the agreed name and vars.AWS_TF_STATE_ROLE_ARN. Co-Authored-By: Claude Opus 4.8 --- 00-remote_state/1-iam/env/00-remote-state-iam.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/00-remote_state/1-iam/env/00-remote-state-iam.tfvars b/00-remote_state/1-iam/env/00-remote-state-iam.tfvars index 2294daa..fb0bf20 100644 --- a/00-remote_state/1-iam/env/00-remote-state-iam.tfvars +++ b/00-remote_state/1-iam/env/00-remote-state-iam.tfvars @@ -6,7 +6,7 @@ # .github/actions/terraform — see that action and CLAUDE.md. region = "eu-west-3" -role_name = "tf_state_access" +role_name = "tf-state-access" # 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.