From c58ea064dff2e61081ebf4d95d1f75261aab40bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Sun, 18 Oct 2026 14:52:50 +0200 Subject: [PATCH 1/9] feat: add storage backend versioning to the aws and azure runner groups --- .../runner_group/schemas/input_schema.json | 19 +++ .../aws/runner_group/schemas/ui_schema.json | 16 +++ .../aws/runner_group/storage_backend.tf | 43 ++++++ .../storage_backend_versioning.tofutest.hcl | 132 ++++++++++++++++++ .../aws/runner_group/variables.tf | 26 ++++ .../runner_group/schemas/input_schema.json | 19 +++ .../azure/runner_group/schemas/ui_schema.json | 16 +++ .../azure/runner_group/storage_backend.tf | 29 ++++ .../storage_backend_versioning.tofutest.hcl | 119 ++++++++++++++++ .../azure/runner_group/variables.tf | 27 ++++ 10 files changed, 446 insertions(+) create mode 100644 stackguardian_private_runner/aws/runner_group/tests/storage_backend_versioning.tofutest.hcl create mode 100644 stackguardian_private_runner/azure/runner_group/tests/storage_backend_versioning.tofutest.hcl diff --git a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json index 5a14947..c4d39a7 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json @@ -127,6 +127,25 @@ "title": "Force Destroy Storage Backend", "type": "boolean", "default": false + }, + "storage_backend_versioning": { + "title": "Storage Backend Versioning", + "type": "object", + "properties": { + "enabled": { + "title": "Enable Versioning", + "type": "boolean", + "default": false + }, + "noncurrent_retention_days": { + "title": "Old Version Retention (days)", + "type": "integer", + "default": 365, + "minimum": 0, + "maximum": 99999 + } + }, + "additionalProperties": false } } }, diff --git a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json index 41def6f..103952a 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json @@ -7,6 +7,7 @@ "create_storage_backend", "existing_s3_bucket_name", "force_destroy_storage_backend", + "storage_backend_versioning", "override_names", "max_runners" ], @@ -47,6 +48,21 @@ "ui:widget": "checkbox", "ui:description": "Warning: Force destroy the S3 bucket on module destruction (deletes all data)" }, + "storage_backend_versioning": { + "ui:title": "Storage Backend Versioning", + "ui:description": "Keep previous versions of objects in the S3 bucket (only for a bucket this template creates)", + "ui:order": [ + "enabled", + "noncurrent_retention_days" + ], + "enabled": { + "ui:widget": "checkbox", + "ui:description": "Turn on S3 versioning. Turning it off later suspends versioning and keeps existing versions" + }, + "noncurrent_retention_days": { + "ui:description": "Days to keep a version after it is replaced, then delete it. 0 keeps old versions forever" + } + }, "override_names": { "ui:title": "Resource Naming Configuration", "ui:description": "Customize resource names (optional)", diff --git a/stackguardian_private_runner/aws/runner_group/storage_backend.tf b/stackguardian_private_runner/aws/runner_group/storage_backend.tf index a2974ce..6c0b8fd 100644 --- a/stackguardian_private_runner/aws/runner_group/storage_backend.tf +++ b/stackguardian_private_runner/aws/runner_group/storage_backend.tf @@ -41,6 +41,49 @@ resource "aws_s3_bucket_cors_configuration" "this" { } } +# Object versioning (opt-in via storage_backend_versioning). Destroying this +# resource suspends versioning on the bucket, which is all S3 allows once it has +# been enabled. +resource "aws_s3_bucket_versioning" "this" { + count = var.create_storage_backend && var.storage_backend_versioning.enabled ? 1 : 0 + + bucket = aws_s3_bucket.this[0].id + + versioning_configuration { + status = "Enabled" + } +} + +# Expire versions noncurrent_retention_days after they are replaced, then drop +# the delete markers left with no versions behind them. Skipped when retention +# is 0 (keep forever). +resource "aws_s3_bucket_lifecycle_configuration" "this" { + count = ( + var.create_storage_backend + && var.storage_backend_versioning.enabled + && var.storage_backend_versioning.noncurrent_retention_days > 0 + ) ? 1 : 0 + + bucket = aws_s3_bucket.this[0].id + + rule { + id = "expire-noncurrent-versions" + status = "Enabled" + + filter {} + + noncurrent_version_expiration { + noncurrent_days = var.storage_backend_versioning.noncurrent_retention_days + } + + expiration { + expired_object_delete_marker = true + } + } + + depends_on = [aws_s3_bucket_versioning.this] +} + # Data source for existing S3 bucket (when using an existing bucket) data "aws_s3_bucket" "existing" { count = var.create_storage_backend ? 0 : 1 diff --git a/stackguardian_private_runner/aws/runner_group/tests/storage_backend_versioning.tofutest.hcl b/stackguardian_private_runner/aws/runner_group/tests/storage_backend_versioning.tofutest.hcl new file mode 100644 index 0000000..3c431fe --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/tests/storage_backend_versioning.tofutest.hcl @@ -0,0 +1,132 @@ +mock_provider "stackguardian" {} +# ARN and ID attributes are validated at plan time, so the mocks need real shapes. +mock_provider "aws" { + mock_resource "aws_iam_policy" { + defaults = { + arn = "arn:aws:iam::123456789012:policy/test-runner-s3-policy" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/test-private-runner-s3-role" + } + } + + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } +} + +variables { + stackguardian = { + api_key = "sgu_testkey" + org_name = "contract-org" + } +} + +run "versioning_off_by_default" { + command = plan + + assert { + condition = length(aws_s3_bucket_versioning.this) == 0 + error_message = "Versioning must stay off unless storage_backend_versioning.enabled is set." + } + + assert { + condition = length(aws_s3_bucket_lifecycle_configuration.this) == 0 + error_message = "No lifecycle rule without versioning." + } +} + +run "versioning_with_one_year_retention" { + command = plan + + variables { + storage_backend_versioning = { + enabled = true + noncurrent_retention_days = 365 + } + } + + assert { + condition = aws_s3_bucket_versioning.this[0].versioning_configuration[0].status == "Enabled" + error_message = "Versioning must be enabled on the bucket." + } + + assert { + condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].noncurrent_version_expiration[0].noncurrent_days == 365 + error_message = "Noncurrent versions must expire after 365 days." + } + + assert { + condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].expiration[0].expired_object_delete_marker == true + error_message = "Expired delete markers must be cleaned up." + } +} + +run "zero_retention_keeps_versions_forever" { + command = plan + + variables { + storage_backend_versioning = { + enabled = true + noncurrent_retention_days = 0 + } + } + + assert { + condition = length(aws_s3_bucket_versioning.this) == 1 + error_message = "Versioning must be enabled." + } + + assert { + condition = length(aws_s3_bucket_lifecycle_configuration.this) == 0 + error_message = "Retention 0 must not create an expiry rule." + } +} + +run "existing_bucket_is_left_alone" { + command = plan + + variables { + create_storage_backend = false + existing_s3_bucket_name = "existing-bucket" + storage_backend_versioning = { + enabled = true + } + } + + assert { + condition = length(aws_s3_bucket_versioning.this) == 0 && length(aws_s3_bucket_lifecycle_configuration.this) == 0 + error_message = "A bucket the module doesn't create must not get versioning or lifecycle rules." + } +} + +run "rejects_fractional_retention" { + command = plan + + variables { + storage_backend_versioning = { + enabled = true + noncurrent_retention_days = 1.5 + } + } + + expect_failures = [var.storage_backend_versioning] +} + +run "rejects_negative_retention" { + command = plan + + variables { + storage_backend_versioning = { + enabled = true + noncurrent_retention_days = -1 + } + } + + expect_failures = [var.storage_backend_versioning] +} diff --git a/stackguardian_private_runner/aws/runner_group/variables.tf b/stackguardian_private_runner/aws/runner_group/variables.tf index 2839b1f..16ec72a 100644 --- a/stackguardian_private_runner/aws/runner_group/variables.tf +++ b/stackguardian_private_runner/aws/runner_group/variables.tf @@ -26,6 +26,32 @@ variable "force_destroy_storage_backend" { default = false } +variable "storage_backend_versioning" { + description = <= 0 + && var.storage_backend_versioning.noncurrent_retention_days <= 99999 + ) + error_message = "storage_backend_versioning.noncurrent_retention_days must be a whole number between 0 and 99999." + } +} + /*-----------------------------------+ | StackGuardian Platform Variables | +-----------------------------------*/ diff --git a/stackguardian_private_runner/azure/runner_group/schemas/input_schema.json b/stackguardian_private_runner/azure/runner_group/schemas/input_schema.json index 497ceb0..cc3bc04 100644 --- a/stackguardian_private_runner/azure/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/azure/runner_group/schemas/input_schema.json @@ -191,6 +191,25 @@ } }, "additionalProperties": false + }, + "storage_backend_versioning": { + "title": "Storage Backend Versioning", + "type": "object", + "properties": { + "enabled": { + "title": "Enable Versioning", + "type": "boolean", + "default": false + }, + "noncurrent_retention_days": { + "title": "Old Version Retention (days)", + "type": "integer", + "default": 365, + "minimum": 0, + "maximum": 99999 + } + }, + "additionalProperties": false } } }, diff --git a/stackguardian_private_runner/azure/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/azure/runner_group/schemas/ui_schema.json index ce187bd..09a04b7 100644 --- a/stackguardian_private_runner/azure/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/azure/runner_group/schemas/ui_schema.json @@ -9,6 +9,7 @@ "create_blob_reader_role_assignment", "create_storage_backend", "azure_storage", + "storage_backend_versioning", "existing_azure_storage_account_name", "existing_azure_storage_account_access_key", "override_names", @@ -71,6 +72,21 @@ "ui:description": "Replication strategy for the storage account" } }, + "storage_backend_versioning": { + "ui:title": "Storage Backend Versioning", + "ui:description": "Keep previous versions of blobs in the storage account (only for an account this template creates)", + "ui:order": [ + "enabled", + "noncurrent_retention_days" + ], + "enabled": { + "ui:widget": "checkbox", + "ui:description": "Turn on blob versioning. Turning it off later stops new versions and keeps existing ones" + }, + "noncurrent_retention_days": { + "ui:description": "Days to keep a previous version (counted from when it was written), then delete it. 0 keeps old versions forever" + } + }, "existing_azure_storage_account_name": { "ui:placeholder": "myexistingstorageaccount", "ui:description": "Name of an existing Azure Storage Account to use as storage backend" diff --git a/stackguardian_private_runner/azure/runner_group/storage_backend.tf b/stackguardian_private_runner/azure/runner_group/storage_backend.tf index 5b5b9f9..24b4d71 100644 --- a/stackguardian_private_runner/azure/runner_group/storage_backend.tf +++ b/stackguardian_private_runner/azure/runner_group/storage_backend.tf @@ -50,6 +50,8 @@ resource "azurerm_storage_account" "this" { } blob_properties { + versioning_enabled = var.storage_backend_versioning.enabled + cors_rule { allowed_headers = ["*"] allowed_methods = ["GET", "HEAD", "PUT", "POST", "DELETE", "MERGE", "OPTIONS", "PATCH"] @@ -73,3 +75,30 @@ resource "azurerm_storage_container" "runner" { storage_account_id = azurerm_storage_account.this[0].id container_access_type = "private" } + +# Delete previous blob versions noncurrent_retention_days after they were +# written. Skipped when versioning is off or retention is 0 (keep forever). +resource "azurerm_storage_management_policy" "this" { + count = ( + var.create_storage_backend + && var.storage_backend_versioning.enabled + && var.storage_backend_versioning.noncurrent_retention_days > 0 + ) ? 1 : 0 + + storage_account_id = azurerm_storage_account.this[0].id + + rule { + name = "expire-noncurrent-versions" + enabled = true + + filters { + blob_types = ["blockBlob"] + } + + actions { + version { + delete_after_days_since_creation = var.storage_backend_versioning.noncurrent_retention_days + } + } + } +} diff --git a/stackguardian_private_runner/azure/runner_group/tests/storage_backend_versioning.tofutest.hcl b/stackguardian_private_runner/azure/runner_group/tests/storage_backend_versioning.tofutest.hcl new file mode 100644 index 0000000..5ddbc51 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/tests/storage_backend_versioning.tofutest.hcl @@ -0,0 +1,119 @@ +mock_provider "stackguardian" {} +# Resource IDs are parsed at plan time, so the mocks need real shapes. +mock_provider "azurerm" { + mock_resource "azurerm_storage_account" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/testaccount" + } + } + + mock_data "azurerm_client_config" { + defaults = { + subscription_id = "00000000-0000-0000-0000-000000000000" + tenant_id = "00000000-0000-0000-0000-000000000000" + object_id = "00000000-0000-0000-0000-000000000000" + } + } +} +mock_provider "azuread" { + mock_resource "azuread_application" { + defaults = { + id = "/applications/00000000-0000-0000-0000-000000000000" + client_id = "00000000-0000-0000-0000-000000000000" + } + } +} + +variables { + stackguardian = { + api_key = "sgu_testkey" + org_name = "contract-org" + } +} + +run "versioning_off_by_default" { + command = plan + + assert { + condition = azurerm_storage_account.this[0].blob_properties[0].versioning_enabled == false + error_message = "Blob versioning must stay off unless storage_backend_versioning.enabled is set." + } + + assert { + condition = length(azurerm_storage_management_policy.this) == 0 + error_message = "No management policy without versioning." + } +} + +run "versioning_with_one_year_retention" { + command = plan + + variables { + storage_backend_versioning = { + enabled = true + noncurrent_retention_days = 365 + } + } + + assert { + condition = azurerm_storage_account.this[0].blob_properties[0].versioning_enabled == true + error_message = "Blob versioning must be enabled on the storage account." + } + + assert { + condition = azurerm_storage_management_policy.this[0].rule[0].actions[0].version[0].delete_after_days_since_creation == 365 + error_message = "Previous versions must be deleted after 365 days." + } +} + +run "zero_retention_keeps_versions_forever" { + command = plan + + variables { + storage_backend_versioning = { + enabled = true + noncurrent_retention_days = 0 + } + } + + assert { + condition = azurerm_storage_account.this[0].blob_properties[0].versioning_enabled == true + error_message = "Blob versioning must be enabled." + } + + assert { + condition = length(azurerm_storage_management_policy.this) == 0 + error_message = "Retention 0 must not create a delete rule." + } +} + +run "existing_account_is_left_alone" { + command = plan + + variables { + create_storage_backend = false + existing_azure_storage_account_name = "existingaccount" + existing_azure_storage_account_access_key = "dGVzdA==" + storage_backend_versioning = { + enabled = true + } + } + + assert { + condition = length(azurerm_storage_account.this) == 0 && length(azurerm_storage_management_policy.this) == 0 + error_message = "An account the module doesn't create must not get versioning or a management policy." + } +} + +run "rejects_retention_above_azure_max" { + command = plan + + variables { + storage_backend_versioning = { + enabled = true + noncurrent_retention_days = 100000 + } + } + + expect_failures = [var.storage_backend_versioning] +} diff --git a/stackguardian_private_runner/azure/runner_group/variables.tf b/stackguardian_private_runner/azure/runner_group/variables.tf index 17b9b04..6096afa 100644 --- a/stackguardian_private_runner/azure/runner_group/variables.tf +++ b/stackguardian_private_runner/azure/runner_group/variables.tf @@ -50,6 +50,33 @@ variable "azure_storage" { } } +variable "storage_backend_versioning" { + description = <= 0 + && var.storage_backend_versioning.noncurrent_retention_days <= 99999 + ) + error_message = "storage_backend_versioning.noncurrent_retention_days must be a whole number between 0 and 99999." + } +} + /*---------------------------+ | Azure Platform Variables | +---------------------------*/ From 0fbf5e15201ec6e5bbd7e65cbb7b2c0a6fa2986d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Sun, 18 Oct 2026 14:52:51 +0200 Subject: [PATCH 2/9] docs: cover storage backend versioning --- stackguardian_private_runner/README.md | 2 +- .../aws/runner_group/DOCUMENTATION.md | 8 ++++++++ stackguardian_private_runner/aws/runner_group/README.md | 8 ++++++++ .../azure/runner_group/DOCUMENTATION.md | 8 ++++++++ stackguardian_private_runner/azure/runner_group/README.md | 8 ++++++++ 5 files changed, 33 insertions(+), 1 deletion(-) diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index da16b4b..ab017b8 100644 --- a/stackguardian_private_runner/README.md +++ b/stackguardian_private_runner/README.md @@ -170,7 +170,7 @@ runner_constraints: ### Runner Group - **StackGuardian Runner Group**: Platform integration for runner management -- **S3 Storage Backend**: Encrypted bucket for Terraform state with versioning +- **S3 Storage Backend**: Private bucket for runner state, with optional versioning and expiry of old versions - **AWS Connector**: Cross-account access configuration ### Autoscaling Group diff --git a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md index 66075aa..9b6825b 100644 --- a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md @@ -45,6 +45,8 @@ Deploying on Azure instead? Use the **StackGuardian Runner Group - Azure** templ | Create Storage Backend | Whether to create a new S3 bucket | Enabled | | Existing S3 Bucket Name | Name of an existing S3 bucket to use (when not creating new) | — | | Force Destroy Storage Backend | Delete all data in the S3 bucket on destroy (use with caution) | Disabled | +| Storage Backend Versioning — Enable Versioning | Keep previous versions of objects in the S3 bucket | Disabled | +| Storage Backend Versioning — Old Version Retention (days) | Days to keep a version after it is replaced; 0 keeps them forever | 365 | | Global Prefix | Prefix for the runner group and connector names. Leave empty to omit it | SG_RUNNER | | Runner Group Name | Name half of the runner group; the full name is `{prefix}-{name}` | 6-character random string | | Connector Name | Name half of the connector | Same as the runner group | @@ -79,6 +81,12 @@ and its name is not information a tag adds. **Data Retention**: **Force Destroy Storage Backend** deletes all bucket contents on destroy. Leave it disabled to protect your data. +**Versioning**: Applies only to a bucket this template creates. When enabled, S3 keeps +every previous version of an object and a lifecycle rule deletes each one **Old Version +Retention** days after it was replaced. Turning versioning on for an existing deployment +changes the bucket in place. S3 can't fully disable versioning once it is on: turning it +off again suspends it and keeps the stored versions, which then no longer expire. + ## Outputs | Output | Description | diff --git a/stackguardian_private_runner/aws/runner_group/README.md b/stackguardian_private_runner/aws/runner_group/README.md index 2e7e971..86d0432 100644 --- a/stackguardian_private_runner/aws/runner_group/README.md +++ b/stackguardian_private_runner/aws/runner_group/README.md @@ -15,6 +15,9 @@ deployment never initializes `azurerm` or `azuread`. The Azure equivalent is - **S3 bucket** with public access blocked and CORS limited to the StackGuardian platform origin (when `create_storage_backend = true`). +- **Object versioning + lifecycle rule** on that bucket (when + `storage_backend_versioning.enabled = true`): old versions are deleted + `noncurrent_retention_days` after they are replaced. - **IAM role + policy** scoped to the bucket. The trust policy allows the StackGuardian AWS accounts (`163602625436`, `476299211833`) and the caller's own account, gated by an external ID of the form `{org_name}:{24-char-random}`. @@ -81,6 +84,8 @@ module "runner_group" { | `create_storage_backend` | Create a new S3 bucket | `true` | | `existing_s3_bucket_name` | Existing bucket name (when `create_storage_backend = false`) | `""` | | `force_destroy_storage_backend` | Force destroy the bucket on `destroy` — deletes all objects | `false` | +| `storage_backend_versioning.enabled` | Turn on S3 versioning for the bucket the module creates | `false` | +| `storage_backend_versioning.noncurrent_retention_days` | Days to keep a version after it is replaced; `0` keeps them forever | `365` | | `override_names.global_prefix` | Prefix for the runner group and connector names; `""` omits it | `SG_RUNNER` | | `override_names.runner_group_name` | Name half of the runner group | (6-char random) | | `override_names.connector_name` | Name half of the connector | (runner group's name) | @@ -147,3 +152,6 @@ together. not enough to assume the role. - `force_destroy_storage_backend` deletes every object in the bucket on `destroy`. Leave it off unless you mean it. +- S3 versioning can only be suspended once enabled, never fully turned off. Setting + `storage_backend_versioning.enabled` back to `false` suspends it and keeps the existing + versions; the lifecycle rule is removed, so they no longer expire. diff --git a/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md index 0a08f29..3942f1c 100644 --- a/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md @@ -59,6 +59,8 @@ required. | Create Storage Backend | Whether to create a new Azure Storage Account | Enabled | | Azure Storage — Account Tier | Performance tier of the Storage Account (Standard / Premium) | Standard | | Azure Storage — Replication Type | Replication strategy (LRS / GRS / RAGRS / ZRS) | LRS | +| Storage Backend Versioning — Enable Versioning | Keep previous versions of blobs in the storage account | Disabled | +| Storage Backend Versioning — Old Version Retention (days) | Days to keep a previous version; 0 keeps them forever | 365 | | Existing Azure Storage Account Name | Name of an existing Storage Account to use (when not creating new) | — | | Existing Azure Storage Account Access Key | Access key for that account (sensitive) | — | | Global Prefix | Prefix for the runner group and connector names. Leave empty to omit it | SG_RUNNER | @@ -106,6 +108,12 @@ group only ever lives in one org, and its name is not information a tag adds. **Data Retention**: The Azure Storage Account is destroyed along with its contents on `terraform destroy` — back up anything you need first. +**Versioning**: Applies only to a storage account this template creates. When enabled, +Azure keeps previous versions of each blob and a lifecycle management policy deletes them +**Old Version Retention** days after they were written (Azure counts from when a version +was written, not from when it was replaced). Turning versioning on or off for an existing +deployment changes the account in place; turning it off keeps the stored versions. + ## Outputs | Output | Description | diff --git a/stackguardian_private_runner/azure/runner_group/README.md b/stackguardian_private_runner/azure/runner_group/README.md index dca826c..bdf0420 100644 --- a/stackguardian_private_runner/azure/runner_group/README.md +++ b/stackguardian_private_runner/azure/runner_group/README.md @@ -19,6 +19,9 @@ an Azure deployment never initializes the AWS provider. The AWS equivalent is - **Storage Account + private `runner` blob container** with TLS 1.2 minimum and CORS limited to the StackGuardian platform origin (when `create_storage_backend = true`). The container name `runner` is required by the platform. +- **Blob versioning + lifecycle management policy** on that account (when + `storage_backend_versioning.enabled = true`): previous versions are deleted + `noncurrent_retention_days` after they were written. - **Entra ID application + service principal** backing the OIDC connector. - **Federated identity credential** issued by the StackGuardian API URI for the org subject `/orgs/{org_name}` — no long-lived secret is stored on the platform. @@ -102,6 +105,8 @@ When `create_azure_resource_group = false`, `azure_resource_group_name` becomes | `existing_azure_storage_account_access_key` | Access key for that account (sensitive) | `""` | | `azure_storage.account_tier` | Storage Account performance tier (`Standard` / `Premium`) | `Standard` | | `azure_storage.account_replication_type` | Replication strategy (`LRS` / `GRS` / `RAGRS` / `ZRS`) | `LRS` | +| `storage_backend_versioning.enabled` | Turn on blob versioning for the account the module creates | `false` | +| `storage_backend_versioning.noncurrent_retention_days` | Days to keep a previous version; `0` keeps them forever | `365` | | `create_blob_reader_role_assignment` | Grant the connector SP `Storage Blob Data Reader` | `true` | | `override_names.global_prefix` | Prefix for the runner group and connector names; `""` omits it | `SG_RUNNER` | | `override_names.runner_group_name` | Name half of the runner group | (6-char random) | @@ -180,3 +185,6 @@ together. - The runner registration token and the storage access key are marked sensitive. - `tofu destroy` removes the storage account and everything in it. Back up anything you need first. +- Azure counts a previous version's age from when that version was written, not from + when it was replaced, so a version can be deleted sooner after replacement than the + same setting would on S3. From 183dd2db3e69b37e44b8b41cf6aee321d0d9450c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Tue, 27 Oct 2026 15:21:23 +0100 Subject: [PATCH 3/9] feat: allow extra cors origins on the runner group storage backend --- .../runner_group/schemas/input_schema.json | 9 ++++ .../aws/runner_group/schemas/ui_schema.json | 4 ++ .../aws/runner_group/storage_backend.tf | 7 +-- ...utest.hcl => storage_backend.tofutest.hcl} | 45 +++++++++++++++++++ .../aws/runner_group/variables.tf | 20 +++++++++ .../runner_group/schemas/input_schema.json | 9 ++++ .../azure/runner_group/schemas/ui_schema.json | 4 ++ .../azure/runner_group/storage_backend.tf | 2 +- ...utest.hcl => storage_backend.tofutest.hcl} | 28 ++++++++++++ .../azure/runner_group/variables.tf | 20 +++++++++ 10 files changed, 144 insertions(+), 4 deletions(-) rename stackguardian_private_runner/aws/runner_group/tests/{storage_backend_versioning.tofutest.hcl => storage_backend.tofutest.hcl} (72%) rename stackguardian_private_runner/azure/runner_group/tests/{storage_backend_versioning.tofutest.hcl => storage_backend.tofutest.hcl} (81%) diff --git a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json index c4d39a7..e6bf567 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json @@ -146,6 +146,15 @@ } }, "additionalProperties": false + }, + "storage_backend_cors_extra_origins": { + "title": "Extra CORS Origins", + "type": "array", + "items": { + "type": "string", + "pattern": "^https?://[^/]+$" + }, + "default": [] } } }, diff --git a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json index 103952a..d5c5556 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json @@ -8,6 +8,7 @@ "existing_s3_bucket_name", "force_destroy_storage_backend", "storage_backend_versioning", + "storage_backend_cors_extra_origins", "override_names", "max_runners" ], @@ -63,6 +64,9 @@ "ui:description": "Days to keep a version after it is replaced, then delete it. 0 keeps old versions forever" } }, + "storage_backend_cors_extra_origins": { + "ui:description": "Extra browser origins allowed to fetch from the S3 bucket, e.g. http://localhost:3000. The StackGuardian console origin is always allowed" + }, "override_names": { "ui:title": "Resource Naming Configuration", "ui:description": "Customize resource names (optional)", diff --git a/stackguardian_private_runner/aws/runner_group/storage_backend.tf b/stackguardian_private_runner/aws/runner_group/storage_backend.tf index 6c0b8fd..7a8e72a 100644 --- a/stackguardian_private_runner/aws/runner_group/storage_backend.tf +++ b/stackguardian_private_runner/aws/runner_group/storage_backend.tf @@ -34,9 +34,10 @@ resource "aws_s3_bucket_cors_configuration" "this" { cors_rule { allowed_headers = ["*"] allowed_methods = ["GET", "HEAD", "PUT"] - allowed_origins = [ - local.sg_app_uri - ] + allowed_origins = concat( + [local.sg_app_uri], + var.storage_backend_cors_extra_origins, + ) expose_headers = [] } } diff --git a/stackguardian_private_runner/aws/runner_group/tests/storage_backend_versioning.tofutest.hcl b/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl similarity index 72% rename from stackguardian_private_runner/aws/runner_group/tests/storage_backend_versioning.tofutest.hcl rename to stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl index 3c431fe..a63dd87 100644 --- a/stackguardian_private_runner/aws/runner_group/tests/storage_backend_versioning.tofutest.hcl +++ b/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl @@ -130,3 +130,48 @@ run "rejects_negative_retention" { expect_failures = [var.storage_backend_versioning] } + +run "cors_allows_console_origin_only_by_default" { + command = plan + + variables { + stackguardian = { + api_key = "sgu_testkey" + api_uri = "https://testapi.qa.stackguardian.io" + org_name = "contract-org" + } + } + + assert { + condition = tolist(aws_s3_bucket_cors_configuration.this[0].cors_rule)[0].allowed_origins == toset(["https://dash.qa.stackguardian.io"]) + error_message = "Only the console origin of the selected API region may be allowed by default." + } +} + +run "cors_adds_extra_origins" { + command = plan + + variables { + stackguardian = { + api_key = "sgu_testkey" + api_uri = "https://testapi.qa.stackguardian.io" + org_name = "contract-org" + } + storage_backend_cors_extra_origins = ["http://localhost:3000"] + } + + assert { + condition = tolist(aws_s3_bucket_cors_configuration.this[0].cors_rule)[0].allowed_origins == toset(["https://dash.qa.stackguardian.io", "http://localhost:3000"]) + error_message = "Extra origins must be added next to the console origin." + } +} + +run "cors_rejects_origin_with_path" { + command = plan + + variables { + storage_backend_cors_extra_origins = ["http://localhost:3000/"] + } + + expect_failures = [var.storage_backend_cors_extra_origins] +} diff --git a/stackguardian_private_runner/aws/runner_group/variables.tf b/stackguardian_private_runner/aws/runner_group/variables.tf index 16ec72a..efbc927 100644 --- a/stackguardian_private_runner/aws/runner_group/variables.tf +++ b/stackguardian_private_runner/aws/runner_group/variables.tf @@ -52,6 +52,26 @@ variable "storage_backend_versioning" { } } +variable "storage_backend_cors_extra_origins" { + description = < Date: Tue, 27 Oct 2026 15:21:24 +0100 Subject: [PATCH 4/9] docs: cover extra cors origins --- stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md | 1 + stackguardian_private_runner/aws/runner_group/README.md | 1 + stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md | 1 + stackguardian_private_runner/azure/runner_group/README.md | 1 + 4 files changed, 4 insertions(+) diff --git a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md index 9b6825b..a3a212a 100644 --- a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md @@ -47,6 +47,7 @@ Deploying on Azure instead? Use the **StackGuardian Runner Group - Azure** templ | Force Destroy Storage Backend | Delete all data in the S3 bucket on destroy (use with caution) | Disabled | | Storage Backend Versioning — Enable Versioning | Keep previous versions of objects in the S3 bucket | Disabled | | Storage Backend Versioning — Old Version Retention (days) | Days to keep a version after it is replaced; 0 keeps them forever | 365 | +| Extra CORS Origins | Extra browser origins allowed to fetch from the bucket (e.g. a local frontend at http://localhost:3000); the StackGuardian console is always allowed | — | | Global Prefix | Prefix for the runner group and connector names. Leave empty to omit it | SG_RUNNER | | Runner Group Name | Name half of the runner group; the full name is `{prefix}-{name}` | 6-character random string | | Connector Name | Name half of the connector | Same as the runner group | diff --git a/stackguardian_private_runner/aws/runner_group/README.md b/stackguardian_private_runner/aws/runner_group/README.md index 86d0432..9a015b3 100644 --- a/stackguardian_private_runner/aws/runner_group/README.md +++ b/stackguardian_private_runner/aws/runner_group/README.md @@ -86,6 +86,7 @@ module "runner_group" { | `force_destroy_storage_backend` | Force destroy the bucket on `destroy` — deletes all objects | `false` | | `storage_backend_versioning.enabled` | Turn on S3 versioning for the bucket the module creates | `false` | | `storage_backend_versioning.noncurrent_retention_days` | Days to keep a version after it is replaced; `0` keeps them forever | `365` | +| `storage_backend_cors_extra_origins` | Extra browser origins for the bucket's CORS rule (e.g. `http://localhost:3000`); the console origin is always allowed | `[]` | | `override_names.global_prefix` | Prefix for the runner group and connector names; `""` omits it | `SG_RUNNER` | | `override_names.runner_group_name` | Name half of the runner group | (6-char random) | | `override_names.connector_name` | Name half of the connector | (runner group's name) | diff --git a/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md index 3942f1c..ccad18b 100644 --- a/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md @@ -61,6 +61,7 @@ required. | Azure Storage — Replication Type | Replication strategy (LRS / GRS / RAGRS / ZRS) | LRS | | Storage Backend Versioning — Enable Versioning | Keep previous versions of blobs in the storage account | Disabled | | Storage Backend Versioning — Old Version Retention (days) | Days to keep a previous version; 0 keeps them forever | 365 | +| Extra CORS Origins | Extra browser origins allowed to fetch from the storage account (e.g. a local frontend at http://localhost:3000); the StackGuardian console is always allowed | — | | Existing Azure Storage Account Name | Name of an existing Storage Account to use (when not creating new) | — | | Existing Azure Storage Account Access Key | Access key for that account (sensitive) | — | | Global Prefix | Prefix for the runner group and connector names. Leave empty to omit it | SG_RUNNER | diff --git a/stackguardian_private_runner/azure/runner_group/README.md b/stackguardian_private_runner/azure/runner_group/README.md index bdf0420..9aae053 100644 --- a/stackguardian_private_runner/azure/runner_group/README.md +++ b/stackguardian_private_runner/azure/runner_group/README.md @@ -107,6 +107,7 @@ When `create_azure_resource_group = false`, `azure_resource_group_name` becomes | `azure_storage.account_replication_type` | Replication strategy (`LRS` / `GRS` / `RAGRS` / `ZRS`) | `LRS` | | `storage_backend_versioning.enabled` | Turn on blob versioning for the account the module creates | `false` | | `storage_backend_versioning.noncurrent_retention_days` | Days to keep a previous version; `0` keeps them forever | `365` | +| `storage_backend_cors_extra_origins` | Extra browser origins for the storage account's CORS rule (e.g. `http://localhost:3000`); the console origin is always allowed | `[]` | | `create_blob_reader_role_assignment` | Grant the connector SP `Storage Blob Data Reader` | `true` | | `override_names.global_prefix` | Prefix for the runner group and connector names; `""` omits it | `SG_RUNNER` | | `override_names.runner_group_name` | Name half of the runner group | (6-char random) | From 054dd2f0c9f2fcb292fadf69b48871092f88923a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Thu, 29 Oct 2026 16:03:58 +0100 Subject: [PATCH 5/9] feat: version the runner storage backend by default and warn when it is off --- .../aws/runner_group/checks.tf | 19 ++++ .../runner_group/schemas/input_schema.json | 4 +- .../aws/runner_group/schemas/ui_schema.json | 4 +- .../aws/runner_group/storage_backend.tf | 50 +++++++---- .../tests/storage_backend.tofutest.hcl | 90 +++++++++++++------ .../aws/runner_group/variables.tf | 16 ++-- .../azure/runner_group/checks.tf | 20 +++++ .../runner_group/schemas/input_schema.json | 7 +- .../azure/runner_group/schemas/ui_schema.json | 8 +- .../azure/runner_group/storage_backend.tf | 8 +- .../tests/storage_backend.tofutest.hcl | 64 ++++++++----- .../azure/runner_group/variables.tf | 29 +++--- 12 files changed, 222 insertions(+), 97 deletions(-) create mode 100644 stackguardian_private_runner/aws/runner_group/checks.tf create mode 100644 stackguardian_private_runner/azure/runner_group/checks.tf diff --git a/stackguardian_private_runner/aws/runner_group/checks.tf b/stackguardian_private_runner/aws/runner_group/checks.tf new file mode 100644 index 0000000..edbc5b3 --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/checks.tf @@ -0,0 +1,19 @@ +# Plan-time warnings. A failing check never blocks a plan or apply; it prints a +# warning, so these flag risky but allowed setups. + +check "versioning_enabled" { + assert { + condition = !var.create_storage_backend || var.storage_backend_versioning.enabled + error_message = "State file versioning is OFF for the storage backend bucket: an overwritten or deleted state file cannot be recovered. Set storage_backend_versioning.enabled = true unless you have another backup of the bucket." + } +} + +check "existing_bucket_settings_ignored" { + assert { + condition = ( + var.create_storage_backend + || (!var.storage_backend_versioning.enabled && length(var.storage_backend_cors_extra_origins) == 0) + ) + error_message = "create_storage_backend = false: storage_backend_versioning and storage_backend_cors_extra_origins are not applied to the existing bucket ${var.existing_s3_bucket_name}. Configure versioning (for state file history) and CORS on it yourself." + } +} diff --git a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json index e6bf567..a298a70 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json @@ -135,7 +135,7 @@ "enabled": { "title": "Enable Versioning", "type": "boolean", - "default": false + "default": true }, "noncurrent_retention_days": { "title": "Old Version Retention (days)", @@ -152,7 +152,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^https?://[^/]+$" + "pattern": "^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?$" }, "default": [] } diff --git a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json index d5c5556..d0e1ced 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json @@ -51,14 +51,14 @@ }, "storage_backend_versioning": { "ui:title": "Storage Backend Versioning", - "ui:description": "Keep previous versions of objects in the S3 bucket (only for a bucket this template creates)", + "ui:description": "Keeps the history of the runners' state files in the S3 bucket, so an overwritten or deleted state file can be recovered. On by default; turning it off is not recommended.", "ui:order": [ "enabled", "noncurrent_retention_days" ], "enabled": { "ui:widget": "checkbox", - "ui:description": "Turn on S3 versioning. Turning it off later suspends versioning and keeps existing versions" + "ui:description": "S3 versioning for state file history. Turning it off suspends versioning, keeps existing versions (they still expire) and prints a warning on every plan" }, "noncurrent_retention_days": { "ui:description": "Days to keep a version after it is replaced, then delete it. 0 keeps old versions forever" diff --git a/stackguardian_private_runner/aws/runner_group/storage_backend.tf b/stackguardian_private_runner/aws/runner_group/storage_backend.tf index 7a8e72a..346966d 100644 --- a/stackguardian_private_runner/aws/runner_group/storage_backend.tf +++ b/stackguardian_private_runner/aws/runner_group/storage_backend.tf @@ -55,30 +55,50 @@ resource "aws_s3_bucket_versioning" "this" { } } -# Expire versions noncurrent_retention_days after they are replaced, then drop -# the delete markers left with no versions behind them. Skipped when retention -# is 0 (keep forever). +# Lifecycle rules for the bucket this module creates. This resource owns the +# bucket's whole lifecycle configuration. +# +# The version expiry rule is deliberately not tied to storage_backend_versioning +# .enabled: after versioning is turned off (suspended) the versions already +# stored must keep expiring, or they are kept and billed forever. On a bucket +# that never had versioning the rule simply matches nothing. resource "aws_s3_bucket_lifecycle_configuration" "this" { - count = ( - var.create_storage_backend - && var.storage_backend_versioning.enabled - && var.storage_backend_versioning.noncurrent_retention_days > 0 - ) ? 1 : 0 + count = var.create_storage_backend ? 1 : 0 bucket = aws_s3_bucket.this[0].id + # Expire versions noncurrent_retention_days after they are replaced, then drop + # the delete markers left with no versions behind them. Skipped when retention + # is 0 (keep forever). + dynamic "rule" { + for_each = var.storage_backend_versioning.noncurrent_retention_days > 0 ? [1] : [] + + content { + id = "expire-noncurrent-versions" + status = "Enabled" + + filter {} + + noncurrent_version_expiration { + noncurrent_days = var.storage_backend_versioning.noncurrent_retention_days + } + + expiration { + expired_object_delete_marker = true + } + } + } + + # Interrupted multipart uploads (large state or artifact files) otherwise + # leave their parts in the bucket, billed, until removed by hand. rule { - id = "expire-noncurrent-versions" + id = "abort-incomplete-multipart-uploads" status = "Enabled" filter {} - noncurrent_version_expiration { - noncurrent_days = var.storage_backend_versioning.noncurrent_retention_days - } - - expiration { - expired_object_delete_marker = true + abort_incomplete_multipart_upload { + days_after_initiation = 7 } } diff --git a/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl b/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl index a63dd87..5c0c419 100644 --- a/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl +++ b/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl @@ -27,44 +27,55 @@ variables { } } -run "versioning_off_by_default" { +run "versioning_on_by_default" { command = plan assert { - condition = length(aws_s3_bucket_versioning.this) == 0 - error_message = "Versioning must stay off unless storage_backend_versioning.enabled is set." + condition = aws_s3_bucket_versioning.this[0].versioning_configuration[0].status == "Enabled" + error_message = "Versioning must be on by default." + } + + assert { + condition = [for r in aws_s3_bucket_lifecycle_configuration.this[0].rule : r.id] == ["expire-noncurrent-versions", "abort-incomplete-multipart-uploads"] + error_message = "By default the bucket gets the version expiry and the multipart cleanup rules." + } + + assert { + condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].noncurrent_version_expiration[0].noncurrent_days == 365 + error_message = "Noncurrent versions must expire after 365 days by default." } assert { - condition = length(aws_s3_bucket_lifecycle_configuration.this) == 0 - error_message = "No lifecycle rule without versioning." + condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].expiration[0].expired_object_delete_marker == true + error_message = "Expired delete markers must be cleaned up." + } + + assert { + condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[1].abort_incomplete_multipart_upload[0].days_after_initiation == 7 + error_message = "Incomplete multipart uploads must be aborted after 7 days." } } -run "versioning_with_one_year_retention" { +run "versioning_off_keeps_expiring_old_versions_and_warns" { command = plan variables { storage_backend_versioning = { - enabled = true - noncurrent_retention_days = 365 + enabled = false } } assert { - condition = aws_s3_bucket_versioning.this[0].versioning_configuration[0].status == "Enabled" - error_message = "Versioning must be enabled on the bucket." + condition = length(aws_s3_bucket_versioning.this) == 0 + error_message = "Versioning must be off (suspended) when disabled." } assert { condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].noncurrent_version_expiration[0].noncurrent_days == 365 - error_message = "Noncurrent versions must expire after 365 days." + error_message = "Versions stored before turning versioning off must keep expiring." } - assert { - condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].expiration[0].expired_object_delete_marker == true - error_message = "Expired delete markers must be cleaned up." - } + expect_failures = [check.versioning_enabled] } run "zero_retention_keeps_versions_forever" { @@ -72,7 +83,6 @@ run "zero_retention_keeps_versions_forever" { variables { storage_backend_versioning = { - enabled = true noncurrent_retention_days = 0 } } @@ -83,26 +93,42 @@ run "zero_retention_keeps_versions_forever" { } assert { - condition = length(aws_s3_bucket_lifecycle_configuration.this) == 0 - error_message = "Retention 0 must not create an expiry rule." + condition = [for r in aws_s3_bucket_lifecycle_configuration.this[0].rule : r.id] == ["abort-incomplete-multipart-uploads"] + error_message = "Retention 0 must leave only the multipart cleanup rule." } } -run "existing_bucket_is_left_alone" { +run "existing_bucket_is_left_alone_and_warns" { command = plan variables { create_storage_backend = false existing_s3_bucket_name = "existing-bucket" - storage_backend_versioning = { - enabled = true - } } assert { condition = length(aws_s3_bucket_versioning.this) == 0 && length(aws_s3_bucket_lifecycle_configuration.this) == 0 error_message = "A bucket the module doesn't create must not get versioning or lifecycle rules." } + + expect_failures = [check.existing_bucket_settings_ignored] +} + +run "existing_bucket_with_versioning_off" { + command = plan + + variables { + create_storage_backend = false + existing_s3_bucket_name = "existing-bucket" + storage_backend_versioning = { + enabled = false + } + } + + assert { + condition = length(aws_s3_bucket_versioning.this) == 0 + error_message = "No versioning on a bucket the module doesn't create." + } } run "rejects_fractional_retention" { @@ -110,7 +136,6 @@ run "rejects_fractional_retention" { variables { storage_backend_versioning = { - enabled = true noncurrent_retention_days = 1.5 } } @@ -123,7 +148,6 @@ run "rejects_negative_retention" { variables { storage_backend_versioning = { - enabled = true noncurrent_retention_days = -1 } } @@ -157,20 +181,30 @@ run "cors_adds_extra_origins" { api_uri = "https://testapi.qa.stackguardian.io" org_name = "contract-org" } - storage_backend_cors_extra_origins = ["http://localhost:3000"] + storage_backend_cors_extra_origins = ["http://localhost:3000", "https://dash.qa.stackguardian.io"] } assert { condition = tolist(aws_s3_bucket_cors_configuration.this[0].cors_rule)[0].allowed_origins == toset(["https://dash.qa.stackguardian.io", "http://localhost:3000"]) - error_message = "Extra origins must be added next to the console origin." + error_message = "Extra origins must be added next to the console origin, without duplicates." } } -run "cors_rejects_origin_with_path" { +run "cors_rejects_wildcard_origin" { + command = plan + + variables { + storage_backend_cors_extra_origins = ["https://*"] + } + + expect_failures = [var.storage_backend_cors_extra_origins] +} + +run "cors_rejects_path_query_and_fragment" { command = plan variables { - storage_backend_cors_extra_origins = ["http://localhost:3000/"] + storage_backend_cors_extra_origins = ["http://localhost:3000/", "https://a.example.com?x", "https://a.example.com#f"] } expect_failures = [var.storage_backend_cors_extra_origins] diff --git a/stackguardian_private_runner/aws/runner_group/variables.tf b/stackguardian_private_runner/aws/runner_group/variables.tf index efbc927..634981d 100644 --- a/stackguardian_private_runner/aws/runner_group/variables.tf +++ b/stackguardian_private_runner/aws/runner_group/variables.tf @@ -28,16 +28,20 @@ variable "force_destroy_storage_backend" { variable "storage_backend_versioning" { description = < 0 ) ? 1 : 0 diff --git a/stackguardian_private_runner/azure/runner_group/tests/storage_backend.tofutest.hcl b/stackguardian_private_runner/azure/runner_group/tests/storage_backend.tofutest.hcl index 8acedcc..839b244 100644 --- a/stackguardian_private_runner/azure/runner_group/tests/storage_backend.tofutest.hcl +++ b/stackguardian_private_runner/azure/runner_group/tests/storage_backend.tofutest.hcl @@ -31,39 +31,40 @@ variables { } } -run "versioning_off_by_default" { +run "versioning_on_by_default" { command = plan assert { - condition = azurerm_storage_account.this[0].blob_properties[0].versioning_enabled == false - error_message = "Blob versioning must stay off unless storage_backend_versioning.enabled is set." + condition = azurerm_storage_account.this[0].blob_properties[0].versioning_enabled == true + error_message = "Blob versioning must be on by default." } assert { - condition = length(azurerm_storage_management_policy.this) == 0 - error_message = "No management policy without versioning." + condition = azurerm_storage_management_policy.this[0].rule[0].actions[0].version[0].delete_after_days_since_creation == 365 + error_message = "Previous versions must be deleted after 365 days by default." } } -run "versioning_with_one_year_retention" { +run "versioning_off_keeps_expiring_old_versions_and_warns" { command = plan variables { storage_backend_versioning = { - enabled = true - noncurrent_retention_days = 365 + enabled = false } } assert { - condition = azurerm_storage_account.this[0].blob_properties[0].versioning_enabled == true - error_message = "Blob versioning must be enabled on the storage account." + condition = azurerm_storage_account.this[0].blob_properties[0].versioning_enabled == false + error_message = "Blob versioning must be off when disabled." } assert { condition = azurerm_storage_management_policy.this[0].rule[0].actions[0].version[0].delete_after_days_since_creation == 365 - error_message = "Previous versions must be deleted after 365 days." + error_message = "Versions stored before turning versioning off must keep expiring." } + + expect_failures = [check.versioning_enabled] } run "zero_retention_keeps_versions_forever" { @@ -71,7 +72,6 @@ run "zero_retention_keeps_versions_forever" { variables { storage_backend_versioning = { - enabled = true noncurrent_retention_days = 0 } } @@ -87,22 +87,33 @@ run "zero_retention_keeps_versions_forever" { } } -run "existing_account_is_left_alone" { +run "rejects_premium_tier" { + command = plan + + variables { + azure_storage = { + account_tier = "Premium" + } + } + + expect_failures = [var.azure_storage] +} + +run "existing_account_is_left_alone_and_warns" { command = plan variables { create_storage_backend = false existing_azure_storage_account_name = "existingaccount" existing_azure_storage_account_access_key = "dGVzdA==" - storage_backend_versioning = { - enabled = true - } } assert { condition = length(azurerm_storage_account.this) == 0 && length(azurerm_storage_management_policy.this) == 0 error_message = "An account the module doesn't create must not get versioning or a management policy." } + + expect_failures = [check.existing_account_settings_ignored] } run "rejects_retention_above_azure_max" { @@ -110,7 +121,6 @@ run "rejects_retention_above_azure_max" { variables { storage_backend_versioning = { - enabled = true noncurrent_retention_days = 100000 } } @@ -118,7 +128,7 @@ run "rejects_retention_above_azure_max" { expect_failures = [var.storage_backend_versioning] } -run "cors_adds_extra_origins" { +run "cors_adds_extra_origins_without_duplicates" { command = plan variables { @@ -127,20 +137,30 @@ run "cors_adds_extra_origins" { api_uri = "https://testapi.qa.stackguardian.io" org_name = "contract-org" } - storage_backend_cors_extra_origins = ["http://localhost:3000"] + storage_backend_cors_extra_origins = ["http://localhost:3000", "https://dash.qa.stackguardian.io"] } assert { condition = azurerm_storage_account.this[0].blob_properties[0].cors_rule[0].allowed_origins == tolist(["https://dash.qa.stackguardian.io", "http://localhost:3000"]) - error_message = "Extra origins must be added next to the console origin." + error_message = "Extra origins must be added next to the console origin, without duplicates." } } -run "cors_rejects_origin_with_path" { +run "cors_rejects_wildcard_origin" { + command = plan + + variables { + storage_backend_cors_extra_origins = ["https://*.example.com"] + } + + expect_failures = [var.storage_backend_cors_extra_origins] +} + +run "cors_rejects_path_query_and_fragment" { command = plan variables { - storage_backend_cors_extra_origins = ["http://localhost:3000/logs"] + storage_backend_cors_extra_origins = ["http://localhost:3000/logs", "https://a.example.com?x", "https://a.example.com#f"] } expect_failures = [var.storage_backend_cors_extra_origins] diff --git a/stackguardian_private_runner/azure/runner_group/variables.tf b/stackguardian_private_runner/azure/runner_group/variables.tf index f15a1be..a829b9c 100644 --- a/stackguardian_private_runner/azure/runner_group/variables.tf +++ b/stackguardian_private_runner/azure/runner_group/variables.tf @@ -27,7 +27,9 @@ variable "azure_storage" { description = < Date: Thu, 29 Oct 2026 16:03:59 +0100 Subject: [PATCH 6/9] docs: explain why the storage backend is versioned by default --- stackguardian_private_runner/README.md | 2 +- .../aws/runner_group/DOCUMENTATION.md | 17 ++++++---- .../aws/runner_group/README.md | 25 +++++++++------ .../azure/runner_group/DOCUMENTATION.md | 22 ++++++++----- .../azure/runner_group/README.md | 31 +++++++++++++------ 5 files changed, 64 insertions(+), 33 deletions(-) diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index ab017b8..9e29bd6 100644 --- a/stackguardian_private_runner/README.md +++ b/stackguardian_private_runner/README.md @@ -170,7 +170,7 @@ runner_constraints: ### Runner Group - **StackGuardian Runner Group**: Platform integration for runner management -- **S3 Storage Backend**: Private bucket for runner state, with optional versioning and expiry of old versions +- **S3 Storage Backend**: Private bucket for runner state, versioned by default so state files keep their history - **AWS Connector**: Cross-account access configuration ### Autoscaling Group diff --git a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md index a3a212a..d3144b5 100644 --- a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md @@ -45,7 +45,7 @@ Deploying on Azure instead? Use the **StackGuardian Runner Group - Azure** templ | Create Storage Backend | Whether to create a new S3 bucket | Enabled | | Existing S3 Bucket Name | Name of an existing S3 bucket to use (when not creating new) | — | | Force Destroy Storage Backend | Delete all data in the S3 bucket on destroy (use with caution) | Disabled | -| Storage Backend Versioning — Enable Versioning | Keep previous versions of objects in the S3 bucket | Disabled | +| Storage Backend Versioning — Enable Versioning | Keep the history of state files in the S3 bucket (not recommended to disable) | Enabled | | Storage Backend Versioning — Old Version Retention (days) | Days to keep a version after it is replaced; 0 keeps them forever | 365 | | Extra CORS Origins | Extra browser origins allowed to fetch from the bucket (e.g. a local frontend at http://localhost:3000); the StackGuardian console is always allowed | — | | Global Prefix | Prefix for the runner group and connector names. Leave empty to omit it | SG_RUNNER | @@ -82,11 +82,16 @@ and its name is not information a tag adds. **Data Retention**: **Force Destroy Storage Backend** deletes all bucket contents on destroy. Leave it disabled to protect your data. -**Versioning**: Applies only to a bucket this template creates. When enabled, S3 keeps -every previous version of an object and a lifecycle rule deletes each one **Old Version -Retention** days after it was replaced. Turning versioning on for an existing deployment -changes the bucket in place. S3 can't fully disable versioning once it is on: turning it -off again suspends it and keeps the stored versions, which then no longer expire. +**Versioning**: On by default, because it keeps the history of the runners' state +files: an overwritten or deleted state file can be recovered from a previous version. +S3 keeps every previous version of an object, and a lifecycle rule deletes each one +**Old Version Retention** days after it was replaced. The same lifecycle configuration +aborts incomplete multipart uploads after 7 days. Existing deployments that never set +this get versioning turned on in place on their next apply. Turning it off is allowed, +but every plan then prints a warning. S3 can't fully disable versioning once it is on: +turning it off suspends it, and the stored versions keep expiring as configured. Applies +only to a bucket this template creates; for an existing bucket, configure versioning +yourself. ## Outputs diff --git a/stackguardian_private_runner/aws/runner_group/README.md b/stackguardian_private_runner/aws/runner_group/README.md index 9a015b3..9aab15d 100644 --- a/stackguardian_private_runner/aws/runner_group/README.md +++ b/stackguardian_private_runner/aws/runner_group/README.md @@ -15,9 +15,11 @@ deployment never initializes `azurerm` or `azuread`. The Azure equivalent is - **S3 bucket** with public access blocked and CORS limited to the StackGuardian platform origin (when `create_storage_backend = true`). -- **Object versioning + lifecycle rule** on that bucket (when - `storage_backend_versioning.enabled = true`): old versions are deleted - `noncurrent_retention_days` after they are replaced. +- **Object versioning** on that bucket, on by default, so the runners' state files keep + their history. +- **Lifecycle rules** on that bucket: old versions are deleted + `noncurrent_retention_days` after they are replaced, and incomplete multipart uploads + are aborted after 7 days. - **IAM role + policy** scoped to the bucket. The trust policy allows the StackGuardian AWS accounts (`163602625436`, `476299211833`) and the caller's own account, gated by an external ID of the form `{org_name}:{24-char-random}`. @@ -84,9 +86,9 @@ module "runner_group" { | `create_storage_backend` | Create a new S3 bucket | `true` | | `existing_s3_bucket_name` | Existing bucket name (when `create_storage_backend = false`) | `""` | | `force_destroy_storage_backend` | Force destroy the bucket on `destroy` — deletes all objects | `false` | -| `storage_backend_versioning.enabled` | Turn on S3 versioning for the bucket the module creates | `false` | +| `storage_backend_versioning.enabled` | S3 versioning (state file history) for the bucket the module creates; turning it off prints a warning | `true` | | `storage_backend_versioning.noncurrent_retention_days` | Days to keep a version after it is replaced; `0` keeps them forever | `365` | -| `storage_backend_cors_extra_origins` | Extra browser origins for the bucket's CORS rule (e.g. `http://localhost:3000`); the console origin is always allowed | `[]` | +| `storage_backend_cors_extra_origins` | Extra browser origins for the bucket's CORS rule (e.g. `http://localhost:3000`); bare origins only, no wildcards; the console origin is always allowed | `[]` | | `override_names.global_prefix` | Prefix for the runner group and connector names; `""` omits it | `SG_RUNNER` | | `override_names.runner_group_name` | Name half of the runner group | (6-char random) | | `override_names.connector_name` | Name half of the connector | (runner group's name) | @@ -147,12 +149,17 @@ together. ## Security Notes - The bucket blocks all public access and accepts browser requests only from the - StackGuardian console origin. + StackGuardian console origin, plus any origins you list in + `storage_backend_cors_extra_origins` (no wildcards accepted). - The IAM policy grants only the S3 actions runners need, scoped to the one bucket. - Cross-account access is gated by a random external ID, so a leaked role ARN alone is not enough to assume the role. - `force_destroy_storage_backend` deletes every object in the bucket on `destroy`. Leave it off unless you mean it. -- S3 versioning can only be suspended once enabled, never fully turned off. Setting - `storage_backend_versioning.enabled` back to `false` suspends it and keeps the existing - versions; the lifecycle rule is removed, so they no longer expire. +- Versioning is what lets you recover an overwritten or deleted state file. Turning it + off is allowed, but every plan then prints a warning. S3 can only suspend versioning + once it was enabled: the stored versions stay and keep expiring per + `noncurrent_retention_days`. +- With `create_storage_backend = false` the module doesn't touch your bucket: set up + versioning and CORS on it yourself. A plan warning says so when versioning or extra + CORS origins are set. diff --git a/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md index ccad18b..9ec9392 100644 --- a/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md @@ -57,10 +57,10 @@ required. | Azure Resource Group Name | Resource Group name. Optional override when creating; required when using an existing RG | — | | Create Blob Reader Role Assignment | Grant the OIDC connector SP `Storage Blob Data Reader` on the storage account. Disable when the deploying identity lacks role-assignment write permission | Enabled | | Create Storage Backend | Whether to create a new Azure Storage Account | Enabled | -| Azure Storage — Account Tier | Performance tier of the Storage Account (Standard / Premium) | Standard | +| Azure Storage — Account Tier | Performance tier of the Storage Account. Only Standard: Premium has no blob versioning, so state files would keep no history | Standard | | Azure Storage — Replication Type | Replication strategy (LRS / GRS / RAGRS / ZRS) | LRS | -| Storage Backend Versioning — Enable Versioning | Keep previous versions of blobs in the storage account | Disabled | -| Storage Backend Versioning — Old Version Retention (days) | Days to keep a previous version; 0 keeps them forever | 365 | +| Storage Backend Versioning — Enable Versioning | Keep the history of state files in the storage account (not recommended to disable) | Enabled | +| Storage Backend Versioning — Old Version Retention (days) | Days to keep a previous version, counted from when it was written; 0 keeps them forever | 365 | | Extra CORS Origins | Extra browser origins allowed to fetch from the storage account (e.g. a local frontend at http://localhost:3000); the StackGuardian console is always allowed | — | | Existing Azure Storage Account Name | Name of an existing Storage Account to use (when not creating new) | — | | Existing Azure Storage Account Access Key | Access key for that account (sensitive) | — | @@ -109,11 +109,17 @@ group only ever lives in one org, and its name is not information a tag adds. **Data Retention**: The Azure Storage Account is destroyed along with its contents on `terraform destroy` — back up anything you need first. -**Versioning**: Applies only to a storage account this template creates. When enabled, -Azure keeps previous versions of each blob and a lifecycle management policy deletes them -**Old Version Retention** days after they were written (Azure counts from when a version -was written, not from when it was replaced). Turning versioning on or off for an existing -deployment changes the account in place; turning it off keeps the stored versions. +**Versioning**: On by default, because it keeps the history of the runners' state +files: an overwritten or deleted state file can be recovered from a previous version. +Azure keeps previous versions of each blob, and a lifecycle management policy deletes +them **Old Version Retention** days after they were written. Azure counts from when a +version was written, not from when it was replaced: with 365 days, overwriting a blob +last written 400 days ago deletes its previous version on the next daily policy run. +Existing deployments that never set this get versioning turned on in place on their next +apply. Turning it off is allowed, but every plan then prints a warning; the stored +versions keep expiring as configured. Only **Standard** storage accounts are accepted, +because Premium accounts have no blob versioning. Applies only to a storage account this template creates; for an existing +account, configure versioning yourself. ## Outputs diff --git a/stackguardian_private_runner/azure/runner_group/README.md b/stackguardian_private_runner/azure/runner_group/README.md index 9aae053..41043b7 100644 --- a/stackguardian_private_runner/azure/runner_group/README.md +++ b/stackguardian_private_runner/azure/runner_group/README.md @@ -19,8 +19,9 @@ an Azure deployment never initializes the AWS provider. The AWS equivalent is - **Storage Account + private `runner` blob container** with TLS 1.2 minimum and CORS limited to the StackGuardian platform origin (when `create_storage_backend = true`). The container name `runner` is required by the platform. -- **Blob versioning + lifecycle management policy** on that account (when - `storage_backend_versioning.enabled = true`): previous versions are deleted +- **Blob versioning** on that account, on by default, so the runners' state files keep + their history. +- **Lifecycle management policy** on that account: previous versions are deleted `noncurrent_retention_days` after they were written. - **Entra ID application + service principal** backing the OIDC connector. - **Federated identity credential** issued by the StackGuardian API URI for the org @@ -103,11 +104,11 @@ When `create_azure_resource_group = false`, `azure_resource_group_name` becomes | `create_storage_backend` | Create a new Storage Account | `true` | | `existing_azure_storage_account_name` | Existing Storage Account name (when `create_storage_backend = false`) | `""` | | `existing_azure_storage_account_access_key` | Access key for that account (sensitive) | `""` | -| `azure_storage.account_tier` | Storage Account performance tier (`Standard` / `Premium`) | `Standard` | +| `azure_storage.account_tier` | Storage Account performance tier; only `Standard` (Premium has no blob versioning) | `Standard` | | `azure_storage.account_replication_type` | Replication strategy (`LRS` / `GRS` / `RAGRS` / `ZRS`) | `LRS` | -| `storage_backend_versioning.enabled` | Turn on blob versioning for the account the module creates | `false` | -| `storage_backend_versioning.noncurrent_retention_days` | Days to keep a previous version; `0` keeps them forever | `365` | -| `storage_backend_cors_extra_origins` | Extra browser origins for the storage account's CORS rule (e.g. `http://localhost:3000`); the console origin is always allowed | `[]` | +| `storage_backend_versioning.enabled` | Blob versioning (state file history) for the account the module creates; turning it off prints a warning | `true` | +| `storage_backend_versioning.noncurrent_retention_days` | Days to keep a previous version, counted from when it was written; `0` keeps them forever | `365` | +| `storage_backend_cors_extra_origins` | Extra browser origins for the storage account's CORS rule (e.g. `http://localhost:3000`); bare origins only, no wildcards; the console origin is always allowed | `[]` | | `create_blob_reader_role_assignment` | Grant the connector SP `Storage Blob Data Reader` | `true` | | `override_names.global_prefix` | Prefix for the runner group and connector names; `""` omits it | `SG_RUNNER` | | `override_names.runner_group_name` | Name half of the runner group | (6-char random) | @@ -178,7 +179,8 @@ together. - The storage account disables public blob access and enforces TLS 1.2 minimum; the container is private. -- Browser requests are accepted only from the StackGuardian console origin (CORS). +- Browser requests are accepted only from the StackGuardian console origin, plus any + origins you list in `storage_backend_cors_extra_origins` (CORS; no wildcards accepted). - The connector authenticates by OIDC federation, so no client secret is stored on the platform. - The connector service principal gets only `Storage Blob Data Reader`, scoped to the one @@ -186,6 +188,17 @@ together. - The runner registration token and the storage access key are marked sensitive. - `tofu destroy` removes the storage account and everything in it. Back up anything you need first. +- Versioning is what lets you recover an overwritten or deleted state file. Turning it + off is allowed, but every plan then prints a warning; the stored versions stay and keep + expiring per `noncurrent_retention_days`. +- Only `Standard` storage accounts are accepted: Premium (page-blob) accounts have no + blob versioning and can't be managed by this module. - Azure counts a previous version's age from when that version was written, not from - when it was replaced, so a version can be deleted sooner after replacement than the - same setting would on S3. + when it was replaced. Example: with 365 days, overwrite a blob last written 400 days + ago and its previous version is deleted on the next daily policy run. Azure's lifecycle + rules offer no "days since replaced" option for versions. +- Incomplete uploads need no cleanup rule on Azure: uncommitted blocks are discarded by + the service after 7 days. +- With `create_storage_backend = false` the module doesn't touch your account: set up + versioning and CORS on it yourself. A plan warning says so when versioning or extra + CORS origins are set. From 777c862bcdf632a3b0185c00fb5daab2ee2bbd98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Thu, 29 Oct 2026 16:18:15 +0100 Subject: [PATCH 7/9] feat: always version the runner storage backend --- .../aws/runner_group/checks.tf | 18 ++------- .../runner_group/schemas/input_schema.json | 7 +--- .../aws/runner_group/schemas/ui_schema.json | 9 +---- .../aws/runner_group/storage_backend.tf | 12 ++---- .../tests/storage_backend.tofutest.hcl | 39 +++++++------------ .../aws/runner_group/variables.tf | 18 ++++----- .../azure/runner_group/checks.tf | 19 ++------- .../runner_group/schemas/input_schema.json | 7 +--- .../azure/runner_group/schemas/ui_schema.json | 9 +---- .../azure/runner_group/storage_backend.tf | 9 ++--- .../tests/storage_backend.tofutest.hcl | 35 ++++++++++------- .../azure/runner_group/variables.tf | 17 ++++---- 12 files changed, 70 insertions(+), 129 deletions(-) diff --git a/stackguardian_private_runner/aws/runner_group/checks.tf b/stackguardian_private_runner/aws/runner_group/checks.tf index edbc5b3..92e2941 100644 --- a/stackguardian_private_runner/aws/runner_group/checks.tf +++ b/stackguardian_private_runner/aws/runner_group/checks.tf @@ -1,19 +1,9 @@ # Plan-time warnings. A failing check never blocks a plan or apply; it prints a -# warning, so these flag risky but allowed setups. +# warning. -check "versioning_enabled" { +check "existing_bucket_cors_ignored" { assert { - condition = !var.create_storage_backend || var.storage_backend_versioning.enabled - error_message = "State file versioning is OFF for the storage backend bucket: an overwritten or deleted state file cannot be recovered. Set storage_backend_versioning.enabled = true unless you have another backup of the bucket." - } -} - -check "existing_bucket_settings_ignored" { - assert { - condition = ( - var.create_storage_backend - || (!var.storage_backend_versioning.enabled && length(var.storage_backend_cors_extra_origins) == 0) - ) - error_message = "create_storage_backend = false: storage_backend_versioning and storage_backend_cors_extra_origins are not applied to the existing bucket ${var.existing_s3_bucket_name}. Configure versioning (for state file history) and CORS on it yourself." + condition = var.create_storage_backend || length(var.storage_backend_cors_extra_origins) == 0 + error_message = "create_storage_backend = false: storage_backend_cors_extra_origins is not applied to the existing bucket ${var.existing_s3_bucket_name}. Configure CORS on it yourself." } } diff --git a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json index a298a70..2e180b8 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json @@ -129,14 +129,9 @@ "default": false }, "storage_backend_versioning": { - "title": "Storage Backend Versioning", + "title": "State File Version History", "type": "object", "properties": { - "enabled": { - "title": "Enable Versioning", - "type": "boolean", - "default": true - }, "noncurrent_retention_days": { "title": "Old Version Retention (days)", "type": "integer", diff --git a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json index d0e1ced..1931e69 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json @@ -50,16 +50,11 @@ "ui:description": "Warning: Force destroy the S3 bucket on module destruction (deletes all data)" }, "storage_backend_versioning": { - "ui:title": "Storage Backend Versioning", - "ui:description": "Keeps the history of the runners' state files in the S3 bucket, so an overwritten or deleted state file can be recovered. On by default; turning it off is not recommended.", + "ui:title": "State File Version History", + "ui:description": "Versioning is always on for the S3 bucket: it keeps the history of the runners' state files, so an overwritten or deleted state file can be recovered. Choose how long old versions are kept.", "ui:order": [ - "enabled", "noncurrent_retention_days" ], - "enabled": { - "ui:widget": "checkbox", - "ui:description": "S3 versioning for state file history. Turning it off suspends versioning, keeps existing versions (they still expire) and prints a warning on every plan" - }, "noncurrent_retention_days": { "ui:description": "Days to keep a version after it is replaced, then delete it. 0 keeps old versions forever" } diff --git a/stackguardian_private_runner/aws/runner_group/storage_backend.tf b/stackguardian_private_runner/aws/runner_group/storage_backend.tf index 346966d..245ce73 100644 --- a/stackguardian_private_runner/aws/runner_group/storage_backend.tf +++ b/stackguardian_private_runner/aws/runner_group/storage_backend.tf @@ -42,11 +42,10 @@ resource "aws_s3_bucket_cors_configuration" "this" { } } -# Object versioning (opt-in via storage_backend_versioning). Destroying this -# resource suspends versioning on the bucket, which is all S3 allows once it has -# been enabled. +# Object versioning - always on. The storage backend keeps the history of the +# runners' state files, so this is not optional. resource "aws_s3_bucket_versioning" "this" { - count = var.create_storage_backend && var.storage_backend_versioning.enabled ? 1 : 0 + count = var.create_storage_backend ? 1 : 0 bucket = aws_s3_bucket.this[0].id @@ -57,11 +56,6 @@ resource "aws_s3_bucket_versioning" "this" { # Lifecycle rules for the bucket this module creates. This resource owns the # bucket's whole lifecycle configuration. -# -# The version expiry rule is deliberately not tied to storage_backend_versioning -# .enabled: after versioning is turned off (suspended) the versions already -# stored must keep expiring, or they are kept and billed forever. On a bucket -# that never had versioning the rule simply matches nothing. resource "aws_s3_bucket_lifecycle_configuration" "this" { count = var.create_storage_backend ? 1 : 0 diff --git a/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl b/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl index 5c0c419..ae5393e 100644 --- a/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl +++ b/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl @@ -27,12 +27,12 @@ variables { } } -run "versioning_on_by_default" { +run "versioning_always_on" { command = plan assert { condition = aws_s3_bucket_versioning.this[0].versioning_configuration[0].status == "Enabled" - error_message = "Versioning must be on by default." + error_message = "Versioning must always be on for a bucket the module creates." } assert { @@ -56,26 +56,20 @@ run "versioning_on_by_default" { } } -run "versioning_off_keeps_expiring_old_versions_and_warns" { +run "old_enabled_flag_cannot_turn_versioning_off" { command = plan variables { storage_backend_versioning = { - enabled = false + enabled = false + noncurrent_retention_days = 365 } } assert { - condition = length(aws_s3_bucket_versioning.this) == 0 - error_message = "Versioning must be off (suspended) when disabled." - } - - assert { - condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].noncurrent_version_expiration[0].noncurrent_days == 365 - error_message = "Versions stored before turning versioning off must keep expiring." + condition = aws_s3_bucket_versioning.this[0].versioning_configuration[0].status == "Enabled" + error_message = "A leftover enabled = false must not turn versioning off." } - - expect_failures = [check.versioning_enabled] } run "zero_retention_keeps_versions_forever" { @@ -98,7 +92,7 @@ run "zero_retention_keeps_versions_forever" { } } -run "existing_bucket_is_left_alone_and_warns" { +run "existing_bucket_is_left_alone" { command = plan variables { @@ -110,25 +104,18 @@ run "existing_bucket_is_left_alone_and_warns" { condition = length(aws_s3_bucket_versioning.this) == 0 && length(aws_s3_bucket_lifecycle_configuration.this) == 0 error_message = "A bucket the module doesn't create must not get versioning or lifecycle rules." } - - expect_failures = [check.existing_bucket_settings_ignored] } -run "existing_bucket_with_versioning_off" { +run "existing_bucket_warns_about_ignored_cors" { command = plan variables { - create_storage_backend = false - existing_s3_bucket_name = "existing-bucket" - storage_backend_versioning = { - enabled = false - } + create_storage_backend = false + existing_s3_bucket_name = "existing-bucket" + storage_backend_cors_extra_origins = ["http://localhost:3000"] } - assert { - condition = length(aws_s3_bucket_versioning.this) == 0 - error_message = "No versioning on a bucket the module doesn't create." - } + expect_failures = [check.existing_bucket_cors_ignored] } run "rejects_fractional_retention" { diff --git a/stackguardian_private_runner/aws/runner_group/variables.tf b/stackguardian_private_runner/aws/runner_group/variables.tf index 634981d..ca82afe 100644 --- a/stackguardian_private_runner/aws/runner_group/variables.tf +++ b/stackguardian_private_runner/aws/runner_group/variables.tf @@ -28,20 +28,18 @@ variable "force_destroy_storage_backend" { variable "storage_backend_versioning" { description = < Date: Thu, 29 Oct 2026 16:18:15 +0100 Subject: [PATCH 8/9] docs: storage backend versioning is always on --- stackguardian_private_runner/README.md | 2 +- .../aws/runner_group/DOCUMENTATION.md | 20 ++++++++----------- .../aws/runner_group/README.md | 18 ++++++++--------- .../azure/runner_group/DOCUMENTATION.md | 17 ++++++++-------- .../azure/runner_group/README.md | 17 ++++++++-------- 5 files changed, 33 insertions(+), 41 deletions(-) diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index 9e29bd6..65d4447 100644 --- a/stackguardian_private_runner/README.md +++ b/stackguardian_private_runner/README.md @@ -170,7 +170,7 @@ runner_constraints: ### Runner Group - **StackGuardian Runner Group**: Platform integration for runner management -- **S3 Storage Backend**: Private bucket for runner state, versioned by default so state files keep their history +- **S3 Storage Backend**: Private bucket for runner state, always versioned so state files keep their history - **AWS Connector**: Cross-account access configuration ### Autoscaling Group diff --git a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md index d3144b5..91b0f4e 100644 --- a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md @@ -45,8 +45,7 @@ Deploying on Azure instead? Use the **StackGuardian Runner Group - Azure** templ | Create Storage Backend | Whether to create a new S3 bucket | Enabled | | Existing S3 Bucket Name | Name of an existing S3 bucket to use (when not creating new) | — | | Force Destroy Storage Backend | Delete all data in the S3 bucket on destroy (use with caution) | Disabled | -| Storage Backend Versioning — Enable Versioning | Keep the history of state files in the S3 bucket (not recommended to disable) | Enabled | -| Storage Backend Versioning — Old Version Retention (days) | Days to keep a version after it is replaced; 0 keeps them forever | 365 | +| State File Version History — Old Version Retention (days) | Days to keep a version after it is replaced; 0 keeps them forever | 365 | | Extra CORS Origins | Extra browser origins allowed to fetch from the bucket (e.g. a local frontend at http://localhost:3000); the StackGuardian console is always allowed | — | | Global Prefix | Prefix for the runner group and connector names. Leave empty to omit it | SG_RUNNER | | Runner Group Name | Name half of the runner group; the full name is `{prefix}-{name}` | 6-character random string | @@ -82,16 +81,13 @@ and its name is not information a tag adds. **Data Retention**: **Force Destroy Storage Backend** deletes all bucket contents on destroy. Leave it disabled to protect your data. -**Versioning**: On by default, because it keeps the history of the runners' state -files: an overwritten or deleted state file can be recovered from a previous version. -S3 keeps every previous version of an object, and a lifecycle rule deletes each one -**Old Version Retention** days after it was replaced. The same lifecycle configuration -aborts incomplete multipart uploads after 7 days. Existing deployments that never set -this get versioning turned on in place on their next apply. Turning it off is allowed, -but every plan then prints a warning. S3 can't fully disable versioning once it is on: -turning it off suspends it, and the stored versions keep expiring as configured. Applies -only to a bucket this template creates; for an existing bucket, configure versioning -yourself. +**Versioning**: Always on, and not optional. The storage backend keeps the history of +the runners' state files, so an overwritten or deleted state file can always be +recovered from a previous version. S3 keeps every previous version of an object, and a +lifecycle rule deletes each one **Old Version Retention** days after it was replaced. +The same lifecycle configuration aborts incomplete multipart uploads after 7 days. +Existing deployments get versioning turned on in place on their next apply. Applies only +to a bucket this template creates; for an existing bucket, enable versioning yourself. ## Outputs diff --git a/stackguardian_private_runner/aws/runner_group/README.md b/stackguardian_private_runner/aws/runner_group/README.md index 9aab15d..11f78e2 100644 --- a/stackguardian_private_runner/aws/runner_group/README.md +++ b/stackguardian_private_runner/aws/runner_group/README.md @@ -15,8 +15,8 @@ deployment never initializes `azurerm` or `azuread`. The Azure equivalent is - **S3 bucket** with public access blocked and CORS limited to the StackGuardian platform origin (when `create_storage_backend = true`). -- **Object versioning** on that bucket, on by default, so the runners' state files keep - their history. +- **Object versioning** on that bucket, always on, so the runners' state files keep their + history. - **Lifecycle rules** on that bucket: old versions are deleted `noncurrent_retention_days` after they are replaced, and incomplete multipart uploads are aborted after 7 days. @@ -86,7 +86,6 @@ module "runner_group" { | `create_storage_backend` | Create a new S3 bucket | `true` | | `existing_s3_bucket_name` | Existing bucket name (when `create_storage_backend = false`) | `""` | | `force_destroy_storage_backend` | Force destroy the bucket on `destroy` — deletes all objects | `false` | -| `storage_backend_versioning.enabled` | S3 versioning (state file history) for the bucket the module creates; turning it off prints a warning | `true` | | `storage_backend_versioning.noncurrent_retention_days` | Days to keep a version after it is replaced; `0` keeps them forever | `365` | | `storage_backend_cors_extra_origins` | Extra browser origins for the bucket's CORS rule (e.g. `http://localhost:3000`); bare origins only, no wildcards; the console origin is always allowed | `[]` | | `override_names.global_prefix` | Prefix for the runner group and connector names; `""` omits it | `SG_RUNNER` | @@ -156,10 +155,9 @@ together. not enough to assume the role. - `force_destroy_storage_backend` deletes every object in the bucket on `destroy`. Leave it off unless you mean it. -- Versioning is what lets you recover an overwritten or deleted state file. Turning it - off is allowed, but every plan then prints a warning. S3 can only suspend versioning - once it was enabled: the stored versions stay and keep expiring per - `noncurrent_retention_days`. -- With `create_storage_backend = false` the module doesn't touch your bucket: set up - versioning and CORS on it yourself. A plan warning says so when versioning or extra - CORS origins are set. +- Versioning is always on and can't be turned off: the storage backend keeps the history + of the runners' state files, so an overwritten or deleted state file can always be + recovered. `storage_backend_versioning` only sets how long old versions are kept. +- With `create_storage_backend = false` the module doesn't touch your bucket: enable + versioning on it yourself, and set up CORS. A plan warning says so when extra CORS + origins are set. diff --git a/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md index 9ec9392..9bb3755 100644 --- a/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md @@ -59,8 +59,7 @@ required. | Create Storage Backend | Whether to create a new Azure Storage Account | Enabled | | Azure Storage — Account Tier | Performance tier of the Storage Account. Only Standard: Premium has no blob versioning, so state files would keep no history | Standard | | Azure Storage — Replication Type | Replication strategy (LRS / GRS / RAGRS / ZRS) | LRS | -| Storage Backend Versioning — Enable Versioning | Keep the history of state files in the storage account (not recommended to disable) | Enabled | -| Storage Backend Versioning — Old Version Retention (days) | Days to keep a previous version, counted from when it was written; 0 keeps them forever | 365 | +| State File Version History — Old Version Retention (days) | Days to keep a previous version, counted from when it was written; 0 keeps them forever | 365 | | Extra CORS Origins | Extra browser origins allowed to fetch from the storage account (e.g. a local frontend at http://localhost:3000); the StackGuardian console is always allowed | — | | Existing Azure Storage Account Name | Name of an existing Storage Account to use (when not creating new) | — | | Existing Azure Storage Account Access Key | Access key for that account (sensitive) | — | @@ -109,17 +108,17 @@ group only ever lives in one org, and its name is not information a tag adds. **Data Retention**: The Azure Storage Account is destroyed along with its contents on `terraform destroy` — back up anything you need first. -**Versioning**: On by default, because it keeps the history of the runners' state -files: an overwritten or deleted state file can be recovered from a previous version. +**Versioning**: Always on, and not optional. The storage backend keeps the history of +the runners' state files, so an overwritten or deleted state file can always be +recovered from a previous version. Azure keeps previous versions of each blob, and a lifecycle management policy deletes them **Old Version Retention** days after they were written. Azure counts from when a version was written, not from when it was replaced: with 365 days, overwriting a blob last written 400 days ago deletes its previous version on the next daily policy run. -Existing deployments that never set this get versioning turned on in place on their next -apply. Turning it off is allowed, but every plan then prints a warning; the stored -versions keep expiring as configured. Only **Standard** storage accounts are accepted, -because Premium accounts have no blob versioning. Applies only to a storage account this template creates; for an existing -account, configure versioning yourself. +Existing deployments get blob versioning turned on in place on their next apply. Only +**Standard** storage accounts are accepted, because Premium accounts have no blob +versioning. Applies only to a storage account this template creates; for an existing +account, enable blob versioning yourself. ## Outputs diff --git a/stackguardian_private_runner/azure/runner_group/README.md b/stackguardian_private_runner/azure/runner_group/README.md index 41043b7..95dc6e1 100644 --- a/stackguardian_private_runner/azure/runner_group/README.md +++ b/stackguardian_private_runner/azure/runner_group/README.md @@ -19,8 +19,8 @@ an Azure deployment never initializes the AWS provider. The AWS equivalent is - **Storage Account + private `runner` blob container** with TLS 1.2 minimum and CORS limited to the StackGuardian platform origin (when `create_storage_backend = true`). The container name `runner` is required by the platform. -- **Blob versioning** on that account, on by default, so the runners' state files keep - their history. +- **Blob versioning** on that account, always on, so the runners' state files keep their + history. - **Lifecycle management policy** on that account: previous versions are deleted `noncurrent_retention_days` after they were written. - **Entra ID application + service principal** backing the OIDC connector. @@ -106,7 +106,6 @@ When `create_azure_resource_group = false`, `azure_resource_group_name` becomes | `existing_azure_storage_account_access_key` | Access key for that account (sensitive) | `""` | | `azure_storage.account_tier` | Storage Account performance tier; only `Standard` (Premium has no blob versioning) | `Standard` | | `azure_storage.account_replication_type` | Replication strategy (`LRS` / `GRS` / `RAGRS` / `ZRS`) | `LRS` | -| `storage_backend_versioning.enabled` | Blob versioning (state file history) for the account the module creates; turning it off prints a warning | `true` | | `storage_backend_versioning.noncurrent_retention_days` | Days to keep a previous version, counted from when it was written; `0` keeps them forever | `365` | | `storage_backend_cors_extra_origins` | Extra browser origins for the storage account's CORS rule (e.g. `http://localhost:3000`); bare origins only, no wildcards; the console origin is always allowed | `[]` | | `create_blob_reader_role_assignment` | Grant the connector SP `Storage Blob Data Reader` | `true` | @@ -188,9 +187,9 @@ together. - The runner registration token and the storage access key are marked sensitive. - `tofu destroy` removes the storage account and everything in it. Back up anything you need first. -- Versioning is what lets you recover an overwritten or deleted state file. Turning it - off is allowed, but every plan then prints a warning; the stored versions stay and keep - expiring per `noncurrent_retention_days`. +- Blob versioning is always on and can't be turned off: the storage backend keeps the + history of the runners' state files, so an overwritten or deleted state file can always + be recovered. `storage_backend_versioning` only sets how long old versions are kept. - Only `Standard` storage accounts are accepted: Premium (page-blob) accounts have no blob versioning and can't be managed by this module. - Azure counts a previous version's age from when that version was written, not from @@ -199,6 +198,6 @@ together. rules offer no "days since replaced" option for versions. - Incomplete uploads need no cleanup rule on Azure: uncommitted blocks are discarded by the service after 7 days. -- With `create_storage_backend = false` the module doesn't touch your account: set up - versioning and CORS on it yourself. A plan warning says so when versioning or extra - CORS origins are set. +- With `create_storage_backend = false` the module doesn't touch your account: enable + blob versioning on it yourself, and set up CORS. A plan warning says so when extra CORS + origins are set. From 04eeaf06642e606bf76760f6a94256d4a7a0908d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Fri, 30 Oct 2026 13:08:46 +0100 Subject: [PATCH 9/9] fix: copy the whole repo for tofu tests so shared module sources resolve --- Taskfile.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index eed1fdc..6be9e11 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -112,13 +112,13 @@ tasks: export AZUREAD_CLIENT_SECRET="$ARM_CLIENT_SECRET" export AZUREAD_TENANT_ID="$ARM_TENANT_ID" while IFS= read -r config_dir; do - if [ "$config_dir" = "$root" ]; then - target="$temp/root" - else - target="$temp/${config_dir#"$root"/}" - fi - mkdir -p "$target" - tar --exclude='.git' --exclude='.terraform' --exclude='.terraform.lock.hcl' -cf - -C "$config_dir" . | tar -xf - -C "$target" + # Copy the whole repository so relative module sources (../../runner_group) resolve + rel="${config_dir#"$root"}" + rel="${rel#/}" + work="$temp/$(printf '%s' "${rel:-root}" | tr '/' '_')" + target="$work${rel:+/$rel}" + mkdir -p "$work" + tar --exclude='.git' --exclude='.terraform' --exclude='.terraform.lock.hcl' -cf - -C "$root" . | tar -xf - -C "$work" if [ "$config_dir" = "$root" ]; then : > "$target/provider.tf" printf '%s\n' \ @@ -142,7 +142,7 @@ tasks: while IFS= read -r provider_file; do perl -0pi -e 's/required_version = "= 1\.5\.7"/required_version = ">= 1.6.0"/' "$provider_file" perl -0pi -e 's/source\s*=\s*"StackGuardian\/stackguardian"/source = "registry.terraform.io\/StackGuardian\/stackguardian"/' "$provider_file" - done < <(find "$target" -name provider.tf -type f) + done < <(find "$work" -name provider.tf -type f) tofu -chdir="$target" init -backend=false tofu -chdir="$target" test done < <(find "$root" \( -name .git -o -name .terraform \) -type d -prune -o -path '*/tests/*.tofutest.hcl' -type f -print | while IFS= read -r test_file; do dirname "$(dirname "$test_file")"; done | sort -u)