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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .github/workflows/backup_scaleway.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Terraform — Scaleway backup bucket

# 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)
#
# State R/W uses the org state role (vars.AWS_TF_STATE_ROLE_ARN).
# Scaleway creds come from the `scaleway` environment.

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. 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-03-backup-dev-bucket
cancel-in-progress: false

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 }}
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

# 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:
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 ;;
Comment thread
nbrieussel marked this conversation as resolved.
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 }}

# 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'
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 "$BUCKET_NAME" --region "$BUCKET_REGION"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Spec Kit planning artifacts — local design docs, not part of the reviewed diff
specs/

# Local .terraform directories
.terraform/

Expand Down
3 changes: 3 additions & 0 deletions .specify/feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"feature_directory": "specs/001-backup-s3-foundation"
}
33 changes: 33 additions & 0 deletions 01-iam/bootstrap/scaleway/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,39 @@ 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.
# 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/)."
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
Expand Down
6 changes: 6 additions & 0 deletions 01-iam/bootstrap/scaleway/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
93 changes: 93 additions & 0 deletions 03-backup/scaleway/.terraform.lock.hcl

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

11 changes: 11 additions & 0 deletions 03-backup/scaleway/env/03-backup-dev-bucket.tfvars
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions 03-backup/scaleway/iam.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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" "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.kubernetes.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" "kubernetes_key" {
rotation_days = 365
}

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.kubernetes_key.rotation_rfc3339
}
23 changes: 23 additions & 0 deletions 03-backup/scaleway/infisical.tf
Original file line number Diff line number Diff line change
@@ -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.kubernetes.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.kubernetes.secret_key
env_slug = var.infisical_env_slug
workspace_id = var.infisical_workspace_id
folder_path = infisical_secret_folder.backup.path
}
Loading
Loading