diff --git a/stackguardian_private_runner/.gitignore b/stackguardian_private_runner/.gitignore index 6f9233f..f892b57 100644 --- a/stackguardian_private_runner/.gitignore +++ b/stackguardian_private_runner/.gitignore @@ -12,11 +12,15 @@ override.tf.json *_override.tf *_override.tf.json tfplan +tofuplan *.tfplan # Generated permissions files *_permissions.json +# Generated cleanup dumps +ami_cleanup_info.txt + # IDE .claude/settings.local.json .idea/ diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index 1a567bf..da16b4b 100644 --- a/stackguardian_private_runner/README.md +++ b/stackguardian_private_runner/README.md @@ -1,6 +1,6 @@ # StackGuardian Private Runner -Deploy auto-scaling StackGuardian Private Runners on AWS with custom AMI creation. +Deploy auto-scaling StackGuardian Private Runners on AWS or Azure. > **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 @@ -9,16 +9,25 @@ Deploy auto-scaling StackGuardian Private Runners on AWS with custom AMI creatio ## Overview -This project provides four templates that work together to create a complete auto-scaling private runner solution: +This project provides Terraform modules that work together to create a complete auto-scaling private runner solution on AWS or Azure. + +### AWS 1. **[Packer AMI Builder](aws/packer/)** - Build custom AMIs with pre-installed dependencies -2. **[Runner Group](runner_group/)** - Create StackGuardian Runner Group with S3 storage backend +2. **[Runner Group](aws/runner_group/)** - Create StackGuardian Runner Group with S3 storage backend 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/), or the ready-made [AWS Quickstart example](examples/aws/quickstart/) that deploys one end to end. -## Complete Deployment Guide +### Azure + +1. **[Packer Image Builder](azure/packer/)** - Build custom Azure Managed Images with pre-installed dependencies +2. **[Runner Group](azure/runner_group/)** - Create StackGuardian Runner Group with Azure Blob Storage backend +3. **[Single Runner](azure/azure_runner/)** - Deploy a standalone runner on an Azure Linux VM +4. **[Autoscaler](azure/autoscaler/)** - Azure Function-based intelligent scaling for VM Scale Sets + +## AWS Deployment Guide ### Step 1: Build Custom AMI @@ -55,11 +64,11 @@ echo "AMI ID: $AMI_ID" Navigate to the **Runner Group** module and create the StackGuardian runner group with S3 backend. ```bash -cd ../runner_group/ -# Or from root: cd runner_group/ +cd ../aws/runner_group/ +# Or from root: cd aws/runner_group/ ``` -See [runner_group/README.md](runner_group/README.md) for full configuration options. +See [aws/runner_group/README.md](aws/runner_group/README.md) for full configuration options. **Deploy:** @@ -83,7 +92,7 @@ STORAGE_ROLE_ARN=$(terraform output -raw storage_backend_role_arn) Navigate to the **Autoscaling Group** module and deploy EC2 runner instances. ```bash -cd ../aws/autoscaling_group/ +cd ../autoscaling_group/ # Or from root: cd aws/autoscaling_group/ ``` @@ -196,23 +205,57 @@ Before starting, ensure you have: Each module has its own README with detailed configuration options: +### AWS Modules + +Stack overview: [aws/DOCUMENTATION.md](aws/DOCUMENTATION.md) + | Module | Purpose | Configuration | |--------|---------|---------------| | [aws/packer](aws/packer/) | Build custom AMI | [README](aws/packer/README.md) | -| [runner_group](runner_group/) | Create Runner Group and S3 backend | [README](runner_group/README.md) | +| [aws/runner_group](aws/runner_group/) | Create Runner Group and S3 backend | [README](aws/runner_group/README.md) | +| [aws/single_runner](aws/single_runner/) | Deploy one EC2 runner (no autoscaling) | [README](aws/single_runner/README.md) | | [aws/autoscaling_group](aws/autoscaling_group/) | Deploy EC2 Auto Scaling Group | [README](aws/autoscaling_group/README.md) | | [aws/autoscaler](aws/autoscaler/) | Deploy Lambda autoscaler | [README](aws/autoscaler/README.md) | +### Azure Modules + +Stack overview: [azure/DOCUMENTATION.md](azure/DOCUMENTATION.md) + +| Module | Purpose | Configuration | +|--------|---------|---------------| +| [azure/packer](azure/packer/) | Build custom Azure Managed Image | [README](azure/packer/README.md) | +| [azure/runner_group](azure/runner_group/) | Create Runner Group and Blob Storage backend | [README](azure/runner_group/README.md) | +| [azure/azure_runner](azure/azure_runner/) | Deploy Azure Linux VM runner | [README](azure/azure_runner/README.md) | +| [azure/vmss](azure/vmss/) | Deploy Azure VM Scale Set of runners | [README](azure/vmss/README.md) | +| [azure/autoscaler](azure/autoscaler/) | Deploy Azure Function autoscaler | [README](azure/autoscaler/README.md) | + +### Shared + +| Module | Purpose | Configuration | +|--------|---------|---------------| +| [runner_group](runner_group/) | Cloud-agnostic platform resources, called by both `*/runner_group` wrappers. Not deployed directly. | [README](runner_group/README.md) | + +### Examples + +| Example | Purpose | +|---------|---------| +| [examples/aws/quickstart](examples/aws/quickstart/) | Runner group + AMI build + one EC2 runner in a single apply | +| [examples/azure/quickstart](examples/azure/quickstart/) | Runner group + image build + one Azure VM runner in a single apply | +| [examples/aws/packer](examples/aws/packer/) | Just the AMI build — bake an image once and reuse its `ami_id` | +| [examples/azure/packer](examples/azure/packer/) | Just the image build — bake an image once and reuse its `image_id` | + ### Common Required Parameters | Parameter | Description | Example | |-----------|-------------|---------| -| `aws_region` | Target AWS region | `"eu-central-1"` | +| `aws_region` / `azure_location` | Target cloud region | `"eu-central-1"` / `"westeurope"` | | `stackguardian.api_key` | StackGuardian API key | `"sgu_..."` | -| `network.vpc_id` | Existing VPC ID | `"vpc-12345678"` | +| `network.vpc_id` / `network.vnet_id` | Existing network ID | `"vpc-12345678"` / `"/subscriptions/..."` | ## Key Outputs +### AWS Outputs + | Template | Output | Description | Usage | |----------|--------|-------------|-------| | Packer | `ami_id` | Built AMI identifier, recorded in state | Input for Autoscaling Group | @@ -222,17 +265,30 @@ Each module has its own README with detailed configuration options: | Autoscaling Group | `autoscaling_group_name` | ASG name | Input for Autoscaler | | Autoscaler | `lambda_function_name` | Lambda function name | Monitoring | +### Azure Outputs + +| Template | Output | Description | Usage | +|----------|--------|-------------|-------| +| Packer | `image_id` | Created Azure Managed Image ID | Input for Azure Runner | +| Azure Runner | `vm_id` | Azure VM identifier | Monitoring | +| Azure Runner | `vm_private_ip` | Runner private IP address | Connectivity | +| Autoscaler | `function_app_name` | Azure Function App name | Monitoring | +| Autoscaler | `storage_account_name` | Storage Account name | Monitoring | + ## Architecture Benefits -- **Performance**: Pre-built AMI reduces job startup time by 60-80% +- **Performance**: Pre-built images reduce job startup time by 60-80% - **Scalability**: Auto-scaling based on job queue depth with configurable thresholds -- **Security**: Encrypted storage, least-privilege IAM, and configurable network access +- **Security**: Encrypted storage, least-privilege access, and configurable network access - **Cost Management**: Scale to zero when idle, with automatic cleanup options -- **Reliability**: Multi-AZ deployment with health checks and auto-recovery +- **Reliability**: Health checks and auto-recovery across both cloud providers ## Alternative: Single Runner -For simpler deployments without auto-scaling, use the [Single Runner](aws/single_runner/) module. +For simpler deployments without auto-scaling: + +- **AWS**: Use the [AWS Single Runner](aws/single_runner/) module. See [README](aws/single_runner/README.md). +- **Azure**: Use the [Azure Single Runner](azure/azure_runner/) module. See [README](azure/azure_runner/README.md). **When to use:** @@ -240,20 +296,20 @@ For simpler deployments without auto-scaling, use the [Single Runner](aws/single - Low-volume workflow execution - Simpler infrastructure requirements -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 +For AWS, 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: +### AWS + +For automated AWS deployments, use a script to deploy all modules: ```bash #!/bin/bash -# Deploy complete private runner infrastructure +# Deploy complete private runner infrastructure (AWS) set -e @@ -263,7 +319,7 @@ terraform init && terraform apply -auto-approve AMI_ID=$(terraform output -raw ami_id) # Step 2: Create Runner Group -cd ../../runner_group/ +cd ../runner_group/ terraform init && terraform apply -auto-approve RUNNER_GROUP_NAME=$(terraform output -raw runner_group_name) RUNNER_GROUP_TOKEN=$(terraform output -raw runner_group_token) @@ -271,7 +327,7 @@ S3_BUCKET_NAME=$(terraform output -raw s3_bucket_name) STORAGE_ROLE_ARN=$(terraform output -raw storage_backend_role_arn) # Step 3: Deploy Autoscaling Group -cd ../aws/autoscaling_group/ +cd ../autoscaling_group/ terraform init terraform apply -auto-approve \ -var="ami_id=$AMI_ID" \ @@ -289,6 +345,222 @@ terraform apply -auto-approve \ -var="runner_group_name=$RUNNER_GROUP_NAME" \ -var="s3_bucket_name=$S3_BUCKET_NAME" -echo "Deployment complete!" +echo "AWS Deployment complete!" +echo "Runner Group: $RUNNER_GROUP_NAME" +``` + +### Azure + +For automated Azure deployments: + +```bash +#!/bin/bash +# Deploy complete private runner infrastructure (Azure) + +set -e + +# Step 1: Build Custom Image +cd azure/packer/ +terraform init && terraform apply -auto-approve +IMAGE_ID=$(terraform output -raw image_id) + +# Step 2: Create Runner Group +cd ../runner_group/ +terraform init && terraform apply -auto-approve +RUNNER_GROUP_NAME=$(terraform output -raw runner_group_name) +RUNNER_GROUP_TOKEN=$(terraform output -raw runner_group_token) +RESOURCE_GROUP_NAME=$(terraform output -raw azure_resource_group_name) + +# Step 3: Deploy Azure Runner +# storage_backend_identity_id is a User-Assigned Managed Identity you create +# yourself and grant "Storage Blob Data Contributor" on the storage account; +# see examples/azure/quickstart for a worked version. +cd ../azure_runner/ +terraform init +terraform apply -auto-approve \ + -var="vm_image_id=$IMAGE_ID" \ + -var="runner_group_name=$RUNNER_GROUP_NAME" \ + -var="runner_group_token=$RUNNER_GROUP_TOKEN" \ + -var="storage_backend_identity_id=$STORAGE_BACKEND_IDENTITY_ID" + +# Step 4: Deploy Autoscaler +cd ../autoscaler/ +terraform init +terraform apply -auto-approve + +echo "Azure Deployment complete!" echo "Runner Group: $RUNNER_GROUP_NAME" ``` + +--- + +## Azure Deployment Guide + +### Prerequisites + +Before starting Azure deployment, ensure you have: + +1. **StackGuardian Account**: API key (`sgo_*` or `sgu_*`) +2. **Azure Subscription**: With Contributor permissions +3. **Azure CLI**: Authenticated (`az login`) +4. **Network Infrastructure**: Existing VNet with subnet and internet access (or let modules create new ones) +5. **Local Tools**: Terraform >= 1.0 installed + +### Step 1: Build Custom Image + +Navigate to the **Packer** module and create an optimized Azure Managed Image. + +```bash +cd azure/packer/ +``` + +See [azure/packer/README.md](azure/packer/README.md) for full configuration options. + +**Deploy:** + +```bash +terraform init +terraform plan +terraform apply +``` + +**Save outputs for Step 3:** + +```bash +IMAGE_ID=$(terraform output -raw image_id) +echo "Image ID: $IMAGE_ID" +``` + +### Step 2: Create Runner Group + +Navigate to the **Runner Group** module and create the StackGuardian runner group. + +```bash +cd ../runner_group/ +# Or from root: cd azure/runner_group/ +``` + +See [azure/runner_group/README.md](azure/runner_group/README.md) for full configuration options. + +**Deploy:** + +```bash +terraform init +terraform plan +terraform apply +``` + +**Save outputs for Steps 3 and 4:** + +```bash +RUNNER_GROUP_NAME=$(terraform output -raw runner_group_name) +RUNNER_GROUP_TOKEN=$(terraform output -raw runner_group_token) +RESOURCE_GROUP_NAME=$(terraform output -raw azure_resource_group_name) +STORAGE_ACCOUNT_ID=$(terraform output -raw azure_storage_account_id) +``` + +### Step 3: Deploy Azure Runner + +Navigate to the **Azure Runner** module and deploy the runner VM. + +```bash +cd ../azure_runner/ +# Or from root: cd azure/azure_runner/ +``` + +See [azure/azure_runner/README.md](azure/azure_runner/README.md) for full configuration options. + +**Configure with outputs from Steps 1 and 2:** + +```hcl +vm_image_id = "/subscriptions/.../images/sg-runner-ubuntu" # From Step 1 +runner_group_name = "your-runner-group" # From Step 2 +runner_group_token = "your-token" # From Step 2 +storage_backend_identity_id = "/subscriptions/.../userAssignedIdentities/..." # See note below +``` + +> **Managed identity:** the runner group module does not create the identity the VM +> uses to reach the storage account. Create a User-Assigned Managed Identity and grant it +> `Storage Blob Data Contributor` scoped to `azure_storage_account_id` from Step 2 — see +> [examples/azure/quickstart](examples/azure/quickstart/) for a worked version. + +**Deploy:** + +```bash +terraform init +terraform plan +terraform apply +``` + +### Step 4: Deploy Azure Autoscaler + +Navigate to the **Autoscaler** module and deploy intelligent scaling. + +```bash +cd ../autoscaler/ +# Or from root: cd azure/autoscaler/ +``` + +See [azure/autoscaler/README.md](azure/autoscaler/README.md) for full configuration options, including manual Azure CLI setup. + +**Configure with outputs from Step 2:** + +```hcl +resource_group_name = "my-resource-group" +azure_location = "westeurope" + +vmss = { + name = "my-runner-vmss" + resource_group_name = "vmss-resource-group" +} + +stackguardian = { + api_key = "sgu_xxxxxxxxxxxx" + org_name = "my-org" +} + +override_names = { + global_prefix = "sg-runner" + runner_group_name = "your-runner-group" # From Step 2 +} +``` + +**Deploy:** + +```bash +terraform init +terraform plan +terraform apply +``` + +### Step 5: Configure Workflows + +Use the runner group in your StackGuardian workflows: + +```yaml +# In your StackGuardian workflow +runner_constraints: + runner_group: +``` + +### What Gets Created (Azure) + +#### Packer Image Builder + +- **Azure Managed Image**: Pre-configured with Docker, Terraform, OpenTofu, and sg-runner +- **Multi-OS Support**: Ubuntu LTS and RHEL compatibility +- **Tool Installation**: Configurable versions of infrastructure tools + +#### Azure Runner + +- **Linux Virtual Machine**: Runner instance with configurable size and storage +- **Network Security Group**: Configurable inbound rules with full outbound access +- **SSH Key Pair**: Auto-generated or user-provided +- **Network Infrastructure**: Optional VNet and subnet creation + +#### Azure Autoscaler + +- **Function App**: FlexConsumption plan with Python 3.11 runtime +- **Storage Account**: For function state and autoscaler timestamps +- **Application Insights**: Monitoring and logging +- **Role Assignments**: Managed identity with VMSS and storage access diff --git a/stackguardian_private_runner/aws/DOCUMENTATION.md b/stackguardian_private_runner/aws/DOCUMENTATION.md index 8c88d51..a4666b3 100644 --- a/stackguardian_private_runner/aws/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/DOCUMENTATION.md @@ -39,6 +39,7 @@ Build a custom AMI for StackGuardian Private Runner with pre-installed dependenc | Parameter | Description | Default | |-----------|-------------|---------| +| existing_ami_id | Reuse an AMI you already have; nothing is built and every other parameter here is ignored | `""` | | instance_type | EC2 instance type for the Packer build process | `t3.medium` | | network.private_subnet_id | Private subnet ID for the build instance | `""` | | network.public_subnet_id | Public subnet ID for the build instance | `""` | @@ -54,6 +55,7 @@ Build a custom AMI for StackGuardian Private Runner with pre-installed dependenc | 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 | Deregister this deployment's AMI on terraform destroy | `true` | +| ami_name_prefix | Prefix of the generated AMI name | `SG-RUNNER-ami` | | terraform.primary_version | Primary Terraform version to install | `""` | | terraform.additional_versions | Additional Terraform versions to install | `[]` | | opentofu.primary_version | Primary OpenTofu version to install | `""` | @@ -70,7 +72,7 @@ Build a custom AMI for StackGuardian Private Runner with pre-installed dependenc ## Template 2: Runner Group -Create a StackGuardian Runner Group with S3 storage backend and AWS connector. +Create a StackGuardian Runner Group with S3 storage backend and AWS connector. This is the `aws/runner_group/` template. It requires only the AWS provider — the platform-side resources it shares with the Azure stack live in the internal, cloud-agnostic `runner_group/` module it calls, so an AWS deployment never initializes `azurerm`/`azuread`. ### Required Parameters @@ -88,12 +90,15 @@ Create a StackGuardian Runner Group with S3 storage backend and AWS connector. | create_storage_backend | Whether to create a new S3 bucket | `true` | | existing_s3_bucket_name | Existing S3 bucket name (when create_storage_backend is false) | - | | force_destroy_storage_backend | Force destroy S3 bucket on module destruction | `false` | -| override_names.global_prefix | Prefix for naming all resources | `SG_RUNNER` | -| override_names.include_org_in_prefix | Append organization name to prefix | `false` | -| override_names.runner_group_name | Override the runner group name | (auto-generated) | -| override_names.connector_name | Override the connector name | (auto-generated) | +| 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; the full name is `{prefix}-{name}` | (6-char random) | +| override_names.connector_name | Name half of the connector | (the runner group's name) | | max_runners | Maximum number of runners allowed | `3` | +The AWS account ID is not part of these names — it is one of the tags applied to the +runner group and the connector, alongside `StackGuardian Private Runner`, +`Managed by IaC`, `aws`, the prefix, and the region. + ### Outputs | Output | Description | @@ -103,6 +108,7 @@ Create a StackGuardian Runner Group with S3 storage backend and AWS connector. | s3_bucket_name | Name of the S3 bucket for storage backend | | storage_backend_role_arn | ARN of the IAM role for S3 access | | connector_name | Name of the StackGuardian connector | +| connector_external_id | External ID enforced by the IAM role trust policy | --- diff --git a/stackguardian_private_runner/aws/autoscaler/DOCUMENTATION.md b/stackguardian_private_runner/aws/autoscaler/DOCUMENTATION.md index ee7c896..8b277de 100644 --- a/stackguardian_private_runner/aws/autoscaler/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/autoscaler/DOCUMENTATION.md @@ -8,8 +8,8 @@ This template creates an intelligent autoscaling system that monitors your Stack ### What This Template Creates -- **Lambda Function** that checks job queue status every minute and scales runners accordingly -- **EventBridge Scheduler** that triggers the scaling check on a regular interval +- **Lambda Function** that checks job queue status and scales runners accordingly +- **EventBridge Scheduler** that triggers the scaling check on a configurable interval (every minute by default) - **IAM Roles** with permissions to manage Auto Scaling Groups and access S3 - **CloudWatch Logs** for monitoring and troubleshooting @@ -44,21 +44,25 @@ Before using this template, you need: | Global Prefix | Prefix for naming all resources created by this module | SG_RUNNER | | Include Org in Prefix | When enabled, appends organization name to the global prefix | Disabled | | Minimum Runners | Minimum number of runners to maintain | 1 | +| Maximum Runners | Maximum number of runners the autoscaler can provision | 3 | +| Desired Runners | Optional initial capacity. Leave empty to let the autoscaler choose between minimum and maximum on first run | (empty) | | Scale Out Threshold | Number of queued jobs to trigger scale-out | 3 | | Scale In Threshold | Number of queued jobs below which to trigger scale-in | 1 | | Scale Out Step | Number of instances to add when scaling out | 1 | | Scale In Step | Number of instances to remove when scaling in | 1 | | Scale Out Cooldown (minutes) | Minutes to wait after scale-out before scaling again (minimum: 4) | 4 | | Scale In Cooldown (minutes) | Minutes to wait after scale-in before scaling again | 5 | +| Schedule Expression | EventBridge Scheduler expression that drives how often the autoscaler Lambda runs | rate(1 minute) | | Python Runtime | Python runtime version for the Lambda function | python3.11 | | Timeout (seconds) | Timeout in seconds for the Lambda function | 60 | | Memory Size (MB) | Memory size in MB for the Lambda function | 128 | | Repository URL | Git repository URL containing the autoscaler Lambda source code | https://github.com/StackGuardian/sg-runner-autoscaler | | Branch | Git branch to use for the autoscaler Lambda source code | main | +| Additional Tags | Extra tags merged into every taggable resource created by this template | (none) | ## Important Notes -**Scaling Behavior**: The autoscaler checks your job queue every minute. When more than 3 jobs are queued (default), it adds runners. When fewer than 2 jobs are queued, it removes runners down to the minimum size. Cooldown periods prevent rapid scaling fluctuations. +**Scaling Behavior**: The autoscaler checks your job queue on the configured schedule (every minute by default). When more than 3 jobs are queued (default), it adds runners up to the maximum. When fewer than 2 jobs are queued, it removes runners down to the minimum size. Cooldown periods prevent rapid scaling fluctuations. **Dependencies**: This template requires outputs from the Runner Group and Autoscaling Group templates. Deploy those first and use their outputs as inputs to this template. @@ -66,6 +70,8 @@ Before using this template, you need: **Runner Types**: Choose "External" for private runners or "Shared External" for managed shared runners. This determines which queue metric is used for scaling decisions. +**Tagging**: The Lambda function, CloudWatch log group and IAM roles/policies are tagged with a purpose marker, the global prefix and a resource name. Anything set under Additional Tags is merged on top. AWS does not support tags on EventBridge Scheduler schedules, so that resource is untagged. + ## Outputs | Output | Description | @@ -74,10 +80,12 @@ Before using this template, you need: | Lambda Function ARN | The ARN for referencing the Lambda function | | Scheduler Name | The name of the EventBridge Scheduler | | Log Group Name | CloudWatch Log Group for viewing autoscaler logs | +| Lambda Role ARN | ARN of the Lambda execution role | +| Scheduler ARN | ARN of the EventBridge Scheduler | ## Security Features - API keys are stored securely as Lambda environment variables (encrypted at rest) -- IAM roles follow the principle of least privilege +- IAM roles follow the principle of least privilege: scaling permissions are restricted to the target Auto Scaling Group, S3 access to the runner group bucket and log writes to this template's own log group. Only the read-only Describe calls, which AWS does not allow to be resource-scoped, remain account-wide - CloudWatch logs are retained for 14 days for audit purposes - No customer VPC configuration required - Lambda runs in AWS-managed infrastructure diff --git a/stackguardian_private_runner/aws/autoscaler/README.md b/stackguardian_private_runner/aws/autoscaler/README.md index 5fd3a48..af254ab 100644 --- a/stackguardian_private_runner/aws/autoscaler/README.md +++ b/stackguardian_private_runner/aws/autoscaler/README.md @@ -1,18 +1,22 @@ # StackGuardian Runner Autoscaler - AWS Module +> Part of [StackGuardian Private Runner](../../README.md) — [AWS stack overview](../DOCUMENTATION.md) · [platform template doc](DOCUMENTATION.md) + This Terraform module deploys a Lambda-based autoscaler that monitors StackGuardian job queues and automatically scales an Auto Scaling Group up or down based on workload demand. ## Overview -The autoscaler module provides intelligent scaling for StackGuardian Private Runners by monitoring job queue depth and adjusting the number of runner instances accordingly. It runs as a serverless Lambda function triggered every minute by EventBridge Scheduler. +The autoscaler module provides intelligent scaling for StackGuardian Private Runners by monitoring job queue depth and adjusting the number of runner instances accordingly. It runs as a serverless Lambda function triggered by EventBridge Scheduler on a configurable schedule (every minute by default). ### What Gets Created - **Lambda Function**: Python-based autoscaler that queries StackGuardian API for queue status -- **EventBridge Scheduler**: Triggers the Lambda function every minute +- **EventBridge Scheduler**: Triggers the Lambda function on the configured schedule (every minute by default) - **IAM Roles & Policies**: Execution roles for Lambda and EventBridge Scheduler - **CloudWatch Log Group**: Stores Lambda execution logs with 14-day retention +All taggable resources are tagged with a `purpose` / `prefix` marker plus any tags supplied via `tags`. + ## Prerequisites Before deploying this module, you need: @@ -93,17 +97,21 @@ module "autoscaler" { | `override_names.global_prefix` | Prefix for resource naming | `SG_RUNNER` | | `override_names.include_org_in_prefix` | Append org name to prefix | `false` | | `scaling.min_size` | Minimum number of runners | `1` | +| `scaling.max_runners` | Maximum number of runners the autoscaler may provision | `3` | +| `scaling.desired_runners` | Optional initial capacity; `null` lets the autoscaler decide | `null` | | `scaling.scale_out_threshold` | Queued jobs to trigger scale-out | `3` | | `scaling.scale_in_threshold` | Queued jobs to trigger scale-in | `1` | | `scaling.scale_out_step` | Instances to add when scaling out | `1` | | `scaling.scale_in_step` | Instances to remove when scaling in | `1` | | `scaling.scale_out_cooldown_duration` | Minutes after scale-out before scaling again (min: 4) | `4` | | `scaling.scale_in_cooldown_duration` | Minutes after scale-in before scaling again | `5` | +| `scaling.schedule_expression` | EventBridge Scheduler expression driving how often the Lambda runs | `rate(1 minute)` | | `lambda_config.runtime` | Python runtime version | `python3.11` | | `lambda_config.timeout` | Lambda timeout in seconds | `60` | | `lambda_config.memory_size` | Lambda memory in MB | `128` | | `autoscaler_repo.url` | Git repository URL for Lambda source | `https://github.com/StackGuardian/sg-runner-autoscaler` | | `autoscaler_repo.branch` | Git branch for Lambda source | `main` | +| `tags` | Additional tags merged into every taggable resource | `{}` | ### Configuration Examples @@ -148,12 +156,15 @@ module "autoscaler" { scaling = { min_size = 2 + max_runners = 10 + desired_runners = 3 scale_out_threshold = 5 scale_in_threshold = 2 scale_out_step = 2 scale_in_step = 1 scale_out_cooldown_duration = 5 scale_in_cooldown_duration = 10 + schedule_expression = "rate(2 minutes)" } lambda_config = { @@ -161,6 +172,11 @@ module "autoscaler" { timeout = 120 memory_size = 256 } + + tags = { + environment = "production" + owner = "platform-team" + } } ``` @@ -184,9 +200,9 @@ terraform apply ### Auto-scaling Behavior -The autoscaler operates on a 1-minute cycle: +The autoscaler runs on the cadence set by `scaling.schedule_expression` (1-minute cycle by default): -1. **Scale-out**: When queued jobs >= `scale_out_threshold`, adds `scale_out_step` instances +1. **Scale-out**: When queued jobs >= `scale_out_threshold`, adds `scale_out_step` instances (up to `max_runners`) 2. **Scale-in**: When queued jobs < `scale_in_threshold`, removes `scale_in_step` instances (down to `min_size`) 3. **Cooldown**: After scaling, waits the configured cooldown duration before scaling again @@ -230,6 +246,17 @@ Examples with default prefix `SG_RUNNER`: With `include_org_in_prefix = true` and `org_name = "demo"`: - Lambda: `SG_RUNNER_demo-autoscale-private-runner` +### Resource Tagging + +Every taggable resource (Lambda function, CloudWatch log group, IAM roles and policies) receives: + +- `purpose = "stackguardian-private-runner"` +- `prefix = {global_prefix}` +- `Name = {resource name}` +- any key/value pairs supplied through `tags` + +EventBridge Scheduler schedules are not taggable in AWS, so `aws_scheduler_schedule` carries no tags. + ## Troubleshooting ### Common Issues @@ -276,7 +303,7 @@ aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names {asg_nam ## Security Considerations - **API Key Protection**: The StackGuardian API key is stored as a Lambda environment variable (encrypted at rest) -- **IAM Least Privilege**: Lambda role has minimal permissions for S3, ASG, EC2, and CloudWatch +- **IAM Least Privilege**: The Lambda role is scoped per resource wherever AWS allows it - `s3:GetObject`/`s3:PutObject` to the runner group bucket, `autoscaling:SetDesiredCapacity`/`autoscaling:SetInstanceProtection` to this module's ASG, and the `logs:*` actions to this module's own log group. `autoscaling:DescribeAutoScalingGroups` and `ec2:DescribeInstances` remain on `Resource = "*"` because AWS does not support resource-level permissions for those Describe actions - **Network Security**: Lambda runs in AWS-managed VPC (no customer VPC configuration required) - **Log Retention**: CloudWatch logs are retained for 14 days @@ -284,7 +311,7 @@ aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names {asg_nam | Name | Version | |------|---------| -| terraform | >= 1.0 | +| terraform | >= 1.4 | | aws | >= 4.0 | | null | >= 3.0 | | archive | >= 2.0 | diff --git a/stackguardian_private_runner/aws/autoscaler/iam.tf b/stackguardian_private_runner/aws/autoscaler/iam.tf index 2d9910e..bd15354 100644 --- a/stackguardian_private_runner/aws/autoscaler/iam.tf +++ b/stackguardian_private_runner/aws/autoscaler/iam.tf @@ -18,6 +18,10 @@ resource "aws_iam_role" "lambda" { } ] }) + + tags = merge(local.common_tags, { + Name = "${local.effective_prefix}-autoscale-lambda-role" + }) } # IAM Policy for Lambda Autoscaling Function @@ -40,24 +44,31 @@ resource "aws_iam_policy" "lambda" { ] }, { + # Mutating calls support resource-level permissions, so they are + # scoped to this module's Auto Scaling Group only Sid = "AutoScalingAccess" Effect = "Allow" Action = [ "autoscaling:SetDesiredCapacity", - "autoscaling:SetInstanceProtection", - "autoscaling:DescribeAutoScalingGroups" + "autoscaling:SetInstanceProtection" ] - Resource = "*" + Resource = local.asg_arn }, { - Sid = "EC2Access" + # autoscaling:DescribeAutoScalingGroups and ec2:DescribeInstances do + # not support resource-level permissions; AWS rejects any resource + # other than "*" for them, so they stay unscoped by necessity + Sid = "DescribeAccess" Effect = "Allow" Action = [ + "autoscaling:DescribeAutoScalingGroups", "ec2:DescribeInstances" ] Resource = "*" }, { + # Both the log group itself (CreateLogGroup) and its streams + # (CreateLogStream, PutLogEvents) have to be listed Sid = "CloudWatchLogs" Effect = "Allow" Action = [ @@ -65,10 +76,17 @@ resource "aws_iam_policy" "lambda" { "logs:CreateLogStream", "logs:PutLogEvents" ] - Resource = "*" + Resource = [ + aws_cloudwatch_log_group.autoscaler.arn, + "${aws_cloudwatch_log_group.autoscaler.arn}:*" + ] } ] }) + + tags = merge(local.common_tags, { + Name = "${local.effective_prefix}-autoscale-lambda-policy" + }) } resource "aws_iam_role_policy_attachment" "lambda" { @@ -96,6 +114,10 @@ resource "aws_iam_role" "scheduler" { } ] }) + + tags = merge(local.common_tags, { + Name = "${local.effective_prefix}-scheduler-execution-role" + }) } # IAM Policy for EventBridge Scheduler @@ -115,6 +137,10 @@ resource "aws_iam_policy" "scheduler" { } ] }) + + tags = merge(local.common_tags, { + Name = "${local.effective_prefix}-scheduler-execution-policy" + }) } resource "aws_iam_role_policy_attachment" "scheduler" { diff --git a/stackguardian_private_runner/aws/autoscaler/lambda.tf b/stackguardian_private_runner/aws/autoscaler/lambda.tf index ffd529a..0c24c37 100644 --- a/stackguardian_private_runner/aws/autoscaler/lambda.tf +++ b/stackguardian_private_runner/aws/autoscaler/lambda.tf @@ -24,6 +24,8 @@ resource "aws_lambda_function" "autoscaler" { SCALE_OUT_STEP = tostring(var.scaling.scale_out_step) SCALE_IN_STEP = tostring(var.scaling.scale_in_step) MIN_RUNNERS = tostring(var.scaling.min_size) + MAX_RUNNERS = tostring(var.scaling.max_runners) + DESIRED_RUNNERS = var.scaling.desired_runners == null ? "" : tostring(var.scaling.desired_runners) SG_BASE_URI = local.sg_api_uri SG_API_KEY = var.stackguardian.api_key SG_ORG = var.stackguardian.org_name @@ -43,10 +45,18 @@ resource "aws_lambda_function" "autoscaler" { lifecycle { replace_triggered_by = [terraform_data.build_lambda] } + + tags = merge(local.common_tags, { + Name = local.lambda_function_name + }) } # CloudWatch Log Group for Lambda resource "aws_cloudwatch_log_group" "autoscaler" { name = local.log_group_name retention_in_days = 14 + + tags = merge(local.common_tags, { + Name = local.log_group_name + }) } diff --git a/stackguardian_private_runner/aws/autoscaler/locals.tf b/stackguardian_private_runner/aws/autoscaler/locals.tf index daf7cf7..5f5fbb1 100644 --- a/stackguardian_private_runner/aws/autoscaler/locals.tf +++ b/stackguardian_private_runner/aws/autoscaler/locals.tf @@ -7,6 +7,10 @@ data "external" "env" { ] } +# Account id used to build the ARNs referenced by the Lambda IAM policy +# (the region comes from var.aws_region, which also configures the provider) +data "aws_caller_identity" "current" {} + locals { # StackGuardian configuration - use provided values or extract from environment # Use nonsensitive() for non-secret fields to prevent sensitivity propagation @@ -24,6 +28,15 @@ locals { : var.override_names.global_prefix ) + # Common tags applied to every taggable resource, plus user supplied extras + common_tags = merge( + { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + }, + var.tags + ) + # Lambda build directory and zip path lambda_build_dir = "${path.module}/.lambda_build" lambda_zip_path = "${local.lambda_build_dir}/lambda.zip" @@ -33,4 +46,9 @@ locals { # CloudWatch log group name log_group_name = "/aws/lambda/${local.lambda_function_name}" + + # ARN of the Auto Scaling Group the autoscaler is allowed to scale. + # ASG ARNs embed a service generated UUID that is not known before the group + # exists, so the UUID segment is wildcarded and the group name is pinned. + asg_arn = "arn:aws:autoscaling:${var.aws_region}:${data.aws_caller_identity.current.account_id}:autoScalingGroup:*:autoScalingGroupName/${var.asg_name}" } diff --git a/stackguardian_private_runner/aws/autoscaler/provider.tf b/stackguardian_private_runner/aws/autoscaler/provider.tf index f7f8ac8..4b475af 100644 --- a/stackguardian_private_runner/aws/autoscaler/provider.tf +++ b/stackguardian_private_runner/aws/autoscaler/provider.tf @@ -1,4 +1,7 @@ terraform { + # terraform_data (lambda_build.tf) requires 1.4 or newer + required_version = ">= 1.4" + required_providers { aws = { source = "hashicorp/aws" diff --git a/stackguardian_private_runner/aws/autoscaler/scheduler.tf b/stackguardian_private_runner/aws/autoscaler/scheduler.tf index ff193fc..ef61f81 100644 --- a/stackguardian_private_runner/aws/autoscaler/scheduler.tf +++ b/stackguardian_private_runner/aws/autoscaler/scheduler.tf @@ -1,4 +1,5 @@ -# EventBridge Scheduler Schedule for triggering Lambda every minute +# EventBridge Scheduler Schedule for triggering the autoscaler Lambda +# (aws_scheduler_schedule does not support tags; only schedule groups do) resource "aws_scheduler_schedule" "autoscaler" { name = "${local.effective_prefix}-autoscale-trigger" group_name = "default" @@ -7,7 +8,7 @@ resource "aws_scheduler_schedule" "autoscaler" { mode = "OFF" } - schedule_expression = "rate(1 minute)" + schedule_expression = var.scaling.schedule_expression target { arn = aws_lambda_function.autoscaler.arn diff --git a/stackguardian_private_runner/aws/autoscaler/schemas/input_schema.json b/stackguardian_private_runner/aws/autoscaler/schemas/input_schema.json index 4ba904b..a40816a 100644 --- a/stackguardian_private_runner/aws/autoscaler/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/autoscaler/schemas/input_schema.json @@ -1,6 +1,5 @@ { "type": "object", - "additionalProperties": false, "properties": { "stackguardian": { "title": "StackGuardian Configuration", @@ -118,6 +117,17 @@ "default": 1, "minimum": 1 }, + "max_runners": { + "title": "Maximum Runners", + "type": "integer", + "default": 3, + "minimum": 1 + }, + "desired_runners": { + "title": "Desired Runners", + "type": ["integer", "null"], + "default": null + }, "scale_out_threshold": { "title": "Scale Out Threshold", "type": "integer", @@ -148,6 +158,11 @@ "title": "Scale In Cooldown (minutes)", "type": "integer", "default": 5 + }, + "schedule_expression": { + "title": "Schedule Expression", + "type": "string", + "default": "rate(1 minute)" } }, "additionalProperties": false @@ -197,6 +212,14 @@ } }, "additionalProperties": false + }, + "tags": { + "title": "Additional Tags", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} } }, "required": ["stackguardian", "asg_name", "runner_group_name", "s3_bucket_name"] diff --git a/stackguardian_private_runner/aws/autoscaler/schemas/ui_schema.json b/stackguardian_private_runner/aws/autoscaler/schemas/ui_schema.json index 7599af0..be58d8b 100644 --- a/stackguardian_private_runner/aws/autoscaler/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/autoscaler/schemas/ui_schema.json @@ -11,7 +11,8 @@ "scaling", "override_names", "lambda_config", - "autoscaler_repo" + "autoscaler_repo", + "tags" ], "stackguardian": { "ui:title": "StackGuardian Configuration", @@ -56,16 +57,25 @@ "ui:description": "Configure auto-scaling thresholds and behavior", "ui:order": [ "min_size", + "max_runners", + "desired_runners", "scale_out_threshold", "scale_in_threshold", "scale_out_step", "scale_in_step", "scale_out_cooldown_duration", - "scale_in_cooldown_duration" + "scale_in_cooldown_duration", + "schedule_expression" ], "min_size": { "ui:description": "Minimum number of runners to maintain" }, + "max_runners": { + "ui:description": "Maximum number of runners the autoscaler can provision" + }, + "desired_runners": { + "ui:description": "Optional initial capacity. Leave empty to let the autoscaler choose between minimum and maximum on first run" + }, "scale_out_threshold": { "ui:description": "Number of queued jobs to trigger scale-out" }, @@ -83,6 +93,10 @@ }, "scale_in_cooldown_duration": { "ui:description": "Minutes to wait after scale-in before scaling again" + }, + "schedule_expression": { + "ui:placeholder": "rate(1 minute)", + "ui:description": "EventBridge Scheduler expression that drives how often the autoscaler Lambda runs, e.g. rate(1 minute) or cron(0/5 * * * ? *)" } }, "override_names": { @@ -126,5 +140,9 @@ "ui:placeholder": "main", "ui:description": "Git branch to use for the autoscaler Lambda source code" } + }, + "tags": { + "ui:title": "Additional Tags", + "ui:description": "Extra tags merged into every taggable resource created by this module (optional)" } } diff --git a/stackguardian_private_runner/aws/autoscaler/variables.tf b/stackguardian_private_runner/aws/autoscaler/variables.tf index 638d54e..f60f0a4 100644 --- a/stackguardian_private_runner/aws/autoscaler/variables.tf +++ b/stackguardian_private_runner/aws/autoscaler/variables.tf @@ -90,19 +90,42 @@ variable "override_names" { } } +/*-------------------+ + | Resource Tagging | + +-------------------*/ +variable "tags" { + description = "Additional tags applied to every taggable resource created by this module" + type = map(string) + default = {} +} + /*-----------------------------------+ | Scaling Configuration | +-----------------------------------*/ variable "scaling" { - description = "Auto scaling thresholds and behavior configuration" + description = <= 4 error_message = "scale_out_cooldown_duration must be at least 4 minutes." } + + validation { + condition = var.scaling.max_runners >= var.scaling.min_size + error_message = "max_runners must be greater than or equal to min_size." + } + + validation { + condition = ( + var.scaling.desired_runners == null || + (var.scaling.desired_runners >= var.scaling.min_size && var.scaling.desired_runners <= var.scaling.max_runners) + ) + error_message = "desired_runners must be between min_size and max_runners (inclusive)." + } + + validation { + condition = var.scaling.schedule_expression != "" + error_message = "schedule_expression must not be empty." + } } /*-----------------------------------+ diff --git a/stackguardian_private_runner/aws/autoscaling_group/DOCUMENTATION.md b/stackguardian_private_runner/aws/autoscaling_group/DOCUMENTATION.md index 1d983a8..c43ad74 100644 --- a/stackguardian_private_runner/aws/autoscaling_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/autoscaling_group/DOCUMENTATION.md @@ -76,7 +76,7 @@ This template creates an automatically scaling group of EC2 instances that run S **VPC Endpoints**: For fully private deployments without NAT Gateway, you can use VPC endpoints. Provide the security group IDs of your VPC endpoints (STS, SSM, ECR, etc.) in the "VPC Endpoint Security Group IDs" field. The template will add inbound rules to allow HTTPS traffic from the runners. -**Proxy Support**: If your network requires HTTP proxy for outbound connections, configure the "Proxy URL" field with your proxy address (e.g., http://proxy.example.com:8080). +**Proxy Support**: If your network requires HTTP proxy for outbound connections, configure the "Proxy URL" field with your proxy address (e.g., http://proxy.example.com:8080). Each instance exports it as `HTTP_PROXY`/`HTTPS_PROXY` at boot, before it registers with StackGuardian. Leave it empty and no proxy is configured. **Scaling Behavior**: This template creates the ASG with static capacity limits. For dynamic queue-based scaling, deploy the companion `autoscaler` template which monitors your job queue and adjusts capacity automatically. @@ -90,6 +90,10 @@ This template creates an automatically scaling group of EC2 instances that run S | Security Group ID | ID of the runner security group | | IAM Role ARN | ARN of the EC2 instance role | | NAT Gateway Public IP | Public IP of NAT Gateway (if created) | +| NAT Gateway ID | ID of the NAT Gateway (only when Create Network Infrastructure is enabled) | +| IAM Instance Profile Name | Name of the IAM instance profile (only when the ASG is created) | +| Launch Template ID | ID of the Launch Template (only when the ASG is created) | +| Launch Template Latest Version | Latest version number of the Launch Template | ## Security Features diff --git a/stackguardian_private_runner/aws/autoscaling_group/README.md b/stackguardian_private_runner/aws/autoscaling_group/README.md index dac8926..aef176b 100644 --- a/stackguardian_private_runner/aws/autoscaling_group/README.md +++ b/stackguardian_private_runner/aws/autoscaling_group/README.md @@ -1,5 +1,7 @@ # StackGuardian Autoscaled Private Runner - AWS Module +> Part of [StackGuardian Private Runner](../../README.md) — [AWS stack overview](../DOCUMENTATION.md) · [platform template doc](DOCUMENTATION.md) + This Terraform module deploys an Auto Scaling Group of StackGuardian Private Runners on AWS EC2. It creates the infrastructure (ASG, Launch Template, networking) for running Private Runners. For dynamic queue-based scaling, deploy this module together with the `autoscaler` module. ## Overview @@ -100,7 +102,9 @@ module "autoscaling_runner" { | `network.public_subnet_id` | Public subnet ID (required for NAT Gateway) | `""` | | `network.associate_public_ip` | Assign public IPs to instances | `false` | | `network.create_network_infrastructure` | Create NAT Gateway and route tables | `false` | +| `network.proxy_url` | HTTP proxy URL for private networks | `""` | | `network.additional_security_group_ids` | Additional security groups to attach | `[]` | +| `network.vpc_endpoint_security_group_ids` | VPC endpoint security groups (adds inbound 443 rule) | `[]` | | `volume.type` | EBS volume type | `gp3` | | `volume.size` | EBS volume size in GB | `100` | | `volume.delete_on_termination` | Delete volume on instance termination | `false` | @@ -312,7 +316,7 @@ aws autoscaling describe-scaling-activities \ | Name | Version | |------|---------| -| terraform | >= 1.0 | +| terraform | >= 1.3.0 | | aws | >= 4.0 | | external | >= 2.0 | diff --git a/stackguardian_private_runner/aws/autoscaling_group/autoscaling.tf b/stackguardian_private_runner/aws/autoscaling_group/autoscaling.tf index c5365ab..a1797ee 100644 --- a/stackguardian_private_runner/aws/autoscaling_group/autoscaling.tf +++ b/stackguardian_private_runner/aws/autoscaling_group/autoscaling.tf @@ -63,6 +63,7 @@ resource "aws_launch_template" "this" { sg_runner_group_name = var.runner_group_name sg_runner_group_token = var.runner_group_token sg_runner_startup_timeout = tostring(var.runner_startup_timeout) + proxy_url = var.network.proxy_url } ) ) diff --git a/stackguardian_private_runner/aws/autoscaling_group/provider.tf b/stackguardian_private_runner/aws/autoscaling_group/provider.tf index f585deb..20de7dd 100644 --- a/stackguardian_private_runner/aws/autoscaling_group/provider.tf +++ b/stackguardian_private_runner/aws/autoscaling_group/provider.tf @@ -1,4 +1,8 @@ terraform { + # optional() attributes with defaults (var.network, var.scaling, var.firewall) + # need 1.3+ + required_version = ">= 1.3.0" + required_providers { aws = { source = "hashicorp/aws" diff --git a/stackguardian_private_runner/aws/autoscaling_group/schemas/input_schema.json b/stackguardian_private_runner/aws/autoscaling_group/schemas/input_schema.json index f96da18..a910694 100644 --- a/stackguardian_private_runner/aws/autoscaling_group/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/autoscaling_group/schemas/input_schema.json @@ -1,6 +1,5 @@ { "type": "object", - "additionalProperties": false, "properties": { "stackguardian": { "title": "StackGuardian Configuration", diff --git a/stackguardian_private_runner/aws/autoscaling_group/templates/register_runner.sh.tpl b/stackguardian_private_runner/aws/autoscaling_group/templates/register_runner.sh.tpl index 8de4271..deff561 100644 --- a/stackguardian_private_runner/aws/autoscaling_group/templates/register_runner.sh.tpl +++ b/stackguardian_private_runner/aws/autoscaling_group/templates/register_runner.sh.tpl @@ -4,6 +4,16 @@ set -e startup_log_file="/tmp/sg_runner_startup.log" +## Configure HTTP/HTTPS proxy if provided (for private network deployments) +proxy_url="${proxy_url}" +if [ -n "$proxy_url" ]; then + echo ">> Configuring proxy: $proxy_url" | tee -a "$startup_log_file" + export HTTP_PROXY="$proxy_url" + export HTTPS_PROXY="$proxy_url" + export http_proxy="$proxy_url" + export https_proxy="$proxy_url" +fi + ## Mount the additional EBS volume to /var ## The volume is attached as the second device (typically nvme1n1 or xvdf) echo ">> Setting up additional EBS volume for /var" | tee -a "$startup_log_file" diff --git a/stackguardian_private_runner/aws/packer/DOCUMENTATION.md b/stackguardian_private_runner/aws/packer/DOCUMENTATION.md index 82d8c66..b679e9e 100644 --- a/stackguardian_private_runner/aws/packer/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/packer/DOCUMENTATION.md @@ -36,6 +36,7 @@ Before deploying this template: | Parameter | Description | Default | |-----------|-------------|---------| +| Existing AMI ID | Hand back an AMI you already have instead of building one. When set, nothing is built and every other parameter below is ignored | Empty | | Instance Type | EC2 instance type for the Packer build process (minimum 2 vCPU, 4GB RAM recommended) | `t3.medium` | | OS Family | Base operating system: Amazon Linux 2, Ubuntu, or RHEL | Amazon Linux 2 | | OS Version | Specific OS version (required for Ubuntu/RHEL, e.g., "22.04" or "9.6") | Empty | @@ -44,10 +45,11 @@ Before deploying this template: | 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 | -| Automatic AMI Cleanup | Auto-cleanup AMI on stack destroy | Enabled | +| Deregistration Protection - Enable Protection | Prevent accidental AMI deletion through AWS console or API | Enabled | +| Deregistration Protection - With Cooldown | 24-hour waiting period before allowing deregistration | Disabled | +| Delete Snapshots | Delete EBS snapshots during cleanup | Enabled | +| Cleanup AMIs on Destroy | Auto-cleanup AMI on stack destroy | Enabled | +| AMI Name Prefix | Prefix of the generated AMI name; the full name is `{prefix}-{os_family}{os_version}-{timestamp}` | `SG-RUNNER-ami` | | Primary Terraform Version | Main Terraform version to install as `/bin/terraform` | Empty | | Additional Terraform Versions | Extra Terraform versions (installed as `/bin/terraform{version}`) | Empty | | Primary OpenTofu Version | Main OpenTofu version to install as `/bin/tofu` | Empty | @@ -67,9 +69,11 @@ Before deploying this template: **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. +**Skipping the Build**: Set *Existing AMI ID* to reuse an AMI you already have. The template then creates nothing at all — no build instance, no Packer download, no cleanup on destroy — and reports that AMI as its `ami_id` output. The AMI must live in the selected region and already carry Docker, cron, jq and sg-runner; nothing is checked before the runner tries to boot from it. Set it on a fresh deployment: adding it to one that already built an AMI tears down the build records, and the cleanup deregisters the AMI that was built. + **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. +**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. To preview what a cleanup would remove without changing anything, run `scripts/cleanup_amis.sh` yourself with `DRY_RUN=true` — every destructive call is printed instead of executed. ## Outputs diff --git a/stackguardian_private_runner/aws/packer/README.md b/stackguardian_private_runner/aws/packer/README.md index 1df415d..b42c30d 100644 --- a/stackguardian_private_runner/aws/packer/README.md +++ b/stackguardian_private_runner/aws/packer/README.md @@ -1,5 +1,7 @@ # StackGuardian Private Runner - Packer AMI Builder (AWS) +> Part of [StackGuardian Private Runner](../../README.md) — [AWS stack overview](../DOCUMENTATION.md) · [platform template doc](DOCUMENTATION.md) + Build custom Amazon Machine Images (AMIs) for StackGuardian Private Runner deployments with pre-installed dependencies and configurable tooling. ## Overview @@ -96,6 +98,7 @@ module "packer_ami" { | Parameter | Description | Default | |-----------|-------------|---------| +| `existing_ami_id` | Existing AMI to hand back instead of building one - see [Skipping the build](#skipping-the-build) | `""` | | `aws_region` | AWS region for AMI creation | `eu-central-1` | | `instance_type` | EC2 instance type for build process | `t3.medium` | | `os.family` | Operating system family (`amazon`, `ubuntu`, `rhel`) | `amazon` | @@ -109,6 +112,7 @@ module "packer_ami" { | `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` | Deregister this deployment's AMI on terraform destroy | `true` | +| `ami_name_prefix` | Prefix for the generated AMI name | `"SG-RUNNER-ami"` | | `terraform.primary_version` | Primary Terraform version to install | `""` | | `terraform.additional_versions` | Additional Terraform versions | `[]` | | `opentofu.primary_version` | Primary OpenTofu version to install | `""` | @@ -149,6 +153,39 @@ replaced on every apply either. > supersedes it. AMIs belonging to other deployments are never deregistered, since > the module never adopts an AMI it did not build. +### Skipping the Build + +Set `existing_ami_id` to an AMI you already have and this module builds nothing: + +```hcl +module "packer" { + source = "../../aws/packer" + existing_ami_id = "ami-0123456789abcdef0" + + aws_region = "eu-central-1" + network = { + vpc_id = "vpc-0123456789abcdef0" + public_subnet_id = "subnet-0123456789abcdef0" + } +} +``` + +No build instance, no Packer download, no destroy-time cleanup — the module +creates no resources at all, and `ami_id` returns the value you passed. Every +other build input is ignored. It exists so a caller can wire +`module.packer.ami_id` into an instance once and choose per deployment whether an +AMI gets built; the module call itself cannot be `count`-ed, because it declares +its own provider. + +The AMI is used as-is: it has to live in `aws_region` and carry docker, cron, jq +and sg-runner. Nothing is validated at plan time. + +> **Note:** `existing_ami_id` is meant for a fresh state. Adding it to a state +> that already built an AMI destroys the build records, and the destroy-time +> cleanup deregisters the built AMI — including when that is the AMI you are +> passing in. Run `tofu state rm null_resource.ami_cleanup[0]` first if that is +> what you are doing. + ### Configuration Examples #### Basic Configuration (Amazon Linux 2) @@ -284,11 +321,29 @@ terraform apply -var="ami_id=$AMI_ID" ### Cleanup +See [TERRAFORM_DESTROY_GUIDE.md](TERRAFORM_DESTROY_GUIDE.md) for the full destroy +walkthrough, including why AMIs survive `destroy` by default and how deregistration +protection interacts with cleanup. + ```bash # Destroy and cleanup AMI (if cleanup_amis_on_destroy = true) terraform destroy ``` +To preview exactly what the cleanup would deregister and delete without touching +anything, run the script directly with `DRY_RUN=true`: + +```bash +DRY_RUN=true \ + TARGET_AMI_ID="$(terraform output -raw ami_id)" \ + REGION="us-east-1" \ + sh ./scripts/cleanup_amis.sh +``` + +Every destructive call — disabling deregistration protection, deregistering the +AMI, and deleting its snapshots — is printed as `[dry-run] aws ec2 ...` instead of +being executed. + For manual cleanup when deregistration protection is enabled: ```bash @@ -319,9 +374,9 @@ terraform output -json cleanup_commands | jq -r '.delete_snapshots' | `locals.tf` | AMI selection mappings, SSH username configuration | | `provider.tf` | AWS and utility provider configuration | | `ami.pkr.hcl` | Packer template for AMI creation | -| `scripts/build_ami.sh` | Shell script to execute Packer | -| `scripts/setup.sh` | AMI provisioning script | -| `scripts/cleanup_amis.sh` | AMI cleanup automation | +| `../../packer/scripts/build.sh` | Shared: installs Packer and runs the build | +| `../../packer/scripts/setup.sh` | Shared: image provisioning script | +| `scripts/cleanup_amis.sh` | AMI cleanup automation (AWS-specific) | ### Build Flow @@ -337,13 +392,13 @@ terraform apply | rebuild_ami_token changes | | | v - | scripts/build_ami.sh + | ../../packer/scripts/build.sh | | | v | ami.pkr.hcl (Packer template) | | | v - | scripts/setup.sh (on EC2) + | ../../packer/scripts/setup.sh (on EC2) | v [Parse AMI ID] --> data.external.packer_ami_id (reads packer_manifest.log) @@ -438,7 +493,7 @@ terraform apply | Name | Version | |------|---------| -| terraform | >= 1.0 | +| terraform | >= 1.4.0 | | aws | >= 4.0 | | null | >= 3.0 | | external | >= 2.0 | diff --git a/stackguardian_private_runner/aws/packer/TERRAFORM_DESTROY_GUIDE.md b/stackguardian_private_runner/aws/packer/TERRAFORM_DESTROY_GUIDE.md index e14d84d..25bafea 100644 --- a/stackguardian_private_runner/aws/packer/TERRAFORM_DESTROY_GUIDE.md +++ b/stackguardian_private_runner/aws/packer/TERRAFORM_DESTROY_GUIDE.md @@ -35,14 +35,21 @@ The destroy process will: ### Option 1: Use the Automated Cleanup Script ```bash -./scripts/cleanup_amis.sh +TARGET_AMI_ID="ami-0123456789abcdef0" REGION="us-east-1" ./scripts/cleanup_amis.sh ``` This script will: -- List all SG-RUNNER AMIs -- Optionally deregister selected AMIs -- Delete associated EBS snapshots +- Disable deregistration protection on the target AMI (unless it has a cooldown) +- Deregister the AMI named by `TARGET_AMI_ID` +- Delete its associated EBS snapshots (set `DELETE_SNAPSHOTS=false` to keep them) + +Set `DRY_RUN=true` to preview the cleanup without changing anything — each +destructive call is printed as `[dry-run] aws ec2 ...` instead of being executed: + +```bash +DRY_RUN=true TARGET_AMI_ID="ami-0123456789abcdef0" REGION="us-east-1" ./scripts/cleanup_amis.sh +``` ### Option 2: Manual AWS CLI Cleanup diff --git a/stackguardian_private_runner/aws/packer/ami.pkr.hcl b/stackguardian_private_runner/aws/packer/ami.pkr.hcl index 034e17e..72cc89d 100644 --- a/stackguardian_private_runner/aws/packer/ami.pkr.hcl +++ b/stackguardian_private_runner/aws/packer/ami.pkr.hcl @@ -1,3 +1,4 @@ +variable "ami_name_prefix" { default = "SG-RUNNER-ami" } variable "base_ami" {} variable "os_family" {} variable "os_version" {} @@ -11,7 +12,7 @@ variable "terraform_version" {} variable "terraform_versions" {} variable "opentofu_version" {} variable "opentofu_versions" {} -variable "sg_runner_pre_release" {} +variable "sg_runner_pre_release" { default = "false" } variable "user_script" {} variable "vpc_id" {} variable "deregistration_protection_enabled" {} @@ -27,7 +28,7 @@ packer { } source "amazon-ebs" "this" { - ami_name = "SG-RUNNER-ami-${var.os_family}${var.os_version}-{{timestamp}}" + ami_name = "${var.ami_name_prefix}-${var.os_family}${var.os_version}-{{timestamp}}" ami_description = < natively, so these reach ami.pkr.hcl + # without the build script having to know the per-cloud variable list. + PKR_VAR_base_ami = data.aws_ami.this[0].id + PKR_VAR_ami_name_prefix = var.ami_name_prefix + PKR_VAR_os_family = var.os.family + PKR_VAR_os_version = var.os.family != "amazon" ? var.os.version : "" + PKR_VAR_update_os_before_install = var.os.update_os_before_install + PKR_VAR_region = var.aws_region + PKR_VAR_ssh_username = local.ssh_usernames[var.os.family] + PKR_VAR_public_subnet_id = var.network.public_subnet_id + PKR_VAR_private_subnet_id = var.network.private_subnet_id + PKR_VAR_proxy_url = var.network.proxy_url + PKR_VAR_user_script = var.os.user_script + PKR_VAR_terraform_version = var.terraform.primary_version + PKR_VAR_terraform_versions = join(" ", var.terraform.additional_versions) + PKR_VAR_opentofu_version = var.opentofu.primary_version + PKR_VAR_opentofu_versions = join(" ", var.opentofu.additional_versions) + PKR_VAR_sg_runner_pre_release = var.sg_runner.pre_release + PKR_VAR_vpc_id = var.network.vpc_id + PKR_VAR_deregistration_protection_enabled = var.packer_config.deregistration_protection.enabled + PKR_VAR_deregistration_protection_with_cooldown = var.packer_config.deregistration_protection.with_cooldown } } @@ -63,6 +73,8 @@ resource "null_resource" "packer_build" { # 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" { + count = local.build_ami ? 1 : 0 + program = [ "sh", "-c", @@ -78,11 +90,13 @@ data "external" "packer_ami_id" { # 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"] + count = local.build_ami ? 1 : 0 + + input = data.external.packer_ami_id[0].result["ami_id"] lifecycle { ignore_changes = [input] - replace_triggered_by = [null_resource.packer_build] + replace_triggered_by = [null_resource.packer_build[0]] } } @@ -90,8 +104,10 @@ resource "terraform_data" "ami_id" { # # 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. +# Never runs for an AMI passed in through existing_ami_id: the module did not +# build it, so it has no business deregistering it. resource "null_resource" "ami_cleanup" { - count = var.packer_config.cleanup_amis_on_destroy ? 1 : 0 + count = local.build_ami && var.packer_config.cleanup_amis_on_destroy ? 1 : 0 # Store AMI information as triggers so they're available during destroy triggers = { @@ -112,3 +128,16 @@ resource "null_resource" "ami_cleanup" { } } } + +# The build resources gained a count when existing_ami_id was introduced. These +# keep a state written before that from re-keying, which would otherwise destroy +# and rebuild the AMI on the next apply. +moved { + from = null_resource.packer_build + to = null_resource.packer_build[0] +} + +moved { + from = terraform_data.ami_id + to = terraform_data.ami_id[0] +} diff --git a/stackguardian_private_runner/aws/packer/schemas/input_schema.json b/stackguardian_private_runner/aws/packer/schemas/input_schema.json index f681d30..265d858 100644 --- a/stackguardian_private_runner/aws/packer/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/packer/schemas/input_schema.json @@ -1,7 +1,12 @@ { "type": "object", - "additionalProperties": false, "properties": { + "existing_ami_id": { + "title": "Existing AMI ID", + "type": "string", + "default": "", + "pattern": "^$|^ami-.*$" + }, "aws_region": { "title": "AWS Region", "type": "string", @@ -229,6 +234,12 @@ }, "additionalProperties": false }, + "ami_name_prefix": { + "title": "AMI Name Prefix", + "type": "string", + "default": "SG-RUNNER-ami", + "minLength": 1 + }, "terraform": { "title": "Terraform Installation", "type": "object", diff --git a/stackguardian_private_runner/aws/packer/schemas/ui_schema.json b/stackguardian_private_runner/aws/packer/schemas/ui_schema.json index a69959c..3f8fa47 100644 --- a/stackguardian_private_runner/aws/packer/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/packer/schemas/ui_schema.json @@ -2,15 +2,21 @@ "ui:title": "StackGuardian Private Runner - Packer AMI Builder", "ui:description": "Build a custom AMI for StackGuardian Private Runner with pre-installed dependencies including Docker, Terraform, OpenTofu, and StackGuardian runner components. This AMI can then be used with the AWS deployment template for optimal performance.", "ui:order": [ + "existing_ami_id", "aws_region", "network", "os", "instance_type", "packer_config", + "ami_name_prefix", "terraform", "opentofu", "sg_runner" ], + "existing_ami_id": { + "ui:placeholder": "ami-***", + "ui:description": "Hand back an AMI you already have instead of building one. Leave empty to run the Packer build. When it is set nothing is built - no build instance, no Packer download, no cleanup on destroy - and every other setting here is ignored. The AMI must live in the selected region and already carry Docker, cron, jq and sg-runner." + }, "aws_region": { "ui:widget": "select", "ui:description": "The AWS region where the AMI will be built" @@ -132,5 +138,9 @@ "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." } + }, + "ami_name_prefix": { + "ui:placeholder": "SG-RUNNER-ami", + "ui:description": "Prefix of the generated AMI name. The default matches AMIs built by earlier versions." } } diff --git a/stackguardian_private_runner/aws/packer/scripts/cleanup_amis.sh b/stackguardian_private_runner/aws/packer/scripts/cleanup_amis.sh index cbba4a6..40c0521 100755 --- a/stackguardian_private_runner/aws/packer/scripts/cleanup_amis.sh +++ b/stackguardian_private_runner/aws/packer/scripts/cleanup_amis.sh @@ -1,10 +1,27 @@ #!/bin/sh +# Cleanup helper for the AMI produced by this Packer build. +# +# Targets ONLY the AMI whose ID is passed in via TARGET_AMI_ID (sourced from the +# Terraform state on `terraform destroy`). Will not enumerate or delete other +# AMIs. +# +# Optional env: +# TARGET_AMI_ID AMI ID to deregister. Nothing happens when unset. +# REGION AWS region. Falls back to AWS_DEFAULT_REGION, then to +# the CLI profile, then to us-east-1. +# DELETE_SNAPSHOTS=true Also delete the AMI's backing EBS snapshots (default). +# DRY_RUN=true Print actions without executing them. set -e AWS_EXECUTABLE="" WORKING_DIR="" +_dry_run() { #{{{ + [ "${DRY_RUN:-false}" = "true" ] +} +#}}}: _dry_run + _detect_arch() { #{{{ machine="$(uname -m)" @@ -182,6 +199,11 @@ _disable_ami_protection() { #{{{ ami_id="$1" region="$2" + if _dry_run; then + echo ">> [dry-run] aws ec2 disable-image-deregistration-protection --region $region --image-id $ami_id" + return 0 + fi + echo ">> Disabling deregistration protection for AMI: $ami_id" if $AWS_EXECUTABLE ec2 disable-image-deregistration-protection --region "$region" --image-id "$ami_id" 2>/dev/null; then echo ">> ✓ Deregistration protection disabled" @@ -193,6 +215,44 @@ _disable_ami_protection() { #{{{ } #}}}: _disable_ami_protection +_deregister_ami() { #{{{ + ami_id="$1" + region="$2" + + if _dry_run; then + echo ">> [dry-run] aws ec2 deregister-image --region $region --image-id $ami_id" + return 0 + fi + + echo ">> Deregistering AMI: $ami_id" + if $AWS_EXECUTABLE ec2 deregister-image --region "$region" --image-id "$ami_id" 2>/dev/null; then + echo ">> ✓ AMI deregistered successfully" + return 0 + fi + + echo ">> ✗ Failed to deregister AMI: $ami_id" + return 1 +} +#}}}: _deregister_ami + +_delete_snapshot() { #{{{ + snapshot_id="$1" + region="$2" + + if _dry_run; then + echo ">> [dry-run] aws ec2 delete-snapshot --region $region --snapshot-id $snapshot_id" + return 0 + fi + + echo ">> Deleting snapshot: $snapshot_id" + if $AWS_EXECUTABLE ec2 delete-snapshot --region "$region" --snapshot-id "$snapshot_id" 2>/dev/null; then + echo ">> ✓ Snapshot deleted successfully" + else + echo ">> ✗ Failed to delete snapshot: $snapshot_id" + fi +} +#}}}: _delete_snapshot + _cleanup_target_ami() { #{{{ ami_id="$1" region="$2" @@ -230,7 +290,9 @@ _cleanup_ami() { #{{{ if [ "$protection_enabled" != "disabled" ]; then echo ">> ⚠️ AMI has deregistration protection enabled" - echo ">> 🚨 Automatic cleanup enabled - attempting to disable protection" + if ! _dry_run; then + echo ">> 🚨 Automatic cleanup enabled - attempting to disable protection" + fi if ! _disable_ami_protection "$ami_id" "$region"; then echo ">> ✗ Cannot proceed with cleanup - protection disable failed" @@ -261,19 +323,11 @@ _cleanup_ami() { #{{{ --output text 2>/dev/null || echo "") fi - echo ">> Deregistering AMI: $ami_id" - if $AWS_EXECUTABLE ec2 deregister-image --region "$region" --image-id "$ami_id" 2>/dev/null; then - echo ">> ✓ AMI deregistered successfully" - + if _deregister_ami "$ami_id" "$region"; then if [ "$delete_snapshots_flag" = "true" ]; then if [ -n "$snapshots" ] && [ "$snapshots" != "None" ]; then for snapshot_id in $snapshots; do - echo ">> Deleting snapshot: $snapshot_id" - if $AWS_EXECUTABLE ec2 delete-snapshot --region "$region" --snapshot-id "$snapshot_id" 2>/dev/null; then - echo ">> ✓ Snapshot deleted successfully" - else - echo ">> ✗ Failed to delete snapshot: $snapshot_id" - fi + _delete_snapshot "$snapshot_id" "$region" done else echo ">> No snapshots found for this AMI" @@ -282,7 +336,6 @@ _cleanup_ami() { #{{{ echo ">> Skipping snapshot deletion (delete_snapshots=false)" fi else - echo ">> ✗ Failed to deregister AMI: $ami_id" if [ "$protection_enabled" = "enabled-with-cooldown" ]; then echo ">> 💡 This may be due to the 24-hour cooldown period being active" echo ">> 📅 Please retry this command after the cooldown expires" @@ -299,6 +352,10 @@ main() { #{{{ echo ">> AMI Cleanup Script - Automatic AMI deregistration and snapshot deletion" echo "## ----------" + if _dry_run; then + echo ">> 🔍 DRY_RUN=true - actions will be printed, nothing will be deleted" + fi + # Download/cache AWS CLI v2 if not already available _download_aws_cli @@ -315,7 +372,9 @@ main() { #{{{ target_ami="${TARGET_AMI_ID:-}" - echo ">> 🚨 Automatic cleanup enabled - will bypass AMI protection (except cooldown)" + if ! _dry_run; then + echo ">> 🚨 Automatic cleanup enabled - will bypass AMI protection (except cooldown)" + fi # Only cleanup the specific AMI from terraform state if [ -n "$target_ami" ] && [ "$target_ami" != "null" ]; then diff --git a/stackguardian_private_runner/aws/packer/terraform.tfvars.tpl b/stackguardian_private_runner/aws/packer/terraform.tfvars.tpl index bd63c0a..59a5b0b 100644 --- a/stackguardian_private_runner/aws/packer/terraform.tfvars.tpl +++ b/stackguardian_private_runner/aws/packer/terraform.tfvars.tpl @@ -109,6 +109,10 @@ os = { # 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. +# Prefix of the generated AMI name; the default matches AMIs built by earlier +# versions of this module. +# ami_name_prefix = "SG-RUNNER-ami" + # packer_config = { # version = "1.14.1" # rebuild_ami_token = "" # e.g. "2026-07-30" or "tofu-1.11" to force a rebuild diff --git a/stackguardian_private_runner/aws/packer/variables.tf b/stackguardian_private_runner/aws/packer/variables.tf index 222119e..2d0b78f 100644 --- a/stackguardian_private_runner/aws/packer/variables.tf +++ b/stackguardian_private_runner/aws/packer/variables.tf @@ -1,6 +1,24 @@ /*-------------------+ | General Variables | +-------------------*/ +variable "existing_ami_id" { + description = < Part of [StackGuardian Private Runner](../../README.md) — [AWS stack overview](../DOCUMENTATION.md) · [platform template doc](DOCUMENTATION.md) + +Provisions a StackGuardian Runner Group with an S3 storage backend and the `AWS_RBAC` +connector the platform uses to reach it. + +This module requires **only** the AWS provider. The platform-side resources (runner +group, connector, registration token) live in the shared, cloud-agnostic +[`runner_group/`](../../runner_group/) module, which this module calls — so an AWS +deployment never initializes `azurerm` or `azuread`. The Azure equivalent is +[`azure/runner_group/`](../../azure/runner_group/). + +## What Gets Created + +- **S3 bucket** with public access blocked and CORS limited to the StackGuardian + platform origin (when `create_storage_backend = true`). +- **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}`. +- **StackGuardian Runner Group** with `max_number_of_runners` and default tags. +- **StackGuardian Connector** (`AWS_RBAC`) wired to the role and external ID above. + +## Prerequisites + +- StackGuardian API key (`sgu_*` user key, `sgo_*` org key, or a `${secret::SECRET_NAME}` + reference). +- OpenTofu >= 1.7 or Terraform >= 1.3. +- AWS credentials with permission to create S3 buckets and IAM roles. + +## Quick Start + +`terraform.tfvars`: + +```hcl +stackguardian = { + api_key = "sgu_your_api_key_here" + api_uri = "https://api.app.stackguardian.io" + org_name = "your-org-name" +} + +aws_region = "eu-central-1" +``` + +```bash +tofu init +tofu plan +tofu apply +``` + +### As a module + +```hcl +module "runner_group" { + source = "./stackguardian_private_runner/aws/runner_group" + + stackguardian = { + api_key = "sgu_your_api_key" + } + + aws_region = "eu-central-1" + max_runners = 3 +} +``` + +## Configuration + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| `stackguardian.api_key` | StackGuardian API key (must start with `sgu_` or `sgo_`) | `string` (sensitive) | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `stackguardian.api_uri` | StackGuardian API endpoint (EU1 / US1 / DASH) | `https://api.app.stackguardian.io` | +| `stackguardian.org_name` | Organization name; falls back to the `SG_ORG_ID` env var | `""` | +| `aws_region` | Target AWS region | `eu-central-1` | +| `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` | +| `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) | +| `max_runners` | Maximum runners allowed in the group | `3` | + +### Naming + +The runner group and the connector are named `{global_prefix}-{name}`, or just +`{name}` when `global_prefix` is empty. `name` is whatever you pass as +`override_names.runner_group_name`; left empty it is a 6-character random string, +which is all the uniqueness a runner group needs. + +- Runner group: `{global_prefix}-{name}` — e.g. `SG_RUNNER-k3m9xz` +- Connector: same name as the runner group (separate API namespaces, so no clash) + +The account ID is **not** in the name — it is a tag. + +AWS resources keep their own scheme: +- IAM role: `{global_prefix}-private-runner-s3-role` +- S3 bucket: `{8-char-random}-private-runner-storage-backend` + + +### Tags + +The platform models tags as a flat list of strings — there are no keys — capped at +10. Both the runner group and the connector get: + +| Tag | Example | +|-----|---------| +| Purpose marker | `StackGuardian Private Runner` | +| Provisioner | `Managed by IaC` | +| Cloud | `aws` | +| Account ID | `123456789012` | +| Naming prefix | `SG_RUNNER` | +| Region | `eu-central-1` | + +The org name and the runner group name are deliberately not tagged: a runner group +only ever lives in one org, and its own name is not information a tag adds. + +## Outputs + +| Output | Description | +|--------|-------------| +| `runner_group_name` / `runner_group_id` | Name of the created runner group | +| `runner_group_token` | Registration token for runners (sensitive) | +| `runner_group_url` | Direct link to the runner group in the web console | +| `connector_name` / `connector_id` | Name of the AWS connector | +| `connector_external_id` | External ID enforced by the role's trust policy | +| `s3_bucket_name` / `s3_bucket_arn` | Storage backend bucket | +| `storage_backend_role_arn` / `storage_backend_role_name` | IAM role runners assume | +| `sg_org_name` / `sg_api_uri` / `aws_region` | Resolved platform and region settings | + +Feed `runner_group_name`, `runner_group_token`, and `storage_backend_role_arn` into +[`aws/single_runner`](../single_runner/) or [`aws/autoscaling_group`](../autoscaling_group/). +See [`examples/aws/quickstart`](../../examples/aws/quickstart/) for the whole stack wired +together. + +## Security Notes + +- The bucket blocks all public access and accepts browser requests only from the + StackGuardian console origin. +- 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. diff --git a/stackguardian_private_runner/aws/runner_group/locals.tf b/stackguardian_private_runner/aws/runner_group/locals.tf new file mode 100644 index 0000000..439f643 --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/locals.tf @@ -0,0 +1,81 @@ +data "external" "env" { + program = [ + "sh", + "-c", + "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\"}'" + ] +} + +data "aws_caller_identity" "current" {} + +locals { + # StackGuardian configuration + # Use nonsensitive() for non-secret fields to prevent sensitivity propagation + sg_org_name = ( + nonsensitive(var.stackguardian.org_name) != "" + ? nonsensitive(var.stackguardian.org_name) + : data.external.env.result.sg_org_name + ) + 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] + + # Platform naming: {prefix}-{name}, or just {name} when no prefix is set. + # The name half is yours to pick; left empty it is a random suffix, which is + # all the uniqueness a runner group needs. The account ID used to sit here - + # it is a tag now. + runner_group_base = ( + var.override_names.runner_group_name != "" + ? var.override_names.runner_group_name + : random_string.name_suffix.result + ) + + runner_group_name = ( + var.override_names.global_prefix != "" + ? "${var.override_names.global_prefix}-${local.runner_group_base}" + : local.runner_group_base + ) + + # The connector is created 1:1 with the runner group and shares its name - + # they live in separate API namespaces (/integrations/ vs runnergroups/). + connector_base = ( + var.override_names.connector_name != "" + ? var.override_names.connector_name + : local.runner_group_base + ) + + connector_name = ( + var.override_names.global_prefix != "" + ? "${var.override_names.global_prefix}-${local.connector_base}" + : local.connector_base + ) + + # Bare values - the platform's tags are a flat list of strings with no keys. + platform_tags = compact([ + data.aws_caller_identity.current.account_id, + var.override_names.global_prefix, + var.aws_region, + ]) + + # S3 bucket name / ARN + s3_bucket_name = ( + var.create_storage_backend + ? aws_s3_bucket.this[0].bucket + : var.existing_s3_bucket_name + ) + + s3_bucket_arn = ( + var.create_storage_backend + ? aws_s3_bucket.this[0].arn + : "arn:aws:s3:::${local.s3_bucket_name}" + ) + + connector_external_id = "${local.sg_org_name}:${random_string.connector_external_id.result}" +} diff --git a/stackguardian_private_runner/aws/runner_group/outputs.tf b/stackguardian_private_runner/aws/runner_group/outputs.tf new file mode 100644 index 0000000..b3b5cff --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/outputs.tf @@ -0,0 +1,82 @@ +/*---------------------------------+ + | Runner Group Outputs | + +---------------------------------*/ +output "runner_group_name" { + description = "The name of the StackGuardian runner group" + value = module.runner_group.runner_group_name +} + +output "runner_group_id" { + description = "The ID of the StackGuardian runner group" + value = module.runner_group.runner_group_id +} + +output "runner_group_token" { + description = "The token for runner registration (sensitive)" + sensitive = true + value = module.runner_group.runner_group_token +} + +output "runner_group_url" { + description = "Direct URL to the runner group in the StackGuardian web console" + value = module.runner_group.runner_group_url +} + +/*---------------------------------+ + | Connector Outputs | + +---------------------------------*/ +output "connector_name" { + description = "The name of the StackGuardian AWS connector" + value = module.runner_group.connector_name +} + +output "connector_id" { + description = "The ID of the StackGuardian AWS connector" + value = module.runner_group.connector_id +} + +output "connector_external_id" { + description = "The external ID used for cross-account S3 access" + value = local.connector_external_id +} + +/*---------------------------------+ + | Storage Backend Outputs | + +---------------------------------*/ +output "s3_bucket_name" { + description = "The name of the S3 bucket used for storage backend" + value = local.s3_bucket_name +} + +output "s3_bucket_arn" { + description = "The ARN of the S3 bucket used for storage backend" + value = local.s3_bucket_arn +} + +output "storage_backend_role_arn" { + description = "The ARN of the IAM role for storage backend access" + value = aws_iam_role.storage_backend.arn +} + +output "storage_backend_role_name" { + description = "The name of the IAM role for storage backend access" + value = aws_iam_role.storage_backend.name +} + +/*---------------------------------+ + | StackGuardian Platform Outputs | + +---------------------------------*/ +output "sg_org_name" { + description = "The StackGuardian organization name" + value = local.sg_org_name +} + +output "sg_api_uri" { + description = "The StackGuardian API URI" + value = local.sg_api_uri +} + +output "aws_region" { + description = "The AWS region" + value = var.aws_region +} diff --git a/stackguardian_private_runner/aws/runner_group/provider.tf b/stackguardian_private_runner/aws/runner_group/provider.tf new file mode 100644 index 0000000..75b227d --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/provider.tf @@ -0,0 +1,33 @@ +terraform { + # optional() attributes with defaults (var.override_names) need 1.3+ + required_version = ">= 1.3.0" + + required_providers { + stackguardian = { + source = "registry.terraform.io/StackGuardian/stackguardian" + version = ">= 1.3.3" + } + aws = { + source = "hashicorp/aws" + version = ">= 4.0" + } + external = { + source = "hashicorp/external" + version = ">= 2.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +provider "stackguardian" { + api_key = var.stackguardian.api_key + org_name = local.sg_org_name + api_uri = local.sg_api_uri +} diff --git a/stackguardian_private_runner/aws/runner_group/runner_group.tf b/stackguardian_private_runner/aws/runner_group/runner_group.tf new file mode 100644 index 0000000..0ee874d --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/runner_group.tf @@ -0,0 +1,38 @@ +# StackGuardian Runner Group + connector. +# +# The platform resources live in the shared, cloud-agnostic module so that this +# template only ever requires the AWS provider — an AWS deployment never pulls in +# azurerm/azuread. + +# Random half of the runner group name, used when no name is supplied. Held in +# state, so it is stable across applies and only changes if this is replaced. +resource "random_string" "name_suffix" { + length = 6 + lower = true + upper = false + numeric = true + special = false +} + +module "runner_group" { + source = "../../runner_group" + + sg_org_name = local.sg_org_name + sg_app_uri = local.sg_app_uri + + runner_group_name = local.runner_group_name + connector_name = local.connector_name + max_runners = var.max_runners + + tags = local.platform_tags + + storage_backend = { + type = "aws_s3" + aws = { + region = var.aws_region + bucket_name = local.s3_bucket_name + role_arn = aws_iam_role.storage_backend.arn + external_id = local.connector_external_id + } + } +} diff --git a/stackguardian_private_runner/runner_group/schemas/input_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json similarity index 77% rename from stackguardian_private_runner/runner_group/schemas/input_schema.json rename to stackguardian_private_runner/aws/runner_group/schemas/input_schema.json index b10a7f0..5a14947 100644 --- a/stackguardian_private_runner/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json @@ -1,6 +1,5 @@ { "type": "object", - "additionalProperties": false, "properties": { "stackguardian": { "title": "StackGuardian Configuration", @@ -25,7 +24,8 @@ "api_key": { "title": "API Key", "type": "string", - "pattern": "^(sg[uo]_.*|\\$\\{secret::[a-zA-Z0-9_-]+\\})$" + "pattern": "^(sg[uo]_.*|\\$\\{secret::[a-zA-Z0-9_-]+\\})$", + "minLength": 1 }, "org_name": { "title": "Organization Name", @@ -33,7 +33,9 @@ "default": "" } }, - "required": ["api_key"] + "required": [ + "api_key" + ] }, "aws_region": { "title": "AWS Region", @@ -83,25 +85,25 @@ "global_prefix": { "title": "Global Prefix", "type": "string", - "default": "SG_RUNNER" - }, - "include_org_in_prefix": { - "title": "Include Organization Name in Prefix", - "type": "boolean", - "default": false + "default": "SG_RUNNER", + "description": "Prefix for the runner group and connector names. Leave empty to omit it." }, "runner_group_name": { - "title": "Runner Group Name Override", + "title": "Runner Group Name", "type": "string", - "default": "" + "default": "", + "description": "Name half of the runner group. A short random string is generated when empty." }, "connector_name": { - "title": "Connector Name Override", + "title": "Connector Name", "type": "string", - "default": "" + "default": "", + "description": "Name half of the connector. Defaults to the runner group's name." } }, - "required": ["global_prefix"], + "required": [ + "global_prefix" + ], "additionalProperties": false }, "max_runners": { @@ -117,7 +119,9 @@ { "properties": { "create_storage_backend": { - "enum": [true] + "enum": [ + true + ] }, "force_destroy_storage_backend": { "title": "Force Destroy Storage Backend", @@ -129,17 +133,24 @@ { "properties": { "create_storage_backend": { - "enum": [false] + "enum": [ + false + ] }, "existing_s3_bucket_name": { "title": "Existing S3 Bucket Name", - "type": "string" + "type": "string", + "minLength": 1 } }, - "required": ["existing_s3_bucket_name"] + "required": [ + "existing_s3_bucket_name" + ] } ] } }, - "required": ["stackguardian"] + "required": [ + "stackguardian" + ] } diff --git a/stackguardian_private_runner/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json similarity index 61% rename from stackguardian_private_runner/runner_group/schemas/ui_schema.json rename to stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json index b15fcb5..41def6f 100644 --- a/stackguardian_private_runner/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json @@ -1,6 +1,6 @@ { - "ui:title": "StackGuardian Runner Group", - "ui:description": "Create a new StackGuardian Runner Group with S3 storage backend and AWS connector. Default tags are automatically applied: 'StackGuardian Private Runner', runner group name, and organization name.", + "ui:title": "StackGuardian Runner Group - AWS", + "ui:description": "Create a new StackGuardian Runner Group backed by an S3 storage bucket. Default tags are automatically applied: 'StackGuardian Private Runner', runner group name, and organization name.", "ui:order": [ "stackguardian", "aws_region", @@ -13,7 +13,11 @@ "stackguardian": { "ui:title": "StackGuardian Configuration", "ui:description": "Configure your StackGuardian platform connection", - "ui:order": ["api_uri", "api_key", "org_name"], + "ui:order": [ + "api_uri", + "api_key", + "org_name" + ], "api_uri": { "ui:widget": "select", "ui:description": "Select your StackGuardian platform region" @@ -33,7 +37,7 @@ }, "create_storage_backend": { "ui:widget": "checkbox", - "ui:description": "Whether to create a new S3 bucket for the storage backend" + "ui:description": "Whether to create a new S3 bucket as the storage backend" }, "existing_s3_bucket_name": { "ui:placeholder": "my-existing-bucket", @@ -41,27 +45,27 @@ }, "force_destroy_storage_backend": { "ui:widget": "checkbox", - "ui:description": "⚠️ **Warning:** Force destroy the S3 bucket on module destruction (deletes all data)" + "ui:description": "Warning: Force destroy the S3 bucket on module destruction (deletes all data)" }, "override_names": { "ui:title": "Resource Naming Configuration", "ui:description": "Customize resource names (optional)", - "ui:order": ["global_prefix", "include_org_in_prefix", "runner_group_name", "connector_name"], + "ui:order": [ + "global_prefix", + "runner_group_name", + "connector_name" + ], "global_prefix": { "ui:placeholder": "SG_RUNNER", - "ui:description": "Prefix for naming all resources" - }, - "include_org_in_prefix": { - "ui:widget": "checkbox", - "ui:description": "When enabled, prefix becomes {global_prefix}_{org_name} (e.g., SG_RUNNER_demo-org)" + "ui:description": "Prefix for the runner group and connector names. Leave empty to omit it." }, "runner_group_name": { "ui:placeholder": "(auto-generated)", - "ui:description": "Override the runner group name. If empty, uses {effective_prefix}-runner-group-{account_id}" + "ui:description": "Name half of the runner group; the full name is {prefix}-{name}. A short random string is generated when empty." }, "connector_name": { "ui:placeholder": "(auto-generated)", - "ui:description": "Override the connector name. If empty, uses {effective_prefix}-private-runner-backend-{account_id}" + "ui:description": "Name half of the connector. Defaults to the runner group's name." } }, "max_runners": { diff --git a/stackguardian_private_runner/runner_group/storage_backend.tf b/stackguardian_private_runner/aws/runner_group/storage_backend.tf similarity index 88% rename from stackguardian_private_runner/runner_group/storage_backend.tf rename to stackguardian_private_runner/aws/runner_group/storage_backend.tf index a89ba4a..a2974ce 100644 --- a/stackguardian_private_runner/runner_group/storage_backend.tf +++ b/stackguardian_private_runner/aws/runner_group/storage_backend.tf @@ -1,4 +1,4 @@ -# S3 Bucket for Storage Backend (only created when create_storage_backend = true) +# S3 Bucket for Storage Backend (created when create_storage_backend = true) resource "random_string" "storage_backend_prefix" { count = var.create_storage_backend ? 1 : 0 @@ -41,7 +41,7 @@ resource "aws_s3_bucket_cors_configuration" "this" { } } -# Data source for existing S3 bucket (when using existing bucket) +# Data source for existing S3 bucket (when using an existing bucket) data "aws_s3_bucket" "existing" { count = var.create_storage_backend ? 0 : 1 diff --git a/stackguardian_private_runner/runner_group/storage_backend_role.tf b/stackguardian_private_runner/aws/runner_group/storage_backend_role.tf similarity index 88% rename from stackguardian_private_runner/runner_group/storage_backend_role.tf rename to stackguardian_private_runner/aws/runner_group/storage_backend_role.tf index c4eaf9b..8df52b1 100644 --- a/stackguardian_private_runner/runner_group/storage_backend_role.tf +++ b/stackguardian_private_runner/aws/runner_group/storage_backend_role.tf @@ -7,7 +7,7 @@ resource "random_string" "connector_external_id" { # This IAM role is used by the StackGuardian platform and runners to access the S3 bucket resource "aws_iam_role" "storage_backend" { - name = "${local.effective_prefix}-private-runner-s3-role" + name = "${var.override_names.global_prefix}-private-runner-s3-role" assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -24,7 +24,7 @@ resource "aws_iam_role" "storage_backend" { Action = "sts:AssumeRole" Condition = { StringEquals = { - "sts:ExternalId" = "${local.sg_org_name}:${random_string.connector_external_id.result}" + "sts:ExternalId" = local.connector_external_id } } } @@ -34,7 +34,7 @@ resource "aws_iam_role" "storage_backend" { # This policy allows the StackGuardian platform/runner to access the S3 bucket resource "aws_iam_policy" "storage_backend_access" { - name = "${local.effective_prefix}-runner-s3-policy" + name = "${var.override_names.global_prefix}-runner-s3-policy" description = "Policy for access to the Storage Backend S3 Bucket" policy = jsonencode({ diff --git a/stackguardian_private_runner/aws/runner_group/variables.tf b/stackguardian_private_runner/aws/runner_group/variables.tf new file mode 100644 index 0000000..2839b1f --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/variables.tf @@ -0,0 +1,102 @@ +/*---------------------------+ + | Storage Backend Options | + +---------------------------*/ +variable "create_storage_backend" { + description = <= 1 + error_message = "max_runners must be at least 1." + } +} diff --git a/stackguardian_private_runner/aws/single_runner/DOCUMENTATION.md b/stackguardian_private_runner/aws/single_runner/DOCUMENTATION.md index 5106d3a..1d8dd24 100644 --- a/stackguardian_private_runner/aws/single_runner/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/single_runner/DOCUMENTATION.md @@ -30,6 +30,7 @@ This template creates a single EC2 instance configured as a StackGuardian Privat | API Key | Your StackGuardian API key | `string` | | AMI ID | The AMI with pre-installed dependencies (docker, cron, jq, sg-runner) | `string` | | Runner Group Name | Name of the runner group from the Runner Group template | `string` | +| Runner Group Token | Registration token from the Runner Group template. Sensitive - prefer a secret reference (`${secret::SECRET_NAME}`) over a literal value | `string` (password) | | Storage Backend Role ARN | IAM role ARN for S3 storage access from the Runner Group template | `string` | | VPC ID | Existing VPC for deployment | `string` | @@ -48,8 +49,8 @@ This template creates a single EC2 instance configured as a StackGuardian Privat | Associate Public IP | Assign a public IP to the instance | `false` | | Create Network Infrastructure | Create NAT Gateway and route tables | `false` | | Proxy URL | HTTP proxy for private network deployments | - | -| Additional Security Groups | Extra security groups to attach | `[]` | -| VPC Endpoint Security Groups | Security groups of VPC endpoints (STS, SSM, ECR). Adds inbound 443 rule to allow runner access. | `[]` | +| Additional Security Group IDs | Extra security groups to attach | `[]` | +| VPC Endpoint Security Group IDs | Security groups of VPC endpoints (STS, SSM, ECR). Adds inbound 443 rule to allow runner access. | `[]` | | Volume Type | EBS volume type (gp2, gp3, io1, io2) | `gp3` | | Volume Size (GB) | Storage size in GB (minimum 8GB) | `100` | | Delete on Termination | Delete volume when instance terminates | `false` | @@ -70,6 +71,8 @@ This template creates a single EC2 instance configured as a StackGuardian Privat - Configure a proxy URL, or - Use VPC endpoints with `vpc_endpoint_security_group_ids` +**Proxy Support**: When "Proxy URL" is set, the instance exports it as `HTTP_PROXY`/`HTTPS_PROXY` at boot, before it registers with StackGuardian. Leave it empty and no proxy is configured. + **VPC Endpoints**: For fully private deployments without internet access, create VPC endpoints for AWS services (STS, SSM, ECR, S3) and provide their security group IDs. The template automatically adds inbound rules to allow the runner to access these endpoints. **Subnet Priority**: When both private and public subnets are provided, the instance is deployed to the private subnet. diff --git a/stackguardian_private_runner/aws/single_runner/README.md b/stackguardian_private_runner/aws/single_runner/README.md index c9414af..dc64d70 100644 --- a/stackguardian_private_runner/aws/single_runner/README.md +++ b/stackguardian_private_runner/aws/single_runner/README.md @@ -1,5 +1,7 @@ # StackGuardian Private Runner - AWS Single Runner Module +> Part of [StackGuardian Private Runner](../../README.md) — [AWS stack overview](../DOCUMENTATION.md) · [platform template doc](DOCUMENTATION.md) + Deploy a standalone StackGuardian Private Runner on AWS EC2. This module creates a single EC2 instance that automatically registers with your StackGuardian runner group and executes workflow jobs in your AWS environment. ## Overview @@ -304,7 +306,7 @@ curl -v https://sts..amazonaws.com | Name | Version | |------|---------| -| terraform | >= 1.0 | +| terraform | >= 1.3.0 | | aws | >= 4.0 | | stackguardian | >= 1.3.3 | | external | >= 2.0 | diff --git a/stackguardian_private_runner/aws/single_runner/ec2.tf b/stackguardian_private_runner/aws/single_runner/ec2.tf index 6c6942e..15beb29 100644 --- a/stackguardian_private_runner/aws/single_runner/ec2.tf +++ b/stackguardian_private_runner/aws/single_runner/ec2.tf @@ -41,6 +41,7 @@ resource "aws_instance" "this" { sg_runner_group_name = var.runner_group_name sg_runner_group_token = var.runner_group_token sg_runner_startup_timeout = tostring(var.runner_startup_timeout) + proxy_url = var.network.proxy_url } ) ) diff --git a/stackguardian_private_runner/aws/single_runner/provider.tf b/stackguardian_private_runner/aws/single_runner/provider.tf index 8436859..2dddffe 100644 --- a/stackguardian_private_runner/aws/single_runner/provider.tf +++ b/stackguardian_private_runner/aws/single_runner/provider.tf @@ -1,4 +1,7 @@ terraform { + # optional() attributes with defaults (var.network, var.firewall) need 1.3+ + required_version = ">= 1.3.0" + required_providers { stackguardian = { source = "registry.terraform.io/StackGuardian/stackguardian" 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 c097ee5..287b5f6 100644 --- a/stackguardian_private_runner/aws/single_runner/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/single_runner/schemas/input_schema.json @@ -1,6 +1,5 @@ { "type": "object", - "additionalProperties": false, "properties": { "stackguardian": { "title": "StackGuardian Configuration", diff --git a/stackguardian_private_runner/aws/single_runner/templates/register_runner.sh.tpl b/stackguardian_private_runner/aws/single_runner/templates/register_runner.sh.tpl index df05f3c..90b8f11 100644 --- a/stackguardian_private_runner/aws/single_runner/templates/register_runner.sh.tpl +++ b/stackguardian_private_runner/aws/single_runner/templates/register_runner.sh.tpl @@ -4,6 +4,16 @@ set -e startup_log_file="/var/log/sg_runner_startup.log" +## Configure HTTP/HTTPS proxy if provided (for private network deployments) +proxy_url="${proxy_url}" +if [ -n "$proxy_url" ]; then + echo ">> Configuring proxy: $proxy_url" | tee -a "$startup_log_file" + export HTTP_PROXY="$proxy_url" + export HTTPS_PROXY="$proxy_url" + export http_proxy="$proxy_url" + export https_proxy="$proxy_url" +fi + ## Sometimes registration fails because `docker.service` is not ready. ## We will check if `docker.service` is ready and continue. ## Otherwise, sleep for 1 second and try again. diff --git a/stackguardian_private_runner/azure/DOCUMENTATION.md b/stackguardian_private_runner/azure/DOCUMENTATION.md new file mode 100644 index 0000000..8dfb17c --- /dev/null +++ b/stackguardian_private_runner/azure/DOCUMENTATION.md @@ -0,0 +1,352 @@ +# StackGuardian Private Runner - Azure Full Stack Template + +Deploy a complete auto-scaling StackGuardian Private Runner infrastructure on Azure using the StackGuardian platform. + +## Overview + +This Stack deploys a production-ready private runner environment with custom managed image building, an auto-scaling VM Scale Set, and an Azure Function-based autoscaler. The Stack orchestrates five Azure templates, which work together to provide a fully managed runner infrastructure inside your own subscription. + +### What This Stack Creates + +- **Custom Managed Image** with pre-installed Docker, Terraform, OpenTofu, and StackGuardian runner components +- **Runner Group** on StackGuardian platform with Azure Blob Storage backend and an Entra ID (OIDC) connector +- **VM Scale Set** with Linux instances that automatically register as runners +- **Function App Autoscaler** (Flex Consumption, Python 3.11) that monitors job queues and adjusts VMSS capacity +- **Network Infrastructure** (optional) including VNet, subnet, NSG, and a NAT Gateway for private deployments +- **Managed Identities and Role Assignments** for the runner instances, the autoscaler function, and blob storage access + +## Prerequisites + +- StackGuardian organization API key (`sgo_*` or `sgu_*`) +- Azure subscription with Contributor permissions (User Access Administrator as well, if the templates should create role assignments for you) +- Azure CLI authenticated (`az login`) on the machine or runner executing the apply — Packer, the Function App code deployment, and image cleanup all shell out to `az` +- A Resource Group for the runner infrastructure (the `azure/runner_group` template can create one for you) +- A User-Assigned Managed Identity that the runner VMs will use to read the storage backend (see [Managed identity model](#managed-identity-model)) +- Outbound internet access for the runner instances (NAT Gateway, Azure Firewall, or an HTTP proxy) +- OpenTofu >= 1.4 (`tofu`) or Terraform >= 1.4 — the `packer` template records the built image in state via `terraform_data`, which needs 1.4+. Everything here is plain HCL, so `terraform` works identically if that is what you have. +- Local tooling on the executing machine: `az`, `git`, `zip`, and `wget` (the Packer bootstrap downloads the Packer binary itself) + +--- + +## Template 1: Packer Image Builder + +Build a custom Azure managed image for the StackGuardian Private Runner with pre-installed dependencies. + +The image is built **once**. Packer runs on the first apply, the resulting image resource ID is recorded in state (`terraform_data.image_id`), and every following plan reuses it — repeated applies cost no build time. To build a fresh image, change `packer_config.rebuild_image_token` to any new value; that triggers exactly one rebuild, and the new value then sits there without rebuilding again. + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| resource_group_name | Resource group where the managed image is stored | string | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| existing_image_id | Reuse a managed image you already have; nothing is built and every other parameter here is ignored | `""` | +| azure_location | Azure region where the image is built | `westeurope` | +| create_resource_group | Create the resource group (if false, it must already exist) | `false` | +| vm_size | VM size used for the Packer build VM | `Standard_D2s_v3` | +| image_name_prefix | Prefix for the generated image name | `sg-runner` | +| network.vnet_name | Existing VNet for the build VM (empty = Packer creates temporary networking) | `""` | +| network.subnet_name | Existing subnet inside that VNet | `""` | +| network.resource_group_name | Resource group of the existing VNet, if different | `""` | +| network.proxy_url | HTTP proxy URL forwarded to the build VM | `""` | +| os.publisher | Base image publisher — `Canonical` or `RedHat` | `Canonical` | +| os.offer | Base image offer | `0001-com-ubuntu-server-jammy` | +| os.sku | Base image SKU | `22_04-lts-gen2` | +| os.version | Base image version | `latest` | +| os.update_os_before_install | Update OS packages before installing components | `true` | +| os.user_script | Custom shell script to execute during provisioning | `""` | +| packer_config.version | Packer version to download and use | `1.14.1` | +| packer_config.rebuild_image_token | Change to any new value to build a fresh image (otherwise built once and reused from state) | `""` | +| packer_config.cleanup_images_on_destroy | Delete this deployment's image on 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 | `""` | +| opentofu.additional_versions | Additional OpenTofu versions to install | `[]` | +| sg_runner.pre_release | Bake the newest sg-runner pre-release into the image instead of the latest stable release | `false` | + +There is no `ssh_username` input — the build user is derived from `os.publisher` (`ubuntu` for Canonical, `azureuser` for RedHat). + +### Outputs + +| Output | Description | +|--------|-------------| +| image_id | Resource ID of the managed image recorded in state | +| image_info | Comprehensive image information for tracking | +| resource_group_name | Resource group where the image is stored | +| cleanup_commands | Ready-made `az image` commands for manual cleanup | + +--- + +## Template 2: Runner Group + +Create a StackGuardian Runner Group with an Azure Blob Storage backend and an Entra ID OIDC connector. This is the `azure/runner_group/` template. It requires only the Azure providers — the platform-side resources it shares with the AWS stack live in the internal, cloud-agnostic `runner_group/` module it calls, so an Azure deployment never initializes the AWS provider. + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| stackguardian.api_key | Your organization's API key (`sgo_*`/`sgu_*`) or secret reference | string | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| stackguardian.api_uri | StackGuardian platform region | `https://api.app.stackguardian.io` | +| stackguardian.org_name | Your organization name | (from `SG_ORG_ID` environment) | +| azure_location | Azure region for the storage account and resource group | `westeurope` | +| create_azure_resource_group | Create the resource group that hosts the storage account | `true` | +| azure_resource_group_name | Name override when creating, or the existing RG name when not creating | (derived from prefix) | +| create_storage_backend | Create a new Storage Account | `true` | +| existing_azure_storage_account_name | Existing Storage Account name (when `create_storage_backend` is false) | `""` | +| existing_azure_storage_account_access_key | Access key for that existing account (sensitive) | `""` | +| azure_storage.account_tier | Storage Account performance tier | `Standard` | +| azure_storage.account_replication_type | Replication strategy (LRS, GRS, RAGRS, ZRS) | `LRS` | +| create_blob_reader_role_assignment | Grant the connector service principal `Storage Blob Data Reader` | `true` | +| override_names.global_prefix | Prefix for the runner group and connector names; `""` omits it | `SG_RUNNER` | +| override_names.runner_group_name | Name half of the runner group; the full name is `{prefix}-{name}` | (6-char random) | +| override_names.connector_name | Name half of the connector | (the runner group's name) | +| max_runners | Maximum number of runners allowed in the group | `3` | + +The subscription ID is not part of these names — it is one of the tags applied to the +runner group and the connector, alongside `StackGuardian Private Runner`, +`Managed by IaC`, `azure`, the prefix, and the region. + +### Outputs + +| Output | Description | +|--------|-------------| +| runner_group_name | Name of the StackGuardian runner group | +| runner_group_token | Token for runner registration (sensitive) | +| runner_group_url | Direct link to the runner group in the web console | +| connector_name | Name of the StackGuardian connector | +| azure_resource_group_name | Resource group hosting the storage account — feed this to the `resource_group_name` input of the Azure templates | +| azure_resource_group_location | Location of that resource group | +| azure_storage_account_name | Name of the Storage Account used as backend | +| azure_storage_account_id | Resource ID of that Storage Account — scope role assignments to it | +| azure_storage_access_key | Access key for that Storage Account (sensitive) | +| azure_connector_service_principal_object_id | Object ID of the OIDC connector service principal | +| sg_org_name / sg_api_uri | Resolved organization name and platform API URI | + +Set `create_blob_reader_role_assignment = false` when the identity running the apply lacks `Microsoft.Authorization/roleAssignments/write`. In that case, use `azure_connector_service_principal_object_id` to grant `Storage Blob Data Reader` out of band before runners can read the backend. + +--- + +## Template 3: VM Scale Set + +Deploy a VM Scale Set whose instances boot from the custom image and register themselves as StackGuardian runners. + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| stackguardian.api_key | Your organization's API key | string | +| resource_group_name | Resource group for the scale set | string | +| vm_image_id | Custom image resource ID (from the packer template) | string | +| runner_group_name | Runner group name (from the runner_group template) | string | +| runner_group_token | Runner group token (from the runner_group template) | string | +| storage_backend_identity_id | Resource ID of the User-Assigned Managed Identity used to read the storage backend | string | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| stackguardian.api_uri | StackGuardian platform region | `https://api.app.stackguardian.io` | +| stackguardian.org_name | Your organization name | (from `SG_ORG_ID` environment) | +| azure_location | Azure region for deployment | `westeurope` | +| vm_size | VM size for each scale-set instance | `Standard_D4s_v3` | +| override_names.global_prefix | Prefix for naming all resources | `SG_RUNNER` | +| override_names.include_org_in_prefix | Append organization name to prefix | `false` | +| network.create_network | Create a new VNet and subnet | `false` | +| network.vnet_id | Existing VNet resource ID (required when not creating) | `""` | +| network.subnet_id | Existing subnet resource ID (required when not creating) | `""` | +| network.vnet_address_space | Address space for the new VNet | `["10.0.0.0/16"]` | +| network.subnet_address_prefix | Address prefix for the new subnet | `10.0.1.0/24` | +| network.create_network_infrastructure | Create a NAT Gateway and attach it to the created subnet | `false` | +| network.service_endpoints | VNet service endpoints on the created subnet (e.g. `Microsoft.Storage`) | `[]` | +| network.proxy_url | HTTP proxy URL for private deployments | `""` | +| network.additional_nsg_ids | Additional NSG IDs to associate | `[]` | +| os_disk.caching | OS disk caching mode | `ReadWrite` | +| os_disk.storage_account_type | OS disk type | `Premium_LRS` | +| os_disk.disk_size_gb | OS disk size in GB (min 30) | `100` | +| firewall.admin_username | Linux admin user on each instance | `azureuser` | +| firewall.ssh_public_key | SSH public key to install (preferred) | `""` | +| firewall.generate_ssh_key | Generate an RSA keypair and expose the private key as an output | `false` | +| firewall.ssh_access_rules | Map of source prefixes allowed to reach SSH | `{}` | +| firewall.additional_inbound_rules | Additional NSG inbound rules | `{}` | +| scaling.min_size | Minimum instance count | `1` | +| scaling.max_size | Maximum instance count | `3` | +| scaling.desired_capacity | Initial instance count (ignored on later applies) | `1` | +| upgrade_policy.mode | `Manual`, `Rolling` or `Automatic` model rollout | `Manual` | +| upgrade_policy.health_probe_id | Load Balancer probe used as the health signal | `""` | +| upgrade_policy.application_health_extension | In-guest health extension (`protocol`, `port`, `request_path`) | `null` | +| upgrade_policy.max_batch_instance_percent | Max percent of instances upgraded per batch | `20` | +| upgrade_policy.max_unhealthy_instance_percent | Max percent allowed unhealthy during upgrade | `20` | +| upgrade_policy.max_unhealthy_upgraded_instance_percent | Max percent of upgraded instances allowed unhealthy | `20` | +| upgrade_policy.pause_time_between_batches | ISO 8601 wait between batches | `PT5M` | +| upgrade_policy.automatic_instance_repair | Let Azure replace unhealthy instances | `false` | +| upgrade_policy.automatic_instance_repair_grace_period | ISO 8601 grace period before repairs | `PT30M` | +| runner_startup_timeout | Max seconds to wait for Docker to start | `300` | + +Either `firewall.ssh_public_key` or `firewall.generate_ssh_key = true` must be set — the template refuses to build a scale set with no way in. `Rolling`/`Automatic` upgrades and `automatic_instance_repair` each require a health signal: supply `health_probe_id` or `application_health_extension`. + +### Outputs + +| Output | Description | +|--------|-------------| +| vmss_id | Resource ID of the VM Scale Set | +| vmss_name | Name of the VM Scale Set — feed to the autoscaler's `vmss.name` | +| vmss_resource_group_name | Resource group of the scale set — feed to the autoscaler's `vmss.resource_group_name` | +| network_security_group_id | ID of the network security group | +| vnet_id / subnet_id | IDs of the VNet and subnet (created or existing) | +| ssh_private_key | Generated SSH private key, when `generate_ssh_key = true` (sensitive) | +| ssh_public_key | SSH public key installed on the instances | +| storage_backend_identity_id | Pass-through of the managed identity ID | + +--- + +## Template 4: Autoscaler + +Deploy an Azure Function App that monitors StackGuardian job queues and scales the VM Scale Set. + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| stackguardian.api_key | Your organization's API key | string | +| resource_group_name | Existing resource group for the autoscaler resources | string | +| vmss.name | Name of the VM Scale Set to manage (from the vmss template) | string | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| stackguardian.api_uri | StackGuardian platform region | `https://api.app.stackguardian.io` | +| stackguardian.org_name | Your organization name | (from `SG_ORG_ID` environment) | +| azure_location | Azure region for the Function App | `westeurope` | +| vmss.resource_group_name | Resource group of the scale set | (falls back to `resource_group_name`) | +| override_names.global_prefix | Prefix for naming all resources | `SG_RUNNER` | +| override_names.include_org_in_prefix | Append organization name to prefix | `false` | +| override_names.runner_group_name | Runner group the autoscaler queries — set this to the runner group name | `""` | +| scaling.min_runners | Minimum runners to maintain | `1` | +| scaling.max_runners | Maximum runners allowed | `3` | +| scaling.desired_runners | Initial capacity; `null` lets the function pick | `null` | +| scaling.scale_out_threshold | Queued jobs that trigger scale-out | `3` | +| scaling.scale_in_threshold | Queued jobs below which to scale in | `1` | +| scaling.scale_out_step | Instances to add per scale-out | `1` | +| scaling.scale_in_step | Instances to remove per scale-in | `1` | +| scaling.scale_out_cooldown_duration | Minutes to wait after scale-out (min 4) | `4` | +| scaling.scale_in_cooldown_duration | Minutes to wait after scale-in | `5` | +| scaling.schedule_cron | NCRONTAB expression driving the timer trigger | `0 */1 * * * *` | +| storage.account_tier | Storage Account tier for autoscaler state | `Standard` | +| storage.account_replication_type | Replication strategy (LRS, GRS, RAGRS, ZRS) | `LRS` | +| storage.account_url | Explicit storage account URL (for private endpoints) | `""` | +| storage.use_rbac | Authenticate to storage with the managed identity instead of a connection string | `false` | +| application_insights_retention_in_days | Telemetry retention (30, 60, 90, 120, 180, 270, 365, 550, 730) | `30` | +| autoscaler_repo.url | Git repository holding the Function App code | `https://github.com/StackGuardian/sg-runner-autoscaler` | +| autoscaler_repo.branch | Branch to deploy from | `main` | + +`override_names.runner_group_name` is not merely cosmetic here: it becomes the `SG_RUNNER_GROUP` app setting the function uses to query the queue. Leave it empty and the function has no runner group to poll. + +The function code is not vendored in this repository. On apply, the template reads the tip commit of `autoscaler_repo.branch` with `git ls-remote`, clones it, zips it, and pushes it with `az functionapp deployment source config-zip`. Because the commit hash is a replace trigger, a new commit on that branch redeploys the code on the next apply — pin `autoscaler_repo.branch` to a tag or a fork if you want that frozen. The runner type is fixed to `external`; there is no `runner_type` input on the Azure side. + +### Outputs + +| Output | Description | +|--------|-------------| +| function_app_name | Name of the autoscaler Function App | +| function_app_id | Resource ID of the Function App | +| function_app_default_hostname | Default hostname of the Function App | +| function_app_identity_principal_id | Principal ID of the Function App's system-assigned identity | +| storage_account_name / storage_account_id | Storage Account holding autoscaler state | +| storage_container_name | Blob container holding the cooldown timestamps | +| application_insights_name | Application Insights instance | +| application_insights_instrumentation_key | Instrumentation key (sensitive) | +| application_insights_connection_string | Connection string (sensitive) | +| vmss_name / vmss_resource_group | The scale set under management | + +--- + +## Template 5: Azure Runner (single VM) + +Deploy one Linux VM as a StackGuardian runner instead of a scale set. Use this for a fixed-size footprint, for a pilot, or where a scale set is more machinery than the workload justifies. It takes the same required inputs as the VMSS template (`vm_image_id`, `runner_group_name`, `runner_group_token`, `storage_backend_identity_id`, `stackguardian.api_key`, `resource_group_name`) and the same `network`, `os_disk`, `firewall`, and `runner_startup_timeout` options. + +Differences from Template 3: + +- `vm_size` defaults to `Standard_D4s_v3`, but there is a single instance — no `scaling` and no `upgrade_policy`. +- `network.associate_public_ip` (default `false`) attaches a public IP directly to the VM's NIC. The VMSS template has no equivalent. +- Outputs are per-VM: `vm_id`, `vm_name`, `vm_private_ip`, `vm_public_ip`, `network_interface_id`, plus the same `network_security_group_id`, `vnet_id`, `subnet_id`, `ssh_private_key`, `ssh_public_key`, and `storage_backend_identity_id`. + +The autoscaler template only manages a VM Scale Set, so this path is not autoscaled. + +--- + +## Important Notes + +**Deployment Order**: Templates must be deployed in sequence: + +1. **Packer** — build the managed image first +2. **Runner Group** — create the runner group, resource group, and storage backend +3. **VM Scale Set** (or **Azure Runner**) — deploy instances using outputs from templates 1 and 2 +4. **Autoscaler** — deploy the Function App using outputs from templates 2 and 3 + +**Output wiring between templates**: + +| Producer | Output | Consumer | Input | +|----------|--------|----------|-------| +| packer | `image_id` | vmss / azure_runner | `vm_image_id` | +| runner_group | `runner_group_name` | vmss / azure_runner | `runner_group_name` | +| runner_group | `runner_group_name` | autoscaler | `override_names.runner_group_name` | +| runner_group | `runner_group_token` | vmss / azure_runner | `runner_group_token` | +| runner_group | `azure_resource_group_name` | vmss / autoscaler / azure_runner | `resource_group_name` | +| vmss | `vmss_name` | autoscaler | `vmss.name` | +| vmss | `vmss_resource_group_name` | autoscaler | `vmss.resource_group_name` | + +**Managed identity model**: the runner VMs are assigned a **User-Assigned Managed Identity** (`storage_backend_identity_id`) so they can read and write the storage backend. That identity is an input you supply — the `runner_group` template does not emit one. On its Azure path, `runner_group` instead registers an Entra ID application plus service principal with an OIDC federated credential (issuer and audience are the StackGuardian API URI, subject `/orgs/`) and grants that principal `Storage Blob Data Reader` on the storage account; that is how the *platform* reaches the backend, not how the *VMs* do. Create the User-Assigned Managed Identity yourself, grant it the blob data role you need on the storage account from `azure_storage_account_id`, and pass its resource ID in. The autoscaler Function App is separate again: it uses a **system-assigned** identity, created and role-assigned by that template. + +**Resource group model**: Azure has no implicit container the way an AWS region does, so every template takes a `resource_group_name`. The simplest arrangement is to let `azure/runner_group` create one (`create_azure_resource_group = true`) and pass its `azure_resource_group_name` output to all three Azure templates. The `packer` template can create its own separate resource group for images (`create_resource_group = true`), which keeps image lifecycle independent of the runner infrastructure. + +**Azure CLI dependency**: three of these templates shell out to `az` during apply — Packer authenticates with `use_azure_cli_auth`, the autoscaler deploys the function zip with `az functionapp deployment source config-zip`, and image cleanup on destroy runs `az image delete`. The executing identity must be logged in (`az login`) *and* have the target subscription selected, not just have `ARM_*` provider credentials in the environment. + +**Network Requirements**: Runner instances need outbound internet access to reach the StackGuardian API and download packages. Options include: + +- An existing subnet with its own route to the internet (Azure Firewall, ExpressRoute, an existing NAT Gateway) +- A subnet created by the template with `network.create_network = true` and `network.create_network_infrastructure = true`, which provisions a NAT Gateway with a public IP. The template never attaches a NAT Gateway to a subnet it does not own, so this pair must be set together. +- An HTTP proxy via `network.proxy_url` + +`network.service_endpoints` only applies to a subnet the template creates. When bringing your own subnet, configure the endpoints on it directly. + +**API Key Security**: Use the `${secret::SECRET_NAME}` format to reference secrets stored in StackGuardian rather than hardcoding API keys. The whole `stackguardian` object is marked `sensitive` in every Azure template, so the key never appears in plan output; the templates call `nonsensitive()` on `org_name` and `api_uri` internally so those non-secret fields can still be used in resource names and URLs. + +**Scaling Behavior**: The autoscaler Function App runs on a timer trigger driven by `scaling.schedule_cron` (every minute by default) and adjusts VMSS capacity based on job queue depth. Cooldown periods prevent rapid oscillation. `scaling.desired_capacity` on the VMSS template is only the initial size — it is ignored on subsequent applies so the autoscaler and Terraform do not fight over instance count. + +--- + +## How Azure Differs from AWS + +These are not cosmetic naming differences; they change how the stack behaves. + +- **Rollout of image changes.** The AWS Auto Scaling Group performs a rolling `instance_refresh` when the launch template changes. The Azure VMSS defaults to `upgrade_mode = "Manual"`: pointing `vm_image_id` at a new image updates the scale set model, but running instances keep the old model until the autoscaler or an operator replaces them. Set `upgrade_policy.mode = "Rolling"` (with a health signal) to get behaviour comparable to the AWS instance refresh. Azure cannot change the upgrade mode of an existing scale set, so switching this value **replaces the VMSS and recreates every runner**. +- **What runs the autoscaler.** AWS uses a Lambda on an EventBridge Scheduler rate expression. Azure uses a Flex Consumption (`FC1`) Function App running Python 3.11, driven by an NCRONTAB timer trigger (`scaling.schedule_cron`). There is no `lambda_config` equivalent — runtime and version are fixed by the template. +- **Where the function code comes from.** Both platforms pull the autoscaler from `autoscaler_repo`. On Azure the deployment is a `git clone` plus `az functionapp deployment source config-zip` executed by a local provisioner, which is why `git`, `zip`, and `az` must be present wherever you run the apply. +- **Permissions.** AWS grants an IAM role with inline policies. Azure grants built-in roles to the Function App's system-assigned identity: `Virtual Machine Contributor` on the scale set, `Reader` and `Network Contributor` on the scale set's resource group, and `Storage Blob Data Contributor` (or `Storage Blob Data Owner` in RBAC mode) on the autoscaler storage account, plus queue and table data roles when `storage.use_rbac = true`. Creating these requires `Microsoft.Authorization/roleAssignments/write`. +- **Storage backend.** AWS uses an S3 bucket with a cross-account IAM role and an external ID. Azure uses a Storage Account with a container named `runner` and an OIDC federated credential on an Entra ID app registration. `s3_bucket_name` and `storage_backend_role_arn` are `null` on the Azure path; `azure_storage_account_name` and `azure_resource_group_name` take their place. +- **Naming.** Azure resource names are lowercased and underscores become hyphens, because several Azure resource types reject the `SG_RUNNER` style. The storage account name is truncated and given a random suffix to satisfy the 3–24 character globally-unique lowercase-alphanumeric rule. +- **Org name resolution.** Azure templates take the organization name from `stackguardian.org_name`, falling back to the `SG_ORG_ID` environment variable. There is no `override_names.org_name` input. + +--- + +## Security Features + +- Managed identities throughout — user-assigned for runner instances, system-assigned for the autoscaler Function App; no static cloud credentials on the VMs +- OIDC federated credentials for the StackGuardian connector instead of a long-lived client secret +- Built-in Azure roles scoped to the specific scale set, resource group, or storage account rather than subscription-wide grants +- Storage accounts enforce TLS 1.2 and disallow public blob access +- Network security groups default to deny, with SSH and any additional ports opened only on request +- Optional VNet service endpoints so runners reach Azure PaaS over the Azure backbone +- NAT Gateway support so runners get outbound access without public IPs +- Bring-your-own SSH key (`firewall.ssh_public_key`) so no private key is ever written to state; key generation is opt-in +- Automatic image cleanup on destroy, scoped to the image this deployment actually built diff --git a/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md b/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md new file mode 100644 index 0000000..8912a19 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md @@ -0,0 +1,103 @@ +# StackGuardian Runner Autoscaler - Azure Template + +Deploy an Azure Function-based autoscaler that monitors StackGuardian job queues and scales a VM Scale Set up or down based on workload demand. + +## Overview + +This template creates an intelligent autoscaling system that monitors your StackGuardian job queues and automatically adjusts the number of runner instances on an Azure VM Scale Set. When jobs are queued, more runners are added; when the queue is empty, runners are removed to reduce costs. + +### What This Template Creates + +- **Function App** (FlexConsumption, Python 3.11) that checks job queue status every minute and scales runners accordingly +- **Storage Account** for autoscaler state (cooldown timestamps) with TLS 1.2 enforced +- **Application Insights** for monitoring, logging, and alerting on the autoscaler function (30-day telemetry retention by default) +- **Role Assignments** granting the Function App's managed identity scoped access to manage VMSS, storage, and networking + +## Prerequisites + +Before using this template, you need: + +1. **VM Scale Set** - An existing Azure VMSS running StackGuardian runner instances +2. **Runner Group** - Deploy the "StackGuardian Runner Group" template first +3. **StackGuardian API Key** - Available from your organization settings +4. **Azure Resource Group** - An existing resource group for autoscaler resources +5. **Azure Permissions** - Permissions to create Function Apps, Storage Accounts, Application Insights, and role assignments + +## Template Parameters + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| API Key | Your organization's API key (sgo_*/sgu_*) or a secret reference (${secret::SECRET_NAME}) | String | +| Resource Group Name | The name of the existing Azure Resource Group where autoscaler resources will be deployed | String | +| VMSS Name | Name of the existing VM Scale Set running StackGuardian runner instances | String | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| API Region | Select your StackGuardian platform region | EU1 - Europe | +| Organization Name | Your organization name on the StackGuardian Platform | (extracted from environment) | +| Azure Region | The Azure region where autoscaler resources will be deployed | westeurope | +| VMSS Resource Group | Resource group containing the VM Scale Set (defaults to the autoscaler resource group if empty) | (autoscaler resource group) | +| Global Prefix | Prefix used for naming all Azure resources created by this module | SG_RUNNER | +| Include Org in Prefix | When enabled, appends organization name to the global prefix | Disabled | +| Runner Group Name | Override the default StackGuardian runner group name | (empty) | +| Minimum Runners | Minimum number of runners to maintain | 1 | +| Maximum Runners | Maximum number of runners the autoscaler can provision | 3 | +| Desired Runners | Optional initial capacity. Leave empty to let the autoscaler choose between min and max | (empty) | +| Scale Out Threshold | Number of queued jobs to trigger scale-out | 3 | +| Scale In Threshold | Number of queued jobs below which to trigger scale-in | 1 | +| Scale Out Step | Number of instances to add when scaling out | 1 | +| Scale In Step | Number of instances to remove when scaling in | 1 | +| Scale Out Cooldown (minutes) | Minutes to wait after scale-out before scaling again (minimum: 4) | 4 | +| Scale In Cooldown (minutes) | Minutes to wait after scale-in before scaling again | 5 | +| Schedule (NCRONTAB) | Timer trigger expression that drives the Function App (every minute by default) | 0 */1 * * * * | +| Storage Account Tier | Performance tier of the storage account | Standard | +| Replication Type | Replication strategy for the storage account | LRS | +| Storage Account URL | Optional explicit storage account URL (for private endpoints) | (empty) | +| Use RBAC (Managed Identity) | Use managed identity instead of connection strings for storage authentication | Disabled | +| Application Insights Retention (days) | How long Application Insights keeps autoscaler telemetry (30, 60, 90, 120, 180, 270, 365, 550, 730) | 30 | +| Repository URL | Git repository containing the autoscaler Function App source code | https://github.com/StackGuardian/sg-runner-autoscaler | +| Branch | Git branch to deploy the autoscaler Function App code from | main | + +## Important Notes + +**Scaling Behavior**: The autoscaler runs on a 1-minute timer. When queued jobs reach the scale-out threshold (default: 3), runners are added; when queued jobs fall to the scale-in threshold (default: 1), runners are drained and removed down to the minimum. Cooldown periods prevent rapid scaling fluctuations. + +**Dependencies**: This template requires an existing VM Scale Set and the outputs of the Runner Group template. Deploy those first and provide the VMSS name and runner group name as inputs. + +**Cost Optimization**: The autoscaler reduces costs by automatically scaling VMSS instances down when runners are not needed. Tune the minimum/maximum runners and thresholds to match your workload patterns. + +**Private Endpoint Support**: For VNet-integrated deployments, set the Storage Account URL to your private endpoint URL (e.g., `https://mystorageaccount.privatelink.blob.core.windows.net`). + +**Storage Authentication**: Enable RBAC to authenticate to blob storage using the Function App's managed identity instead of connection strings. This is the recommended option for production deployments. + +**Function Code Deployment**: The template clones the configured repository and branch and publishes the code to the Function App. The tip commit of the branch is resolved on every plan, so a new commit on the tracked branch causes the next apply to redeploy the function code. Leave the repository settings at their defaults unless you are testing a fork or a feature branch. + +## Outputs + +| Output | Description | +|--------|-------------| +| Function App Name | The name of the Azure Function App handling autoscaling | +| Function App Hostname | The default hostname of the Function App | +| Storage Account Name | The name of the Storage Account used for autoscaler state | +| Application Insights Name | The Application Insights instance for monitoring autoscaler logs and metrics | +| VMSS Name | The name of the VM Scale Set being managed | +| VMSS Resource Group | The resource group of the VM Scale Set | +| Function App ID | Resource ID of the Azure Function App | +| Function App Identity Principal ID | Principal ID of the Function App's managed identity | +| Storage Account ID | Resource ID of the Storage Account | +| Storage Container Name | Blob container holding autoscaler state | +| Application Insights Connection String | Connection string for Application Insights (sensitive) | +| Application Insights Instrumentation Key | Instrumentation key for Application Insights (sensitive) | + +## Security Features + +- System-assigned managed identity authenticates to Azure resources via RBAC - no credentials stored in app settings +- Role assignments are scoped narrowly to the specific VMSS, storage account, and resource group (least privilege) +- Storage account requires TLS 1.2 minimum +- Private endpoint support for blob storage in VNet-integrated environments +- StackGuardian API key is marked sensitive so it is redacted from plan output, and is stored as a Function App setting (encrypted at rest) +- Application Insights provides centralized logging and alerting for audit and troubleshooting diff --git a/stackguardian_private_runner/azure/autoscaler/README.md b/stackguardian_private_runner/azure/autoscaler/README.md new file mode 100644 index 0000000..0881aaf --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/README.md @@ -0,0 +1,684 @@ +# StackGuardian Runner Autoscaler - Azure Module + +> Part of [StackGuardian Private Runner](../../README.md) — [Azure stack overview](../DOCUMENTATION.md) · [platform template doc](DOCUMENTATION.md) + +Deploy an Azure Function-based autoscaler that monitors StackGuardian job queues and automatically scales a VM Scale Set up or down based on workload demand. + +## Overview + +The autoscaler module provides intelligent scaling for StackGuardian Private Runners by monitoring job queue depth and adjusting the number of VM instances accordingly. It runs as a serverless Azure Function triggered every minute by a timer. + +### What Gets Created + +- **Function App**: FlexConsumption plan with Python 3.11 runtime for autoscaling logic +- **Storage Account**: Blob storage for autoscaler state (cooldown timestamps) +- **Application Insights**: Monitoring, logging, and alerting (30-day retention by default) +- **Role Assignments**: Managed identity with VMSS, storage, and network access + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Azure Subscription │ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────────────┐ │ +│ │ Resource Group │ │ VMSS Resource Group │ │ +│ │ │ │ (existing) │ │ +│ │ ┌───────────────┐ │ │ ┌───────────────────────┐ │ │ +│ │ │ Function App │──┼────┼──│ VM Scale Set │ │ │ +│ │ │ (autoscaler) │ │ │ │ (existing runners) │ │ │ +│ │ └───────┬───────┘ │ │ └───────────────────────┘ │ │ +│ │ │ │ │ │ │ +│ │ ┌───────▼───────┐ │ └─────────────────────────────┘ │ +│ │ │ Storage Acct │ │ │ +│ │ │ (state) │ │ │ +│ │ └───────────────┘ │ │ +│ │ │ │ +│ │ ┌───────────────┐ │ │ +│ │ │ App Insights │ │ │ +│ │ │ (monitoring) │ │ │ +│ │ └───────────────┘ │ │ +│ └─────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Prerequisites + +Before deploying this module, you need: + +1. **Existing VM Scale Set** - An Azure VMSS with StackGuardian runner instances +2. **StackGuardian Runner Group** - Deploy the `runner_group` module first to get: + - `runner_group_name` +3. **StackGuardian API Key** - Organization API key (`sgu_*` or `sgo_*`) from the StackGuardian platform +4. **Azure Resource Group** - Existing resource group for autoscaler resources +5. **Azure CLI** - Authenticated (`az login`) + +## Quick Start + +### Step 1: Deploy Prerequisites + +Ensure you have an existing VM Scale Set and StackGuardian runner group. + +### Step 2: Deploy Autoscaler + +```bash +terraform init +terraform apply +``` + +### Basic Configuration Example + +```hcl +module "azure_autoscaler" { + source = "./azure/autoscaler" + + resource_group_name = "my-resource-group" + azure_location = "westeurope" + + vmss = { + name = "my-runner-vmss" + resource_group_name = "vmss-resource-group" + } + + stackguardian = { + api_key = "sgu_xxxxxxxxxxxx" + org_name = "my-org" + } + + override_names = { + global_prefix = "sg-runner" + runner_group_name = "my-runner-group" + } +} +``` + +## Configuration + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| `resource_group_name` | Existing Azure Resource Group for autoscaler resources | `string` | +| `stackguardian.api_key` | StackGuardian API key (`sgu_*` or `sgo_*`) | `string` | +| `vmss.name` | Name of the existing VM Scale Set to manage | `string` | +| `override_names.global_prefix` | Prefix for naming all resources | `string` | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `azure_location` | Azure region for deployment | `westeurope` | +| `stackguardian.org_name` | Organization name (extracted from environment if not provided) | `""` | +| `vmss.resource_group_name` | VMSS resource group (defaults to `resource_group_name`) | `""` | +| `override_names.runner_group_name` | Override the StackGuardian runner group name | `""` | +| `scaling.scale_out_cooldown_duration` | Minutes after scale-out before scaling again (min: 4) | `4` | +| `scaling.scale_in_cooldown_duration` | Minutes after scale-in before scaling again | `5` | +| `scaling.scale_out_threshold` | Queued jobs to trigger scale-out | `3` | +| `scaling.scale_in_threshold` | Queued jobs to trigger scale-in (min: 1) | `1` | +| `scaling.scale_out_step` | Instances to add when scaling out | `1` | +| `scaling.scale_in_step` | Instances to remove when scaling in | `1` | +| `scaling.min_runners` | Minimum number of runners to maintain | `1` | +| `storage.account_tier` | Storage account performance tier | `Standard` | +| `storage.account_replication_type` | Storage replication strategy (LRS, GRS, RAGRS, ZRS) | `LRS` | +| `storage.account_url` | Explicit storage URL (for private endpoints) | `""` | +| `storage.use_rbac` | Authenticate to storage with managed identity instead of connection strings | `false` | +| `application_insights_retention_in_days` | Application Insights telemetry retention (30, 60, 90, 120, 180, 270, 365, 550, 730) | `30` | +| `autoscaler_repo.url` | Git repository URL for the Function App source | `https://github.com/StackGuardian/sg-runner-autoscaler` | +| `autoscaler_repo.branch` | Git branch for the Function App source | `main` | + +### Configuration Examples + +#### Basic Configuration + +```hcl +module "azure_autoscaler" { + source = "./azure/autoscaler" + + resource_group_name = "my-resource-group" + + vmss = { + name = "my-runner-vmss" + } + + stackguardian = { + api_key = "sgu_xxxxxxxxxxxx" + } + + override_names = { + global_prefix = "sg-runner" + runner_group_name = "my-runner-group" + } +} +``` + +#### Advanced Configuration + +```hcl +module "azure_autoscaler" { + source = "./azure/autoscaler" + + resource_group_name = "my-resource-group" + azure_location = "westeurope" + + vmss = { + name = "my-runner-vmss" + resource_group_name = "vmss-resource-group" + } + + stackguardian = { + api_key = "sgu_xxxxxxxxxxxx" + org_name = "my-org" + } + + override_names = { + global_prefix = "prod-runner" + runner_group_name = "prod-runner-group" + } + + scaling = { + scale_out_cooldown_duration = 5 + scale_in_cooldown_duration = 10 + scale_out_threshold = 5 + scale_in_threshold = 2 + scale_out_step = 2 + scale_in_step = 1 + min_runners = 2 + } + + storage = { + account_tier = "Standard" + account_replication_type = "GRS" + } + + application_insights_retention_in_days = 90 + + autoscaler_repo = { + url = "https://github.com/StackGuardian/sg-runner-autoscaler" + branch = "main" + } +} +``` + +#### Private Network with Storage Endpoint + +```hcl +module "azure_autoscaler" { + source = "./azure/autoscaler" + + resource_group_name = "my-resource-group" + azure_location = "westeurope" + + vmss = { + name = "my-runner-vmss" + resource_group_name = "vmss-resource-group" + } + + stackguardian = { + api_key = "sgu_xxxxxxxxxxxx" + org_name = "my-org" + } + + override_names = { + global_prefix = "sg-runner" + runner_group_name = "my-runner-group" + } + + storage = { + account_url = "https://mystorageaccount.privatelink.blob.core.windows.net" + } +} +``` + +## Usage + +### Terraform Deployment + +```bash +# Initialize Terraform +terraform init + +# Validate configuration +terraform validate + +# Preview changes +terraform plan + +# Apply configuration +terraform apply +``` + +### Auto-scaling Behavior + +The autoscaler operates on a 1-minute cycle: + +1. **Scale-out**: When queued jobs >= `scale_out_threshold`, adds `scale_out_step` instances +2. **Scale-in**: When queued jobs <= `scale_in_threshold`, marks runners as DRAINING, then removes idle ones +3. **Cooldown**: After scaling, waits the configured cooldown duration before scaling again + +Default behavior: +- Scales out when 3+ jobs are queued +- Scales in when 1 or fewer jobs are queued +- 4-minute cooldown after scale-out +- 5-minute cooldown after scale-in + +### Function Code Deployment + +The module clones `autoscaler_repo.url` at `autoscaler_repo.branch` and publishes +it to the Function App via `scripts/deploy_function.sh`. The deployment re-runs +whenever any of the following change: + +- The repository URL or branch +- The commit currently at the tip of that branch (resolved with `git ls-remote`) +- The contents of `scripts/deploy_function.sh` +- The Function App itself (if it is recreated) + +Because the branch tip is resolved on every plan, pushing a new commit to the +tracked branch is enough to make the next `tofu apply` redeploy the function code. +The local machine running the apply needs `git`, `zip`, and an authenticated +`az` CLI. + +### Cleanup + +```bash +# Destroy the autoscaler +terraform destroy +``` + +--- + +## Manual Deployment (Azure CLI) + +For deployments without Terraform, follow this step-by-step guide using Azure CLI. + +### Step 1: Set Variables + +```bash +# Required - customize these +RESOURCE_GROUP="my-autoscaler-rg" +LOCATION="westeurope" +STORAGE_ACCOUNT="sgautoscaler$(openssl rand -hex 4)" +FUNCTION_APP="sg-autoscaler" +APP_INSIGHTS="sg-autoscaler-insights" + +# VMSS configuration +VMSS_NAME="my-runner-vmss" +VMSS_RESOURCE_GROUP="my-vmss-rg" + +# StackGuardian configuration +SG_API_KEY="sgu_xxxxxxxxxxxx" +SG_ORG="my-org" +SG_RUNNER_GROUP="my-runner-group" +SG_BASE_URI="https://api.app.stackguardian.io" + +# Get subscription ID +SUBSCRIPTION_ID=$(az account show --query id -o tsv) +``` + +### Step 2: Create Resource Group + +```bash +az group create --name $RESOURCE_GROUP --location $LOCATION +``` + +### Step 3: Create Storage Account + +```bash +az storage account create \ + --name $STORAGE_ACCOUNT \ + --resource-group $RESOURCE_GROUP \ + --location $LOCATION \ + --sku Standard_LRS \ + --min-tls-version TLS1_2 + +# Get connection string +STORAGE_CONN_STRING=$(az storage account show-connection-string \ + --name $STORAGE_ACCOUNT \ + --resource-group $RESOURCE_GROUP \ + --query connectionString -o tsv) + +# Create container for autoscaler state +az storage container create \ + --name autoscaler-state \ + --account-name $STORAGE_ACCOUNT +``` + +### Step 4: Create Application Insights + +```bash +az monitor app-insights component create \ + --app $APP_INSIGHTS \ + --location $LOCATION \ + --resource-group $RESOURCE_GROUP \ + --application-type other + +# Get connection string +APP_INSIGHTS_CONN=$(az monitor app-insights component show \ + --app $APP_INSIGHTS \ + --resource-group $RESOURCE_GROUP \ + --query connectionString -o tsv) +``` + +### Step 5: Create Function App + +```bash +# Create Function App with FlexConsumption plan +az functionapp create \ + --name $FUNCTION_APP \ + --resource-group $RESOURCE_GROUP \ + --storage-account $STORAGE_ACCOUNT \ + --flexconsumption-location $LOCATION \ + --runtime python \ + --runtime-version 3.11 \ + --functions-version 4 +``` + +### Step 6: Configure App Settings + +The autoscaler uses **RBAC (managed identity)** to access blob storage instead of connection strings. + +```bash +az functionapp config appsettings set \ + --name $FUNCTION_APP \ + --resource-group $RESOURCE_GROUP \ + --settings \ + AZURE_SUBSCRIPTION_ID="$SUBSCRIPTION_ID" \ + AZURE_RESOURCE_GROUP_NAME="$VMSS_RESOURCE_GROUP" \ + AZURE_VMSS_NAME="$VMSS_NAME" \ + AZURE_STORAGE_ACCOUNT_NAME="$STORAGE_ACCOUNT" \ + AZURE_BLOB_CONTAINER_NAME="autoscaler-state" \ + SCALE_IN_TIMESTAMP_BLOB_NAME="scale_in_timestamp" \ + SCALE_OUT_TIMESTAMP_BLOB_NAME="scale_out_timestamp" \ + SG_BASE_URI="$SG_BASE_URI" \ + SG_API_KEY="$SG_API_KEY" \ + SG_ORG="$SG_ORG" \ + SG_RUNNER_GROUP="$SG_RUNNER_GROUP" \ + SG_RUNNER_TYPE="external" \ + SCALE_OUT_COOLDOWN_DURATION="4" \ + SCALE_IN_COOLDOWN_DURATION="5" \ + SCALE_OUT_THRESHOLD="3" \ + SCALE_IN_THRESHOLD="1" \ + SCALE_IN_STEP="1" \ + SCALE_OUT_STEP="1" \ + MIN_RUNNERS="1" \ + AzureWebJobsStorage="$STORAGE_CONN_STRING" \ + APPLICATIONINSIGHTS_CONNECTION_STRING="$APP_INSIGHTS_CONN" +``` + +> **Note**: For private endpoints, also set `AZURE_STORAGE_ACCOUNT_URL` to the private endpoint URL (e.g., `https://mystorageaccount.privatelink.blob.core.windows.net`). + +### Step 7: Assign Roles to Managed Identity + +```bash +# Enable system-assigned managed identity +az functionapp identity assign \ + --name $FUNCTION_APP \ + --resource-group $RESOURCE_GROUP + +# Get the principal ID +PRINCIPAL_ID=$(az functionapp identity show \ + --name $FUNCTION_APP \ + --resource-group $RESOURCE_GROUP \ + --query principalId -o tsv) + +# Grant Virtual Machine Contributor on VMSS +az role assignment create \ + --assignee $PRINCIPAL_ID \ + --role "Virtual Machine Contributor" \ + --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$VMSS_RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachineScaleSets/$VMSS_NAME" + +# Grant Reader on VMSS resource group +az role assignment create \ + --assignee $PRINCIPAL_ID \ + --role "Reader" \ + --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$VMSS_RESOURCE_GROUP" + +# Grant Storage Blob Data Contributor on storage account +STORAGE_ID=$(az storage account show --name $STORAGE_ACCOUNT --resource-group $RESOURCE_GROUP --query id -o tsv) +az role assignment create \ + --assignee $PRINCIPAL_ID \ + --role "Storage Blob Data Contributor" \ + --scope "$STORAGE_ID" + +# Grant Network Contributor on VMSS resource group (required for scaling) +# This allows the function to join VMs to subnets and NSGs during scale operations +az role assignment create \ + --assignee $PRINCIPAL_ID \ + --role "Network Contributor" \ + --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$VMSS_RESOURCE_GROUP" +``` + +### Step 8: Deploy Function Code + +Clone the autoscaler repository and deploy using one of these methods: + +```bash +# Clone the autoscaler repository +git clone --depth 1 https://github.com/StackGuardian/sg-runner-autoscaler.git +cd sg-runner-autoscaler + +# Copy Azure-specific requirements +cp azure_requirements.txt requirements.txt +``` + +**Option A: Using Azure Functions Core Tools (func CLI)** + +```bash +func azure functionapp publish $FUNCTION_APP --python +``` + +**Option B: Using Azure CLI (if func CLI is not installed)** + +```bash +# Create deployment package +zip -r deploy.zip . -x ".git/*" + +# Deploy using Azure CLI +az functionapp deployment source config-zip \ + --resource-group $RESOURCE_GROUP \ + --name $FUNCTION_APP \ + --src deploy.zip \ + --build-remote true + +# Cleanup +rm deploy.zip +``` + +### Step 9: Verify + +```bash +# Check function app status +az functionapp show --name $FUNCTION_APP --resource-group $RESOURCE_GROUP --query state + +# View recent logs +az monitor app-insights query \ + --app $APP_INSIGHTS \ + --resource-group $RESOURCE_GROUP \ + --analytics-query "traces | where timestamp > ago(10m) | order by timestamp desc | take 20" +``` + +--- + +## How It Works + +1. **Timer Trigger**: Azure Function runs every minute +2. **Queue Check**: Queries StackGuardian API for pending jobs in the runner group +3. **Scale Decision**: + - If `pending_jobs >= SCALE_OUT_THRESHOLD` --> Scale OUT (add instances) + - If `pending_jobs <= SCALE_IN_THRESHOLD` --> Scale IN (mark runners as DRAINING) +4. **Graceful Termination**: DRAINING runners with no active tasks are deregistered and removed +5. **Cooldown**: Scaling operations respect cooldown periods to prevent thrashing +6. **State**: Timestamps stored in Azure Blob Storage + +## Private Network Setup + +When using private endpoints for storage (VNet integration), you need to provide the explicit storage URL: + +### Terraform Configuration + +```hcl +storage = { + account_url = "https://mystorageaccount.privatelink.blob.core.windows.net" +} +``` + +### Manual Setup + +Set the `AZURE_STORAGE_ACCOUNT_URL` environment variable: + +```bash +az functionapp config appsettings set \ + --name $FUNCTION_APP \ + --resource-group $RESOURCE_GROUP \ + --settings AZURE_STORAGE_ACCOUNT_URL="https://mystorageaccount.privatelink.blob.core.windows.net" +``` + +> **Note**: The autoscaler uses RBAC (managed identity) for blob storage access. The `Storage Blob Data Contributor` role must be assigned to the Function App's managed identity on the storage account. + +## Environment Variables Reference + +| Variable | Description | Default | +|----------|-------------|---------| +| `AZURE_SUBSCRIPTION_ID` | Azure subscription ID | Required | +| `AZURE_RESOURCE_GROUP_NAME` | VMSS resource group | Required | +| `AZURE_VMSS_NAME` | VM Scale Set name | Required | +| `AZURE_STORAGE_ACCOUNT_NAME` | Storage account name (for RBAC auth) | Required | +| `AZURE_STORAGE_ACCOUNT_URL` | Explicit storage URL (for private endpoints) | Optional | +| `AZURE_BLOB_CONTAINER_NAME` | Blob container name | Required | +| `SG_BASE_URI` | StackGuardian API endpoint | Required | +| `SG_API_KEY` | StackGuardian API key | Required | +| `SG_ORG` | StackGuardian organization | Required | +| `SG_RUNNER_GROUP` | Runner group name | Required | +| `SG_RUNNER_TYPE` | Runner type | `"external"` | +| `SCALE_OUT_THRESHOLD` | Jobs to trigger scale out | `3` | +| `SCALE_IN_THRESHOLD` | Jobs to trigger scale in | `1` | +| `SCALE_OUT_STEP` | Instances to add | `1` | +| `SCALE_IN_STEP` | Instances to remove | `1` | +| `SCALE_OUT_COOLDOWN_DURATION` | Minutes between scale out | `4` | +| `SCALE_IN_COOLDOWN_DURATION` | Minutes between scale in | `5` | +| `MIN_RUNNERS` | Minimum instances to keep | `1` | + +## Architecture + +### Resource Organization + +| File | Contents | +|------|----------| +| `provider.tf` | Azure, random, external, and null provider configuration | +| `variables.tf` | Input variable definitions and validations | +| `locals.tf` | Computed values, naming conventions, VMSS resource group resolution | +| `function_autoscaler.tf` | Function App, App Service Plan, Application Insights, code deployment | +| `rbac.tf` | Role assignments for the Function App managed identity | +| `storage.tf` | Storage Account and blob containers | +| `scripts/deploy_function.sh` | Clones the autoscaler repo and publishes the zip package to the Function App | +| `outputs.tf` | Module outputs | + +### Resource Naming Convention + +Resources are named using the pattern: `{sanitized_prefix}-{resource-type}` + +The `global_prefix` is lowercased with underscores replaced by hyphens. + +Examples with default prefix `sg-runner`: +- Function App: `sg-runner-autoscaler` +- App Service Plan: `sg-runner-autoscaler-plan` +- Application Insights: `sg-runner-autoscaler-insights` +- Storage Account: `sgrunner{random-suffix}` (alphanumeric only, max 24 chars) +- Blob Container: `autoscaler-state` + +## Troubleshooting + +### Common Issues + +1. **401 Unauthorized (StackGuardian API)** + - **Symptoms**: Function executes but fails to communicate with StackGuardian API + - **Cause**: Invalid or expired `SG_API_KEY` + - **Fix**: Update the app setting with a valid API key: + ```bash + az functionapp config appsettings set \ + --name \ + --resource-group \ + --settings SG_API_KEY="sgu_your_new_key" + ``` + +2. **Function fails to scale VMSS** + - Verify the `vmss.name` matches the actual VM Scale Set name + - Check managed identity role assignments (Virtual Machine Contributor, Network Contributor) + +3. **Storage access errors** + - Verify the Function App's managed identity has `Storage Blob Data Contributor` role + - For private endpoints, ensure `storage.account_url` is set correctly + +### Debugging Commands + +```bash +# Check Function App logs +az monitor app-insights query \ + --app \ + --resource-group \ + --analytics-query "traces | order by timestamp desc | take 50" + +# Check exceptions in App Insights +az monitor app-insights query \ + --app \ + --resource-group \ + --analytics-query "exceptions | order by timestamp desc | take 10" + +# Check Function status +az functionapp function list --name --resource-group + +# Manually trigger Function +az functionapp function invoke \ + --name \ + --resource-group \ + --function-name timer_trigger +``` + +## Outputs + +| Output | Description | +|--------|-------------| +| `function_app_name` | The name of the Azure Function App | +| `function_app_id` | The ID of the Azure Function App | +| `function_app_default_hostname` | The default hostname of the Function App | +| `function_app_identity_principal_id` | The Principal ID of the Function App's managed identity | +| `storage_account_name` | The name of the Storage Account | +| `storage_account_id` | The ID of the Storage Account | +| `storage_container_name` | The name of the blob container for state | +| `application_insights_name` | The name of the Application Insights instance | +| `application_insights_instrumentation_key` | The instrumentation key for Application Insights | +| `application_insights_connection_string` | The connection string for Application Insights | +| `vmss_name` | The name of the VM Scale Set being managed | +| `vmss_resource_group` | The resource group of the VM Scale Set | + +## Security Considerations + +- **Managed Identity**: System-assigned managed identity with RBAC -- no credentials stored in app settings for Azure resource access +- **TLS 1.2 Enforced**: Storage account requires minimum TLS 1.2 +- **Least Privilege**: Role assignments scoped to specific resources (VMSS, storage account, resource group) +- **Private Endpoint Support**: Storage can be accessed via private endpoints for VNet-integrated deployments +- **API Key Protection**: StackGuardian API key is stored as a Function App setting (encrypted at rest) +- **Log Retention**: Application Insights provides centralized logging and monitoring + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.0 | +| azurerm | >= 3.0 | +| random | >= 3.0 | +| external | >= 2.0 | +| null | >= 3.0 | + +## Next Steps + +After deployment: + +1. Monitor Function App logs for scaling events via Application Insights +2. Adjust scaling thresholds based on workload patterns +3. Review Application Insights metrics for function invocations and errors + +## Support + +- [StackGuardian Documentation](https://docs.stackguardian.io) +- [GitHub Issues](https://github.com/StackGuardian/terraform-stackguardian-modules/issues) diff --git a/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf b/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf new file mode 100644 index 0000000..fe53734 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf @@ -0,0 +1,94 @@ +/*-------------------------------------------+ + | Azure Function App for Autoscaling | + +-------------------------------------------*/ + +# App Service Plan (FlexConsumption for serverless) +resource "azurerm_service_plan" "autoscaler" { + name = "${local.sanitized_prefix}-autoscaler-plan" + resource_group_name = var.resource_group_name + location = var.azure_location + os_type = "Linux" + sku_name = "FC1" # FlexConsumption plan + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-autoscaler-plan" + }) +} + +# Application Insights for monitoring +resource "azurerm_application_insights" "autoscaler" { + name = "${local.sanitized_prefix}-autoscaler-insights" + resource_group_name = var.resource_group_name + location = var.azure_location + application_type = "other" + retention_in_days = var.application_insights_retention_in_days + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-autoscaler-insights" + }) +} + +# Function App with Flex Consumption plan +resource "azurerm_function_app_flex_consumption" "autoscaler" { + name = "${local.sanitized_prefix}-autoscaler" + resource_group_name = var.resource_group_name + location = var.azure_location + service_plan_id = azurerm_service_plan.autoscaler.id + + # Runtime configuration + runtime_name = "python" + runtime_version = "3.11" + + # Storage configuration + storage_container_type = "blobContainer" + storage_container_endpoint = "${azurerm_storage_account.autoscaler.primary_blob_endpoint}deployments" + storage_authentication_type = var.storage.use_rbac ? "SystemAssignedIdentity" : "StorageAccountConnectionString" + storage_access_key = var.storage.use_rbac ? null : azurerm_storage_account.autoscaler.primary_access_key + + site_config { + application_insights_connection_string = azurerm_application_insights.autoscaler.connection_string + } + + app_settings = local.app_settings + + identity { + type = "SystemAssigned" + } + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-autoscaler" + }) +} + +/*-------------------------------------------+ + | Automatic Code Deployment | + +-------------------------------------------*/ + +# Fetch latest commit hash from remote repo to trigger redeploy on changes +data "external" "repo_commit" { + program = [ + "sh", "-c", + "echo \"{\\\"commit\\\": \\\"$(git ls-remote ${var.autoscaler_repo.url} ${var.autoscaler_repo.branch} | cut -f1)\\\"}\"" + ] +} + +# Clones the autoscaler repo and deploys the zip package via Azure CLI +resource "terraform_data" "deploy_function_code" { + triggers_replace = [ + azurerm_function_app_flex_consumption.autoscaler.id, + var.autoscaler_repo.url, + var.autoscaler_repo.branch, + data.external.repo_commit.result.commit, + filemd5("${path.module}/scripts/deploy_function.sh") + ] + + provisioner "local-exec" { + command = "sh ${path.module}/scripts/deploy_function.sh" + environment = { + REPO_URL = var.autoscaler_repo.url + REPO_BRANCH = var.autoscaler_repo.branch + RESOURCE_GROUP_NAME = var.resource_group_name + FUNCTION_APP_NAME = azurerm_function_app_flex_consumption.autoscaler.name + } + } +} diff --git a/stackguardian_private_runner/azure/autoscaler/locals.tf b/stackguardian_private_runner/azure/autoscaler/locals.tf new file mode 100644 index 0000000..1b6e978 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/locals.tf @@ -0,0 +1,98 @@ +# Extract SG org name from environment if not provided +data "external" "env" { + program = [ + "sh", + "-c", + "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\"}'" + ] +} + +data "azurerm_client_config" "current" {} + +locals { + # StackGuardian configuration - use provided values or extract from environment + # Use nonsensitive() for non-secret fields to prevent sensitivity propagation + sg_org_name = ( + nonsensitive(var.stackguardian.org_name) != "" + ? nonsensitive(var.stackguardian.org_name) + : data.external.env.result.sg_org_name + ) + sg_api_uri = nonsensitive(var.stackguardian.api_uri) + + # Resource group for VMSS (defaults to main resource group if not specified) + vmss_resource_group = ( + var.vmss.resource_group_name != "" + ? var.vmss.resource_group_name + : var.resource_group_name + ) + + # Computed prefix with optional org name (matches AWS pattern) + effective_prefix = ( + var.override_names.include_org_in_prefix && local.sg_org_name != "" + ? "${var.override_names.global_prefix}_${local.sg_org_name}" + : var.override_names.global_prefix + ) + + # Sanitized prefix for Azure resources (lowercase, hyphens) + sanitized_prefix = replace(lower(local.effective_prefix), "_", "-") + + # Common tags for all taggable resources + common_tags = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } + + # Storage account prefix (max 15 chars to leave room for 8-char random suffix + margin) + storage_account_prefix = substr("autoscaler${replace(local.sanitized_prefix, "-", "")}", 0, 16) + + # Storage URL: use explicit URL if provided (for private endpoints) + storage_account_url = ( + var.storage.account_url != "" + ? var.storage.account_url + : "" + ) + + # Base app settings (always present regardless of auth mode) + base_app_settings = { + # Azure configuration + AZURE_SUBSCRIPTION_ID = data.azurerm_client_config.current.subscription_id + AZURE_RESOURCE_GROUP_NAME = local.vmss_resource_group + AZURE_VMSS_NAME = var.vmss.name + AZURE_STORAGE_ACCOUNT_NAME = azurerm_storage_account.autoscaler.name + AZURE_STORAGE_ACCOUNT_URL = local.storage_account_url + AZURE_BLOB_CONTAINER_NAME = azurerm_storage_container.autoscaler_state.name + SCALE_IN_TIMESTAMP_BLOB_NAME = "scale_in_timestamp" + SCALE_OUT_TIMESTAMP_BLOB_NAME = "scale_out_timestamp" + + # StackGuardian configuration + SG_BASE_URI = local.sg_api_uri + SG_API_KEY = var.stackguardian.api_key + SG_ORG = local.sg_org_name + SG_RUNNER_GROUP = var.override_names.runner_group_name + SG_RUNNER_TYPE = "external" + + # Scaling configuration + SCALE_OUT_COOLDOWN_DURATION = tostring(var.scaling.scale_out_cooldown_duration) + SCALE_IN_COOLDOWN_DURATION = tostring(var.scaling.scale_in_cooldown_duration) + SCALE_OUT_THRESHOLD = tostring(var.scaling.scale_out_threshold) + SCALE_IN_THRESHOLD = tostring(var.scaling.scale_in_threshold) + SCALE_IN_STEP = tostring(var.scaling.scale_in_step) + SCALE_OUT_STEP = tostring(var.scaling.scale_out_step) + MIN_RUNNERS = tostring(var.scaling.min_runners) + MAX_RUNNERS = tostring(var.scaling.max_runners) + DESIRED_RUNNERS = var.scaling.desired_runners == null ? "" : tostring(var.scaling.desired_runners) + SCHEDULE_CRON = var.scaling.schedule_cron + + # Monitoring + APPLICATIONINSIGHTS_CONNECTION_STRING = azurerm_application_insights.autoscaler.connection_string + } + + # Storage auth app settings depend on RBAC mode + storage_app_settings = var.storage.use_rbac ? { + AzureWebJobsStorage__accountName = azurerm_storage_account.autoscaler.name + } : { + AzureWebJobsStorage = azurerm_storage_account.autoscaler.primary_connection_string + } + + app_settings = merge(local.base_app_settings, local.storage_app_settings) +} diff --git a/stackguardian_private_runner/azure/autoscaler/outputs.tf b/stackguardian_private_runner/azure/autoscaler/outputs.tf new file mode 100644 index 0000000..bc0ed22 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/outputs.tf @@ -0,0 +1,73 @@ +/*---------------------------------+ + | Azure Function Outputs | + +---------------------------------*/ +output "function_app_name" { + description = "The name of the Azure Function App that handles auto-scaling" + value = azurerm_function_app_flex_consumption.autoscaler.name +} + +output "function_app_id" { + description = "The ID of the Azure Function App" + value = azurerm_function_app_flex_consumption.autoscaler.id +} + +output "function_app_default_hostname" { + description = "The default hostname of the Azure Function App" + value = azurerm_function_app_flex_consumption.autoscaler.default_hostname +} + +output "function_app_identity_principal_id" { + description = "The Principal ID of the Function App's managed identity" + value = azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id +} + +/*---------------------------------+ + | Storage Outputs | + +---------------------------------*/ +output "storage_account_name" { + description = "The name of the Storage Account used for autoscaler state" + value = azurerm_storage_account.autoscaler.name +} + +output "storage_account_id" { + description = "The ID of the Storage Account" + value = azurerm_storage_account.autoscaler.id +} + +output "storage_container_name" { + description = "The name of the blob container for autoscaler state" + value = azurerm_storage_container.autoscaler_state.name +} + +/*---------------------------------+ + | Monitoring Outputs | + +---------------------------------*/ +output "application_insights_name" { + description = "The name of the Application Insights instance" + value = azurerm_application_insights.autoscaler.name +} + +output "application_insights_instrumentation_key" { + description = "The instrumentation key for Application Insights" + value = azurerm_application_insights.autoscaler.instrumentation_key + sensitive = true +} + +output "application_insights_connection_string" { + description = "The connection string for Application Insights" + value = azurerm_application_insights.autoscaler.connection_string + sensitive = true +} + +/*---------------------------------+ + | VMSS Configuration Outputs | + +---------------------------------*/ +output "vmss_name" { + description = "The name of the VM Scale Set being managed" + value = var.vmss.name +} + +output "vmss_resource_group" { + description = "The resource group of the VM Scale Set" + value = local.vmss_resource_group +} diff --git a/stackguardian_private_runner/azure/autoscaler/provider.tf b/stackguardian_private_runner/azure/autoscaler/provider.tf new file mode 100644 index 0000000..8961116 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/provider.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + external = { + source = "hashicorp/external" + version = ">= 2.0" + } + null = { + source = "hashicorp/null" + version = ">= 3.0" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/stackguardian_private_runner/azure/autoscaler/rbac.tf b/stackguardian_private_runner/azure/autoscaler/rbac.tf new file mode 100644 index 0000000..7b74d71 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/rbac.tf @@ -0,0 +1,53 @@ +/*-------------------------------------------+ + | Role Assignments for Function App MI | + +-------------------------------------------*/ +# Mirrors aws/autoscaler/iam.tf in shape: all permissions granted to the +# autoscaler runtime live here. The Function App runs with a system-assigned +# managed identity; these role_assignments grant it the access it needs to +# read/scale the VMSS and read/write its own state blobs. + +# Manage VM Scale Set instances (scale in/out, instance lifecycle) +resource "azurerm_role_assignment" "vmss_contributor" { + scope = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${local.vmss_resource_group}/providers/Microsoft.Compute/virtualMachineScaleSets/${var.vmss.name}" + role_definition_name = "Virtual Machine Contributor" + principal_id = azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id +} + +# Read VMSS instance metadata (status, count) within the resource group +resource "azurerm_role_assignment" "vmss_reader" { + scope = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${local.vmss_resource_group}" + role_definition_name = "Reader" + principal_id = azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id +} + +# Storage access for autoscaler state blobs. +# RBAC mode: Storage Blob Data Owner (runtime host coordination). +# Connection-string mode: Storage Blob Data Contributor (app-level blobs only). +resource "azurerm_role_assignment" "storage_blob_contributor" { + scope = azurerm_storage_account.autoscaler.id + role_definition_name = var.storage.use_rbac ? "Storage Blob Data Owner" : "Storage Blob Data Contributor" + principal_id = azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id +} + +# Queue + Table data contributor required by the Functions runtime in RBAC mode +resource "azurerm_role_assignment" "storage_queue_data_contributor" { + count = var.storage.use_rbac ? 1 : 0 + scope = azurerm_storage_account.autoscaler.id + role_definition_name = "Storage Queue Data Contributor" + principal_id = azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id +} + +resource "azurerm_role_assignment" "storage_table_data_contributor" { + count = var.storage.use_rbac ? 1 : 0 + scope = azurerm_storage_account.autoscaler.id + role_definition_name = "Storage Table Data Contributor" + principal_id = azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id +} + +# Network Contributor on the VMSS resource group is required for VMSS scaling +# operations that touch VNets, subnets, NSGs. +resource "azurerm_role_assignment" "network_contributor" { + scope = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${local.vmss_resource_group}" + role_definition_name = "Network Contributor" + principal_id = azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id +} diff --git a/stackguardian_private_runner/azure/autoscaler/schemas/input_schema.json b/stackguardian_private_runner/azure/autoscaler/schemas/input_schema.json new file mode 100644 index 0000000..da53865 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/schemas/input_schema.json @@ -0,0 +1,246 @@ +{ + "type": "object", + "properties": { + "stackguardian": { + "title": "StackGuardian Configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "api_uri": { + "title": "API Region", + "type": "string", + "enum": [ + "https://api.app.stackguardian.io", + "https://api.us.stackguardian.io", + "https://testapi.qa.stackguardian.io" + ], + "enumNames": [ + "EU1 - Europe", + "US1 - East", + "DASH - QA Environment" + ], + "default": "https://api.app.stackguardian.io" + }, + "api_key": { + "title": "API Key", + "type": "string", + "pattern": "^(sg[uo]_.*|\\$\\{secret::[a-zA-Z0-9_-]+\\})$", + "minLength": 1 + }, + "org_name": { + "title": "Organization Name", + "type": "string", + "default": "" + } + }, + "required": ["api_key"] + }, + "azure_location": { + "title": "Azure Region", + "type": "string", + "enum": [ + "eastus", + "eastus2", + "westus", + "westus2", + "westus3", + "centralus", + "northcentralus", + "southcentralus", + "westcentralus", + "canadacentral", + "canadaeast", + "brazilsouth", + "northeurope", + "westeurope", + "uksouth", + "ukwest", + "francecentral", + "germanywestcentral", + "norwayeast", + "swedencentral", + "switzerlandnorth", + "italynorth", + "polandcentral", + "spaincentral", + "eastasia", + "southeastasia", + "japaneast", + "japanwest", + "australiaeast", + "australiasoutheast", + "centralindia", + "southindia", + "koreacentral", + "koreasouth", + "uaenorth", + "southafricanorth", + "qatarcentral", + "israelcentral" + ], + "default": "westeurope" + }, + "resource_group_name": { + "title": "Resource Group Name", + "type": "string", + "minLength": 1 + }, + "vmss": { + "title": "VM Scale Set", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "title": "VMSS Name", + "type": "string", + "minLength": 1 + }, + "resource_group_name": { + "title": "VMSS Resource Group", + "type": "string", + "default": "" + } + }, + "required": ["name"] + }, + "override_names": { + "title": "Resource Naming", + "type": "object", + "additionalProperties": false, + "properties": { + "global_prefix": { + "title": "Global Prefix", + "type": "string", + "default": "SG_RUNNER" + }, + "include_org_in_prefix": { + "title": "Include Org in Prefix", + "type": "boolean", + "default": false + }, + "runner_group_name": { + "title": "Runner Group Name", + "type": "string", + "default": "" + } + }, + "required": ["global_prefix"] + }, + "scaling": { + "title": "Scaling Configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "min_runners": { + "title": "Minimum Runners", + "type": "integer", + "default": 1, + "minimum": 1 + }, + "max_runners": { + "title": "Maximum Runners", + "type": "integer", + "default": 3, + "minimum": 1 + }, + "desired_runners": { + "title": "Desired Runners", + "type": ["integer", "null"], + "default": null + }, + "scale_out_threshold": { + "title": "Scale Out Threshold", + "type": "integer", + "default": 3 + }, + "scale_in_threshold": { + "title": "Scale In Threshold", + "type": "integer", + "default": 1, + "minimum": 1 + }, + "scale_out_step": { + "title": "Scale Out Step", + "type": "integer", + "default": 1, + "minimum": 1 + }, + "scale_in_step": { + "title": "Scale In Step", + "type": "integer", + "default": 1, + "minimum": 1 + }, + "scale_out_cooldown_duration": { + "title": "Scale Out Cooldown (minutes)", + "type": "integer", + "default": 4, + "minimum": 4 + }, + "scale_in_cooldown_duration": { + "title": "Scale In Cooldown (minutes)", + "type": "integer", + "default": 5 + }, + "schedule_cron": { + "title": "Schedule (NCRONTAB)", + "type": "string", + "default": "0 */1 * * * *" + } + } + }, + "storage": { + "title": "Storage Backend", + "type": "object", + "additionalProperties": false, + "properties": { + "account_tier": { + "title": "Storage Account Tier", + "type": "string", + "enum": ["Standard", "Premium"], + "default": "Standard" + }, + "account_replication_type": { + "title": "Replication Type", + "type": "string", + "enum": ["LRS", "GRS", "RAGRS", "ZRS"], + "default": "LRS" + }, + "account_url": { + "title": "Storage Account URL", + "type": "string", + "default": "" + }, + "use_rbac": { + "title": "Use RBAC (Managed Identity)", + "type": "boolean", + "default": false + } + } + }, + "application_insights_retention_in_days": { + "title": "Application Insights Retention (days)", + "type": "integer", + "enum": [30, 60, 90, 120, 180, 270, 365, 550, 730], + "default": 30 + }, + "autoscaler_repo": { + "title": "Autoscaler Repository", + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "title": "Repository URL", + "type": "string", + "default": "https://github.com/StackGuardian/sg-runner-autoscaler" + }, + "branch": { + "title": "Branch", + "type": "string", + "default": "main" + } + } + } + }, + "required": ["stackguardian", "resource_group_name", "vmss"] +} diff --git a/stackguardian_private_runner/azure/autoscaler/schemas/ui_schema.json b/stackguardian_private_runner/azure/autoscaler/schemas/ui_schema.json new file mode 100644 index 0000000..af78c57 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/schemas/ui_schema.json @@ -0,0 +1,161 @@ +{ + "ui:title": "StackGuardian Runner Autoscaler - Azure", + "ui:description": "Deploy an Azure Function-based autoscaler that monitors StackGuardian job queues and scales a VM Scale Set up or down based on workload demand.", + "ui:order": [ + "stackguardian", + "azure_location", + "resource_group_name", + "vmss", + "scaling", + "override_names", + "storage", + "application_insights_retention_in_days", + "autoscaler_repo" + ], + "stackguardian": { + "ui:title": "StackGuardian Configuration", + "ui:description": "Configure your StackGuardian platform connection", + "ui:order": ["api_uri", "api_key", "org_name"], + "api_uri": { + "ui:widget": "select", + "ui:description": "Select your StackGuardian platform region" + }, + "api_key": { + "ui:placeholder": "sgu_*** or sgo_*** or ${secret::SECRET_NAME}", + "ui:description": "Your organization's API key (sgo_*/sgu_*) or a secret reference (${secret::SECRET_NAME})" + }, + "org_name": { + "ui:placeholder": "your-org-name", + "ui:description": "Your organization name on the StackGuardian Platform (extracted from environment if empty)" + } + }, + "azure_location": { + "ui:widget": "select", + "ui:placeholder": "Select Azure Region", + "ui:description": "The Azure region where autoscaler resources will be deployed" + }, + "resource_group_name": { + "ui:placeholder": "my-resource-group", + "ui:description": "The name of the existing Azure Resource Group where autoscaler resources will be deployed" + }, + "vmss": { + "ui:title": "VM Scale Set", + "ui:description": "Configure the existing VM Scale Set to be managed by the autoscaler", + "ui:order": ["name", "resource_group_name"], + "name": { + "ui:placeholder": "my-runner-vmss", + "ui:description": "Name of the existing VM Scale Set running StackGuardian runner instances" + }, + "resource_group_name": { + "ui:placeholder": "vmss-resource-group", + "ui:description": "Resource group containing the VM Scale Set (defaults to the autoscaler resource group if empty)" + } + }, + "override_names": { + "ui:title": "Resource Naming Configuration", + "ui:description": "Customize resource names (optional)", + "ui:order": ["global_prefix", "include_org_in_prefix", "runner_group_name"], + "global_prefix": { + "ui:placeholder": "SG_RUNNER", + "ui:description": "Prefix used for naming all Azure resources created by this module" + }, + "include_org_in_prefix": { + "ui:widget": "checkbox", + "ui:description": "When enabled, appends organization name to the global prefix (e.g., SG_RUNNER_demo-org)" + }, + "runner_group_name": { + "ui:placeholder": "my-runner-group", + "ui:description": "Override the default StackGuardian runner group name (output from runner_group module)" + } + }, + "scaling": { + "ui:title": "Scaling Configuration", + "ui:description": "Configure auto-scaling thresholds and behavior", + "ui:order": [ + "min_runners", + "max_runners", + "desired_runners", + "scale_out_threshold", + "scale_in_threshold", + "scale_out_step", + "scale_in_step", + "scale_out_cooldown_duration", + "scale_in_cooldown_duration", + "schedule_cron" + ], + "min_runners": { + "ui:description": "Minimum number of runners to maintain" + }, + "max_runners": { + "ui:description": "Maximum number of runners the autoscaler can provision" + }, + "desired_runners": { + "ui:description": "Optional initial capacity. Leave empty to let the autoscaler choose between min and max on first run" + }, + "scale_out_threshold": { + "ui:description": "Number of queued jobs to trigger scale-out" + }, + "scale_in_threshold": { + "ui:description": "Number of queued jobs below which to trigger scale-in" + }, + "scale_out_step": { + "ui:description": "Number of instances to add when scaling out" + }, + "scale_in_step": { + "ui:description": "Number of instances to remove when scaling in" + }, + "scale_out_cooldown_duration": { + "ui:description": "Minutes to wait after scale-out before scaling again (minimum: 4)" + }, + "scale_in_cooldown_duration": { + "ui:description": "Minutes to wait after scale-in before scaling again" + }, + "schedule_cron": { + "ui:placeholder": "0 */1 * * * *", + "ui:description": "NCRONTAB expression that drives the Function App timer trigger (every minute by default)" + } + }, + "storage": { + "ui:title": "Storage Backend", + "ui:description": "Storage configuration for autoscaler state (cooldown timestamps)", + "ui:order": [ + "account_tier", + "account_replication_type", + "account_url", + "use_rbac" + ], + "account_tier": { + "ui:widget": "select", + "ui:description": "Performance tier of the storage account" + }, + "account_replication_type": { + "ui:widget": "select", + "ui:description": "Replication strategy for the storage account" + }, + "account_url": { + "ui:placeholder": "https://mystorageaccount.privatelink.blob.core.windows.net", + "ui:description": "Optional explicit storage account URL (for private endpoints / VNet-integrated deployments)" + }, + "use_rbac": { + "ui:widget": "checkbox", + "ui:description": "Use managed identity (RBAC) instead of connection strings for storage authentication" + } + }, + "application_insights_retention_in_days": { + "ui:widget": "select", + "ui:description": "How long Application Insights keeps autoscaler telemetry. Application Insights only supports the listed values" + }, + "autoscaler_repo": { + "ui:title": "Autoscaler Repository", + "ui:description": "Configure the source repository for the autoscaler Function App code (optional)", + "ui:order": ["url", "branch"], + "url": { + "ui:placeholder": "https://github.com/StackGuardian/sg-runner-autoscaler", + "ui:description": "Git repository URL containing the autoscaler Function App source code" + }, + "branch": { + "ui:placeholder": "main", + "ui:description": "Git branch to use for the autoscaler Function App source code" + } + } +} diff --git a/stackguardian_private_runner/azure/autoscaler/scripts/deploy_function.sh b/stackguardian_private_runner/azure/autoscaler/scripts/deploy_function.sh new file mode 100644 index 0000000..f42761f --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/scripts/deploy_function.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -e + +echo "Deploying autoscaler function code..." + +TEMP_DIR=$(mktemp -d) +# Always clean up, including when set -e aborts the script early +trap 'rm -rf "$TEMP_DIR"' EXIT + +echo "Cloning repository: $REPO_URL (branch: $REPO_BRANCH)" +git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" "$TEMP_DIR/repo" +CLONED_COMMIT=$(git -C "$TEMP_DIR/repo" rev-parse HEAD) +echo "Cloned commit: $CLONED_COMMIT" + +cd "$TEMP_DIR/repo" +cp azure_requirements.txt requirements.txt + +# Create deployment package +echo "Creating deployment package..." +zip -r "$TEMP_DIR/deploy.zip" . -x ".git/*" + +# Deploy using Azure CLI +# Exit codes 1/3 = health check or SyncTrigger timeout after successful +# upload (known issue with Flex Consumption plans). Tolerate them; fail +# on anything else. +set +e +az functionapp deployment source config-zip \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$FUNCTION_APP_NAME" \ + --src "$TEMP_DIR/deploy.zip" \ + --build-remote true \ + --timeout 300 +AZ_EXIT=$? +set -e +if [ "$AZ_EXIT" -ne 0 ] && [ "$AZ_EXIT" -ne 1 ] && [ "$AZ_EXIT" -ne 3 ]; then + echo "ERROR: Deployment failed with exit code $AZ_EXIT" + exit "$AZ_EXIT" +fi + +echo "Function code deployed successfully to $FUNCTION_APP_NAME" diff --git a/stackguardian_private_runner/azure/autoscaler/storage.tf b/stackguardian_private_runner/azure/autoscaler/storage.tf new file mode 100644 index 0000000..a133090 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/storage.tf @@ -0,0 +1,41 @@ +/*-------------------------------------------+ + | Storage Account for Autoscaler State | + +-------------------------------------------*/ + +resource "random_string" "storage_suffix" { + length = 8 + special = false + upper = false +} + +# Storage account name must be globally unique, 3-24 chars, lowercase alphanumeric only +resource "azurerm_storage_account" "autoscaler" { + name = "${local.storage_account_prefix}${random_string.storage_suffix.result}" + resource_group_name = var.resource_group_name + location = var.azure_location + account_tier = var.storage.account_tier + account_replication_type = var.storage.account_replication_type + + # Security settings + min_tls_version = "TLS1_2" + allow_nested_items_to_be_public = false + public_network_access_enabled = true + + tags = merge(local.common_tags, { + Name = "${local.storage_account_prefix}${random_string.storage_suffix.result}" + }) +} + +# Container for autoscaler state +resource "azurerm_storage_container" "autoscaler_state" { + name = "autoscaler-state" + storage_account_id = azurerm_storage_account.autoscaler.id + container_access_type = "private" +} + +# Container for function app deployments (required for Flex Consumption) +resource "azurerm_storage_container" "deployments" { + name = "deployments" + storage_account_id = azurerm_storage_account.autoscaler.id + container_access_type = "private" +} diff --git a/stackguardian_private_runner/azure/autoscaler/variables.tf b/stackguardian_private_runner/azure/autoscaler/variables.tf new file mode 100644 index 0000000..098dc95 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/variables.tf @@ -0,0 +1,233 @@ +/*-------------------+ + | General Variables | + +-------------------*/ +variable "azure_location" { + description = "The Azure region where resources will be deployed" + type = string + default = "westeurope" +} + +variable "resource_group_name" { + description = "The name of the existing Azure Resource Group where resources will be deployed" + type = string +} + +/*-----------------------------------+ + | StackGuardian Resources Variables | + +-----------------------------------*/ +variable "stackguardian" { + description = "StackGuardian platform configuration" + type = object({ + api_key = string + api_uri = optional(string, "https://api.app.stackguardian.io") + org_name = optional(string, "") + }) + sensitive = true + + validation { + condition = can(regex("^sg[uo]_.*", var.stackguardian.api_key)) + error_message = "The api_key must be a valid StackGuardian API key starting with 'sgu_' (user) or 'sgo_' (organization)." + } + + validation { + condition = contains([ + "https://api.app.stackguardian.io", + "https://api.us.stackguardian.io", + "https://testapi.qa.stackguardian.io" + ], var.stackguardian.api_uri) + error_message = "The api_uri must be either 'https://api.app.stackguardian.io' (EU1), 'https://api.us.stackguardian.io' (US1) or 'https://testapi.qa.stackguardian.io' (DASH)." + } +} + +variable "override_names" { + description = <= 4 + error_message = "The scale_out_cooldown_duration must be at least 4 minutes." + } + + validation { + condition = var.scaling.scale_in_threshold >= 1 + error_message = "The scale_in_threshold must be at least 1." + } + + validation { + condition = var.scaling.scale_in_step >= 1 + error_message = "The scale_in_step must be at least 1." + } + + validation { + condition = var.scaling.scale_out_step >= 1 + error_message = "The scale_out_step must be at least 1." + } + + validation { + condition = var.scaling.min_runners >= 1 + error_message = "The min_runners must be at least 1." + } + + validation { + condition = var.scaling.min_runners <= var.scaling.scale_out_threshold + error_message = "The min_runners must be less than or equal to scale_out_threshold." + } + + validation { + condition = var.scaling.scale_in_threshold <= var.scaling.scale_out_threshold + error_message = "The scale_in_threshold must be less than or equal to scale_out_threshold." + } + + validation { + condition = var.scaling.max_runners >= var.scaling.min_runners + error_message = "The max_runners must be greater than or equal to min_runners." + } + + validation { + condition = ( + var.scaling.desired_runners == null || + (var.scaling.desired_runners >= var.scaling.min_runners && var.scaling.desired_runners <= var.scaling.max_runners) + ) + error_message = "The desired_runners must be between min_runners and max_runners (inclusive)." + } +} + +/*---------------------------+ + | Storage Backend Variables | + +---------------------------*/ +variable "storage" { + description = < Runner Groups** to verify registration. +3. Configure your workflows to use the runner group name. +4. Monitor runner health and job execution in the StackGuardian platform. + +For auto-scaling capabilities, use the VMSS and Autoscaler templates instead. diff --git a/stackguardian_private_runner/azure/azure_runner/README.md b/stackguardian_private_runner/azure/azure_runner/README.md new file mode 100644 index 0000000..9388eb9 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/README.md @@ -0,0 +1,380 @@ +# StackGuardian Private Runner - Azure Single Runner Module + +> Part of [StackGuardian Private Runner](../../README.md) — [Azure stack overview](../DOCUMENTATION.md) · [platform template doc](DOCUMENTATION.md) + +Deploy a standalone StackGuardian Private Runner on an Azure Linux VM. This module creates a single VM instance that automatically registers with your StackGuardian runner group and executes workflow jobs in your Azure environment. + +## Overview + +This Terraform module provisions a single Azure Linux VM-based private runner for StackGuardian. The runner connects to the StackGuardian platform, retrieves workflow jobs, and executes them within your Azure VNet. It supports both existing and newly created VNet deployments with optional public IP assignment. + +### What Gets Created + +- **Linux Virtual Machine**: Single runner instance with configurable VM size and OS disk +- **Network Interface**: Connected to your VNet subnet with optional public IP +- **Network Security Group**: Configurable inbound rules with full outbound access +- **SSH Key Pair**: Auto-generated 4096-bit RSA key or user-provided public key +- **VNet and Subnet** (optional): When `create_network = true`, creates new networking infrastructure, optionally with VNet service endpoints +- **Public IP** (optional): When `associate_public_ip = true`, assigns a static public IP + +## Prerequisites + +1. **StackGuardian Runner Group**: Create a runner group on StackGuardian platform first +2. **Storage Backend Identity**: User-Assigned Managed Identity resource ID (from the runner group module) +3. **Custom VM Image**: Pre-built image with required dependencies (docker, cron, jq, sg-runner) + - Use the companion [Packer module](../packer/) to build a custom image +4. **Azure Resource Group**: Existing resource group for deployment +5. **StackGuardian API Key**: Organization or user API key (`sgo_*` or `sgu_*`) + +## Quick Start + +### Step 1: Build the Image + +Use the companion Packer module to build a custom image with all required dependencies: + +```bash +cd ../packer +terraform init +terraform apply +``` + +### Step 2: Deploy the Runner + +```bash +terraform init +terraform plan +terraform apply +``` + +### Basic Configuration Example + +```hcl +module "azure_runner" { + source = "./azure/azure_runner" + + vm_image_id = "/subscriptions/.../providers/Microsoft.Compute/images/sg-runner-ubuntu-22_04-lts-gen2" + resource_group_name = "my-resource-group" + azure_location = "westeurope" + + runner_group_name = "my-runner-group" + runner_group_token = "runner-group-token" + storage_backend_identity_id = "/subscriptions/.../providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity" + + stackguardian = { + api_key = "sgu_your_api_key" + } + + network = { + vnet_id = "/subscriptions/.../providers/Microsoft.Network/virtualNetworks/my-vnet" + subnet_id = "/subscriptions/.../providers/Microsoft.Network/virtualNetworks/my-vnet/subnets/my-subnet" + } +} +``` + +## Configuration + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| `vm_image_id` | Custom image ID with pre-installed dependencies (docker, cron, jq, sg-runner) | `string` | +| `resource_group_name` | Name of the Azure Resource Group for deployment | `string` | +| `runner_group_name` | Name of the StackGuardian runner group | `string` | +| `runner_group_token` | Runner group token for registration (from runner_group module) | `string` | +| `storage_backend_identity_id` | Resource ID of the User-Assigned Managed Identity for storage backend access | `string` | +| `stackguardian.api_key` | StackGuardian API key (starts with `sgu_` or `sgo_`) | `string` | +| `network` | Either set `create_network = true`, or provide both `vnet_id` and `subnet_id` | `object` | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `vm_size` | Azure VM size (min 4 vCPU, 8GB RAM recommended) | `Standard_D4s_v3` | +| `azure_location` | Target Azure region | `westeurope` | +| `stackguardian.org_name` | Organization name (extracted from the `SG_ORG_ID` environment variable if not provided) | `""` | +| `stackguardian.api_uri` | StackGuardian API endpoint. One of `https://api.app.stackguardian.io` (EU1), `https://api.us.stackguardian.io` (US1), `https://testapi.qa.stackguardian.io` (DASH) | `https://api.app.stackguardian.io` | +| `override_names.global_prefix` | Prefix for all resource names | `SG_RUNNER` | +| `override_names.include_org_in_prefix` | Append the org name to the prefix (e.g. `SG_RUNNER_demo-org`) | `false` | +| `network.create_network` | Create a new VNet and Subnet | `false` | +| `network.vnet_address_space` | Address space for new VNet | `["10.0.0.0/16"]` | +| `network.subnet_address_prefix` | Address prefix for new subnet | `10.0.1.0/24` | +| `network.associate_public_ip` | Assign a public IP to the VM | `false` | +| `network.create_network_infrastructure` | Create a NAT Gateway (with public IP) for outbound access from the created subnet | `false` | +| `network.service_endpoints` | Azure VNet service endpoints to enable on the created subnet (e.g. `["Microsoft.Storage"]`) | `[]` | +| `network.proxy_url` | HTTP proxy URL for private network deployments | `""` | +| `network.additional_nsg_ids` | Additional NSG IDs to associate with the NIC | `[]` | +| `os_disk.caching` | OS disk caching mode (None, ReadOnly, ReadWrite) | `ReadWrite` | +| `os_disk.storage_account_type` | OS disk storage type | `Premium_LRS` | +| `os_disk.disk_size_gb` | OS disk size in GB (minimum 30) | `100` | +| `firewall.admin_username` | SSH admin username | `azureuser` | +| `firewall.ssh_public_key` | Custom SSH public key content | `""` | +| `firewall.generate_ssh_key` | Auto-generate a 4096-bit RSA key pair (private key is stored in state) | `false` | +| `firewall.ssh_access_rules` | Map of CIDR blocks for SSH access | `{}` | +| `firewall.additional_inbound_rules` | Additional NSG inbound rules | `{}` | +| `runner_startup_timeout` | Seconds to wait for Docker before shutdown | `300` | + +### Configuration Examples + +#### Basic Configuration (Existing VNet) + +```hcl +module "azure_runner" { + source = "./azure/azure_runner" + + vm_image_id = "/subscriptions/.../providers/Microsoft.Compute/images/sg-runner-ubuntu" + resource_group_name = "my-resource-group" + + runner_group_name = "my-runner-group" + runner_group_token = "my-token" + storage_backend_identity_id = "/subscriptions/.../providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity" + + stackguardian = { + api_key = "sgu_your_api_key" + } + + network = { + vnet_id = "/subscriptions/.../Microsoft.Network/virtualNetworks/my-vnet" + subnet_id = "/subscriptions/.../Microsoft.Network/virtualNetworks/my-vnet/subnets/my-subnet" + } +} +``` + +#### Create New Network with Public IP + +```hcl +module "azure_runner" { + source = "./azure/azure_runner" + + vm_image_id = "/subscriptions/.../providers/Microsoft.Compute/images/sg-runner-ubuntu" + resource_group_name = "my-resource-group" + azure_location = "westeurope" + + runner_group_name = "my-runner-group" + runner_group_token = "my-token" + storage_backend_identity_id = "/subscriptions/.../providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity" + + stackguardian = { + api_key = "sgu_your_api_key" + org_name = "my-org" + } + + network = { + create_network = true + vnet_address_space = ["10.0.0.0/16"] + subnet_address_prefix = "10.0.1.0/24" + associate_public_ip = true + } +} +``` + +#### Private Deployment with Service Endpoints + +```hcl +module "azure_runner" { + source = "./azure/azure_runner" + + vm_image_id = "/subscriptions/.../providers/Microsoft.Compute/images/sg-runner-ubuntu" + resource_group_name = "my-resource-group" + + runner_group_name = "my-runner-group" + runner_group_token = "my-token" + storage_backend_identity_id = "/subscriptions/.../providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity" + + stackguardian = { + api_key = "sgu_your_api_key" + } + + network = { + create_network = true + create_network_infrastructure = true + associate_public_ip = false + + # Reach Azure services over the backbone instead of the public internet + service_endpoints = ["Microsoft.Storage", "Microsoft.KeyVault"] + } +} +``` + +#### Custom Firewall Rules and Disk Configuration + +```hcl +module "azure_runner" { + source = "./azure/azure_runner" + + vm_image_id = "/subscriptions/.../providers/Microsoft.Compute/images/sg-runner-ubuntu" + resource_group_name = "my-resource-group" + vm_size = "Standard_D8s_v3" + + runner_group_name = "my-runner-group" + runner_group_token = "my-token" + storage_backend_identity_id = "/subscriptions/.../providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity" + + stackguardian = { + api_key = "sgu_your_api_key" + } + + network = { + vnet_id = "/subscriptions/.../Microsoft.Network/virtualNetworks/my-vnet" + subnet_id = "/subscriptions/.../Microsoft.Network/virtualNetworks/my-vnet/subnets/my-subnet" + } + + firewall = { + admin_username = "azureuser" + generate_ssh_key = true + ssh_access_rules = { + office = "10.0.0.0/8" + vpn = "192.168.1.0/24" + } + } + + os_disk = { + caching = "ReadWrite" + storage_account_type = "Premium_LRS" + disk_size_gb = 200 + } +} +``` + +## Usage + +### Deployment + +```bash +# Initialize Terraform +terraform init + +# Review the plan +terraform plan + +# Apply the configuration +terraform apply +``` + +### Cleanup + +```bash +# Destroy all resources +terraform destroy +``` + +## Architecture + +### Resource Organization + +| File | Purpose | +|------|---------| +| `provider.tf` | Azure, StackGuardian, and utility provider configuration | +| `variables.tf` | Input variable definitions and validation | +| `locals.tf` | Environment lookup, computed values, naming conventions, network mode logic | +| `data.tf` | Azure subscription data source | +| `vm.tf` | Linux VM, SSH key generation, User-Assigned Managed Identity | +| `network.tf` | VNet, Subnet, NSG, Public IP, Network Interface | +| `outputs.tf` | Module outputs | +| `templates/register_runner.sh.tpl` | Runner registration and startup script | + +### Resource Naming Convention + +Resources are named using the pattern: `{sanitized_prefix}-{resource-type}` + +The `global_prefix` is lowercased with underscores replaced by hyphens. When +`override_names.include_org_in_prefix = true`, the StackGuardian org name is appended to the prefix +first (e.g. `SG_RUNNER_demo-org` -> `sg-runner-demo-org`). + +Examples with the default prefix `SG_RUNNER`: +- VM: `sg-runner-private-runner` +- NSG: `sg-runner-nsg` +- VNet: `sg-runner-vnet` +- Subnet: `sg-runner-subnet` +- NIC: `sg-runner-nic` +- Public IP: `sg-runner-pip` +- NAT Gateway: `sg-runner-natgw` + +## Troubleshooting + +### Common Issues + +1. **Runner not registering with StackGuardian** + - Verify the runner group exists and the API key has access + - Check network connectivity to the StackGuardian API + - Review instance cloud-init logs: `/var/log/cloud-init-output.log` + +2. **SSH connection failures** + - Verify NSG rules allow SSH from your IP (check `ssh_access_rules`) + - Confirm the generated SSH key is being used: `terraform output -raw ssh_private_key` + - Ensure `associate_public_ip = true` if connecting over the internet + +3. **Storage backend access denied** + - Verify `storage_backend_identity_id` is correct + - Ensure the Managed Identity has the required role assignments on the storage account + +4. **Docker not starting** + - Check `runner_startup_timeout` is sufficient (default: 300s) + - Verify the custom image has Docker pre-installed + +### Debugging Commands + +```bash +# Connect via Azure Serial Console +az serial-console connect --resource-group --name + +# Check cloud-init logs (once connected) +sudo cat /var/log/cloud-init-output.log + +# Check Docker status +sudo systemctl status docker + +# Test StackGuardian API connectivity +curl -v https://api.app.stackguardian.io/health + +# View VM status +az vm show --resource-group --name --query provisioningState +``` + +## Outputs + +| Output | Description | +|--------|-------------| +| `vm_id` | The ID of the Azure Linux Virtual Machine | +| `vm_name` | The name of the Azure Linux Virtual Machine | +| `vm_private_ip` | The private IP address of the VM | +| `vm_public_ip` | The public IP address of the VM (if assigned) | +| `network_interface_id` | The ID of the network interface | +| `network_security_group_id` | The ID of the network security group | +| `vnet_id` | The ID of the VNet (created or existing) | +| `subnet_id` | The ID of the subnet (created or existing) | +| `ssh_private_key` | The generated SSH private key (if `generate_ssh_key = true`) | +| `ssh_public_key` | The SSH public key used for the VM | +| `storage_backend_identity_id` | The resource ID of the storage backend managed identity | + +## Security Considerations + +- **SSH-Only Authentication**: Password authentication is disabled; only SSH key-based access is allowed +- **Bring Your Own Key**: `generate_ssh_key` is off by default; when enabled, the generated 4096-bit RSA private key is stored in Terraform state and exposed as a sensitive output +- **NSG Defaults**: Inbound traffic is blocked by default; SSH access must be explicitly configured via `ssh_access_rules` +- **Full Outbound**: Security group allows all outbound traffic for runner operations +- **Managed Identity**: User-Assigned Managed Identity provides secure access to storage backend without credentials +- **Service Endpoints**: Optional VNet service endpoints keep traffic to Azure services on the Azure backbone + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.0 | +| azurerm | >= 3.0 | +| stackguardian | >= 1.3.3 | +| external | >= 2.0 | +| random | >= 3.0 | +| tls | >= 4.0 | + +## Next Steps + +After deployment: + +1. Verify the runner appears in your StackGuardian runner group +2. Create a workflow that targets your runner group +3. Monitor runner health in the StackGuardian dashboard + +## Support + +- [StackGuardian Documentation](https://docs.stackguardian.io) +- [GitHub Issues](https://github.com/StackGuardian/terraform-stackguardian-modules/issues) diff --git a/stackguardian_private_runner/azure/azure_runner/data.tf b/stackguardian_private_runner/azure/azure_runner/data.tf new file mode 100644 index 0000000..54bb1f3 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/data.tf @@ -0,0 +1,2 @@ +# Azure subscription data +data "azurerm_client_config" "current" {} diff --git a/stackguardian_private_runner/azure/azure_runner/locals.tf b/stackguardian_private_runner/azure/azure_runner/locals.tf new file mode 100644 index 0000000..8111644 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/locals.tf @@ -0,0 +1,74 @@ +# Extract SG org name from environment if not provided +data "external" "env" { + program = [ + "sh", + "-c", + "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\"}'" + ] +} + +locals { + # StackGuardian configuration - use provided values or extract from environment + # Use nonsensitive() for non-secret fields to prevent sensitivity propagation + sg_org_name = ( + nonsensitive(var.stackguardian.org_name) != "" + ? nonsensitive(var.stackguardian.org_name) + : data.external.env.result.sg_org_name + ) + sg_api_uri = nonsensitive(var.stackguardian.api_uri) + + # Network mode logic + create_network = var.network.create_network + use_existing_network = !local.create_network + + # NAT gateway is only meaningful when the module owns the subnet + # (an existing subnet may already have its own NAT/firewall/route). + create_nat_gateway = var.network.create_network_infrastructure && local.create_network + + # Service endpoints only apply to the subnet this module creates + subnet_service_endpoints = ( + length(var.network.service_endpoints) > 0 + ? var.network.service_endpoints + : null + ) + + # Subnet ID (created or existing) + subnet_id = ( + local.create_network + ? azurerm_subnet.this[0].id + : var.network.subnet_id + ) + + # SSH key logic: provided key > generated key + use_generated_key = var.firewall.generate_ssh_key && var.firewall.ssh_public_key == "" + ssh_public_key = ( + local.use_generated_key + ? tls_private_key.ssh[0].public_key_openssh + : var.firewall.ssh_public_key + ) + + # Computed prefix with optional org name (matches AWS pattern) + effective_prefix = ( + var.override_names.include_org_in_prefix && local.sg_org_name != "" + ? "${var.override_names.global_prefix}_${local.sg_org_name}" + : var.override_names.global_prefix + ) + + # Sanitized prefix for Azure naming (lowercase, hyphens) + sanitized_prefix = replace(lower(local.effective_prefix), "_", "-") + + # Common tags for all taggable resources + common_tags = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } + + # VM name + vm_name = "${local.sanitized_prefix}-private-runner" + + # Combine NSG IDs + all_nsg_ids = concat( + [azurerm_network_security_group.this.id], + var.network.additional_nsg_ids + ) +} diff --git a/stackguardian_private_runner/azure/azure_runner/network.tf b/stackguardian_private_runner/azure/azure_runner/network.tf new file mode 100644 index 0000000..f84548d --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/network.tf @@ -0,0 +1,177 @@ +/*-------------------------------------------+ + | Virtual Network (optional - when creating)| + +-------------------------------------------*/ +resource "azurerm_virtual_network" "this" { + count = local.create_network ? 1 : 0 + + name = "${local.sanitized_prefix}-vnet" + address_space = var.network.vnet_address_space + location = var.azure_location + resource_group_name = var.resource_group_name + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-vnet" + }) +} + +resource "azurerm_subnet" "this" { + count = local.create_network ? 1 : 0 + + name = "${local.sanitized_prefix}-subnet" + resource_group_name = var.resource_group_name + virtual_network_name = azurerm_virtual_network.this[0].name + address_prefixes = [var.network.subnet_address_prefix] + + # Opt-in: reach Azure services over the backbone instead of the public internet + service_endpoints = local.subnet_service_endpoints +} + +/*-------------------------------------------+ + | Network Security Group | + +-------------------------------------------*/ +resource "azurerm_network_security_group" "this" { + name = "${local.sanitized_prefix}-nsg" + location = var.azure_location + resource_group_name = var.resource_group_name + + # Allow SSH (only if SSH access rules are provided) + dynamic "security_rule" { + for_each = var.firewall.ssh_access_rules + content { + name = "SSH-${security_rule.key}" + priority = 100 + index(keys(var.firewall.ssh_access_rules), security_rule.key) + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = security_rule.value + destination_address_prefix = "*" + } + } + + # Additional inbound rules + dynamic "security_rule" { + for_each = var.firewall.additional_inbound_rules + content { + name = security_rule.key + priority = security_rule.value.priority + direction = security_rule.value.direction + access = security_rule.value.access + protocol = security_rule.value.protocol + source_port_range = security_rule.value.source_port_range + destination_port_range = security_rule.value.destination_port_range + source_address_prefix = security_rule.value.source_address_prefix + destination_address_prefix = security_rule.value.destination_address_prefix + } + } + + # Allow all outbound traffic + security_rule { + name = "AllowAllOutbound" + priority = 4096 + direction = "Outbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-nsg" + }) +} + +/*-------------------------------------------+ + | NAT Gateway (optional) | + +-------------------------------------------*/ +# Provides outbound internet access for runners on a private (created) subnet. +# Only created when network.create_network_infrastructure = true AND +# the module is creating the subnet itself. +resource "azurerm_public_ip" "nat" { + count = local.create_nat_gateway ? 1 : 0 + + name = "${local.sanitized_prefix}-nat-pip" + location = var.azure_location + resource_group_name = var.resource_group_name + allocation_method = "Static" + sku = "Standard" + zones = ["1"] + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-nat-pip" + }) +} + +resource "azurerm_nat_gateway" "this" { + count = local.create_nat_gateway ? 1 : 0 + + name = "${local.sanitized_prefix}-natgw" + location = var.azure_location + resource_group_name = var.resource_group_name + sku_name = "Standard" + idle_timeout_in_minutes = 10 + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-natgw" + }) +} + +resource "azurerm_nat_gateway_public_ip_association" "this" { + count = local.create_nat_gateway ? 1 : 0 + + nat_gateway_id = azurerm_nat_gateway.this[0].id + public_ip_address_id = azurerm_public_ip.nat[0].id +} + +resource "azurerm_subnet_nat_gateway_association" "this" { + count = local.create_nat_gateway ? 1 : 0 + + subnet_id = azurerm_subnet.this[0].id + nat_gateway_id = azurerm_nat_gateway.this[0].id +} + +/*-------------------------------------------+ + | Public IP (optional) | + +-------------------------------------------*/ +resource "azurerm_public_ip" "this" { + count = var.network.associate_public_ip ? 1 : 0 + + name = "${local.sanitized_prefix}-pip" + location = var.azure_location + resource_group_name = var.resource_group_name + allocation_method = "Static" + sku = "Standard" + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-pip" + }) +} + +/*-------------------------------------------+ + | Network Interface | + +-------------------------------------------*/ +resource "azurerm_network_interface" "this" { + name = "${local.sanitized_prefix}-nic" + location = var.azure_location + resource_group_name = var.resource_group_name + + ip_configuration { + name = "internal" + subnet_id = local.subnet_id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = var.network.associate_public_ip ? azurerm_public_ip.this[0].id : null + } + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-nic" + }) +} + +# Associate NSG with NIC +resource "azurerm_network_interface_security_group_association" "this" { + network_interface_id = azurerm_network_interface.this.id + network_security_group_id = azurerm_network_security_group.this.id +} diff --git a/stackguardian_private_runner/azure/azure_runner/outputs.tf b/stackguardian_private_runner/azure/azure_runner/outputs.tf new file mode 100644 index 0000000..a3a5346 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/outputs.tf @@ -0,0 +1,67 @@ +/*-----------------------+ + | VM Resource Outputs | + +-----------------------*/ +output "vm_id" { + description = "The ID of the Azure Linux Virtual Machine" + value = azurerm_linux_virtual_machine.this.id +} + +output "vm_name" { + description = "The name of the Azure Linux Virtual Machine" + value = azurerm_linux_virtual_machine.this.name +} + +output "vm_private_ip" { + description = "The private IP address of the VM" + value = azurerm_network_interface.this.private_ip_address +} + +output "vm_public_ip" { + description = "The public IP address of the VM (if assigned)" + value = var.network.associate_public_ip ? azurerm_public_ip.this[0].ip_address : null +} + +/*-----------------------+ + | Network Outputs | + +-----------------------*/ +output "network_interface_id" { + description = "The ID of the network interface" + value = azurerm_network_interface.this.id +} + +output "network_security_group_id" { + description = "The ID of the network security group" + value = azurerm_network_security_group.this.id +} + +output "vnet_id" { + description = "The ID of the VNet (created or existing)" + value = local.create_network ? azurerm_virtual_network.this[0].id : var.network.vnet_id +} + +output "subnet_id" { + description = "The ID of the subnet (created or existing)" + value = local.subnet_id +} + +/*-----------------------+ + | SSH Key Outputs | + +-----------------------*/ +output "ssh_private_key" { + description = "The generated SSH private key (if generate_ssh_key = true)" + value = local.use_generated_key ? tls_private_key.ssh[0].private_key_pem : null + sensitive = true +} + +output "ssh_public_key" { + description = "The SSH public key used for the VM" + value = local.ssh_public_key +} + +/*-----------------------+ + | Identity Outputs | + +-----------------------*/ +output "storage_backend_identity_id" { + description = "The resource ID of the storage backend managed identity" + value = var.storage_backend_identity_id +} diff --git a/stackguardian_private_runner/azure/azure_runner/provider.tf b/stackguardian_private_runner/azure/azure_runner/provider.tf new file mode 100644 index 0000000..44ed0d8 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/provider.tf @@ -0,0 +1,38 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + # Ceiling is load-bearing: azurerm 5.x removed azurerm_subnet.service_endpoints, + # which this module uses for network.service_endpoints + version = ">= 3.0, < 5.0" + } + stackguardian = { + source = "registry.terraform.io/StackGuardian/stackguardian" + version = ">= 1.3.3" + } + external = { + source = "hashicorp/external" + version = ">= 2.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + tls = { + source = "hashicorp/tls" + version = ">= 4.0" + } + } +} + +provider "azurerm" { + features {} +} + +provider "stackguardian" { + api_key = var.stackguardian.api_key + org_name = local.sg_org_name + api_uri = local.sg_api_uri +} diff --git a/stackguardian_private_runner/azure/azure_runner/schemas/input_schema.json b/stackguardian_private_runner/azure/azure_runner/schemas/input_schema.json new file mode 100644 index 0000000..2202fc0 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/schemas/input_schema.json @@ -0,0 +1,302 @@ +{ + "type": "object", + "properties": { + "stackguardian": { + "title": "StackGuardian Platform", + "type": "object", + "properties": { + "api_uri": { + "title": "API Region", + "type": "string", + "default": "https://api.app.stackguardian.io", + "enum": [ + "https://api.app.stackguardian.io", + "https://api.us.stackguardian.io", + "https://testapi.qa.stackguardian.io" + ], + "enumNames": [ + "EU1 - Europe", + "US1 - United States", + "DASH - QA" + ] + }, + "api_key": { + "title": "API Key", + "type": "string", + "pattern": "^(sg[uo]_.*|\\$\\{secret::[a-zA-Z0-9_-]+\\})$", + "minLength": 1 + }, + "org_name": { + "title": "Organization Name", + "type": "string", + "default": "" + } + }, + "required": ["api_key"], + "additionalProperties": false + }, + "azure_location": { + "title": "Azure Region", + "type": "string", + "default": "westeurope" + }, + "resource_group_name": { + "title": "Resource Group Name", + "type": "string", + "minLength": 1 + }, + "runner_group_name": { + "title": "Runner Group Name", + "type": "string", + "minLength": 1 + }, + "runner_group_token": { + "title": "Runner Group Token", + "type": "string", + "minLength": 1 + }, + "storage_backend_identity_id": { + "title": "Storage Backend Identity ID", + "type": "string", + "minLength": 1 + }, + "vm_image_id": { + "title": "VM Image ID", + "type": "string", + "pattern": "^/subscriptions/.*", + "minLength": 1 + }, + "vm_size": { + "title": "VM Size", + "type": "string", + "default": "Standard_D4s_v3" + }, + "network": { + "title": "Network", + "type": "object", + "properties": { + "create_network": { + "title": "Create New VNet & Subnet", + "type": "boolean", + "default": false + }, + "vnet_id": { + "title": "Existing VNet ID", + "type": "string", + "default": "" + }, + "subnet_id": { + "title": "Existing Subnet ID", + "type": "string", + "default": "" + }, + "vnet_address_space": { + "title": "VNet Address Space", + "type": "array", + "items": { "type": "string" }, + "default": ["10.0.0.0/16"] + }, + "subnet_address_prefix": { + "title": "Subnet Address Prefix", + "type": "string", + "default": "10.0.1.0/24" + }, + "associate_public_ip": { + "title": "Associate Public IP", + "type": "boolean", + "default": false + }, + "create_network_infrastructure": { + "title": "Create NAT Gateway", + "type": "boolean", + "default": false + }, + "service_endpoints": { + "title": "Service Endpoints", + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "proxy_url": { + "title": "Proxy URL", + "type": "string", + "default": "" + }, + "additional_nsg_ids": { + "title": "Additional NSG IDs", + "type": "array", + "items": { "type": "string" }, + "default": [] + } + }, + "dependencies": { + "create_network": { + "oneOf": [ + { + "properties": { + "create_network": { "enum": [false] }, + "vnet_id": { + "title": "Existing VNet ID", + "type": "string", + "minLength": 1 + }, + "subnet_id": { + "title": "Existing Subnet ID", + "type": "string", + "minLength": 1 + } + }, + "required": ["vnet_id", "subnet_id"] + }, + { + "properties": { + "create_network": { "enum": [true] }, + "vnet_address_space": { + "title": "VNet Address Space", + "type": "array", + "items": { "type": "string" }, + "default": ["10.0.0.0/16"] + }, + "subnet_address_prefix": { + "title": "Subnet Address Prefix", + "type": "string", + "default": "10.0.1.0/24" + }, + "service_endpoints": { + "title": "Service Endpoints", + "type": "array", + "items": { "type": "string" }, + "default": [] + } + } + } + ] + } + } + }, + "os_disk": { + "title": "OS Disk", + "type": "object", + "properties": { + "caching": { + "title": "Disk Caching", + "type": "string", + "default": "ReadWrite", + "enum": ["None", "ReadOnly", "ReadWrite"] + }, + "storage_account_type": { + "title": "Storage Account Type", + "type": "string", + "default": "Premium_LRS", + "enum": ["Standard_LRS", "StandardSSD_LRS", "Premium_LRS", "Premium_ZRS"] + }, + "disk_size_gb": { + "title": "Disk Size (GB)", + "type": "number", + "default": 100, + "minimum": 30 + } + }, + "additionalProperties": false + }, + "firewall": { + "title": "Firewall & SSH", + "type": "object", + "properties": { + "admin_username": { + "title": "Admin Username", + "type": "string", + "default": "azureuser" + }, + "generate_ssh_key": { + "title": "Generate SSH Key", + "type": "boolean", + "default": false + }, + "ssh_public_key": { + "title": "SSH Public Key", + "type": "string", + "default": "" + }, + "ssh_access_rules": { + "title": "SSH Access Rules", + "type": "object", + "default": {}, + "additionalProperties": { "type": "string" } + }, + "additional_inbound_rules": { + "title": "Additional Inbound Rules", + "type": "object", + "default": {}, + "additionalProperties": { + "type": "object", + "properties": { + "priority": { "type": "number" }, + "direction": { "type": "string", "default": "Inbound", "enum": ["Inbound", "Outbound"] }, + "access": { "type": "string", "default": "Allow", "enum": ["Allow", "Deny"] }, + "protocol": { "type": "string" }, + "source_port_range": { "type": "string", "default": "*" }, + "destination_port_range": { "type": "string" }, + "source_address_prefix": { "type": "string" }, + "destination_address_prefix": { "type": "string", "default": "*" } + }, + "required": ["priority", "protocol", "destination_port_range", "source_address_prefix"] + } + } + }, + "dependencies": { + "generate_ssh_key": { + "oneOf": [ + { + "properties": { + "generate_ssh_key": { "enum": [false] }, + "ssh_public_key": { + "type": "string", + "minLength": 1 + } + }, + "required": ["ssh_public_key"] + }, + { + "properties": { + "generate_ssh_key": { "enum": [true] } + } + } + ] + } + } + }, + "runner_startup_timeout": { + "title": "Runner Startup Timeout (seconds)", + "type": "number", + "default": 300, + "minimum": 30 + }, + "override_names": { + "title": "Resource Naming", + "type": "object", + "properties": { + "global_prefix": { + "title": "Global Prefix", + "type": "string", + "default": "SG_RUNNER" + }, + "include_org_in_prefix": { + "title": "Include Org in Prefix", + "type": "boolean", + "default": false + } + }, + "required": ["global_prefix"], + "additionalProperties": false + } + }, + "required": [ + "stackguardian", + "resource_group_name", + "runner_group_name", + "runner_group_token", + "storage_backend_identity_id", + "vm_image_id" + ] +} diff --git a/stackguardian_private_runner/azure/azure_runner/schemas/ui_schema.json b/stackguardian_private_runner/azure/azure_runner/schemas/ui_schema.json new file mode 100644 index 0000000..acbd79e --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/schemas/ui_schema.json @@ -0,0 +1,179 @@ +{ + "ui:order": [ + "stackguardian", + "azure_location", + "resource_group_name", + "runner_group_name", + "runner_group_token", + "storage_backend_identity_id", + "vm_image_id", + "vm_size", + "network", + "os_disk", + "firewall", + "runner_startup_timeout", + "override_names" + ], + "stackguardian": { + "ui:title": "StackGuardian Platform", + "ui:description": "Connection details used by the runner to register itself with the StackGuardian platform.", + "ui:order": ["api_uri", "api_key", "org_name"], + "api_uri": { + "ui:widget": "select", + "ui:description": "Region of the StackGuardian control plane your organization runs in." + }, + "api_key": { + "ui:placeholder": "sgu_*** or ${secret::SECRET_NAME}", + "ui:description": "Your organization's API key (sgo_*/sgu_*) or a secret reference (${secret::SECRET_NAME})." + }, + "org_name": { + "ui:placeholder": "demo-org", + "ui:description": "Override the StackGuardian organization name. Leave blank to derive it from the API key." + } + }, + "azure_location": { + "ui:placeholder": "westeurope", + "ui:description": "Azure region where the VM and supporting resources are deployed." + }, + "resource_group_name": { + "ui:placeholder": "rg-stackguardian-runner", + "ui:description": "Name of the existing Azure Resource Group where resources will be created." + }, + "runner_group_name": { + "ui:description": "Name of the StackGuardian runner group this instance will register against (output of the runner_group module)." + }, + "runner_group_token": { + "ui:placeholder": "${secret::RUNNER_GROUP_TOKEN}", + "ui:description": "Registration token for the runner group (output of the runner_group module). Use a secret reference." + }, + "storage_backend_identity_id": { + "ui:placeholder": "/subscriptions/.../userAssignedIdentities/...", + "ui:description": "Resource ID of the User-Assigned Managed Identity used by the runner to access the storage backend (output of the runner_group module)." + }, + "vm_image_id": { + "ui:placeholder": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/images/{name}", + "ui:description": "Custom Azure image ID with docker, cron, jq, and sg-runner pre-installed (typically built via the sibling Packer module)." + }, + "vm_size": { + "ui:placeholder": "Standard_D4s_v3", + "ui:description": "VM SKU. Minimum 4 vCPU and 8 GB RAM recommended." + }, + "network": { + "ui:title": "Network", + "ui:description": "Either supply existing VNet/Subnet IDs or create a new VNet and Subnet.", + "ui:order": [ + "create_network", + "vnet_id", + "subnet_id", + "vnet_address_space", + "subnet_address_prefix", + "associate_public_ip", + "create_network_infrastructure", + "service_endpoints", + "proxy_url", + "additional_nsg_ids" + ], + "create_network": { + "ui:widget": "radio", + "ui:options": { "inline": true }, + "ui:description": "Create a new VNet and Subnet for the runner. When false, you must provide existing VNet and Subnet IDs." + }, + "vnet_id": { + "ui:placeholder": "/subscriptions/.../virtualNetworks/...", + "ui:description": "Resource ID of an existing Virtual Network." + }, + "subnet_id": { + "ui:placeholder": "/subscriptions/.../subnets/...", + "ui:description": "Resource ID of an existing Subnet within the VNet above." + }, + "vnet_address_space": { + "ui:description": "CIDR blocks for the new VNet.", + "items": { "ui:placeholder": "10.0.0.0/16" } + }, + "subnet_address_prefix": { + "ui:placeholder": "10.0.1.0/24", + "ui:description": "CIDR for the new subnet inside the VNet address space." + }, + "associate_public_ip": { + "ui:widget": "radio", + "ui:options": { "inline": true }, + "ui:description": "Assign a public IP to the VM. Disable for fully private deployments." + }, + "create_network_infrastructure": { + "ui:widget": "radio", + "ui:options": { "inline": true }, + "ui:description": "Create a NAT Gateway with public IP and associate it with the (created) subnet for outbound internet access." + }, + "service_endpoints": { + "ui:description": "Azure VNet service endpoints to enable on the created subnet (e.g. Microsoft.Storage). Only applies when \"Create New VNet & Subnet\" is enabled.", + "items": { "ui:placeholder": "Microsoft.Storage" } + }, + "proxy_url": { + "ui:placeholder": "http://proxy.internal:3128", + "ui:description": "Optional HTTP proxy URL for private network deployments." + }, + "additional_nsg_ids": { + "ui:description": "Additional NSG resource IDs to associate with the NIC.", + "items": { "ui:placeholder": "/subscriptions/.../networkSecurityGroups/..." } + } + }, + "os_disk": { + "ui:title": "OS Disk", + "ui:description": "OS disk configuration for the VM.", + "ui:order": ["caching", "storage_account_type", "disk_size_gb"], + "caching": { "ui:widget": "select" }, + "storage_account_type": { "ui:widget": "select" }, + "disk_size_gb": { + "ui:description": "OS disk size in GB. Minimum 30." + } + }, + "firewall": { + "ui:title": "Firewall & SSH", + "ui:description": "SSH access and additional inbound rules for the NSG attached to the VM.", + "ui:order": [ + "admin_username", + "generate_ssh_key", + "ssh_public_key", + "ssh_access_rules", + "additional_inbound_rules" + ], + "admin_username": { + "ui:placeholder": "azureuser", + "ui:description": "Linux admin user on the VM." + }, + "generate_ssh_key": { + "ui:widget": "radio", + "ui:options": { "inline": true }, + "ui:description": "**Warning:** when true, an RSA keypair is generated and the private key is stored in Terraform state and exposed as a sensitive output. Prefer providing your own ssh_public_key." + }, + "ssh_public_key": { + "ui:widget": "textarea", + "ui:options": { "rows": 3 }, + "ui:placeholder": "ssh-rsa AAAA... user@host", + "ui:description": "SSH public key authorized to log in as the admin user." + }, + "ssh_access_rules": { + "ui:description": "Map of friendly names to source CIDRs allowed to reach port 22 (e.g. office: 1.2.3.4/32)." + }, + "additional_inbound_rules": { + "ui:description": "Map of named NSG inbound rules. Keys are rule names; values configure priority, protocol, ports and source CIDRs." + } + }, + "runner_startup_timeout": { + "ui:description": "Maximum seconds to wait for Docker to start before shutting down the instance." + }, + "override_names": { + "ui:title": "Resource Naming", + "ui:description": "Customize the prefix used for naming Azure resources created by this module.", + "ui:order": ["global_prefix", "include_org_in_prefix"], + "global_prefix": { + "ui:placeholder": "SG_RUNNER", + "ui:description": "Prefix used for naming all Azure resources created by this module." + }, + "include_org_in_prefix": { + "ui:widget": "radio", + "ui:options": { "inline": true }, + "ui:description": "When true, appends the org name to the prefix (e.g. SG_RUNNER_demo-org). The org name comes from the StackGuardian Platform section above." + } + } +} diff --git a/stackguardian_private_runner/azure/azure_runner/templates/register_runner.sh.tpl b/stackguardian_private_runner/azure/azure_runner/templates/register_runner.sh.tpl new file mode 100644 index 0000000..f854039 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/templates/register_runner.sh.tpl @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -e + +startup_log_file="/var/log/sg_runner_startup.log" + +echo ">> Starting StackGuardian Private Runner registration..." | tee -a "$startup_log_file" + +## Configure HTTP/HTTPS proxy if provided (for private network deployments) +proxy_url="${proxy_url}" +if [ -n "$proxy_url" ]; then + echo ">> Configuring proxy: $proxy_url" | tee -a "$startup_log_file" + export HTTP_PROXY="$proxy_url" + export HTTPS_PROXY="$proxy_url" + export http_proxy="$proxy_url" + export https_proxy="$proxy_url" +fi + +## Wait for Docker with timeout +## Sometimes registration fails because docker.service is not ready. +## We will check if docker.service is ready and continue. +## Otherwise, sleep for 1 second and try again. +timeout="${sg_runner_startup_timeout}" +counter=0 + +until systemctl is-active --quiet docker; do + echo ">> Docker not ready.. Trying again in 1 second." | tee -a "$startup_log_file" + sleep 1 + counter=$((counter + 1)) + + if [ $counter -ge $timeout ]; then + echo ">> ERROR: Docker failed to start after $timeout seconds. Shutting down instance." | tee -a "$startup_log_file" + shutdown -h now + fi +done + +echo ">> Docker is ready." | tee -a "$startup_log_file" + +## Register Private Runner +export SG_BASE_API="${sg_api_uri}/api/v1" +sg-runner register \ + --organization "${sg_org_name}" \ + --runner-group "${sg_runner_group_name}" \ + --sg-node-token "${sg_runner_group_token}" 2>&1 | tee -a "$startup_log_file" + +echo ">> StackGuardian Private Runner registration complete." | tee -a "$startup_log_file" diff --git a/stackguardian_private_runner/azure/azure_runner/variables.tf b/stackguardian_private_runner/azure/azure_runner/variables.tf new file mode 100644 index 0000000..d0f778f --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/variables.tf @@ -0,0 +1,235 @@ +/*------------------------+ + | VM Instance Variables | + +------------------------*/ +variable "vm_size" { + description = "The Azure VM size for Private Runner (min 4 vCPU, 8GB RAM recommended)" + type = string + default = "Standard_D4s_v3" # 4 vCPU, 16GB RAM +} + +variable "vm_image_id" { + description = <= 30 + error_message = "OS disk size must be at least 30 GB." + } +} + +/*------------------------------+ + | SSH Connection Variables | + +------------------------------*/ +variable "firewall" { + description = < Part of [StackGuardian Private Runner](../../README.md) — [Azure stack overview](../DOCUMENTATION.md) · [platform template doc](DOCUMENTATION.md) + +Terraform module that builds a custom Azure managed image preloaded with the StackGuardian Private Runner agent, Terraform, and OpenTofu, using HashiCorp Packer driven from a `null_resource` `local-exec`. + +## Overview + +This module provisions an Azure managed image that the sibling `azure_runner` autoscaler module (or any VM/VMSS) can boot directly. Packer is invoked from Terraform, the build runs against either a temporary or an existing VNet/subnet, and the resulting image ID is parsed back out of the Packer manifest and exposed as a Terraform output. Optionally, the module can also create the destination resource group and clean up the image on `terraform destroy`. + +### What Gets Created + +- **`azurerm_resource_group`** (optional, count-gated by `create_resource_group`): destination resource group for the image. +- **`null_resource.packer_build`**: runs the shared `../../packer/scripts/build.sh`, which installs Packer, then inits and builds `image.pkr.hcl`. Runs on the first apply, and again only when `packer_config.rebuild_image_token` changes. +- **`data.external.packer_image_id`**: parses `packer_manifest.log` to extract the resource ID of the freshly built managed image. +- **`terraform_data.image_id`**: records that image ID in state, so later plans read it from state instead of the build log. +- **`null_resource.image_cleanup`** (when `cleanup_images_on_destroy = true`): destroy-time hook that runs `scripts/cleanup_image.sh` to delete the image from Azure. + +## Prerequisites + +- An Azure subscription and credentials available to the runner (one of: `az login`, `ARM_*` service-principal env vars, or managed identity). +- Permission to create managed images in the target resource group (and to create the resource group itself, if `create_resource_group = true`). +- Outbound network access from the build VM to package mirrors (Ubuntu archive / RHEL repos, HashiCorp/OpenTofu releases). If the network is locked down, supply `network.proxy_url`. +- `sh`, `curl`, and `unzip` available on the machine running Terraform — `../../packer/scripts/build.sh` uses them to bootstrap Packer (default version `1.14.1`). + +## Quick Start + +### Step 1: Configure Azure credentials + +```bash +az login +az account set --subscription "" +``` + +### Step 2: Apply the module + +```bash +terraform init +terraform plan +terraform apply +``` + +### Basic Configuration Example + +```hcl +module "private_runner_image" { + source = "./packer" + + resource_group_name = "sg-runner-images-rg" + create_resource_group = true +} +``` + +## Configuration + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| `resource_group_name` | Resource group where the image will be stored | `string` | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `existing_image_id` | Existing managed image to hand back instead of building one - see [Skipping the build](#skipping-the-build) | `""` | +| `azure_location` | Target Azure region for the build | `"westeurope"` | +| `create_resource_group` | Create the resource group as part of this deployment | `false` | +| `vm_size` | Packer build VM size (min 2 vCPU / 4GB RAM) | `"Standard_D2s_v3"` | +| `network.vnet_name` | Existing VNet to attach the build VM to | `""` (Packer creates temp networking) | +| `network.subnet_name` | Existing subnet inside the VNet above | `""` | +| `network.resource_group_name` | Resource group containing the existing VNet | `""` | +| `network.proxy_url` | HTTP proxy URL forwarded to the build VM | `""` | +| `os.publisher` | Image publisher — `Canonical` or `RedHat` | `"Canonical"` | +| `os.offer` | Marketplace image offer | `"0001-com-ubuntu-server-jammy"` | +| `os.sku` | Marketplace image SKU | `"22_04-lts-gen2"` | +| `os.version` | Marketplace image version | `"latest"` | +| `os.update_os_before_install` | Run full OS update before installing the agent | `true` | +| `os.user_script` | Extra shell script executed after agent install | `""` | +| `packer_config.version` | Packer version bootstrapped by `../../packer/scripts/build.sh` | `"1.14.1"` | +| `packer_config.rebuild_image_token` | Change to any new value to build a fresh image once | `""` | +| `packer_config.cleanup_images_on_destroy` | Delete the image on `terraform destroy` | `true` | +| `image_name_prefix` | Prefix for the generated image name | `"sg-runner"` | +| `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` | +| `terraform.primary_version` | Default Terraform version pre-installed | `""` | +| `terraform.additional_versions` | Extra Terraform versions to install | `[]` | +| `opentofu.primary_version` | Default OpenTofu version pre-installed | `""` | +| `opentofu.additional_versions` | Extra OpenTofu versions to install | `[]` | + +### When Packer Runs + +Building an image takes several minutes, so this module builds **once per state** and +then reuses what it built: + +| Situation | Result | +|-----------|--------| +| First apply | Packer builds the image, and its ID is recorded in state | +| Every plan/apply after that | No build, no diff — the image ID comes from state | +| `rebuild_image_token` changed to a new value | Packer builds a new image, 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_image_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 image ID lives in `terraform_data.image_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 VM/VMSS is no longer +replaced on every apply either. + +> **Note:** `packer_config.cleanup_images_on_destroy` (default `true`) only ever +> touches the image this deployment built — on destroy, and on the rebuild that +> supersedes it. Images belonging to other deployments are never deleted, since the +> module never adopts an image it did not build. + +### Skipping the Build + +Set `existing_image_id` to an image you already have and this module builds +nothing: + +```hcl +module "packer" { + source = "../../azure/packer" + existing_image_id = "/subscriptions//resourceGroups//providers/Microsoft.Compute/images/" + + azure_location = "westeurope" + resource_group_name = "sg-runner-rg" +} +``` + +No build VM, no Packer download, no destroy-time cleanup — the module creates no +resources at all, and `image_id` returns the value you passed. Every other build +input is ignored. It exists so a caller can wire `module.packer.image_id` into a +VM once and choose per deployment whether an image gets built; the module call +itself cannot be `count`-ed, because it declares its own provider. + +The image is used as-is: it has to live in `azure_location` and carry docker, +cron, jq and sg-runner. Nothing is validated at plan time. + +> **Note:** `existing_image_id` is meant for a fresh state. Adding it to a state +> that already built an image destroys the build records, and the destroy-time +> cleanup deletes the built image — including when that is the image you are +> passing in. Run `tofu state rm null_resource.image_cleanup[0]` first if that +> is what you are doing. + +### Configuration Examples + +#### Basic Configuration + +```hcl +module "private_runner_image" { + source = "./packer" + + resource_group_name = "sg-runner-images-rg" + create_resource_group = true +} +``` + +#### Advanced Configuration + +```hcl +module "private_runner_image" { + source = "./packer" + + azure_location = "northeurope" + resource_group_name = "sg-runner-images-rg" + create_resource_group = false + vm_size = "Standard_D4s_v3" + image_name_prefix = "sg-runner-prod" + + network = { + vnet_name = "shared-vnet" + subnet_name = "build-subnet" + resource_group_name = "shared-network-rg" + proxy_url = "http://proxy.internal:8080" + } + + os = { + publisher = "RedHat" + offer = "RHEL" + sku = "9-lvm-gen2" + version = "latest" + update_os_before_install = true + user_script = file("${path.module}/hardening.sh") + } + + packer_config = { + version = "1.14.1" + rebuild_image_token = "2026-07-30-tofu-1.11" + cleanup_images_on_destroy = true + } + + terraform = { + primary_version = "1.6.6" + additional_versions = ["1.5.7"] + } + + opentofu = { + primary_version = "1.8.0" + additional_versions = ["1.7.3"] + } +} +``` + +## Usage + +```bash +terraform init +terraform validate +terraform plan +terraform apply +``` + +The first `apply` runs the Packer build; every `apply` after that reuses the image ID recorded in state and does nothing. After changing `terraform`/`opentofu`/`os`/`network` settings, set `packer_config.rebuild_image_token` to a new value to build once with the new configuration (see [When Packer Runs](#when-packer-runs)). + +### Cleanup + +```bash +terraform destroy +``` + +When `packer_config.cleanup_images_on_destroy = true` (default), the destroy provisioner runs `scripts/cleanup_image.sh` and deletes the managed image from Azure. If disabled, the image will remain in the resource group and must be deleted manually (see `cleanup_commands` output). + +## Architecture + +### Resource Organization + +- `image.pkr.hcl` — Packer template (azure-arm builder + provisioners). +- `main.tf` — Terraform resources (RG, build, manifest parsing, recorded image ID, cleanup). +- `locals.tf` — derived values (OS family, SSH username, image name, RG selection). +- `variables.tf` — input variables. +- `outputs.tf` — exported image metadata and cleanup commands. +- `provider.tf` — provider requirements. +- `../../packer/scripts/build.sh` — shared: installs Packer at `packer_config.version`, runs the build, writes `packer_manifest.log`. +- `../../packer/scripts/setup.sh` — shared: provisions the image (Docker, jq, cron, sg-runner, Terraform/OpenTofu). +- `scripts/cleanup_image.sh` — destroy-time image deletion (Azure-specific). + +### Resource Naming Convention + +Image name follows: `{image_name_prefix}-{os_family}-{os.sku}` where `os_family` is `ubuntu` for `Canonical` and `rhel` for `RedHat`. A timestamp suffix is appended by Packer at build time. + +## Troubleshooting + +### Common Issues + +1. **`az` CLI / Azure auth not available** + - Run `az login`, or export `ARM_CLIENT_ID` / `ARM_CLIENT_SECRET` / `ARM_TENANT_ID` / `ARM_SUBSCRIPTION_ID` before `terraform apply`. + +2. **Packer install fails in `../../packer/scripts/build.sh`** + - Confirm outbound HTTPS to `releases.hashicorp.com`. Behind a proxy, set `HTTPS_PROXY` in the runner environment as well as `network.proxy_url`. + +3. **`image_id` output is blank** + - The build failed before producing an artifact, so nothing was recorded in state. Inspect the Terraform `local-exec` output and re-run the script manually with the same env vars to surface the Packer error, then change `packer_config.rebuild_image_token` to retry the build. + - A missing or deleted `packer_manifest.log` does **not** blank the output: the ID is read from `terraform_data.image_id` in state. + +4. **Cleanup script can't find the image** + - The image was already deleted manually or by a prior destroy. The script exits non-fatally; you can ignore it. + +5. **Existing VNet/Subnet not used** + - All three of `network.vnet_name`, `network.subnet_name`, and `network.resource_group_name` must be set; otherwise Packer falls back to creating temporary networking. + +### Debugging Commands + +```bash +# Tail the latest build output +tail -f packer_manifest.log + +# Re-run the build script manually +PACKER_TEMPLATE=./image.pkr.hcl PACKER_VERSION=1.14.1 sh ../../packer/scripts/build.sh + +# Inspect the produced image +az image show --ids "$(terraform output -raw image_id)" + +# List all images produced by this prefix +az image list --resource-group "$(terraform output -raw resource_group_name)" \ + --query "[?starts_with(name, 'sg-runner')].{name:name, id:id}" -o table +``` + +## Outputs + +| Output | Description | +|--------|-------------| +| `image_id` | Resource ID of the created Azure managed image | +| `image_info` | Comprehensive image metadata (id, location, RG, OS family/SKU, image name, prefix, cleanup settings) | +| `resource_group_name` | Resource group where the image is stored | +| `cleanup_commands` | Azure CLI commands to inspect or manually delete the image | + +## Security Considerations + +- Image stays inside the customer's subscription and resource group — nothing is published to a shared gallery. +- No inbound ports are exposed; Packer uses ephemeral SSH credentials over the (temporary or supplied) subnet. +- HTTP proxy support via `network.proxy_url` for restricted egress environments. +- `os.user_script` allows custom hardening, CA cert injection, or extra tooling without forking the module. + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.4.0 (`terraform_data`) | +| azurerm | >= 3.0 | +| null | >= 3.0 | +| external | >= 2.0 | + +## Next Steps + +Pass `module.private_runner_image.image_id` into the sibling `azure_runner` module (or your own VMSS) to boot StackGuardian private runners from the freshly built image. + +## Support + +- StackGuardian docs: +- Module source / issues: this repository diff --git a/stackguardian_private_runner/azure/packer/image.pkr.hcl b/stackguardian_private_runner/azure/packer/image.pkr.hcl new file mode 100644 index 0000000..ad30877 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/image.pkr.hcl @@ -0,0 +1,98 @@ +variable "azure_location" {} +variable "resource_group_name" {} +variable "vm_size" {} +variable "image_publisher" {} +variable "image_offer" {} +variable "image_sku" {} +variable "image_version" {} +variable "image_name_prefix" {} +variable "sg_runner_pre_release" { default = "false" } +variable "os_family" {} +variable "ssh_username" {} +variable "update_os_before_install" {} +variable "terraform_version" {} +variable "terraform_versions" {} +variable "opentofu_version" {} +variable "opentofu_versions" {} +variable "user_script" {} +variable "vnet_name" { default = "" } +variable "subnet_name" { default = "" } +variable "vnet_resource_group_name" { default = "" } +variable "proxy_url" { default = "" } + +packer { + required_plugins { + azure = { + source = "github.com/hashicorp/azure" + version = "~> 2" + } + } +} + +source "azure-arm" "this" { + # Use Azure CLI authentication (must be logged in) + use_azure_cli_auth = true + + # Location and VM configuration + location = var.azure_location + vm_size = var.vm_size + + # Source image configuration + os_type = "Linux" + image_publisher = var.image_publisher + image_offer = var.image_offer + image_sku = var.image_sku + image_version = var.image_version + + # Output image configuration + managed_image_name = "${var.image_name_prefix}-${var.os_family}-${var.image_sku}-{{timestamp}}" + managed_image_resource_group_name = var.resource_group_name + + # Network configuration (empty means Packer creates temporary VNet) + virtual_network_name = var.vnet_name != "" ? var.vnet_name : null + virtual_network_subnet_name = var.subnet_name != "" ? var.subnet_name : null + virtual_network_resource_group_name = var.vnet_resource_group_name != "" ? var.vnet_resource_group_name : null + + # SSH configuration + ssh_username = var.ssh_username + + # Azure-specific settings + azure_tags = { + purpose = "stackguardian-private-runner" + os = var.os_family + } +} + +build { + sources = ["source.azure-arm.this"] + + # Install dependencies and StackGuardian runner + provisioner "shell" { + script = "../../packer/scripts/setup.sh" + environment_vars = [ + "OS_FAMILY=${var.os_family}", + "SSH_USERNAME=${var.ssh_username}", + "UPDATE_OS=${var.update_os_before_install}", + "TERRAFORM_VERSION=${var.terraform_version}", + "TERRAFORM_VERSIONS=${var.terraform_versions}", + "OPENTOFU_VERSION=${var.opentofu_version}", + "OPENTOFU_VERSIONS=${var.opentofu_versions}", + "SG_RUNNER_PRE_RELEASE=${var.sg_runner_pre_release}", + "USER_SCRIPT=${var.user_script}", + "PRIVATE_NETWORK=${var.vnet_name != "" ? "true" : "false"}", + "PROXY_URL=${var.proxy_url}" + ] + } + + # Azure-specific: deprovision the VM (required for image generalization) + provisioner "shell" { + execute_command = "chmod +x {{ .Path }}; {{ .Vars }} sudo -E sh '{{ .Path }}'" + expect_disconnect = true + inline = [ + "export HISTSIZE=0", + "sync", + "/usr/sbin/waagent -force -deprovision+user || true" + ] + inline_shebang = "/bin/sh -x" + } +} diff --git a/stackguardian_private_runner/azure/packer/locals.tf b/stackguardian_private_runner/azure/packer/locals.tf new file mode 100644 index 0000000..38475cd --- /dev/null +++ b/stackguardian_private_runner/azure/packer/locals.tf @@ -0,0 +1,35 @@ +locals { + # Determine OS family from publisher + os_family = var.os.publisher == "Canonical" ? "ubuntu" : "rhel" + + # SSH username based on OS + ssh_usernames = { + ubuntu = "ubuntu" + rhel = "azureuser" + } + ssh_username = local.ssh_usernames[local.os_family] + + # Image name with timestamp placeholder (actual timestamp added by Packer) + image_name = "${var.image_name_prefix}-${local.os_family}-${var.os.sku}" + + # Resource group name (created or existing) + resource_group_name = var.create_resource_group ? azurerm_resource_group.packer[0].name : var.resource_group_name + + # Network configuration (empty strings mean Packer creates temporary networking) + use_existing_network = var.network.vnet_name != "" && var.network.subnet_name != "" + + # Whether to build at all. An existing_image_id turns every build resource off. + build_image = var.existing_image_id == "" + + # The image built by this module, as recorded in state. Packer runs on the first + # apply and then only when packer_config.rebuild_image_token changes, so this + # value stays stable across re-plans. Null when the build was skipped. + built_image_id = one(terraform_data.image_id[*].output) + + # What the module reports: the image it was handed, otherwise the one it built. + image_id = ( + var.existing_image_id != "" + ? var.existing_image_id + : (local.built_image_id == null ? "" : local.built_image_id) + ) +} diff --git a/stackguardian_private_runner/azure/packer/main.tf b/stackguardian_private_runner/azure/packer/main.tf new file mode 100644 index 0000000..be96627 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/main.tf @@ -0,0 +1,143 @@ +/*-------------------------------------------+ + | Optional Resource Group Creation | + +-------------------------------------------*/ +resource "azurerm_resource_group" "packer" { + count = var.create_resource_group ? 1 : 0 + + name = var.resource_group_name + location = var.azure_location + + tags = { + purpose = "stackguardian-private-runner-images" + } +} + +/*-------------------------------------------+ + | Build Custom Image Using Packer | + +-------------------------------------------*/ +# +# Created once per state, so Packer runs on the first apply only. Change +# packer_config.rebuild_image_token to any new value to replace this resource and +# build a fresh image; re-plans with an unchanged token do nothing. +resource "null_resource" "packer_build" { + count = local.build_image ? 1 : 0 + + provisioner "local-exec" { + working_dir = path.module + command = "sh ../../packer/scripts/build.sh" + environment = { + # Drives the shared build script itself + PACKER_VERSION = var.packer_config.version + PACKER_TEMPLATE = "./image.pkr.hcl" + + # Packer reads PKR_VAR_ natively, so these reach image.pkr.hcl + # without the build script having to know the per-cloud variable list. + PKR_VAR_azure_location = var.azure_location + PKR_VAR_resource_group_name = local.resource_group_name + PKR_VAR_vm_size = var.vm_size + PKR_VAR_image_publisher = var.os.publisher + PKR_VAR_image_offer = var.os.offer + PKR_VAR_image_sku = var.os.sku + PKR_VAR_image_version = var.os.version + PKR_VAR_image_name_prefix = var.image_name_prefix + PKR_VAR_os_family = local.os_family + PKR_VAR_ssh_username = local.ssh_username + PKR_VAR_update_os_before_install = var.os.update_os_before_install + PKR_VAR_user_script = var.os.user_script + PKR_VAR_terraform_version = var.terraform.primary_version + PKR_VAR_terraform_versions = join(" ", var.terraform.additional_versions) + PKR_VAR_opentofu_version = var.opentofu.primary_version + PKR_VAR_opentofu_versions = join(" ", var.opentofu.additional_versions) + PKR_VAR_sg_runner_pre_release = var.sg_runner.pre_release + PKR_VAR_vnet_name = var.network.vnet_name + PKR_VAR_subnet_name = var.network.subnet_name + PKR_VAR_vnet_resource_group_name = var.network.resource_group_name + PKR_VAR_proxy_url = var.network.proxy_url + } + } + + triggers = { + rebuild_token = var.packer_config.rebuild_image_token + } + + depends_on = [azurerm_resource_group.packer] +} + +/*-------------------------------------------+ + | Parse the Image ID from Packer Output | + +-------------------------------------------*/ +# +# Only meaningful right after a build. It returns an empty image ID when the log +# is missing (fresh checkout, CI runner) instead of failing the plan, because the +# recorded image ID is read from state via terraform_data.image_id below. +data "external" "packer_image_id" { + count = local.build_image ? 1 : 0 + + working_dir = path.module + program = [ + "sh", + "-c", + "image_id=$(grep 'artifact,0,id' packer_manifest.log 2>/dev/null | tail -1 | cut -d, -f6); printf '{\"image_id\": \"%s\"}' \"$image_id\"" + ] + + depends_on = [null_resource.packer_build] +} + +/*-------------------------------------------+ + | Record the Built Image 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" "image_id" { + count = local.build_image ? 1 : 0 + + input = data.external.packer_image_id[0].result["image_id"] + + lifecycle { + ignore_changes = [input] + replace_triggered_by = [null_resource.packer_build[0]] + } +} + +/*-------------------------------------------+ + | Conditional Image Cleanup Resource | + +-------------------------------------------*/ +# +# Tracks the image this module built, so a destroy never deletes an image it did +# not create. Re-keyed by a rebuild, which deletes the superseded image. Never +# runs for an image passed in through existing_image_id. +resource "null_resource" "image_cleanup" { + count = local.build_image && var.packer_config.cleanup_images_on_destroy ? 1 : 0 + + # Store image information as triggers so they're available during destroy + triggers = { + image_id = local.image_id + resource_group_name = local.resource_group_name + script_path = "${path.module}/scripts/cleanup_image.sh" + } + + provisioner "local-exec" { + when = destroy + command = "sh ${self.triggers.script_path}" + environment = { + TARGET_IMAGE_ID = self.triggers.image_id + } + } + + depends_on = [null_resource.packer_build] +} + +# The build resources gained a count when existing_image_id was introduced. +# These keep a state written before that from re-keying, which would otherwise +# destroy and rebuild the image on the next apply. +moved { + from = null_resource.packer_build + to = null_resource.packer_build[0] +} + +moved { + from = terraform_data.image_id + to = terraform_data.image_id[0] +} diff --git a/stackguardian_private_runner/azure/packer/outputs.tf b/stackguardian_private_runner/azure/packer/outputs.tf new file mode 100644 index 0000000..0051c89 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/outputs.tf @@ -0,0 +1,42 @@ +/*----------------------------------+ + | Packer Azure Image Builder | + +----------------------------------*/ +output "image_id" { + description = "The resource ID of the Azure managed image built by this module and recorded in state" + value = local.image_id + + precondition { + condition = local.image_id != "" + error_message = "No image recorded: the Packer build produced no image ID. Check packer_manifest.log in the packer module directory." + } +} + +output "image_info" { + description = "Comprehensive image information for tracking and cleanup" + value = { + image_id = local.image_id + location = var.azure_location + resource_group_name = local.resource_group_name + os_family = local.os_family + os_sku = var.os.sku + image_name = local.image_name + image_name_prefix = var.image_name_prefix + cleanup_settings = { + automatic_cleanup = var.packer_config.cleanup_images_on_destroy + } + } +} + +output "resource_group_name" { + description = "The resource group name where the image is stored" + value = local.resource_group_name +} + +output "cleanup_commands" { + description = "Azure CLI commands for manual image cleanup" + value = { + list_image = "az image show --ids ${local.image_id}" + delete_image = "az image delete --ids ${local.image_id}" + list_all = "az image list --resource-group ${local.resource_group_name} --query \"[?starts_with(name, '${var.image_name_prefix}')].{name:name, id:id}\" -o table" + } +} diff --git a/stackguardian_private_runner/azure/packer/provider.tf b/stackguardian_private_runner/azure/packer/provider.tf new file mode 100644 index 0000000..a5d0197 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/provider.tf @@ -0,0 +1,23 @@ +terraform { + # terraform_data (used to record the built image ID in state) needs 1.4+ + required_version = ">= 1.4.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0" + } + null = { + source = "hashicorp/null" + version = ">= 3.0" + } + external = { + source = "hashicorp/external" + version = ">= 2.0" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/stackguardian_private_runner/azure/packer/schemas/input_schema.json b/stackguardian_private_runner/azure/packer/schemas/input_schema.json new file mode 100644 index 0000000..5086ce5 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/schemas/input_schema.json @@ -0,0 +1,212 @@ +{ + "type": "object", + "properties": { + "existing_image_id": { + "title": "Existing Image ID", + "type": "string", + "default": "", + "pattern": "^$|^/subscriptions/.*" + }, + "azure_location": { + "title": "Azure Location", + "type": "string", + "default": "westeurope", + "minLength": 1 + }, + "resource_group_name": { + "title": "Resource Group Name", + "type": "string", + "minLength": 1 + }, + "create_resource_group": { + "title": "Create Resource Group", + "type": "boolean", + "default": false + }, + "vm_size": { + "title": "Build VM Size", + "type": "string", + "default": "Standard_D2s_v3", + "minLength": 1 + }, + "network": { + "title": "Network", + "type": "object", + "properties": { + "vnet_name": { + "title": "Virtual Network Name", + "type": "string", + "default": "" + }, + "subnet_name": { + "title": "Subnet Name", + "type": "string", + "default": "" + }, + "resource_group_name": { + "title": "Network Resource Group Name", + "type": "string", + "default": "" + }, + "proxy_url": { + "title": "HTTP Proxy URL", + "type": "string", + "default": "" + } + }, + "default": { + "vnet_name": "", + "subnet_name": "", + "resource_group_name": "", + "proxy_url": "" + }, + "additionalProperties": false + }, + "os": { + "title": "Operating System", + "type": "object", + "properties": { + "publisher": { + "title": "Publisher", + "type": "string", + "enum": [ + "Canonical", + "RedHat" + ], + "default": "Canonical" + }, + "offer": { + "title": "Offer", + "type": "string", + "minLength": 1, + "default": "0001-com-ubuntu-server-jammy" + }, + "sku": { + "title": "SKU", + "type": "string", + "minLength": 1, + "default": "22_04-lts-gen2" + }, + "version": { + "title": "Version", + "type": "string", + "default": "latest" + }, + "update_os_before_install": { + "title": "Update OS Before Install", + "type": "boolean", + "default": true + }, + "user_script": { + "title": "User Script", + "type": "string", + "default": "" + } + }, + "required": [ + "publisher", + "offer", + "sku" + ], + "additionalProperties": false + }, + "packer_config": { + "title": "Packer Configuration", + "type": "object", + "properties": { + "version": { + "title": "Packer Version", + "type": "string", + "default": "1.14.1", + "minLength": 1 + }, + "rebuild_image_token": { + "title": "Rebuild Image Token", + "type": "string", + "default": "" + }, + "cleanup_images_on_destroy": { + "title": "Cleanup Images on Destroy", + "type": "boolean", + "default": true + } + }, + "default": { + "version": "1.14.1", + "rebuild_image_token": "", + "cleanup_images_on_destroy": true + }, + "additionalProperties": false + }, + "image_name_prefix": { + "title": "Image Name Prefix", + "type": "string", + "default": "sg-runner", + "minLength": 1 + }, + "terraform": { + "title": "Terraform", + "type": "object", + "properties": { + "primary_version": { + "title": "Primary Version", + "type": "string", + "default": "" + }, + "additional_versions": { + "title": "Additional Versions", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "default": { + "primary_version": "", + "additional_versions": [] + }, + "additionalProperties": false + }, + "opentofu": { + "title": "OpenTofu", + "type": "object", + "properties": { + "primary_version": { + "title": "Primary Version", + "type": "string", + "default": "" + }, + "additional_versions": { + "title": "Additional Versions", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "default": { + "primary_version": "", + "additional_versions": [] + }, + "additionalProperties": false + }, + "sg_runner": { + "title": "StackGuardian Runner", + "type": "object", + "properties": { + "pre_release": { + "title": "Use Pre-release", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + }, + "required": [ + "resource_group_name" + ], + "additionalProperties": false +} diff --git a/stackguardian_private_runner/azure/packer/schemas/ui_schema.json b/stackguardian_private_runner/azure/packer/schemas/ui_schema.json new file mode 100644 index 0000000..00c8551 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/schemas/ui_schema.json @@ -0,0 +1,143 @@ +{ + "ui:order": [ + "existing_image_id", + "azure_location", + "resource_group_name", + "create_resource_group", + "vm_size", + "network", + "os", + "packer_config", + "image_name_prefix", + "terraform", + "opentofu", + "sg_runner" + ], + "existing_image_id": { + "ui:placeholder": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/images/{name}", + "ui:description": "Hand back a managed image you already have instead of building one. Leave empty to run the Packer build. When it is set nothing is built - no build VM, no Packer download, no cleanup on destroy - and every other setting here is ignored. The image must live in the selected location and already carry Docker, cron, jq and sg-runner." + }, + "azure_location": { + "ui:description": "The target Azure region to build the Private Runner image", + "ui:placeholder": "westeurope" + }, + "resource_group_name": { + "ui:description": "The name of the resource group where the image will be stored", + "ui:placeholder": "sg-runner-images-rg" + }, + "create_resource_group": { + "ui:widget": "checkbox", + "ui:description": "Create the resource group as part of this deployment. If disabled, the resource group must already exist." + }, + "vm_size": { + "ui:description": "The Azure VM size used by Packer during the build (min 2 vCPU, 4GB RAM recommended)", + "ui:placeholder": "Standard_D2s_v3" + }, + "network": { + "ui:title": "Network", + "ui:description": "Network configuration for the Packer build instance. Leave VNet/Subnet empty to let Packer create temporary networking.", + "vnet_name": { + "ui:description": "Name of an existing virtual network to use for the build VM", + "ui:placeholder": "vnet-***" + }, + "subnet_name": { + "ui:description": "Name of an existing subnet inside the VNet above", + "ui:placeholder": "subnet-***" + }, + "resource_group_name": { + "ui:description": "Resource group containing the existing VNet (if different from the image resource group)", + "ui:placeholder": "network-rg" + }, + "proxy_url": { + "ui:description": "HTTP proxy URL forwarded to the build VM during image creation", + "ui:placeholder": "http://proxy.example.com:8080" + } + }, + "os": { + "ui:title": "Operating System", + "ui:description": "Operating system configuration for the image", + "publisher": { + "ui:widget": "select", + "ui:description": "Image publisher. Currently supported: Canonical (Ubuntu) and RedHat (RHEL)." + }, + "offer": { + "ui:description": "Marketplace image offer", + "ui:placeholder": "0001-com-ubuntu-server-jammy" + }, + "sku": { + "ui:description": "Marketplace image SKU", + "ui:placeholder": "22_04-lts-gen2" + }, + "version": { + "ui:description": "Marketplace image version (use \"latest\" to always pull the newest)", + "ui:placeholder": "latest" + }, + "update_os_before_install": { + "ui:widget": "checkbox", + "ui:description": "Run a full OS package update before installing the runner agent" + }, + "user_script": { + "ui:widget": "textarea", + "ui:options": { + "rows": 6 + }, + "ui:description": "Optional shell script executed on the build VM after the runner agent is installed. Useful for hardening, extra tooling, or custom CA certificates.", + "ui:placeholder": "#!/bin/bash\nset -e\n# custom provisioning\n" + } + }, + "packer_config": { + "ui:title": "Packer Configuration", + "ui:description": "Packer build settings", + "version": { + "ui:description": "Packer version installed by the build script", + "ui:placeholder": "1.14.1" + }, + "rebuild_image_token": { + "ui:description": "The image 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 image once; leaving it unchanged never rebuilds.", + "ui:placeholder": "2026-07-30" + }, + "cleanup_images_on_destroy": { + "ui:widget": "checkbox", + "ui:description": "When enabled (the default), the managed image built by this deployment is deleted on destroy, and the superseded image is deleted whenever a rebuild is triggered. Images built by other deployments are never touched. **Warning:** disabling this leaves orphaned managed images in the resource group." + } + }, + "image_name_prefix": { + "ui:description": "Prefix used for the generated image name (combined with OS family and SKU)", + "ui:placeholder": "sg-runner" + }, + "terraform": { + "ui:title": "Terraform", + "ui:description": "Terraform binaries pre-installed on the image", + "primary_version": { + "ui:description": "Default Terraform version available on the runner (leave empty to skip)", + "ui:placeholder": "1.6.6" + }, + "additional_versions": { + "ui:description": "Extra Terraform versions to install alongside the primary version", + "items": { + "ui:placeholder": "1.5.7" + } + } + }, + "opentofu": { + "ui:title": "OpenTofu", + "ui:description": "OpenTofu binaries pre-installed on the image", + "primary_version": { + "ui:description": "Default OpenTofu version available on the runner (leave empty to skip)", + "ui:placeholder": "1.8.0" + }, + "additional_versions": { + "ui:description": "Extra OpenTofu versions to install alongside the primary version", + "items": { + "ui:placeholder": "1.7.3" + } + } + }, + "sg_runner": { + "ui:title": "StackGuardian Runner", + "pre_release": { + "ui:widget": "checkbox", + "ui:description": "Bake the newest sg-runner pre-release into the image instead of the latest stable release." + } + } +} diff --git a/stackguardian_private_runner/azure/packer/scripts/cleanup_image.sh b/stackguardian_private_runner/azure/packer/scripts/cleanup_image.sh new file mode 100755 index 0000000..8105ec9 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/scripts/cleanup_image.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# Cleanup helper for the managed image produced by this Packer build. +# +# Mirrors aws/packer/scripts/cleanup_amis.sh: targets ONLY the image whose +# resource ID is passed in via TARGET_IMAGE_ID (sourced from the Terraform +# state on `terraform destroy`). Will not enumerate or delete other images. +# +# Required env: +# TARGET_IMAGE_ID Full Azure resource ID of the managed image to delete. +# Optional env: +# DRY_RUN=true Print actions without executing them. + +set -e + +_have_az() { + command -v az >/dev/null 2>&1 +} + +_verify_credentials() { + if ! az account show >/dev/null 2>&1; then + echo "ERROR: not logged in to Azure CLI. Run 'az login' first." >&2 + exit 1 + fi +} + +_image_exists() { + image_id="$1" + az resource show --ids "$image_id" >/dev/null 2>&1 +} + +_delete_image() { + image_id="$1" + + if [ "${DRY_RUN:-false}" = "true" ]; then + echo ">> [dry-run] az image delete --ids $image_id" + return 0 + fi + + echo ">> Deleting Azure managed image: $image_id" + if az image delete --ids "$image_id" 2>&1; then + echo ">> ✓ Image deleted" + else + echo ">> ✗ Failed to delete image: $image_id" >&2 + return 1 + fi +} + +main() { + echo "## ----------" + echo ">> Azure managed image cleanup" + echo "## ----------" + + if ! _have_az; then + echo "INFO: Azure CLI not available. Image must be cleaned up manually." + echo ">> Install az and re-run, or remove the image via the Azure portal." + exit 0 + fi + + _verify_credentials + + target="${TARGET_IMAGE_ID:-}" + if [ -z "$target" ] || [ "$target" = "null" ]; then + echo ">> No TARGET_IMAGE_ID provided - nothing to cleanup." + exit 0 + fi + + if ! _image_exists "$target"; then + echo ">> Image not found (already deleted?): $target" + exit 0 + fi + + _delete_image "$target" + + echo "## ----------" + echo ">> Cleanup complete" + echo "## ----------" +} + +main "$@" diff --git a/stackguardian_private_runner/azure/packer/terraform.tfvars.tpl b/stackguardian_private_runner/azure/packer/terraform.tfvars.tpl new file mode 100644 index 0000000..5633ac6 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/terraform.tfvars.tpl @@ -0,0 +1,137 @@ +/*-------------------+ + | General Variables | + +-------------------*/ +azure_location = "westeurope" +vm_size = "Standard_D2s_v3" + +# Resource group that holds the built managed image. +resource_group_name = "sg-runner-images-rg" + +# Set to true to have this module create the resource group above. +# Leave false to reuse a resource group that already exists. +create_resource_group = false + +# Prefix for the generated image name. The final name is +# ---, e.g. sg-runner-ubuntu-22_04-lts-gen2-1712345678 +image_name_prefix = "sg-runner" + +# Bake the newest sg-runner pre-release into the image instead of the latest +# stable release. Falls back to stable when no pre-release exists. +# sg_runner = { +# pre_release = false +# } + +/*------------------------------+ + | Image Build Network Settings | + +------------------------------*/ +# Default: leave everything empty and Packer creates a temporary VNet/subnet, +# public IP and NSG for the build VM, then tears them down afterwards. +network = {} + +# Build inside an existing VNet/subnet instead (e.g. to reach a private mirror +# or to satisfy a policy that forbids ad-hoc networking): +# network = { +# vnet_name = "sg-runner-vnet" +# subnet_name = "build-subnet" +# resource_group_name = "networking-rg" # RG of the VNet, if different +# proxy_url = "http://proxy.company.com:8080" # Optional +# } + +/*---------------------------+ + | Operating System Settings | + +---------------------------*/ +# Ubuntu 22.04 LTS (default). publisher must be "Canonical" or "RedHat". +os = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + version = "latest" + update_os_before_install = true + user_script = "" +} + +# Ubuntu 24.04 LTS: +# os = { +# publisher = "Canonical" +# offer = "ubuntu-24_04-lts" +# sku = "server-gen1" +# version = "latest" +# update_os_before_install = true +# } + +# RHEL 9: +# os = { +# publisher = "RedHat" +# offer = "RHEL" +# sku = "9_4" +# version = "latest" +# update_os_before_install = true +# } + +# The SSH user is derived from the publisher (ubuntu for Canonical, +# azureuser for RedHat) - there is no ssh_username input here. + +# Example user scripts - run after the base provisioning, on the build VM: +# os = { +# publisher = "Canonical" +# offer = "0001-com-ubuntu-server-jammy" +# sku = "22_04-lts-gen2" +# version = "latest" +# update_os_before_install = true +# user_script = "apt-get update && apt-get install -y jq htop" +# } + +# os = { +# publisher = "Canonical" +# offer = "0001-com-ubuntu-server-jammy" +# sku = "22_04-lts-gen2" +# version = "latest" +# user_script = < Part of [StackGuardian Private Runner](../../README.md) — [Azure stack overview](../DOCUMENTATION.md) · [platform template doc](DOCUMENTATION.md) + +Provisions a StackGuardian Runner Group with an Azure Blob Storage backend and the +`AZURE_OIDC` connector the platform uses to reach it. + +This module requires **only** the Azure providers (`azurerm`, `azuread`). The +platform-side resources (runner group, connector, registration token) live in the shared, +cloud-agnostic [`runner_group/`](../../runner_group/) module, which this module calls — so +an Azure deployment never initializes the AWS provider. The AWS equivalent is +[`aws/runner_group/`](../../aws/runner_group/). + +## What Gets Created + +- **Resource Group** hosting the storage account, and acting as the canonical RG for the + downstream `azure/*` modules (when `create_azure_resource_group = true`). Its name is + exported as `azure_resource_group_name`. +- **Storage Account + private `runner` blob container** with TLS 1.2 minimum and CORS + limited to the StackGuardian platform origin (when `create_storage_backend = true`). + The container name `runner` is required by the platform. +- **Entra ID application + service principal** backing the OIDC connector. +- **Federated identity credential** issued by the StackGuardian API URI for the org + subject `/orgs/{org_name}` — no long-lived secret is stored on the platform. +- **`Storage Blob Data Reader` role assignment** on the storage account (when + `create_blob_reader_role_assignment = true`). +- **StackGuardian Runner Group** with `max_number_of_runners` and default tags. +- **StackGuardian Connector** (`AZURE_OIDC`) wired to the identity above. + +## Prerequisites + +- StackGuardian API key (`sgu_*` user key, `sgo_*` org key, or a `${secret::SECRET_NAME}` + reference). +- OpenTofu >= 1.7 or Terraform >= 1.3. +- Azure credentials (CLI / service principal) with permission to create Resource Groups, + Storage Accounts, Entra ID applications, service principals, and role assignments. + Creating the role assignment needs `Microsoft.Authorization/roleAssignments/write` + (e.g. `Owner` or `User Access Administrator`); if the deploying identity lacks it, set + `create_blob_reader_role_assignment = false` and create the assignment out of band using + the `azure_connector_service_principal_object_id` output. + +## Quick Start + +`terraform.tfvars`: + +```hcl +stackguardian = { + api_key = "sgu_your_api_key_here" + api_uri = "https://api.app.stackguardian.io" + org_name = "your-org-name" +} + +azure_location = "westeurope" + +# Optional — when omitted the module creates a resource group named +# "{sanitized_prefix}-rg-{subscription_id}". +# azure_resource_group_name = "my-resource-group" +``` + +```bash +tofu init +tofu plan +tofu apply +``` + +### As a module + +```hcl +module "runner_group" { + source = "./stackguardian_private_runner/azure/runner_group" + + stackguardian = { + api_key = "sgu_your_api_key" + } + + azure_location = "westeurope" + max_runners = 3 +} +``` + +## Configuration + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| `stackguardian.api_key` | StackGuardian API key (must start with `sgu_` or `sgo_`) | `string` (sensitive) | + +When `create_azure_resource_group = false`, `azure_resource_group_name` becomes required. + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `stackguardian.api_uri` | StackGuardian API endpoint (EU1 / US1 / DASH) | `https://api.app.stackguardian.io` | +| `stackguardian.org_name` | Organization name; falls back to the `SG_ORG_ID` env var | `""` | +| `azure_location` | Azure region for the resource group and storage account | `westeurope` | +| `create_azure_resource_group` | Create a new resource group | `true` | +| `azure_resource_group_name` | Name override when creating; the existing RG name when not | `""` | +| `create_storage_backend` | Create a new Storage Account | `true` | +| `existing_azure_storage_account_name` | Existing Storage Account name (when `create_storage_backend = false`) | `""` | +| `existing_azure_storage_account_access_key` | Access key for that account (sensitive) | `""` | +| `azure_storage.account_tier` | Storage Account performance tier (`Standard` / `Premium`) | `Standard` | +| `azure_storage.account_replication_type` | Replication strategy (`LRS` / `GRS` / `RAGRS` / `ZRS`) | `LRS` | +| `create_blob_reader_role_assignment` | Grant the connector SP `Storage Blob Data Reader` | `true` | +| `override_names.global_prefix` | Prefix for the runner group and connector names; `""` omits it | `SG_RUNNER` | +| `override_names.runner_group_name` | Name half of the runner group | (6-char random) | +| `override_names.connector_name` | Name half of the connector | (runner group's name) | +| `max_runners` | Maximum runners allowed in the group | `3` | + +### Naming + +The runner group and the connector are named `{global_prefix}-{name}`, or just +`{name}` when `global_prefix` is empty. `name` is whatever you pass as +`override_names.runner_group_name`; left empty it is a 6-character random string, +which is all the uniqueness a runner group needs. + +- Runner group: `{global_prefix}-{name}` — e.g. `SG_RUNNER-k3m9xz` +- Connector: same name as the runner group (separate API namespaces, so no clash) + +The subscription ID is **not** in the name — it is a tag. It used to cost 36 of the +name's characters while telling you nothing you could not read off the tags. + +Azure resources keep their own scheme, since Azure naming rules force sanitization +(the prefix is lowercased and underscores become dashes): + +- Resource group: `{sanitized_prefix}-rg-{subscription_id}` +- Storage account: `stgbackend{prefix}` truncated to 16 chars + an 8-char random suffix + (the 24-char, lowercase-alphanumeric global limit) +- Entra ID application: `{global_prefix}-sg-connector` + + +### Tags + +The platform models tags as a flat list of strings — there are no keys — capped at +10. Both the runner group and the connector get: + +| Tag | Example | +|-----|---------| +| Purpose marker | `StackGuardian Private Runner` | +| Provisioner | `Managed by IaC` | +| Cloud | `azure` | +| Subscription ID | `a97621d8-9158-4681-81b6-38b1222afba4` | +| Naming prefix | `SG_RUNNER` | +| Region | `germanywestcentral` | + +The org name and the runner group name are deliberately not tagged: a runner group +only ever lives in one org, and its own name is not information a tag adds. + +## Outputs + +| Output | Description | +|--------|-------------| +| `runner_group_name` / `runner_group_id` | Name of the created runner group | +| `runner_group_token` | Registration token for runners (sensitive) | +| `runner_group_url` | Direct link to the runner group in the web console | +| `connector_name` / `connector_id` | Name of the Azure connector | +| `azure_connector_service_principal_object_id` | Connector SP object ID — use it to create the role assignment out of band | +| `azure_resource_group_name` | Resource group name — feed into the downstream `azure/*` modules | +| `azure_resource_group_location` | Resource group region | +| `azure_storage_account_name` | Storage backend account name | +| `azure_storage_account_id` | Storage account resource ID (`null` when using an existing account) — scope role assignments to it | +| `azure_storage_access_key` | Storage account access key (sensitive) | +| `sg_org_name` / `sg_api_uri` / `azure_location` | Resolved platform and region settings | + +Feed `runner_group_name`, `runner_group_token`, and `azure_resource_group_name` into +[`azure/azure_runner`](../azure_runner/) or [`azure/vmss`](../vmss/). See +[`examples/azure/quickstart`](../../examples/azure/quickstart/) for the whole stack wired +together. + +## Security Notes + +- The storage account disables public blob access and enforces TLS 1.2 minimum; the + container is private. +- Browser requests are accepted only from the StackGuardian console origin (CORS). +- The connector authenticates by OIDC federation, so no client secret is stored on the + platform. +- The connector service principal gets only `Storage Blob Data Reader`, scoped to the one + storage account. +- The runner registration token and the storage access key are marked sensitive. +- `tofu destroy` removes the storage account and everything in it. Back up anything you + need first. diff --git a/stackguardian_private_runner/azure/runner_group/connector_identity.tf b/stackguardian_private_runner/azure/runner_group/connector_identity.tf new file mode 100644 index 0000000..782d25a --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/connector_identity.tf @@ -0,0 +1,31 @@ +# Azure AD App Registration + Service Principal backing the OIDC connector. +# The connector itself is created by the shared runner_group module from these IDs. + +resource "azuread_application" "connector" { + display_name = "${var.override_names.global_prefix}-sg-connector" + + owners = [data.azurerm_client_config.current.object_id] +} + +resource "azuread_service_principal" "connector" { + client_id = azuread_application.connector.client_id + + owners = [data.azurerm_client_config.current.object_id] +} + +resource "azuread_application_federated_identity_credential" "connector" { + application_id = azuread_application.connector.id + display_name = "${var.override_names.global_prefix}-sg-oidc" + issuer = local.sg_api_uri + subject = "/orgs/${local.sg_org_name}" + audiences = [local.sg_api_uri] +} + +# Grant the SP "Storage Blob Data Reader" on the storage account +resource "azurerm_role_assignment" "connector_blob_reader" { + count = var.create_storage_backend && var.create_blob_reader_role_assignment ? 1 : 0 + + scope = azurerm_storage_account.this[0].id + role_definition_name = "Storage Blob Data Reader" + principal_id = azuread_service_principal.connector.object_id +} diff --git a/stackguardian_private_runner/azure/runner_group/locals.tf b/stackguardian_private_runner/azure/runner_group/locals.tf new file mode 100644 index 0000000..f381204 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/locals.tf @@ -0,0 +1,102 @@ +data "external" "env" { + program = [ + "sh", + "-c", + "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\"}'" + ] +} + +data "azurerm_client_config" "current" {} + +locals { + # StackGuardian configuration + # Use nonsensitive() for non-secret fields to prevent sensitivity propagation + sg_org_name = ( + nonsensitive(var.stackguardian.org_name) != "" + ? nonsensitive(var.stackguardian.org_name) + : data.external.env.result.sg_org_name + ) + 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] + + subscription_id = data.azurerm_client_config.current.subscription_id + + # Platform naming: {prefix}-{name}, or just {name} when no prefix is set. + # The name half is yours to pick; left empty it is a random suffix, which is + # all the uniqueness a runner group needs. The subscription ID used to sit + # here and cost 36 characters - it is a tag now. + runner_group_base = ( + var.override_names.runner_group_name != "" + ? var.override_names.runner_group_name + : random_string.name_suffix.result + ) + + runner_group_name = ( + var.override_names.global_prefix != "" + ? "${var.override_names.global_prefix}-${local.runner_group_base}" + : local.runner_group_base + ) + + # The connector is created 1:1 with the runner group and shares its name - + # they live in separate API namespaces (/integrations/ vs runnergroups/). + connector_base = ( + var.override_names.connector_name != "" + ? var.override_names.connector_name + : local.runner_group_base + ) + + connector_name = ( + var.override_names.global_prefix != "" + ? "${var.override_names.global_prefix}-${local.connector_base}" + : local.connector_base + ) + + # Bare values - the platform's tags are a flat list of strings with no keys. + platform_tags = compact([ + local.subscription_id, + var.override_names.global_prefix, + var.azure_location, + ]) + + # Azure resource names derive from the prefix, sanitized to Azure's rules + sanitized_prefix = replace(lower(var.override_names.global_prefix), "_", "-") + + # Storage account names must be globally unique, 3-24 chars, lowercase alphanumeric only + storage_account_prefix = substr("stgbackend${replace(local.sanitized_prefix, "-", "")}", 0, 16) + + # Desired RG name — used both for naming a newly created RG and as a fallback. When the user + # passes an explicit azure_resource_group_name we honor it; otherwise derive from the prefix. + desired_resource_group_name = ( + var.azure_resource_group_name != "" + ? var.azure_resource_group_name + : "${local.sanitized_prefix}-rg-${local.subscription_id}" + ) + + # Effective RG name used by the module. References the resource when creating to establish + # an implicit dependency; falls back to the user-supplied existing RG name otherwise. + resource_group_name = ( + var.create_azure_resource_group + ? azurerm_resource_group.this[0].name + : var.azure_resource_group_name + ) + + storage_account_name = ( + var.create_storage_backend + ? azurerm_storage_account.this[0].name + : var.existing_azure_storage_account_name + ) + + storage_access_key = ( + var.create_storage_backend + ? azurerm_storage_account.this[0].primary_access_key + : var.existing_azure_storage_account_access_key + ) +} diff --git a/stackguardian_private_runner/azure/runner_group/outputs.tf b/stackguardian_private_runner/azure/runner_group/outputs.tf new file mode 100644 index 0000000..2d2c3da --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/outputs.tf @@ -0,0 +1,88 @@ +/*---------------------------------+ + | Runner Group Outputs | + +---------------------------------*/ +output "runner_group_name" { + description = "The name of the StackGuardian runner group" + value = module.runner_group.runner_group_name +} + +output "runner_group_id" { + description = "The ID of the StackGuardian runner group" + value = module.runner_group.runner_group_id +} + +output "runner_group_token" { + description = "The token for runner registration (sensitive)" + sensitive = true + value = module.runner_group.runner_group_token +} + +output "runner_group_url" { + description = "Direct URL to the runner group in the StackGuardian web console" + value = module.runner_group.runner_group_url +} + +/*---------------------------------+ + | Connector Outputs | + +---------------------------------*/ +output "connector_name" { + description = "The name of the StackGuardian Azure connector" + value = module.runner_group.connector_name +} + +output "connector_id" { + description = "The ID of the StackGuardian Azure connector" + value = module.runner_group.connector_id +} + +output "azure_connector_service_principal_object_id" { + description = "Object ID of the OIDC connector service principal. Use this to create the 'Storage Blob Data Reader' role assignment out of band when create_blob_reader_role_assignment = false." + value = azuread_service_principal.connector.object_id +} + +/*---------------------------------+ + | Storage Backend Outputs | + +---------------------------------*/ +output "azure_resource_group_name" { + description = "The name of the Azure Resource Group containing the storage account. Pass this to downstream azure/* modules' resource_group_name input." + value = local.resource_group_name +} + +output "azure_resource_group_location" { + description = "The location of the Azure Resource Group" + value = var.azure_location +} + +output "azure_storage_account_name" { + description = "The name of the Azure Storage Account used for storage backend" + value = local.storage_account_name +} + +output "azure_storage_account_id" { + description = "The resource ID of the Azure Storage Account used for storage backend (null when using an existing account)" + value = var.create_storage_backend ? azurerm_storage_account.this[0].id : null +} + +output "azure_storage_access_key" { + description = "The access key for the Azure Storage Account (sensitive)" + sensitive = true + value = local.storage_access_key +} + +/*---------------------------------+ + | StackGuardian Platform Outputs | + +---------------------------------*/ +output "sg_org_name" { + description = "The StackGuardian organization name" + value = local.sg_org_name +} + +output "sg_api_uri" { + description = "The StackGuardian API URI" + value = local.sg_api_uri +} + +output "azure_location" { + description = "The Azure region" + value = var.azure_location +} diff --git a/stackguardian_private_runner/azure/runner_group/provider.tf b/stackguardian_private_runner/azure/runner_group/provider.tf new file mode 100644 index 0000000..7695066 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/provider.tf @@ -0,0 +1,39 @@ +terraform { + # optional() attributes with defaults (var.override_names, var.azure_storage) need 1.3+ + required_version = ">= 1.3.0" + + required_providers { + stackguardian = { + source = "registry.terraform.io/StackGuardian/stackguardian" + version = ">= 1.3.3" + } + azurerm = { + source = "hashicorp/azurerm" + # Floor is load-bearing: azurerm_storage_container.storage_account_id landed in 4.x. + # Ceiling matches the rest of the azure/* modules, which 5.x breaks. + version = ">= 4.0, < 5.0" + } + azuread = { + source = "hashicorp/azuread" + version = ">= 2.0" + } + external = { + source = "hashicorp/external" + version = ">= 2.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + } +} + +provider "azurerm" { + features {} +} + +provider "stackguardian" { + api_key = var.stackguardian.api_key + org_name = local.sg_org_name + api_uri = local.sg_api_uri +} diff --git a/stackguardian_private_runner/azure/runner_group/runner_group.tf b/stackguardian_private_runner/azure/runner_group/runner_group.tf new file mode 100644 index 0000000..1d9080f --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/runner_group.tf @@ -0,0 +1,39 @@ +# StackGuardian Runner Group + connector. +# +# The platform resources live in the shared, cloud-agnostic module so that this +# template only ever requires the Azure providers — an Azure deployment never pulls +# in the AWS provider. + +# Random half of the runner group name, used when no name is supplied. Held in +# state, so it is stable across applies and only changes if this is replaced. +resource "random_string" "name_suffix" { + length = 6 + lower = true + upper = false + numeric = true + special = false +} + +module "runner_group" { + source = "../../runner_group" + + sg_org_name = local.sg_org_name + sg_app_uri = local.sg_app_uri + + runner_group_name = local.runner_group_name + connector_name = local.connector_name + max_runners = var.max_runners + + tags = local.platform_tags + + storage_backend = { + type = "azure_blob_storage" + azure = { + storage_account_name = local.storage_account_name + access_key = local.storage_access_key + tenant_id = data.azurerm_client_config.current.tenant_id + subscription_id = local.subscription_id + client_id = azuread_application.connector.client_id + } + } +} diff --git a/stackguardian_private_runner/azure/runner_group/schemas/input_schema.json b/stackguardian_private_runner/azure/runner_group/schemas/input_schema.json new file mode 100644 index 0000000..497ceb0 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/schemas/input_schema.json @@ -0,0 +1,226 @@ +{ + "type": "object", + "properties": { + "stackguardian": { + "title": "StackGuardian Configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "api_uri": { + "title": "API Region", + "type": "string", + "enum": [ + "https://api.app.stackguardian.io", + "https://api.us.stackguardian.io", + "https://testapi.qa.stackguardian.io" + ], + "enumNames": [ + "EU1 - Europe", + "US1 - East", + "DASH - QA Environment" + ], + "default": "https://api.app.stackguardian.io" + }, + "api_key": { + "title": "API Key", + "type": "string", + "pattern": "^(sg[uo]_.*|\\$\\{secret::[a-zA-Z0-9_-]+\\})$", + "minLength": 1 + }, + "org_name": { + "title": "Organization Name", + "type": "string", + "default": "" + } + }, + "required": [ + "api_key" + ] + }, + "azure_location": { + "title": "Azure Region", + "type": "string", + "enum": [ + "eastus", + "eastus2", + "centralus", + "northcentralus", + "southcentralus", + "westcentralus", + "westus", + "westus2", + "westus3", + "canadacentral", + "canadaeast", + "mexicocentral", + "brazilsouth", + "brazilsoutheast", + "northeurope", + "westeurope", + "francecentral", + "francesouth", + "germanywestcentral", + "germanynorth", + "italynorth", + "norwayeast", + "norwaywest", + "polandcentral", + "spaincentral", + "swedencentral", + "switzerlandnorth", + "switzerlandwest", + "uksouth", + "ukwest", + "australiacentral", + "australiacentral2", + "australiaeast", + "australiasoutheast", + "newzealandnorth", + "centralindia", + "southindia", + "westindia", + "jioindiacentral", + "jioindiawest", + "japaneast", + "japanwest", + "koreacentral", + "koreasouth", + "eastasia", + "southeastasia", + "indonesiacentral", + "malaysiawest", + "taiwannorth", + "uaenorth", + "uaecentral", + "qatarcentral", + "israelcentral", + "saudiarabiacentral", + "southafricanorth", + "southafricawest" + ], + "default": "westeurope" + }, + "create_azure_resource_group": { + "title": "Create Azure Resource Group", + "type": "boolean", + "default": true + }, + "azure_resource_group_name": { + "title": "Azure Resource Group Name", + "type": "string", + "default": "" + }, + "create_blob_reader_role_assignment": { + "title": "Create Blob Reader Role Assignment", + "type": "boolean", + "default": true + }, + "create_storage_backend": { + "title": "Create Storage Backend", + "type": "boolean", + "default": true + }, + "override_names": { + "title": "Resource Naming", + "type": "object", + "properties": { + "global_prefix": { + "title": "Global Prefix", + "type": "string", + "default": "SG_RUNNER", + "description": "Prefix for the runner group and connector names. Leave empty to omit it." + }, + "runner_group_name": { + "title": "Runner Group Name", + "type": "string", + "default": "", + "description": "Name half of the runner group. A short random string is generated when empty." + }, + "connector_name": { + "title": "Connector Name", + "type": "string", + "default": "", + "description": "Name half of the connector. Defaults to the runner group's name." + } + }, + "required": [ + "global_prefix" + ], + "additionalProperties": false + }, + "max_runners": { + "title": "Maximum Runners", + "type": "integer", + "default": 3, + "minimum": 1 + } + }, + "dependencies": { + "create_storage_backend": { + "oneOf": [ + { + "properties": { + "create_storage_backend": { + "enum": [ + true + ] + }, + "azure_storage": { + "title": "Azure Storage Configuration", + "type": "object", + "properties": { + "account_tier": { + "title": "Account Tier", + "type": "string", + "enum": [ + "Standard", + "Premium" + ], + "default": "Standard" + }, + "account_replication_type": { + "title": "Replication Type", + "type": "string", + "enum": [ + "LRS", + "GRS", + "RAGRS", + "ZRS" + ], + "default": "LRS" + } + }, + "additionalProperties": false + } + } + }, + { + "properties": { + "create_storage_backend": { + "enum": [ + false + ] + }, + "existing_azure_storage_account_name": { + "title": "Existing Azure Storage Account Name", + "type": "string", + "minLength": 1 + }, + "existing_azure_storage_account_access_key": { + "title": "Existing Azure Storage Account Access Key", + "type": "string", + "minLength": 1 + } + }, + "required": [ + "existing_azure_storage_account_name", + "existing_azure_storage_account_access_key" + ] + } + ] + } + }, + "required": [ + "stackguardian" + ] +} diff --git a/stackguardian_private_runner/azure/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/azure/runner_group/schemas/ui_schema.json new file mode 100644 index 0000000..ce187bd --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/schemas/ui_schema.json @@ -0,0 +1,106 @@ +{ + "ui:title": "StackGuardian Runner Group - Azure", + "ui:description": "Create a new StackGuardian Runner Group backed by Azure Blob Storage, with an Entra ID OIDC connector. Default tags are automatically applied: 'StackGuardian Private Runner', runner group name, and organization name.", + "ui:order": [ + "stackguardian", + "azure_location", + "create_azure_resource_group", + "azure_resource_group_name", + "create_blob_reader_role_assignment", + "create_storage_backend", + "azure_storage", + "existing_azure_storage_account_name", + "existing_azure_storage_account_access_key", + "override_names", + "max_runners" + ], + "stackguardian": { + "ui:title": "StackGuardian Configuration", + "ui:description": "Configure your StackGuardian platform connection", + "ui:order": [ + "api_uri", + "api_key", + "org_name" + ], + "api_uri": { + "ui:widget": "select", + "ui:description": "Select your StackGuardian platform region" + }, + "api_key": { + "ui:placeholder": "sgu_*** or sgo_*** or ${secret::SECRET_NAME}", + "ui:description": "Your organization's API key (sgo_*/sgu_*) or a secret reference (${secret::SECRET_NAME})" + }, + "org_name": { + "ui:placeholder": "your-org-name (optional)", + "ui:description": "Your organization name. If not provided, will be extracted from environment." + } + }, + "azure_location": { + "ui:widget": "select", + "ui:description": "The Azure region where the resource group and storage account will be deployed" + }, + "create_azure_resource_group": { + "ui:widget": "checkbox", + "ui:description": "Create a new Azure Resource Group for the storage account. Disable to deploy into an existing resource group." + }, + "azure_resource_group_name": { + "ui:placeholder": "(auto-generated when creating)", + "ui:description": "Optional name override when creating the resource group; required when using an existing resource group." + }, + "create_blob_reader_role_assignment": { + "ui:widget": "checkbox", + "ui:description": "Grant the OIDC connector service principal 'Storage Blob Data Reader' on the storage account. Disable when the identity running Terraform lacks role-assignment write permission; you must then create the assignment out of band." + }, + "create_storage_backend": { + "ui:widget": "checkbox", + "ui:description": "Whether to create a new Azure Storage Account as the storage backend" + }, + "azure_storage": { + "ui:title": "Azure Storage Configuration", + "ui:description": "Configure Azure Storage Account settings", + "ui:order": [ + "account_tier", + "account_replication_type" + ], + "account_tier": { + "ui:widget": "select", + "ui:description": "Performance tier of the storage account" + }, + "account_replication_type": { + "ui:widget": "select", + "ui:description": "Replication strategy for the storage account" + } + }, + "existing_azure_storage_account_name": { + "ui:placeholder": "myexistingstorageaccount", + "ui:description": "Name of an existing Azure Storage Account to use as storage backend" + }, + "existing_azure_storage_account_access_key": { + "ui:placeholder": "primary or secondary access key", + "ui:description": "Access key for the existing Azure Storage Account" + }, + "override_names": { + "ui:title": "Resource Naming Configuration", + "ui:description": "Customize resource names (optional)", + "ui:order": [ + "global_prefix", + "runner_group_name", + "connector_name" + ], + "global_prefix": { + "ui:placeholder": "SG_RUNNER", + "ui:description": "Prefix for the runner group and connector names. Leave empty to omit it." + }, + "runner_group_name": { + "ui:placeholder": "(auto-generated)", + "ui:description": "Name half of the runner group; the full name is {prefix}-{name}. A short random string is generated when empty." + }, + "connector_name": { + "ui:placeholder": "(auto-generated)", + "ui:description": "Name half of the connector. Defaults to the runner group's name." + } + }, + "max_runners": { + "ui:description": "Maximum number of runners allowed in the runner group" + } +} diff --git a/stackguardian_private_runner/azure/runner_group/storage_backend.tf b/stackguardian_private_runner/azure/runner_group/storage_backend.tf new file mode 100644 index 0000000..5b5b9f9 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/storage_backend.tf @@ -0,0 +1,75 @@ +# Azure Resource Group (created when create_azure_resource_group = true) +resource "azurerm_resource_group" "this" { + count = var.create_azure_resource_group ? 1 : 0 + + name = local.desired_resource_group_name + location = var.azure_location + + tags = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } + + lifecycle { + precondition { + condition = local.desired_resource_group_name != "" + error_message = "Could not derive an Azure Resource Group name. Set var.azure_resource_group_name or var.override_names.global_prefix." + } + } +} + +# Azure Blob Storage for Storage Backend (created when create_storage_backend = true) + +resource "random_string" "storage_suffix" { + count = var.create_storage_backend ? 1 : 0 + + length = 8 + special = false + upper = false +} + +resource "azurerm_storage_account" "this" { + count = var.create_storage_backend ? 1 : 0 + + name = "${local.storage_account_prefix}${random_string.storage_suffix[0].result}" + resource_group_name = local.resource_group_name + location = var.azure_location + account_tier = var.azure_storage.account_tier + account_replication_type = var.azure_storage.account_replication_type + + # Security settings + min_tls_version = "TLS1_2" + allow_nested_items_to_be_public = false + public_network_access_enabled = true + + lifecycle { + precondition { + condition = local.resource_group_name != "" + error_message = "azure_resource_group_name resolved to empty. When create_azure_resource_group = false, you must pass an existing resource group via var.azure_resource_group_name." + } + } + + blob_properties { + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["GET", "HEAD", "PUT", "POST", "DELETE", "MERGE", "OPTIONS", "PATCH"] + allowed_origins = [local.sg_app_uri] + exposed_headers = ["*"] + max_age_in_seconds = 3600 + } + } + + tags = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } +} + +# Container for runner storage backend (named "runner" per SG docs requirement) +resource "azurerm_storage_container" "runner" { + count = var.create_storage_backend ? 1 : 0 + + name = "runner" + storage_account_id = azurerm_storage_account.this[0].id + container_access_type = "private" +} diff --git a/stackguardian_private_runner/azure/runner_group/variables.tf b/stackguardian_private_runner/azure/runner_group/variables.tf new file mode 100644 index 0000000..17b9b04 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/variables.tf @@ -0,0 +1,158 @@ +/*---------------------------+ + | Storage Backend Options | + +---------------------------*/ +variable "create_storage_backend" { + description = <= 1 + error_message = "max_runners must be at least 1." + } +} diff --git a/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md b/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md new file mode 100644 index 0000000..f15c82f --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md @@ -0,0 +1,114 @@ +# Private Runner VMSS - Azure Template + +Deploy a self-managed StackGuardian Private Runner on Azure as a Virtual Machine Scale Set. Instances boot from a pre-baked custom image and register themselves with your StackGuardian organization automatically. + +## Overview + +This template gives you a horizontally-scalable pool of StackGuardian runners running in your own Azure subscription. The runners pick up jobs from your StackGuardian platform and execute them inside your network - so credentials, source code, and outputs never leave your perimeter. + +### What This Template Creates + +- **VM Scale Set** - The pool of Linux instances that run StackGuardian jobs. +- **Network Security Group** - Default-deny inbound, with SSH and other ports opened only on demand. +- **Virtual Network and Subnet** (optional) - When you don't have an existing VNet to drop the runners into. +- **NAT Gateway with public IP** (optional) - Outbound internet access for runners deployed in private subnets. +- **Managed identity binding** - Runners use a User-Assigned Managed Identity for secure access to the storage backend. +- **SSH key** (optional) - The platform can generate one for you, or you can bring your own. +- **Application Health extension** (optional) - An in-guest health probe, required if you enable rolling upgrades or automatic instance repair without a load balancer. + +## Prerequisites + +- A StackGuardian API key (`sgo_*` or `sgu_*`), or a configured platform secret reference like `${secret::API_KEY}`. +- An Azure subscription and a target Resource Group already created. +- A custom Azure image with Docker, cron, jq, and the SG runner pre-installed. Use the companion **Packer** template to build one. +- A StackGuardian Runner Group already provisioned (use the companion **Runner Group** template). You'll feed its outputs into this template. +- Either an existing VNet/Subnet, or permission to create networking in the target subscription. + +## Template Parameters + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| StackGuardian Platform → API Key | Your organization's API key (`sgo_*`/`sgu_*`) or a secret reference (`${secret::SECRET_NAME}`). | `string` | +| Resource Group Name | Name of the existing Azure Resource Group where resources will be created. | `string` | +| Runner Group Name | Name of the StackGuardian runner group these VMSS instances will register against (output of the runner_group module). | `string` | +| Runner Group Token | Registration token for the runner group (output of the runner_group module). Use a secret reference. | `string` | +| Storage Backend Identity ID | Resource ID of the User-Assigned Managed Identity used by runners to access the storage backend (output of the runner_group module). | `string` | +| VM Image ID | Custom Azure image ID with docker, cron, jq, and sg-runner pre-installed (typically built via the sibling Packer module). | `string` | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| StackGuardian Platform → API Region | Region of the StackGuardian control plane your organization runs in. | `EU1 - Europe` | +| StackGuardian Platform → Organization Name | Override the StackGuardian organization name. Leave blank to derive it from the API key. | `""` | +| Azure Region | Azure region where the VM Scale Set and supporting resources are deployed. | `westeurope` | +| VM Size | VM SKU for each scale-set instance. Minimum 4 vCPU and 8 GB RAM recommended. | `Standard_D4s_v3` | +| Network → Create New VNet & Subnet | Create a new VNet and Subnet for the runner. When false, you must provide existing VNet and Subnet IDs. | `false` | +| Network → Existing VNet ID | Resource ID of an existing Virtual Network (required when not creating a new one). | - | +| Network → Existing Subnet ID | Resource ID of an existing Subnet within the VNet above (required when not creating a new one). | - | +| Network → VNet Address Space | CIDR blocks for the new VNet. | `["10.0.0.0/16"]` | +| Network → Subnet Address Prefix | CIDR for the new subnet inside the VNet address space. | `10.0.1.0/24` | +| Network → Create NAT Gateway | Create a NAT Gateway with public IP and associate it with the (created) subnet for outbound internet access. Only applies when "Create New VNet & Subnet" is enabled. | `false` | +| Network → Proxy URL | Optional HTTP proxy URL for private network deployments. | `""` | +| Network → Additional NSG IDs | Additional NSG resource IDs to associate with each instance. | `[]` | +| Network → Service Endpoints | VNet service endpoints enabled on the subnet this template creates, so runners reach Azure PaaS over the Azure backbone (e.g. `Microsoft.Storage`, `Microsoft.KeyVault`, `Microsoft.ContainerRegistry`). Ignored when using an existing subnet. | `[]` | +| OS Disk → Disk Caching | One of `None`, `ReadOnly`, `ReadWrite`. | `ReadWrite` | +| OS Disk → Storage Account Type | One of `Standard_LRS`, `StandardSSD_LRS`, `Premium_LRS`, `Premium_ZRS`. | `Premium_LRS` | +| OS Disk → Disk Size (GB) | OS disk size in GB. Minimum 30. | `100` | +| Firewall & SSH → Admin Username | Linux admin user on each instance. | `azureuser` | +| Firewall & SSH → Generate SSH Key | When true, an RSA keypair is generated and the private key is stored in Terraform state and exposed as a sensitive output. Prefer providing your own SSH public key. | `false` | +| Firewall & SSH → SSH Public Key | SSH public key authorized to log in as the admin user. Required unless Generate SSH Key is enabled. | `""` | +| Firewall & SSH → SSH Access Rules | Map of friendly names to source CIDRs allowed to reach port 22. | `{}` | +| Firewall & SSH → Additional Inbound Rules | Map of named NSG inbound rules (priority, protocol, ports, source CIDRs). | `{}` | +| Scaling → Minimum Instances | Floor on instance count. Must be at least 1. | `1` | +| Scaling → Maximum Instances | Ceiling on instance count. Must be greater than or equal to Minimum Instances. | `3` | +| Scaling → Desired Capacity | Initial instance count. Must be between Minimum and Maximum. | `1` | +| Upgrade Policy → Upgrade Mode | How a new image or VM size reaches running instances. `Manual` leaves them on the old model until they are replaced; `Rolling` replaces them in batches; `Automatic` replaces them all at once. | `Manual` | +| Upgrade Policy → Health Probe ID | Load Balancer probe used as the health signal for rolling upgrades and instance repair. | `""` | +| Upgrade Policy → Application Health Extension | In-guest health probe (protocol, port, request path). Use this when there is no load balancer to probe from. | `null` | +| Upgrade Policy → Max Batch Instance Percent | Percent of instances upgraded in a single batch (5-100). | `20` | +| Upgrade Policy → Max Unhealthy Instance Percent | Percent of instances allowed to be unhealthy during the upgrade. Must be at least the batch percent. | `20` | +| Upgrade Policy → Max Unhealthy Upgraded Instance Percent | Percent of already-upgraded instances allowed to be unhealthy before the upgrade aborts (0-100). | `20` | +| Upgrade Policy → Pause Between Batches | ISO 8601 duration to wait between batches. | `PT5M` | +| Upgrade Policy → Automatic Instance Repair | Let Azure replace instances that report unhealthy. Needs a health signal. | `false` | +| Upgrade Policy → Instance Repair Grace Period | ISO 8601 grace period after a state change before repairs kick in. | `PT30M` | +| Runner Startup Timeout (seconds) | Maximum seconds to wait for Docker to start before shutting down each instance. | `300` | +| Resource Naming → Global Prefix | Prefix used for naming all Azure resources created by this template. | `SG_RUNNER` | +| Resource Naming → Include Org in Prefix | When true, appends the StackGuardian organization name to the prefix (e.g. `SG_RUNNER_demo-org`). The org name comes from the StackGuardian Platform section - there is no separate naming override. | `false` | + +## Important Notes + +**Runner image**: Instances boot from a pre-baked custom image. If the image is missing Docker or the SG runner agent, instances will self-shutdown after the startup timeout. Always rebuild the image via the Packer template after changes. + +**Auto-scaling**: After the first deployment, the companion Autoscaler template drives instance count between Minimum and Maximum based on workload. Re-running this template will not fight the autoscaler - the desired count is intentionally ignored on subsequent applies. + +**Generated SSH keys**: If you let the platform generate an SSH keypair, the private key is stored in Terraform state and exposed as a sensitive template output. For production, prefer supplying your own public key and managing the private key separately. + +**Network mode**: You must either create a new VNet/Subnet or supply existing IDs - the template won't deploy without one of those. "Create NAT Gateway" and "Service Endpoints" only apply to a subnet this template creates, so they need "Create New VNet & Subnet" enabled; on an existing subnet they are ignored and you configure them yourself. + +**Image upgrades**: By default ("Manual"), publishing a new runner image and re-running the template updates the scale set model but leaves running instances on the old image - they roll over as the autoscaler replaces them. Choose "Rolling" to have Azure replace instances in batches immediately. Rolling and Automatic both need a health signal: either a Load Balancer probe ID or the Application Health Extension. Azure cannot change the upgrade mode of an existing scale set, so changing this on a deployed stack replaces the VM Scale Set and recreates every runner. + +**API key safety**: Use a `${secret::NAME}` reference for the API key and runner group token. Pasting raw `sgo_*`/`sgu_*` values into the form embeds them in the run inputs. + +## Outputs + +| Output | Description | +|--------|-------------| +| VMSS Name | Name of the deployed VM Scale Set - feed this to the Autoscaler template. | +| VMSS Resource Group | Resource group containing the VMSS - feed this to the Autoscaler template. | +| VNet ID | The VNet hosting the runners (created or existing). | +| Subnet ID | The subnet hosting the runners (created or existing). | +| SSH Public Key | SSH public key configured on the VMSS instances. | +| SSH Private Key | Generated RSA private key (only when "Generate SSH Key" is enabled). Sensitive. | +| VMSS ID | Resource ID of the Linux VM Scale Set. | +| Network Security Group ID | ID of the NSG attached to the runner subnet. | + +## Security Features + +- NSG denies all inbound by default; only the rules you explicitly add are opened. +- Storage backend access uses a User-Assigned Managed Identity - no static credentials on the instance. +- Runner group token is treated as sensitive and not surfaced in logs. +- Outbound traffic flows through an optional NAT Gateway you can disable for fully private deployments (use with a proxy or ExpressRoute). +- Custom-image-only boot - no plaintext bootstrap of the runner agent over the network. diff --git a/stackguardian_private_runner/azure/vmss/README.md b/stackguardian_private_runner/azure/vmss/README.md new file mode 100644 index 0000000..636ff5e --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/README.md @@ -0,0 +1,337 @@ +# Private Runner VMSS - Azure Module + +> Part of [StackGuardian Private Runner](../../README.md) — [Azure stack overview](../DOCUMENTATION.md) · [platform template doc](DOCUMENTATION.md) + +Terraform module that deploys a self-registering StackGuardian Private Runner as an Azure Linux Virtual Machine Scale Set (VMSS), wired to an existing or freshly-created VNet/Subnet, an NSG, and (optionally) a NAT Gateway for outbound traffic. + +## Overview + +This module is the Azure counterpart to the AWS ASG-based runner. It provisions a Linux VMSS using a custom Azure image (built via the sibling Packer module) that has docker, cron, jq and `sg-runner` pre-installed. Each instance runs a cloud-init script that registers the VM with a StackGuardian runner group at boot. Capacity is bounded here; the actual scale in/out is driven by the sibling `autoscaler` module (an Azure Function), so `instances` is set on first apply and then ignored. + +### What Gets Created + +- **Linux VM Scale Set** with a User-Assigned Managed Identity for storage backend access. Manual upgrade mode by default; opt in to rolling upgrades via `upgrade_policy`. +- **Network Security Group** with optional SSH allow rules and arbitrary additional inbound rules. +- **Virtual Network + Subnet** (only when `network.create_network = true`), optionally with VNet service endpoints. +- **NAT Gateway + Public IP + subnet association** (only when `network.create_network = true` and `network.create_network_infrastructure = true`). +- **TLS RSA keypair** (only when `firewall.generate_ssh_key = true`; private key is exposed as a sensitive output). +- **Application Health extension** on the scale set (only when `upgrade_policy.application_health_extension` is set). + +## Prerequisites + +- An Azure custom image with docker, cron, jq, and `sg-runner` baked in. Use the sibling `azure/packer` module to produce one. +- A StackGuardian organization API key (`sgo_*` or `sgu_*`) or a secret reference (`${secret::NAME}`). +- A StackGuardian runner group provisioned via the `stackguardian_runner_group` module - its outputs (`runner_group_name`, `runner_group_token`, `storage_backend_identity_id`) feed this module. +- An existing Azure resource group, and either: + - Existing VNet + Subnet IDs, or + - Permission to create networking (VNet, Subnet, NAT Gateway, Public IP) in the target resource group. +- Azure permissions to create VMSS, NSG, and identity assignments. + +## Quick Start + +### Step 1: Build a runner image + +```bash +cd ../packer +terraform init && terraform apply +``` + +Capture the resulting custom image ID - you'll pass it as `vm_image_id`. + +### Step 2: Create a runner group + +```bash +cd ../../runner_group +terraform init && terraform apply +``` + +Capture `runner_group_name`, `runner_group_token`, and `storage_backend_identity_id` from its outputs. + +### Step 3: Deploy this module + +### Basic Configuration Example + +```hcl +module "vmss" { + source = "./azure/vmss" + + resource_group_name = "rg-stackguardian-runner" + azure_location = "westeurope" + vm_image_id = "/subscriptions/xxxx/resourceGroups/rg-images/providers/Microsoft.Compute/images/sg-runner-1" + + runner_group_name = module.runner_group.runner_group_name + runner_group_token = module.runner_group.runner_group_token + storage_backend_identity_id = module.runner_group.storage_backend_identity_id + + stackguardian = { + api_key = "sgo_xxxxxxxxxxxx" + org_name = "demo-org" + } + + network = { + vnet_id = "/subscriptions/.../virtualNetworks/my-vnet" + subnet_id = "/subscriptions/.../subnets/runner-subnet" + } + + firewall = { + ssh_public_key = file("~/.ssh/id_rsa.pub") + } +} +``` + +## Configuration + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| `vm_image_id` | Azure custom image ID with `docker`, `cron`, `jq`, `sg-runner` pre-installed. Must start with `/subscriptions/`. | `string` | +| `resource_group_name` | Existing Azure Resource Group to deploy into. | `string` | +| `runner_group_name` | StackGuardian runner group name (from `runner_group` module output). | `string` | +| `runner_group_token` | Runner group registration token (sensitive, from `runner_group` module output). | `string` | +| `storage_backend_identity_id` | Resource ID of the User-Assigned Managed Identity for storage backend access. | `string` | +| `stackguardian.api_key` | StackGuardian API key (`sgo_*`/`sgu_*`) or `${secret::NAME}` reference. | `string` | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `vm_size` | Azure VM SKU per instance (min 4 vCPU, 8 GB RAM recommended). | `Standard_D4s_v3` | +| `azure_location` | Azure region. | `westeurope` | +| `stackguardian.api_uri` | Control-plane URL (EU1 / US1 / DASH). | `https://api.app.stackguardian.io` | +| `stackguardian.org_name` | Override org name (else derived from API key). | `""` | +| `network.create_network` | Create a new VNet and Subnet. | `false` | +| `network.vnet_id` / `subnet_id` | Existing VNet/Subnet IDs (required when `create_network = false`). | `""` | +| `network.vnet_address_space` | CIDR blocks for the new VNet. | `["10.0.0.0/16"]` | +| `network.subnet_address_prefix` | CIDR for the new subnet. | `10.0.1.0/24` | +| `network.create_network_infrastructure` | Create NAT Gateway with public IP. Only applies together with `create_network = true`. | `false` | +| `network.proxy_url` | HTTP proxy URL for private network deployments. | `""` | +| `network.additional_nsg_ids` | Extra NSG IDs to associate. | `[]` | +| `network.service_endpoints` | VNet service endpoints on the module-created subnet, e.g. `["Microsoft.Storage", "Microsoft.KeyVault"]`. Ignored for an existing subnet. | `[]` | +| `os_disk.caching` | `None` / `ReadOnly` / `ReadWrite`. | `ReadWrite` | +| `os_disk.storage_account_type` | `Standard_LRS` / `StandardSSD_LRS` / `Premium_LRS` / `Premium_ZRS`. | `Premium_LRS` | +| `os_disk.disk_size_gb` | OS disk size in GB (min 30). | `100` | +| `firewall.admin_username` | Linux admin user. | `azureuser` | +| `firewall.ssh_public_key` | SSH public key (required unless `generate_ssh_key = true`). | `""` | +| `firewall.generate_ssh_key` | Generate an RSA keypair (private key in state). | `false` | +| `firewall.ssh_access_rules` | Map of name to source CIDR allowed on port 22. | `{}` | +| `firewall.additional_inbound_rules` | Map of name to NSG rule object (priority, protocol, ports, CIDRs). | `{}` | +| `scaling.min_size` | Minimum instance count. Must be >= 1. | `1` | +| `scaling.max_size` | Maximum instance count. | `3` | +| `scaling.desired_capacity` | Initial instance count (ignored after first apply). | `1` | +| `upgrade_policy.mode` | `Manual` / `Rolling` / `Automatic`. Manual leaves running instances on the old model. | `Manual` | +| `upgrade_policy.health_probe_id` | Load Balancer probe ID used as the health signal. | `""` | +| `upgrade_policy.application_health_extension` | In-guest health probe (`{ protocol, port, request_path }`); alternative health signal when there is no load balancer. | `null` | +| `upgrade_policy.max_batch_instance_percent` | Instances upgraded per batch (5-100). | `20` | +| `upgrade_policy.max_unhealthy_instance_percent` | Unhealthy instances tolerated during the upgrade (must be >= batch percent). | `20` | +| `upgrade_policy.max_unhealthy_upgraded_instance_percent` | Unhealthy upgraded instances tolerated before abort (0-100). | `20` | +| `upgrade_policy.pause_time_between_batches` | ISO 8601 wait between batches. | `PT5M` | +| `upgrade_policy.automatic_instance_repair` | Let Azure replace unhealthy instances (needs a health signal). | `false` | +| `upgrade_policy.automatic_instance_repair_grace_period` | ISO 8601 grace period before repairs. | `PT30M` | +| `runner_startup_timeout` | Seconds to wait for Docker before instance self-shutdown. | `300` | +| `override_names.global_prefix` | Resource name prefix. | `SG_RUNNER` | +| `override_names.include_org_in_prefix` | Append the org name (from `stackguardian.org_name`) to the prefix. | `false` | + +### Configuration Examples + +#### Create network + NAT Gateway + +```hcl +module "vmss" { + source = "./azure/vmss" + # ... required params ... + + network = { + create_network = true + vnet_address_space = ["10.20.0.0/16"] + subnet_address_prefix = "10.20.1.0/24" + create_network_infrastructure = true + } + + firewall = { + ssh_public_key = file("~/.ssh/id_rsa.pub") + ssh_access_rules = { + office = "203.0.113.0/24" + } + } +} +``` + +#### Private subnet with service endpoints + +```hcl +module "vmss" { + source = "./azure/vmss" + # ... required params ... + + network = { + create_network = true + subnet_address_prefix = "10.20.1.0/24" + service_endpoints = ["Microsoft.Storage", "Microsoft.KeyVault"] + } +} +``` + +#### Roll the fleet automatically on a new image + +```hcl +module "vmss" { + source = "./azure/vmss" + # ... required params ... + + upgrade_policy = { + mode = "Rolling" + + # No load balancer in this module, so use the in-guest health probe. + # Point health_probe_id at a Load Balancer probe instead if you have one. + application_health_extension = { + protocol = "tcp" + port = 22 + } + + max_batch_instance_percent = 20 + pause_time_between_batches = "PT5M" + } +} +``` + +#### Generate SSH key (private key in state) + +```hcl +firewall = { + generate_ssh_key = true +} + +# Then read the output: +output "private_key" { + value = module.vmss.ssh_private_key + sensitive = true +} +``` + +## Usage + +```bash +terraform init +terraform validate +terraform plan +terraform apply +``` + +### Auto-scaling + +This module sets `instances` to `scaling.desired_capacity` once and then ignores it. The sibling `azure/autoscaler` module (an Azure Function) drives scale events between `min_size` and `max_size` based on runner queue depth. Re-running `terraform apply` will not fight the autoscaler. + +### Image upgrades + +With the default `upgrade_policy.mode = "Manual"`, changing `vm_image_id` (or `vm_size`, or anything else in the VMSS model) updates the scale set model but leaves running instances on the old model until the autoscaler or an operator replaces them. Set `upgrade_policy.mode = "Rolling"` to have Azure replace instances in batches as soon as the model changes - the Azure equivalent of the AWS module's `instance_refresh` block. Rolling and Automatic modes require a health signal, either `upgrade_policy.health_probe_id` (a Load Balancer probe) or `upgrade_policy.application_health_extension` (an in-guest probe, which needs no load balancer). + +Note that `upgrade_mode` cannot be changed in place on Azure: switching `upgrade_policy.mode` on an already-deployed scale set replaces the VMSS, so every runner is recreated. Pick the mode when you first deploy, or plan for the replacement. + +### Cleanup + +```bash +terraform destroy +``` + +When `firewall.generate_ssh_key = true`, the generated private key is destroyed with the state. + +## Architecture + +### Resource Organization + +| File | Contents | +|------|----------| +| `vmss.tf` | `azurerm_linux_virtual_machine_scale_set`, optional `tls_private_key`. | +| `network.tf` | NSG, optional VNet, Subnet, NAT Gateway, Public IP, subnet associations. | +| `data.tf` | Data sources used by the module. | +| `locals.tf` | Naming helpers, computed flags (`create_network`, `create_nat_gateway`, `use_generated_key`), tags. | +| `variables.tf` | Module inputs. | +| `outputs.tf` | Module outputs. | +| `provider.tf` | `terraform` block and provider versions. | +| `templates/register_runner.sh.tpl` | Cloud-init template that registers the instance with StackGuardian. | + +### Resource Naming Convention + +Resources are named `-vmss-` where `sanitized_prefix` is built from `override_names.global_prefix`, optionally suffixed with the StackGuardian org name when `include_org_in_prefix = true`. The org name comes from `stackguardian.org_name`, falling back to the `SG_ORG_ID` environment variable - there is no separate naming override. + +## Troubleshooting + +1. **`vm_image_id must be a valid Azure resource ID starting with '/subscriptions/'`** + - You passed an image name or short ID. Use the full resource ID, e.g. `/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/images/{name}`. + +2. **`Either set create_network = true, or provide both vnet_id and subnet_id`** + - You left `create_network = false` (default) but didn't supply `vnet_id` and `subnet_id`. Pick one mode. + +3. **`Either provide ssh_public_key or set generate_ssh_key = true`** + - You disabled key generation but didn't provide a public key. Either set `firewall.ssh_public_key` or flip `firewall.generate_ssh_key = true`. + +4. **`Rolling and Automatic upgrades need a health signal`** + - You set `upgrade_policy.mode` to `Rolling` or `Automatic` without a health source. Set `upgrade_policy.health_probe_id`, or `upgrade_policy.application_health_extension = { protocol = "tcp", port = 22 }`. + +5. **Instance starts but doesn't register with StackGuardian** + - Check `/var/log/cloud-init-output.log` on the instance. + - Verify the API URI matches your control plane and the runner group token isn't expired. + - If on a private network, set `network.proxy_url`. + +6. **Instance shuts itself down after 5 minutes** + - Docker didn't start within `runner_startup_timeout`. Verify the image actually has docker installed and enabled - rebuild via Packer if needed. + +### Debugging Commands + +```bash +# Find an instance +az vmss list-instances --resource-group --name -o table + +# SSH (using the configured admin_username) +ssh azureuser@ + +# On the instance +sudo journalctl -u cloud-final --no-pager +sudo tail -n 200 /var/log/cloud-init-output.log +sudo systemctl status docker +sudo systemctl status sg-runner || true +``` + +## Outputs + +| Output | Description | +|--------|-------------| +| `vmss_id` | Resource ID of the VMSS. | +| `vmss_name` | Name of the VMSS (consume from `azure/autoscaler` `vmss.name`). | +| `vmss_resource_group_name` | Resource group containing the VMSS. | +| `network_security_group_id` | NSG resource ID. | +| `vnet_id` | VNet ID (created or existing). | +| `subnet_id` | Subnet ID (created or existing). | +| `ssh_private_key` | Generated RSA private key, PEM-encoded (sensitive; only when `firewall.generate_ssh_key = true`). | +| `ssh_public_key` | SSH public key in use on the VMSS. | +| `storage_backend_identity_id` | Pass-through of the storage backend managed identity ID. | + +## Security Considerations + +- The NSG denies all inbound by default; SSH is opened only via `firewall.ssh_access_rules` (per-CIDR), and any extra exposure must be opted in via `firewall.additional_inbound_rules`. +- Outbound is allowed (rule priority 4096) so runners can reach the StackGuardian control plane and container registries. +- Storage backend access is via a User-Assigned Managed Identity - no static credentials on the instance. +- `runner_group_token` is marked sensitive and is not echoed to logs; prefer `${secret::NAME}` references. +- Generating SSH keys via `firewall.generate_ssh_key = true` stores the private key in Terraform state. Treat the state file as a secret or supply your own key. + +## Requirements + +| Name | Version | +|------|---------| +| `terraform` | `>= 1.0` | +| `azurerm` | `>= 3.0` | +| `external` | `>= 2.0` | +| `random` | `>= 3.0` | +| `tls` | `>= 4.0` | + +## Next Steps + +- Wire the sibling `azure/autoscaler` module to this VMSS using `vmss_name` and `vmss_resource_group_name`. +- Confirm runners appear in the StackGuardian UI under the configured runner group. +- Adjust `scaling.min_size` / `max_size` once you have a feel for queue throughput. + +## Support + +- StackGuardian docs: https://docs.stackguardian.io +- Module issues: open a ticket in the StackGuardian platform or this repository. diff --git a/stackguardian_private_runner/azure/vmss/data.tf b/stackguardian_private_runner/azure/vmss/data.tf new file mode 100644 index 0000000..cee07df --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/data.tf @@ -0,0 +1 @@ +data "azurerm_client_config" "current" {} diff --git a/stackguardian_private_runner/azure/vmss/locals.tf b/stackguardian_private_runner/azure/vmss/locals.tf new file mode 100644 index 0000000..0e23192 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/locals.tf @@ -0,0 +1,69 @@ +# Extract SG org name from environment if not provided +data "external" "env" { + program = [ + "sh", + "-c", + "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\"}'" + ] +} + +locals { + # StackGuardian configuration - use provided values or extract from environment + # Use nonsensitive() for non-secret fields to prevent sensitivity propagation + sg_org_name = ( + nonsensitive(var.stackguardian.org_name) != "" + ? nonsensitive(var.stackguardian.org_name) + : data.external.env.result.sg_org_name + ) + sg_api_uri = nonsensitive(var.stackguardian.api_uri) + + # Network mode logic + create_network = var.network.create_network + + # Subnet ID (created or existing) + subnet_id = ( + local.create_network + ? azurerm_subnet.this[0].id + : var.network.subnet_id + ) + + # NAT gateway is only meaningful when the module owns the subnet + create_nat_gateway = var.network.create_network_infrastructure && local.create_network + + # Service endpoints only apply to the subnet this module creates + subnet_service_endpoints = ( + length(var.network.service_endpoints) > 0 + ? var.network.service_endpoints + : null + ) + + # SSH key logic: provided key > generated key + use_generated_key = var.firewall.generate_ssh_key && var.firewall.ssh_public_key == "" + ssh_public_key = ( + local.use_generated_key + ? tls_private_key.ssh[0].public_key_openssh + : var.firewall.ssh_public_key + ) + + # Upgrade policy: azurerm requires a rolling_upgrade_policy block for + # Automatic/Rolling and rejects it for Manual (the default) + rolling_upgrade = var.upgrade_policy.mode != "Manual" + + # Computed prefix with optional org name (matches AWS pattern) + effective_prefix = ( + var.override_names.include_org_in_prefix && local.sg_org_name != "" + ? "${var.override_names.global_prefix}_${local.sg_org_name}" + : var.override_names.global_prefix + ) + + # Sanitized prefix for Azure naming (lowercase, hyphens) + sanitized_prefix = replace(lower(local.effective_prefix), "_", "-") + + # Common tags + common_tags = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } + + vmss_name = "${local.sanitized_prefix}-private-runner-vmss" +} diff --git a/stackguardian_private_runner/azure/vmss/network.tf b/stackguardian_private_runner/azure/vmss/network.tf new file mode 100644 index 0000000..d8d60a0 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/network.tf @@ -0,0 +1,129 @@ +/*-------------------------------------------+ + | Virtual Network (optional - when creating)| + +-------------------------------------------*/ +resource "azurerm_virtual_network" "this" { + count = local.create_network ? 1 : 0 + + name = "${local.sanitized_prefix}-vmss-vnet" + address_space = var.network.vnet_address_space + location = var.azure_location + resource_group_name = var.resource_group_name + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-vmss-vnet" + }) +} + +resource "azurerm_subnet" "this" { + count = local.create_network ? 1 : 0 + + name = "${local.sanitized_prefix}-vmss-subnet" + resource_group_name = var.resource_group_name + virtual_network_name = azurerm_virtual_network.this[0].name + address_prefixes = [var.network.subnet_address_prefix] + + # Reach Azure PaaS (Storage, Key Vault, ...) over the Azure backbone instead + # of the public internet. Empty list leaves the subnet untouched. + service_endpoints = local.subnet_service_endpoints +} + +/*-------------------------------------------+ + | Network Security Group | + +-------------------------------------------*/ +resource "azurerm_network_security_group" "this" { + name = "${local.sanitized_prefix}-vmss-nsg" + location = var.azure_location + resource_group_name = var.resource_group_name + + dynamic "security_rule" { + for_each = var.firewall.ssh_access_rules + content { + name = "SSH-${security_rule.key}" + priority = 100 + index(keys(var.firewall.ssh_access_rules), security_rule.key) + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = security_rule.value + destination_address_prefix = "*" + } + } + + dynamic "security_rule" { + for_each = var.firewall.additional_inbound_rules + content { + name = security_rule.key + priority = security_rule.value.priority + direction = security_rule.value.direction + access = security_rule.value.access + protocol = security_rule.value.protocol + source_port_range = security_rule.value.source_port_range + destination_port_range = security_rule.value.destination_port_range + source_address_prefix = security_rule.value.source_address_prefix + destination_address_prefix = security_rule.value.destination_address_prefix + } + } + + security_rule { + name = "AllowAllOutbound" + priority = 4096 + direction = "Outbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-vmss-nsg" + }) +} + +/*-------------------------------------------+ + | NAT Gateway (optional) | + +-------------------------------------------*/ +resource "azurerm_public_ip" "nat" { + count = local.create_nat_gateway ? 1 : 0 + + name = "${local.sanitized_prefix}-vmss-nat-pip" + location = var.azure_location + resource_group_name = var.resource_group_name + allocation_method = "Static" + sku = "Standard" + zones = ["1"] + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-vmss-nat-pip" + }) +} + +resource "azurerm_nat_gateway" "this" { + count = local.create_nat_gateway ? 1 : 0 + + name = "${local.sanitized_prefix}-vmss-natgw" + location = var.azure_location + resource_group_name = var.resource_group_name + sku_name = "Standard" + idle_timeout_in_minutes = 10 + + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-vmss-natgw" + }) +} + +resource "azurerm_nat_gateway_public_ip_association" "this" { + count = local.create_nat_gateway ? 1 : 0 + + nat_gateway_id = azurerm_nat_gateway.this[0].id + public_ip_address_id = azurerm_public_ip.nat[0].id +} + +resource "azurerm_subnet_nat_gateway_association" "this" { + count = local.create_nat_gateway ? 1 : 0 + + subnet_id = azurerm_subnet.this[0].id + nat_gateway_id = azurerm_nat_gateway.this[0].id +} diff --git a/stackguardian_private_runner/azure/vmss/outputs.tf b/stackguardian_private_runner/azure/vmss/outputs.tf new file mode 100644 index 0000000..bf20346 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/outputs.tf @@ -0,0 +1,57 @@ +/*-----------------------+ + | VMSS Outputs | + +-----------------------*/ +output "vmss_id" { + description = "The ID of the Linux VM Scale Set" + value = azurerm_linux_virtual_machine_scale_set.this.id +} + +output "vmss_name" { + description = "The name of the Linux VM Scale Set (consume from azure/autoscaler vmss.name input)" + value = azurerm_linux_virtual_machine_scale_set.this.name +} + +output "vmss_resource_group_name" { + description = "The resource group name containing the VMSS (consume from azure/autoscaler vmss.resource_group_name input)" + value = var.resource_group_name +} + +/*-----------------------+ + | Network Outputs | + +-----------------------*/ +output "network_security_group_id" { + description = "The ID of the network security group" + value = azurerm_network_security_group.this.id +} + +output "vnet_id" { + description = "The ID of the VNet (created or existing)" + value = local.create_network ? azurerm_virtual_network.this[0].id : var.network.vnet_id +} + +output "subnet_id" { + description = "The ID of the subnet (created or existing)" + value = local.subnet_id +} + +/*-----------------------+ + | SSH Key Outputs | + +-----------------------*/ +output "ssh_private_key" { + description = "The generated SSH private key (only populated when firewall.generate_ssh_key = true)" + value = local.use_generated_key ? tls_private_key.ssh[0].private_key_pem : null + sensitive = true +} + +output "ssh_public_key" { + description = "The SSH public key used for the VMSS instances" + value = local.ssh_public_key +} + +/*-----------------------+ + | Identity Outputs | + +-----------------------*/ +output "storage_backend_identity_id" { + description = "The resource ID of the storage backend managed identity (passed through)" + value = var.storage_backend_identity_id +} diff --git a/stackguardian_private_runner/azure/vmss/provider.tf b/stackguardian_private_runner/azure/vmss/provider.tf new file mode 100644 index 0000000..8d214ed --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/provider.tf @@ -0,0 +1,28 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + # Ceiling is load-bearing: azurerm 5.x removed azurerm_subnet.service_endpoints, + # which this module uses for network.service_endpoints + version = ">= 3.0, < 5.0" + } + external = { + source = "hashicorp/external" + version = ">= 2.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + tls = { + source = "hashicorp/tls" + version = ">= 4.0" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/stackguardian_private_runner/azure/vmss/schemas/input_schema.json b/stackguardian_private_runner/azure/vmss/schemas/input_schema.json new file mode 100644 index 0000000..828eb09 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/schemas/input_schema.json @@ -0,0 +1,400 @@ +{ + "type": "object", + "properties": { + "stackguardian": { + "title": "StackGuardian Platform", + "type": "object", + "properties": { + "api_uri": { + "title": "API Region", + "type": "string", + "default": "https://api.app.stackguardian.io", + "enum": [ + "https://api.app.stackguardian.io", + "https://api.us.stackguardian.io", + "https://testapi.qa.stackguardian.io" + ], + "enumNames": [ + "EU1 - Europe", + "US1 - United States", + "DASH - QA" + ] + }, + "api_key": { + "title": "API Key", + "type": "string", + "pattern": "^(sg[uo]_.*|\\$\\{secret::[a-zA-Z0-9_-]+\\})$", + "minLength": 1 + }, + "org_name": { + "title": "Organization Name", + "type": "string", + "default": "" + } + }, + "required": ["api_key"], + "additionalProperties": false + }, + "azure_location": { + "title": "Azure Region", + "type": "string", + "default": "westeurope" + }, + "resource_group_name": { + "title": "Resource Group Name", + "type": "string", + "minLength": 1 + }, + "runner_group_name": { + "title": "Runner Group Name", + "type": "string", + "minLength": 1 + }, + "runner_group_token": { + "title": "Runner Group Token", + "type": "string", + "minLength": 1 + }, + "storage_backend_identity_id": { + "title": "Storage Backend Identity ID", + "type": "string", + "minLength": 1 + }, + "vm_image_id": { + "title": "VM Image ID", + "type": "string", + "pattern": "^/subscriptions/.*", + "minLength": 1 + }, + "vm_size": { + "title": "VM Size", + "type": "string", + "default": "Standard_D4s_v3" + }, + "network": { + "title": "Network", + "type": "object", + "properties": { + "create_network": { + "title": "Create New VNet & Subnet", + "type": "boolean", + "default": false + }, + "vnet_id": { + "title": "Existing VNet ID", + "type": "string", + "default": "" + }, + "subnet_id": { + "title": "Existing Subnet ID", + "type": "string", + "default": "" + }, + "vnet_address_space": { + "title": "VNet Address Space", + "type": "array", + "items": { "type": "string" }, + "default": ["10.0.0.0/16"] + }, + "subnet_address_prefix": { + "title": "Subnet Address Prefix", + "type": "string", + "default": "10.0.1.0/24" + }, + "create_network_infrastructure": { + "title": "Create NAT Gateway", + "type": "boolean", + "default": false + }, + "proxy_url": { + "title": "Proxy URL", + "type": "string", + "default": "" + }, + "additional_nsg_ids": { + "title": "Additional NSG IDs", + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "service_endpoints": { + "title": "Service Endpoints", + "type": "array", + "items": { "type": "string" }, + "default": [] + } + }, + "dependencies": { + "create_network": { + "oneOf": [ + { + "properties": { + "create_network": { "enum": [false] }, + "vnet_id": { + "title": "Existing VNet ID", + "type": "string", + "minLength": 1 + }, + "subnet_id": { + "title": "Existing Subnet ID", + "type": "string", + "minLength": 1 + } + }, + "required": ["vnet_id", "subnet_id"] + }, + { + "properties": { + "create_network": { "enum": [true] }, + "vnet_address_space": { + "title": "VNet Address Space", + "type": "array", + "items": { "type": "string" }, + "default": ["10.0.0.0/16"] + }, + "subnet_address_prefix": { + "title": "Subnet Address Prefix", + "type": "string", + "default": "10.0.1.0/24" + } + } + } + ] + } + } + }, + "os_disk": { + "title": "OS Disk", + "type": "object", + "properties": { + "caching": { + "title": "Disk Caching", + "type": "string", + "default": "ReadWrite", + "enum": ["None", "ReadOnly", "ReadWrite"] + }, + "storage_account_type": { + "title": "Storage Account Type", + "type": "string", + "default": "Premium_LRS", + "enum": ["Standard_LRS", "StandardSSD_LRS", "Premium_LRS", "Premium_ZRS"] + }, + "disk_size_gb": { + "title": "Disk Size (GB)", + "type": "number", + "default": 100, + "minimum": 30 + } + }, + "additionalProperties": false + }, + "firewall": { + "title": "Firewall & SSH", + "type": "object", + "properties": { + "admin_username": { + "title": "Admin Username", + "type": "string", + "default": "azureuser" + }, + "generate_ssh_key": { + "title": "Generate SSH Key", + "type": "boolean", + "default": false + }, + "ssh_public_key": { + "title": "SSH Public Key", + "type": "string", + "default": "" + }, + "ssh_access_rules": { + "title": "SSH Access Rules", + "type": "object", + "default": {}, + "additionalProperties": { "type": "string" } + }, + "additional_inbound_rules": { + "title": "Additional Inbound Rules", + "type": "object", + "default": {}, + "additionalProperties": { + "type": "object", + "properties": { + "priority": { "type": "number" }, + "direction": { "type": "string", "default": "Inbound", "enum": ["Inbound", "Outbound"] }, + "access": { "type": "string", "default": "Allow", "enum": ["Allow", "Deny"] }, + "protocol": { "type": "string" }, + "source_port_range": { "type": "string", "default": "*" }, + "destination_port_range": { "type": "string" }, + "source_address_prefix": { "type": "string" }, + "destination_address_prefix": { "type": "string", "default": "*" } + }, + "required": ["priority", "protocol", "destination_port_range", "source_address_prefix"] + } + } + }, + "dependencies": { + "generate_ssh_key": { + "oneOf": [ + { + "properties": { + "generate_ssh_key": { "enum": [false] }, + "ssh_public_key": { + "type": "string", + "minLength": 1 + } + }, + "required": ["ssh_public_key"] + }, + { + "properties": { + "generate_ssh_key": { "enum": [true] } + } + } + ] + } + } + }, + "scaling": { + "title": "Scaling", + "type": "object", + "properties": { + "min_size": { + "title": "Minimum Instances", + "type": "number", + "default": 1, + "minimum": 1 + }, + "max_size": { + "title": "Maximum Instances", + "type": "number", + "default": 3, + "minimum": 1 + }, + "desired_capacity": { + "title": "Desired Capacity", + "type": "number", + "default": 1, + "minimum": 1 + } + }, + "additionalProperties": false + }, + "upgrade_policy": { + "title": "Upgrade Policy", + "type": "object", + "properties": { + "mode": { + "title": "Upgrade Mode", + "type": "string", + "default": "Manual", + "enum": ["Manual", "Rolling", "Automatic"], + "enumNames": [ + "Manual - instances keep the old model until replaced", + "Rolling - Azure replaces instances in batches", + "Automatic - Azure replaces all instances at once" + ] + }, + "health_probe_id": { + "title": "Health Probe ID", + "type": "string", + "default": "" + }, + "application_health_extension": { + "title": "Application Health Extension", + "type": ["object", "null"], + "default": null, + "properties": { + "protocol": { + "title": "Protocol", + "type": "string", + "default": "tcp", + "enum": ["tcp", "http", "https"] + }, + "port": { + "title": "Port", + "type": "number", + "default": 22, + "minimum": 1 + }, + "request_path": { + "title": "Request Path", + "type": "string", + "default": "" + } + }, + "additionalProperties": false + }, + "max_batch_instance_percent": { + "title": "Max Batch Instance Percent", + "type": "number", + "default": 20, + "minimum": 5, + "maximum": 100 + }, + "max_unhealthy_instance_percent": { + "title": "Max Unhealthy Instance Percent", + "type": "number", + "default": 20, + "minimum": 5, + "maximum": 100 + }, + "max_unhealthy_upgraded_instance_percent": { + "title": "Max Unhealthy Upgraded Instance Percent", + "type": "number", + "default": 20, + "minimum": 0, + "maximum": 100 + }, + "pause_time_between_batches": { + "title": "Pause Between Batches", + "type": "string", + "default": "PT5M" + }, + "automatic_instance_repair": { + "title": "Automatic Instance Repair", + "type": "boolean", + "default": false + }, + "automatic_instance_repair_grace_period": { + "title": "Instance Repair Grace Period", + "type": "string", + "default": "PT30M" + } + }, + "additionalProperties": false + }, + "runner_startup_timeout": { + "title": "Runner Startup Timeout (seconds)", + "type": "number", + "default": 300, + "minimum": 30 + }, + "override_names": { + "title": "Resource Naming", + "type": "object", + "properties": { + "global_prefix": { + "title": "Global Prefix", + "type": "string", + "default": "SG_RUNNER" + }, + "include_org_in_prefix": { + "title": "Include Org in Prefix", + "type": "boolean", + "default": false + } + }, + "required": ["global_prefix"], + "additionalProperties": false + } + }, + "required": [ + "stackguardian", + "resource_group_name", + "runner_group_name", + "runner_group_token", + "storage_backend_identity_id", + "vm_image_id" + ] +} diff --git a/stackguardian_private_runner/azure/vmss/schemas/ui_schema.json b/stackguardian_private_runner/azure/vmss/schemas/ui_schema.json new file mode 100644 index 0000000..02fdb88 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/schemas/ui_schema.json @@ -0,0 +1,249 @@ +{ + "ui:order": [ + "stackguardian", + "azure_location", + "resource_group_name", + "runner_group_name", + "runner_group_token", + "storage_backend_identity_id", + "vm_image_id", + "vm_size", + "network", + "os_disk", + "firewall", + "scaling", + "upgrade_policy", + "runner_startup_timeout", + "override_names" + ], + "stackguardian": { + "ui:title": "StackGuardian Platform", + "ui:description": "Connection details used by the runner to register itself with the StackGuardian platform.", + "ui:order": ["api_uri", "api_key", "org_name"], + "api_uri": { + "ui:widget": "select", + "ui:description": "Region of the StackGuardian control plane your organization runs in." + }, + "api_key": { + "ui:placeholder": "sgu_*** or ${secret::SECRET_NAME}", + "ui:description": "Your organization's API key (sgo_*/sgu_*) or a secret reference (${secret::SECRET_NAME})." + }, + "org_name": { + "ui:placeholder": "demo-org", + "ui:description": "Override the StackGuardian organization name. Leave blank to derive it from the API key." + } + }, + "azure_location": { + "ui:placeholder": "westeurope", + "ui:description": "Azure region where the VM Scale Set and supporting resources are deployed." + }, + "resource_group_name": { + "ui:placeholder": "rg-stackguardian-runner", + "ui:description": "Name of the existing Azure Resource Group where resources will be created." + }, + "runner_group_name": { + "ui:description": "Name of the StackGuardian runner group these VMSS instances will register against (output of the runner_group module)." + }, + "runner_group_token": { + "ui:placeholder": "${secret::RUNNER_GROUP_TOKEN}", + "ui:description": "Registration token for the runner group (output of the runner_group module). Use a secret reference." + }, + "storage_backend_identity_id": { + "ui:placeholder": "/subscriptions/.../userAssignedIdentities/...", + "ui:description": "Resource ID of the User-Assigned Managed Identity used by runners to access the storage backend (output of the runner_group module)." + }, + "vm_image_id": { + "ui:placeholder": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/images/{name}", + "ui:description": "Custom Azure image ID with docker, cron, jq, and sg-runner pre-installed (typically built via the sibling Packer module)." + }, + "vm_size": { + "ui:placeholder": "Standard_D4s_v3", + "ui:description": "VM SKU for each scale-set instance. Minimum 4 vCPU and 8 GB RAM recommended." + }, + "network": { + "ui:title": "Network", + "ui:description": "Either supply existing VNet/Subnet IDs or create a new VNet and Subnet.", + "ui:order": [ + "create_network", + "vnet_id", + "subnet_id", + "vnet_address_space", + "subnet_address_prefix", + "create_network_infrastructure", + "proxy_url", + "additional_nsg_ids", + "service_endpoints" + ], + "create_network": { + "ui:widget": "radio", + "ui:options": { "inline": true }, + "ui:description": "Create a new VNet and Subnet for the runner. When false, you must provide existing VNet and Subnet IDs." + }, + "vnet_id": { + "ui:placeholder": "/subscriptions/.../virtualNetworks/...", + "ui:description": "Resource ID of an existing Virtual Network." + }, + "subnet_id": { + "ui:placeholder": "/subscriptions/.../subnets/...", + "ui:description": "Resource ID of an existing Subnet within the VNet above." + }, + "vnet_address_space": { + "ui:description": "CIDR blocks for the new VNet.", + "items": { "ui:placeholder": "10.0.0.0/16" } + }, + "subnet_address_prefix": { + "ui:placeholder": "10.0.1.0/24", + "ui:description": "CIDR for the new subnet inside the VNet address space." + }, + "create_network_infrastructure": { + "ui:widget": "radio", + "ui:options": { "inline": true }, + "ui:description": "Create a NAT Gateway with public IP and associate it with the (created) subnet for outbound internet access." + }, + "proxy_url": { + "ui:placeholder": "http://proxy.internal:3128", + "ui:description": "Optional HTTP proxy URL for private network deployments." + }, + "additional_nsg_ids": { + "ui:description": "Additional NSG resource IDs to associate with each instance.", + "items": { "ui:placeholder": "/subscriptions/.../networkSecurityGroups/..." } + }, + "service_endpoints": { + "ui:description": "VNet service endpoints enabled on the subnet this template creates, so runners reach Azure PaaS over the Azure backbone (e.g. Microsoft.Storage, Microsoft.KeyVault, Microsoft.ContainerRegistry). Ignored when using an existing subnet.", + "items": { "ui:placeholder": "Microsoft.Storage" } + } + }, + "os_disk": { + "ui:title": "OS Disk", + "ui:description": "OS disk configuration for each scale-set instance.", + "ui:order": ["caching", "storage_account_type", "disk_size_gb"], + "caching": { "ui:widget": "select" }, + "storage_account_type": { "ui:widget": "select" }, + "disk_size_gb": { + "ui:description": "OS disk size in GB. Minimum 30." + } + }, + "firewall": { + "ui:title": "Firewall & SSH", + "ui:description": "SSH access and additional inbound rules for the NSG attached to each instance.", + "ui:order": [ + "admin_username", + "generate_ssh_key", + "ssh_public_key", + "ssh_access_rules", + "additional_inbound_rules" + ], + "admin_username": { + "ui:placeholder": "azureuser", + "ui:description": "Linux admin user on each instance." + }, + "generate_ssh_key": { + "ui:widget": "radio", + "ui:options": { "inline": true }, + "ui:description": "**Warning:** when true, an RSA keypair is generated and the private key is stored in Terraform state and exposed as a sensitive output. Prefer providing your own ssh_public_key." + }, + "ssh_public_key": { + "ui:widget": "textarea", + "ui:options": { "rows": 3 }, + "ui:placeholder": "ssh-rsa AAAA... user@host", + "ui:description": "SSH public key authorized to log in as the admin user." + }, + "ssh_access_rules": { + "ui:description": "Map of friendly names to source CIDRs allowed to reach port 22 (e.g. office: 1.2.3.4/32)." + }, + "additional_inbound_rules": { + "ui:description": "Map of named NSG inbound rules. Keys are rule names; values configure priority, protocol, ports and source CIDRs." + } + }, + "scaling": { + "ui:title": "Scaling", + "ui:description": "Capacity bounds for the VM Scale Set. The autoscaler drives instance count between min and max.", + "ui:order": ["min_size", "max_size", "desired_capacity"], + "min_size": { + "ui:description": "Floor on instance count. Must be at least 1." + }, + "max_size": { + "ui:description": "Ceiling on instance count. Must be greater than or equal to min_size." + }, + "desired_capacity": { + "ui:description": "Initial instance count. Must be between min_size and max_size." + } + }, + "upgrade_policy": { + "ui:title": "Upgrade Policy", + "ui:description": "How the scale set rolls out a new image or VM size. Manual keeps the current behaviour: running instances are left alone until the autoscaler or an operator replaces them.", + "ui:order": [ + "mode", + "health_probe_id", + "application_health_extension", + "max_batch_instance_percent", + "max_unhealthy_instance_percent", + "max_unhealthy_upgraded_instance_percent", + "pause_time_between_batches", + "automatic_instance_repair", + "automatic_instance_repair_grace_period" + ], + "mode": { + "ui:widget": "select", + "ui:description": "Manual leaves existing instances on the old model. Rolling replaces them in batches. Both Rolling and Automatic require a health signal below. **Warning:** changing this on a deployed stack replaces the VM Scale Set and recreates every runner." + }, + "health_probe_id": { + "ui:placeholder": "/subscriptions/.../loadBalancers/.../probes/...", + "ui:description": "Load Balancer probe used to judge instance health. Leave blank and use the Application Health Extension when there is no load balancer." + }, + "application_health_extension": { + "ui:description": "Installs the in-guest Application Health extension, which probes the instance locally - the simplest health signal for a runner fleet with no load balancer. Leave unset for Manual upgrades.", + "ui:order": ["protocol", "port", "request_path"], + "protocol": { + "ui:widget": "select", + "ui:description": "tcp opens a socket, http/https issue a request against the path below." + }, + "port": { + "ui:description": "Local port probed on each instance (22 targets sshd, which the runner image already runs)." + }, + "request_path": { + "ui:placeholder": "/health", + "ui:description": "Path requested when protocol is http or https. Required for those protocols." + } + }, + "max_batch_instance_percent": { + "ui:description": "Percent of instances upgraded in a single batch." + }, + "max_unhealthy_instance_percent": { + "ui:description": "Percent of instances allowed to be unhealthy during the upgrade. Must be greater than or equal to the batch percent." + }, + "max_unhealthy_upgraded_instance_percent": { + "ui:description": "Percent of already-upgraded instances allowed to be unhealthy before the upgrade aborts." + }, + "pause_time_between_batches": { + "ui:placeholder": "PT5M", + "ui:description": "ISO 8601 duration to wait between batches, giving new runners time to register." + }, + "automatic_instance_repair": { + "ui:widget": "radio", + "ui:options": { "inline": true }, + "ui:description": "Let Azure replace instances that report unhealthy. Requires a health signal." + }, + "automatic_instance_repair_grace_period": { + "ui:placeholder": "PT30M", + "ui:description": "ISO 8601 grace period after a state change before repairs kick in (30-90 minutes)." + } + }, + "runner_startup_timeout": { + "ui:description": "Maximum seconds to wait for Docker to start before shutting down each instance." + }, + "override_names": { + "ui:title": "Resource Naming", + "ui:description": "Customize the prefix used for naming Azure resources created by this module.", + "ui:order": ["global_prefix", "include_org_in_prefix"], + "global_prefix": { + "ui:placeholder": "SG_RUNNER", + "ui:description": "Prefix used for naming all Azure resources created by this module." + }, + "include_org_in_prefix": { + "ui:widget": "radio", + "ui:options": { "inline": true }, + "ui:description": "When true, appends the StackGuardian organization name to the prefix (e.g. SG_RUNNER_demo-org). The org name comes from the StackGuardian Platform section above." + } + } +} diff --git a/stackguardian_private_runner/azure/vmss/templates/register_runner.sh.tpl b/stackguardian_private_runner/azure/vmss/templates/register_runner.sh.tpl new file mode 100644 index 0000000..f854039 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/templates/register_runner.sh.tpl @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -e + +startup_log_file="/var/log/sg_runner_startup.log" + +echo ">> Starting StackGuardian Private Runner registration..." | tee -a "$startup_log_file" + +## Configure HTTP/HTTPS proxy if provided (for private network deployments) +proxy_url="${proxy_url}" +if [ -n "$proxy_url" ]; then + echo ">> Configuring proxy: $proxy_url" | tee -a "$startup_log_file" + export HTTP_PROXY="$proxy_url" + export HTTPS_PROXY="$proxy_url" + export http_proxy="$proxy_url" + export https_proxy="$proxy_url" +fi + +## Wait for Docker with timeout +## Sometimes registration fails because docker.service is not ready. +## We will check if docker.service is ready and continue. +## Otherwise, sleep for 1 second and try again. +timeout="${sg_runner_startup_timeout}" +counter=0 + +until systemctl is-active --quiet docker; do + echo ">> Docker not ready.. Trying again in 1 second." | tee -a "$startup_log_file" + sleep 1 + counter=$((counter + 1)) + + if [ $counter -ge $timeout ]; then + echo ">> ERROR: Docker failed to start after $timeout seconds. Shutting down instance." | tee -a "$startup_log_file" + shutdown -h now + fi +done + +echo ">> Docker is ready." | tee -a "$startup_log_file" + +## Register Private Runner +export SG_BASE_API="${sg_api_uri}/api/v1" +sg-runner register \ + --organization "${sg_org_name}" \ + --runner-group "${sg_runner_group_name}" \ + --sg-node-token "${sg_runner_group_token}" 2>&1 | tee -a "$startup_log_file" + +echo ">> StackGuardian Private Runner registration complete." | tee -a "$startup_log_file" diff --git a/stackguardian_private_runner/azure/vmss/variables.tf b/stackguardian_private_runner/azure/vmss/variables.tf new file mode 100644 index 0000000..deb8eff --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/variables.tf @@ -0,0 +1,402 @@ +/*--------------------------------+ + | VM Scale Set Instance Variables | + +--------------------------------*/ +variable "vm_size" { + description = "The Azure VM size for each scale-set instance (min 4 vCPU, 8GB RAM recommended)" + type = string + default = "Standard_D4s_v3" +} + +variable "vm_image_id" { + description = <= 30 + error_message = "OS disk size must be at least 30 GB." + } +} + +/*------------------------------+ + | SSH Connection Variables | + +------------------------------*/ +variable "firewall" { + description = <= 1 + error_message = "min_size must be at least 1." + } + + validation { + condition = var.scaling.max_size >= var.scaling.min_size + error_message = "max_size must be greater than or equal to min_size." + } + + validation { + condition = ( + var.scaling.desired_capacity >= var.scaling.min_size && + var.scaling.desired_capacity <= var.scaling.max_size + ) + error_message = "desired_capacity must be between min_size and max_size (inclusive)." + } +} + +/*--------------------------+ + | Upgrade Policy Variables | + +--------------------------*/ +variable "upgrade_policy" { + description = <= max_batch_instance_percent. + - max_unhealthy_upgraded_instance_percent: Max percent of already-upgraded instances + allowed to be unhealthy before the upgrade aborts. + - pause_time_between_batches: ISO 8601 duration to wait between batches (e.g. PT5M). + - automatic_instance_repair: Let Azure replace instances that report unhealthy. Also + requires a health signal. + - automatic_instance_repair_grace_period: ISO 8601 grace period after a state change + before repairs kick in (30-90 minutes). + EOT + type = object({ + mode = optional(string, "Manual") + health_probe_id = optional(string, "") + application_health_extension = optional(object({ + protocol = optional(string, "tcp") + port = optional(number, 22) + request_path = optional(string, "") + }), null) + max_batch_instance_percent = optional(number, 20) + max_unhealthy_instance_percent = optional(number, 20) + max_unhealthy_upgraded_instance_percent = optional(number, 20) + pause_time_between_batches = optional(string, "PT5M") + automatic_instance_repair = optional(bool, false) + automatic_instance_repair_grace_period = optional(string, "PT30M") + }) + default = { + mode = "Manual" + } + + validation { + condition = contains(["Manual", "Rolling", "Automatic"], var.upgrade_policy.mode) + error_message = "The upgrade_policy.mode must be one of: Manual, Rolling, Automatic." + } + + validation { + condition = ( + var.upgrade_policy.mode == "Manual" || + var.upgrade_policy.health_probe_id != "" || + var.upgrade_policy.application_health_extension != null + ) + error_message = "Rolling and Automatic upgrades need a health signal: set upgrade_policy.health_probe_id or upgrade_policy.application_health_extension." + } + + validation { + condition = ( + !var.upgrade_policy.automatic_instance_repair || + var.upgrade_policy.health_probe_id != "" || + var.upgrade_policy.application_health_extension != null + ) + error_message = "The upgrade_policy.automatic_instance_repair needs a health signal: set upgrade_policy.health_probe_id or upgrade_policy.application_health_extension." + } + + validation { + condition = ( + var.upgrade_policy.application_health_extension == null || + contains(["tcp", "http", "https"], try(var.upgrade_policy.application_health_extension.protocol, "")) + ) + error_message = "The upgrade_policy.application_health_extension.protocol must be one of: tcp, http, https." + } + + validation { + condition = ( + var.upgrade_policy.application_health_extension == null || + try(var.upgrade_policy.application_health_extension.protocol, "") == "tcp" || + try(var.upgrade_policy.application_health_extension.request_path, "") != "" + ) + error_message = "The upgrade_policy.application_health_extension.request_path is required when protocol is http or https." + } + + validation { + condition = ( + var.upgrade_policy.max_batch_instance_percent >= 5 && + var.upgrade_policy.max_batch_instance_percent <= 100 && + var.upgrade_policy.max_unhealthy_instance_percent >= 5 && + var.upgrade_policy.max_unhealthy_instance_percent <= 100 && + var.upgrade_policy.max_unhealthy_upgraded_instance_percent >= 0 && + var.upgrade_policy.max_unhealthy_upgraded_instance_percent <= 100 + ) + error_message = "The max_batch_instance_percent and max_unhealthy_instance_percent must be between 5 and 100, max_unhealthy_upgraded_instance_percent between 0 and 100." + } + + validation { + condition = var.upgrade_policy.max_unhealthy_instance_percent >= var.upgrade_policy.max_batch_instance_percent + error_message = "The max_unhealthy_instance_percent must be greater than or equal to max_batch_instance_percent." + } + + validation { + condition = can(regex("^P(T?[0-9]+[DHMS])+$", var.upgrade_policy.pause_time_between_batches)) + error_message = "The pause_time_between_batches must be an ISO 8601 duration (e.g. PT0S, PT5M)." + } + + validation { + condition = can(regex("^P(T?[0-9]+[DHMS])+$", var.upgrade_policy.automatic_instance_repair_grace_period)) + error_message = "The automatic_instance_repair_grace_period must be an ISO 8601 duration (e.g. PT30M)." + } +} + +/*-----------------------------------+ + | Runner Startup Variables | + +-----------------------------------*/ +variable "runner_startup_timeout" { + description = "Maximum time in seconds to wait for Docker to start before shutting down each instance" + type = number + default = 300 +} diff --git a/stackguardian_private_runner/azure/vmss/vmss.tf b/stackguardian_private_runner/azure/vmss/vmss.tf new file mode 100644 index 0000000..aba684a --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/vmss.tf @@ -0,0 +1,127 @@ +/*-------------------------------------------+ + | SSH Key Generation (optional) | + +-------------------------------------------*/ +resource "tls_private_key" "ssh" { + count = local.use_generated_key ? 1 : 0 + + algorithm = "RSA" + rsa_bits = 4096 +} + +/*-------------------------------------------+ + | Linux Virtual Machine Scale Set | + +-------------------------------------------*/ +# Manual upgrade policy (the default) mirrors aws_launch_template + ASG pattern: +# changes to the SKU/image require explicit instance refresh, which the autoscaler +# (or operator) drives — the VMSS resource itself does not roll instances. +# Set upgrade_policy.mode = "Rolling" to have Azure roll the fleet in batches, the +# way the AWS module's instance_refresh block does. +resource "azurerm_linux_virtual_machine_scale_set" "this" { + name = local.vmss_name + resource_group_name = var.resource_group_name + location = var.azure_location + sku = var.vm_size + instances = var.scaling.desired_capacity + + admin_username = var.firewall.admin_username + disable_password_authentication = true + + admin_ssh_key { + username = var.firewall.admin_username + public_key = local.ssh_public_key + } + + source_image_id = var.vm_image_id + + os_disk { + caching = var.os_disk.caching + storage_account_type = var.os_disk.storage_account_type + disk_size_gb = var.os_disk.disk_size_gb + } + + identity { + type = "UserAssigned" + identity_ids = [var.storage_backend_identity_id] + } + + network_interface { + name = "${local.sanitized_prefix}-vmss-nic" + primary = true + network_security_group_id = azurerm_network_security_group.this.id + + ip_configuration { + name = "internal" + primary = true + subnet_id = local.subnet_id + } + } + + custom_data = base64encode( + templatefile("${path.module}/templates/register_runner.sh.tpl", + { + 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 = var.runner_group_token + sg_runner_startup_timeout = tostring(var.runner_startup_timeout) + proxy_url = var.network.proxy_url + } + ) + ) + + upgrade_mode = var.upgrade_policy.mode + + # Health signal Azure requires for Rolling/Automatic upgrades and for instance + # repair. Either a Load Balancer probe or the in-guest extension below satisfies it. + health_probe_id = var.upgrade_policy.health_probe_id != "" ? var.upgrade_policy.health_probe_id : null + + dynamic "extension" { + for_each = var.upgrade_policy.application_health_extension != null ? [var.upgrade_policy.application_health_extension] : [] + + content { + name = "ApplicationHealthLinux" + publisher = "Microsoft.ManagedServices" + type = "ApplicationHealthLinux" + type_handler_version = "1.0" + auto_upgrade_minor_version = true + + settings = jsonencode(merge( + { + protocol = extension.value.protocol + port = extension.value.port + }, + extension.value.request_path != "" ? { requestPath = extension.value.request_path } : {} + )) + } + } + + # azurerm requires this block for Automatic/Rolling and rejects it for Manual + dynamic "rolling_upgrade_policy" { + for_each = local.rolling_upgrade ? [1] : [] + + content { + max_batch_instance_percent = var.upgrade_policy.max_batch_instance_percent + max_unhealthy_instance_percent = var.upgrade_policy.max_unhealthy_instance_percent + max_unhealthy_upgraded_instance_percent = var.upgrade_policy.max_unhealthy_upgraded_instance_percent + pause_time_between_batches = var.upgrade_policy.pause_time_between_batches + } + } + + dynamic "automatic_instance_repair" { + for_each = var.upgrade_policy.automatic_instance_repair ? [1] : [] + + content { + enabled = true + grace_period = var.upgrade_policy.automatic_instance_repair_grace_period + } + } + + tags = merge(local.common_tags, { + Name = local.vmss_name + }) + + lifecycle { + # Autoscaler drives instance count after first apply. + ignore_changes = [instances] + } +} diff --git a/stackguardian_private_runner/examples/aws/packer/.gitignore b/stackguardian_private_runner/examples/aws/packer/.gitignore new file mode 100644 index 0000000..fe3cca7 --- /dev/null +++ b/stackguardian_private_runner/examples/aws/packer/.gitignore @@ -0,0 +1,4 @@ +# Plan artifacts - may embed credentials from the tfvars +tfplan +tofuplan +*.tfplan diff --git a/stackguardian_private_runner/examples/aws/packer/README.md b/stackguardian_private_runner/examples/aws/packer/README.md new file mode 100644 index 0000000..5f47901 --- /dev/null +++ b/stackguardian_private_runner/examples/aws/packer/README.md @@ -0,0 +1,159 @@ +# StackGuardian Private Runner — AWS AMI Build + +Builds the runner AMI and nothing else. No runner group, no connector, no EC2 +instance — just the image, so you can bake it once and point other deployments at +the resulting `ami_id`. + +> For a full working runner in one apply, use +> [examples/aws/quickstart](../quickstart/) instead — it wires this same module +> together with the runner group and an EC2 runner. + +## What Gets Built + +``` +tofu apply + | + v +[Look up your VPC + subnet] data.aws_vpc / data.aws_subnet (read only) + | + v +[Packer build] null_resource.packer_build + | temporary EC2 instance in your subnet, removed when the build ends + | installs: Docker, jq, cron, unzip, sg-runner + | optional: Terraform and/or OpenTofu at the versions you name + v +[Record the AMI ID] terraform_data.ami_id -> output ami_id +``` + +Only the AMI persists. The build instance, its key pair and its security group +are created and destroyed by Packer within the run. + +## Prerequisites + +| Requirement | Notes | +|-------------|-------| +| **OpenTofu >= 1.4** (or Terraform >= 1.4) | The module uses `terraform_data` | +| **AWS credentials** | Via `AWS_PROFILE`, environment variables, or an instance role | +| **An existing VPC and subnet** | This example attaches to them; it does not create networking | +| **Outbound internet from that subnet** | The build downloads packages and release archives | +| `sh`, `curl`, `unzip` | Used to bootstrap Packer at `packer_config.version` | + +Packer itself is downloaded automatically — you do not need it installed. + +## Quick Start + +```bash +cp terraform.tfvars.tpl terraform.tfvars +$EDITOR terraform.tfvars # set network.vpc_id and network.subnet_id +tofu init +tofu apply +tofu output ami_id +``` + +The build takes several minutes. Once it finishes, the AMI ID is recorded in +state and every later plan is a no-op — see [Rebuilding](#rebuilding). + +## Configuration + +### Required + +| Variable | Type | Description | +|----------|------|-------------| +| `network.vpc_id` | `string` | Existing VPC ID | +| `network.subnet_id` | `string` | Existing subnet the build instance runs in | + +### Commonly Adjusted + +| Variable | Default | Description | +|----------|---------|-------------| +| `aws_region` | `eu-central-1` | An AMI is regional — build it where you intend to launch runners | +| `instance_type` | `t3.medium` | Build instance size | +| `network.private_subnet` | `false` | Set `true` for a private subnet — see below | +| `os.family` | `amazon` | `amazon`, `ubuntu`, or `rhel` | +| `os.version` | `""` | Required for `ubuntu` and `rhel` | +| `ami_name_prefix` | `SG-RUNNER-ami` | Prefix of the generated AMI name | +| `terraform.primary_version` | `""` | Installed as `/bin/terraform` | +| `opentofu.primary_version` | `""` | Installed as `/bin/tofu` | +| `sg_runner.pre_release` | `false` | Bake the newest sg-runner pre-release instead of latest stable | + +Everything under `os`, `terraform`, `opentofu` and `sg_runner` is baked in at +build time, so changing any of them has **no effect on an existing AMI** until +you trigger a rebuild. + +### Public vs Private Subnet + +By default the build instance gets a public IP and Packer connects to it over the +internet. Set `network.private_subnet = true` when your subnet is private — +Packer then connects over the **private IP**, which means whatever runs OpenTofu +must have a route into that subnet (VPN, Direct Connect, or running from inside +the VPC). Either way the subnet needs outbound internet access for the build to +download anything. + +## Rebuilding + +The AMI is built **once per state**. Later plans reuse the recorded ID, so the +AMI stays stable and downstream deployments are not disturbed. + +| Situation | Result | +|-----------|--------| +| First apply | Packer builds; the AMI ID is recorded in state | +| Every plan/apply after that | No build, no diff | +| `packer_config.rebuild_ami_token` changed | Packer builds a new AMI, once | +| State destroyed and re-applied | Packer builds again | + +```hcl +packer_config = { + rebuild_ami_token = "2026-08-25-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. + +## Outputs + +| Output | Description | +|--------|-------------| +| `ami_id` | The built AMI, recorded in state — feed this to `aws/single_runner` or `aws/autoscaling_group` | +| `ami_info` | Region, OS, name pattern, deregistration protection and cleanup settings | +| `cleanup_commands` | Ready-to-run AWS CLI commands for inspecting or removing the AMI by hand | +| `subnet_id` | The existing subnet the build ran in | + +## Destroying + +```bash +tofu destroy +``` + +With `packer_config.cleanup_amis_on_destroy` (default `true`) this deregisters the +AMI this deployment built, and deletes its snapshots when `delete_snapshots` is +also true. AMIs from other deployments are never touched. Set it to `false` to +keep the image after tearing down the state. + +> Deregistering an AMI that other deployments still reference will break their +> next instance launch. Check `ami_id` before destroying. + +## Troubleshooting + +| Symptom | Likely cause | +|---------|--------------| +| Plan fails reading the VPC or subnet | `network.vpc_id` / `network.subnet_id` does not exist, or credentials lack `ec2:Describe*` | +| `Resource postcondition failed` on the subnet | `network.subnet_id` is in a different VPC than `network.vpc_id` | +| Packer fails immediately | No outbound path from the subnet, or missing EC2 permissions | +| Packer hangs connecting to the instance | Private subnet without `network.private_subnet = true`, or no route from here into a private subnet | +| `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` | + +To force a rebuild without touching variables: + +```bash +tofu apply -replace=module.packer.null_resource.packer_build +``` + +## Notes + +- The provisioning script is shared with the Azure build: + [`packer/scripts/setup.sh`](../../../packer/scripts/setup.sh). A fix there lands + on both clouds. +- No backend is configured. The recorded AMI ID lives in local state, so keep it + if you want later plans to skip the build. diff --git a/stackguardian_private_runner/examples/aws/packer/main.tf b/stackguardian_private_runner/examples/aws/packer/main.tf new file mode 100644 index 0000000..fd7bd1c --- /dev/null +++ b/stackguardian_private_runner/examples/aws/packer/main.tf @@ -0,0 +1,72 @@ +terraform { + # terraform_data (used by the packer module to record the built AMI ID) needs 1.4+ + required_version = ">= 1.4.0" + + required_providers { + aws = { + source = "hashicorp/aws" + } + null = { + source = "hashicorp/null" + } + external = { + source = "hashicorp/external" + } + local = { + source = "hashicorp/local" + } + } +} + +# No provider block here: aws/packer declares its own and configures the region +# from the aws_region it is given. + +# ------------------------------------------------------- +# Existing Network +# The build instance runs in a VPC and subnet you already +# have; this example never creates networking. +# ------------------------------------------------------- +data "aws_vpc" "build" { + id = var.network.vpc_id +} + +data "aws_subnet" "build" { + id = var.network.subnet_id + + # A subnet from another VPC resolves fine on its own and only fails later, + # when Packer launches the instance. Catch it during plan. + lifecycle { + postcondition { + condition = self.vpc_id == var.network.vpc_id + error_message = "network.subnet_id belongs to VPC ${self.vpc_id}, not to network.vpc_id (${var.network.vpc_id})." + } + } +} + +# ------------------------------------------------------- +# Packer AMI Builder +# Builds the runner AMI: Docker, jq, cron, sg-runner and +# optionally Terraform/OpenTofu. +# ------------------------------------------------------- +module "packer" { + source = "../../../aws/packer" + + aws_region = var.aws_region + instance_type = var.instance_type + + # The module takes exactly one of public_subnet_id / private_subnet_id; which + # one your subnet is decides how Packer reaches the build instance. + network = { + vpc_id = data.aws_vpc.build.id + public_subnet_id = var.network.private_subnet ? "" : data.aws_subnet.build.id + private_subnet_id = var.network.private_subnet ? data.aws_subnet.build.id : "" + proxy_url = var.network.proxy_url + } + + os = var.os + packer_config = var.packer_config + ami_name_prefix = var.ami_name_prefix + terraform = var.terraform + opentofu = var.opentofu + sg_runner = var.sg_runner +} diff --git a/stackguardian_private_runner/examples/aws/packer/outputs.tf b/stackguardian_private_runner/examples/aws/packer/outputs.tf new file mode 100644 index 0000000..9fb220f --- /dev/null +++ b/stackguardian_private_runner/examples/aws/packer/outputs.tf @@ -0,0 +1,19 @@ +output "ami_id" { + description = "AMI built by Packer and recorded in state" + value = module.packer.ami_id +} + +output "ami_info" { + description = "AMI metadata: region, OS, name pattern, deregistration protection and cleanup settings" + value = module.packer.ami_info +} + +output "cleanup_commands" { + description = "Ready-to-run AWS CLI commands for inspecting and removing the AMI by hand" + value = module.packer.cleanup_commands +} + +output "subnet_id" { + description = "Existing subnet the build instance ran in" + value = data.aws_subnet.build.id +} diff --git a/stackguardian_private_runner/examples/aws/packer/terraform.tfvars.tpl b/stackguardian_private_runner/examples/aws/packer/terraform.tfvars.tpl new file mode 100644 index 0000000..dbe9941 --- /dev/null +++ b/stackguardian_private_runner/examples/aws/packer/terraform.tfvars.tpl @@ -0,0 +1,67 @@ +# ============================================================ +# StackGuardian Private Runner - AWS AMI Build +# ============================================================ +# Copy this file to terraform.tfvars and fill in your values. +# Everything commented out is optional and shown with its default. +# ============================================================ + +# --- Required: Existing network --- +# The build instance runs here. It needs outbound internet access; this example +# never creates networking. +network = { + vpc_id = "vpc-0123456789abcdef0" + subnet_id = "subnet-0123456789abcdef0" + # + # Set true when subnet_id is private. Packer then connects over the private + # IP, so wherever you run OpenTofu needs a route into that subnet. + # private_subnet = false + # + # proxy_url = "http://proxy.example.com:8080" +} + +# --- Optional: Where and on what to build --- +# aws_region = "eu-central-1" # an AMI is regional - build where you deploy +# instance_type = "t3.medium" # exists only for the length of the build + +# --- Optional: Image contents --- +# Every value here is baked in at build time, so changing one has no effect on +# an existing AMI until you trigger a rebuild (see rebuild_ami_token below). +# +# os.family must be "amazon", "ubuntu", or "rhel"; version is required for the +# latter two. +# os = { +# family = "amazon" +# version = "" +# update_os_before_install = true +# ssh_username = "" # defaults per family: ec2-user / ubuntu +# user_script = "" # extra shell run after standard setup +# } +# +# ami_name_prefix = "SG-RUNNER-ami" +# +# terraform = { +# primary_version = "1.9.8" +# additional_versions = ["1.8.5"] +# } +# opentofu = { +# primary_version = "1.8.8" +# } +# +# Bake the newest sg-runner pre-release instead of the latest stable release. +# sg_runner = { +# pre_release = false +# } + +# --- Optional: Build lifecycle --- +# Packer builds on the first apply only. Later plans reuse the recorded AMI. To +# build a new one, change rebuild_ami_token to any new value: +# packer_config = { +# version = "1.14.1" +# rebuild_ami_token = "2026-08-25" +# deregistration_protection = { +# enabled = true +# with_cooldown = false +# } +# delete_snapshots = true +# cleanup_amis_on_destroy = true +# } diff --git a/stackguardian_private_runner/examples/aws/packer/variables.tf b/stackguardian_private_runner/examples/aws/packer/variables.tf new file mode 100644 index 0000000..bd469ad --- /dev/null +++ b/stackguardian_private_runner/examples/aws/packer/variables.tf @@ -0,0 +1,131 @@ +/*---------------------+ + | AWS Configuration | + +---------------------*/ +variable "aws_region" { + description = "AWS region the AMI is built in. An AMI is regional - it can only launch instances in this region." + type = string + default = "eu-central-1" +} + +/*-------------------+ + | Network Settings | + +-------------------*/ +variable "network" { + description = <= 1.6** (or Terraform >= 1.4) | The packer module uses `terraform_data` | -| **Packer** | Installed automatically by the build script at the configured version | +| **Packer** | Installed automatically by the build script at the configured version - not needed when you pass `ami_id` | | **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 | +| **An existing VPC and subnet** | This example attaches to them; it does not create networking | +| **Outbound path from that subnet** | Public subnet with an internet gateway, or private behind NAT | ### AWS Permissions @@ -103,8 +103,8 @@ 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`. +At minimum you must set `stackguardian.api_key`, `stackguardian.org_name`, and the +`network` block naming the VPC and subnet to attach to. **2. Initialize** @@ -145,8 +145,8 @@ minute or two of the instance booting. |----------|------|-------------| | `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 | +| `network.vpc_id` | `string` | Existing VPC ID | +| `network.subnet_id` | `string` | Existing subnet for the build instance and the runner | ### Commonly Adjusted @@ -157,6 +157,7 @@ minute or two of the instance booting. | `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 | +| `ami_id` | `""` | Existing AMI to boot instead of building one - see [Bringing your own AMI](#bringing-your-own-ami) | | `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 | @@ -181,9 +182,11 @@ minute or two of the instance booting. | `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 | +| `ami_name_prefix` | `SG-RUNNER-ami` | Prefix of the generated AMI name | 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**. +on an existing deployment has **no effect until you trigger a rebuild**. All of +them are ignored when `ami_id` is set, since nothing is built. ### Full Variable Reference @@ -223,11 +226,71 @@ changes. > AMI this deployment built - on destroy, and on the rebuild that supersedes it. > AMIs from other deployments are never deregistered. +### Bringing your own AMI + +Set `ami_id` and no AMI is built at all: + +```hcl +ami_id = "ami-0123456789abcdef0" +``` + +The packer module then creates nothing - no build instance, no build, no +destroy-time cleanup - and hands that AMI straight to the runner. Packer itself +is never downloaded, and every build input (`os`, `terraform`, `opentofu`, +`sg_runner`, `packer_config`, `ami_name_prefix`, `packer_instance_type`) is +ignored. The `ami_id` output still reports what the runner booted from either way. + +Use it to reuse one AMI across several deployments, to pin a known-good image, or +to run the [`aws/packer` example](../packer/README.md) separately and feed its +`ami_id` output in here. + +The AMI has to live in `aws_region` and carry docker, cron, jq and sg-runner - +the same contents [`aws/packer`](../../../aws/packer/README.md) bakes in. A +missing dependency is not caught at plan time; the runner just fails to register. + +> **Set it on a fresh deployment.** Adding `ami_id` to a deployment that already +> built an AMI tears down the build records, and the destroy-time cleanup +> deregisters the AMI that was built - including when that is the very AMI you +> are passing in. To hand an existing deployment its own AMI, drop the cleanup +> resource from state first: +> `tofu state rm module.packer.null_resource.ami_cleanup[0]`. + ## 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. +This example **attaches to an existing VPC and subnet**. It looks them up with +`data.aws_vpc` / `data.aws_subnet` and places both the Packer build instance and +the runner in that subnet — nothing about your network is managed by this state, +and `destroy` leaves it as it was. + +```hcl +network = { + vpc_id = "vpc-0123456789abcdef0" + subnet_id = "subnet-0123456789abcdef0" +} +``` + +The only network resources created are the ones bound to the instance itself: a +security group and, unless you turn it off, a public IP. + +### Outbound Access + +The runner must reach the StackGuardian API and package mirrors. Either: + +- leave `network.associate_public_ip = true` (the default) and use a public subnet, or +- set it to `false` when the subnet already has its own path out — a NAT gateway or + a proxy. + +The Packer build instance always needs egress from that same subnet. + +### What Belongs on Your Subnet + +| Need | Where it goes | +|------|---------------| +| NAT gateway, route tables for private egress | On your subnet | +| HTTP proxy (`proxy_url`) | Not exposed here — use `aws/single_runner` directly | + +`aws/single_runner` can create a NAT gateway and route tables via +`create_network_infrastructure`; this example never does. The runner's security group allows **all egress** and **no ingress** by default. SSH is opened only if you set `firewall.ssh_access_rules`. @@ -238,7 +301,11 @@ 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"] +network = { + vpc_id = "vpc-0123456789abcdef0" + subnet_id = "subnet-0123456789abcdef0" + vpc_endpoint_security_group_ids = ["sg-0123456789abcdef0"] +} ``` The module adds an inbound HTTPS (443) rule to each listed security group, sourced @@ -256,11 +323,13 @@ you see runners stuck with no logs, check this first. | `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 | +| `ami_id` | AMI the runner booted from - built by Packer, or the `ami_id` passed in | | `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 | +| `subnet_id` | Existing subnet the runner was placed in | +| `ssh_command` | Ready-to-paste SSH command; uses the private IP when no public IP is attached | The runner group token is deliberately **not** exposed as a root output. It is passed module-to-module in memory and marked sensitive. @@ -339,13 +408,15 @@ group and connector from StackGuardian, and tears down the AWS resources. | Symptom | Likely cause | |---------|--------------| -| Jobs hang on plan, no logs | VPC interface endpoints not listed in `vpc_endpoint_security_group_ids` | +| Jobs hang on plan, no logs | VPC interface endpoints not listed in `network.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` | +| Plan fails reading the VPC or subnet | `network.vpc_id` or `network.subnet_id` does not exist, or the credentials lack `ec2:Describe*` | +| `Resource postcondition failed` on the subnet | `network.subnet_id` is in a different VPC than `network.vpc_id` | To force a rebuild without touching variables: @@ -357,9 +428,10 @@ tofu apply -replace=module.packer.null_resource.packer_build 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. +- **Bring your own network.** The example attaches to an existing VPC and subnet + and has no option to create one. `private_subnet_id`, `create_network_infrastructure` + (NAT gateway), and `proxy_url` are supported by `aws/single_runner` but are not + exposed here — use the module directly when you need them. - **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 diff --git a/stackguardian_private_runner/examples/aws/quickstart/locals.tf b/stackguardian_private_runner/examples/aws/quickstart/locals.tf new file mode 100644 index 0000000..1904ca0 --- /dev/null +++ b/stackguardian_private_runner/examples/aws/quickstart/locals.tf @@ -0,0 +1,15 @@ +locals { + # Default SSH user per AMI family, mirroring aws/packer's own mapping. + # os.ssh_username overrides it. + ssh_usernames = { + amazon = "ec2-user" + ubuntu = "ubuntu" + rhel = "ec2-user" + } + + ssh_username = ( + var.os.ssh_username != "" + ? var.os.ssh_username + : local.ssh_usernames[var.os.family] + ) +} diff --git a/stackguardian_private_runner/examples/aws/quickstart/main.tf b/stackguardian_private_runner/examples/aws/quickstart/main.tf index e96189e..a2ee5e5 100644 --- a/stackguardian_private_runner/examples/aws/quickstart/main.tf +++ b/stackguardian_private_runner/examples/aws/quickstart/main.tf @@ -5,7 +5,7 @@ terraform { version = ">= 1.3.3" } aws = { - source = "registry.terraform.io/hashicorp/aws" + source = "hashicorp/aws" } external = { source = "hashicorp/external" @@ -27,43 +27,78 @@ terraform { # Creates: runner group, S3 bucket, IAM role, connector # ------------------------------------------------------- module "runner_group" { - source = "../../../runner_group" + source = "../../../aws/runner_group" stackguardian = var.stackguardian aws_region = var.aws_region - override_names = var.override_names + # The runner group module names only the platform records, so it takes the + # naming fields and not include_org_in_prefix (which the VM modules still use). + override_names = { + global_prefix = var.override_names.global_prefix + runner_group_name = var.override_names.runner_group_name + connector_name = var.override_names.connector_name + } max_runners = var.max_runners create_storage_backend = true force_destroy_storage_backend = var.force_destroy_storage_backend } +# ------------------------------------------------------- +# Existing Network +# This example attaches the runner to a VPC and subnet +# you already have; it never creates networking. +# ------------------------------------------------------- +data "aws_vpc" "runner" { + id = var.network.vpc_id +} + +data "aws_subnet" "runner" { + id = var.network.subnet_id + + # A subnet from another VPC resolves fine on its own and only fails later, + # when the instance or the security group is created. Catch it during plan. + lifecycle { + postcondition { + condition = self.vpc_id == var.network.vpc_id + error_message = "network.subnet_id belongs to VPC ${self.vpc_id}, not to network.vpc_id (${var.network.vpc_id})." + } + } +} + # ------------------------------------------------------- # Module 2: Packer AMI Builder # Builds AMI with sg-runner, Docker, Terraform, etc. +# Passing var.ami_id skips the build: the module then +# creates nothing and hands that AMI straight back. +# (The module owns the skip because it declares its own +# provider, which rules out count on the module call.) # ------------------------------------------------------- module "packer" { source = "../../../aws/packer" + existing_ami_id = var.ami_id + aws_region = var.aws_region instance_type = var.packer_instance_type network = { - vpc_id = var.vpc_id - public_subnet_id = var.public_subnet_id + vpc_id = data.aws_vpc.runner.id + public_subnet_id = data.aws_subnet.runner.id } - os = var.os - packer_config = var.packer_config - terraform = var.terraform - opentofu = var.opentofu - sg_runner = var.sg_runner + os = var.os + packer_config = var.packer_config + ami_name_prefix = var.ami_name_prefix + 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 +# Deploys the private runner using the AMI from Module 2 # and the runner group config from Module 1 # ------------------------------------------------------- module "single_runner" { @@ -84,11 +119,13 @@ module "single_runner" { include_org_in_prefix = var.override_names.include_org_in_prefix } + # The module can also create NAT gateways and route tables; this example never + # does, so create_network_infrastructure stays at its default of false. 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 + vpc_id = data.aws_vpc.runner.id + public_subnet_id = data.aws_subnet.runner.id + associate_public_ip = var.network.associate_public_ip + vpc_endpoint_security_group_ids = var.network.vpc_endpoint_security_group_ids } volume = var.volume diff --git a/stackguardian_private_runner/examples/aws/quickstart/outputs.tf b/stackguardian_private_runner/examples/aws/quickstart/outputs.tf index 4acb3dd..0d88cee 100644 --- a/stackguardian_private_runner/examples/aws/quickstart/outputs.tf +++ b/stackguardian_private_runner/examples/aws/quickstart/outputs.tf @@ -25,7 +25,7 @@ output "s3_bucket_name" { | AMI Outputs | +---------------------------------*/ output "ami_id" { - description = "AMI ID built by Packer and recorded in state" + description = "AMI the runner booted from - built by Packer, or the ami_id that was passed in" value = module.packer.ami_id } @@ -51,3 +51,17 @@ output "security_group_id" { description = "Security group ID of the private runner" value = module.single_runner.security_group_id } + +output "subnet_id" { + description = "Existing subnet the runner was placed in" + value = data.aws_subnet.runner.id +} + +output "ssh_command" { + description = <-runner-group-" -# connector_name = "" # default: "-private-runner-backend-" +# include_org_in_prefix = false # affects the EC2/ASG names only, not the runner group +# runner_group_name = "" # default: a 6-char random suffix +# connector_name = "" # default: same as the runner group name # } # --- Optional: Runner group --- @@ -40,7 +46,16 @@ vpc_endpoint_security_group_ids = [] # force_destroy_storage_backend = false # true also deletes S3 contents on destroy # --- Optional: AMI build --- +# +# Already have a runner AMI? Set ami_id and nothing below is built or used - +# no Packer, no build instance, no destroy-time AMI cleanup. It has to live in +# aws_region and carry docker, cron, jq and sg-runner. Set it on a fresh +# deployment; see the README before adding it to a deployment that already +# built an AMI. +# ami_id = "ami-0123456789abcdef0" +# # packer_instance_type = "t3.medium" +# ami_name_prefix = "SG-RUNNER-ami" # # 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 diff --git a/stackguardian_private_runner/examples/aws/quickstart/variables.tf b/stackguardian_private_runner/examples/aws/quickstart/variables.tf index 9c0e75b..82659ec 100644 --- a/stackguardian_private_runner/examples/aws/quickstart/variables.tf +++ b/stackguardian_private_runner/examples/aws/quickstart/variables.tf @@ -23,20 +23,37 @@ variable "aws_region" { /*-------------------+ | 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 "network" { + description = < For a full working runner in one apply, use +> [examples/azure/quickstart](../quickstart/) instead — it wires this same module +> together with the runner group and a VM runner. + +## What Gets Built + +``` +tofu apply + | + v +[Resource group] azurerm_resource_group (unless create_resource_group = false) + | + v +[Packer build] null_resource.packer_build + | temporary VM + temporary VNet, both removed when the build ends + | installs: Docker, jq, cron, unzip, sg-runner + | optional: Terraform and/or OpenTofu at the versions you name + | generalizes the VM and captures it + v +[Record the image ID] terraform_data.image_id -> output image_id +``` + +Only the resource group and the managed image persist. The build VM, its disk and +its temporary networking are created and destroyed by Packer within the run. + +## Prerequisites + +| Requirement | Notes | +|-------------|-------| +| **OpenTofu >= 1.4** (or Terraform >= 1.4) | The module uses `terraform_data` | +| **Azure CLI, logged in** | The build and the cleanup script shell out to `az` | +| **Azure credentials** | Via `az login` or `ARM_*` environment variables | +| **Contributor** on the target scope | Resource groups, images, VMs, disks, temporary networking | +| `sh`, `curl`, `unzip` | Used to bootstrap Packer at `packer_config.version` | + +Packer itself is downloaded automatically — you do not need it installed. Unlike +the AWS build, you do **not** need to bring a network: Packer makes its own. + +## Quick Start + +```bash +cp terraform.tfvars.tpl terraform.tfvars +$EDITOR terraform.tfvars # set resource_group_name +tofu init +tofu apply +tofu output image_id +``` + +The build takes several minutes. Once it finishes, the image ID is recorded in +state and every later plan is a no-op — see [Rebuilding](#rebuilding). + +## Configuration + +### Required + +| Variable | Type | Description | +|----------|------|-------------| +| `resource_group_name` | `string` | Resource group the image is stored in | + +### Commonly Adjusted + +| Variable | Default | Description | +|----------|---------|-------------| +| `azure_location` | `westeurope` | A managed image is regional — build it where you intend to create runners | +| `vm_size` | `Standard_D2s_v3` | Build VM size | +| `create_resource_group` | `true` | Set `false` to build into a resource group that already exists | +| `os.publisher` | `Canonical` | `Canonical` or `RedHat` | +| `os.offer` / `os.sku` | Ubuntu 22.04 LTS gen2 | Marketplace offer and SKU | +| `image_name_prefix` | `sg-runner` | Prefix of the generated image name | +| `terraform.primary_version` | `""` | Installed as `/bin/terraform` | +| `opentofu.primary_version` | `""` | Installed as `/bin/tofu` | +| `sg_runner.pre_release` | `false` | Bake the newest sg-runner pre-release instead of latest stable | + +Everything under `os`, `terraform`, `opentofu` and `sg_runner` is baked in at +build time, so changing any of them has **no effect on an existing image** until +you trigger a rebuild. + +Confirm your `os` combination exists in the target region before applying: + +```bash +az vm image list --location westeurope --publisher Canonical --all -o table +``` + +### Building Inside an Existing VNet + +By default Packer creates a throwaway VNet for the build and removes it +afterwards. Set `network` when the build must run inside your own: + +```hcl +network = { + vnet_name = "my-vnet" + subnet_name = "build-subnet" + resource_group_name = "my-network-rg" + proxy_url = "" +} +``` + +> With `network` set, Packer connects to the build VM over its **private IP** and +> assigns no public one, so wherever you run OpenTofu needs a route into that +> subnet. Leave it empty unless you have one. Either way the subnet needs +> outbound internet access. + +## Rebuilding + +The image is built **once per state**. Later plans reuse the recorded ID, so the +image stays stable and downstream deployments are not disturbed. + +| Situation | Result | +|-----------|--------| +| First apply | Packer builds; the image ID is recorded in state | +| Every plan/apply after that | No build, no diff | +| `packer_config.rebuild_image_token` changed | Packer builds a new image, once | +| State destroyed and re-applied | Packer builds again | + +```hcl +packer_config = { + rebuild_image_token = "2026-08-25-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. + +## Outputs + +| Output | Description | +|--------|-------------| +| `image_id` | The built managed image, recorded in state — feed this to `azure/azure_runner` or `azure/vmss` | +| `image_info` | Location, resource group, OS, image name and cleanup settings | +| `resource_group_name` | Resource group holding the image | +| `cleanup_commands` | Ready-to-run `az` commands for inspecting or removing the image by hand | + +## Destroying + +```bash +tofu destroy +``` + +With `packer_config.cleanup_images_on_destroy` (default `true`) this deletes the +image this deployment built. Images from other deployments are never touched. Set +it to `false` to keep the image after tearing down the state. + +> Deleting an image that other deployments still reference will break their next +> VM creation. A running VM keeps working, but it can no longer be recreated. +> Check `image_id` before destroying. + +## Troubleshooting + +| Symptom | Likely cause | +|---------|--------------| +| Packer fails immediately | `az login` not done, or missing permissions on the target scope | +| SKU not available in region | Verify with `az vm image list --location --publisher --all` | +| Build hangs connecting to the VM | `network` points at a subnet you have no route into — leave it empty to use Packer's own networking | +| `No image recorded` on output | The build produced no image — check `../../../azure/packer/packer_manifest.log` | +| Packer never re-runs | Working as designed; bump `rebuild_image_token` | +| Image is in the wrong region | `azure_location` — an image can only create VMs in its own region | + +To force a rebuild without touching variables: + +```bash +tofu apply -replace=module.packer.null_resource.packer_build +``` + +## Notes + +- The provisioning script is shared with the AWS build: + [`packer/scripts/setup.sh`](../../../packer/scripts/setup.sh). A fix there lands + on both clouds. +- No backend is configured. The recorded image ID lives in local state, so keep it + if you want later plans to skip the build. diff --git a/stackguardian_private_runner/examples/azure/packer/main.tf b/stackguardian_private_runner/examples/azure/packer/main.tf new file mode 100644 index 0000000..8a8e541 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/packer/main.tf @@ -0,0 +1,47 @@ +terraform { + # terraform_data (used by the packer module to record the built image ID) needs 1.4+ + required_version = ">= 1.4.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0" + } + null = { + source = "hashicorp/null" + } + external = { + source = "hashicorp/external" + } + } +} + +# No provider block here: azure/packer declares and configures its own. + +# ------------------------------------------------------- +# Packer Managed Image Builder +# Builds the runner image: Docker, jq, cron, sg-runner and +# optionally Terraform/OpenTofu. +# +# By default Packer creates and destroys its own temporary +# networking for the build VM. Set var.network to build +# inside a VNet you already have. +# ------------------------------------------------------- +module "packer" { + source = "../../../azure/packer" + + azure_location = var.azure_location + vm_size = var.vm_size + + resource_group_name = var.resource_group_name + create_resource_group = var.create_resource_group + + network = var.network + + os = var.os + packer_config = var.packer_config + image_name_prefix = var.image_name_prefix + terraform = var.terraform + opentofu = var.opentofu + sg_runner = var.sg_runner +} diff --git a/stackguardian_private_runner/examples/azure/packer/outputs.tf b/stackguardian_private_runner/examples/azure/packer/outputs.tf new file mode 100644 index 0000000..cc9bf94 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/packer/outputs.tf @@ -0,0 +1,19 @@ +output "image_id" { + description = "Managed image built by Packer and recorded in state" + value = module.packer.image_id +} + +output "image_info" { + description = "Image metadata: location, resource group, OS, name and cleanup settings" + value = module.packer.image_info +} + +output "resource_group_name" { + description = "Resource group holding the managed image" + value = module.packer.resource_group_name +} + +output "cleanup_commands" { + description = "Ready-to-run az CLI commands for inspecting and removing the image by hand" + value = module.packer.cleanup_commands +} diff --git a/stackguardian_private_runner/examples/azure/packer/terraform.tfvars.tpl b/stackguardian_private_runner/examples/azure/packer/terraform.tfvars.tpl new file mode 100644 index 0000000..37a11f5 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/packer/terraform.tfvars.tpl @@ -0,0 +1,69 @@ +# ============================================================ +# StackGuardian Private Runner - Azure Image Build +# ============================================================ +# Copy this file to terraform.tfvars and fill in your values. +# Everything commented out is optional and shown with its default. +# ============================================================ + +# --- Required: Where the image lives --- +# Created by this example, and removed again on destroy. +resource_group_name = "sg-runner-images" + +# --- Optional: Where and on what to build --- +# A managed image is regional - build it where you intend to create runners. +# azure_location = "westeurope" +# vm_size = "Standard_D2s_v3" # exists only for the length of the build +# +# Set false to build into a resource group that already exists. This example +# then leaves it alone on destroy. +# create_resource_group = true + +# --- Optional: Build networking --- +# By default Packer creates and destroys its own throwaway VNet for the build. +# Point it at an existing VNet when the build must run inside your network - +# Packer then uses the private IP only, so wherever you run OpenTofu needs a +# route into that subnet. +# network = { +# vnet_name = "my-vnet" +# subnet_name = "build-subnet" +# resource_group_name = "my-network-rg" +# proxy_url = "" +# } + +# --- Optional: Image contents --- +# Every value here is baked in at build time, so changing one has no effect on +# an existing image until you trigger a rebuild (see rebuild_image_token below). +# +# publisher must be "Canonical" or "RedHat". +# os = { +# publisher = "Canonical" +# offer = "0001-com-ubuntu-server-jammy" +# sku = "22_04-lts-gen2" +# version = "latest" +# update_os_before_install = true +# user_script = "" # extra shell run after standard setup +# } +# +# image_name_prefix = "sg-runner" +# +# terraform = { +# primary_version = "1.9.8" +# additional_versions = ["1.8.5"] +# } +# opentofu = { +# primary_version = "1.8.8" +# } +# +# Bake the newest sg-runner pre-release instead of the latest stable release. +# sg_runner = { +# pre_release = false +# } + +# --- Optional: Build lifecycle --- +# Packer builds on the first apply only. Later plans reuse the recorded image. +# To build a new one, change rebuild_image_token to any new value: +# packer_config = { +# version = "1.14.1" +# rebuild_image_token = "2026-08-25" +# cleanup_images_on_destroy = true +# } diff --git a/stackguardian_private_runner/examples/azure/packer/variables.tf b/stackguardian_private_runner/examples/azure/packer/variables.tf new file mode 100644 index 0000000..5e98b0d --- /dev/null +++ b/stackguardian_private_runner/examples/azure/packer/variables.tf @@ -0,0 +1,139 @@ +/*---------------------+ + | Azure Configuration | + +---------------------*/ +variable "azure_location" { + description = "Azure region the image is built in. A managed image is regional - it can only create VMs in this region." + type = string + default = "westeurope" +} + +variable "resource_group_name" { + description = "Resource group the managed image is stored in. Created by this example unless create_resource_group is false." + type = string + + validation { + condition = trimspace(var.resource_group_name) != "" + error_message = "resource_group_name is required." + } +} + +variable "create_resource_group" { + description = < Deploying a **single runner** into a VNet and subnet you already have. This +> example never creates networking. For an autoscaled fleet, use the `azure/vmss` +> and `azure/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 Image Lifecycle Works](#how-the-image-lifecycle-works) +- [Networking](#networking) +- [The Storage Backend Identity](#the-storage-backend-identity) +- [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 │ + │ │ • AZURE_OIDC connector │ + │ └──────────────────────────────┘ + │ ┌──────────────────────────────┐ + └───────────►│ Azure (storage backend) │ + │ • resource group │ + │ • storage account + CORS │ + │ • "runner" blob container │ + │ • AAD app + service │ + │ principal, federated to │ + │ the SG platform via OIDC │ + └──────────────────────────────┘ + │ + │ azure_resource_group_name + │ azure_storage_account_name + │ runner_group_name + │ runner_group_token + ▼ + module.packer ┌──────────────────────────────┐ + ──────────────────►│ Packer build (first apply) │ + (skipped when │ • temp build VM + temp │ + vm_image_id set) │ networking (auto-removed) │ + │ • managed image: Docker, │ + │ jq, cron, sg-runner, and │ + │ optional Terraform/Tofu │ + └──────────────────────────────┘ + │ + │ image_id + ▼ + (root module) ┌──────────────────────────────┐ + ──────────────────►│ User-Assigned Managed │ + │ Identity + "Storage Blob │ + │ Data Contributor" on the │ + │ storage account │ + └──────────────────────────────┘ + │ + │ identity id + ▼ + module.azure_runner + ┌──────────────────────────────┐ + │ Runner VM │ + │ • NIC in your existing │ + │ subnet (looked up, not │ + │ created) │ + │ • NSG (all egress, no │ + │ ingress unless SSH is │ + │ configured) │ + │ • static public IP, unless │ + │ you turn it off │ + │ • the managed identity │ + │ attached to the VM │ + └──────────────────────────────┘ +``` + +Everything lands in **one resource group**, created by the runner group module and +reused by the other two, so a single `destroy` removes the whole deployment. Your +VNet and subnet are not part of it - they are read, never managed, and a `destroy` +leaves them untouched. + +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.4** (or Terraform >= 1.4) | The packer module uses `terraform_data` | +| **Packer** | Downloaded automatically by the build script at the configured version - not needed when you pass `vm_image_id` | +| **Azure CLI, logged in** | The Packer build and the image cleanup script shell out to `az` | +| **Azure credentials** | Via `az login` or `ARM_*` environment variables | +| **StackGuardian API key** | Org-scoped key with permission to create runner groups and connectors | +| **An SSH public key** | Password auth is always disabled on the VM | +| **An existing VNet and subnet** | This example attaches to them; it does not create networking | + +### Azure Permissions + +The identity running this needs, at minimum: + +- **Contributor** on the subscription or target scope - resource groups, storage + accounts, images, VMs, NSGs, public IPs, managed identities +- **Read** on the target VNet, plus `Microsoft.Network/virtualNetworks/subnets/join/action` + on the subnet, so the runner's NIC can be placed in it +- **User Access Administrator** (or equivalent) for the two role assignments. If you + do not have it, set `create_role_assignments = false` and create them out of band - + see [The Storage Backend Identity](#the-storage-backend-identity) +- **Azure AD**: permission to create an application and service principal, for the + OIDC connector the runner group module registers + +## 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`, and the +`network` block naming the VNet and subnet to attach to. Set +`firewall.ssh_public_key` too unless you want a generated key sitting in state. + +**2. Initialize** + +```bash +tofu init +``` + +**3. Review the plan** + +```bash +tofu plan -out=tofuplan +``` + +**4. Apply** + +```bash +tofu apply tofuplan +``` + +The first apply builds the managed image, which dominates the runtime - expect +several minutes before the runner VM itself is created. Later applies skip the build +entirely (see [image lifecycle](#how-the-image-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 VM booting. + +## Configuration + +### Required + +| Variable | Type | Description | +|----------|------|-------------| +| `stackguardian.api_key` | `string` | StackGuardian API key (sensitive) | +| `stackguardian.org_name` | `string` | StackGuardian organization name | +| `network.vnet_name` | `string` | Name of the existing VNet | +| `network.subnet_name` | `string` | Name of the existing subnet inside it | +| `network.resource_group_name` | `string` | Resource group holding that VNet | + +Everything else has a default. `firewall.ssh_public_key` is not formally required +only because `firewall.generate_ssh_key` defaults to `true`. + +### Commonly Adjusted + +| Variable | Default | Description | +|----------|---------|-------------| +| `azure_location` | `westeurope` | Region for all Azure resources - must match the region of the VNet you attach to | +| `stackguardian.api_uri` | `https://api.app.stackguardian.io` | Platform endpoint - see note below | +| `azure_resource_group_name` | `""` | Name of the shared resource group; derived from the prefix when empty | +| `firewall.ssh_public_key` | `""` | Your SSH public key; avoids a generated key in state | +| `firewall.ssh_access_rules` | `{}` | CIDRs allowed to reach port 22; nothing is open by default | +| `runner_vm_size` | `Standard_D4s_v3` | Runner VM size | +| `packer_vm_size` | `Standard_D2s_v3` | Build VM size | +| `vm_image_id` | `""` | Existing managed image to boot instead of building one - see [Bringing your own image](#bringing-your-own-image) | +| `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 | +| `create_role_assignments` | `true` | Set `false` when you cannot write role assignments | +| `network.associate_public_ip` | `true` | Set `false` when the subnet already has its own route to the internet | + +> **`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 storage +> account's 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 validation. + +### Image Contents + +| Variable | Default | Description | +|----------|---------|-------------| +| `os.publisher` | `Canonical` | `Canonical` or `RedHat` | +| `os.offer` / `os.sku` | Ubuntu 22.04 LTS gen2 | Marketplace offer and SKU | +| `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` | +| `image_name_prefix` | `sg-runner` | Prefix of the generated image name | +| `sg_runner.pre_release` | `false` | Bake the newest sg-runner pre-release instead of the latest stable release | + +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**. All of +them are ignored when `vm_image_id` is set, since nothing is built. + +Confirm your `os` combination exists in the target region before applying: + +```bash +az vm image list --location westeurope --publisher Canonical --all -o table +``` + +### 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 Image Lifecycle Works + +Building an image takes minutes, so the packer module builds **once per state** and +reuses what it built: + +| Situation | Result | +|-----------|--------| +| First apply | Packer builds the image; its resource ID is recorded in state | +| Every plan/apply after that | No build, no diff - the ID comes from state | +| `packer_config.rebuild_image_token` changed | Packer builds a new image, once | +| State destroyed and re-applied | Packer builds again | + +To force a fresh build - after changing the OS, `user_script`, or tool versions: + +```hcl +packer_config = { + version = "1.14.1" + rebuild_image_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 image ID is stable across applies, **the runner VM is not replaced on +every apply**. A rebuild does replace it, since the VM's source image changes. + +> `packer_config.cleanup_images_on_destroy` (default `true`) only ever touches the +> image this deployment built - on destroy, and on the rebuild that supersedes it. +> Images from other deployments are never deleted. + +### Bringing your own image + +Set `vm_image_id` and no image is built at all: + +```hcl +vm_image_id = "/subscriptions//resourceGroups//providers/Microsoft.Compute/images/" +``` + +The packer module then creates nothing - no build VM, no build, no destroy-time +cleanup - and hands that image straight to the runner VM. Packer itself is never +downloaded, and every build input (`os`, `terraform`, `opentofu`, `sg_runner`, +`packer_config`, `image_name_prefix`, `packer_vm_size`, `packer_network`) is +ignored. The `image_id` output still reports what the VM booted from either way. + +Use it to reuse one image across several deployments, to pin a known-good image, +or to run the [`azure/packer` example](../packer/README.md) separately and feed +its `image_id` output in here. + +The image has to live in `azure_location` and carry docker, cron, jq and +sg-runner - the same contents +[`azure/packer`](../../../azure/packer/README.md) bakes in. A missing dependency +is not caught at plan time; the runner just fails to register. + +> **Set it on a fresh deployment.** Adding `vm_image_id` to a deployment that +> already built an image tears down the build records, and the destroy-time +> cleanup deletes the image that was built - including when that is the very +> image you are passing in. To hand an existing deployment its own image, drop +> the cleanup resource from state first: +> `tofu state rm module.packer.null_resource.image_cleanup[0]`. + +## Networking + +This example **attaches to an existing VNet and subnet**. It looks them up by name +with `data.azurerm_virtual_network` / `data.azurerm_subnet` and places the runner's +NIC in that subnet - nothing about your network is managed by this state, and +`destroy` leaves it as it was. + +```hcl +network = { + vnet_name = "my-vnet" + subnet_name = "runner-subnet" + resource_group_name = "my-network-rg" +} +``` + +The only network resources created are the ones bound to the VM itself: a NIC, an +NSG, and - unless you turn it off - a static public IP. + +> **`azure_location` must match the VNet's region.** A NIC can only join a subnet in +> its own region, and a VM can only boot from a managed image in its own region - so +> the whole deployment follows the network you attach to. The example checks this +> during plan; Azure itself would only report it as a misleading +> `InvalidResourceReference ... was not found` on the NIC, after the image build. + +### Outbound Access + +The runner must reach the StackGuardian API and package mirrors. Either: + +- leave `network.associate_public_ip = true` (the default) and let the public IP + provide the route, or +- set it to `false` when the subnet already has its own path out: a NAT gateway, + Azure Firewall, or ExpressRoute. + +With no public IP and no route of your own, the runner boots, fails to register, and +shuts itself down after `runner_startup_timeout`. + +### What Belongs on Your Subnet + +Anything network-level is yours to configure on the subnet you bring: + +| Need | Where it goes | +|------|---------------| +| Service endpoints (e.g. `Microsoft.Storage` for a locked-down storage account) | On your subnet | +| NAT gateway or firewall for private egress | On your subnet | +| HTTP proxy (`proxy_url`) | Not exposed here - use `azure/azure_runner` directly | + +The runner's NSG allows **all egress** and **no ingress**. SSH is opened only if you +set `firewall.ssh_access_rules`. + +### The Packer Build VM + +Packer builds the image on a **throwaway VM with its own temporary networking**, +which it removes when the build finishes. That networking is created and destroyed +by Packer during the build, not tracked in this state. + +### Building Inside an Existing VNet + +If the build VM must sit in your network too - a proxy-only environment, or a policy +that forbids ad-hoc VNets: + +```hcl +packer_network = { + vnet_name = "my-vnet" + subnet_name = "build-subnet" + resource_group_name = "my-network-rg" + proxy_url = "http://proxy.example.com:8080" +} +``` + +> With `packer_network` set, Packer connects to the build VM over its **private IP** +> and assigns no public one, so wherever you run `tofu apply` needs a route into that +> subnet. Leave it empty unless you have one. + +## The Storage Backend Identity + +The runner authenticates to the storage account with a **User-Assigned Managed +Identity**. The `azure/runner_group` module does not create one - it registers an AAD +application and service principal for the *platform's* OIDC connector, which is a +different principal with a different purpose. So this root module creates the +identity itself and grants it `Storage Blob Data Contributor` on the storage +account, then passes its resource ID to `azure/azure_runner`. + +Two role assignments exist in this deployment: + +| Principal | Role | Purpose | +|-----------|------|---------| +| Connector service principal (from `runner_group`) | `Storage Blob Data Reader` | Lets the SG platform read job state | +| Runner managed identity (from this root module) | `Storage Blob Data Contributor` | Lets the runner read and write job state | + +Both are gated on `create_role_assignments`. If the identity running OpenTofu lacks +`Microsoft.Authorization/roleAssignments/write`, set it to `false` and create both by +hand: + +```bash +az role assignment create \ + --role "Storage Blob Data Contributor" \ + --assignee-object-id "$(tofu output -raw storage_backend_identity_principal_id)" \ + --assignee-principal-type ServicePrincipal \ + --scope "$(az storage account show -n "$(tofu output -raw storage_account_name)" \ + -g "$(tofu output -raw resource_group_name)" --query id -o tsv)" +``` + +Azure RBAC propagation is eventually consistent. A runner that boots seconds after +the assignment is created may see access errors on its first job; they clear on +their own. + +## 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 | +| `resource_group_name` | Resource group holding everything | +| `storage_account_name` | Storage account backing the runner group | +| `storage_backend_identity_id` | Resource ID of the runner's managed identity | +| `storage_backend_identity_principal_id` | Principal ID, for out-of-band role assignment | +| `image_id` | Managed image the runner VM booted from - built by Packer, or the `vm_image_id` passed in | +| `vm_id` / `vm_name` | Runner VM resource ID and name | +| `vm_public_ip` / `vm_private_ip` | Runner IPs | +| `network_security_group_id` | Runner NSG ID | +| `subnet_id` | Existing subnet the runner NIC was attached to | +| `ssh_command` | Ready-to-paste SSH command; uses the private IP when no public IP is attached | +| `ssh_private_key` | Generated private key (sensitive), when `generate_ssh_key` is true | + +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 + +SSH requires opening the NSG first: + +```hcl +firewall = { + ssh_public_key = "ssh-ed25519 AAAA..." + ssh_access_rules = { "my-ip" = "203.0.113.10/32" } +} +``` + +Then: + +```bash +$(tofu output -raw ssh_command) +``` + +If you let the module generate the key: + +```bash +tofu output -raw ssh_private_key > runner_key.pem +chmod 600 runner_key.pem +ssh -i runner_key.pem "$(tofu output -raw ssh_command | cut -d' ' -f2)" +``` + +Without SSH, use the serial console or run-command from the Azure portal. + +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 custom_data run +docker ps # job containers +systemctl status docker # runner depends on this +``` + +> `sg-runner` is a shell script at `/usr/bin/sg-runner`, not a systemd service. +> It is invoked once from `custom_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 OpenTofu): + +```bash +sudo sg-runner-update +``` + +**Change what is baked into the image** - edit `os`, `terraform`, or `opentofu`, +then bump `packer_config.rebuild_image_token` and apply. This replaces the image +*and* the runner VM. + +**Resize the runner** - change `runner_vm_size` and apply. No rebuild needed. + +## Destroying + +```bash +tofu destroy +``` + +This deletes the managed image (unless `cleanup_images_on_destroy` is disabled), +removes the runner group and connector from StackGuardian, and tears down the Azure +resources including the resource group. + +> The storage account is deleted along with the resource group, **and its contents +> with it** - including the Terraform state of every job the runner executed. Unlike +> the AWS example, there is no `force_destroy` gate here. Copy anything you need out +> first. + +## Troubleshooting + +| Symptom | Likely cause | +|---------|--------------| +| `service_endpoints is not expected here` | azurerm resolved to 5.x; the modules need 4.x. This root module pins `~> 4.0` - do not loosen it | +| Plan fails validating `api_uri` | `stackguardian.api_uri` is not one of the three supported values | +| Plan fails validating `firewall` | Neither `ssh_public_key` nor `generate_ssh_key` is set | +| Packer fails immediately | `az login` not done, no outbound path from the build subnet, or missing permissions | +| Plan fails reading the VNet or subnet | `network.vnet_name`, `subnet_name`, or `resource_group_name` does not match an existing resource, or the identity lacks read access | +| `Resource postcondition failed` on the VNet | `azure_location` is not the VNet's region - set it to the region the message names | +| `InvalidResourceReference ... was not found` creating the NIC | The subnet exists but is in another region; the postcondition above normally catches this first | +| `LinkedAuthorizationFailed` creating the NIC | Missing `Microsoft.Network/virtualNetworks/subnets/join/action` on the target subnet | +| `No image recorded` on output | The build produced no image ID - check `../../../azure/packer/packer_manifest.log` | +| Packer never re-runs | Working as designed; bump `rebuild_image_token` | +| `AuthorizationFailed` creating role assignments | Set `create_role_assignments = false` and create them out of band | +| Plan wants to create a role assignment that already exists | Azure RBAC reads are eventually consistent, so a refresh shortly after creation can 404 and drop the assignment from state. Do not apply - it fails with `RoleAssignmentExists`. Re-add it with `tofu import '
' ''` | +| Runner shuts itself down after boot | Docker did not start within `runner_startup_timeout` - `custom_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` | +| Runner registers but jobs fail on state access | Role assignment missing or still propagating | +| SKU not available in region | Verify with `az vm image list --location --publisher --all` | + +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: + +- **Bring your own network.** The example attaches to an existing VNet and subnet + and has no option to create one. `create_network` (new VNet/subnet), + `create_network_infrastructure` (NAT gateway), and `proxy_url` are all supported by + `azure/azure_runner` but are not exposed here - use the module directly when you + need them. +- **Single runner.** No autoscaling; `max_runners` caps the runner group, not the + VM count. +- **azurerm pinned to 4.x.** The `azure/*` modules use `azurerm_subnet.service_endpoints`, + removed in azurerm 5. +- **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/azure/quickstart/locals.tf b/stackguardian_private_runner/examples/azure/quickstart/locals.tf new file mode 100644 index 0000000..381820f --- /dev/null +++ b/stackguardian_private_runner/examples/azure/quickstart/locals.tf @@ -0,0 +1,10 @@ +locals { + # Azure resource names are lowercase-and-hyphens; matches how the modules + # derive their own names from override_names.global_prefix. + sanitized_prefix = replace(lower(var.override_names.global_prefix), "_", "-") + + common_tags = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } +} diff --git a/stackguardian_private_runner/examples/azure/quickstart/main.tf b/stackguardian_private_runner/examples/azure/quickstart/main.tf new file mode 100644 index 0000000..9dbce46 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/quickstart/main.tf @@ -0,0 +1,198 @@ +terraform { + # terraform_data (used by the packer module to record the built image ID) needs 1.4+ + required_version = ">= 1.4.0" + + required_providers { + stackguardian = { + source = "registry.terraform.io/StackGuardian/stackguardian" + version = ">= 1.3.3" + } + # Pinned to 4.x: the azure/* modules use azurerm_subnet.service_endpoints, + # which azurerm 5.x removed. Their own constraint is only ">= 3.0", so the + # root module is where the ceiling has to live. + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + azuread = { + source = "hashicorp/azuread" + version = ">= 2.0" + } + external = { + source = "hashicorp/external" + } + random = { + source = "hashicorp/random" + } + null = { + source = "hashicorp/null" + } + tls = { + source = "hashicorp/tls" + } + } +} + +# The root module owns the managed identity below, so it needs its own azurerm +# configuration; the child modules declare their own. +provider "azurerm" { + features {} +} + +# ------------------------------------------------------- +# Module 1: StackGuardian Runner Group +# Creates: runner group, resource group, storage account, +# blob container, AAD app + OIDC connector +# ------------------------------------------------------- +module "runner_group" { + source = "../../../azure/runner_group" + + azure_location = var.azure_location + + stackguardian = var.stackguardian + + # The runner group module names only the platform records, so it takes the + # naming fields and not include_org_in_prefix (which the VM modules still use). + override_names = { + global_prefix = var.override_names.global_prefix + runner_group_name = var.override_names.runner_group_name + connector_name = var.override_names.connector_name + } + + # Create the resource group here and reuse it for the image and the VM, so the + # whole deployment lands in one place and one destroy removes it. + create_azure_resource_group = true + azure_resource_group_name = var.azure_resource_group_name + + create_storage_backend = true + azure_storage = var.azure_storage + create_blob_reader_role_assignment = var.create_role_assignments + + max_runners = var.max_runners +} + +# ------------------------------------------------------- +# Module 2: Packer Managed Image Builder +# Builds the image with sg-runner, Docker, Terraform, etc. +# Passing var.vm_image_id skips the build: the module then +# creates nothing and hands that image straight back. +# (The module owns the skip because it declares its own +# provider, which rules out count on the module call.) +# ------------------------------------------------------- +module "packer" { + source = "../../../azure/packer" + + existing_image_id = var.vm_image_id + + azure_location = var.azure_location + vm_size = var.packer_vm_size + + # Reuse the runner group's resource group rather than creating a second one + resource_group_name = module.runner_group.azure_resource_group_name + create_resource_group = false + + # The build VM is throwaway: by default Packer creates and destroys its own + # temporary networking for it. Set packer_network to build in an existing subnet. + network = var.packer_network + + os = var.os + packer_config = var.packer_config + image_name_prefix = var.image_name_prefix + sg_runner = var.sg_runner + terraform = var.terraform + opentofu = var.opentofu +} + +# ------------------------------------------------------- +# Storage Backend Managed Identity +# The runner VM authenticates to the storage account with +# this User-Assigned Managed Identity. The runner group +# module does not create one, so the root module does. +# ------------------------------------------------------- +resource "azurerm_user_assigned_identity" "storage_backend" { + name = "${local.sanitized_prefix}-storage-backend-identity" + resource_group_name = module.runner_group.azure_resource_group_name + location = var.azure_location + + tags = local.common_tags +} + +# Read/write on the blob container, because the runner both reads and writes the +# state it produces for the jobs it runs. +resource "azurerm_role_assignment" "storage_backend" { + count = var.create_role_assignments ? 1 : 0 + + scope = module.runner_group.azure_storage_account_id + role_definition_name = "Storage Blob Data Contributor" + principal_id = azurerm_user_assigned_identity.storage_backend.principal_id +} + +# ------------------------------------------------------- +# Existing Network +# This example attaches the runner to a VNet and subnet +# you already have; it never creates networking. +# ------------------------------------------------------- +data "azurerm_virtual_network" "runner" { + name = var.network.vnet_name + resource_group_name = var.network.resource_group_name + + # The VM, its NIC, and the managed image it boots from all have to sit in the + # same region as the subnet. Azure reports a region mismatch as a misleading + # "resource not found" 400 on the NIC, halfway through the apply and after the + # image build - so catch it during plan instead. + lifecycle { + postcondition { + condition = replace(lower(self.location), " ", "") == replace(lower(var.azure_location), " ", "") + error_message = "VNet ${var.network.vnet_name} is in ${self.location}, but azure_location is ${var.azure_location}. Set azure_location to the VNet's region, or attach to a VNet in ${var.azure_location}." + } + } +} + +data "azurerm_subnet" "runner" { + name = var.network.subnet_name + virtual_network_name = data.azurerm_virtual_network.runner.name + resource_group_name = var.network.resource_group_name +} + +# ------------------------------------------------------- +# Module 3: Single Runner VM +# Deploys the private runner from the image of Module 2, +# using the runner group config from Module 1 +# ------------------------------------------------------- +module "azure_runner" { + source = "../../../azure/azure_runner" + + vm_image_id = module.packer.image_id + vm_size = var.runner_vm_size + + azure_location = var.azure_location + resource_group_name = module.runner_group.azure_resource_group_name + + runner_group_name = module.runner_group.runner_group_name + runner_group_token = module.runner_group.runner_group_token + storage_backend_identity_id = azurerm_user_assigned_identity.storage_backend.id + + stackguardian = var.stackguardian + + override_names = { + global_prefix = var.override_names.global_prefix + include_org_in_prefix = var.override_names.include_org_in_prefix + } + + # create_network stays at its default of false: the module attaches the NIC to + # the subnet below instead of provisioning a VNet of its own. + network = { + vnet_id = data.azurerm_virtual_network.runner.id + subnet_id = data.azurerm_subnet.runner.id + associate_public_ip = var.network.associate_public_ip + } + + os_disk = var.os_disk + firewall = var.firewall + runner_startup_timeout = var.runner_startup_timeout + + # No depends_on on the role assignment: azure_runner declares its own provider + # configurations, which makes it a legacy module that cannot take depends_on. + # The VM does not touch the storage backend at boot, and Azure RBAC takes a + # minute to propagate regardless, so the ordering is not load-bearing. +} diff --git a/stackguardian_private_runner/examples/azure/quickstart/outputs.tf b/stackguardian_private_runner/examples/azure/quickstart/outputs.tf new file mode 100644 index 0000000..99aad32 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/quickstart/outputs.tf @@ -0,0 +1,96 @@ +/*---------------------------------+ + | 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 "resource_group_name" { + description = "Resource group holding the storage backend, image, and runner VM" + value = module.runner_group.azure_resource_group_name +} + +output "storage_account_name" { + description = "Storage account used for the storage backend" + value = module.runner_group.azure_storage_account_name +} + +/*---------------------------------+ + | Managed Identity Outputs | + +---------------------------------*/ +output "storage_backend_identity_id" { + description = "Resource ID of the managed identity the runner uses for storage backend access" + value = azurerm_user_assigned_identity.storage_backend.id +} + +output "storage_backend_identity_principal_id" { + description = "Principal ID of that managed identity - use it to create the role assignment out of band when create_role_assignments = false" + value = azurerm_user_assigned_identity.storage_backend.principal_id +} + +/*---------------------------------+ + | Image Outputs | + +---------------------------------*/ +output "image_id" { + description = "Managed image the runner VM booted from - built by Packer, or the vm_image_id that was passed in" + value = module.packer.image_id +} + +/*---------------------------------+ + | Runner VM Outputs | + +---------------------------------*/ +output "vm_id" { + description = "Resource ID of the private runner VM" + value = module.azure_runner.vm_id +} + +output "vm_name" { + description = "Name of the private runner VM" + value = module.azure_runner.vm_name +} + +output "vm_public_ip" { + description = "Public IP of the private runner VM" + value = module.azure_runner.vm_public_ip +} + +output "vm_private_ip" { + description = "Private IP of the private runner VM" + value = module.azure_runner.vm_private_ip +} + +output "network_security_group_id" { + description = "NSG ID of the private runner VM" + value = module.azure_runner.network_security_group_id +} + +output "subnet_id" { + description = "Existing subnet the runner NIC was attached to" + value = data.azurerm_subnet.runner.id +} + +output "ssh_command" { + description = <> Configuring proxy settings for $PROXY_URL" + echo ">> Configuring proxy: $PROXY_URL" export http_proxy="$PROXY_URL" export https_proxy="$PROXY_URL" export HTTP_PROXY="$PROXY_URL" 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" + echo "https_proxy = $PROXY_URL" + echo "use_proxy = on" + } >> ~/.wgetrc fi } #}}}: _configure_proxy _cleanup() { #{{{ echo "## ----------" - echo ">> Cleaning up AMI setup.." + echo ">> Cleaning up image setup.." if [ -n "$TEMP_DIRS" ]; then for temp_dir in $TEMP_DIRS; do @@ -47,16 +61,34 @@ _cleanup() { #{{{ } #}}}: _cleanup +_wait_for_cloud_init() { #{{{ + echo ">> Waiting for cloud-init to complete.." + if command -v cloud-init >/dev/null 2>&1; then + sudo cloud-init status --wait || true + fi + echo ">> Cloud-init completed." +} +#}}}: _wait_for_cloud_init + _apt_dependencies() { #{{{ + _wait_for_cloud_init + + # Wait for apt locks to be released + echo ">> Waiting for apt locks.." + while sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || \ + sudo fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do + echo ">> Waiting for apt lock to be released.." + sleep 5 + done + if [ "$UPDATE_OS" = "true" ]; then - # Configure apt proxy if in private network if [ -n "$PROXY_URL" ]; then echo "Acquire::http::Proxy \"$PROXY_URL\";" | sudo tee /etc/apt/apt.conf.d/01proxy echo "Acquire::https::Proxy \"$PROXY_URL\";" | sudo tee -a /etc/apt/apt.conf.d/01proxy fi - sudo apt update + sudo apt-get update fi - sudo apt install -y \ + sudo apt-get install -y \ docker.io \ unzip \ cron \ @@ -66,7 +98,6 @@ _apt_dependencies() { #{{{ _yum_dependencies() { #{{{ if [ "$UPDATE_OS" = "true" ]; then - # Configure yum proxy if in private network if [ -n "$PROXY_URL" ]; then echo "proxy=$PROXY_URL" | sudo tee -a /etc/yum.conf fi @@ -83,7 +114,6 @@ _yum_dependencies() { #{{{ _dnf_dependencies() { #{{{ if [ "$UPDATE_OS" = "true" ]; then - # Configure dnf proxy if in private network if [ -n "$PROXY_URL" ]; then echo "proxy=$PROXY_URL" | sudo tee -a /etc/dnf/dnf.conf fi @@ -127,7 +157,8 @@ _wget_wrapper() { #{{{ echo ">> Downloading ${url}.." - # Add retry logic and timeout for private networks + # Retry and time out on private networks, where a dropped packet otherwise + # hangs the build until Packer's own timeout. if [ "$PRIVATE_NETWORK" = "true" ]; then wget -q --timeout=60 --tries=3 --retry-connrefused "$url" -O "$output_file" else @@ -152,17 +183,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 +202,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 @@ -303,7 +334,6 @@ _install_sg_runner() { #{{{ echo "## ----------" echo ">> Installing sg-runner.." - # Determine which release to fetch based on SG_RUNNER_PRE_RELEASE env var if [ "$SG_RUNNER_PRE_RELEASE" = "true" ]; then echo ">> Fetching latest pre-release.." url="$(wget -qO- "${github_api_base}/releases" | jq -r '[.[] | select(.prerelease == true)][0].tarball_url // empty')" @@ -333,9 +363,9 @@ _install_sg_runner() { #{{{ exit 1 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 + # Persist runtime config consumed by /usr/bin/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 ">> Saved config to /etc/sg-runner.conf" } #}}}: _install_sg_runner @@ -344,26 +374,22 @@ _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 GITHUB_API_BASE="https://api.github.com/repos/stackguardian/sg-runner" CONFIG_FILE="/etc/sg-runner.conf" -# Read configuration SG_RUNNER_PRE_RELEASE="false" if [ -f "$CONFIG_FILE" ]; then . "$CONFIG_FILE" fi -# Determine download URL if [ -n "$1" ]; then - # Specific ref provided (tag, branch, or commit) echo ">> Downloading sg-runner ref: $1" url="${GITHUB_API_BASE}/tarball/$1" else - # No ref provided, use config to determine release type if [ "$SG_RUNNER_PRE_RELEASE" = "true" ]; then echo ">> Fetching latest pre-release.." url="$(wget -qO- "${GITHUB_API_BASE}/releases" | jq -r '[.[] | select(.prerelease == true)][0].tarball_url // empty')" @@ -382,7 +408,6 @@ if [ -z "$url" ]; then exit 1 fi -# Download and install TEMP_DIR="$(mktemp -d)" trap "rm -rf '$TEMP_DIR'" EXIT @@ -420,31 +445,33 @@ _user_script_wrapper() { #{{{ #}}}: _user_script_wrapper _handle_os_package_installation() { #{{{ + # SSH_USERNAME is the image's admin user - "ubuntu", "ec2-user" or + # "azureuser" depending on the base image, and overridable by the caller. + # It used to be hardcoded per cloud, which silently ignored that override. if [ "$OS_FAMILY" = "ubuntu" ]; then _apt_dependencies _systemctl_enable "cron" "docker" - _usermod_add_to_group "docker" "ubuntu" + _usermod_add_to_group "docker" "$SSH_USERNAME" elif [ "$OS_FAMILY" = "amazon" ]; then _yum_dependencies _systemctl_enable "crond" "docker" - _usermod_add_to_group "docker" "ec2-user" + _usermod_add_to_group "docker" "$SSH_USERNAME" elif [ "$OS_FAMILY" = "rhel" ]; then _dnf_dependencies _systemctl_enable "crond" "docker" - _usermod_add_to_group "docker" "ec2-user" + _usermod_add_to_group "docker" "$SSH_USERNAME" else echo "ERROR: Unsupported OS_FAMILY: $OS_FAMILY" exit 1 fi } -#}}}: _handle_os_family +#}}}: _handle_os_package_installation main() { #{{{ OS_ARCH="$(_detect_arch)" OS_TYPE="$(_detect_os)" - # Configure proxy if in private network _configure_proxy _handle_os_package_installation diff --git a/stackguardian_private_runner/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/runner_group/DOCUMENTATION.md deleted file mode 100644 index 1d12f97..0000000 --- a/stackguardian_private_runner/runner_group/DOCUMENTATION.md +++ /dev/null @@ -1,81 +0,0 @@ -# StackGuardian Runner Group - AWS Template - -Deploy a StackGuardian Runner Group with AWS S3 storage backend directly from the StackGuardian platform. - -## Overview - -This template sets up everything you need to run private runners in your AWS environment. It creates a runner group on the StackGuardian platform, provisions an S3 bucket for storing workflow artifacts, and configures secure access between StackGuardian and your AWS account. - -### What This Template Creates - -- **Runner Group** - A dedicated group in StackGuardian to organize your private runners -- **AWS Connector** - Secure integration between StackGuardian and your AWS account -- **S3 Storage Bucket** - Private storage for workflow outputs and artifacts -- **IAM Access Role** - Secure cross-account access for the StackGuardian platform - -## Prerequisites - -- A StackGuardian API key for your organization -- AWS account with permissions to create S3 buckets and IAM roles -- AWS credentials configured in your StackGuardian workspace - -## Template Parameters - -### Required Parameters - -| Parameter | Description | Type | -|-----------|-------------|------| -| API Key | Your organization's API key on the StackGuardian Platform | Password | - -### Optional Parameters - -| Parameter | Description | Default | -|-----------|-------------|---------| -| API Region | Select your StackGuardian platform region (EU1 or US1) | EU1 - Europe | -| Organization Name | Your organization name (auto-detected if not provided) | Auto-detected | -| AWS Region | The target AWS Region for S3 bucket and IAM resources | eu-central-1 | -| Create Storage Backend | Whether to create a new S3 bucket for the storage backend | Enabled | -| Existing S3 Bucket Name | Name of an existing S3 bucket (when not creating new bucket) | - | -| Force Destroy Storage Backend | Delete all data in S3 bucket when destroying (use with caution) | Disabled | -| Global Prefix | Prefix for naming all resources | SG_RUNNER | -| Include Organization Name in Prefix | Add org name to resource prefix for uniqueness | Disabled | -| Runner Group Name Override | Custom name for the runner group | Auto-generated | -| Connector Name Override | Custom name for the AWS connector | Auto-generated | -| Maximum Runners | Maximum number of runners allowed in the group | 3 | - -## Important Notes - -**API Key Security**: Your API key is stored securely and used only for authenticating with the StackGuardian platform. It must start with `sgu_` (user key) or `sgo_` (organization key). - -**Storage Backend Options**: You can either create a new S3 bucket (recommended) or use an existing one. When using an existing bucket, ensure it has appropriate permissions. - -**Resource Naming**: By default, resources are named using the pattern `SG_RUNNER-{type}-{aws_account_id}`. You can customize this using the naming options. - -**Data Retention**: The `Force Destroy Storage Backend` option will delete all data when the template is destroyed. Leave this disabled to protect your data. - -## Outputs - -| Output | Description | -|--------|-------------| -| Runner Group Name | Name of the created runner group for use in workflow configurations | -| Runner Group Token | Authentication token for registering runners (keep secure) | -| Runner Group URL | Direct link to manage your runner group in the StackGuardian console | -| Connector Name | Name of the AWS connector integration | -| S3 Bucket Name | Name of the storage bucket for use with runner deployments | -| Storage Backend Role ARN | IAM role ARN required by runner EC2 instances | - -## Security Features - -- **Private S3 Storage**: Bucket is configured with public access blocked -- **Cross-Account Access**: Uses AWS IAM roles with external ID for secure access -- **Scoped Permissions**: IAM policies grant only necessary S3 operations -- **CORS Protection**: S3 bucket only allows requests from StackGuardian platform -- **Sensitive Output Protection**: Tokens and credentials are marked sensitive - -## Usage - -After deploying this template, use the outputs to: - -1. **Deploy Runners**: Pass the `runner_group_name`, `runner_group_token`, `s3_bucket_name`, and `storage_backend_role_arn` to the AWS Autoscaled Runner or AWS Runner templates -2. **Configure Workflows**: Reference the runner group in your workflow configurations to execute jobs on private runners -3. **Monitor Runners**: Access the runner group URL to view runner status and manage the group diff --git a/stackguardian_private_runner/runner_group/README.md b/stackguardian_private_runner/runner_group/README.md index 9486d78..06af5fc 100644 --- a/stackguardian_private_runner/runner_group/README.md +++ b/stackguardian_private_runner/runner_group/README.md @@ -1,290 +1,104 @@ -# StackGuardian Runner Group - AWS Module +# Runner Group (shared, cloud-agnostic) -This Terraform module creates a StackGuardian Runner Group with S3 storage backend and AWS connector integration for running private runners in your AWS environment. +> Part of [StackGuardian Private Runner](../README.md) — internal module, not deployed directly. -## Overview +Internal module. Registers a StackGuardian Runner Group and its storage-backend +connector with the platform. -The module provisions all necessary StackGuardian platform resources and AWS infrastructure to enable private runner execution. It creates a runner group on the StackGuardian platform, an S3 bucket for artifact storage, and the IAM roles required for secure cross-account access. +**You almost certainly want a wrapper instead:** -### What Gets Created +- [`aws/runner_group/`](../aws/runner_group/) — S3 backend, `AWS_RBAC` connector +- [`azure/runner_group/`](../azure/runner_group/) — Blob Storage backend, `AZURE_OIDC` + connector -- **StackGuardian Runner Group**: Platform resource for organizing and managing private runners -- **StackGuardian Connector**: AWS RBAC connector for secure S3 access from the StackGuardian platform -- **S3 Bucket**: Storage backend for runner artifacts (optional - can use existing bucket) -- **IAM Role**: Cross-account role for StackGuardian platform access to S3 -- **IAM Policy**: Scoped permissions for S3 bucket operations +## Why this module exists -## Prerequisites +Terraform provider requirements are static: there is no conditional +`required_providers`, and `count = 0` does not stop a provider from being installed and +configured. A single module holding both the AWS and Azure storage backends therefore +forces every caller to install and configure *both* clouds' providers — an AWS user would +need `azurerm` credentials just to create an S3 bucket. -- StackGuardian API key (starts with `sgu_` for user keys or `sgo_` for organization keys) -- AWS credentials with permissions to create S3 buckets and IAM roles -- Terraform >= 1.0 +So the cloud resources live in the per-cloud wrappers, and everything they have in common +— the platform resources, which need no cloud provider at all — lives here. This module +requires only the `stackguardian` provider. Verify with `tofu providers` in either +wrapper: the AWS one never mentions `azurerm`/`azuread`, and the Azure one never mentions +`aws`. -## Quick Start +## What it creates -### Step 1: Configure Variables +- `stackguardian_runner_group` with the resolved storage backend configuration +- `stackguardian_connector` — `AWS_RBAC` or `AZURE_OIDC`, chosen by + `storage_backend.type` +- Reads `stackguardian_runner_group_token` for runner registration -Create a `terraform.tfvars` file: +Everything cloud-side — buckets, storage accounts, IAM roles, Entra ID apps — is created +by the caller and passed in as strings. -```hcl -stackguardian = { - api_key = "sgu_your_api_key_here" - api_uri = "https://api.app.stackguardian.io" # EU1 or use US1 endpoint - org_name = "your-org-name" # Optional if SG_ORG_ID env var is set -} - -aws_region = "eu-central-1" -``` - -### Step 2: Deploy +## Interface -```bash -terraform init -terraform plan -terraform apply -``` - -### Basic Configuration Example +The caller resolves names and identities, then hands over a discriminated +`storage_backend` object: ```hcl module "runner_group" { - source = "./stackguardian_runner_group" - - stackguardian = { - api_key = "sgu_your_api_key" + source = "../../runner_group" + + sg_org_name = local.sg_org_name + sg_app_uri = local.sg_app_uri + + runner_group_name = local.runner_group_name + connector_name = local.connector_name + max_runners = var.max_runners + + storage_backend = { + type = "aws_s3" + aws = { + region = var.aws_region + bucket_name = local.s3_bucket_name + role_arn = aws_iam_role.storage_backend.arn + external_id = local.connector_external_id + } } - - aws_region = "eu-central-1" } ``` -## Configuration - -### Required Parameters - -| Parameter | Description | Type | -|-----------|-------------|------| -| `stackguardian.api_key` | StackGuardian API key (must start with `sgu_` or `sgo_`) | `string` | +The Azure shape instead sets `type = "azure_blob_storage"` and populates `azure` with +`storage_account_name`, `access_key`, `tenant_id`, `subscription_id`, and `client_id`. +Variable validation enforces that the sub-object matching `type` is present. -### Optional Parameters +### Inputs -| Parameter | Description | Default | -|-----------|-------------|---------| -| `stackguardian.api_uri` | StackGuardian API endpoint | `https://api.app.stackguardian.io` | -| `stackguardian.org_name` | Organization name (extracted from env if not provided) | `""` | -| `aws_region` | Target AWS region | `eu-central-1` | -| `create_storage_backend` | Whether to create a new S3 bucket | `true` | -| `existing_s3_bucket_name` | Existing S3 bucket name (when `create_storage_backend = false`) | `""` | -| `force_destroy_storage_backend` | Force destroy S3 bucket on module destruction | `false` | -| `override_names.global_prefix` | Prefix for resource naming | `SG_RUNNER` | -| `override_names.include_org_in_prefix` | Append org name to prefix | `false` | -| `override_names.runner_group_name` | Override runner group name | Auto-generated | -| `override_names.connector_name` | Override connector name | Auto-generated | -| `max_runners` | Maximum number of runners in the group | `3` | - -### Configuration Examples - -#### Basic Configuration - -```hcl -module "runner_group" { - source = "./stackguardian_runner_group" - - stackguardian = { - api_key = var.sg_api_key - } - - aws_region = "eu-central-1" -} -``` - -#### Advanced Configuration - -```hcl -module "runner_group" { - source = "./stackguardian_runner_group" - - stackguardian = { - api_key = var.sg_api_key - api_uri = "https://api.us.stackguardian.io" - org_name = "my-organization" - } - - aws_region = "us-east-1" - create_storage_backend = true - force_destroy_storage_backend = false - max_runners = 10 - - override_names = { - global_prefix = "PROD_RUNNER" - include_org_in_prefix = true - runner_group_name = "production-runners" - connector_name = "prod-s3-connector" - } -} -``` - -#### Using Existing S3 Bucket - -```hcl -module "runner_group" { - source = "./stackguardian_runner_group" - - stackguardian = { - api_key = var.sg_api_key - } - - aws_region = "eu-central-1" - create_storage_backend = false - existing_s3_bucket_name = "my-existing-bucket" -} -``` +| Variable | Description | +|----------|-------------| +| `sg_org_name` | Organization name, already resolved (from `stackguardian.org_name` or `SG_ORG_ID`) | +| `sg_app_uri` | Web console base URI, used to build `runner_group_url` | +| `runner_group_name` | Final runner group name | +| `connector_name` | Final connector name | +| `max_runners` | Maximum runners in the group (default `3`) | +| `storage_backend` | Discriminated backend config — see above | -## Usage - -### Deployment Commands - -```bash -# Initialize Terraform -terraform init - -# Preview changes -terraform plan - -# Apply configuration -terraform apply - -# View outputs -terraform output runner_group_name -terraform output -raw runner_group_token # Sensitive -``` - -### Cleanup - -```bash -# Destroy all resources -terraform destroy -``` - -**Warning**: If `force_destroy_storage_backend = false` (default), the S3 bucket will not be deleted if it contains objects. Empty the bucket first or set `force_destroy_storage_backend = true`. - -## Architecture - -### Resource Organization - -| File | Purpose | -|------|---------| -| `provider.tf` | Provider configuration (AWS, StackGuardian, external, random) | -| `variables.tf` | Input variable definitions | -| `locals.tf` | Computed values and naming logic | -| `runner_group.tf` | StackGuardian runner group resource | -| `connector.tf` | StackGuardian AWS RBAC connector | -| `storage_backend.tf` | S3 bucket and CORS configuration | -| `storage_backend_role.tf` | IAM role and policy for S3 access | -| `data.tf` | Data sources (runner group token) | -| `outputs.tf` | Module outputs | - -### Resource Naming Convention - -Resources are named using the pattern: `{prefix}-{resource-type}-{account_id}` - -- Default prefix: `SG_RUNNER` -- With org in prefix: `SG_RUNNER_{org_name}` -- Examples: - - Runner group: `SG_RUNNER-runner-group-123456789012` - - Connector: `SG_RUNNER-private-runner-backend-123456789012` - - IAM role: `SG_RUNNER-private-runner-s3-role` - -### Security Model - -The module implements secure cross-account access: - -1. **IAM Role Trust Policy**: Allows StackGuardian AWS accounts (163602625436, 476299211833) and your account to assume the role -2. **External ID**: Random 24-character string prefixed with org name prevents confused deputy attacks -3. **Scoped S3 Permissions**: Only necessary S3 actions are permitted on the specific bucket -4. **Public Access Block**: S3 bucket blocks all public access by default - -## Troubleshooting - -### Common Issues - -1. **API Key Validation Error** - - Ensure API key starts with `sgu_` (user key) or `sgo_` (organization key) - - Verify the key has permissions for your organization - -2. **Organization Name Not Found** - - Provide `stackguardian.org_name` explicitly, or - - Set `SG_ORG_ID` environment variable - -3. **S3 Bucket Already Exists** - - Bucket names are globally unique; the module uses random prefixes - - If using existing bucket, ensure `create_storage_backend = false` - -4. **Permission Denied on Destroy** - - Empty the S3 bucket first, or - - Set `force_destroy_storage_backend = true` - -### Debugging Commands - -```bash -# Check Terraform state -terraform state list - -# View specific resource -terraform state show module.runner_group.stackguardian_runner_group.this - -# Enable debug logging -export TF_LOG=DEBUG -terraform apply -``` - -## Outputs +### Outputs | Output | Description | |--------|-------------| -| `runner_group_name` | The name of the StackGuardian runner group | -| `runner_group_id` | The ID of the StackGuardian runner group | -| `runner_group_token` | Token for runner registration (sensitive) | -| `runner_group_url` | Direct URL to the runner group in the web console (sensitive) | -| `connector_name` | The name of the StackGuardian connector | -| `connector_id` | The ID of the StackGuardian connector | -| `connector_external_id` | External ID for cross-account S3 access (sensitive) | -| `s3_bucket_name` | The name of the S3 bucket | -| `s3_bucket_arn` | The ARN of the S3 bucket | -| `storage_backend_role_arn` | ARN of the IAM role for storage backend access | -| `storage_backend_role_name` | Name of the IAM role | -| `sg_org_name` | The StackGuardian organization name (sensitive) | -| `sg_api_uri` | The StackGuardian API URI | -| `aws_region` | The AWS region | - -## Security Considerations - -- **API Key Storage**: Store your StackGuardian API key securely (environment variables, secrets manager) -- **S3 Encryption**: Consider enabling server-side encryption on the S3 bucket -- **IAM Least Privilege**: The module creates scoped IAM policies with only required permissions -- **Network Security**: S3 bucket CORS is configured to allow only the StackGuardian platform origin -- **Public Access**: S3 bucket public access is blocked by default - -## Requirements - -| Name | Version | -|------|---------| -| terraform | >= 1.0 | -| aws | >= 4.0 | -| stackguardian | >= 1.3.3 | -| external | >= 2.0 | -| random | >= 3.0 | - -## Next Steps - -After deploying this module: - -1. Use the `runner_group_name` and `runner_group_token` outputs to deploy runners using the `aws_autoscaled_runner` or `aws_runner` modules -2. Pass `storage_backend_role_arn` and `s3_bucket_name` to runner deployment modules -3. Access your runner group in the StackGuardian web console using the `runner_group_url` output - -## Support - -- [StackGuardian Documentation](https://docs.stackguardian.io/) -- [StackGuardian Terraform Provider](https://registry.terraform.io/providers/StackGuardian/stackguardian/latest/docs) -- [GitHub Issues](https://github.com/StackGuardian/terraform-stackguardian-modules/issues) +| `runner_group_name` / `runner_group_id` | Name of the created runner group | +| `runner_group_token` | Registration token (sensitive) | +| `runner_group_url` | Direct link to the runner group in the web console | +| `connector_name` / `connector_id` | Name of the created connector | + +## Provider configuration + +This module declares no `provider` block — the wrapper's `provider "stackguardian"` is +inherited. Keeping provider configuration out of child modules is deliberate: a module +that configures its own providers is a *legacy module* and cannot take `count`, +`for_each`, or `depends_on`. + +## Adding a cloud + +1. Add a branch to `storage_backend` in `variables.tf`, with a validation rule requiring + its sub-object when `type` matches. +2. Add the matching `stackguardian_connector` resource in `connector.tf` and a branch in + `runner_group.tf`. +3. Create a `{cloud}/runner_group/` wrapper that builds the cloud resources and calls this + module. It should require only that cloud's providers. diff --git a/stackguardian_private_runner/runner_group/connector.tf b/stackguardian_private_runner/runner_group/connector.tf index fb5423c..4e4186c 100644 --- a/stackguardian_private_runner/runner_group/connector.tf +++ b/stackguardian_private_runner/runner_group/connector.tf @@ -1,18 +1,45 @@ -# StackGuardian Connector +# StackGuardian Connector (storage backend authentication) +# +# Both variants are plain platform resources: the cloud identities they point at are +# created by the calling module and arrive here as strings, so this module needs no +# AWS or Azure provider. -resource "stackguardian_connector" "this" { - resource_name = local.connector_name - description = "AWS connector for accessing Private Runner storage backend (S3 Bucket: ${local.s3_bucket_name})." +# AWS Connector — Uses RBAC role for S3 access +resource "stackguardian_connector" "aws" { + count = local.is_aws ? 1 : 0 + + resource_name = var.connector_name + description = "AWS connector for accessing Private Runner storage backend (S3 Bucket: ${local.aws_backend.bucket_name})." settings = { kind = "AWS_RBAC" config = [{ - role_arn = aws_iam_role.storage_backend.arn - external_id = "${local.sg_org_name}:${random_string.connector_external_id.result}" + role_arn = local.aws_backend.role_arn + external_id = local.aws_backend.external_id duration_seconds = "3600" }] } tags = local.default_tags } + +# Azure Connector — Uses OIDC with a Service Principal provisioned by the caller +resource "stackguardian_connector" "azure" { + count = local.is_azure ? 1 : 0 + + resource_name = var.connector_name + description = "Azure OIDC connector for Private Runner storage backend" + + settings = { + kind = "AZURE_OIDC" + + config = [{ + arm_tenant_id = local.azure_backend.tenant_id + arm_subscription_id = local.azure_backend.subscription_id + arm_client_id = local.azure_backend.client_id + }] + } + + tags = local.default_tags +} diff --git a/stackguardian_private_runner/runner_group/locals.tf b/stackguardian_private_runner/runner_group/locals.tf index 45f9c5f..2ecb9c6 100644 --- a/stackguardian_private_runner/runner_group/locals.tf +++ b/stackguardian_private_runner/runner_group/locals.tf @@ -1,73 +1,26 @@ -data "external" "env" { - program = [ - "sh", - "-c", - "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\"}'" - ] -} - -data "aws_caller_identity" "current" {} - locals { - # StackGuardian configuration - # Use nonsensitive() for non-secret fields to prevent sensitivity propagation - sg_org_name = ( - nonsensitive(var.stackguardian.org_name) != "" - ? nonsensitive(var.stackguardian.org_name) - : data.external.env.result.sg_org_name - ) - 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 != "" - ? "${var.override_names.global_prefix}_${local.sg_org_name}" - : var.override_names.global_prefix - ) - - # Resource naming - runner_group_name = ( - var.override_names.runner_group_name != "" - ? var.override_names.runner_group_name - : "${local.effective_prefix}-runner-group-${data.aws_caller_identity.current.account_id}" - ) - - connector_name = ( - var.override_names.connector_name != "" - ? var.override_names.connector_name - : "${local.effective_prefix}-private-runner-backend-${data.aws_caller_identity.current.account_id}" - ) - - # Default tags (not editable by user) - default_tags = [ - "StackGuardian Private Runner", - local.runner_group_name, - local.sg_org_name - ] - - # S3 bucket name (created or existing) - s3_bucket_name = ( - var.create_storage_backend - ? aws_s3_bucket.this[0].bucket - : var.existing_s3_bucket_name - ) - - s3_bucket_arn = ( - var.create_storage_backend - ? aws_s3_bucket.this[0].arn - : "arn:aws:s3:::${local.s3_bucket_name}" - ) - - # Runner group outputs - final_runner_group_name = stackguardian_runner_group.this.resource_name - final_connector_name = stackguardian_connector.this.resource_name + # Storage backend discriminator + is_aws = var.storage_backend.type == "aws_s3" + is_azure = var.storage_backend.type == "azure_blob_storage" + + aws_backend = var.storage_backend.aws + azure_backend = var.storage_backend.azure + + # Tags applied to both the runner group and the connector. The platform models + # tags as a flat list of strings - there are no keys - so values are bare. + # + # Deliberately absent: the org name (a runner group only ever lives in one org) + # and the runner group name (it is the resource's own name). Both were pure + # duplication. The cloud account and prefix live here instead of in the name. + default_tags = compact(concat( + [ + "StackGuardian Private Runner", + # Tool-agnostic on purpose: this module runs under both OpenTofu and + # Terraform, and the tag's job is "do not hand-edit this in the console", + # not to record which binary ran. + "Managed by IaC", + local.is_aws ? "aws" : "azure", + ], + var.tags, + )) } diff --git a/stackguardian_private_runner/runner_group/outputs.tf b/stackguardian_private_runner/runner_group/outputs.tf index 677cf18..e085297 100644 --- a/stackguardian_private_runner/runner_group/outputs.tf +++ b/stackguardian_private_runner/runner_group/outputs.tf @@ -19,64 +19,18 @@ output "runner_group_token" { output "runner_group_url" { description = "Direct URL to the runner group in the StackGuardian web console" - value = "${local.sg_app_uri}/orchestrator/orgs/${local.sg_org_name}/runnergroups/${local.final_runner_group_name}" + value = "${var.sg_app_uri}/orchestrator/orgs/${var.sg_org_name}/runnergroups/${stackguardian_runner_group.this.resource_name}" } /*---------------------------------+ - | Connector Outputs | + | Connector Outputs | +---------------------------------*/ output "connector_name" { description = "The name of the StackGuardian connector" - value = stackguardian_connector.this.resource_name + value = local.is_aws ? stackguardian_connector.aws[0].resource_name : stackguardian_connector.azure[0].resource_name } output "connector_id" { description = "The ID of the StackGuardian connector" - value = stackguardian_connector.this.resource_name -} - -output "connector_external_id" { - description = "The external ID used for cross-account S3 access" - value = "${local.sg_org_name}:${random_string.connector_external_id.result}" -} - -/*---------------------------------+ - | Storage Backend Outputs | - +---------------------------------*/ -output "s3_bucket_name" { - description = "The name of the S3 bucket used for storage backend" - value = local.s3_bucket_name -} - -output "s3_bucket_arn" { - description = "The ARN of the S3 bucket used for storage backend" - value = local.s3_bucket_arn -} - -output "storage_backend_role_arn" { - description = "The ARN of the IAM role for storage backend access" - value = aws_iam_role.storage_backend.arn -} - -output "storage_backend_role_name" { - description = "The name of the IAM role for storage backend access" - value = aws_iam_role.storage_backend.name -} - -/*---------------------------------+ - | StackGuardian Platform Outputs | - +---------------------------------*/ -output "sg_org_name" { - description = "The StackGuardian organization name" - value = local.sg_org_name -} - -output "sg_api_uri" { - description = "The StackGuardian API URI" - value = local.sg_api_uri -} - -output "aws_region" { - description = "The AWS region" - value = var.aws_region + value = local.is_aws ? stackguardian_connector.aws[0].resource_name : stackguardian_connector.azure[0].resource_name } diff --git a/stackguardian_private_runner/runner_group/provider.tf b/stackguardian_private_runner/runner_group/provider.tf index 26f0fed..c68e458 100644 --- a/stackguardian_private_runner/runner_group/provider.tf +++ b/stackguardian_private_runner/runner_group/provider.tf @@ -1,30 +1,11 @@ terraform { + # optional() attributes with defaults (var.storage_backend) need 1.3+ + required_version = ">= 1.3.0" + required_providers { stackguardian = { source = "registry.terraform.io/StackGuardian/stackguardian" version = ">= 1.3.3" } - aws = { - source = "hashicorp/aws" - version = ">= 4.0" - } - external = { - source = "hashicorp/external" - version = ">= 2.0" - } - random = { - source = "hashicorp/random" - version = ">= 3.0" - } } } - -provider "aws" { - region = var.aws_region -} - -provider "stackguardian" { - api_key = var.stackguardian.api_key - org_name = local.sg_org_name - api_uri = local.sg_api_uri -} diff --git a/stackguardian_private_runner/runner_group/runner_group.tf b/stackguardian_private_runner/runner_group/runner_group.tf index 56b39db..485b42d 100644 --- a/stackguardian_private_runner/runner_group/runner_group.tf +++ b/stackguardian_private_runner/runner_group/runner_group.tf @@ -1,17 +1,28 @@ # StackGuardian Runner Group resource "stackguardian_runner_group" "this" { - resource_name = local.runner_group_name - description = "Private Runner Group for AWS S3 storage backend" + resource_name = var.runner_group_name + description = "Private Runner Group for ${local.is_aws ? "AWS S3" : "Azure Blob Storage"} storage backend" max_number_of_runners = var.max_runners - storage_backend_config = { - type = "aws_s3" - aws_region = var.aws_region - s3_bucket_name = local.s3_bucket_name + storage_backend_config = local.is_aws ? { + type = "aws_s3" + aws_region = local.aws_backend.region + s3_bucket_name = local.aws_backend.bucket_name + azure_blob_storage_account_name = null + azure_blob_storage_access_key = null auth = { - integration_id = "/integrations/${stackguardian_connector.this.resource_name}" + integration_id = "/integrations/${stackguardian_connector.aws[0].resource_name}" + } + } : { + type = "azure_blob_storage" + aws_region = null + s3_bucket_name = null + azure_blob_storage_account_name = local.azure_backend.storage_account_name + azure_blob_storage_access_key = local.azure_backend.access_key + auth = { + integration_id = "/integrations/${stackguardian_connector.azure[0].resource_name}" } } diff --git a/stackguardian_private_runner/runner_group/variables.tf b/stackguardian_private_runner/runner_group/variables.tf index 04179f5..233d7d1 100644 --- a/stackguardian_private_runner/runner_group/variables.tf +++ b/stackguardian_private_runner/runner_group/variables.tf @@ -1,90 +1,53 @@ -/*---------------------------+ - | Storage Backend Options | - +---------------------------*/ -variable "create_storage_backend" { - description = <