From 0768c65aaa87f2ca701de879ba10e098a7f4388e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 24 Aug 2026 11:25:11 +0200 Subject: [PATCH 1/4] fix: rework packer AMI build and release channel Packer rebuilt the AMI on every apply, because null_resource.packer_build triggered on timestamp(). That cost a full build each run and, since the AMI ID came straight from the build log, replaced the runner instance every time too. Build once per state instead. The ID is recorded in terraform_data.ami_id and read from there on later plans, so a fresh checkout or a CI runner no longer depends on packer_manifest.log being present. Set packer_config.rebuild_ami_token to any new value to build a fresh AMI; leaving it alone never rebuilds. It is a free-form string rather than a flag so unsetting it does not trigger another build. Also: - build_ami.sh now checks for the artifact line, since tee masks packer's exit status and a silent failure would otherwise be recorded as a successful build - ami_cleanup keys off the recorded ID, so destroy never deregisters an AMI this state did not build - new sg_runner.pre_release bakes the newest sg-runner pre-release into the AMI, falling back to latest stable when none is published - requires terraform >= 1.4 (opentofu >= 1.6) for terraform_data --- stackguardian_private_runner/README.md | 7 ++- .../aws/DOCUMENTATION.md | 3 +- .../aws/packer/DOCUMENTATION.md | 10 +++- .../aws/packer/README.md | 60 ++++++++++++++++--- .../aws/packer/locals.tf | 12 ++++ .../aws/packer/main.tf | 57 +++++++++++++----- .../aws/packer/outputs.tf | 24 +++++--- .../aws/packer/provider.tf | 3 + .../aws/packer/schemas/input_schema.json | 58 +++++++++++++++--- .../aws/packer/schemas/ui_schema.json | 21 ++++++- .../aws/packer/scripts/build_ami.sh | 8 +++ .../aws/packer/scripts/setup.sh | 32 +++++----- .../aws/packer/terraform.tfvars.tpl | 10 +++- .../aws/packer/variables.tf | 32 +++++++++- 14 files changed, 269 insertions(+), 68 deletions(-) diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index 670152b..6933e5c 100644 --- a/stackguardian_private_runner/README.md +++ b/stackguardian_private_runner/README.md @@ -25,6 +25,11 @@ cd aws/packer/ See [aws/packer/README.md](aws/packer/README.md) for full configuration options. +> **AMI reuse:** Packer runs on the first apply only. The AMI ID is recorded in +> state and reused by every later plan, so re-applies are fast and the runner keeps +> the same image. To build a fresh AMI, change `packer_config.rebuild_ami_token` to +> any new value. See [When Packer Runs](aws/packer/README.md#when-packer-runs). + **Deploy:** ```bash @@ -205,7 +210,7 @@ Each module has its own README with detailed configuration options: | Template | Output | Description | Usage | |----------|--------|-------------|-------| -| Packer | `ami_id` | Created AMI identifier | Input for Autoscaling Group | +| Packer | `ami_id` | Built AMI identifier, recorded in state | Input for Autoscaling Group | | Runner Group | `runner_group_name` | StackGuardian runner group name | Input for ASG and Autoscaler | | Runner Group | `runner_group_token` | Token for runner registration | Input for Autoscaling Group | | Runner Group | `s3_bucket_name` | S3 storage backend bucket | Input for ASG and Autoscaler | diff --git a/stackguardian_private_runner/aws/DOCUMENTATION.md b/stackguardian_private_runner/aws/DOCUMENTATION.md index 40f100e..8c88d51 100644 --- a/stackguardian_private_runner/aws/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/DOCUMENTATION.md @@ -49,10 +49,11 @@ Build a custom AMI for StackGuardian Private Runner with pre-installed dependenc | os.ssh_username | SSH username (auto-detected if empty) | `""` | | os.user_script | Custom shell script to execute during provisioning | `""` | | packer_config.version | Packer version to use | `1.14.1` | +| packer_config.rebuild_ami_token | Change to any new value to build a fresh AMI (the AMI is otherwise built once and reused from state) | `""` | | packer_config.deregistration_protection.enabled | Enable AMI deregistration protection | `true` | | packer_config.deregistration_protection.with_cooldown | Enable cooldown period before deregistration | `false` | | packer_config.delete_snapshots | Delete EBS snapshots during cleanup | `true` | -| packer_config.cleanup_amis_on_destroy | Auto-delete AMIs on terraform destroy | `true` | +| packer_config.cleanup_amis_on_destroy | Deregister this deployment's AMI on terraform destroy | `true` | | terraform.primary_version | Primary Terraform version to install | `""` | | terraform.additional_versions | Additional Terraform versions to install | `[]` | | opentofu.primary_version | Primary OpenTofu version to install | `""` | diff --git a/stackguardian_private_runner/aws/packer/DOCUMENTATION.md b/stackguardian_private_runner/aws/packer/DOCUMENTATION.md index 7846914..82d8c66 100644 --- a/stackguardian_private_runner/aws/packer/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/packer/DOCUMENTATION.md @@ -43,6 +43,7 @@ Before deploying this template: | SSH Username | SSH username for the build instance (auto-detected based on OS if empty) | Auto-detected | | Custom User Script | Shell script for additional customization (runs after standard setup) | Empty | | Packer Version | Version of HashiCorp Packer to use for building the AMI | `1.14.1` | +| Rebuild AMI Token | Change to any new value (a date, a version tag) to build a fresh AMI once. Unchanged means no rebuild | Empty | | Enable Deregistration Protection | Prevent accidental AMI deletion through AWS console or API | Enabled | | Enable Cooldown Period | 24-hour waiting period before allowing deregistration | Disabled | | Delete EBS Snapshots | Delete EBS snapshots during cleanup | Enabled | @@ -51,6 +52,7 @@ Before deploying this template: | Additional Terraform Versions | Extra Terraform versions (installed as `/bin/terraform{version}`) | Empty | | Primary OpenTofu Version | Main OpenTofu version to install as `/bin/tofu` | Empty | | Additional OpenTofu Versions | Extra OpenTofu versions (installed as `/bin/tofu{version}`) | Empty | +| Use Pre-release | Install the newest sg-runner pre-release instead of the latest stable release (falls back to stable when none is published) | Disabled | | Proxy URL | HTTP proxy for private network builds (e.g., `http://proxy.company.com:8080`) | Empty | ## Important Notes @@ -61,13 +63,19 @@ Before deploying this template: **Terraform/OpenTofu Versions**: Version strings must follow semantic versioning (e.g., "1.5.7"). The primary version becomes the default binary, while additional versions are installed with version suffixes (e.g., `/bin/terraform1.4.6`). +**sg-runner Release Channel**: The AMI installs the latest stable sg-runner release by default. Enable *Use Pre-release* to bake in the newest pre-release instead — useful for validating upcoming runner changes, not recommended for production. If no pre-release is published, the build falls back to the latest stable release. On an existing deployment the change only takes effect once a new AMI is built, so set *Rebuild AMI Token* to a new value as well. + **AMI Protection**: Deregistration protection is enabled by default to prevent accidental deletion. If cooldown is also enabled, you must wait 24 hours after disabling protection before the AMI can be deregistered. +**AMI Reuse**: The AMI is built on the first deployment only. Its ID is recorded in state and reused on every run after that, so repeated runs cost no build time and the runner keeps the same image. To build a fresh AMI — after changing the OS, the user script, or the Terraform/OpenTofu versions — set *Rebuild AMI Token* to any new value. Leaving the token unchanged never rebuilds. + +**AMI Cleanup**: *Automatic AMI Cleanup* is enabled by default. It only ever deregisters the AMI this deployment built: on destroy, and when a rebuild supersedes it. AMIs built by other deployments are never touched, because the template never adopts an AMI it did not build. Disable it to preserve images for manual cleanup. + ## Outputs | Output | Description | |--------|-------------| -| AMI ID | The ID of the created AMI for use with the AWS deployment template | +| AMI ID | The ID of the AMI to use with the AWS deployment template | | AMI Info | Comprehensive metadata including region, OS details, and protection settings | | Cleanup Commands | Ready-to-use AWS CLI commands for manual AMI cleanup | diff --git a/stackguardian_private_runner/aws/packer/README.md b/stackguardian_private_runner/aws/packer/README.md index 614f405..1df415d 100644 --- a/stackguardian_private_runner/aws/packer/README.md +++ b/stackguardian_private_runner/aws/packer/README.md @@ -27,7 +27,7 @@ This Terraform module automates the creation of custom AMIs using HashiCorp Pack - **AWS Account**: With permissions to create EC2 instances and AMIs - **VPC**: Existing VPC with internet access (direct or via NAT/proxy) - **Subnet**: Public subnet with IGW access OR private subnet with NAT Gateway -- **Terraform**: Version 1.0 or later +- **Terraform**: Version 1.4 or later (OpenTofu 1.6+), for `terraform_data` - **AWS CLI**: Configured with appropriate credentials ### Required IAM Permissions @@ -104,16 +104,51 @@ module "packer_ami" { | `os.ssh_username` | SSH username override | auto-detected | | `os.user_script` | Custom script to run during provisioning | `""` | | `packer_config.version` | Packer version to use | `1.14.1` | +| `packer_config.rebuild_ami_token` | Change to any new value to rebuild the AMI (see [When Packer runs](#when-packer-runs)) | `""` | | `packer_config.deregistration_protection.enabled` | Enable AMI deregistration protection | `true` | | `packer_config.deregistration_protection.with_cooldown` | Enable 24-hour cooldown period | `false` | | `packer_config.delete_snapshots` | Delete EBS snapshots during cleanup | `true` | -| `packer_config.cleanup_amis_on_destroy` | Auto-cleanup AMI on terraform destroy | `true` | +| `packer_config.cleanup_amis_on_destroy` | Deregister this deployment's AMI on terraform destroy | `true` | | `terraform.primary_version` | Primary Terraform version to install | `""` | | `terraform.additional_versions` | Additional Terraform versions | `[]` | | `opentofu.primary_version` | Primary OpenTofu version to install | `""` | | `opentofu.additional_versions` | Additional OpenTofu versions | `[]` | +| `sg_runner.pre_release` | Install the newest sg-runner pre-release instead of the latest stable release (falls back to stable when none exists) | `false` | | `network.proxy_url` | HTTP proxy for private network builds | `""` | +### When Packer Runs + +Building an AMI takes several minutes, so this module builds **once per state** and +then reuses what it built: + +| Situation | Result | +|-----------|--------| +| First apply | Packer builds the AMI, and its ID is recorded in state | +| Every plan/apply after that | No build, no diff — the AMI ID comes from state | +| `rebuild_ami_token` changed to a new value | Packer builds a new AMI, once | +| State destroyed and re-applied | Packer builds again | + +```hcl +# Force one fresh build (e.g. to pick up new Terraform/OpenTofu versions) +packer_config = { + version = "1.14.1" + rebuild_ami_token = "2026-07-30-tofu-1.11" +} +``` + +The token is deliberately a free-form string rather than an on/off flag: bump it to +rebuild, then leave it alone. A boolean would build again the moment you unset it. + +The recorded AMI ID lives in `terraform_data.ami_id`, not in `packer_manifest.log`, +so plans stay stable on a fresh checkout, on a CI runner, or after the log is deleted. +Because the ID no longer changes on every apply, the runner instance is no longer +replaced on every apply either. + +> **Note:** `packer_config.cleanup_amis_on_destroy` (default `true`) only ever +> touches the AMI this deployment built — on destroy, and on the rebuild that +> supersedes it. AMIs belonging to other deployments are never deregistered, since +> the module never adopts an AMI it did not build. + ### Configuration Examples #### Basic Configuration (Amazon Linux 2) @@ -298,6 +333,8 @@ terraform apply | v [Execute Packer] --> null_resource.packer_build + | created once per state; replaced only when + | rebuild_ami_token changes | | | v | scripts/build_ami.sh @@ -309,9 +346,13 @@ terraform apply | scripts/setup.sh (on EC2) | v -[Parse AMI ID] --> data.external.packer_ami_id +[Parse AMI ID] --> data.external.packer_ami_id (reads packer_manifest.log) | v +[Record AMI ID] --> terraform_data.ami_id + | the ID lives here; later plans read it from state + | instead of rebuilding or re-reading the log + v [Register Cleanup] --> null_resource.ami_cleanup | v @@ -339,16 +380,21 @@ Examples: - Verify proxy configuration if in private network - Review `packer_manifest.log` for detailed errors -2. **AMI Cleanup Fails** +2. **Packer Does Not Run / Old AMI Is Used** + - Expected: the AMI is built once and then reused from state + - Change `packer_config.rebuild_ami_token` to any new value to build a fresh one + - Or, without touching variables: `terraform apply -replace=null_resource.packer_build` + +3. **AMI Cleanup Fails** - Check if deregistration protection is enabled - Wait for cooldown period if configured - Verify AWS CLI credentials -3. **Terraform/OpenTofu Not Installed** +4. **Terraform/OpenTofu Not Installed** - Ensure version strings are valid (e.g., `1.5.7`, not `v1.5.7`) - Check network access to download URLs -4. **Permission Denied** +5. **Permission Denied** - Verify IAM permissions for EC2 and AMI operations - Check if AMI deregistration protection is blocking cleanup @@ -376,7 +422,7 @@ terraform apply | Output | Description | |--------|-------------| -| `ami_id` | The ID of the created AMI | +| `ami_id` | The ID of the AMI built by this module and recorded in state | | `ami_info` | Comprehensive AMI metadata (region, OS, timestamps, protection settings) | | `cleanup_commands` | Ready-to-use AWS CLI commands for manual AMI cleanup | diff --git a/stackguardian_private_runner/aws/packer/locals.tf b/stackguardian_private_runner/aws/packer/locals.tf index 1edcfd0..4591b11 100644 --- a/stackguardian_private_runner/aws/packer/locals.tf +++ b/stackguardian_private_runner/aws/packer/locals.tf @@ -21,3 +21,15 @@ locals { } } +/*-------------------+ + | AMI Build Records | + +-------------------*/ +locals { + # Name given to AMIs built by this module (see ami.pkr.hcl) + runner_ami_name_pattern = "SG-RUNNER-ami-${var.os.family}${var.os.family != "amazon" ? var.os.version : ""}-*" + + # The AMI built by this module, as recorded in state. Packer runs on the first + # apply and then only when packer_config.rebuild_ami_token changes, so this + # value stays stable across re-plans. + ami_id = terraform_data.ami_id.output +} diff --git a/stackguardian_private_runner/aws/packer/main.tf b/stackguardian_private_runner/aws/packer/main.tf index 917e8ef..0efdcf5 100644 --- a/stackguardian_private_runner/aws/packer/main.tf +++ b/stackguardian_private_runner/aws/packer/main.tf @@ -20,9 +20,14 @@ data "aws_ami" "this" { } # Build custom AMI using Packer +# +# Created once per state, so Packer runs on the first apply only. Change +# packer_config.rebuild_ami_token to any new value to replace this resource and +# build a fresh AMI; re-plans with an unchanged token do nothing. resource "null_resource" "packer_build" { provisioner "local-exec" { - command = "sh ${path.module}/scripts/build_ami.sh" + command = "sh ${path.module}/scripts/build_ami.sh" + working_dir = path.module environment = { BASE_AMI = data.aws_ami.this.id OS_FAMILY = var.os.family @@ -39,6 +44,7 @@ resource "null_resource" "packer_build" { TERRAFORM_VERSIONS = join(" ", var.terraform.additional_versions) OPENTOFU_VERSION = var.opentofu.primary_version OPENTOFU_VERSIONS = join(" ", var.opentofu.additional_versions) + SG_RUNNER_PRE_RELEASE = var.sg_runner.pre_release VPC_ID = var.network.vpc_id DEREGISTRATION_PROTECTION_ENABLED = var.packer_config.deregistration_protection.enabled DEREGISTRATION_PROTECTION_WITH_COOLDOWN = var.packer_config.deregistration_protection.with_cooldown @@ -47,17 +53,49 @@ resource "null_resource" "packer_build" { triggers = { - timestamp = timestamp() + rebuild_token = var.packer_config.rebuild_ami_token + } +} + +# Parse the AMI ID out of the Packer build log +# +# Only meaningful right after a build. It returns an empty AMI ID when the log is +# missing (fresh checkout, CI runner) instead of failing the plan, because the +# recorded AMI ID is read from state via terraform_data.ami_id below. +data "external" "packer_ami_id" { + program = [ + "sh", + "-c", + "ami_id=$(grep 'artifact,0,id' ${path.module}/packer_manifest.log 2>/dev/null | tail -1 | cut -d, -f6 | cut -d: -f2); printf '{\"ami_id\": \"%s\"}' \"$ami_id\"" + ] + + depends_on = [null_resource.packer_build] +} + +# Record the built AMI ID in state +# +# input is only re-read when a build runs (replace_triggered_by); ignore_changes +# keeps the recorded ID untouched by later plans, even if the build log is stale +# or gone. +resource "terraform_data" "ami_id" { + input = data.external.packer_ami_id.result["ami_id"] + + lifecycle { + ignore_changes = [input] + replace_triggered_by = [null_resource.packer_build] } } # Conditional AMI cleanup resource +# +# Tracks the AMI this module built, so a destroy never deregisters an image it +# did not create. Re-keyed by a rebuild, which deregisters the superseded AMI. resource "null_resource" "ami_cleanup" { count = var.packer_config.cleanup_amis_on_destroy ? 1 : 0 # Store AMI information as triggers so they're available during destroy triggers = { - ami_id = data.external.packer_ami_id.result["ami_id"] + ami_id = local.ami_id region = var.aws_region delete_snapshots = var.packer_config.delete_snapshots script_path = "${path.module}/scripts/cleanup_amis.sh" @@ -73,17 +111,4 @@ resource "null_resource" "ami_cleanup" { REGION = self.triggers.region } } - - depends_on = [null_resource.packer_build] -} - -# Parse the AMI ID from the Packer output -data "external" "packer_ami_id" { - program = [ - "sh", - "-c", - "grep 'artifact,0,id' packer_manifest.log | tail -1 | cut -d, -f6 | cut -d: -f2 | xargs -I{} echo '{\"ami_id\": \"{}\"}'" - ] - - depends_on = [null_resource.packer_build] } diff --git a/stackguardian_private_runner/aws/packer/outputs.tf b/stackguardian_private_runner/aws/packer/outputs.tf index 5f5868f..5f40d46 100644 --- a/stackguardian_private_runner/aws/packer/outputs.tf +++ b/stackguardian_private_runner/aws/packer/outputs.tf @@ -2,18 +2,24 @@ | Packer AMI Machine Image Builder | +----------------------------------*/ output "ami_id" { - description = "The ID of the created AMI" - value = data.external.packer_ami_id.result["ami_id"] + description = "The ID of the AMI built by this module and recorded in state" + value = local.ami_id + + precondition { + condition = local.ami_id != "" + error_message = "No AMI recorded: the Packer build produced no AMI ID. Check packer_manifest.log in the packer module directory." + } } + output "ami_info" { description = "Comprehensive AMI information for tracking and cleanup" value = { - ami_id = data.external.packer_ami_id.result["ami_id"] + ami_id = local.ami_id region = var.aws_region os_family = var.os.family os_version = var.os.version timestamp = formatdate("YYYY-MM-DD-hhmm", timestamp()) - ami_name = "SG-RUNNER-ami-${var.os.family}${var.os.family != "amazon" ? var.os.version : ""}-*" + ami_name = local.runner_ami_name_pattern deregistration_protection = { enabled = var.packer_config.deregistration_protection.enabled with_cooldown = var.packer_config.deregistration_protection.with_cooldown @@ -28,11 +34,11 @@ output "ami_info" { output "cleanup_commands" { description = "AWS CLI commands for manual AMI cleanup" value = { - check_protection = "aws ec2 describe-image-attribute --region ${var.aws_region} --image-id ${data.external.packer_ami_id.result["ami_id"]} --attribute deregistrationProtection" - disable_protection = var.packer_config.deregistration_protection.enabled ? "aws ec2 disable-image-deregistration-protection --region ${var.aws_region} --image-id ${data.external.packer_ami_id.result["ami_id"]}" : "# Protection not enabled" - deregister_ami = "aws ec2 deregister-image --region ${var.aws_region} --image-id ${data.external.packer_ami_id.result["ami_id"]}" - list_snapshots = "aws ec2 describe-images --region ${var.aws_region} --image-ids ${data.external.packer_ami_id.result["ami_id"]} --query 'Images[0].BlockDeviceMappings[*].Ebs.SnapshotId' --output text" - delete_snapshots = var.packer_config.delete_snapshots ? "aws ec2 describe-images --region ${var.aws_region} --image-ids ${data.external.packer_ami_id.result["ami_id"]} --query 'Images[0].BlockDeviceMappings[*].Ebs.SnapshotId' --output text | xargs -n1 aws ec2 delete-snapshot --region ${var.aws_region} --snapshot-id" : "# Snapshot deletion disabled" + check_protection = "aws ec2 describe-image-attribute --region ${var.aws_region} --image-id ${local.ami_id} --attribute deregistrationProtection" + disable_protection = var.packer_config.deregistration_protection.enabled ? "aws ec2 disable-image-deregistration-protection --region ${var.aws_region} --image-id ${local.ami_id}" : "# Protection not enabled" + deregister_ami = "aws ec2 deregister-image --region ${var.aws_region} --image-id ${local.ami_id}" + list_snapshots = "aws ec2 describe-images --region ${var.aws_region} --image-ids ${local.ami_id} --query 'Images[0].BlockDeviceMappings[*].Ebs.SnapshotId' --output text" + delete_snapshots = var.packer_config.delete_snapshots ? "aws ec2 describe-images --region ${var.aws_region} --image-ids ${local.ami_id} --query 'Images[0].BlockDeviceMappings[*].Ebs.SnapshotId' --output text | xargs -n1 aws ec2 delete-snapshot --region ${var.aws_region} --snapshot-id" : "# Snapshot deletion disabled" cleanup_note = var.packer_config.deregistration_protection.with_cooldown ? "NOTE: If cooldown is enabled, wait 24 hours after disabling protection before deregistering" : "No cooldown period configured" } } diff --git a/stackguardian_private_runner/aws/packer/provider.tf b/stackguardian_private_runner/aws/packer/provider.tf index 32c2a2a..7b385a7 100644 --- a/stackguardian_private_runner/aws/packer/provider.tf +++ b/stackguardian_private_runner/aws/packer/provider.tf @@ -1,4 +1,7 @@ terraform { + # terraform_data (used to record the built AMI ID in state) needs 1.4+ + required_version = ">= 1.4.0" + required_providers { aws = { source = "hashicorp/aws" diff --git a/stackguardian_private_runner/aws/packer/schemas/input_schema.json b/stackguardian_private_runner/aws/packer/schemas/input_schema.json index 98ab069..f681d30 100644 --- a/stackguardian_private_runner/aws/packer/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/packer/schemas/input_schema.json @@ -70,13 +70,17 @@ "default": "" } }, - "required": ["vpc_id"], + "required": [ + "vpc_id" + ], "additionalProperties": false }, "os": { "title": "Operating System", "type": "object", - "required": ["family"], + "required": [ + "family" + ], "allOf": [ { "if": { @@ -91,8 +95,16 @@ "family": { "title": "OS Family", "type": "string", - "enum": ["amazon", "ubuntu", "rhel"], - "enumNames": ["Amazon Linux 2", "Ubuntu", "RHEL (Red Hat Enterprise Linux)"], + "enum": [ + "amazon", + "ubuntu", + "rhel" + ], + "enumNames": [ + "Amazon Linux 2", + "Ubuntu", + "RHEL (Red Hat Enterprise Linux)" + ], "default": "amazon" }, "update_os_before_install": { @@ -128,8 +140,16 @@ "family": { "title": "OS Family", "type": "string", - "enum": ["amazon", "ubuntu", "rhel"], - "enumNames": ["Amazon Linux 2", "Ubuntu", "RHEL (Red Hat Enterprise Linux)"], + "enum": [ + "amazon", + "ubuntu", + "rhel" + ], + "enumNames": [ + "Amazon Linux 2", + "Ubuntu", + "RHEL (Red Hat Enterprise Linux)" + ], "default": "amazon" }, "version": { @@ -153,7 +173,9 @@ "default": "" } }, - "required": ["version"] + "required": [ + "version" + ] } } ] @@ -168,6 +190,11 @@ "default": "1.14.1", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "rebuild_ami_token": { + "title": "Rebuild AMI Token", + "type": "string", + "default": "" + }, "deregistration_protection": { "title": "Deregistration Protection", "type": "object", @@ -245,7 +272,22 @@ } }, "additionalProperties": false + }, + "sg_runner": { + "title": "StackGuardian Runner", + "type": "object", + "properties": { + "pre_release": { + "title": "Use Pre-release", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false } }, - "required": ["aws_region", "network"] + "required": [ + "aws_region", + "network" + ] } diff --git a/stackguardian_private_runner/aws/packer/schemas/ui_schema.json b/stackguardian_private_runner/aws/packer/schemas/ui_schema.json index 4e18d88..a69959c 100644 --- a/stackguardian_private_runner/aws/packer/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/packer/schemas/ui_schema.json @@ -8,7 +8,8 @@ "instance_type", "packer_config", "terraform", - "opentofu" + "opentofu", + "sg_runner" ], "aws_region": { "ui:widget": "select", @@ -59,7 +60,9 @@ "user_script": { "ui:widget": "textarea", "ui:placeholder": "#!/bin/bash\n# Your custom installation script here\n# This runs after StackGuardian setup", - "ui:options": { "rows": 6 }, + "ui:options": { + "rows": 6 + }, "ui:description": "Custom shell script to execute during AMI provisioning. Runs after the standard StackGuardian setup. Use this to install additional tools, configure settings, or customize the environment." } }, @@ -69,6 +72,10 @@ "ui:placeholder": "1.14.1", "ui:description": "Version of HashiCorp Packer to use for building the AMI" }, + "rebuild_ami_token": { + "ui:placeholder": "2026-07-30", + "ui:description": "The AMI is built on the first deployment and reused on every run after that, so no build time is spent and the runner keeps the same image. Change this value to anything new (a date, a version tag) to build a fresh AMI once; leaving it unchanged never rebuilds." + }, "deregistration_protection": { "ui:title": "AMI Deregistration Protection", "ui:description": "Configure protection settings to prevent accidental AMI deletion. Recommended for production AMIs.", @@ -89,7 +96,7 @@ "cleanup_amis_on_destroy": { "ui:widget": "checkbox", "ui:title": "Automatic AMI Cleanup", - "ui:description": "When enabled, AMIs and associated EBS snapshots will be automatically deleted when running 'terraform destroy'. Disable this if you want to preserve AMIs for manual cleanup or if they're used by other resources." + "ui:description": "When enabled (the default), the AMI built by this deployment and its EBS snapshots are deleted on destroy, and the superseded AMI is deleted whenever a rebuild is triggered. AMIs built by other deployments are never touched. Disable this to preserve images for manual cleanup." } }, "terraform": { @@ -117,5 +124,13 @@ "ui:placeholder": "1.8.0" } } + }, + "sg_runner": { + "ui:title": "StackGuardian Runner", + "ui:description": "Choose which sg-runner release is baked into the AMI.", + "pre_release": { + "ui:widget": "checkbox", + "ui:description": "Install the newest sg-runner pre-release instead of the latest stable release. Falls back to the latest stable release when no pre-release is published. Intended for testing upcoming runner changes; keep disabled for production. Changing this on an existing deployment only takes effect once a new AMI is built, so also change the Rebuild AMI Token." + } } } diff --git a/stackguardian_private_runner/aws/packer/scripts/build_ami.sh b/stackguardian_private_runner/aws/packer/scripts/build_ami.sh index 9f27ae1..7dce379 100755 --- a/stackguardian_private_runner/aws/packer/scripts/build_ami.sh +++ b/stackguardian_private_runner/aws/packer/scripts/build_ami.sh @@ -121,6 +121,14 @@ main() { #{{{ -var "deregistration_protection_with_cooldown=$DEREGISTRATION_PROTECTION_WITH_COOLDOWN" \ -machine-readable \ ./ami.pkr.hcl | tee packer_manifest.log + + # tee masks Packer's exit status, so check for the artifact line instead. + # Terraform records the build as done as soon as this script succeeds, so a + # silent failure here would stick until the rebuild token is changed. + if ! grep -q 'artifact,0,id' packer_manifest.log; then + echo "ERROR: Packer build produced no AMI. See the output above." + exit 1 + fi } #}}}: main diff --git a/stackguardian_private_runner/aws/packer/scripts/setup.sh b/stackguardian_private_runner/aws/packer/scripts/setup.sh index 15f6683..95722e3 100755 --- a/stackguardian_private_runner/aws/packer/scripts/setup.sh +++ b/stackguardian_private_runner/aws/packer/scripts/setup.sh @@ -20,9 +20,9 @@ _configure_proxy() { #{{{ export HTTPS_PROXY="$PROXY_URL" # Configure wget proxy - echo "http_proxy = $PROXY_URL" >> ~/.wgetrc - echo "https_proxy = $PROXY_URL" >> ~/.wgetrc - echo "use_proxy = on" >> ~/.wgetrc + echo "http_proxy = $PROXY_URL" >>~/.wgetrc + echo "https_proxy = $PROXY_URL" >>~/.wgetrc + echo "use_proxy = on" >>~/.wgetrc fi } #}}}: _configure_proxy @@ -152,17 +152,17 @@ _detect_arch() { #{{{ machine="$(uname -m)" case "$machine" in - x86_64) echo "amd64" ;; - aarch64) echo "arm64" ;; - armv7l) echo "arm" ;; - i386|i686) echo "386" ;; - *) echo "$machine" ;; + x86_64) echo "amd64" ;; + aarch64) echo "arm64" ;; + armv7l) echo "arm" ;; + i386 | i686) echo "386" ;; + *) echo "$machine" ;; esac } #}}}: _detect_arch _detect_os() { #{{{ - uname -s | tr '[:upper:]' '[:lower:]' + uname -s | tr '[:upper:]' '[:lower:]' } #}}}: _detect_os @@ -171,10 +171,10 @@ _get_latest_github_release() { #{{{ file_name="$2" latest_release_url="https://api.github.com/repos/$repo/releases/latest" - wget -qO- "$latest_release_url" \ - | grep "\"browser_download_url\": \".*/$file_name\"" \ - | tr -d ' "' \ - | grep -o 'https.*' + wget -qO- "$latest_release_url" | + grep "\"browser_download_url\": \".*/$file_name\"" | + tr -d ' "' | + grep -o 'https.*' } #}}}: _get_latest_github_release @@ -334,8 +334,8 @@ _install_sg_runner() { #{{{ fi # Save configuration for sg-runner-update - echo "# StackGuardian Runner configuration" | sudo tee /etc/sg-runner.conf > /dev/null - echo "SG_RUNNER_PRE_RELEASE=${SG_RUNNER_PRE_RELEASE:-false}" | sudo tee -a /etc/sg-runner.conf > /dev/null + echo "# StackGuardian Runner configuration" | sudo tee /etc/sg-runner.conf >/dev/null + echo "SG_RUNNER_PRE_RELEASE=${SG_RUNNER_PRE_RELEASE:-false}" | sudo tee -a /etc/sg-runner.conf >/dev/null echo ">> Saved config to /etc/sg-runner.conf" } #}}}: _install_sg_runner @@ -344,7 +344,7 @@ _install_sg_runner_update() { #{{{ echo "## ----------" echo ">> Installing sg-runner-update script.." - sudo tee /usr/bin/sg-runner-update > /dev/null << 'SCRIPT_EOF' + sudo tee /usr/bin/sg-runner-update >/dev/null <<'SCRIPT_EOF' #!/bin/sh set -e diff --git a/stackguardian_private_runner/aws/packer/terraform.tfvars.tpl b/stackguardian_private_runner/aws/packer/terraform.tfvars.tpl index 8bb74b5..bd63c0a 100644 --- a/stackguardian_private_runner/aws/packer/terraform.tfvars.tpl +++ b/stackguardian_private_runner/aws/packer/terraform.tfvars.tpl @@ -106,12 +106,16 @@ os = { /*----------------------------------+ | Packer Configuration Variables | +----------------------------------*/ +# The AMI is built on the first apply and reused on every following plan. +# To build a new one, change rebuild_ami_token to any new value (a date, a tool +# version, anything). Leaving it unchanged never rebuilds. # packer_config = { -# version = "1.14.1" +# version = "1.14.1" +# rebuild_ami_token = "" # e.g. "2026-07-30" or "tofu-1.11" to force a rebuild # deregistration_protection = { # enabled = true # Enable/disable deregistration protection # with_cooldown = false # Enable/disable cooldown period # } -# delete_snapshots = true # Delete EBS snapshots during AMI cleanup -# cleanup_amis_on_destroy = true # Automatically deregister AMIs on terraform destroy +# delete_snapshots = true # Delete EBS snapshots during AMI cleanup +# cleanup_amis_on_destroy = true # Deregister the built AMI on terraform destroy # } diff --git a/stackguardian_private_runner/aws/packer/variables.tf b/stackguardian_private_runner/aws/packer/variables.tf index 90d39dc..222119e 100644 --- a/stackguardian_private_runner/aws/packer/variables.tf +++ b/stackguardian_private_runner/aws/packer/variables.tf @@ -61,9 +61,14 @@ variable "os" { | Packer Configuration Variables | +----------------------------------*/ variable "packer_config" { - description = "Packer build configuration" + description = < Date: Mon, 24 Aug 2026 11:25:18 +0200 Subject: [PATCH 2/4] fix: resolve console URL from an explicit region map The console URL was derived by stripping "api." out of the API host. That works for app.stackguardian.io and us.stackguardian.io but not for QA, where the API lives at testapi.qa.stackguardian.io and the console at dash.qa.stackguardian.io. The wrong host ended up in runner_group_url and, more importantly, in the S3 bucket's CORS allowed_origins, so browser uploads from the console were rejected. Map API host to console host explicitly instead. The console host is not derivable from the API host in every region, so an unlisted api_uri now fails the plan rather than silently producing a bad origin. --- stackguardian_private_runner/runner_group/locals.tf | 9 +++++++++ stackguardian_private_runner/runner_group/outputs.tf | 2 +- .../runner_group/storage_backend.tf | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/stackguardian_private_runner/runner_group/locals.tf b/stackguardian_private_runner/runner_group/locals.tf index 57a465d..45f9c5f 100644 --- a/stackguardian_private_runner/runner_group/locals.tf +++ b/stackguardian_private_runner/runner_group/locals.tf @@ -18,6 +18,15 @@ locals { ) sg_api_uri = nonsensitive(var.stackguardian.api_uri) + # Web console URL per platform region. Kept as an explicit map because the + # console host is not derivable from the API host in every region. + sg_app_uris = { + "https://api.app.stackguardian.io" = "https://app.stackguardian.io" + "https://api.us.stackguardian.io" = "https://us.stackguardian.io" + "https://testapi.qa.stackguardian.io" = "https://dash.qa.stackguardian.io" + } + sg_app_uri = local.sg_app_uris[local.sg_api_uri] + # Computed prefix with optional org name effective_prefix = ( var.override_names.include_org_in_prefix && local.sg_org_name != "" diff --git a/stackguardian_private_runner/runner_group/outputs.tf b/stackguardian_private_runner/runner_group/outputs.tf index ba8a777..677cf18 100644 --- a/stackguardian_private_runner/runner_group/outputs.tf +++ b/stackguardian_private_runner/runner_group/outputs.tf @@ -19,7 +19,7 @@ output "runner_group_token" { output "runner_group_url" { description = "Direct URL to the runner group in the StackGuardian web console" - value = "${replace(local.sg_api_uri, "api.", "")}/orchestrator/orgs/${local.sg_org_name}/runnergroups/${local.final_runner_group_name}" + value = "${local.sg_app_uri}/orchestrator/orgs/${local.sg_org_name}/runnergroups/${local.final_runner_group_name}" } /*---------------------------------+ diff --git a/stackguardian_private_runner/runner_group/storage_backend.tf b/stackguardian_private_runner/runner_group/storage_backend.tf index 32de987..a89ba4a 100644 --- a/stackguardian_private_runner/runner_group/storage_backend.tf +++ b/stackguardian_private_runner/runner_group/storage_backend.tf @@ -35,7 +35,7 @@ resource "aws_s3_bucket_cors_configuration" "this" { allowed_headers = ["*"] allowed_methods = ["GET", "HEAD", "PUT"] allowed_origins = [ - "${replace(local.sg_api_uri, "api.", "")}" + local.sg_app_uri ] expose_headers = [] } From d187442ff8a146e846f901ba51b1061675134ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 24 Aug 2026 11:25:25 +0200 Subject: [PATCH 3/4] fix: pass the runner group token into single_runner The module looked the runner group up itself through the stackguardian provider data sources, which meant it could only be used against a runner group that already existed, and it re-read the token on every plan. Take runner_group_name and runner_group_token as inputs instead and drop the data sources. The module can now be composed directly with runner_group in one apply, with the token passed in memory as a sensitive value. runner_group_token is required, so add it to the template schemas as well - without it the no-code form offers no field for the token and the apply fails on a missing variable. It renders as a password input and documents the ${secret::NAME} reference form, since the value should not be typed literally. --- .../aws/single_runner/data.tf | 16 ---------------- .../aws/single_runner/ec2.tf | 2 +- .../aws/single_runner/schemas/input_schema.json | 6 ++++++ .../aws/single_runner/schemas/ui_schema.json | 6 ++++++ .../aws/single_runner/variables.tf | 8 +++++++- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/stackguardian_private_runner/aws/single_runner/data.tf b/stackguardian_private_runner/aws/single_runner/data.tf index 722718a..e69de29 100644 --- a/stackguardian_private_runner/aws/single_runner/data.tf +++ b/stackguardian_private_runner/aws/single_runner/data.tf @@ -1,16 +0,0 @@ -# Data sources for fetching runner group configuration - -# Fetch runner group details including S3 bucket name -data "stackguardian_runner_group" "this" { - resource_name = var.runner_group_name -} - -# Fetch runner group token for registration -data "stackguardian_runner_group_token" "this" { - runner_group_id = var.runner_group_name -} - -# Fetch S3 bucket details -data "aws_s3_bucket" "this" { - bucket = data.stackguardian_runner_group.this.storage_backend_config.s3_bucket_name -} diff --git a/stackguardian_private_runner/aws/single_runner/ec2.tf b/stackguardian_private_runner/aws/single_runner/ec2.tf index f3ab4b6..6c6942e 100644 --- a/stackguardian_private_runner/aws/single_runner/ec2.tf +++ b/stackguardian_private_runner/aws/single_runner/ec2.tf @@ -39,7 +39,7 @@ resource "aws_instance" "this" { sg_org_name = local.sg_org_name sg_api_uri = local.sg_api_uri sg_runner_group_name = var.runner_group_name - sg_runner_group_token = data.stackguardian_runner_group_token.this.runner_group_token + sg_runner_group_token = var.runner_group_token sg_runner_startup_timeout = tostring(var.runner_startup_timeout) } ) diff --git a/stackguardian_private_runner/aws/single_runner/schemas/input_schema.json b/stackguardian_private_runner/aws/single_runner/schemas/input_schema.json index 9bc9ca2..c097ee5 100644 --- a/stackguardian_private_runner/aws/single_runner/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/single_runner/schemas/input_schema.json @@ -80,6 +80,11 @@ "title": "Runner Group Name", "type": "string" }, + "runner_group_token": { + "title": "Runner Group Token", + "type": "string", + "minLength": 1 + }, "storage_backend_role_arn": { "title": "Storage Backend Role ARN", "type": "string", @@ -254,6 +259,7 @@ "required": [ "ami_id", "runner_group_name", + "runner_group_token", "storage_backend_role_arn", "stackguardian", "network" diff --git a/stackguardian_private_runner/aws/single_runner/schemas/ui_schema.json b/stackguardian_private_runner/aws/single_runner/schemas/ui_schema.json index ad2bf02..49be6f4 100644 --- a/stackguardian_private_runner/aws/single_runner/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/single_runner/schemas/ui_schema.json @@ -6,6 +6,7 @@ "aws_region", "ami_id", "runner_group_name", + "runner_group_token", "storage_backend_role_arn", "instance_type", "override_names", @@ -43,6 +44,11 @@ "ui:placeholder": "my-runner-group", "ui:description": "Name of the runner group on StackGuardian (from stackguardian_runner_group module)" }, + "runner_group_token": { + "ui:widget": "password", + "ui:placeholder": "${secret::RUNNER_GROUP_TOKEN}", + "ui:description": "Registration token for the runner group (from the stackguardian_runner_group module's runner_group_token output). Sensitive - prefer a secret reference (${secret::SECRET_NAME}) over a literal value." + }, "storage_backend_role_arn": { "ui:placeholder": "arn:aws:iam::***:role/***", "ui:description": "ARN of the IAM role for S3 storage backend access (from stackguardian_runner_group module)" diff --git a/stackguardian_private_runner/aws/single_runner/variables.tf b/stackguardian_private_runner/aws/single_runner/variables.tf index 2f936bd..d396556 100644 --- a/stackguardian_private_runner/aws/single_runner/variables.tf +++ b/stackguardian_private_runner/aws/single_runner/variables.tf @@ -34,10 +34,16 @@ variable "aws_region" { | StackGuardian Runner Group Configuration | +-------------------------------------------*/ variable "runner_group_name" { - description = "The name of the StackGuardian runner group. Token and S3 bucket will be fetched automatically." + description = "The name of the StackGuardian runner group." type = string } +variable "runner_group_token" { + description = "The runner group token for registration (from runner_group module output)" + type = string + sensitive = true +} + variable "storage_backend_role_arn" { description = "The ARN of the IAM role for storage backend access (from stackguardian_runner_group module output)" type = string From feaef263c09ab644cb370b3732a1fafb1445f1e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 24 Aug 2026 11:25:40 +0200 Subject: [PATCH 4/4] docs: add an AWS quickstart example Deploying a runner meant three separate applies with outputs copied by hand between them. examples/aws/quickstart wires runner_group, packer, and single_runner into one root module: fill in the API key, org, VPC, and subnet, apply once, and the runner registers itself. The README covers the AMI-reuse behaviour, the VPC interface endpoint rule that otherwise makes jobs hang on plan with no error, and where the example stops - public subnet only, single runner, local state. Also ignore tfplan and .claude/settings.local.json. The existing pattern was *.tfplan, which does not match a plan file written as plain "tfplan", so a binary plan could be committed by accident. --- stackguardian_private_runner/.gitignore | 3 + stackguardian_private_runner/README.md | 12 +- .../examples/aws/quickstart/README.md | 367 ++++++++++++++++++ .../examples/aws/quickstart/main.tf | 97 +++++ .../examples/aws/quickstart/outputs.tf | 53 +++ .../aws/quickstart/terraform.tfvars.tpl | 107 +++++ .../examples/aws/quickstart/variables.tf | 196 ++++++++++ 7 files changed, 834 insertions(+), 1 deletion(-) create mode 100644 stackguardian_private_runner/examples/aws/quickstart/README.md create mode 100644 stackguardian_private_runner/examples/aws/quickstart/main.tf create mode 100644 stackguardian_private_runner/examples/aws/quickstart/outputs.tf create mode 100644 stackguardian_private_runner/examples/aws/quickstart/terraform.tfvars.tpl create mode 100644 stackguardian_private_runner/examples/aws/quickstart/variables.tf diff --git a/stackguardian_private_runner/.gitignore b/stackguardian_private_runner/.gitignore index 9051ed6..6f9233f 100644 --- a/stackguardian_private_runner/.gitignore +++ b/stackguardian_private_runner/.gitignore @@ -11,11 +11,14 @@ override.tf override.tf.json *_override.tf *_override.tf.json +tfplan +*.tfplan # Generated permissions files *_permissions.json # IDE +.claude/settings.local.json .idea/ *.swp *.swo diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index 6933e5c..1a567bf 100644 --- a/stackguardian_private_runner/README.md +++ b/stackguardian_private_runner/README.md @@ -2,6 +2,11 @@ Deploy auto-scaling StackGuardian Private Runners on AWS with custom AMI creation. +> **Just want a runner running?** [`examples/aws/quickstart/`](examples/aws/quickstart/) +> wires the runner group, AMI build, and a single runner into one root module. Fill in +> four values, apply once, and you have a registered runner - no copying outputs +> between modules. + ## Overview This project provides four templates that work together to create a complete auto-scaling private runner solution: @@ -11,7 +16,7 @@ This project provides four templates that work together to create a complete aut 3. **[Autoscaling Group](aws/autoscaling_group/)** - Deploy auto-scaling EC2 runner instances 4. **[Autoscaler](aws/autoscaler/)** - Lambda-based intelligent scaling based on job queue -**Alternative**: For simpler deployments without auto-scaling, see [Single Runner](aws/single_runner/). +**Alternative**: For simpler deployments without auto-scaling, see [Single Runner](aws/single_runner/), or the ready-made [AWS Quickstart example](examples/aws/quickstart/) that deploys one end to end. ## Complete Deployment Guide @@ -237,6 +242,11 @@ For simpler deployments without auto-scaling, use the [Single Runner](aws/single See [aws/single_runner/README.md](aws/single_runner/README.md) for configuration. +The fastest path is [`examples/aws/quickstart/`](examples/aws/quickstart/), a root +module that combines the runner group, AMI build, and single runner into one apply. +Use the `aws/single_runner` module directly instead when you need a private subnet, +NAT gateway, or proxy - the quickstart deliberately covers the public-subnet case only. + ## Automated Deployment For automated deployments, use a script to deploy all modules: diff --git a/stackguardian_private_runner/examples/aws/quickstart/README.md b/stackguardian_private_runner/examples/aws/quickstart/README.md new file mode 100644 index 0000000..20c4e0d --- /dev/null +++ b/stackguardian_private_runner/examples/aws/quickstart/README.md @@ -0,0 +1,367 @@ +# StackGuardian Private Runner - AWS Quickstart + +Zero to a registered, running Private Runner on AWS in a single `apply`. + +This example wires the three building-block modules together into one root module, +so you configure a handful of values once instead of running three deployments and +hand-copying outputs between them. + +> Deploying a single runner on a **public subnet**. For an autoscaled fleet, use +> the `aws/autoscaling_group` and `aws/autoscaler` modules directly - see the +> [top-level README](../../../README.md). + +## Contents + +- [What Gets Deployed](#what-gets-deployed) +- [Prerequisites](#prerequisites) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [How the AMI Lifecycle Works](#how-the-ami-lifecycle-works) +- [Networking](#networking) +- [Outputs](#outputs) +- [Accessing the Runner](#accessing-the-runner) +- [Day-2 Operations](#day-2-operations) +- [Destroying](#destroying) +- [Troubleshooting](#troubleshooting) +- [Limitations](#limitations) + +## What Gets Deployed + +``` + ┌──────────────────────────────┐ + module.runner_group│ StackGuardian control plane │ + ──────────────────►│ • runner group │ + │ │ • connector │ + │ └──────────────────────────────┘ + │ ┌──────────────────────────────┐ + └───────────►│ AWS (storage backend) │ + │ • S3 bucket + CORS + PAB │ + │ • IAM role (assumed by │ + │ the runner for S3 access) │ + └──────────────────────────────┘ + │ + │ runner_group_name + │ runner_group_token + │ storage_backend_role_arn + ▼ + module.packer ┌──────────────────────────────┐ + ──────────────────►│ Packer build (first apply) │ + │ • temp EC2 build instance │ + │ (auto-terminated) │ + │ • custom AMI: Docker, jq, │ + │ cron, sg-runner, and │ + │ optional Terraform/Tofu │ + └──────────────────────────────┘ + │ + │ ami_id + ▼ + module.single_runner + ┌──────────────────────────────┐ + │ Runner EC2 instance │ + │ • security group │ + │ (egress-all, no inbound │ + │ unless SSH configured) │ + │ • IAM role + instance │ + │ profile (SSM + assume │ + │ the S3 role above) │ + │ • optional EC2 key pair │ + └──────────────────────────────┘ +``` + +The runner registers itself with the StackGuardian platform on first boot using the +runner group token, then starts polling for work. + +## Prerequisites + +| Requirement | Notes | +|-------------|-------| +| **OpenTofu >= 1.6** (or Terraform >= 1.4) | The packer module uses `terraform_data` | +| **Packer** | Installed automatically by the build script at the configured version | +| **AWS credentials** | Via `AWS_PROFILE`, environment variables, or instance role | +| **StackGuardian API key** | Org-scoped key with permission to create runner groups and connectors | +| **VPC** | Existing, with a working outbound internet path | +| **Public subnet** | Used for both the Packer build instance and the runner | + +### AWS Permissions + +The identity running this needs, at minimum: + +- **EC2**: `RunInstances`, `TerminateInstances`, `CreateImage`, `DeregisterImage`, + `DescribeImages`, `DescribeInstances`, `CreateTags`, `ModifyImageAttribute`, + `CreateSnapshot`, `DeleteSnapshot`, plus security-group and key-pair management +- **IAM**: `CreateRole`, `CreatePolicy`, `AttachRolePolicy`, `CreateInstanceProfile`, + `PassRole` +- **S3**: `CreateBucket`, `PutBucketPolicy`, `PutBucketCors`, `PutPublicAccessBlock` +- **STS**: `GetCallerIdentity` + +## Quick Start + +**1. Copy the template and fill it in** + +```bash +cp terraform.tfvars.tpl terraform.tfvars +$EDITOR terraform.tfvars +``` + +At minimum you must set `stackguardian.api_key`, `stackguardian.org_name`, +`vpc_id`, and `public_subnet_id`. + +**2. Initialize** + +```bash +tofu init +``` + +**3. Review the plan** + +```bash +tofu plan -out=tfplan +``` + +**4. Apply** + +```bash +tofu apply tfplan +``` + +The first apply builds the AMI, which dominates the runtime - expect several +minutes before the runner instance itself is created. Later applies skip the build +entirely (see [AMI lifecycle](#how-the-ami-lifecycle-works)). + +**5. Confirm the runner came up** + +```bash +tofu output runner_group_url +``` + +Open that URL; the runner should appear as active in the runner group within a +minute or two of the instance booting. + +## Configuration + +### Required + +| Variable | Type | Description | +|----------|------|-------------| +| `stackguardian.api_key` | `string` | StackGuardian API key (sensitive) | +| `stackguardian.org_name` | `string` | StackGuardian organization name | +| `vpc_id` | `string` | Existing VPC ID | +| `public_subnet_id` | `string` | Public subnet for the build instance and runner | + +### Commonly Adjusted + +| Variable | Default | Description | +|----------|---------|-------------| +| `aws_region` | `eu-central-1` | Region for all AWS resources | +| `stackguardian.api_uri` | `https://api.app.stackguardian.io` | Platform endpoint - see note below | +| `vpc_endpoint_security_group_ids` | `[]` | Interface-endpoint SGs to open on 443 - see [Networking](#networking) | +| `runner_instance_type` | `t3.xlarge` | Runner instance size | +| `packer_instance_type` | `t3.medium` | Build instance size | +| `max_runners` | `3` | Max runners in the runner group | +| `override_names.global_prefix` | `SG_RUNNER` | Prefix for created resource names | +| `runner_startup_timeout` | `300` | Seconds to wait for Docker before self-shutdown | +| `force_destroy_storage_backend` | `false` | If `true`, `destroy` also deletes S3 contents | + +> **`api_uri` must be one of three known values.** The runner group module maps the +> API host to its matching web-console host to build the console URL and the S3 CORS +> origin. Supported values are `https://api.app.stackguardian.io` (EU1), +> `https://api.us.stackguardian.io` (US1), and `https://testapi.qa.stackguardian.io` +> (QA). Any other value fails the plan with a map-lookup error. + +### AMI Contents + +| Variable | Default | Description | +|----------|---------|-------------| +| `os.family` | `amazon` | `amazon`, `ubuntu`, or `rhel` | +| `os.version` | `""` | Required for `ubuntu` / `rhel` | +| `os.update_os_before_install` | `true` | Patch the OS before installing | +| `os.user_script` | `""` | Extra shell run after standard setup | +| `terraform.primary_version` | `""` | Installed as `/bin/terraform` | +| `terraform.additional_versions` | `[]` | Installed as `/bin/terraform` | +| `opentofu.primary_version` | `""` | Installed as `/bin/tofu` | +| `opentofu.additional_versions` | `[]` | Installed as `/bin/tofu` | +| `sg_runner.pre_release` | `false` | Bake the newest sg-runner pre-release instead of latest stable | + +Every one of these is baked into the image at build time, so changing any of them +on an existing deployment has **no effect until you trigger a rebuild**. + +### Full Variable Reference + +See [`variables.tf`](variables.tf) - every variable is documented there, and +[`terraform.tfvars.tpl`](terraform.tfvars.tpl) shows each one with its default. + +## How the AMI Lifecycle Works + +Building an AMI takes minutes, so the packer module builds **once per state** and +reuses what it built: + +| Situation | Result | +|-----------|--------| +| First apply | Packer builds the AMI; its ID is recorded in state | +| Every plan/apply after that | No build, no diff - the ID comes from state | +| `packer_config.rebuild_ami_token` changed | Packer builds a new AMI, once | +| State destroyed and re-applied | Packer builds again | + +To force a fresh build - after changing the OS, `user_script`, tool versions, or +the sg-runner channel: + +```hcl +packer_config = { + version = "1.14.1" + rebuild_ami_token = "2026-08-24-tofu-1.11" # any new value +} +``` + +The token is a free-form string rather than a boolean on purpose: bump it to +rebuild, then leave it alone. A boolean would rebuild again the moment you unset it. + +Because the AMI ID is now stable across applies, **the runner instance is no longer +replaced on every apply**. A rebuild does replace it, since the instance's AMI +changes. + +> `packer_config.cleanup_amis_on_destroy` (default `true`) only ever touches the +> AMI this deployment built - on destroy, and on the rebuild that supersedes it. +> AMIs from other deployments are never deregistered. + +## Networking + +This example places both the Packer build instance and the runner on the **public +subnet** you provide, with a public IP attached, and relies on the subnet's route +to an internet gateway for outbound access. + +The runner's security group allows **all egress** and **no ingress** by default. +SSH is opened only if you set `firewall.ssh_access_rules`. + +### VPC Interface Endpoints + +If your VPC resolves AWS APIs through interface endpoints (STS, EC2, SSM, ECR) +rather than over the internet, you **must** list those endpoints' security groups: + +```hcl +vpc_endpoint_security_group_ids = ["sg-0123456789abcdef0"] +``` + +The module adds an inbound HTTPS (443) rule to each listed security group, sourced +from the runner's own security group. + +Leaving this empty in an endpoint-backed VPC is a quiet failure: the endpoint drops +the runner's traffic, and jobs **hang on plan** rather than returning an error. If +you see runners stuck with no logs, check this first. + +## Outputs + +| Output | Description | +|--------|-------------| +| `runner_group_name` | Name of the created runner group | +| `runner_group_url` | Direct link to the runner group in the web console | +| `connector_name` | Name of the created connector | +| `s3_bucket_name` | S3 bucket backing the runner group's storage | +| `ami_id` | AMI built by Packer and recorded in state | +| `instance_id` | Runner EC2 instance ID | +| `instance_public_ip` | Runner public IP | +| `instance_private_ip` | Runner private IP | +| `security_group_id` | Runner security group ID | + +The runner group token is deliberately **not** exposed as a root output. It is +passed module-to-module in memory and marked sensitive. + +## Accessing the Runner + +**Session Manager (recommended).** The instance role includes +`AmazonSSMManagedInstanceCore`, so no inbound access is needed: + +```bash +aws ssm start-session --target "$(tofu output -raw instance_id)" +``` + +**SSH.** Requires opening the security group first: + +```hcl +firewall = { + ssh_public_key = "ssh-ed25519 AAAA..." + ssh_access_rules = { "my-ip" = "203.0.113.10/32" } +} +``` + +Useful checks once you are on the box: + +```bash +sudo tail -f /var/log/sg_runner_startup.log # registration + startup +sudo tail -f /var/log/cloud-init-output.log # full user-data run +docker ps # job containers +systemctl status docker # runner depends on this +cat /etc/sg-runner.conf # baked-in release channel +``` + +> `sg-runner` is a shell script at `/usr/bin/sg-runner`, not a systemd service. +> It is invoked once from user-data as `sg-runner register ...`, so there is no +> `systemctl status sg-runner` or `journalctl -u sg-runner` to check. + +## Day-2 Operations + +**Update the sg-runner binary in place** (no rebuild, no Terraform): + +```bash +sudo sg-runner-update +``` + +**Change what is baked into the image** - edit `os`, `terraform`, `opentofu`, or +`sg_runner`, then bump `packer_config.rebuild_ami_token` and apply. This replaces +the AMI *and* the runner instance. + +**Resize the runner** - change `runner_instance_type` and apply. No rebuild needed. + +**Test an upcoming runner release:** + +```hcl +sg_runner = { pre_release = true } +packer_config = { version = "1.14.1", rebuild_ami_token = "prerelease-test" } +``` + +Falls back to the latest stable release if no pre-release is published. Not +recommended for production. + +## Destroying + +```bash +tofu destroy +``` + +This deregisters the AMI and deletes its snapshots (unless +`cleanup_amis_on_destroy` or `delete_snapshots` is disabled), removes the runner +group and connector from StackGuardian, and tears down the AWS resources. + +> The S3 bucket is **retained if it still has objects**, unless you set +> `force_destroy_storage_backend = true`. That default is intentional - the bucket +> holds Terraform state for jobs the runner executed. + +## Troubleshooting + +| Symptom | Likely cause | +|---------|--------------| +| Jobs hang on plan, no logs | VPC interface endpoints not listed in `vpc_endpoint_security_group_ids` | +| Plan fails on a map lookup | `stackguardian.api_uri` is not one of the three supported values | +| Packer fails immediately | Subnet has no outbound internet path, or IAM permissions are missing | +| `No AMI recorded` on output | The build produced no AMI - check `../../../aws/packer/packer_manifest.log` | +| Packer never re-runs | Working as designed; bump `rebuild_ami_token` | +| Runner shuts itself down after boot | Docker did not start within `runner_startup_timeout` - user-data calls `shutdown -h now` on timeout | +| Runner never appears in the console | Token or org name wrong; check `/var/log/sg_runner_startup.log` | + +To force a rebuild without touching variables: + +```bash +tofu apply -replace=module.packer.null_resource.packer_build +``` + +## Limitations + +This example trades flexibility for a short path to a working runner: + +- **Public subnet only.** `private_subnet_id`, `create_network_infrastructure` + (NAT gateway), and `proxy_url` are supported by the underlying modules but are + not exposed here. Use `aws/single_runner` directly for private deployments. +- **Single runner.** No autoscaling; `max_runners` caps the runner group, not the + instance count. +- **Local state.** No backend is configured. Add one before using this for anything + you intend to keep. +- **Creates a new runner group.** It does not attach to an existing one. diff --git a/stackguardian_private_runner/examples/aws/quickstart/main.tf b/stackguardian_private_runner/examples/aws/quickstart/main.tf new file mode 100644 index 0000000..e96189e --- /dev/null +++ b/stackguardian_private_runner/examples/aws/quickstart/main.tf @@ -0,0 +1,97 @@ +terraform { + required_providers { + stackguardian = { + source = "registry.terraform.io/StackGuardian/stackguardian" + version = ">= 1.3.3" + } + aws = { + source = "registry.terraform.io/hashicorp/aws" + } + external = { + source = "hashicorp/external" + } + random = { + source = "hashicorp/random" + } + null = { + source = "hashicorp/null" + } + local = { + source = "hashicorp/local" + } + } +} + +# ------------------------------------------------------- +# Module 1: StackGuardian Runner Group +# Creates: runner group, S3 bucket, IAM role, connector +# ------------------------------------------------------- +module "runner_group" { + source = "../../../runner_group" + + stackguardian = var.stackguardian + aws_region = var.aws_region + + override_names = var.override_names + + max_runners = var.max_runners + create_storage_backend = true + force_destroy_storage_backend = var.force_destroy_storage_backend +} + +# ------------------------------------------------------- +# Module 2: Packer AMI Builder +# Builds AMI with sg-runner, Docker, Terraform, etc. +# ------------------------------------------------------- +module "packer" { + source = "../../../aws/packer" + + aws_region = var.aws_region + instance_type = var.packer_instance_type + + network = { + vpc_id = var.vpc_id + public_subnet_id = var.public_subnet_id + } + + os = var.os + packer_config = var.packer_config + terraform = var.terraform + opentofu = var.opentofu + sg_runner = var.sg_runner +} + +# ------------------------------------------------------- +# Module 3: Single Runner EC2 Instance +# Deploys the private runner using the AMI from Packer +# and the runner group config from Module 1 +# ------------------------------------------------------- +module "single_runner" { + source = "../../../aws/single_runner" + + ami_id = module.packer.ami_id + instance_type = var.runner_instance_type + + runner_group_name = module.runner_group.runner_group_name + runner_group_token = module.runner_group.runner_group_token + storage_backend_role_arn = module.runner_group.storage_backend_role_arn + + stackguardian = var.stackguardian + aws_region = var.aws_region + + override_names = { + global_prefix = var.override_names.global_prefix + include_org_in_prefix = var.override_names.include_org_in_prefix + } + + network = { + vpc_id = var.vpc_id + public_subnet_id = var.public_subnet_id + associate_public_ip = true + vpc_endpoint_security_group_ids = var.vpc_endpoint_security_group_ids + } + + volume = var.volume + firewall = var.firewall + runner_startup_timeout = var.runner_startup_timeout +} diff --git a/stackguardian_private_runner/examples/aws/quickstart/outputs.tf b/stackguardian_private_runner/examples/aws/quickstart/outputs.tf new file mode 100644 index 0000000..4acb3dd --- /dev/null +++ b/stackguardian_private_runner/examples/aws/quickstart/outputs.tf @@ -0,0 +1,53 @@ +/*---------------------------------+ + | Runner Group Outputs | + +---------------------------------*/ +output "runner_group_name" { + description = "StackGuardian runner group name" + value = module.runner_group.runner_group_name +} + +output "runner_group_url" { + description = "URL to the runner group in the StackGuardian console" + value = module.runner_group.runner_group_url +} + +output "connector_name" { + description = "StackGuardian connector name" + value = module.runner_group.connector_name +} + +output "s3_bucket_name" { + description = "S3 bucket used for storage backend" + value = module.runner_group.s3_bucket_name +} + +/*---------------------------------+ + | AMI Outputs | + +---------------------------------*/ +output "ami_id" { + description = "AMI ID built by Packer and recorded in state" + value = module.packer.ami_id +} + +/*---------------------------------+ + | Runner Instance Outputs | + +---------------------------------*/ +output "instance_id" { + description = "EC2 instance ID of the private runner" + value = module.single_runner.instance_id +} + +output "instance_public_ip" { + description = "Public IP of the private runner instance" + value = module.single_runner.instance_public_ip +} + +output "instance_private_ip" { + description = "Private IP of the private runner instance" + value = module.single_runner.instance_private_ip +} + +output "security_group_id" { + description = "Security group ID of the private runner" + value = module.single_runner.security_group_id +} diff --git a/stackguardian_private_runner/examples/aws/quickstart/terraform.tfvars.tpl b/stackguardian_private_runner/examples/aws/quickstart/terraform.tfvars.tpl new file mode 100644 index 0000000..c080d3b --- /dev/null +++ b/stackguardian_private_runner/examples/aws/quickstart/terraform.tfvars.tpl @@ -0,0 +1,107 @@ +# ============================================================ +# StackGuardian Private Runner - AWS Quickstart +# ============================================================ +# Copy this file to terraform.tfvars and fill in your values. +# Everything commented out is optional and shown with its default. +# ============================================================ + +# --- Required: StackGuardian credentials --- +stackguardian = { + api_key = "sgu_xxxxxxxxxxxxxxxxxxxxx" # Your SG API key + org_name = "my-org" # Your SG organization name + # api_uri = "https://api.app.stackguardian.io" # EU1 (default) + # api_uri = "https://api.us.stackguardian.io" # US1 +} + +# --- Required: AWS network --- +aws_region = "eu-central-1" +vpc_id = "vpc-0123456789abcdef0" +public_subnet_id = "subnet-0123456789abcdef0" + +# Security groups of any VPC *interface endpoints* the runner must reach +# (STS, EC2, SSM, ECR, ...). The module adds an inbound HTTPS (443) rule to each +# one, allowing traffic from the runner's security group. +# +# Leave empty for a VPC with a normal internet path. If your VPC resolves AWS +# APIs through interface endpoints and you leave this empty, the runner will +# hang on plan with no obvious error - the endpoint silently drops its traffic. +vpc_endpoint_security_group_ids = [] + +# --- Optional: Resource naming --- +# override_names = { +# global_prefix = "SG_RUNNER" +# include_org_in_prefix = false +# runner_group_name = "" # default: "-runner-group-" +# connector_name = "" # default: "-private-runner-backend-" +# } + +# --- Optional: Runner group --- +# max_runners = 3 +# force_destroy_storage_backend = false # true also deletes S3 contents on destroy + +# --- Optional: AMI build --- +# packer_instance_type = "t3.medium" +# +# Packer builds the AMI on the first apply only. Later plans reuse it, so the +# runner keeps the same image. To build a new one, change the token below to +# any new value: +# packer_config = { +# version = "1.14.1" +# rebuild_ami_token = "2026-07-30" +# deregistration_protection = { +# enabled = true +# with_cooldown = false +# } +# delete_snapshots = true +# cleanup_amis_on_destroy = true +# } +# +# os = { +# family = "amazon" # amazon | ubuntu | rhel +# version = "" # required for ubuntu/rhel +# update_os_before_install = true +# ssh_username = "" # auto-detected per family when empty +# user_script = "" # extra shell run after standard setup +# } +# +# terraform = { +# primary_version = "1.9.8" +# additional_versions = ["1.8.5"] +# } +# opentofu = { +# primary_version = "1.8.8" +# } +# +# By default the AMI bakes in the latest stable sg-runner release. Set +# pre_release = true to use the newest pre-release instead (falls back to the +# latest stable when there is none). On an existing deployment, also bump +# packer_config.rebuild_ami_token so a new AMI is actually built. +# sg_runner = { +# pre_release = true +# } + +# --- Optional: Runner instance --- +# runner_instance_type = "t3.xlarge" +# volume = { +# type = "gp3" +# size = 100 +# delete_on_termination = false +# } +# Seconds to wait for Docker to come up before the instance shuts itself down. +# Raise it if a custom user_script makes first boot slow. +# runner_startup_timeout = 300 + +# --- Optional: SSH access --- +# firewall = { +# ssh_public_key = "ssh-ed25519 AAAA..." +# ssh_access_rules = { +# "my-ip" = "203.0.113.10/32" +# } +# additional_ingress_rules = { +# "custom" = { +# port = 8080 +# protocol = "tcp" +# cidr_blocks = ["10.0.0.0/8"] +# } +# } +# } diff --git a/stackguardian_private_runner/examples/aws/quickstart/variables.tf b/stackguardian_private_runner/examples/aws/quickstart/variables.tf new file mode 100644 index 0000000..9c0e75b --- /dev/null +++ b/stackguardian_private_runner/examples/aws/quickstart/variables.tf @@ -0,0 +1,196 @@ +/*-----------------------------------+ + | StackGuardian Platform Variables | + +-----------------------------------*/ +variable "stackguardian" { + description = "StackGuardian platform configuration (api_key, api_uri, org_name)" + type = object({ + api_key = string + api_uri = optional(string, "https://api.app.stackguardian.io") + org_name = optional(string, "") + }) + sensitive = true +} + +/*-------------------+ + | AWS Configuration | + +-------------------*/ +variable "aws_region" { + description = "AWS region for all resources" + type = string + default = "eu-central-1" +} + +/*-------------------+ + | Network Settings | + +-------------------*/ +variable "vpc_id" { + description = "VPC ID where all resources will be deployed" + type = string +} + +variable "public_subnet_id" { + description = "Public subnet ID for Packer builds and runner deployment" + type = string +} + +variable "vpc_endpoint_security_group_ids" { + description = "Security group IDs of VPC interface endpoints (STS, EC2, etc.) that should allow HTTPS from the runner" + type = list(string) + default = [] +} + +/*-------------------+ + | Resource Naming | + +-------------------*/ +variable "override_names" { + description = "Resource naming configuration" + type = object({ + global_prefix = string + include_org_in_prefix = optional(bool, false) + runner_group_name = optional(string, "") + connector_name = optional(string, "") + }) + default = { + global_prefix = "SG_RUNNER" + } +} + +/*---------------------------+ + | Runner Group Settings | + +---------------------------*/ +variable "max_runners" { + description = "Maximum number of runners in the runner group" + type = number + default = 3 +} + +variable "force_destroy_storage_backend" { + description = "Force destroy S3 bucket on terraform destroy (deletes all data)" + type = bool + default = false +} + +/*---------------------------+ + | Packer AMI Settings | + +---------------------------*/ +variable "packer_instance_type" { + description = "EC2 instance type for the Packer build process" + type = string + default = "t3.medium" +} + +variable "os" { + description = "Operating system configuration for the runner AMI" + type = object({ + family = string + version = optional(string, "") + update_os_before_install = optional(bool, false) + ssh_username = optional(string, "") + user_script = optional(string, "") + }) + default = { + family = "amazon" + update_os_before_install = true + } +} + +variable "packer_config" { + description = <