Skip to content
16 changes: 8 additions & 8 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion stackguardian_private_runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, always versioned so state files keep their history
- **AWS Connector**: Cross-account access configuration

### Autoscaling Group
Expand Down
10 changes: 10 additions & 0 deletions stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| 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 |
| Connector Name | Name half of the connector | Same as the runner group |
Expand Down Expand Up @@ -79,6 +81,14 @@ 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**: 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

| Output | Description |
Expand Down
16 changes: 15 additions & 1 deletion stackguardian_private_runner/aws/runner_group/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +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** 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.
- **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}`.
Expand Down Expand Up @@ -81,6 +86,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.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` |
| `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) |
Expand Down Expand Up @@ -141,9 +148,16 @@ 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.
- 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.
9 changes: 9 additions & 0 deletions stackguardian_private_runner/aws/runner_group/checks.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Plan-time warnings. A failing check never blocks a plan or apply; it prints a
# warning.

check "existing_bucket_cors_ignored" {
assert {
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."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,29 @@
"title": "Force Destroy Storage Backend",
"type": "boolean",
"default": false
},
"storage_backend_versioning": {
"title": "State File Version History",
"type": "object",
"properties": {
"noncurrent_retention_days": {
"title": "Old Version Retention (days)",
"type": "integer",
"default": 365,
"minimum": 0,
"maximum": 99999
}
},
"additionalProperties": false
},
"storage_backend_cors_extra_origins": {
"title": "Extra CORS Origins",
"type": "array",
"items": {
"type": "string",
"pattern": "^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?$"
},
"default": []
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"create_storage_backend",
"existing_s3_bucket_name",
"force_destroy_storage_backend",
"storage_backend_versioning",
"storage_backend_cors_extra_origins",
"override_names",
"max_runners"
],
Expand Down Expand Up @@ -47,6 +49,19 @@
"ui:widget": "checkbox",
"ui:description": "Warning: Force destroy the S3 bucket on module destruction (deletes all data)"
},
"storage_backend_versioning": {
"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": [
"noncurrent_retention_days"
],
"noncurrent_retention_days": {
"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)",
Expand Down
64 changes: 61 additions & 3 deletions stackguardian_private_runner/aws/runner_group/storage_backend.tf
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,71 @@ 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 = []
}
}

# 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 ? 1 : 0

bucket = aws_s3_bucket.this[0].id

versioning_configuration {
status = "Enabled"
}
}

# Lifecycle rules for the bucket this module creates. This resource owns the
# bucket's whole lifecycle configuration.
resource "aws_s3_bucket_lifecycle_configuration" "this" {
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 = "abort-incomplete-multipart-uploads"
status = "Enabled"

filter {}

abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}

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
Expand Down
Loading
Loading