From d8842cd06d459da54aecf7d76fefdd736def21a2 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sun, 21 Jun 2026 03:15:34 +0200 Subject: [PATCH 1/5] feat(03-backup): add Scaleway backup S3 bucket Terraform root Provisions a Scaleway Object Storage bucket with independent lifecycle from the cluster, scoped workload credentials, configurable retention/lifecycle rules, and a dedicated CI workflow (plan on PR, apply on push, no destroy). - 03-backup/scaleway/: new Terraform root (bucket + SSE + IAM workload identity + Infisical secret storage + lifecycle rules) - 01-iam/bootstrap/scaleway: extend github-ci with ObjectStorage + IAMApplicationManager + IAMPolicyManager permissions - .github/workflows/backup_scaleway.yml: CI workflow, no scheduled destroy - Spec Kit artifacts: spec, plan, research, data-model, tasks, quickstart Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/backup_scaleway.yml | 98 +++++++++ .specify/feature.json | 3 + 01-iam/bootstrap/scaleway/main.tf | 32 +++ 01-iam/bootstrap/scaleway/variables.tf | 6 + 03-backup/scaleway/.terraform.lock.hcl | 93 ++++++++ .../scaleway/env/03-backup-dev-bucket.tfvars | 11 + 03-backup/scaleway/iam.tf | 29 +++ 03-backup/scaleway/infisical.tf | 23 ++ 03-backup/scaleway/main.tf | 55 +++++ 03-backup/scaleway/outputs.tf | 19 ++ 03-backup/scaleway/variables.tf | 88 ++++++++ 03-backup/scaleway/version.tf | 35 +++ CLAUDE.md | 3 +- mise.toml | 1 + .../checklists/requirements.md | 38 ++++ .../contracts/terraform-interface.md | 97 +++++++++ specs/001-backup-s3-foundation/data-model.md | 111 ++++++++++ specs/001-backup-s3-foundation/plan.md | 175 +++++++++++++++ specs/001-backup-s3-foundation/quickstart.md | 88 ++++++++ specs/001-backup-s3-foundation/research.md | 199 +++++++++++++++++ specs/001-backup-s3-foundation/spec.md | 145 +++++++++++++ specs/001-backup-s3-foundation/tasks.md | 202 ++++++++++++++++++ specs/001-backup-s3-foundation/test-job.yaml | 35 +++ 23 files changed, 1585 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/backup_scaleway.yml create mode 100644 .specify/feature.json create mode 100644 03-backup/scaleway/.terraform.lock.hcl create mode 100644 03-backup/scaleway/env/03-backup-dev-bucket.tfvars create mode 100644 03-backup/scaleway/iam.tf create mode 100644 03-backup/scaleway/infisical.tf create mode 100644 03-backup/scaleway/main.tf create mode 100644 03-backup/scaleway/outputs.tf create mode 100644 03-backup/scaleway/variables.tf create mode 100644 03-backup/scaleway/version.tf create mode 100644 specs/001-backup-s3-foundation/checklists/requirements.md create mode 100644 specs/001-backup-s3-foundation/contracts/terraform-interface.md create mode 100644 specs/001-backup-s3-foundation/data-model.md create mode 100644 specs/001-backup-s3-foundation/plan.md create mode 100644 specs/001-backup-s3-foundation/quickstart.md create mode 100644 specs/001-backup-s3-foundation/research.md create mode 100644 specs/001-backup-s3-foundation/spec.md create mode 100644 specs/001-backup-s3-foundation/tasks.md create mode 100644 specs/001-backup-s3-foundation/test-job.yaml diff --git a/.github/workflows/backup_scaleway.yml b/.github/workflows/backup_scaleway.yml new file mode 100644 index 0000000..f838417 --- /dev/null +++ b/.github/workflows/backup_scaleway.yml @@ -0,0 +1,98 @@ +name: Terraform — Scaleway backup bucket + +# Drives 03-backup/scaleway through the .github/actions/terraform composite action: +# - pull_request → plan +# - push to main → apply +# - workflow_dispatch → plan | apply (no destroy — FR-013) +# +# No scheduled trigger. The backup bucket is never destroyed by CI. +# State R/W uses the org state role (vars.AWS_TF_STATE_ROLE_ARN). +# Scaleway creds come from the `scaleway` environment (same API key as the +# cluster workflow — extended with Object Storage + IAM permissions in +# 01-iam/bootstrap/scaleway). + +on: + pull_request: + paths: + - '03-backup/scaleway/**' + - '.github/actions/terraform/**' + - '.github/workflows/backup_scaleway.yml' + push: + branches: [main] + paths: + - '03-backup/scaleway/**' + - '.github/actions/terraform/**' + - '.github/workflows/backup_scaleway.yml' + workflow_dispatch: + inputs: + command: + description: 'Terraform command (apply = provision, no destroy available).' + type: choice + options: [plan, apply] + default: plan + +concurrency: + group: backup-scaleway-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: scaleway + permissions: + id-token: write # mint OIDC tokens for AWS + Infisical + contents: read + 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 }} + BUCKET_NAME: backup-dev-id + BUCKET_REGION: fr-par + 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 01-iam/ci-managed/aws-state-access)." + exit 1 + fi + + - name: Resolve terraform command + id: cmd + env: + EVENT: ${{ github.event_name }} + DISPATCH_CMD: ${{ github.event.inputs.command }} + run: | + case "$EVENT" in + 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: 03-backup/scaleway + tfvars-file: 03-backup-dev-bucket.tfvars + command: ${{ steps.cmd.outputs.command }} + aws-role-arn: ${{ vars.AWS_TF_STATE_ROLE_ARN }} + + - name: Verify backup bucket exists + if: steps.cmd.outputs.command == 'apply' + run: | + curl -fsSL https://raw.githubusercontent.com/scaleway/scaleway-cli/master/scripts/get.sh | sh + export PATH="$HOME/.local/bin:$PATH" + scw object bucket get "$BUCKET_NAME" --region "$BUCKET_REGION" diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 0000000..759c4f1 --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/001-backup-s3-foundation" +} diff --git a/01-iam/bootstrap/scaleway/main.tf b/01-iam/bootstrap/scaleway/main.tf index d78075f..1255aca 100644 --- a/01-iam/bootstrap/scaleway/main.tf +++ b/01-iam/bootstrap/scaleway/main.tf @@ -16,6 +16,38 @@ resource "scaleway_iam_policy" "this" { } } +# Added for 03-backup/scaleway: the backup CI workflow runs under the same +# github-ci identity and needs Object Storage management + IAM application/policy/ +# API key management to provision the bucket and scoped workload credentials. +# Deletion is blocked at the bucket-policy level (see 03-backup/scaleway/policy.tf). +resource "scaleway_iam_policy" "backup_ci" { + name = "github-ci-backup-management" + description = "Object Storage bucket + IAM workload identity management for the backup domain CI workflow (03-backup/scaleway/)." + application_id = scaleway_iam_application.this.id + + rule { + project_ids = [var.project_id] + permission_set_names = [ + "ObjectStorageBucketsRead", + "ObjectStorageBucketsWrite", + "ObjectStorageObjectsRead", + "ObjectStorageObjectsWrite", + ] + } + + # IAM permission sets are organization-scoped — they cannot be combined + # with project_ids in the same rule. + rule { + organization_id = var.organization_id + permission_set_names = [ + # Required to create/manage the scoped workload IAM application, policy, + # and API key in 03-backup/scaleway/iam.tf. + "IAMApplicationManager", + "IAMPolicyManager", + ] + } +} + # The org enforces an expiry on every API key, and `expires_at` is ForceNew, so # the key inherently rotates when the expiry moves. time_rotating makes that # concrete and self-renewing: the timestamp holds steady until the window diff --git a/01-iam/bootstrap/scaleway/variables.tf b/01-iam/bootstrap/scaleway/variables.tf index e14d310..204631a 100644 --- a/01-iam/bootstrap/scaleway/variables.tf +++ b/01-iam/bootstrap/scaleway/variables.tf @@ -24,6 +24,12 @@ variable "project_id" { default = "6283c05b-a4c7-4f83-a75f-83adad236d54" } +variable "organization_id" { + description = "Scaleway organization ID. Used for org-scoped IAM permission sets (IAMApplicationManager, IAMPolicyManager)." + type = string + default = "6283c05b-a4c7-4f83-a75f-83adad236d54" +} + # Scaleway's org policy requires every API key to carry an expiry. This drives # the key's expires_at; once the window elapses, the next apply rotates the key. variable "api_key_rotation_days" { diff --git a/03-backup/scaleway/.terraform.lock.hcl b/03-backup/scaleway/.terraform.lock.hcl new file mode 100644 index 0000000..501b695 --- /dev/null +++ b/03-backup/scaleway/.terraform.lock.hcl @@ -0,0 +1,93 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + hashes = [ + "h1:QWxF+1ePJ4qFCHEc6PyHNeXc865wLvrWVl71d/nABa8=", + "zh:03fcea0a1ea2ca81d62d4d2e2961181bef9068b1c701f2cddc4aa5fac105818a", + "zh:1213944cd623143974ea5c9b70b22ae1ccca33d743924c149ed089d34b8e08b4", + "zh:190a46da0c69082b74da48238ce134d2fc9893e09122ac249c5689f88eab7e13", + "zh:1b312a4b53fa3cf731f95e674c033865feea5455f163b86136f2614424637293", + "zh:2b319814806222c5aba196b1a78756a6b36dc5c91f85edda349234d8a2f20a6a", + "zh:2bddf92c8efc6ad445a2eb8a0e5f88742a0596392c3a4ebc350ebb4105a4a96d", + "zh:3bef0c4f675c09034ff017cf899977b1765b2c0b3d1e489bcb06a5fcac316e2d", + "zh:47c46b5aa22199638fed5c93b195bbfd1182a1408edad4e5c39d4a73a04493f6", + "zh:5f808699650f6db961964466c77f5a581eab142a91c2e54810bb09b6f2fcd3f2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.14.0" + constraints = "~> 0.12" + hashes = [ + "h1:/hlxsUpuN/lvPTNL9+NyVGsOyRsK5NsxwFMsj5CdOp4=", + "h1:4EThC3ocCFiFPMZQSUvSGSxoJqBcGWxMcFYmL67uS7Y=", + "zh:12abfd6b800e4d7fa6db7310dec8ffd440b31993861ef188c7ed5260b3073937", + "zh:23005521e800bb19e1597bf755c5f70d675d30b685d4255001ed5fa47d9df3f1", + "zh:2fea249b582ae97cd1cc10385187ea50993bb47c28cc5df0305e57ceaabf0a10", + "zh:322018d3b987b7aad08697178029a2bb667bed699e88328f0c89c52a2fd41341", + "zh:32a08e98fce2d273cb9b2c89d6c54727cc9f0a32e15bfd896be4e02cc6b48f95", + "zh:3db89aabd0e619616bd4b0f8b373a7586dfe60feffcea12a84a0bdbc445714b3", + "zh:7488f56c81d742dc020f29063626c8f07ca188aa97be61e7307e8d62397020a2", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7cb4067f2e7559b13f7562ef722f948950901eb37834873e98360ab28f66e9d7", + "zh:9d552c8345f61e1b7db8e725144981345f18ac1014d58d6f5ddf0928a195fffb", + "zh:a8e69fb6b97fc9d86fb19a9f4d42abe33c4a68e700b15387ce2e17d2b9934bed", + "zh:aeeb900eb8dd0f790c60ea5c0e0c8d42bd6e4a54f391681d4decca15b544394b", + "zh:c239c619101a8c95e1f14061eb973c57a8d15fa0e68878ced5bbd76858ee5b79", + ] +} + +provider "registry.terraform.io/infisical/infisical" { + version = "0.16.32" + constraints = "~> 0.16" + hashes = [ + "h1:IYFOE05ITZi1HVGggR5sQM5Y96e606AWbN17am0mqdo=", + "h1:KLog2xD5jntJk7Yt9ePBex+KJ8rVkpsga4hiJrmU+9Y=", + "zh:05a2d64651d01cb0e99cc914e9795d6c20a671ea54c011ed1e9be57cebae037a", + "zh:3075619c6a3c298aa0d3e6c977afa0e6b18a7c23283c36af92ca3e2bfec0dc90", + "zh:3198d5219ce5c3d2fedcc0096a6b1889bd9caac6acaa8fcac29a6f45835b1e26", + "zh:335ad3f71ada2c43b3e64d37437d2163903815d8509689ae8d0ba66c86833913", + "zh:410a5cfa062a5c61f026a1419f8bdead0bfc46fc194c801f7d55e3fe45717e82", + "zh:534cbb32ff5cc329058666c49b2148834b5e57abaa4f360d518370554f1d0543", + "zh:68c0d3eca17f23df5e60578e480eb41e3d84cc3479b9ec6ea6cd5ef7df1d3d52", + "zh:867b1f91cf34685f55f69e847224f64dff5d101e77f888d9598c334688025233", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:8a68a03db0484b4c4dd1a41abcb767a3d9e05508a13a1018b11b180e3ea68fc1", + "zh:8deb26470ae6db94df3b2198a04ce4432c5be3cac4cf51bcea57f0968ca46ca5", + "zh:cb6f956f777e56a205bf55ef84220cf11b11ae9bee17eb560f5a3661ac3b1a9f", + "zh:dc3881aa9ade9257b7893969b08a5b84c3360588c4061f5c4a20b3e0aef5202f", + "zh:e48ee43b6ff928a46123a83f5409588b8e98c5ad7a844987d7e5f3b768d8cc4e", + "zh:ebbaae992d0b2f0c33cb4e9dac2bb636a6d26389cc9ee07254098c10b6fb0f6f", + ] +} + +provider "registry.terraform.io/scaleway/scaleway" { + version = "2.76.0" + constraints = "~> 2.0" + hashes = [ + "h1:3RImo3Jf88dXcIqmBRuO/A85jAxrAQsvYa4zhVqW1tI=", + "h1:ktRogBsJlCf3JTOTYPm9hKaPUzaps0aLwxsNfP155ns=", + "zh:13b6790dca2c91c7478d6e9cd03d84713e0b0ec001c9923d3c93bd12a1f4152f", + "zh:219582ef77ec6f27d2928684b95603b5a921001631f9eec68c14819fdbc1efea", + "zh:26bc09c7aadc49fe83a9d6af9c1d0951f93de129f90d04e5e65e1d82c8ac1914", + "zh:4015a970be3669ee009344afb269a09eaf9fe1363a12a10d3311326ba769463a", + "zh:4c1c5997ef4182e49e46ff6563581a90b5cfa30f0ec8d7d919b3dc0603ed3043", + "zh:58d91e08a8fe38ddda2c03013d1ba77ff4e21a91fef0a425575b5af7bf7f4ebc", + "zh:644a61f963483c8eba7f8427650c4956e1d8c59710a11bb2691ad8996ee14a9c", + "zh:6745d5d80006c375b104a005cfc007f90e2cb547cf9e96c886d453be3307a399", + "zh:8dcac392ca182af40b823c6721833de1325c279b1e5bdcddf1a1cf2789f3558a", + "zh:9eb284d3e9a14d4d64e2a7a865be45ec0eee32ec16c51bcc1722c0449bfb9fab", + "zh:a4eb4973cc1d9ac4911733d62f5b0e53fbc7b90112efa312918c0320966e276e", + "zh:ce58ae8014b2981bbc875bc7ad4cdeb93957370bfa4308eeb193155ee988fbec", + "zh:d3f0f3bec0a6f4759948749cdb0eb64e4415507a82c79659f1ede894f96f4976", + ] +} diff --git a/03-backup/scaleway/env/03-backup-dev-bucket.tfvars b/03-backup/scaleway/env/03-backup-dev-bucket.tfvars new file mode 100644 index 0000000..2c00036 --- /dev/null +++ b/03-backup/scaleway/env/03-backup-dev-bucket.tfvars @@ -0,0 +1,11 @@ +bucket_name = "backup-dev-id" +region = "fr-par" +project_id = "6283c05b-a4c7-4f83-a75f-83adad236d54" + +ci_application_id = "4d50bbbd-e8a2-4b51-80c5-6aa47de669f7" + +# Lifecycle defaults are accepted for dev: +# retention_days = 365 +# noncurrent_version_expiry_days = 30 +# cold_storage_enabled = true +# cold_storage_transition_days = 90 diff --git a/03-backup/scaleway/iam.tf b/03-backup/scaleway/iam.tf new file mode 100644 index 0000000..8dd1fd6 --- /dev/null +++ b/03-backup/scaleway/iam.tf @@ -0,0 +1,29 @@ +resource "scaleway_iam_application" "workload" { + name = "backup-workload-${terraform.workspace}" + description = "Backup workload identity for ${terraform.workspace} — object read/write on the backup bucket, no administrative rights (managed by terraform: 03-backup/scaleway/)." +} + +resource "scaleway_iam_policy" "workload" { + name = "backup-workload-objects-${terraform.workspace}" + description = "Object-level read/write on the backup project. No bucket-level permissions — cannot delete or reconfigure the bucket." + application_id = scaleway_iam_application.workload.id + + rule { + project_ids = [var.project_id] + permission_set_names = ["ObjectStorageObjectsRead", "ObjectStorageObjectsWrite"] + } +} + +# Scaleway requires every API key to carry an expiry. time_rotating keeps +# the expiry self-renewing: once the window elapses, the next apply rotates +# the key. Update the Kubernetes Secret (via ESO re-sync) after each rotation. +resource "time_rotating" "workload_key" { + rotation_days = 365 +} + +resource "scaleway_iam_api_key" "workload" { + application_id = scaleway_iam_application.workload.id + description = "Backup workload credentials for ${terraform.workspace}. Consumed via Infisical → ESO → Kubernetes Secret." + default_project_id = var.project_id + expires_at = time_rotating.workload_key.rotation_rfc3339 +} diff --git a/03-backup/scaleway/infisical.tf b/03-backup/scaleway/infisical.tf new file mode 100644 index 0000000..54a92ba --- /dev/null +++ b/03-backup/scaleway/infisical.tf @@ -0,0 +1,23 @@ +resource "infisical_secret_folder" "backup" { + project_id = var.infisical_workspace_id + environment_slug = var.infisical_env_slug + folder_path = "/" + name = trimprefix(var.infisical_folder_path, "/") + description = "Backup workload credentials (managed by terraform: 03-backup/scaleway/)." +} + +resource "infisical_secret" "access_key" { + name = "BACKUP_ACCESS_KEY" + value = scaleway_iam_api_key.workload.access_key + env_slug = var.infisical_env_slug + workspace_id = var.infisical_workspace_id + folder_path = infisical_secret_folder.backup.path +} + +resource "infisical_secret" "secret_key" { + name = "BACKUP_SECRET_KEY" + value = scaleway_iam_api_key.workload.secret_key + env_slug = var.infisical_env_slug + workspace_id = var.infisical_workspace_id + folder_path = infisical_secret_folder.backup.path +} diff --git a/03-backup/scaleway/main.tf b/03-backup/scaleway/main.tf new file mode 100644 index 0000000..744727e --- /dev/null +++ b/03-backup/scaleway/main.tf @@ -0,0 +1,55 @@ +resource "scaleway_object_bucket" "backup" { + name = var.bucket_name + region = var.region + + versioning { + enabled = var.versioning_enabled + } + + lifecycle_rule { + id = "backup-retention" + enabled = true + + expiration { + days = var.retention_days + } + + noncurrent_version_expiration { + noncurrent_days = var.noncurrent_version_expiry_days + } + + dynamic "transition" { + for_each = var.cold_storage_enabled ? [var.cold_storage_transition_days] : [] + content { + days = transition.value + storage_class = "GLACIER" + } + } + } + + # Deletion intentionally NOT protected at the provider level — see spec FR-014. + # Bucket deletion is a manual-only, human-operator action with admin credentials. + # Scaleway bucket policies do not support s3:DeleteBucket as an action, so the + # protection relies on two layers: (1) prevent_destroy below blocks terraform destroy, + # (2) no destroy trigger in the backup CI workflow blocks automated deletion. + + lifecycle { + prevent_destroy = true + + precondition { + condition = !var.cold_storage_enabled || var.cold_storage_transition_days < var.retention_days + error_message = "cold_storage_transition_days (${var.cold_storage_transition_days}) must be strictly less than retention_days (${var.retention_days}) when cold_storage_enabled is true. See spec FR-016." + } + } +} + +resource "scaleway_object_bucket_server_side_encryption_configuration" "backup" { + bucket = scaleway_object_bucket.backup.name + region = var.region + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} diff --git a/03-backup/scaleway/outputs.tf b/03-backup/scaleway/outputs.tf new file mode 100644 index 0000000..6e6e1f4 --- /dev/null +++ b/03-backup/scaleway/outputs.tf @@ -0,0 +1,19 @@ +output "bucket_name" { + description = "Provisioned backup bucket name." + value = scaleway_object_bucket.backup.name +} + +output "bucket_region" { + description = "Region the backup bucket was created in." + value = scaleway_object_bucket.backup.region +} + +output "bucket_endpoint" { + description = "S3-compatible endpoint URL for the backup bucket." + value = "https://s3.${var.region}.scw.cloud/${var.bucket_name}" +} + +output "workload_access_key" { + description = "Public access key for the scoped backup workload identity. The secret key is in Infisical only." + value = scaleway_iam_api_key.workload.access_key +} diff --git a/03-backup/scaleway/variables.tf b/03-backup/scaleway/variables.tf new file mode 100644 index 0000000..a45b15f --- /dev/null +++ b/03-backup/scaleway/variables.tf @@ -0,0 +1,88 @@ +variable "bucket_name" { + description = "Backup bucket name. Must include the environment name (e.g. backup-dev-id). See spec FR-015." + type = string +} + +variable "region" { + description = "Scaleway region for the bucket." + type = string + default = "fr-par" +} + +# ── Lifecycle ──────────────────────────────────────────────────────────────── + +variable "versioning_enabled" { + description = "Enable bucket versioning. Once enabled, can only be suspended, never disabled." + type = bool + default = true +} + +variable "retention_days" { + description = "Days before current-version objects expire." + type = number + default = 365 +} + +variable "noncurrent_version_expiry_days" { + description = "Days before non-current object versions are deleted." + type = number + default = 30 +} + +variable "cold_storage_enabled" { + description = "Enable transition of objects to GLACIER storage class." + type = bool + default = true +} + +variable "cold_storage_transition_days" { + description = "Days before objects are transitioned to GLACIER. Only evaluated when cold_storage_enabled = true. Must be less than retention_days (FR-016)." + type = number + default = 90 +} + +# ── Identity ───────────────────────────────────────────────────────────────── + +variable "project_id" { + description = "Scaleway project ID for bucket and IAM resource scoping." + type = string +} + +variable "ci_application_id" { + description = "IAM application ID of the github-ci identity (from 01-iam/bootstrap/scaleway outputs). Reserved for a future bucket policy Deny statement if Scaleway adds s3:DeleteBucket support." + type = string + default = "" +} + +# ── Infisical ──────────────────────────────────────────────────────────────── + +variable "infisical_workspace_id" { + description = "Infisical project (workspace) ID." + type = string + default = "7ecb6ed4-058a-46cd-ac9f-7e792469cf0f" +} + +variable "infisical_env_slug" { + description = "Infisical environment slug." + type = string + default = "staging" +} + +variable "infisical_folder_path" { + description = "Infisical folder path where backup workload credentials are written." + type = string + default = "/backup" +} + +variable "infisical_client_id" { + description = "Infisical universal auth client ID. Set for local development; leave empty when using OIDC in CI." + type = string + default = "" +} + +variable "infisical_client_secret" { + description = "Infisical universal auth client secret. Set for local development; leave empty when using OIDC in CI." + type = string + sensitive = true + default = "" +} diff --git a/03-backup/scaleway/version.tf b/03-backup/scaleway/version.tf new file mode 100644 index 0000000..f003d35 --- /dev/null +++ b/03-backup/scaleway/version.tf @@ -0,0 +1,35 @@ +terraform { + backend "s3" { + bucket = "id-terraform-state20260612164136440800000001" + region = "eu-west-3" + workspace_key_prefix = "backup/scaleway" + 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" + } + } +} + +provider "scaleway" {} + +provider "infisical" { + auth = { + # Uncomment `universal` and comment `oidc` when running terraform locally. + universal = {} + # oidc = {} + } +} diff --git a/CLAUDE.md b/CLAUDE.md index f220d55..76ba276 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,8 @@ The CI **identity & governance foundation**: **keyless GitHub-OIDC → AWS** acc For additional context about technologies to be used, project structure, -shell commands, and other important information, read the current plan +shell commands, and other important information, read the current plan: +`specs/001-backup-s3-foundation/plan.md` ## Roadmap conventions diff --git a/mise.toml b/mise.toml index 046867d..bc2853d 100644 --- a/mise.toml +++ b/mise.toml @@ -67,4 +67,5 @@ terraform -chdir=01-iam/bootstrap/infisical providers lock -platform=dar terraform -chdir=01-iam/ci-managed/aws-state-access 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 +terraform -chdir=03-backup/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64 """ diff --git a/specs/001-backup-s3-foundation/checklists/requirements.md b/specs/001-backup-s3-foundation/checklists/requirements.md new file mode 100644 index 0000000..dcc5235 --- /dev/null +++ b/specs/001-backup-s3-foundation/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Backup Storage Foundation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-20 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All 16 functional requirements map to at least one acceptance scenario (FR-014–016 added via clarification session 2026-06-20) +- No [NEEDS CLARIFICATION] markers were needed — the input description was exhaustive +- Assumptions section explicitly calls out: credential propagation out of scope, single credential pair per env, bucket name from config file, dev-only scope, no observability +- "Glacier" and "tfvars" from the input description were abstracted to "cold storage tier" and "environment configuration file" to keep the spec technology-agnostic +- Clarification session resolved: no provider-level deletion protection (CI identity has no deletion rights), dev-only initial scope, observability deferred, bucket naming convention required, lifecycle validation required as temporary safety net diff --git a/specs/001-backup-s3-foundation/contracts/terraform-interface.md b/specs/001-backup-s3-foundation/contracts/terraform-interface.md new file mode 100644 index 0000000..e17deba --- /dev/null +++ b/specs/001-backup-s3-foundation/contracts/terraform-interface.md @@ -0,0 +1,97 @@ +# Terraform Interface Contract: 03-backup/scaleway + +This document defines the public interface of the `03-backup/scaleway` Terraform root — the variables callers supply via `env/.tfvars` and the outputs it produces. + +--- + +## Input Variables + +### Identity & Scope + +| Variable | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `project_id` | string | Yes | — | Scaleway project the bucket and IAM resources belong to | +| `ci_application_id` | string | Yes | — | IAM application ID of the `github-ci` identity (for the bucket policy Deny statement). Stable after `01-iam/bootstrap/scaleway` is applied. | + +### Bucket + +| Variable | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `bucket_name` | string | Yes | — | Full bucket name. MUST include the environment name (e.g., `backup-dev-id`). | +| `region` | string | No | `"fr-par"` | Scaleway region where the bucket is created | + +### Lifecycle + +| Variable | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `versioning_enabled` | bool | No | `true` | Enable/suspend bucket versioning | +| `retention_days` | number | No | `365` | Days before current-version objects expire | +| `noncurrent_version_expiry_days` | number | No | `30` | Days before non-current versions expire | +| `cold_storage_enabled` | bool | No | `true` | Enable the GLACIER storage-class transition rule | +| `cold_storage_transition_days` | number | No | `90` | Days before objects transition to GLACIER (only evaluated when `cold_storage_enabled = true`) | + +**Validation gate (FR-016):** If `cold_storage_enabled = true`, then `cold_storage_transition_days` MUST be strictly less than `retention_days`. Violation causes `terraform plan` to fail with an explicit error. + +### Infisical (secret storage) + +| Variable | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `infisical_workspace_id` | string | No | `"7ecb6ed4-058a-46cd-ac9f-7e792469cf0f"` | Infisical project ID for writing scoped credentials | +| `infisical_env_slug` | string | No | `"staging"` | Infisical environment slug | +| `infisical_folder_path` | string | No | `"/backup/dev"` | Infisical folder path for backup workload credentials | + +### Infisical auth (local dev only) + +| Variable | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `infisical_client_id` | string | No | `""` | Universal auth client ID (local dev only) | +| `infisical_client_secret` | string | No | `""` | Universal auth client secret (local dev only; sensitive) | + +--- + +## Outputs + +| Output | Type | Sensitive | Description | +|--------|------|-----------|-------------| +| `bucket_name` | string | No | Provisioned bucket name | +| `bucket_region` | string | No | Region the bucket was created in | +| `bucket_endpoint` | string | No | S3-compatible endpoint URL for the bucket | +| `workload_access_key` | string | No | Public access key for the scoped backup workload identity | + +**Note:** The workload secret key is written to Infisical and is never surfaced as a Terraform output. It is never stored in tfvars files. State contains it as a sensitive value. + +--- + +## Infisical Secret Outputs + +The root writes two secrets to Infisical under `var.infisical_folder_path`: + +| Secret name | Content | +|-------------|---------| +| `BACKUP_ACCESS_KEY` | SCW access key for the backup workload identity | +| `BACKUP_SECRET_KEY` | SCW secret key for the backup workload identity (sensitive) | + +--- + +## Environment Files + +| File | Workspace | Environment | +|------|-----------|-------------| +| `env/dev.tfvars` | `dev` | Development / homelab | + +State key: `backup/scaleway/dev/terraform.tfstate` (in the shared S3 state bucket). + +--- + +## CI Workflow Contract + +| Trigger | Command | +|---------|---------| +| Pull request touching `03-backup/scaleway/**` | `plan` | +| Push to `main` touching `03-backup/scaleway/**` | `apply` | +| `workflow_dispatch` | `plan` or `apply` (no `destroy` option) | +| Scheduled event | **Never** (no cron, no destroy) | + +The backup CI workflow has no `schedule:` trigger and no `destroy` command mapping, satisfying FR-013. + +Post-apply step (on push to main only): `scw object bucket get ` — confirms the bucket exists. No S3 API property checks. diff --git a/specs/001-backup-s3-foundation/data-model.md b/specs/001-backup-s3-foundation/data-model.md new file mode 100644 index 0000000..cc4b47a --- /dev/null +++ b/specs/001-backup-s3-foundation/data-model.md @@ -0,0 +1,111 @@ +# Data Model: Backup S3 Foundation + +## Entities + +### BackupBucket + +The Scaleway Object Storage bucket providing isolated, lifecycle-managed backup storage. + +| Field | Type | Default | Constraint | +|-------|------|---------|------------| +| `name` | string | — | MUST include environment name (e.g., `backup-dev-id`) | +| `region` | string | `"fr-par"` | Scaleway region | +| `versioning_enabled` | bool | `true` | Once enabled, cannot be unversioned (only suspended) | +| `retention_days` | number | `365` | Days until current-version objects expire | +| `noncurrent_version_expiry_days` | number | `30` | Days until non-current object versions expire | +| `cold_storage_enabled` | bool | `true` | Toggle for GLACIER tier transition | +| `cold_storage_transition_days` | number | `90` | Days until transition to GLACIER storage class | +| `encryption` | string | `"AES256"` | Fixed; SSE-S3 via AES-256 | +| `force_destroy` | bool | `false` | Fixed; prevents accidental data loss via Terraform | + +**Invariants:** +- `cold_storage_enabled = false` OR `cold_storage_transition_days < retention_days` (FR-016 — validated at plan time via Terraform `precondition`) +- `name` contains the environment name as a substring (convention, not mechanically enforced) +- `force_destroy` is always false (hardcoded; no tfvars override) +- `lifecycle.prevent_destroy = true` on the Terraform resource — bucket cannot be destroyed via `terraform destroy` + +**State transitions (versioning):** +``` +unversioned → versioning_enabled=true → versioned (irreversible) +versioned → versioning_enabled=false → suspended (data retained, no new versions) +``` + +--- + +### ScopedAccessIdentity + +The Scaleway IAM application issued to backup workloads. Has object-level read/write access to the bucket but no administrative rights (no bucket deletion, no configuration changes). + +| Field | Type | Notes | +|-------|------|-------| +| `name` | string | e.g., `backup-workload-dev` | +| `iam_permissions` | list(string) | `["ObjectStorageObjectsRead", "ObjectStorageObjectsWrite"]` | +| `access_key` | string | Public identifier; surfaced as Terraform output | +| `secret_key` | string | Sensitive; written to Infisical only, never output in plaintext | +| `infisical_path` | string | e.g., `/backup/dev/` | + +**Invariants:** +- No `ObjectStorageBuckets*` permissions — identity has no bucket-level administrative rights +- `secret_key` MUST NOT appear in Terraform output (sensitive = true) or tfvars files + +**Access verification (FR-011):** +| Action | Expected result | +|--------|----------------| +| `s3:PutObject` | Allow | +| `s3:GetObject` | Allow | +| `s3:DeleteBucket` | Deny (IAM has no bucket permission + bucket policy has no allow) | + +--- + +### CIBucketPolicy + +An S3-style bucket policy applied to `BackupBucket` that enforces FR-014: the CI provisioning identity cannot delete the bucket even though its IAM policy grants `ObjectStorageBucketsWrite`. + +| Field | Type | Notes | +|-------|------|-------| +| `principal` | string | `application_id:` (the `github-ci` IAM application) | +| `effect` | string | `"Deny"` | +| `action` | list(string) | `["s3:DeleteBucket"]` | +| `resource` | list(string) | The bucket name | + +**Why this is needed:** Scaleway's IAM does not have a "create/update bucket but not delete" permission set. The only mechanism to enforce a deletion prohibition for an identity that has `ObjectStorageBucketsWrite` is an explicit DENY in the S3 bucket policy. Per Scaleway IAM evaluation logic, explicit denies in bucket policies override IAM allows. + +--- + +### LifecyclePolicy + +Not a distinct Terraform resource — represented as a `lifecycle_rule` block within `scaleway_object_bucket`. Documented here as a logical entity. + +| Rule | Condition | Action | +|------|-----------|--------| +| Current-version expiry | Always active when `enabled = true` | Expire objects after `retention_days` days | +| Noncurrent-version expiry | Always active when versioning enabled | Expire noncurrent versions after `noncurrent_version_expiry_days` days | +| Cold-tier transition | Active only when `cold_storage_enabled = true` | Transition to GLACIER after `cold_storage_transition_days` days | + +All three rules share a single `lifecycle_rule` block with `id = "backup-retention"` and `enabled = true`. + +--- + +## Cross-Entity Relationships + +``` +BackupBucket + ├── has CIBucketPolicy (1:1) — enforces deletion deny for CI principal + ├── has LifecyclePolicy (inline) — retention, expiry, cold tier rules + └── has SSEConfiguration (1:1) — AES256 encryption + +ScopedAccessIdentity + └── IAM policy scoped to BackupBucket's project + (no direct resource-level binding — Scaleway IAM is project-scoped) +``` + +--- + +## Validation Rules + +| Rule ID | Entity | Condition | Error | +|---------|--------|-----------|-------| +| FR-016 | BackupBucket | `!cold_storage_enabled \|\| cold_storage_transition_days < retention_days` | `cold_storage_transition_days must be strictly less than retention_days` | +| FR-015 | BackupBucket | `name` contains env name | Convention (not enforced by Terraform; enforced by tfvars design) | +| FR-001 | BackupBucket | SSE resource always present | N/A — resource is unconditional | +| FR-007 | ScopedAccessIdentity | `secret_key` output marked `sensitive = true`; NOT in any tfvars file | N/A — enforced by Terraform output config | diff --git a/specs/001-backup-s3-foundation/plan.md b/specs/001-backup-s3-foundation/plan.md new file mode 100644 index 0000000..b6de89b --- /dev/null +++ b/specs/001-backup-s3-foundation/plan.md @@ -0,0 +1,175 @@ +# Implementation Plan: Backup Storage Foundation + +**Branch**: `001-backup-s3-foundation` | **Date**: 2026-06-20 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `/specs/001-backup-s3-foundation/spec.md` + +## Summary + +Provision a Scaleway Object Storage bucket (`03-backup/scaleway/`) in its own Terraform root with independent lifecycle from the cluster. The bucket has AES-256 SSE encryption, versioning, configurable lifecycle rules (retention, noncurrent expiry, GLACIER tier transition), and a scoped IAM identity for backup workloads. The CI workflow runs plan on PRs and apply on merge — no scheduled destroy. + +## Technical Context + +**Language/Version**: HCL (Terraform 1.14 — pinned in `.github/actions/terraform/action.yml`) + +**Primary Dependencies**: +- `scaleway/scaleway` ~> 2.0 — Object Storage bucket, SSE config, bucket policy, IAM application/policy/API key +- `infisical/infisical` ~> 0.16 — write scoped workload credentials to Infisical +- `hashicorp/time` ~> 0.12 — API key rotation (same pattern as `01-iam/bootstrap/scaleway`) + +**Storage**: AWS S3 remote state backend, workspace_key_prefix = `"backup/scaleway"`, workspace = `dev` + +**Testing**: CI verification step in the backup workflow using AWS CLI against Scaleway's S3-compatible endpoint + +**Target Platform**: Scaleway (fr-par region, dev workspace) + +**Project Type**: Terraform infrastructure root + +**Performance Goals**: N/A — provisioning latency is not a concern + +**Constraints**: Bucket MUST survive cluster destroy/apply cycles (separate state key enforces this). CI MUST NOT have bucket deletion rights (enforced by bucket policy DENY + `prevent_destroy`). + +**Scale/Scope**: Single `dev` environment for initial delivery. Additional environments added via `env/.tfvars` — no code changes required. + +## Constitution Check + +The constitution file is still a template (not yet filled in for this project). Constitution check is skipped — no gates apply. Design choices default to CLAUDE.md conventions and CONVENTIONS.md principles. + +**CLAUDE.md compliance check**: +- ✅ All parameters are variables with sane low-cost defaults +- ✅ Environment overrides in `env/.tfvars`; filename = workspace name +- ✅ Deployment via composite action `.github/actions/terraform` (root + tfvars-file + command) +- ✅ PR → plan, push main → apply; NO scheduled destroy (FR-013) +- ✅ Numbered domain prefix (`03-backup/`); sub-folder = provider (`scaleway/`) +- ✅ No hardcoded values in shared infrastructure code + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-backup-s3-foundation/ +├── spec.md # Feature specification +├── plan.md # This file +├── research.md # Phase 0 — provider patterns, IAM strategy, validation approach +├── data-model.md # Phase 1 — entities, invariants, validation rules +├── quickstart.md # Phase 1 — end-to-end validation guide +├── contracts/ +│ └── terraform-interface.md # Variable + output interface contract +└── tasks.md # Phase 2 — generated by /speckit-tasks (not yet created) +``` + +### Source Code Layout + +```text +03-backup/ # new domain: backup storage + scaleway/ # provider-scoped root (room for future providers) + version.tf # providers: scaleway + infisical + time; S3 backend + variables.tf # all configurable inputs with sane defaults + main.tf # scaleway_object_bucket + SSE config + iam.tf # scoped workload IAM application + policy + API key + policy.tf # scaleway_object_bucket_policy (CI deletion deny) + infisical.tf # write workload credentials to Infisical + outputs.tf # bucket_name, bucket_endpoint, workload_access_key + .terraform.lock.hcl # pinned for darwin_arm64 + linux_amd64 + env/ + dev.tfvars # dev workspace: bucket name, project_id, ci_application_id, ... + +.github/workflows/ + backup_scaleway.yml # CI: plan on PR, apply on push, NO destroy trigger + +01-iam/bootstrap/scaleway/ + main.tf # MODIFIED: add 2nd IAM policy rule to github-ci app + # covering ObjectStorage + IAM permissions for backup CI +``` + +**Structure Decision**: Single root at `03-backup/scaleway/` — the backup domain has no bootstrap/ci-managed split because all resources (bucket + scoped identity) are managed by CI. There is no human-only trust anchor in this domain. + +--- + +## Design Decisions + +### D1 — New domain `03-backup/` + +Backup storage is a distinct infrastructure domain: independent lifecycle, independent state, independent credentials. It belongs in a new numbered domain (`03` = next available, after `02-cluster`). The domain number encodes apply-order independence — `03-backup` has no dependency on `02-cluster`. + +### D2 — Bucket policy DENY for FR-014 (CI deletion prohibition) + +Scaleway has no IAM permission set for "create/configure bucket without delete". The only enforceable implementation of FR-014 is: +1. `scaleway_object_bucket_policy` with `Effect: Deny`, `Action: s3:DeleteBucket`, scoped to the `github-ci` IAM application principal +2. `lifecycle { prevent_destroy = true }` on the bucket resource (Terraform-level guard) +3. No `destroy` command in the backup CI workflow + +The bucket policy explicit deny overrides the IAM `ObjectStorageBucketsWrite` allow per Scaleway/S3 evaluation semantics. All three layers must be in place. + +### D3 — Extend existing `github-ci` Scaleway identity + +The backup CI workflow reuses the `scaleway` GitHub environment (existing `SCW_ACCESS_KEY` / `SCW_SECRET_KEY`). A second IAM policy is added to the existing `github-ci` application in `01-iam/bootstrap/scaleway/main.tf`: +- `ObjectStorageBucketsRead`, `ObjectStorageBucketsWrite` — bucket lifecycle management (deletion blocked by D2) +- `ObjectStorageObjectsRead`, `ObjectStorageObjectsWrite` — object management (needed for Terraform refresh) +- IAM application/policy/API key management — provision the scoped backup workload identity + +This avoids a second API key pair and keeps credential management in one place. Acceptable for the initial `dev` scope; revisit when `prod` is added. + +### D4 — FR-016 validation via Terraform `precondition` + +The constraint `cold_storage_transition_days < retention_days` (when `cold_storage_enabled = true`) is enforced as a `lifecycle { precondition {...} }` block on `scaleway_object_bucket`. This fires at plan time, before any apply, with an actionable error message. Variable-level `validation` blocks cannot cross-reference other variables, making a resource precondition the only viable approach. + +### D5 — AES-256 SSE (not KMS) + +`scaleway_object_bucket_server_side_encryption_configuration` with `sse_algorithm = "AES256"`. Satisfies FR-001 (encryption enabled) at zero incremental cost. KMS is available but out of scope. + +### D6 — Vérification minimaliste : existence du bucket + Job cluster + +Le workflow CI ne fait qu'un seul check post-apply : `scw object bucket get ` confirme que le bucket existe et est accessible. Pas de vérification des propriétés S3 (encryption, versioning, lifecycle) — ces garanties découlent des ressources Terraform provisionnées et n'ont pas besoin d'être re-testées contre l'API Scaleway. + +La validation de bout en bout du chemin workload (credentials → S3) se fait via un Job Kubernetes one-shot déployé depuis le repo `gitops` (voir `quickstart.md`). Ce Job est la preuve que le chemin complet Terraform → Infisical → ESO → pod → bucket fonctionne. + +--- + +## Re-check: Constitution Check (Post-Design) + +With the design complete, the three core CONVENTIONS.md principles for Terraform roots are verified: + +| Principle | Status | +|-----------|--------| +| All parameterizable values are variables with sane defaults | ✅ (`retention_days=365`, `cold_storage_transition_days=90`, etc.) | +| Environment overrides live in `env/.tfvars`; filename = workspace | ✅ (`env/dev.tfvars` → workspace `dev`) | +| Deploy via composite action; PR→plan, push→apply, no scheduled destroy | ✅ (backup_scaleway.yml maps triggers accordingly) | + +No violations. No complexity justification table required. + +--- + +## Implementation Sequence + +The implementation has a strict ordering due to the credential dependency: the `github-ci` Scaleway identity must have Object Storage + IAM permissions before the backup Terraform root can be applied. + +``` +Step 1: Update 01-iam/bootstrap/scaleway/main.tf + → Add second IAM policy to github-ci: ObjectStorage + IAM management + → Apply via iam_terraform-backend-role.yml CI or local admin apply + → Prerequisite for all subsequent steps + +Step 2: New 03-backup/scaleway/ root + → version.tf (providers, S3 backend, workspace_key_prefix = "backup/scaleway") + → variables.tf (all inputs with defaults) + → main.tf (scaleway_object_bucket with lifecycle + versioning; SSE config) + → policy.tf (scaleway_object_bucket_policy — CI deletion deny) + → iam.tf (scoped workload IAM application + policy + API key) + → infisical.tf (Infisical secret folder + two secrets) + → outputs.tf (bucket_name, bucket_endpoint, workload_access_key) + → env/dev.tfvars + +Step 3: Provider lock file + → mise run lock (add 03-backup/scaleway to the lock targets in mise.toml) + +Step 4: CI workflow + → .github/workflows/backup_scaleway.yml + → Triggers: PR → plan, push main → apply; no schedule, no destroy + → Adds verification step (AWS CLI checks) after apply + +Step 5: Validation + → Run quickstart.md verification scenarios against the dev environment + → All SC-00x acceptance criteria must pass +``` diff --git a/specs/001-backup-s3-foundation/quickstart.md b/specs/001-backup-s3-foundation/quickstart.md new file mode 100644 index 0000000..2f7821a --- /dev/null +++ b/specs/001-backup-s3-foundation/quickstart.md @@ -0,0 +1,88 @@ +# Quickstart & Validation Guide: Backup Storage Foundation + +**Variables**: [contracts/terraform-interface.md](contracts/terraform-interface.md) +**Data model**: [data-model.md](data-model.md) + +--- + +## Provision (dev) + +```bash +terraform -chdir=03-backup/scaleway init +terraform -chdir=03-backup/scaleway workspace select -or-create dev +terraform -chdir=03-backup/scaleway plan -var-file=env/dev.tfvars +terraform -chdir=03-backup/scaleway apply -var-file=env/dev.tfvars -auto-approve +``` + +--- + +## Vérifier que le bucket existe + +```bash +scw object bucket get backup-dev-id --region fr-par +``` + +Sortie attendue : métadonnées du bucket (region, endpoint, date de création). Une erreur 404 indique que le bucket n'a pas été créé. + +--- + +## Valider l'accès workload depuis le cluster (test local) + +Ce test valide le chemin complet : credentials Terraform → Secret Kubernetes → pod → S3. +Il s'exécute manuellement contre le cluster local (minikube) ou Scaleway. + +**Étape 1 — Récupérer les credentials depuis les outputs Terraform** + +```bash +ACCESS_KEY=$(terraform -chdir=03-backup/scaleway output -raw workload_access_key) +# La secret key n'est pas surfacée en output — la lire depuis Infisical : +SECRET_KEY=$(infisical secrets get BACKUP_SECRET_KEY --path /backup/dev --env staging --plain) +``` + +**Étape 2 — Créer le Secret Kubernetes (bypass ESO pour le test local)** + +```bash +kubectl create namespace backup --dry-run=client -o yaml | kubectl apply -f - +kubectl create secret generic backup-workload-credentials \ + --from-literal=access_key="$ACCESS_KEY" \ + --from-literal=secret_key="$SECRET_KEY" \ + --namespace backup +``` + +**Étape 3 — Lancer le Job de validation** + +```bash +kubectl apply -f specs/001-backup-s3-foundation/test-job.yaml +kubectl wait --for=condition=complete job/backup-probe --namespace backup --timeout=60s +kubectl logs job/backup-probe --namespace backup +``` + +Sortie attendue dans les logs : `upload: /tmp/probe.txt to s3://backup-dev-id/probe/...` + +**Étape 4 — Nettoyage** + +```bash +kubectl delete job backup-probe --namespace backup +kubectl delete secret backup-workload-credentials --namespace backup +``` + +Le Job YAML (`test-job.yaml`) est colocalisé avec cette spec. En production, le workload réel remplace ce Job et ses credentials viennent d'ESO (pas d'un Secret créé manuellement). + +--- + +## FR-016 — gate de validation lifecycle + +```bash +terraform -chdir=03-backup/scaleway plan \ + -var-file=env/dev.tfvars \ + -var="cold_storage_enabled=true" \ + -var="cold_storage_transition_days=365" \ + -var="retention_days=365" +# Attendu : Error: cold_storage_transition_days must be strictly less than retention_days +``` + +--- + +## Note — CI + +L'intégration CI du test cluster (Job Kubernetes automatisé dans le workflow backup) est hors scope de cette feature. Le `scw object bucket get` post-apply dans le workflow CI est le seul check automatisé. diff --git a/specs/001-backup-s3-foundation/research.md b/specs/001-backup-s3-foundation/research.md new file mode 100644 index 0000000..23a4907 --- /dev/null +++ b/specs/001-backup-s3-foundation/research.md @@ -0,0 +1,199 @@ +# Research: Backup S3 Foundation + +**Phase 0 output for `/speckit-plan`** — all NEEDS CLARIFICATION items resolved. + +## 1. Scaleway Object Storage — Terraform Resources + +**Decision**: Use `scaleway_object_bucket` (inline lifecycle_rule / versioning blocks) + separate `scaleway_object_bucket_server_side_encryption_configuration` + `scaleway_object_bucket_policy` for explicit deny. + +**Rationale**: The Scaleway provider supports two styles for lifecycle configuration: inline blocks on `scaleway_object_bucket` and the standalone `scaleway_object_bucket_lifecycle_configuration` resource. The inline blocks are the current recommended pattern and cover all required attributes (expiration, transition, noncurrent_version_expiration, noncurrent_version_transition). Versioning is also inline (`versioning { enabled = true }`). SSE requires a separate resource (`scaleway_object_bucket_server_side_encryption_configuration`) to make encryption explicit in state. + +**Alternatives considered**: `scaleway_object_bucket_lifecycle_configuration` standalone resource — rejected because inline blocks require fewer resources and avoid dependency ordering issues. + +**Key resource attributes:** + +```hcl +resource "scaleway_object_bucket" "backup" { + name = var.bucket_name + region = var.region + + versioning { + enabled = var.versioning_enabled + } + + lifecycle_rule { + id = "backup-retention" + enabled = true + + expiration { + days = var.retention_days + } + + noncurrent_version_expiration { + noncurrent_days = var.noncurrent_version_expiry_days + } + + dynamic "transition" { + for_each = var.cold_storage_enabled ? [var.cold_storage_transition_days] : [] + content { + days = transition.value + storage_class = "GLACIER" + } + } + } + + lifecycle { + prevent_destroy = true + precondition { + condition = !var.cold_storage_enabled || var.cold_storage_transition_days < var.retention_days + error_message = "cold_storage_transition_days must be strictly less than retention_days." + } + } +} +``` + +--- + +## 2. Scaleway IAM Permission Sets for Object Storage + +**Decision**: Use layered defense for FR-014 (CI SHALL NOT have bucket deletion rights): +1. Scaleway IAM policy for CI identity: `ObjectStorageBucketsRead` + `ObjectStorageBucketsWrite` + `ObjectStorageObjectsRead` + `ObjectStorageObjectsWrite` (needed to create/manage bucket and its configuration) +2. `scaleway_object_bucket_policy` with explicit `Deny` on `s3:DeleteBucket` scoped to the CI application principal — overrides IAM allows +3. `lifecycle { prevent_destroy = true }` on the bucket Terraform resource — blocks destroy via Terraform +4. No scheduled destroy in the backup CI workflow (FR-013) + +**Rationale**: Scaleway does not expose a "bucket create but not delete" IAM permission set. The only path to enforce the spec's FR-014 requirement is through an S3 bucket policy DENY statement, which takes precedence over IAM policy ALLOWs per Scaleway's IAM evaluation logic (same as AWS: explicit deny overrides allow). The `prevent_destroy` lifecycle provides Terraform-level defense in depth. + +**Scoped backup workload identity permissions**: `ObjectStorageObjectsRead` + `ObjectStorageObjectsWrite`. These exclude all bucket-level administrative actions. Verification test: attempt `s3:DeleteBucket` with scoped credentials → denied by IAM (no bucket permission) + by bucket policy. + +**Scaleway IAM permission sets confirmed available:** +- `ObjectStorageBucketsRead` — list/stat buckets +- `ObjectStorageBucketsWrite` — full bucket CRUD (create, update, delete) +- `ObjectStorageObjectsRead` — read/list objects +- `ObjectStorageObjectsWrite` — write/update/delete objects +- `ObjectStorageFullAccess` — all of the above + +--- + +## 3. Encryption + +**Decision**: `scaleway_object_bucket_server_side_encryption_configuration` with `sse_algorithm = "AES256"`. + +**Rationale**: AES-256 SSE is the cost-free default for Scaleway Object Storage. Scaleway also offers KMS-based SSE (`aws:kms`) but it requires provisioning a KMS key, which introduces cost and operational overhead. The spec only requires encryption enabled (FR-001) with no KMS mandate. + +```hcl +resource "scaleway_object_bucket_server_side_encryption_configuration" "backup" { + bucket = scaleway_object_bucket.backup.name + region = var.region + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} +``` + +--- + +## 4. FR-016 Validation Strategy + +**Decision**: Terraform `lifecycle { precondition {...} }` block on `scaleway_object_bucket`. + +**Rationale**: Preconditions on resources evaluate at plan time, before any apply, and produce a clear error message. This is the canonical Terraform pattern for validating cross-variable constraints. A `variable { validation {...} }` block on individual variables cannot cross-reference other variables, so a resource-level precondition is the only viable approach for the "transition_days < retention_days" constraint. + +**Condition**: `!var.cold_storage_enabled || var.cold_storage_transition_days < var.retention_days` +- When cold storage is disabled: constraint is bypassed (vacuously true) +- When cold storage is enabled: transition delay must be strictly less than retention period + +--- + +## 5. Workload Identity Strategy — Static Credentials via Infisical + +**Decision**: Credentials statiques (API key Scaleway) générées par Terraform, stockées dans Infisical, injectées comme variables d'environnement dans le pod via ESO (External Secrets Operator). Pas de pod workload identity. + +**Rationale**: Scaleway Kapsule ne supporte pas le mapping IAM → Kubernetes ServiceAccount (IRSA-equivalent). C'est une [feature request ouverte](https://feature-request.scaleway.com/posts/677/map-iam-to-kubernetes-kapsule-service-account) sans date d'implémentation connue. La seule option disponible est donc une API key statique distribuée via Infisical. + +**Chemin complet** : `03-backup/scaleway apply` → `scaleway_iam_api_key` → `infisical_secret` → ESO lit depuis Infisical → `kind: Secret` Kubernetes → pod monte `SCW_ACCESS_KEY` + `SCW_SECRET_KEY` comme env vars. + +## 5b. CI Credentials Strategy + +**Decision**: Add a new IAM policy to the existing `github-ci` Scaleway application (in `01-iam/bootstrap/scaleway/main.tf`) covering Object Storage management + IAM application/policy/API key management. The backup CI workflow reuses the existing `scaleway` GitHub environment. + +**Rationale**: Creating a second Scaleway IAM application and a second set of API key secrets in GitHub would require a new GitHub environment or secret naming convention, adding operational overhead. Extending the existing `github-ci` application is consistent with how the cluster CI credentials work (one application, one API key, all CI use cases). The expanded permissions are scoped to the project and remain within the principle of least-privilege for what the backup root needs. + +**Permissions to add to `github-ci`**: +- `ObjectStorageBucketsRead` + `ObjectStorageBucketsWrite` — create/manage bucket (deletion enforced-denied by bucket policy) +- `ObjectStorageObjectsRead` + `ObjectStorageObjectsWrite` — manage bucket content (needed for Terraform refresh on object resources) +- IAM application/policy/API key management — provision the scoped backup workload identity + +**Alternatives considered**: New dedicated IAM application for backup CI — rejected for initial delivery. Can be revisited when a second environment (`prod`) is added and credential isolation becomes a priority. + +--- + +## 6. State Isolation + +**Decision**: New remote S3 backend root with `workspace_key_prefix = "backup/scaleway"`, workspace name `dev` (from `env/dev.tfvars`). + +**Rationale**: This root has no Terraform dependency on the cluster roots and must survive cluster destroy/apply cycles. A completely separate state key guarantees isolation. The naming follows the pattern of `cluster/scaleway` already in use. + +--- + +## 7. CI Verification Approach + +**Decision**: Add a `verify` job to the backup CI workflow using the AWS CLI with the Scaleway S3-compatible endpoint (`https://s3.{region}.scw.cloud`). The verification job runs after `terraform apply` only on push to main. + +**Rationale**: The AWS CLI is already present in GitHub Actions ubuntu-latest runners. Using it against Scaleway's S3-compatible API requires no new tooling. The `scw` CLI is an alternative but is not guaranteed to be present in runners without setup. + +**Verification commands** (using AWS CLI with Scaleway endpoint): +```bash +# Bucket encryption (FR-009) +aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api get-bucket-encryption --bucket "$BUCKET_NAME" + +# Versioning (FR-009) +aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api get-bucket-versioning --bucket "$BUCKET_NAME" + +# Lifecycle rules (FR-010) +aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api get-bucket-lifecycle-configuration --bucket "$BUCKET_NAME" + +# Scoped credential scope test (FR-011) +AWS_ACCESS_KEY_ID="$WORKLOAD_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$WORKLOAD_SECRET_KEY" \ + aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api put-object --bucket "$BUCKET_NAME" --key "ci-verify/probe.txt" --body /dev/null +AWS_ACCESS_KEY_ID="$WORKLOAD_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$WORKLOAD_SECRET_KEY" \ + aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api get-object --bucket "$BUCKET_NAME" --key "ci-verify/probe.txt" /dev/null +# Expect non-zero exit: +AWS_ACCESS_KEY_ID="$WORKLOAD_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$WORKLOAD_SECRET_KEY" \ + aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api delete-bucket --bucket "$BUCKET_NAME" \ + && echo "::error::Scoped credentials should NOT be able to delete the bucket" && exit 1 \ + || echo "Bucket deletion correctly denied" +``` + +Scoped credentials are read from Infisical at verify time or surfaced via Terraform outputs (sensitive). + +--- + +## 8. Bucket Policy for CI Deletion Deny + +**Decision**: `scaleway_object_bucket_policy` with explicit Deny on `s3:DeleteBucket` for the CI IAM application principal. + +```hcl +resource "scaleway_object_bucket_policy" "backup" { + bucket = scaleway_object_bucket.backup.name + region = var.region + + policy = jsonencode({ + Version = "2023-04-17" + Statement = [ + { + Sid = "DenyBucketDeletionForCI" + Effect = "Deny" + Principal = { SCW = "application_id:${var.ci_application_id}" } + Action = ["s3:DeleteBucket"] + Resource = [scaleway_object_bucket.backup.name] + } + ] + }) +} +``` + +The CI application ID is passed via `var.ci_application_id` in `env/dev.tfvars`. It is stable (never changes after `01-iam/bootstrap/scaleway` is applied) and safe to hardcode in tfvars. diff --git a/specs/001-backup-s3-foundation/spec.md b/specs/001-backup-s3-foundation/spec.md new file mode 100644 index 0000000..dc71362 --- /dev/null +++ b/specs/001-backup-s3-foundation/spec.md @@ -0,0 +1,145 @@ +# Feature Specification: Backup Storage Foundation + +**Feature Branch**: `001-backup-s3-foundation` + +**Created**: 2026-06-20 + +**Status**: Draft + +**Input**: User description: "Fondation backup S3 (Scaleway Object Storage) — root Terraform dédié, état isolé, bucket chiffré + versionné, configurable, credentials scoped, workflow CI sans destroy." + +## Clarifications + +### Session 2026-06-20 + +- Q: Should the bucket have provider-level deletion protection? → A: No provider-level protection. Bucket deletion is reserved for human operators with administrative credentials; the CI identity SHALL NOT have bucket deletion rights. The implementation code SHALL include a comment at the point where deletion protection is absent, documenting this deliberate choice. +- Q: Which environments are in scope for the initial delivery? → A: Single `dev` environment only. Additional environments (e.g., `prod`) are out of scope and can be added via a new environment configuration file without modifying shared infrastructure code. +- Q: Is operational observability (monitoring, alerting) in scope? → A: No. Bucket-level observability is deferred to a future platform observability feature. CI verification at deploy time is the only correctness signal required. +- Q: Is a bucket naming convention required to prevent collisions across environments? → A: Yes. The bucket name SHALL include the environment name as a component (e.g., `backup-dev-`). This is a platform requirement, not an operator preference. +- Q: Should the platform validate conflicting lifecycle values (cold storage transition delay ≥ object retention period)? → A: Yes, as a temporary safety net until a future lifecycle policy operator is implemented. The platform SHALL reject configurations where the cold storage transition delay is greater than or equal to the object retention period. This validation is explicitly transitional. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Persistent Backup Bucket (Priority: P1) + +A platform operator provisions a backup storage bucket that is completely independent from the cluster lifecycle. The bucket persists through cluster destroy and re-apply cycles — cluster teardowns never affect it. + +**Why this priority**: Without isolation from the cluster lifecycle, backup data would be destroyed every night when the scheduled cluster teardown runs, defeating the entire purpose of backups. + +**Independent Test**: Can be tested by provisioning the bucket, writing a marker object, triggering a full cluster destroy/apply cycle, and confirming the marker object still exists. + +**Acceptance Scenarios**: + +1. **Given** the backup bucket has been provisioned and contains a marker object, **When** the cluster is fully destroyed and re-applied, **Then** the marker object is still present in the bucket. +2. **Given** the backup bucket infrastructure has been applied, **When** the cluster infrastructure is destroyed, **Then** no dependency causes the backup bucket to be deleted or modified. + +--- + +### User Story 2 - Configurable Data Retention (Priority: P2) + +A platform operator can adjust the bucket's data lifecycle policies (object retention, noncurrent version expiry, cold storage tier transition) per environment through an environment configuration file, without modifying shared infrastructure code. + +**Why this priority**: Different environments have different cost/durability tradeoffs. A dev environment might keep objects for 30 days; production might keep them for a year. These values must be externalizable, never hardcoded. + +**Independent Test**: Can be tested by deploying the bucket with a non-default environment configuration file and asserting via the cloud provider CLI that the applied lifecycle rules match the file's values exactly. + +**Acceptance Scenarios**: + +1. **Given** an environment configuration file specifies a 90-day object retention and cold storage tier transition disabled, **When** the bucket is provisioned using that configuration, **Then** the bucket's lifecycle policy reflects exactly those settings. +2. **Given** no environment configuration overrides are provided, **When** the bucket is provisioned with default settings, **Then** the defaults (365-day retention, 30-day noncurrent version expiry, 90-day cold storage tier transition enabled, versioning on) are applied without any required operator input. +3. **Given** the bucket is already provisioned, **When** the operator updates the environment configuration file and re-applies, **Then** the lifecycle policy updates to match the new values. + +--- + +### User Story 3 - Scoped Backup Credentials (Priority: P3) + +Backup workloads are issued credentials that allow reading and writing backup objects but cannot perform administrative actions (deleting the bucket, modifying bucket configuration, etc.). + +**Why this priority**: Principle of least privilege — a compromised backup agent must not be able to destroy the backup bucket. Credential scope is a security boundary, not an afterthought. + +**Independent Test**: Can be tested by using the issued credentials to attempt a write, a read, and a bucket deletion — verifying the first two succeed and the third is denied with an access error. + +**Acceptance Scenarios**: + +1. **Given** scoped credentials have been issued, **When** a backup workload writes an object using those credentials, **Then** the write succeeds. +2. **Given** scoped credentials have been issued, **When** a backup workload reads a previously written object using those credentials, **Then** the read succeeds. +3. **Given** scoped credentials have been issued, **When** a process attempts to delete the bucket using those credentials, **Then** the operation is denied with an access error. + +--- + +### User Story 4 - Automated CI Verification (Priority: P4) + +Every deployment of the backup bucket is automatically verified end-to-end by the CI pipeline, with no manual steps required. Verification covers encryption, versioning, lifecycle accuracy, credential scope, and cluster-lifecycle isolation. + +**Why this priority**: Manual verification is error-prone and not repeatable. The acceptance criteria require CI-based proof that can be executed and re-executed by any contributor. + +**Independent Test**: Can be tested by running the CI verification job on a feature branch and observing that all four verification assertions pass without human intervention. + +**Acceptance Scenarios**: + +1. **Given** the CI job runs after provisioning, **When** it inspects the bucket, **Then** it confirms the bucket exists with encryption and versioning enabled. +2. **Given** the CI job runs, **When** it reads the bucket's lifecycle configuration, **Then** it confirms the rules match the values declared in the environment configuration file used during provisioning. +3. **Given** the CI job runs with the scoped credentials, **When** it attempts a write, a read, and a bucket deletion, **Then** write and read succeed, and bucket deletion is denied. +4. **Given** the CI job runs after a cluster destroy/apply cycle, **When** it checks for a pre-written marker object, **Then** the object is still present. + +--- + +### Edge Cases + +- ~~What happens when the cold storage tier transition delay is set shorter than the noncurrent version expiry window?~~ Resolved: the platform SHALL reject configurations where the cold storage transition delay ≥ object retention period (FR-016). This validation is a temporary safety net pending a future lifecycle policy operator. +- ~~How does the system behave if the bucket name collides with an existing bucket in the same account?~~ Resolved: bucket name SHALL include the environment name, making collisions a convention violation rather than an operational risk. +- What happens to objects already in standard storage when the cold storage tier transition is toggled from disabled to enabled on an existing bucket? +- How are leaked scoped credentials revoked without disrupting the bucket or its data? +- What happens if the CI verification job runs while the bucket is being provisioned (race condition)? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The platform SHALL provision an object storage bucket with server-side encryption enabled. +- **FR-002**: The platform SHALL enable versioning on the bucket by default, with versioning configurable (on/off) via an environment configuration file. +- **FR-003**: The platform SHALL apply a configurable object retention policy (maximum age before expiry) to the bucket, with a default of 365 days. +- **FR-004**: The platform SHALL apply a configurable noncurrent version expiry policy, with a default of 30 days. +- **FR-005**: The platform SHALL support a configurable cold storage tier transition rule, with an on/off toggle and a transition delay in days, defaulting to enabled at 90 days. +- **FR-006**: The platform SHALL issue a scoped access identity with read and write permissions on the bucket's objects, and no administrative permissions (no bucket deletion, no bucket configuration changes). +- **FR-007**: The platform SHALL expose the scoped credentials as protected outputs, not stored in plaintext configuration files. +- **FR-015**: The backup bucket name SHALL include the environment name as a component (e.g., `backup-dev-`), ensuring names are unique across environments by convention. +- **FR-016**: The platform SHALL reject configurations where the cold storage tier transition delay is greater than or equal to the object retention period, producing an explicit validation error. This is a temporary safety net pending a future lifecycle policy operator. +- **FR-014**: The CI identity used by the backup CI workflow SHALL NOT have bucket deletion rights. Bucket deletion SHALL be possible only by a human operator acting with administrative credentials outside of any automated pipeline. +- **FR-008**: The backup bucket's provisioning lifecycle SHALL be fully independent from the cluster's provisioning lifecycle — destroying the cluster SHALL NOT affect the bucket. +- **FR-009**: The CI pipeline SHALL verify after each deployment that the bucket's encryption and versioning are enabled. +- **FR-010**: The CI pipeline SHALL verify after each deployment that the bucket's lifecycle rules match the values declared in the environment configuration file. +- **FR-011**: The CI pipeline SHALL verify that the scoped credentials can write and read objects but cannot delete the bucket. +- **FR-012**: The CI pipeline SHALL verify that a pre-written marker object persists through a full cluster destroy/apply cycle. +- **FR-013**: The platform SHALL provide a dedicated CI workflow for the backup domain that runs plan on pull requests and apply on merge to main, with no scheduled destroy ever configured. + +### Key Entities + +- **Backup Bucket**: The object storage container. Attributes: name, region, encryption status, versioning status. +- **Lifecycle Policy**: Data retention rules attached to the bucket. Attributes: object retention period (days), noncurrent version expiry period (days), cold storage tier transition toggle (enabled/disabled), cold storage tier transition delay (days). +- **Scoped Access Identity**: The access identity issued to backup workloads. Attributes: permission scope (read/write objects only, no administrative rights), credential outputs (protected/sensitive). +- **Backup Infrastructure Domain**: The isolated infrastructure unit managing the bucket and its credentials. Attribute: independent state lifecycle from all other infrastructure domains. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: The backup bucket retains 100% of its stored objects through every cluster destroy/apply cycle, verified on at least one complete cycle before merge to main. +- **SC-002**: 100% of configurable lifecycle parameters (retention, noncurrent expiry, cold storage tier toggle and delay) are reflected exactly in the provisioned bucket, with zero hardcoded values in shared infrastructure code. +- **SC-003**: Scoped credentials produce a successful write and a successful read, and an explicit access denial on bucket deletion, in 100% of CI verification runs. +- **SC-004**: All four CI verification assertions (encryption + versioning, lifecycle accuracy, credential scope, cluster isolation) pass on every deployment without any manual operator steps. +- **SC-005**: The backup CI workflow executes plan on every pull request and apply on every merge to main, with zero occurrences of a destroy step triggered by a scheduled event. + +## Assumptions + +- The backup bucket will be hosted in the same cloud provider region as the cluster unless overridden by the environment configuration file. +- Credential rotation and propagation to consuming workloads (e.g., via a secrets manager) is out of scope for this feature; this feature provisions and outputs the initial credentials only. +- The cold storage tier is the only tiered storage class in scope; additional storage tiers are out of scope. +- The bucket name is defined in the environment configuration file, not auto-generated at provisioning time. +- The initial delivery provisions a single `dev` environment. The workspace model allows adding further environments (e.g., `prod`) without code changes, but they are out of scope here. +- A single scoped access identity (one credential pair) per environment is sufficient; per-workload or multi-identity credential management is out of scope. +- The CI verification job uses the cloud provider CLI already available in the CI environment; no new tooling is introduced. +- The scoped identity has no path to escalating its own permissions (no IAM self-modification rights). +- No provider-level deletion protection is applied to the bucket; this is intentional and SHALL be documented via a code comment at the relevant point of implementation. Bucket deletion is a manual-only, human-operator action. +- Operational observability (monitoring, alerting) is out of scope for this feature; CI verification at deploy time is the only required correctness signal. +- The cross-rule lifecycle validation (FR-016) is explicitly temporary; a future lifecycle policy operator will supersede it with richer guardrails. The spec requirement is scoped to the transition delay vs. retention period constraint only. diff --git a/specs/001-backup-s3-foundation/tasks.md b/specs/001-backup-s3-foundation/tasks.md new file mode 100644 index 0000000..9a58a36 --- /dev/null +++ b/specs/001-backup-s3-foundation/tasks.md @@ -0,0 +1,202 @@ +# Tasks: Backup Storage Foundation + +**Input**: Design documents from `/specs/001-backup-s3-foundation/` + +**Branch**: `001-backup-s3-foundation` | **Plan**: [plan.md](plan.md) | **Spec**: [spec.md](spec.md) + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: User story label (US1–US4) +- Tests: not requested — no test tasks generated + +--- + +## Phase 1: Setup + +**Purpose**: Create the new Terraform root's directory structure. + +- [x] T001 Create directories `03-backup/scaleway/` and `03-backup/scaleway/env/` + +--- + +## Phase 2: Foundational (Blocking Prerequisite) + +**Purpose**: Extend the existing `github-ci` Scaleway identity with the permissions the backup CI workflow needs. Must be applied (or at least committed to trigger CI apply) before the backup root's workflow can run `plan` or `apply`. + +**⚠️ CRITICAL**: The backup workflow will fail without this. Apply via the existing `iam_terraform-backend-role.yml` CI workflow or locally with admin Scaleway credentials. + +- [x] T002 Update `01-iam/bootstrap/scaleway/main.tf` — add a second `scaleway_iam_policy` resource (e.g. `backup_ci`) attached to `scaleway_iam_application.this`, granting `ObjectStorageBucketsRead`, `ObjectStorageBucketsWrite`, `ObjectStorageObjectsRead`, `ObjectStorageObjectsWrite`, and IAM management permissions (`IamReadOnly` + `IamManager` or equivalent) scoped to `var.project_id` + +**Checkpoint**: `01-iam/bootstrap/scaleway` applied → `github-ci` has Object Storage + IAM permissions → backup root CI can proceed. + +--- + +## Phase 3: User Story 1 — Persistent Backup Bucket (Priority: P1) 🎯 MVP + +**Goal**: Provision an encrypted, versioned bucket in its own Terraform root with state completely independent from the cluster. Cluster destroy/apply cycles cannot touch it. + +**Independent Test**: `scw object bucket get backup-dev-id --region fr-par` returns bucket metadata after `terraform apply`. Run `terraform destroy -target=module.*cluster*` and confirm the bucket still exists. + +- [x] T003 [US1] Create `03-backup/scaleway/version.tf` — S3 backend (`bucket`, `region="eu-west-3"`, `workspace_key_prefix="backup/scaleway"`, `key="terraform.tfstate"`, `encrypt=true`, `use_lockfile=true`) + `required_providers`: scaleway `~>2.0`, infisical `~>0.16`, time `~>0.12`; `provider "scaleway" {}` + `provider "infisical" { auth = { oidc = {} } }` (matching pattern of `02-cluster/scaleway/version.tf`) + +- [x] T004 [P] [US1] Create `03-backup/scaleway/variables.tf` — declare ALL variables with sane defaults: + - Bucket: `bucket_name` (string, required), `region` (default `"fr-par"`) + - Lifecycle: `versioning_enabled` (bool, default `true`), `retention_days` (number, default `365`), `noncurrent_version_expiry_days` (number, default `30`), `cold_storage_enabled` (bool, default `true`), `cold_storage_transition_days` (number, default `90`) + - Identity: `project_id` (string, required), `ci_application_id` (string, required — the `github-ci` app ID from `01-iam/bootstrap/scaleway` outputs) + - Infisical: `infisical_workspace_id` (default `"7ecb6ed4-058a-46cd-ac9f-7e792469cf0f"`), `infisical_env_slug` (default `"staging"`), `infisical_folder_path` (default `"/backup/dev"`), `infisical_client_id` (default `""`), `infisical_client_secret` (sensitive, default `""`) + +- [x] T005 [P] [US1] Create `03-backup/scaleway/env/dev.tfvars` — fill dev workspace values: `bucket_name = "backup-dev-id"`, `region = "fr-par"`, `project_id = ""`, `ci_application_id = ""` (retrieve via `terraform -chdir=01-iam/bootstrap/scaleway output application_id`), infisical defaults accepted + +- [x] T006 [US1] Create `03-backup/scaleway/main.tf` — two resources: + 1. `scaleway_object_bucket "backup"`: `name=var.bucket_name`, `region=var.region`, `versioning { enabled=var.versioning_enabled }`, `lifecycle { prevent_destroy=true; precondition { condition = !var.cold_storage_enabled || var.cold_storage_transition_days < var.retention_days; error_message = "cold_storage_transition_days must be strictly less than retention_days" } }`. Add comment at the `force_destroy` absence: `# Deletion intentionally NOT protected at the provider level — see spec.md FR-014. Bucket deletion is a manual-only, human-operator action.` + 2. `scaleway_object_bucket_server_side_encryption_configuration "backup"`: `bucket=scaleway_object_bucket.backup.name`, `region=var.region`, `rule { apply_server_side_encryption_by_default { sse_algorithm="AES256" } }` + +- [x] T007 [P] [US1] Create `03-backup/scaleway/outputs.tf` — three outputs: `bucket_name` (value=`scaleway_object_bucket.backup.name`), `bucket_region` (value=`scaleway_object_bucket.backup.region`), `bucket_endpoint` (value=`"https://s3.${var.region}.scw.cloud/${var.bucket_name}"`) + +- [x] T008 [US1] Add `03-backup/scaleway` to `mise.toml` lock task (add `terraform -chdir=03-backup/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64` alongside the other roots), then run `mise run lock` to generate `03-backup/scaleway/.terraform.lock.hcl` + +**Checkpoint**: `terraform apply -var-file=env/dev.tfvars` succeeds. `scw object bucket get backup-dev-id --region fr-par` returns bucket metadata. ✅ US1 done. + +--- + +## Phase 4: User Story 2 — Configurable Data Retention (Priority: P2) + +**Goal**: Complete the lifecycle_rule block so all four lifecycle parameters (retention, noncurrent expiry, cold storage toggle + delay) are driven by `dev.tfvars` with no hardcoded values. + +**Independent Test**: Modify `dev.tfvars` to set `cold_storage_enabled=true`, `cold_storage_transition_days=30`, `retention_days=365`. Re-apply. Confirm lifecycle rule changes (manual check sufficient — see quickstart.md). Then test the FR-016 gate: set `cold_storage_transition_days=365` and confirm plan fails with the precondition error. + +- [x] T009 [US2] Update `03-backup/scaleway/main.tf` — expand the `lifecycle_rule` block inside `scaleway_object_bucket.backup`: + - Add `id = "backup-retention"`, `enabled = true` + - Add `expiration { days = var.retention_days }` + - Add `noncurrent_version_expiration { noncurrent_days = var.noncurrent_version_expiry_days }` + - Add `dynamic "transition" { for_each = var.cold_storage_enabled ? [var.cold_storage_transition_days] : []; content { days = transition.value; storage_class = "GLACIER" } }` + - The FR-016 precondition is already present from T006 — no change needed + +**Checkpoint**: `terraform plan` with `cold_storage_transition_days=365` + `retention_days=365` fails with precondition error. Plan with valid values shows correct lifecycle rules in the diff. ✅ US2 done. + +--- + +## Phase 5: User Story 3 — Scoped Backup Credentials (Priority: P3) + +**Goal**: Provision a Scaleway IAM application with object-level read+write access (no bucket deletion), write its credentials to Infisical, and attach a bucket policy that explicitly denies `s3:DeleteBucket` to the CI identity. + +**Independent Test**: Run quickstart.md §"Valider l'accès workload depuis le cluster" — create the test Secret manually from Terraform outputs, launch `test-job.yaml`, verify the Job succeeds. Confirm `workload_access_key` appears in `terraform output`. + +- [x] T010 [US3] Create `03-backup/scaleway/iam.tf` — three resources: + 1. `scaleway_iam_application "workload"`: `name = "backup-workload-${terraform.workspace}"` + 2. `scaleway_iam_policy "workload"`: `application_id = scaleway_iam_application.workload.id`, `rule { project_ids = [var.project_id]; permission_set_names = ["ObjectStorageObjectsRead", "ObjectStorageObjectsWrite"] }` + 3. `time_rotating "workload_key"`: `rotation_days = 365`; `scaleway_iam_api_key "workload"`: `application_id = scaleway_iam_application.workload.id`, `expires_at = time_rotating.workload_key.rotation_rfc3339`, `default_project_id = var.project_id` + +- [x] T011 [US3] Create `03-backup/scaleway/policy.tf` — `scaleway_object_bucket_policy "backup"`: `bucket = scaleway_object_bucket.backup.name`, `region = var.region`, `policy = jsonencode({ Version = "2023-04-17"; Statement = [{ Sid = "DenyBucketDeletionForCI"; Effect = "Deny"; Principal = { SCW = "application_id:${var.ci_application_id}" }; Action = ["s3:DeleteBucket"]; Resource = [scaleway_object_bucket.backup.name] }] })` + +- [x] T012 [P] [US3] Create `03-backup/scaleway/infisical.tf` — `infisical_secret_folder "backup"` at path `var.infisical_folder_path` under root `/`, then `infisical_secret "access_key"` (`name = "BACKUP_ACCESS_KEY"`, `value = scaleway_iam_api_key.workload.access_key`) and `infisical_secret "secret_key"` (`name = "BACKUP_SECRET_KEY"`, `value = scaleway_iam_api_key.workload.secret_key`, sensitive). Follow the pattern in `01-iam/bootstrap/scaleway/main.tf`. + +- [x] T013 [US3] Update `03-backup/scaleway/outputs.tf` — add `workload_access_key` output: `value = scaleway_iam_api_key.workload.access_key`, `description = "Public access key for the scoped backup workload identity."` + +**Checkpoint**: `terraform apply` succeeds with 5+ new resources. `terraform output workload_access_key` shows an access key. Infisical `/backup/dev/` folder contains `BACKUP_ACCESS_KEY` and `BACKUP_SECRET_KEY`. ✅ US3 done. + +--- + +## Phase 6: User Story 4 — CI Workflow (Priority: P4) + +**Goal**: A CI workflow that runs `plan` on PRs and `apply` on push to main, with no scheduled destroy, and a post-apply bucket existence check. + +**Independent Test**: Open a PR touching `03-backup/scaleway/**` and confirm the workflow runs `plan`. Merge and confirm `apply` runs. Confirm no `schedule:` trigger exists in the file. + +- [x] T014 [US4] Create `.github/workflows/backup_scaleway.yml` — model after `scaleway.yml` with these differences: `on.push.paths` and `on.pull_request.paths` targeting `03-backup/scaleway/**` and the workflow file itself; `on.workflow_dispatch.inputs.command` options `[plan, apply]` only (no `destroy`); `Resolve terraform command` step maps `push → apply`, else `plan` (no `schedule` case, no `destroy`); composite action call with `root: 03-backup/scaleway`, `tfvars-file: dev.tfvars`; `concurrency.group: backup-scaleway-${{ github.ref }}`; same `environment: scaleway`, `permissions`, `env:` block as `scaleway.yml` + +- [x] T015 [US4] Update `.github/workflows/backup_scaleway.yml` — add a final step after the composite action, conditioned on `steps.cmd.outputs.command == 'apply'`: run `scw object bucket get ${{ env.BUCKET_NAME }} --region fr-par` where `BUCKET_NAME` is a job-level env var set from `dev.tfvars`'s bucket name (hardcode `backup-dev-id` as a workflow env var) + +**Checkpoint**: Workflow file passes `actionlint`. PR triggers plan job. Push to main triggers apply + post-apply bucket check. ✅ US4 done. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +- [x] T016 Run `actionlint .github/workflows/backup_scaleway.yml` and fix any issues +- [x] T017 [P] Verify `03-backup/scaleway/.terraform.lock.hcl` covers both `linux_amd64` and `darwin_arm64` platforms (inspect the file — each provider entry should have hashes for both) +- [x] T018 [P] Add a comment in `01-iam/bootstrap/scaleway/main.tf` above the new policy resource explaining it was added for the backup domain CI workflow (referencing `03-backup/scaleway/`) +- [ ] T019 Run quickstart.md local validation (bucket existence check + test-job.yaml Job) against the dev environment + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 — **blocks CI apply of Phase 3+** (not local dev) +- **US1 (Phase 3)**: Depends on Phase 1; needs Phase 2 applied for CI; can be developed locally without it +- **US2 (Phase 4)**: Depends on US1 (Phase 3) — expands `main.tf` +- **US3 (Phase 5)**: Depends on US1 (Phase 3) — new files `iam.tf`, `policy.tf`, `infisical.tf`; US2 can be done in parallel with US3 +- **US4 (Phase 6)**: Depends on US1 (bucket name known) — can be written in parallel with US2/US3 +- **Polish (Phase 7)**: Depends on all phases complete + +### User Story Dependencies + +``` +Phase 2 (IAM permissions) ──► must be applied before CI can run Phase 3+ +Phase 3 (US1) ──► bucket exists + ├──► Phase 4 (US2): lifecycle rules complete + ├──► Phase 5 (US3): credentials + policy (parallel with US2) + └──► Phase 6 (US4): CI workflow (parallel with US2 + US3) +``` + +### Within Each Phase + +- T004, T005: parallel with each other and with T003 (different files) +- T010, T011, T012: T010 and T012 can be written in parallel (different files); T011 depends on knowing `ci_application_id` (from T005/dev.tfvars) +- T014, T015: T015 extends T014 — sequential + +--- + +## Parallel Execution Examples + +```bash +# Phase 3 — write all files in parallel (different files, no conflict): +Task T003: version.tf +Task T004: variables.tf # parallel +Task T005: env/dev.tfvars # parallel +# Then: +Task T006: main.tf # after T004 (variable names needed) +Task T007: outputs.tf # parallel with T006 + +# Phase 5 — partially parallel: +Task T010: iam.tf +Task T012: infisical.tf # parallel with T010 +# Then: +Task T011: policy.tf # after T010 (references scaleway_iam_api_key not needed but ci_application_id from T005 needed) +Task T013: outputs.tf # after T010 +``` + +--- + +## Implementation Strategy + +### MVP (User Story 1 only) + +1. T001 → T002 → T003 + T004 + T005 (parallel) → T006 → T007 → T008 +2. Apply locally: `terraform -chdir=03-backup/scaleway apply -var-file=env/dev.tfvars` +3. Validate: `scw object bucket get backup-dev-id --region fr-par` +4. **STOP and validate** — bucket exists independently ✅ + +### Incremental Delivery + +1. MVP (US1) → bucket exists +2. US2 → lifecycle is configurable; FR-016 gate works +3. US3 → workload credentials provisioned; local Job test passes +4. US4 → CI workflow running; post-apply check automated + +### Single Developer + +Work sequentially: US1 → US2 → US3 → US4 → Polish. Total ~17 tasks, mostly file creation. + +--- + +## Notes + +- `ci_application_id` in `env/dev.tfvars` must be fetched from the existing state of `01-iam/bootstrap/scaleway`: `terraform -chdir=01-iam/bootstrap/scaleway output application_id` +- T002 is **non-blocking for local development** — you can apply `03-backup/scaleway` locally with admin Scaleway credentials before T002 is applied. It becomes blocking when the CI workflow runs. +- The `scaleway_iam_api_key` for the workload has a 365-day rotation via `time_rotating` — same pattern as the cluster CI key in `01-iam/bootstrap/scaleway` +- `test-job.yaml` in `specs/001-backup-s3-foundation/` is a manual test fixture, not part of the Terraform root diff --git a/specs/001-backup-s3-foundation/test-job.yaml b/specs/001-backup-s3-foundation/test-job.yaml new file mode 100644 index 0000000..74f15f0 --- /dev/null +++ b/specs/001-backup-s3-foundation/test-job.yaml @@ -0,0 +1,35 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: backup-probe + namespace: backup +spec: + ttlSecondsAfterFinished: 300 + template: + spec: + restartPolicy: Never + containers: + - name: probe + image: scaleway/cli:latest + command: + - sh + - -c + - | + echo "probe-$(date +%s)" > /tmp/probe.txt + scw object put /tmp/probe.txt bucket=$BUCKET_NAME key=probe/$(date +%s).txt + echo "Done." + env: + - name: BUCKET_NAME + value: backup-dev-id + - name: SCW_DEFAULT_REGION + value: fr-par + - name: SCW_ACCESS_KEY + valueFrom: + secretKeyRef: + name: backup-workload-credentials + key: access_key + - name: SCW_SECRET_KEY + valueFrom: + secretKeyRef: + name: backup-workload-credentials + key: secret_key From cfa6fc3b22ce6bb58fccd801961f8daa25ea6a0a Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sun, 21 Jun 2026 03:24:54 +0200 Subject: [PATCH 2/5] fix(03-backup): use OIDC auth for Infisical provider in CI Co-Authored-By: Claude Sonnet 4.6 --- 03-backup/scaleway/version.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/03-backup/scaleway/version.tf b/03-backup/scaleway/version.tf index f003d35..bff9de7 100644 --- a/03-backup/scaleway/version.tf +++ b/03-backup/scaleway/version.tf @@ -29,7 +29,7 @@ provider "scaleway" {} provider "infisical" { auth = { # Uncomment `universal` and comment `oidc` when running terraform locally. - universal = {} - # oidc = {} + # universal = {} + oidc = {} } } From da139191ff773af592d7851b09a57fce90d6b2f7 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sun, 21 Jun 2026 04:00:15 +0200 Subject: [PATCH 3/5] fix(03-backup): address PR #41 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename IAM resources workload → kubernetes (reflects the k8s workload context for the backup identity, not a generic machine identity) - Workflow: document no-destroy domain restriction with layered rationale, fix concurrency group to workspace name (not branch), cancel-in-progress=false to avoid state corruption, add comment on command-resolution logic - Workflow: BUCKET_NAME / BUCKET_REGION → vars.BACKUP_BUCKET_NAME / BACKUP_BUCKET_REGION - main.tf: add FinOps comment on noncurrent_version_expiration - 01-iam/bootstrap/scaleway: fix stale reference to policy.tf (removed earlier) - .gitignore: ignore specs/ to reduce PR noise; untrack existing specs files Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/backup_scaleway.yml | 42 ++-- .gitignore | 3 + 01-iam/bootstrap/scaleway/main.tf | 3 +- 03-backup/scaleway/iam.tf | 20 +- 03-backup/scaleway/infisical.tf | 4 +- 03-backup/scaleway/main.tf | 3 + 03-backup/scaleway/outputs.tf | 4 +- .../checklists/requirements.md | 38 ---- .../contracts/terraform-interface.md | 97 --------- specs/001-backup-s3-foundation/data-model.md | 111 ---------- specs/001-backup-s3-foundation/plan.md | 175 --------------- specs/001-backup-s3-foundation/quickstart.md | 88 -------- specs/001-backup-s3-foundation/research.md | 199 ----------------- specs/001-backup-s3-foundation/spec.md | 145 ------------- specs/001-backup-s3-foundation/tasks.md | 202 ------------------ specs/001-backup-s3-foundation/test-job.yaml | 35 --- 16 files changed, 51 insertions(+), 1118 deletions(-) delete mode 100644 specs/001-backup-s3-foundation/checklists/requirements.md delete mode 100644 specs/001-backup-s3-foundation/contracts/terraform-interface.md delete mode 100644 specs/001-backup-s3-foundation/data-model.md delete mode 100644 specs/001-backup-s3-foundation/plan.md delete mode 100644 specs/001-backup-s3-foundation/quickstart.md delete mode 100644 specs/001-backup-s3-foundation/research.md delete mode 100644 specs/001-backup-s3-foundation/spec.md delete mode 100644 specs/001-backup-s3-foundation/tasks.md delete mode 100644 specs/001-backup-s3-foundation/test-job.yaml diff --git a/.github/workflows/backup_scaleway.yml b/.github/workflows/backup_scaleway.yml index f838417..1de43cd 100644 --- a/.github/workflows/backup_scaleway.yml +++ b/.github/workflows/backup_scaleway.yml @@ -1,15 +1,24 @@ name: Terraform — Scaleway backup bucket -# Drives 03-backup/scaleway through the .github/actions/terraform composite action: -# - pull_request → plan -# - push to main → apply -# - workflow_dispatch → plan | apply (no destroy — FR-013) +# Drives 03-backup/scaleway through the .github/actions/terraform composite action. +# +# Domain restriction — NO DESTROY in CI (FR-013): +# The backup domain is intentionally permanent infrastructure. Destroying the +# bucket from CI would silently erase production backups. Destruction must be +# a deliberate admin action with elevated credentials outside of this pipeline. +# Enforcement is layered: +# 1. No `destroy` option in workflow_dispatch (this file). +# 2. No `schedule → destroy` mapping in the command-resolution step. +# 3. `prevent_destroy = true` in 03-backup/scaleway/main.tf blocks +# `terraform destroy` even if someone runs it manually against this root. +# +# Trigger → command mapping: +# pull_request → plan (safety check, never mutates state) +# push to main → apply (the only automated mutation path) +# workflow_dispatch → plan | apply (operator override, explicit choice) # -# No scheduled trigger. The backup bucket is never destroyed by CI. # State R/W uses the org state role (vars.AWS_TF_STATE_ROLE_ARN). -# Scaleway creds come from the `scaleway` environment (same API key as the -# cluster workflow — extended with Object Storage + IAM permissions in -# 01-iam/bootstrap/scaleway). +# Scaleway creds come from the `scaleway` environment. on: pull_request: @@ -26,14 +35,17 @@ on: workflow_dispatch: inputs: command: - description: 'Terraform command (apply = provision, no destroy available).' + description: 'Terraform command. destroy is not available — see domain restriction above.' type: choice options: [plan, apply] default: plan +# Concurrency is keyed on the Terraform workspace, not the branch. +# cancel-in-progress is false: Terraform state corruption from mid-run +# interrupts is far worse than waiting in queue. concurrency: - group: backup-scaleway-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: backup-scaleway-03-backup-dev-bucket + cancel-in-progress: false permissions: contents: read @@ -52,8 +64,8 @@ jobs: 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 }} - BUCKET_NAME: backup-dev-id - BUCKET_REGION: fr-par + BUCKET_NAME: ${{ vars.BACKUP_BUCKET_NAME }} + BUCKET_REGION: ${{ vars.BACKUP_BUCKET_REGION }} steps: - name: Assert state role ARN is present env: @@ -64,6 +76,10 @@ jobs: exit 1 fi + # Maps the GitHub event to a Terraform command. + # push → apply is the only automated mutation path (main branch only, per `on.push.branches`). + # workflow_dispatch lets an operator force plan or apply without a code change. + # Everything else (pull_request, etc.) stays read-only with plan. - name: Resolve terraform command id: cmd env: diff --git a/.gitignore b/.gitignore index 8eae8cb..f30d2f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Spec Kit planning artifacts — local design docs, not part of the reviewed diff +specs/ + # Local .terraform directories .terraform/ diff --git a/01-iam/bootstrap/scaleway/main.tf b/01-iam/bootstrap/scaleway/main.tf index 1255aca..c367af7 100644 --- a/01-iam/bootstrap/scaleway/main.tf +++ b/01-iam/bootstrap/scaleway/main.tf @@ -19,7 +19,8 @@ resource "scaleway_iam_policy" "this" { # Added for 03-backup/scaleway: the backup CI workflow runs under the same # github-ci identity and needs Object Storage management + IAM application/policy/ # API key management to provision the bucket and scoped workload credentials. -# Deletion is blocked at the bucket-policy level (see 03-backup/scaleway/policy.tf). +# Bucket deletion is blocked via prevent_destroy + absence of a destroy trigger +# in the CI workflow (Scaleway bucket policies do not support s3:DeleteBucket). resource "scaleway_iam_policy" "backup_ci" { name = "github-ci-backup-management" description = "Object Storage bucket + IAM workload identity management for the backup domain CI workflow (03-backup/scaleway/)." diff --git a/03-backup/scaleway/iam.tf b/03-backup/scaleway/iam.tf index 8dd1fd6..c7c8bfa 100644 --- a/03-backup/scaleway/iam.tf +++ b/03-backup/scaleway/iam.tf @@ -1,12 +1,12 @@ -resource "scaleway_iam_application" "workload" { - name = "backup-workload-${terraform.workspace}" - description = "Backup workload identity for ${terraform.workspace} — object read/write on the backup bucket, no administrative rights (managed by terraform: 03-backup/scaleway/)." +resource "scaleway_iam_application" "kubernetes" { + name = "backup-k8s-${terraform.workspace}" + description = "Kubernetes workload identity for ${terraform.workspace} — grants pods object read/write on the backup bucket via Infisical → ESO → Secret (no administrative rights, managed by terraform: 03-backup/scaleway/)." } -resource "scaleway_iam_policy" "workload" { - name = "backup-workload-objects-${terraform.workspace}" +resource "scaleway_iam_policy" "kubernetes" { + name = "backup-k8s-objects-${terraform.workspace}" description = "Object-level read/write on the backup project. No bucket-level permissions — cannot delete or reconfigure the bucket." - application_id = scaleway_iam_application.workload.id + application_id = scaleway_iam_application.kubernetes.id rule { project_ids = [var.project_id] @@ -17,13 +17,13 @@ resource "scaleway_iam_policy" "workload" { # Scaleway requires every API key to carry an expiry. time_rotating keeps # the expiry self-renewing: once the window elapses, the next apply rotates # the key. Update the Kubernetes Secret (via ESO re-sync) after each rotation. -resource "time_rotating" "workload_key" { +resource "time_rotating" "kubernetes_key" { rotation_days = 365 } -resource "scaleway_iam_api_key" "workload" { - application_id = scaleway_iam_application.workload.id +resource "scaleway_iam_api_key" "kubernetes" { + application_id = scaleway_iam_application.kubernetes.id description = "Backup workload credentials for ${terraform.workspace}. Consumed via Infisical → ESO → Kubernetes Secret." default_project_id = var.project_id - expires_at = time_rotating.workload_key.rotation_rfc3339 + expires_at = time_rotating.kubernetes_key.rotation_rfc3339 } diff --git a/03-backup/scaleway/infisical.tf b/03-backup/scaleway/infisical.tf index 54a92ba..e55d3d7 100644 --- a/03-backup/scaleway/infisical.tf +++ b/03-backup/scaleway/infisical.tf @@ -8,7 +8,7 @@ resource "infisical_secret_folder" "backup" { resource "infisical_secret" "access_key" { name = "BACKUP_ACCESS_KEY" - value = scaleway_iam_api_key.workload.access_key + value = scaleway_iam_api_key.kubernetes.access_key env_slug = var.infisical_env_slug workspace_id = var.infisical_workspace_id folder_path = infisical_secret_folder.backup.path @@ -16,7 +16,7 @@ resource "infisical_secret" "access_key" { resource "infisical_secret" "secret_key" { name = "BACKUP_SECRET_KEY" - value = scaleway_iam_api_key.workload.secret_key + value = scaleway_iam_api_key.kubernetes.secret_key env_slug = var.infisical_env_slug workspace_id = var.infisical_workspace_id folder_path = infisical_secret_folder.backup.path diff --git a/03-backup/scaleway/main.tf b/03-backup/scaleway/main.tf index 744727e..cb2a1b1 100644 --- a/03-backup/scaleway/main.tf +++ b/03-backup/scaleway/main.tf @@ -14,6 +14,9 @@ resource "scaleway_object_bucket" "backup" { days = var.retention_days } + # FinOps safeguard: avoids paying for stale superseded versions. + # The true backup retention policy (frequency, tiers, RTO/RPO) will be + # defined at the backup-solution layer (e.g. Velero schedule), not here. noncurrent_version_expiration { noncurrent_days = var.noncurrent_version_expiry_days } diff --git a/03-backup/scaleway/outputs.tf b/03-backup/scaleway/outputs.tf index 6e6e1f4..9b94874 100644 --- a/03-backup/scaleway/outputs.tf +++ b/03-backup/scaleway/outputs.tf @@ -14,6 +14,6 @@ output "bucket_endpoint" { } output "workload_access_key" { - description = "Public access key for the scoped backup workload identity. The secret key is in Infisical only." - value = scaleway_iam_api_key.workload.access_key + description = "Public access key for the scoped Kubernetes backup workload identity. The secret key is in Infisical only." + value = scaleway_iam_api_key.kubernetes.access_key } diff --git a/specs/001-backup-s3-foundation/checklists/requirements.md b/specs/001-backup-s3-foundation/checklists/requirements.md deleted file mode 100644 index dcc5235..0000000 --- a/specs/001-backup-s3-foundation/checklists/requirements.md +++ /dev/null @@ -1,38 +0,0 @@ -# Specification Quality Checklist: Backup Storage Foundation - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-06-20 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All 16 functional requirements map to at least one acceptance scenario (FR-014–016 added via clarification session 2026-06-20) -- No [NEEDS CLARIFICATION] markers were needed — the input description was exhaustive -- Assumptions section explicitly calls out: credential propagation out of scope, single credential pair per env, bucket name from config file, dev-only scope, no observability -- "Glacier" and "tfvars" from the input description were abstracted to "cold storage tier" and "environment configuration file" to keep the spec technology-agnostic -- Clarification session resolved: no provider-level deletion protection (CI identity has no deletion rights), dev-only initial scope, observability deferred, bucket naming convention required, lifecycle validation required as temporary safety net diff --git a/specs/001-backup-s3-foundation/contracts/terraform-interface.md b/specs/001-backup-s3-foundation/contracts/terraform-interface.md deleted file mode 100644 index e17deba..0000000 --- a/specs/001-backup-s3-foundation/contracts/terraform-interface.md +++ /dev/null @@ -1,97 +0,0 @@ -# Terraform Interface Contract: 03-backup/scaleway - -This document defines the public interface of the `03-backup/scaleway` Terraform root — the variables callers supply via `env/.tfvars` and the outputs it produces. - ---- - -## Input Variables - -### Identity & Scope - -| Variable | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| -| `project_id` | string | Yes | — | Scaleway project the bucket and IAM resources belong to | -| `ci_application_id` | string | Yes | — | IAM application ID of the `github-ci` identity (for the bucket policy Deny statement). Stable after `01-iam/bootstrap/scaleway` is applied. | - -### Bucket - -| Variable | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| -| `bucket_name` | string | Yes | — | Full bucket name. MUST include the environment name (e.g., `backup-dev-id`). | -| `region` | string | No | `"fr-par"` | Scaleway region where the bucket is created | - -### Lifecycle - -| Variable | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| -| `versioning_enabled` | bool | No | `true` | Enable/suspend bucket versioning | -| `retention_days` | number | No | `365` | Days before current-version objects expire | -| `noncurrent_version_expiry_days` | number | No | `30` | Days before non-current versions expire | -| `cold_storage_enabled` | bool | No | `true` | Enable the GLACIER storage-class transition rule | -| `cold_storage_transition_days` | number | No | `90` | Days before objects transition to GLACIER (only evaluated when `cold_storage_enabled = true`) | - -**Validation gate (FR-016):** If `cold_storage_enabled = true`, then `cold_storage_transition_days` MUST be strictly less than `retention_days`. Violation causes `terraform plan` to fail with an explicit error. - -### Infisical (secret storage) - -| Variable | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| -| `infisical_workspace_id` | string | No | `"7ecb6ed4-058a-46cd-ac9f-7e792469cf0f"` | Infisical project ID for writing scoped credentials | -| `infisical_env_slug` | string | No | `"staging"` | Infisical environment slug | -| `infisical_folder_path` | string | No | `"/backup/dev"` | Infisical folder path for backup workload credentials | - -### Infisical auth (local dev only) - -| Variable | Type | Required | Default | Description | -|----------|------|----------|---------|-------------| -| `infisical_client_id` | string | No | `""` | Universal auth client ID (local dev only) | -| `infisical_client_secret` | string | No | `""` | Universal auth client secret (local dev only; sensitive) | - ---- - -## Outputs - -| Output | Type | Sensitive | Description | -|--------|------|-----------|-------------| -| `bucket_name` | string | No | Provisioned bucket name | -| `bucket_region` | string | No | Region the bucket was created in | -| `bucket_endpoint` | string | No | S3-compatible endpoint URL for the bucket | -| `workload_access_key` | string | No | Public access key for the scoped backup workload identity | - -**Note:** The workload secret key is written to Infisical and is never surfaced as a Terraform output. It is never stored in tfvars files. State contains it as a sensitive value. - ---- - -## Infisical Secret Outputs - -The root writes two secrets to Infisical under `var.infisical_folder_path`: - -| Secret name | Content | -|-------------|---------| -| `BACKUP_ACCESS_KEY` | SCW access key for the backup workload identity | -| `BACKUP_SECRET_KEY` | SCW secret key for the backup workload identity (sensitive) | - ---- - -## Environment Files - -| File | Workspace | Environment | -|------|-----------|-------------| -| `env/dev.tfvars` | `dev` | Development / homelab | - -State key: `backup/scaleway/dev/terraform.tfstate` (in the shared S3 state bucket). - ---- - -## CI Workflow Contract - -| Trigger | Command | -|---------|---------| -| Pull request touching `03-backup/scaleway/**` | `plan` | -| Push to `main` touching `03-backup/scaleway/**` | `apply` | -| `workflow_dispatch` | `plan` or `apply` (no `destroy` option) | -| Scheduled event | **Never** (no cron, no destroy) | - -The backup CI workflow has no `schedule:` trigger and no `destroy` command mapping, satisfying FR-013. - -Post-apply step (on push to main only): `scw object bucket get ` — confirms the bucket exists. No S3 API property checks. diff --git a/specs/001-backup-s3-foundation/data-model.md b/specs/001-backup-s3-foundation/data-model.md deleted file mode 100644 index cc4b47a..0000000 --- a/specs/001-backup-s3-foundation/data-model.md +++ /dev/null @@ -1,111 +0,0 @@ -# Data Model: Backup S3 Foundation - -## Entities - -### BackupBucket - -The Scaleway Object Storage bucket providing isolated, lifecycle-managed backup storage. - -| Field | Type | Default | Constraint | -|-------|------|---------|------------| -| `name` | string | — | MUST include environment name (e.g., `backup-dev-id`) | -| `region` | string | `"fr-par"` | Scaleway region | -| `versioning_enabled` | bool | `true` | Once enabled, cannot be unversioned (only suspended) | -| `retention_days` | number | `365` | Days until current-version objects expire | -| `noncurrent_version_expiry_days` | number | `30` | Days until non-current object versions expire | -| `cold_storage_enabled` | bool | `true` | Toggle for GLACIER tier transition | -| `cold_storage_transition_days` | number | `90` | Days until transition to GLACIER storage class | -| `encryption` | string | `"AES256"` | Fixed; SSE-S3 via AES-256 | -| `force_destroy` | bool | `false` | Fixed; prevents accidental data loss via Terraform | - -**Invariants:** -- `cold_storage_enabled = false` OR `cold_storage_transition_days < retention_days` (FR-016 — validated at plan time via Terraform `precondition`) -- `name` contains the environment name as a substring (convention, not mechanically enforced) -- `force_destroy` is always false (hardcoded; no tfvars override) -- `lifecycle.prevent_destroy = true` on the Terraform resource — bucket cannot be destroyed via `terraform destroy` - -**State transitions (versioning):** -``` -unversioned → versioning_enabled=true → versioned (irreversible) -versioned → versioning_enabled=false → suspended (data retained, no new versions) -``` - ---- - -### ScopedAccessIdentity - -The Scaleway IAM application issued to backup workloads. Has object-level read/write access to the bucket but no administrative rights (no bucket deletion, no configuration changes). - -| Field | Type | Notes | -|-------|------|-------| -| `name` | string | e.g., `backup-workload-dev` | -| `iam_permissions` | list(string) | `["ObjectStorageObjectsRead", "ObjectStorageObjectsWrite"]` | -| `access_key` | string | Public identifier; surfaced as Terraform output | -| `secret_key` | string | Sensitive; written to Infisical only, never output in plaintext | -| `infisical_path` | string | e.g., `/backup/dev/` | - -**Invariants:** -- No `ObjectStorageBuckets*` permissions — identity has no bucket-level administrative rights -- `secret_key` MUST NOT appear in Terraform output (sensitive = true) or tfvars files - -**Access verification (FR-011):** -| Action | Expected result | -|--------|----------------| -| `s3:PutObject` | Allow | -| `s3:GetObject` | Allow | -| `s3:DeleteBucket` | Deny (IAM has no bucket permission + bucket policy has no allow) | - ---- - -### CIBucketPolicy - -An S3-style bucket policy applied to `BackupBucket` that enforces FR-014: the CI provisioning identity cannot delete the bucket even though its IAM policy grants `ObjectStorageBucketsWrite`. - -| Field | Type | Notes | -|-------|------|-------| -| `principal` | string | `application_id:` (the `github-ci` IAM application) | -| `effect` | string | `"Deny"` | -| `action` | list(string) | `["s3:DeleteBucket"]` | -| `resource` | list(string) | The bucket name | - -**Why this is needed:** Scaleway's IAM does not have a "create/update bucket but not delete" permission set. The only mechanism to enforce a deletion prohibition for an identity that has `ObjectStorageBucketsWrite` is an explicit DENY in the S3 bucket policy. Per Scaleway IAM evaluation logic, explicit denies in bucket policies override IAM allows. - ---- - -### LifecyclePolicy - -Not a distinct Terraform resource — represented as a `lifecycle_rule` block within `scaleway_object_bucket`. Documented here as a logical entity. - -| Rule | Condition | Action | -|------|-----------|--------| -| Current-version expiry | Always active when `enabled = true` | Expire objects after `retention_days` days | -| Noncurrent-version expiry | Always active when versioning enabled | Expire noncurrent versions after `noncurrent_version_expiry_days` days | -| Cold-tier transition | Active only when `cold_storage_enabled = true` | Transition to GLACIER after `cold_storage_transition_days` days | - -All three rules share a single `lifecycle_rule` block with `id = "backup-retention"` and `enabled = true`. - ---- - -## Cross-Entity Relationships - -``` -BackupBucket - ├── has CIBucketPolicy (1:1) — enforces deletion deny for CI principal - ├── has LifecyclePolicy (inline) — retention, expiry, cold tier rules - └── has SSEConfiguration (1:1) — AES256 encryption - -ScopedAccessIdentity - └── IAM policy scoped to BackupBucket's project - (no direct resource-level binding — Scaleway IAM is project-scoped) -``` - ---- - -## Validation Rules - -| Rule ID | Entity | Condition | Error | -|---------|--------|-----------|-------| -| FR-016 | BackupBucket | `!cold_storage_enabled \|\| cold_storage_transition_days < retention_days` | `cold_storage_transition_days must be strictly less than retention_days` | -| FR-015 | BackupBucket | `name` contains env name | Convention (not enforced by Terraform; enforced by tfvars design) | -| FR-001 | BackupBucket | SSE resource always present | N/A — resource is unconditional | -| FR-007 | ScopedAccessIdentity | `secret_key` output marked `sensitive = true`; NOT in any tfvars file | N/A — enforced by Terraform output config | diff --git a/specs/001-backup-s3-foundation/plan.md b/specs/001-backup-s3-foundation/plan.md deleted file mode 100644 index b6de89b..0000000 --- a/specs/001-backup-s3-foundation/plan.md +++ /dev/null @@ -1,175 +0,0 @@ -# Implementation Plan: Backup Storage Foundation - -**Branch**: `001-backup-s3-foundation` | **Date**: 2026-06-20 | **Spec**: [spec.md](spec.md) - -**Input**: Feature specification from `/specs/001-backup-s3-foundation/spec.md` - -## Summary - -Provision a Scaleway Object Storage bucket (`03-backup/scaleway/`) in its own Terraform root with independent lifecycle from the cluster. The bucket has AES-256 SSE encryption, versioning, configurable lifecycle rules (retention, noncurrent expiry, GLACIER tier transition), and a scoped IAM identity for backup workloads. The CI workflow runs plan on PRs and apply on merge — no scheduled destroy. - -## Technical Context - -**Language/Version**: HCL (Terraform 1.14 — pinned in `.github/actions/terraform/action.yml`) - -**Primary Dependencies**: -- `scaleway/scaleway` ~> 2.0 — Object Storage bucket, SSE config, bucket policy, IAM application/policy/API key -- `infisical/infisical` ~> 0.16 — write scoped workload credentials to Infisical -- `hashicorp/time` ~> 0.12 — API key rotation (same pattern as `01-iam/bootstrap/scaleway`) - -**Storage**: AWS S3 remote state backend, workspace_key_prefix = `"backup/scaleway"`, workspace = `dev` - -**Testing**: CI verification step in the backup workflow using AWS CLI against Scaleway's S3-compatible endpoint - -**Target Platform**: Scaleway (fr-par region, dev workspace) - -**Project Type**: Terraform infrastructure root - -**Performance Goals**: N/A — provisioning latency is not a concern - -**Constraints**: Bucket MUST survive cluster destroy/apply cycles (separate state key enforces this). CI MUST NOT have bucket deletion rights (enforced by bucket policy DENY + `prevent_destroy`). - -**Scale/Scope**: Single `dev` environment for initial delivery. Additional environments added via `env/.tfvars` — no code changes required. - -## Constitution Check - -The constitution file is still a template (not yet filled in for this project). Constitution check is skipped — no gates apply. Design choices default to CLAUDE.md conventions and CONVENTIONS.md principles. - -**CLAUDE.md compliance check**: -- ✅ All parameters are variables with sane low-cost defaults -- ✅ Environment overrides in `env/.tfvars`; filename = workspace name -- ✅ Deployment via composite action `.github/actions/terraform` (root + tfvars-file + command) -- ✅ PR → plan, push main → apply; NO scheduled destroy (FR-013) -- ✅ Numbered domain prefix (`03-backup/`); sub-folder = provider (`scaleway/`) -- ✅ No hardcoded values in shared infrastructure code - -## Project Structure - -### Documentation (this feature) - -```text -specs/001-backup-s3-foundation/ -├── spec.md # Feature specification -├── plan.md # This file -├── research.md # Phase 0 — provider patterns, IAM strategy, validation approach -├── data-model.md # Phase 1 — entities, invariants, validation rules -├── quickstart.md # Phase 1 — end-to-end validation guide -├── contracts/ -│ └── terraform-interface.md # Variable + output interface contract -└── tasks.md # Phase 2 — generated by /speckit-tasks (not yet created) -``` - -### Source Code Layout - -```text -03-backup/ # new domain: backup storage - scaleway/ # provider-scoped root (room for future providers) - version.tf # providers: scaleway + infisical + time; S3 backend - variables.tf # all configurable inputs with sane defaults - main.tf # scaleway_object_bucket + SSE config - iam.tf # scoped workload IAM application + policy + API key - policy.tf # scaleway_object_bucket_policy (CI deletion deny) - infisical.tf # write workload credentials to Infisical - outputs.tf # bucket_name, bucket_endpoint, workload_access_key - .terraform.lock.hcl # pinned for darwin_arm64 + linux_amd64 - env/ - dev.tfvars # dev workspace: bucket name, project_id, ci_application_id, ... - -.github/workflows/ - backup_scaleway.yml # CI: plan on PR, apply on push, NO destroy trigger - -01-iam/bootstrap/scaleway/ - main.tf # MODIFIED: add 2nd IAM policy rule to github-ci app - # covering ObjectStorage + IAM permissions for backup CI -``` - -**Structure Decision**: Single root at `03-backup/scaleway/` — the backup domain has no bootstrap/ci-managed split because all resources (bucket + scoped identity) are managed by CI. There is no human-only trust anchor in this domain. - ---- - -## Design Decisions - -### D1 — New domain `03-backup/` - -Backup storage is a distinct infrastructure domain: independent lifecycle, independent state, independent credentials. It belongs in a new numbered domain (`03` = next available, after `02-cluster`). The domain number encodes apply-order independence — `03-backup` has no dependency on `02-cluster`. - -### D2 — Bucket policy DENY for FR-014 (CI deletion prohibition) - -Scaleway has no IAM permission set for "create/configure bucket without delete". The only enforceable implementation of FR-014 is: -1. `scaleway_object_bucket_policy` with `Effect: Deny`, `Action: s3:DeleteBucket`, scoped to the `github-ci` IAM application principal -2. `lifecycle { prevent_destroy = true }` on the bucket resource (Terraform-level guard) -3. No `destroy` command in the backup CI workflow - -The bucket policy explicit deny overrides the IAM `ObjectStorageBucketsWrite` allow per Scaleway/S3 evaluation semantics. All three layers must be in place. - -### D3 — Extend existing `github-ci` Scaleway identity - -The backup CI workflow reuses the `scaleway` GitHub environment (existing `SCW_ACCESS_KEY` / `SCW_SECRET_KEY`). A second IAM policy is added to the existing `github-ci` application in `01-iam/bootstrap/scaleway/main.tf`: -- `ObjectStorageBucketsRead`, `ObjectStorageBucketsWrite` — bucket lifecycle management (deletion blocked by D2) -- `ObjectStorageObjectsRead`, `ObjectStorageObjectsWrite` — object management (needed for Terraform refresh) -- IAM application/policy/API key management — provision the scoped backup workload identity - -This avoids a second API key pair and keeps credential management in one place. Acceptable for the initial `dev` scope; revisit when `prod` is added. - -### D4 — FR-016 validation via Terraform `precondition` - -The constraint `cold_storage_transition_days < retention_days` (when `cold_storage_enabled = true`) is enforced as a `lifecycle { precondition {...} }` block on `scaleway_object_bucket`. This fires at plan time, before any apply, with an actionable error message. Variable-level `validation` blocks cannot cross-reference other variables, making a resource precondition the only viable approach. - -### D5 — AES-256 SSE (not KMS) - -`scaleway_object_bucket_server_side_encryption_configuration` with `sse_algorithm = "AES256"`. Satisfies FR-001 (encryption enabled) at zero incremental cost. KMS is available but out of scope. - -### D6 — Vérification minimaliste : existence du bucket + Job cluster - -Le workflow CI ne fait qu'un seul check post-apply : `scw object bucket get ` confirme que le bucket existe et est accessible. Pas de vérification des propriétés S3 (encryption, versioning, lifecycle) — ces garanties découlent des ressources Terraform provisionnées et n'ont pas besoin d'être re-testées contre l'API Scaleway. - -La validation de bout en bout du chemin workload (credentials → S3) se fait via un Job Kubernetes one-shot déployé depuis le repo `gitops` (voir `quickstart.md`). Ce Job est la preuve que le chemin complet Terraform → Infisical → ESO → pod → bucket fonctionne. - ---- - -## Re-check: Constitution Check (Post-Design) - -With the design complete, the three core CONVENTIONS.md principles for Terraform roots are verified: - -| Principle | Status | -|-----------|--------| -| All parameterizable values are variables with sane defaults | ✅ (`retention_days=365`, `cold_storage_transition_days=90`, etc.) | -| Environment overrides live in `env/.tfvars`; filename = workspace | ✅ (`env/dev.tfvars` → workspace `dev`) | -| Deploy via composite action; PR→plan, push→apply, no scheduled destroy | ✅ (backup_scaleway.yml maps triggers accordingly) | - -No violations. No complexity justification table required. - ---- - -## Implementation Sequence - -The implementation has a strict ordering due to the credential dependency: the `github-ci` Scaleway identity must have Object Storage + IAM permissions before the backup Terraform root can be applied. - -``` -Step 1: Update 01-iam/bootstrap/scaleway/main.tf - → Add second IAM policy to github-ci: ObjectStorage + IAM management - → Apply via iam_terraform-backend-role.yml CI or local admin apply - → Prerequisite for all subsequent steps - -Step 2: New 03-backup/scaleway/ root - → version.tf (providers, S3 backend, workspace_key_prefix = "backup/scaleway") - → variables.tf (all inputs with defaults) - → main.tf (scaleway_object_bucket with lifecycle + versioning; SSE config) - → policy.tf (scaleway_object_bucket_policy — CI deletion deny) - → iam.tf (scoped workload IAM application + policy + API key) - → infisical.tf (Infisical secret folder + two secrets) - → outputs.tf (bucket_name, bucket_endpoint, workload_access_key) - → env/dev.tfvars - -Step 3: Provider lock file - → mise run lock (add 03-backup/scaleway to the lock targets in mise.toml) - -Step 4: CI workflow - → .github/workflows/backup_scaleway.yml - → Triggers: PR → plan, push main → apply; no schedule, no destroy - → Adds verification step (AWS CLI checks) after apply - -Step 5: Validation - → Run quickstart.md verification scenarios against the dev environment - → All SC-00x acceptance criteria must pass -``` diff --git a/specs/001-backup-s3-foundation/quickstart.md b/specs/001-backup-s3-foundation/quickstart.md deleted file mode 100644 index 2f7821a..0000000 --- a/specs/001-backup-s3-foundation/quickstart.md +++ /dev/null @@ -1,88 +0,0 @@ -# Quickstart & Validation Guide: Backup Storage Foundation - -**Variables**: [contracts/terraform-interface.md](contracts/terraform-interface.md) -**Data model**: [data-model.md](data-model.md) - ---- - -## Provision (dev) - -```bash -terraform -chdir=03-backup/scaleway init -terraform -chdir=03-backup/scaleway workspace select -or-create dev -terraform -chdir=03-backup/scaleway plan -var-file=env/dev.tfvars -terraform -chdir=03-backup/scaleway apply -var-file=env/dev.tfvars -auto-approve -``` - ---- - -## Vérifier que le bucket existe - -```bash -scw object bucket get backup-dev-id --region fr-par -``` - -Sortie attendue : métadonnées du bucket (region, endpoint, date de création). Une erreur 404 indique que le bucket n'a pas été créé. - ---- - -## Valider l'accès workload depuis le cluster (test local) - -Ce test valide le chemin complet : credentials Terraform → Secret Kubernetes → pod → S3. -Il s'exécute manuellement contre le cluster local (minikube) ou Scaleway. - -**Étape 1 — Récupérer les credentials depuis les outputs Terraform** - -```bash -ACCESS_KEY=$(terraform -chdir=03-backup/scaleway output -raw workload_access_key) -# La secret key n'est pas surfacée en output — la lire depuis Infisical : -SECRET_KEY=$(infisical secrets get BACKUP_SECRET_KEY --path /backup/dev --env staging --plain) -``` - -**Étape 2 — Créer le Secret Kubernetes (bypass ESO pour le test local)** - -```bash -kubectl create namespace backup --dry-run=client -o yaml | kubectl apply -f - -kubectl create secret generic backup-workload-credentials \ - --from-literal=access_key="$ACCESS_KEY" \ - --from-literal=secret_key="$SECRET_KEY" \ - --namespace backup -``` - -**Étape 3 — Lancer le Job de validation** - -```bash -kubectl apply -f specs/001-backup-s3-foundation/test-job.yaml -kubectl wait --for=condition=complete job/backup-probe --namespace backup --timeout=60s -kubectl logs job/backup-probe --namespace backup -``` - -Sortie attendue dans les logs : `upload: /tmp/probe.txt to s3://backup-dev-id/probe/...` - -**Étape 4 — Nettoyage** - -```bash -kubectl delete job backup-probe --namespace backup -kubectl delete secret backup-workload-credentials --namespace backup -``` - -Le Job YAML (`test-job.yaml`) est colocalisé avec cette spec. En production, le workload réel remplace ce Job et ses credentials viennent d'ESO (pas d'un Secret créé manuellement). - ---- - -## FR-016 — gate de validation lifecycle - -```bash -terraform -chdir=03-backup/scaleway plan \ - -var-file=env/dev.tfvars \ - -var="cold_storage_enabled=true" \ - -var="cold_storage_transition_days=365" \ - -var="retention_days=365" -# Attendu : Error: cold_storage_transition_days must be strictly less than retention_days -``` - ---- - -## Note — CI - -L'intégration CI du test cluster (Job Kubernetes automatisé dans le workflow backup) est hors scope de cette feature. Le `scw object bucket get` post-apply dans le workflow CI est le seul check automatisé. diff --git a/specs/001-backup-s3-foundation/research.md b/specs/001-backup-s3-foundation/research.md deleted file mode 100644 index 23a4907..0000000 --- a/specs/001-backup-s3-foundation/research.md +++ /dev/null @@ -1,199 +0,0 @@ -# Research: Backup S3 Foundation - -**Phase 0 output for `/speckit-plan`** — all NEEDS CLARIFICATION items resolved. - -## 1. Scaleway Object Storage — Terraform Resources - -**Decision**: Use `scaleway_object_bucket` (inline lifecycle_rule / versioning blocks) + separate `scaleway_object_bucket_server_side_encryption_configuration` + `scaleway_object_bucket_policy` for explicit deny. - -**Rationale**: The Scaleway provider supports two styles for lifecycle configuration: inline blocks on `scaleway_object_bucket` and the standalone `scaleway_object_bucket_lifecycle_configuration` resource. The inline blocks are the current recommended pattern and cover all required attributes (expiration, transition, noncurrent_version_expiration, noncurrent_version_transition). Versioning is also inline (`versioning { enabled = true }`). SSE requires a separate resource (`scaleway_object_bucket_server_side_encryption_configuration`) to make encryption explicit in state. - -**Alternatives considered**: `scaleway_object_bucket_lifecycle_configuration` standalone resource — rejected because inline blocks require fewer resources and avoid dependency ordering issues. - -**Key resource attributes:** - -```hcl -resource "scaleway_object_bucket" "backup" { - name = var.bucket_name - region = var.region - - versioning { - enabled = var.versioning_enabled - } - - lifecycle_rule { - id = "backup-retention" - enabled = true - - expiration { - days = var.retention_days - } - - noncurrent_version_expiration { - noncurrent_days = var.noncurrent_version_expiry_days - } - - dynamic "transition" { - for_each = var.cold_storage_enabled ? [var.cold_storage_transition_days] : [] - content { - days = transition.value - storage_class = "GLACIER" - } - } - } - - lifecycle { - prevent_destroy = true - precondition { - condition = !var.cold_storage_enabled || var.cold_storage_transition_days < var.retention_days - error_message = "cold_storage_transition_days must be strictly less than retention_days." - } - } -} -``` - ---- - -## 2. Scaleway IAM Permission Sets for Object Storage - -**Decision**: Use layered defense for FR-014 (CI SHALL NOT have bucket deletion rights): -1. Scaleway IAM policy for CI identity: `ObjectStorageBucketsRead` + `ObjectStorageBucketsWrite` + `ObjectStorageObjectsRead` + `ObjectStorageObjectsWrite` (needed to create/manage bucket and its configuration) -2. `scaleway_object_bucket_policy` with explicit `Deny` on `s3:DeleteBucket` scoped to the CI application principal — overrides IAM allows -3. `lifecycle { prevent_destroy = true }` on the bucket Terraform resource — blocks destroy via Terraform -4. No scheduled destroy in the backup CI workflow (FR-013) - -**Rationale**: Scaleway does not expose a "bucket create but not delete" IAM permission set. The only path to enforce the spec's FR-014 requirement is through an S3 bucket policy DENY statement, which takes precedence over IAM policy ALLOWs per Scaleway's IAM evaluation logic (same as AWS: explicit deny overrides allow). The `prevent_destroy` lifecycle provides Terraform-level defense in depth. - -**Scoped backup workload identity permissions**: `ObjectStorageObjectsRead` + `ObjectStorageObjectsWrite`. These exclude all bucket-level administrative actions. Verification test: attempt `s3:DeleteBucket` with scoped credentials → denied by IAM (no bucket permission) + by bucket policy. - -**Scaleway IAM permission sets confirmed available:** -- `ObjectStorageBucketsRead` — list/stat buckets -- `ObjectStorageBucketsWrite` — full bucket CRUD (create, update, delete) -- `ObjectStorageObjectsRead` — read/list objects -- `ObjectStorageObjectsWrite` — write/update/delete objects -- `ObjectStorageFullAccess` — all of the above - ---- - -## 3. Encryption - -**Decision**: `scaleway_object_bucket_server_side_encryption_configuration` with `sse_algorithm = "AES256"`. - -**Rationale**: AES-256 SSE is the cost-free default for Scaleway Object Storage. Scaleway also offers KMS-based SSE (`aws:kms`) but it requires provisioning a KMS key, which introduces cost and operational overhead. The spec only requires encryption enabled (FR-001) with no KMS mandate. - -```hcl -resource "scaleway_object_bucket_server_side_encryption_configuration" "backup" { - bucket = scaleway_object_bucket.backup.name - region = var.region - - rule { - apply_server_side_encryption_by_default { - sse_algorithm = "AES256" - } - } -} -``` - ---- - -## 4. FR-016 Validation Strategy - -**Decision**: Terraform `lifecycle { precondition {...} }` block on `scaleway_object_bucket`. - -**Rationale**: Preconditions on resources evaluate at plan time, before any apply, and produce a clear error message. This is the canonical Terraform pattern for validating cross-variable constraints. A `variable { validation {...} }` block on individual variables cannot cross-reference other variables, so a resource-level precondition is the only viable approach for the "transition_days < retention_days" constraint. - -**Condition**: `!var.cold_storage_enabled || var.cold_storage_transition_days < var.retention_days` -- When cold storage is disabled: constraint is bypassed (vacuously true) -- When cold storage is enabled: transition delay must be strictly less than retention period - ---- - -## 5. Workload Identity Strategy — Static Credentials via Infisical - -**Decision**: Credentials statiques (API key Scaleway) générées par Terraform, stockées dans Infisical, injectées comme variables d'environnement dans le pod via ESO (External Secrets Operator). Pas de pod workload identity. - -**Rationale**: Scaleway Kapsule ne supporte pas le mapping IAM → Kubernetes ServiceAccount (IRSA-equivalent). C'est une [feature request ouverte](https://feature-request.scaleway.com/posts/677/map-iam-to-kubernetes-kapsule-service-account) sans date d'implémentation connue. La seule option disponible est donc une API key statique distribuée via Infisical. - -**Chemin complet** : `03-backup/scaleway apply` → `scaleway_iam_api_key` → `infisical_secret` → ESO lit depuis Infisical → `kind: Secret` Kubernetes → pod monte `SCW_ACCESS_KEY` + `SCW_SECRET_KEY` comme env vars. - -## 5b. CI Credentials Strategy - -**Decision**: Add a new IAM policy to the existing `github-ci` Scaleway application (in `01-iam/bootstrap/scaleway/main.tf`) covering Object Storage management + IAM application/policy/API key management. The backup CI workflow reuses the existing `scaleway` GitHub environment. - -**Rationale**: Creating a second Scaleway IAM application and a second set of API key secrets in GitHub would require a new GitHub environment or secret naming convention, adding operational overhead. Extending the existing `github-ci` application is consistent with how the cluster CI credentials work (one application, one API key, all CI use cases). The expanded permissions are scoped to the project and remain within the principle of least-privilege for what the backup root needs. - -**Permissions to add to `github-ci`**: -- `ObjectStorageBucketsRead` + `ObjectStorageBucketsWrite` — create/manage bucket (deletion enforced-denied by bucket policy) -- `ObjectStorageObjectsRead` + `ObjectStorageObjectsWrite` — manage bucket content (needed for Terraform refresh on object resources) -- IAM application/policy/API key management — provision the scoped backup workload identity - -**Alternatives considered**: New dedicated IAM application for backup CI — rejected for initial delivery. Can be revisited when a second environment (`prod`) is added and credential isolation becomes a priority. - ---- - -## 6. State Isolation - -**Decision**: New remote S3 backend root with `workspace_key_prefix = "backup/scaleway"`, workspace name `dev` (from `env/dev.tfvars`). - -**Rationale**: This root has no Terraform dependency on the cluster roots and must survive cluster destroy/apply cycles. A completely separate state key guarantees isolation. The naming follows the pattern of `cluster/scaleway` already in use. - ---- - -## 7. CI Verification Approach - -**Decision**: Add a `verify` job to the backup CI workflow using the AWS CLI with the Scaleway S3-compatible endpoint (`https://s3.{region}.scw.cloud`). The verification job runs after `terraform apply` only on push to main. - -**Rationale**: The AWS CLI is already present in GitHub Actions ubuntu-latest runners. Using it against Scaleway's S3-compatible API requires no new tooling. The `scw` CLI is an alternative but is not guaranteed to be present in runners without setup. - -**Verification commands** (using AWS CLI with Scaleway endpoint): -```bash -# Bucket encryption (FR-009) -aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api get-bucket-encryption --bucket "$BUCKET_NAME" - -# Versioning (FR-009) -aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api get-bucket-versioning --bucket "$BUCKET_NAME" - -# Lifecycle rules (FR-010) -aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api get-bucket-lifecycle-configuration --bucket "$BUCKET_NAME" - -# Scoped credential scope test (FR-011) -AWS_ACCESS_KEY_ID="$WORKLOAD_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$WORKLOAD_SECRET_KEY" \ - aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api put-object --bucket "$BUCKET_NAME" --key "ci-verify/probe.txt" --body /dev/null -AWS_ACCESS_KEY_ID="$WORKLOAD_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$WORKLOAD_SECRET_KEY" \ - aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api get-object --bucket "$BUCKET_NAME" --key "ci-verify/probe.txt" /dev/null -# Expect non-zero exit: -AWS_ACCESS_KEY_ID="$WORKLOAD_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$WORKLOAD_SECRET_KEY" \ - aws --endpoint-url "https://s3.${REGION}.scw.cloud" s3api delete-bucket --bucket "$BUCKET_NAME" \ - && echo "::error::Scoped credentials should NOT be able to delete the bucket" && exit 1 \ - || echo "Bucket deletion correctly denied" -``` - -Scoped credentials are read from Infisical at verify time or surfaced via Terraform outputs (sensitive). - ---- - -## 8. Bucket Policy for CI Deletion Deny - -**Decision**: `scaleway_object_bucket_policy` with explicit Deny on `s3:DeleteBucket` for the CI IAM application principal. - -```hcl -resource "scaleway_object_bucket_policy" "backup" { - bucket = scaleway_object_bucket.backup.name - region = var.region - - policy = jsonencode({ - Version = "2023-04-17" - Statement = [ - { - Sid = "DenyBucketDeletionForCI" - Effect = "Deny" - Principal = { SCW = "application_id:${var.ci_application_id}" } - Action = ["s3:DeleteBucket"] - Resource = [scaleway_object_bucket.backup.name] - } - ] - }) -} -``` - -The CI application ID is passed via `var.ci_application_id` in `env/dev.tfvars`. It is stable (never changes after `01-iam/bootstrap/scaleway` is applied) and safe to hardcode in tfvars. diff --git a/specs/001-backup-s3-foundation/spec.md b/specs/001-backup-s3-foundation/spec.md deleted file mode 100644 index dc71362..0000000 --- a/specs/001-backup-s3-foundation/spec.md +++ /dev/null @@ -1,145 +0,0 @@ -# Feature Specification: Backup Storage Foundation - -**Feature Branch**: `001-backup-s3-foundation` - -**Created**: 2026-06-20 - -**Status**: Draft - -**Input**: User description: "Fondation backup S3 (Scaleway Object Storage) — root Terraform dédié, état isolé, bucket chiffré + versionné, configurable, credentials scoped, workflow CI sans destroy." - -## Clarifications - -### Session 2026-06-20 - -- Q: Should the bucket have provider-level deletion protection? → A: No provider-level protection. Bucket deletion is reserved for human operators with administrative credentials; the CI identity SHALL NOT have bucket deletion rights. The implementation code SHALL include a comment at the point where deletion protection is absent, documenting this deliberate choice. -- Q: Which environments are in scope for the initial delivery? → A: Single `dev` environment only. Additional environments (e.g., `prod`) are out of scope and can be added via a new environment configuration file without modifying shared infrastructure code. -- Q: Is operational observability (monitoring, alerting) in scope? → A: No. Bucket-level observability is deferred to a future platform observability feature. CI verification at deploy time is the only correctness signal required. -- Q: Is a bucket naming convention required to prevent collisions across environments? → A: Yes. The bucket name SHALL include the environment name as a component (e.g., `backup-dev-`). This is a platform requirement, not an operator preference. -- Q: Should the platform validate conflicting lifecycle values (cold storage transition delay ≥ object retention period)? → A: Yes, as a temporary safety net until a future lifecycle policy operator is implemented. The platform SHALL reject configurations where the cold storage transition delay is greater than or equal to the object retention period. This validation is explicitly transitional. - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Persistent Backup Bucket (Priority: P1) - -A platform operator provisions a backup storage bucket that is completely independent from the cluster lifecycle. The bucket persists through cluster destroy and re-apply cycles — cluster teardowns never affect it. - -**Why this priority**: Without isolation from the cluster lifecycle, backup data would be destroyed every night when the scheduled cluster teardown runs, defeating the entire purpose of backups. - -**Independent Test**: Can be tested by provisioning the bucket, writing a marker object, triggering a full cluster destroy/apply cycle, and confirming the marker object still exists. - -**Acceptance Scenarios**: - -1. **Given** the backup bucket has been provisioned and contains a marker object, **When** the cluster is fully destroyed and re-applied, **Then** the marker object is still present in the bucket. -2. **Given** the backup bucket infrastructure has been applied, **When** the cluster infrastructure is destroyed, **Then** no dependency causes the backup bucket to be deleted or modified. - ---- - -### User Story 2 - Configurable Data Retention (Priority: P2) - -A platform operator can adjust the bucket's data lifecycle policies (object retention, noncurrent version expiry, cold storage tier transition) per environment through an environment configuration file, without modifying shared infrastructure code. - -**Why this priority**: Different environments have different cost/durability tradeoffs. A dev environment might keep objects for 30 days; production might keep them for a year. These values must be externalizable, never hardcoded. - -**Independent Test**: Can be tested by deploying the bucket with a non-default environment configuration file and asserting via the cloud provider CLI that the applied lifecycle rules match the file's values exactly. - -**Acceptance Scenarios**: - -1. **Given** an environment configuration file specifies a 90-day object retention and cold storage tier transition disabled, **When** the bucket is provisioned using that configuration, **Then** the bucket's lifecycle policy reflects exactly those settings. -2. **Given** no environment configuration overrides are provided, **When** the bucket is provisioned with default settings, **Then** the defaults (365-day retention, 30-day noncurrent version expiry, 90-day cold storage tier transition enabled, versioning on) are applied without any required operator input. -3. **Given** the bucket is already provisioned, **When** the operator updates the environment configuration file and re-applies, **Then** the lifecycle policy updates to match the new values. - ---- - -### User Story 3 - Scoped Backup Credentials (Priority: P3) - -Backup workloads are issued credentials that allow reading and writing backup objects but cannot perform administrative actions (deleting the bucket, modifying bucket configuration, etc.). - -**Why this priority**: Principle of least privilege — a compromised backup agent must not be able to destroy the backup bucket. Credential scope is a security boundary, not an afterthought. - -**Independent Test**: Can be tested by using the issued credentials to attempt a write, a read, and a bucket deletion — verifying the first two succeed and the third is denied with an access error. - -**Acceptance Scenarios**: - -1. **Given** scoped credentials have been issued, **When** a backup workload writes an object using those credentials, **Then** the write succeeds. -2. **Given** scoped credentials have been issued, **When** a backup workload reads a previously written object using those credentials, **Then** the read succeeds. -3. **Given** scoped credentials have been issued, **When** a process attempts to delete the bucket using those credentials, **Then** the operation is denied with an access error. - ---- - -### User Story 4 - Automated CI Verification (Priority: P4) - -Every deployment of the backup bucket is automatically verified end-to-end by the CI pipeline, with no manual steps required. Verification covers encryption, versioning, lifecycle accuracy, credential scope, and cluster-lifecycle isolation. - -**Why this priority**: Manual verification is error-prone and not repeatable. The acceptance criteria require CI-based proof that can be executed and re-executed by any contributor. - -**Independent Test**: Can be tested by running the CI verification job on a feature branch and observing that all four verification assertions pass without human intervention. - -**Acceptance Scenarios**: - -1. **Given** the CI job runs after provisioning, **When** it inspects the bucket, **Then** it confirms the bucket exists with encryption and versioning enabled. -2. **Given** the CI job runs, **When** it reads the bucket's lifecycle configuration, **Then** it confirms the rules match the values declared in the environment configuration file used during provisioning. -3. **Given** the CI job runs with the scoped credentials, **When** it attempts a write, a read, and a bucket deletion, **Then** write and read succeed, and bucket deletion is denied. -4. **Given** the CI job runs after a cluster destroy/apply cycle, **When** it checks for a pre-written marker object, **Then** the object is still present. - ---- - -### Edge Cases - -- ~~What happens when the cold storage tier transition delay is set shorter than the noncurrent version expiry window?~~ Resolved: the platform SHALL reject configurations where the cold storage transition delay ≥ object retention period (FR-016). This validation is a temporary safety net pending a future lifecycle policy operator. -- ~~How does the system behave if the bucket name collides with an existing bucket in the same account?~~ Resolved: bucket name SHALL include the environment name, making collisions a convention violation rather than an operational risk. -- What happens to objects already in standard storage when the cold storage tier transition is toggled from disabled to enabled on an existing bucket? -- How are leaked scoped credentials revoked without disrupting the bucket or its data? -- What happens if the CI verification job runs while the bucket is being provisioned (race condition)? - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: The platform SHALL provision an object storage bucket with server-side encryption enabled. -- **FR-002**: The platform SHALL enable versioning on the bucket by default, with versioning configurable (on/off) via an environment configuration file. -- **FR-003**: The platform SHALL apply a configurable object retention policy (maximum age before expiry) to the bucket, with a default of 365 days. -- **FR-004**: The platform SHALL apply a configurable noncurrent version expiry policy, with a default of 30 days. -- **FR-005**: The platform SHALL support a configurable cold storage tier transition rule, with an on/off toggle and a transition delay in days, defaulting to enabled at 90 days. -- **FR-006**: The platform SHALL issue a scoped access identity with read and write permissions on the bucket's objects, and no administrative permissions (no bucket deletion, no bucket configuration changes). -- **FR-007**: The platform SHALL expose the scoped credentials as protected outputs, not stored in plaintext configuration files. -- **FR-015**: The backup bucket name SHALL include the environment name as a component (e.g., `backup-dev-`), ensuring names are unique across environments by convention. -- **FR-016**: The platform SHALL reject configurations where the cold storage tier transition delay is greater than or equal to the object retention period, producing an explicit validation error. This is a temporary safety net pending a future lifecycle policy operator. -- **FR-014**: The CI identity used by the backup CI workflow SHALL NOT have bucket deletion rights. Bucket deletion SHALL be possible only by a human operator acting with administrative credentials outside of any automated pipeline. -- **FR-008**: The backup bucket's provisioning lifecycle SHALL be fully independent from the cluster's provisioning lifecycle — destroying the cluster SHALL NOT affect the bucket. -- **FR-009**: The CI pipeline SHALL verify after each deployment that the bucket's encryption and versioning are enabled. -- **FR-010**: The CI pipeline SHALL verify after each deployment that the bucket's lifecycle rules match the values declared in the environment configuration file. -- **FR-011**: The CI pipeline SHALL verify that the scoped credentials can write and read objects but cannot delete the bucket. -- **FR-012**: The CI pipeline SHALL verify that a pre-written marker object persists through a full cluster destroy/apply cycle. -- **FR-013**: The platform SHALL provide a dedicated CI workflow for the backup domain that runs plan on pull requests and apply on merge to main, with no scheduled destroy ever configured. - -### Key Entities - -- **Backup Bucket**: The object storage container. Attributes: name, region, encryption status, versioning status. -- **Lifecycle Policy**: Data retention rules attached to the bucket. Attributes: object retention period (days), noncurrent version expiry period (days), cold storage tier transition toggle (enabled/disabled), cold storage tier transition delay (days). -- **Scoped Access Identity**: The access identity issued to backup workloads. Attributes: permission scope (read/write objects only, no administrative rights), credential outputs (protected/sensitive). -- **Backup Infrastructure Domain**: The isolated infrastructure unit managing the bucket and its credentials. Attribute: independent state lifecycle from all other infrastructure domains. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: The backup bucket retains 100% of its stored objects through every cluster destroy/apply cycle, verified on at least one complete cycle before merge to main. -- **SC-002**: 100% of configurable lifecycle parameters (retention, noncurrent expiry, cold storage tier toggle and delay) are reflected exactly in the provisioned bucket, with zero hardcoded values in shared infrastructure code. -- **SC-003**: Scoped credentials produce a successful write and a successful read, and an explicit access denial on bucket deletion, in 100% of CI verification runs. -- **SC-004**: All four CI verification assertions (encryption + versioning, lifecycle accuracy, credential scope, cluster isolation) pass on every deployment without any manual operator steps. -- **SC-005**: The backup CI workflow executes plan on every pull request and apply on every merge to main, with zero occurrences of a destroy step triggered by a scheduled event. - -## Assumptions - -- The backup bucket will be hosted in the same cloud provider region as the cluster unless overridden by the environment configuration file. -- Credential rotation and propagation to consuming workloads (e.g., via a secrets manager) is out of scope for this feature; this feature provisions and outputs the initial credentials only. -- The cold storage tier is the only tiered storage class in scope; additional storage tiers are out of scope. -- The bucket name is defined in the environment configuration file, not auto-generated at provisioning time. -- The initial delivery provisions a single `dev` environment. The workspace model allows adding further environments (e.g., `prod`) without code changes, but they are out of scope here. -- A single scoped access identity (one credential pair) per environment is sufficient; per-workload or multi-identity credential management is out of scope. -- The CI verification job uses the cloud provider CLI already available in the CI environment; no new tooling is introduced. -- The scoped identity has no path to escalating its own permissions (no IAM self-modification rights). -- No provider-level deletion protection is applied to the bucket; this is intentional and SHALL be documented via a code comment at the relevant point of implementation. Bucket deletion is a manual-only, human-operator action. -- Operational observability (monitoring, alerting) is out of scope for this feature; CI verification at deploy time is the only required correctness signal. -- The cross-rule lifecycle validation (FR-016) is explicitly temporary; a future lifecycle policy operator will supersede it with richer guardrails. The spec requirement is scoped to the transition delay vs. retention period constraint only. diff --git a/specs/001-backup-s3-foundation/tasks.md b/specs/001-backup-s3-foundation/tasks.md deleted file mode 100644 index 9a58a36..0000000 --- a/specs/001-backup-s3-foundation/tasks.md +++ /dev/null @@ -1,202 +0,0 @@ -# Tasks: Backup Storage Foundation - -**Input**: Design documents from `/specs/001-backup-s3-foundation/` - -**Branch**: `001-backup-s3-foundation` | **Plan**: [plan.md](plan.md) | **Spec**: [spec.md](spec.md) - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) -- **[Story]**: User story label (US1–US4) -- Tests: not requested — no test tasks generated - ---- - -## Phase 1: Setup - -**Purpose**: Create the new Terraform root's directory structure. - -- [x] T001 Create directories `03-backup/scaleway/` and `03-backup/scaleway/env/` - ---- - -## Phase 2: Foundational (Blocking Prerequisite) - -**Purpose**: Extend the existing `github-ci` Scaleway identity with the permissions the backup CI workflow needs. Must be applied (or at least committed to trigger CI apply) before the backup root's workflow can run `plan` or `apply`. - -**⚠️ CRITICAL**: The backup workflow will fail without this. Apply via the existing `iam_terraform-backend-role.yml` CI workflow or locally with admin Scaleway credentials. - -- [x] T002 Update `01-iam/bootstrap/scaleway/main.tf` — add a second `scaleway_iam_policy` resource (e.g. `backup_ci`) attached to `scaleway_iam_application.this`, granting `ObjectStorageBucketsRead`, `ObjectStorageBucketsWrite`, `ObjectStorageObjectsRead`, `ObjectStorageObjectsWrite`, and IAM management permissions (`IamReadOnly` + `IamManager` or equivalent) scoped to `var.project_id` - -**Checkpoint**: `01-iam/bootstrap/scaleway` applied → `github-ci` has Object Storage + IAM permissions → backup root CI can proceed. - ---- - -## Phase 3: User Story 1 — Persistent Backup Bucket (Priority: P1) 🎯 MVP - -**Goal**: Provision an encrypted, versioned bucket in its own Terraform root with state completely independent from the cluster. Cluster destroy/apply cycles cannot touch it. - -**Independent Test**: `scw object bucket get backup-dev-id --region fr-par` returns bucket metadata after `terraform apply`. Run `terraform destroy -target=module.*cluster*` and confirm the bucket still exists. - -- [x] T003 [US1] Create `03-backup/scaleway/version.tf` — S3 backend (`bucket`, `region="eu-west-3"`, `workspace_key_prefix="backup/scaleway"`, `key="terraform.tfstate"`, `encrypt=true`, `use_lockfile=true`) + `required_providers`: scaleway `~>2.0`, infisical `~>0.16`, time `~>0.12`; `provider "scaleway" {}` + `provider "infisical" { auth = { oidc = {} } }` (matching pattern of `02-cluster/scaleway/version.tf`) - -- [x] T004 [P] [US1] Create `03-backup/scaleway/variables.tf` — declare ALL variables with sane defaults: - - Bucket: `bucket_name` (string, required), `region` (default `"fr-par"`) - - Lifecycle: `versioning_enabled` (bool, default `true`), `retention_days` (number, default `365`), `noncurrent_version_expiry_days` (number, default `30`), `cold_storage_enabled` (bool, default `true`), `cold_storage_transition_days` (number, default `90`) - - Identity: `project_id` (string, required), `ci_application_id` (string, required — the `github-ci` app ID from `01-iam/bootstrap/scaleway` outputs) - - Infisical: `infisical_workspace_id` (default `"7ecb6ed4-058a-46cd-ac9f-7e792469cf0f"`), `infisical_env_slug` (default `"staging"`), `infisical_folder_path` (default `"/backup/dev"`), `infisical_client_id` (default `""`), `infisical_client_secret` (sensitive, default `""`) - -- [x] T005 [P] [US1] Create `03-backup/scaleway/env/dev.tfvars` — fill dev workspace values: `bucket_name = "backup-dev-id"`, `region = "fr-par"`, `project_id = ""`, `ci_application_id = ""` (retrieve via `terraform -chdir=01-iam/bootstrap/scaleway output application_id`), infisical defaults accepted - -- [x] T006 [US1] Create `03-backup/scaleway/main.tf` — two resources: - 1. `scaleway_object_bucket "backup"`: `name=var.bucket_name`, `region=var.region`, `versioning { enabled=var.versioning_enabled }`, `lifecycle { prevent_destroy=true; precondition { condition = !var.cold_storage_enabled || var.cold_storage_transition_days < var.retention_days; error_message = "cold_storage_transition_days must be strictly less than retention_days" } }`. Add comment at the `force_destroy` absence: `# Deletion intentionally NOT protected at the provider level — see spec.md FR-014. Bucket deletion is a manual-only, human-operator action.` - 2. `scaleway_object_bucket_server_side_encryption_configuration "backup"`: `bucket=scaleway_object_bucket.backup.name`, `region=var.region`, `rule { apply_server_side_encryption_by_default { sse_algorithm="AES256" } }` - -- [x] T007 [P] [US1] Create `03-backup/scaleway/outputs.tf` — three outputs: `bucket_name` (value=`scaleway_object_bucket.backup.name`), `bucket_region` (value=`scaleway_object_bucket.backup.region`), `bucket_endpoint` (value=`"https://s3.${var.region}.scw.cloud/${var.bucket_name}"`) - -- [x] T008 [US1] Add `03-backup/scaleway` to `mise.toml` lock task (add `terraform -chdir=03-backup/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64` alongside the other roots), then run `mise run lock` to generate `03-backup/scaleway/.terraform.lock.hcl` - -**Checkpoint**: `terraform apply -var-file=env/dev.tfvars` succeeds. `scw object bucket get backup-dev-id --region fr-par` returns bucket metadata. ✅ US1 done. - ---- - -## Phase 4: User Story 2 — Configurable Data Retention (Priority: P2) - -**Goal**: Complete the lifecycle_rule block so all four lifecycle parameters (retention, noncurrent expiry, cold storage toggle + delay) are driven by `dev.tfvars` with no hardcoded values. - -**Independent Test**: Modify `dev.tfvars` to set `cold_storage_enabled=true`, `cold_storage_transition_days=30`, `retention_days=365`. Re-apply. Confirm lifecycle rule changes (manual check sufficient — see quickstart.md). Then test the FR-016 gate: set `cold_storage_transition_days=365` and confirm plan fails with the precondition error. - -- [x] T009 [US2] Update `03-backup/scaleway/main.tf` — expand the `lifecycle_rule` block inside `scaleway_object_bucket.backup`: - - Add `id = "backup-retention"`, `enabled = true` - - Add `expiration { days = var.retention_days }` - - Add `noncurrent_version_expiration { noncurrent_days = var.noncurrent_version_expiry_days }` - - Add `dynamic "transition" { for_each = var.cold_storage_enabled ? [var.cold_storage_transition_days] : []; content { days = transition.value; storage_class = "GLACIER" } }` - - The FR-016 precondition is already present from T006 — no change needed - -**Checkpoint**: `terraform plan` with `cold_storage_transition_days=365` + `retention_days=365` fails with precondition error. Plan with valid values shows correct lifecycle rules in the diff. ✅ US2 done. - ---- - -## Phase 5: User Story 3 — Scoped Backup Credentials (Priority: P3) - -**Goal**: Provision a Scaleway IAM application with object-level read+write access (no bucket deletion), write its credentials to Infisical, and attach a bucket policy that explicitly denies `s3:DeleteBucket` to the CI identity. - -**Independent Test**: Run quickstart.md §"Valider l'accès workload depuis le cluster" — create the test Secret manually from Terraform outputs, launch `test-job.yaml`, verify the Job succeeds. Confirm `workload_access_key` appears in `terraform output`. - -- [x] T010 [US3] Create `03-backup/scaleway/iam.tf` — three resources: - 1. `scaleway_iam_application "workload"`: `name = "backup-workload-${terraform.workspace}"` - 2. `scaleway_iam_policy "workload"`: `application_id = scaleway_iam_application.workload.id`, `rule { project_ids = [var.project_id]; permission_set_names = ["ObjectStorageObjectsRead", "ObjectStorageObjectsWrite"] }` - 3. `time_rotating "workload_key"`: `rotation_days = 365`; `scaleway_iam_api_key "workload"`: `application_id = scaleway_iam_application.workload.id`, `expires_at = time_rotating.workload_key.rotation_rfc3339`, `default_project_id = var.project_id` - -- [x] T011 [US3] Create `03-backup/scaleway/policy.tf` — `scaleway_object_bucket_policy "backup"`: `bucket = scaleway_object_bucket.backup.name`, `region = var.region`, `policy = jsonencode({ Version = "2023-04-17"; Statement = [{ Sid = "DenyBucketDeletionForCI"; Effect = "Deny"; Principal = { SCW = "application_id:${var.ci_application_id}" }; Action = ["s3:DeleteBucket"]; Resource = [scaleway_object_bucket.backup.name] }] })` - -- [x] T012 [P] [US3] Create `03-backup/scaleway/infisical.tf` — `infisical_secret_folder "backup"` at path `var.infisical_folder_path` under root `/`, then `infisical_secret "access_key"` (`name = "BACKUP_ACCESS_KEY"`, `value = scaleway_iam_api_key.workload.access_key`) and `infisical_secret "secret_key"` (`name = "BACKUP_SECRET_KEY"`, `value = scaleway_iam_api_key.workload.secret_key`, sensitive). Follow the pattern in `01-iam/bootstrap/scaleway/main.tf`. - -- [x] T013 [US3] Update `03-backup/scaleway/outputs.tf` — add `workload_access_key` output: `value = scaleway_iam_api_key.workload.access_key`, `description = "Public access key for the scoped backup workload identity."` - -**Checkpoint**: `terraform apply` succeeds with 5+ new resources. `terraform output workload_access_key` shows an access key. Infisical `/backup/dev/` folder contains `BACKUP_ACCESS_KEY` and `BACKUP_SECRET_KEY`. ✅ US3 done. - ---- - -## Phase 6: User Story 4 — CI Workflow (Priority: P4) - -**Goal**: A CI workflow that runs `plan` on PRs and `apply` on push to main, with no scheduled destroy, and a post-apply bucket existence check. - -**Independent Test**: Open a PR touching `03-backup/scaleway/**` and confirm the workflow runs `plan`. Merge and confirm `apply` runs. Confirm no `schedule:` trigger exists in the file. - -- [x] T014 [US4] Create `.github/workflows/backup_scaleway.yml` — model after `scaleway.yml` with these differences: `on.push.paths` and `on.pull_request.paths` targeting `03-backup/scaleway/**` and the workflow file itself; `on.workflow_dispatch.inputs.command` options `[plan, apply]` only (no `destroy`); `Resolve terraform command` step maps `push → apply`, else `plan` (no `schedule` case, no `destroy`); composite action call with `root: 03-backup/scaleway`, `tfvars-file: dev.tfvars`; `concurrency.group: backup-scaleway-${{ github.ref }}`; same `environment: scaleway`, `permissions`, `env:` block as `scaleway.yml` - -- [x] T015 [US4] Update `.github/workflows/backup_scaleway.yml` — add a final step after the composite action, conditioned on `steps.cmd.outputs.command == 'apply'`: run `scw object bucket get ${{ env.BUCKET_NAME }} --region fr-par` where `BUCKET_NAME` is a job-level env var set from `dev.tfvars`'s bucket name (hardcode `backup-dev-id` as a workflow env var) - -**Checkpoint**: Workflow file passes `actionlint`. PR triggers plan job. Push to main triggers apply + post-apply bucket check. ✅ US4 done. - ---- - -## Phase 7: Polish & Cross-Cutting Concerns - -- [x] T016 Run `actionlint .github/workflows/backup_scaleway.yml` and fix any issues -- [x] T017 [P] Verify `03-backup/scaleway/.terraform.lock.hcl` covers both `linux_amd64` and `darwin_arm64` platforms (inspect the file — each provider entry should have hashes for both) -- [x] T018 [P] Add a comment in `01-iam/bootstrap/scaleway/main.tf` above the new policy resource explaining it was added for the backup domain CI workflow (referencing `03-backup/scaleway/`) -- [ ] T019 Run quickstart.md local validation (bucket existence check + test-job.yaml Job) against the dev environment - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies — start immediately -- **Foundational (Phase 2)**: Depends on Phase 1 — **blocks CI apply of Phase 3+** (not local dev) -- **US1 (Phase 3)**: Depends on Phase 1; needs Phase 2 applied for CI; can be developed locally without it -- **US2 (Phase 4)**: Depends on US1 (Phase 3) — expands `main.tf` -- **US3 (Phase 5)**: Depends on US1 (Phase 3) — new files `iam.tf`, `policy.tf`, `infisical.tf`; US2 can be done in parallel with US3 -- **US4 (Phase 6)**: Depends on US1 (bucket name known) — can be written in parallel with US2/US3 -- **Polish (Phase 7)**: Depends on all phases complete - -### User Story Dependencies - -``` -Phase 2 (IAM permissions) ──► must be applied before CI can run Phase 3+ -Phase 3 (US1) ──► bucket exists - ├──► Phase 4 (US2): lifecycle rules complete - ├──► Phase 5 (US3): credentials + policy (parallel with US2) - └──► Phase 6 (US4): CI workflow (parallel with US2 + US3) -``` - -### Within Each Phase - -- T004, T005: parallel with each other and with T003 (different files) -- T010, T011, T012: T010 and T012 can be written in parallel (different files); T011 depends on knowing `ci_application_id` (from T005/dev.tfvars) -- T014, T015: T015 extends T014 — sequential - ---- - -## Parallel Execution Examples - -```bash -# Phase 3 — write all files in parallel (different files, no conflict): -Task T003: version.tf -Task T004: variables.tf # parallel -Task T005: env/dev.tfvars # parallel -# Then: -Task T006: main.tf # after T004 (variable names needed) -Task T007: outputs.tf # parallel with T006 - -# Phase 5 — partially parallel: -Task T010: iam.tf -Task T012: infisical.tf # parallel with T010 -# Then: -Task T011: policy.tf # after T010 (references scaleway_iam_api_key not needed but ci_application_id from T005 needed) -Task T013: outputs.tf # after T010 -``` - ---- - -## Implementation Strategy - -### MVP (User Story 1 only) - -1. T001 → T002 → T003 + T004 + T005 (parallel) → T006 → T007 → T008 -2. Apply locally: `terraform -chdir=03-backup/scaleway apply -var-file=env/dev.tfvars` -3. Validate: `scw object bucket get backup-dev-id --region fr-par` -4. **STOP and validate** — bucket exists independently ✅ - -### Incremental Delivery - -1. MVP (US1) → bucket exists -2. US2 → lifecycle is configurable; FR-016 gate works -3. US3 → workload credentials provisioned; local Job test passes -4. US4 → CI workflow running; post-apply check automated - -### Single Developer - -Work sequentially: US1 → US2 → US3 → US4 → Polish. Total ~17 tasks, mostly file creation. - ---- - -## Notes - -- `ci_application_id` in `env/dev.tfvars` must be fetched from the existing state of `01-iam/bootstrap/scaleway`: `terraform -chdir=01-iam/bootstrap/scaleway output application_id` -- T002 is **non-blocking for local development** — you can apply `03-backup/scaleway` locally with admin Scaleway credentials before T002 is applied. It becomes blocking when the CI workflow runs. -- The `scaleway_iam_api_key` for the workload has a 365-day rotation via `time_rotating` — same pattern as the cluster CI key in `01-iam/bootstrap/scaleway` -- `test-job.yaml` in `specs/001-backup-s3-foundation/` is a manual test fixture, not part of the Terraform root diff --git a/specs/001-backup-s3-foundation/test-job.yaml b/specs/001-backup-s3-foundation/test-job.yaml deleted file mode 100644 index 74f15f0..0000000 --- a/specs/001-backup-s3-foundation/test-job.yaml +++ /dev/null @@ -1,35 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - name: backup-probe - namespace: backup -spec: - ttlSecondsAfterFinished: 300 - template: - spec: - restartPolicy: Never - containers: - - name: probe - image: scaleway/cli:latest - command: - - sh - - -c - - | - echo "probe-$(date +%s)" > /tmp/probe.txt - scw object put /tmp/probe.txt bucket=$BUCKET_NAME key=probe/$(date +%s).txt - echo "Done." - env: - - name: BUCKET_NAME - value: backup-dev-id - - name: SCW_DEFAULT_REGION - value: fr-par - - name: SCW_ACCESS_KEY - valueFrom: - secretKeyRef: - name: backup-workload-credentials - key: access_key - - name: SCW_SECRET_KEY - valueFrom: - secretKeyRef: - name: backup-workload-credentials - key: secret_key From 339acdbe97f9581246b2e508ebd2065ea67285f5 Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sun, 21 Jun 2026 10:53:07 +0200 Subject: [PATCH 4/5] fix(03-backup): read bucket name/region from terraform outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backup bucket is permanently deployed — state always exists regardless of the terraform command. Read outputs unconditionally via terraform output -json instead of hardcoded GitHub vars, so the verify step always gets live values from state. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/backup_scaleway.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/backup_scaleway.yml b/.github/workflows/backup_scaleway.yml index 1de43cd..c80fc28 100644 --- a/.github/workflows/backup_scaleway.yml +++ b/.github/workflows/backup_scaleway.yml @@ -64,8 +64,6 @@ jobs: 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 }} - BUCKET_NAME: ${{ vars.BACKUP_BUCKET_NAME }} - BUCKET_REGION: ${{ vars.BACKUP_BUCKET_REGION }} steps: - name: Assert state role ARN is present env: @@ -106,9 +104,19 @@ jobs: command: ${{ steps.cmd.outputs.command }} aws-role-arn: ${{ vars.AWS_TF_STATE_ROLE_ARN }} + # The backup bucket is permanent infrastructure — state always exists regardless + # of the terraform command. Read outputs unconditionally so downstream steps + # always have the live values from state rather than hardcoded strings. + - name: Read Terraform outputs + id: tf-outputs + run: | + outputs=$(terraform -chdir=03-backup/scaleway output -json) + echo "bucket_name=$(echo "$outputs" | jq -r '.bucket_name.value')" >> "$GITHUB_OUTPUT" + echo "bucket_region=$(echo "$outputs" | jq -r '.bucket_region.value')" >> "$GITHUB_OUTPUT" + - name: Verify backup bucket exists if: steps.cmd.outputs.command == 'apply' run: | curl -fsSL https://raw.githubusercontent.com/scaleway/scaleway-cli/master/scripts/get.sh | sh export PATH="$HOME/.local/bin:$PATH" - scw object bucket get "$BUCKET_NAME" --region "$BUCKET_REGION" + scw object bucket get "${{ steps.tf-outputs.outputs.bucket_name }}" --region "${{ steps.tf-outputs.outputs.bucket_region }}" From 0b5c432d0250f6b76634b4aae7808414c8a4119a Mon Sep 17 00:00:00 2001 From: Nicolas Brieussel Date: Sun, 21 Jun 2026 14:52:28 +0200 Subject: [PATCH 5/5] fix(03-backup): pass terraform outputs via env to avoid template injection zizmor flagged `${{ steps.tf-outputs.outputs.* }}` expanded inline in a `run:` block as template-injection risk. Values now passed via `env:` on the verify step so the runner sets them as environment variables before shell execution. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/backup_scaleway.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/backup_scaleway.yml b/.github/workflows/backup_scaleway.yml index c80fc28..043e17c 100644 --- a/.github/workflows/backup_scaleway.yml +++ b/.github/workflows/backup_scaleway.yml @@ -116,7 +116,10 @@ jobs: - name: Verify backup bucket exists if: steps.cmd.outputs.command == 'apply' + env: + BUCKET_NAME: ${{ steps.tf-outputs.outputs.bucket_name }} + BUCKET_REGION: ${{ steps.tf-outputs.outputs.bucket_region }} run: | curl -fsSL https://raw.githubusercontent.com/scaleway/scaleway-cli/master/scripts/get.sh | sh export PATH="$HOME/.local/bin:$PATH" - scw object bucket get "${{ steps.tf-outputs.outputs.bucket_name }}" --region "${{ steps.tf-outputs.outputs.bucket_region }}" + scw object bucket get "$BUCKET_NAME" --region "$BUCKET_REGION"