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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions stackguardian_private_runner/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions stackguardian_private_runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -25,6 +30,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
Expand Down Expand Up @@ -205,7 +215,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 |
Expand All @@ -232,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:
Expand Down
3 changes: 2 additions & 1 deletion stackguardian_private_runner/aws/DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | `""` |
Expand Down
10 changes: 9 additions & 1 deletion stackguardian_private_runner/aws/packer/DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand All @@ -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 |

Expand Down
60 changes: 53 additions & 7 deletions stackguardian_private_runner/aws/packer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |

Expand Down
12 changes: 12 additions & 0 deletions stackguardian_private_runner/aws/packer/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
57 changes: 41 additions & 16 deletions stackguardian_private_runner/aws/packer/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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]
}
Loading
Loading