From b6f82bac23bf1c1f40044dd9da439fee8ebb41b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Tue, 25 Nov 2025 13:39:58 +0100 Subject: [PATCH 01/37] SG-3995: Introduce module for Azure Autoscaler. --- stackguardian_private_runner/README.md | 50 ++- stackguardian_private_runner/azure/README.md | 379 ++++++++++++++++++ .../azure/function_autoscaler.tf | 151 +++++++ stackguardian_private_runner/azure/locals.tf | 31 ++ stackguardian_private_runner/azure/outputs.tf | 73 ++++ .../azure/provider.tf | 26 ++ stackguardian_private_runner/azure/storage.tf | 42 ++ .../azure/variables.tf | 156 +++++++ 8 files changed, 906 insertions(+), 2 deletions(-) create mode 100644 stackguardian_private_runner/azure/README.md create mode 100644 stackguardian_private_runner/azure/function_autoscaler.tf create mode 100644 stackguardian_private_runner/azure/locals.tf create mode 100644 stackguardian_private_runner/azure/outputs.tf create mode 100644 stackguardian_private_runner/azure/provider.tf create mode 100644 stackguardian_private_runner/azure/storage.tf create mode 100644 stackguardian_private_runner/azure/variables.tf diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index 1a567bf..4359d6d 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 @@ -18,7 +18,10 @@ This project provides four templates that work together to create a complete aut **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 +3. **[Azure Module](azure/)** - Deploy an Azure Function-based autoscaler for existing VM Scale Sets + +## AWS Deployment Guide ### Step 1: Build Custom AMI @@ -292,3 +295,46 @@ terraform apply -auto-approve \ echo "Deployment complete!" echo "Runner Group: $RUNNER_GROUP_NAME" ``` + +--- + +## Azure Deployment Guide + +For Azure deployments, the autoscaler manages an **existing** VM Scale Set with StackGuardian runners. + +See the **[Azure Module README](azure/README.md)** for complete instructions, including: +- Manual setup using Azure CLI +- Terraform module usage (WIP) + +### Quick Start + +```hcl +module "azure_autoscaler" { + source = "./azure" + + 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" + } +} +``` + +### What Gets Created (Azure) + +- **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/azure/README.md b/stackguardian_private_runner/azure/README.md new file mode 100644 index 0000000..f9d5b24 --- /dev/null +++ b/stackguardian_private_runner/azure/README.md @@ -0,0 +1,379 @@ +# Azure Private Runner Autoscaler + +Deploy an Azure Function-based autoscaler for managing StackGuardian Private Runners on an existing Azure VM Scale Set. + +## Overview + +The autoscaler monitors StackGuardian's job queue and automatically scales your VM Scale Set: +- **Scale OUT**: When pending jobs exceed threshold, add VM instances +- **Scale IN**: When pending jobs fall below threshold, gracefully drain and remove instances +- **Cooldown**: Respects configurable cooldown periods between scaling operations + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Azure Subscription │ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────────────┐ │ +│ │ Resource Group │ │ VMSS Resource Group │ │ +│ │ │ │ (existing) │ │ +│ │ ┌───────────────┐ │ │ ┌───────────────────────┐ │ │ +│ │ │ Function App │──┼────┼──│ VM Scale Set │ │ │ +│ │ │ (autoscaler) │ │ │ │ (existing runners) │ │ │ +│ │ └───────┬───────┘ │ │ └───────────────────────┘ │ │ +│ │ │ │ │ │ │ +│ │ ┌───────▼───────┐ │ └─────────────────────────────┘ │ +│ │ │ Storage Acct │ │ │ +│ │ │ (state) │ │ │ +│ │ └───────────────┘ │ │ +│ │ │ │ +│ │ ┌───────────────┐ │ │ +│ │ │ App Insights │ │ │ +│ │ │ (monitoring) │ │ │ +│ │ └───────────────┘ │ │ +│ └─────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Prerequisites + +- Azure CLI authenticated (`az login`) +- Azure Functions Core Tools v4+ (`func --version`) - *optional, can use Azure CLI instead* +- Git +- Existing VM Scale Set with StackGuardian runner instances +- StackGuardian API Key (starts with `sgu_`) +- StackGuardian Runner Group name + +--- + +## Option 1: Manual Setup + +Step-by-step guide to deploy the autoscaler 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 + +```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_BLOB_STORAGE_CONN_STRING="$STORAGE_CONN_STRING" \ + 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" +``` + +### 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" +``` + +### 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" +``` + +--- + +## Option 2: Terraform Module (WIP) + +> **Note**: This Terraform module is a work in progress and automates the manual steps above. + +### Prerequisites + +- Terraform >= 1.0 +- Azure CLI authenticated (`az login`) +- Git + +### Usage + +```hcl +module "azure_autoscaler" { + source = "./stackguardian_private_runner/azure" + + resource_group_name = "my-existing-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" + } + + scaling = { + 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 + } +} +``` + +```bash +terraform init +terraform apply +``` + +### Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| `azure_location` | Azure region | `string` | `"westeurope"` | no | +| `resource_group_name` | Resource group name | `string` | n/a | yes | +| `stackguardian` | SG platform config | `object` | n/a | yes | +| `vmss` | VM Scale Set config | `object` | n/a | yes | +| `override_names` | Naming overrides | `object` | See defaults | no | +| `scaling` | Scaling parameters | `object` | See defaults | no | +| `storage` | Storage config | `object` | See defaults | no | + +### Outputs + +| Name | Description | +|------|-------------| +| `function_app_name` | Name of the Azure Function App | +| `function_app_default_hostname` | Default hostname | +| `storage_account_name` | Name of the Storage Account | + +--- + +## 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 + +## 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_BLOB_STORAGE_CONN_STRING` | Storage connection string | Required | +| `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` | + +## Troubleshooting + +### Check Function App Logs +```bash +az monitor app-insights query \ + --app \ + --resource-group \ + --analytics-query "traces | order by timestamp desc | take 50" +``` + +### Check Function Status +```bash +az functionapp function list --name --resource-group +``` + +### Manually Trigger Function +```bash +az functionapp function invoke \ + --name \ + --resource-group \ + --function-name timer_trigger +``` diff --git a/stackguardian_private_runner/azure/function_autoscaler.tf b/stackguardian_private_runner/azure/function_autoscaler.tf new file mode 100644 index 0000000..802c4fc --- /dev/null +++ b/stackguardian_private_runner/azure/function_autoscaler.tf @@ -0,0 +1,151 @@ +/*-------------------------------------------+ + | 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 = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } +} + +# 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" + + tags = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } +} + +# 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 = "StorageAccountConnectionString" + storage_access_key = azurerm_storage_account.autoscaler.primary_access_key + + site_config { + application_insights_connection_string = azurerm_application_insights.autoscaler.connection_string + } + + app_settings = { + # Azure configuration (matches azure_service.py expectations) + 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_BLOB_STORAGE_CONN_STRING = azurerm_storage_account.autoscaler.primary_connection_string + 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 (matches stackguardian_autoscaler.py expectations) + 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) + + # Function runtime settings + AzureWebJobsStorage = azurerm_storage_account.autoscaler.primary_connection_string + APPLICATIONINSIGHTS_CONNECTION_STRING = azurerm_application_insights.autoscaler.connection_string + } + + identity { + type = "SystemAssigned" + } + + tags = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } +} + +/*-------------------------------------------+ + | Automatic Code Deployment | + +-------------------------------------------*/ +# Clones the autoscaler repo and deploys using func CLI +resource "null_resource" "deploy_function_code" { + depends_on = [azurerm_function_app_flex_consumption.autoscaler] + + triggers = { + function_app_id = azurerm_function_app_flex_consumption.autoscaler.id + } + + provisioner "local-exec" { + command = <<-EOT + set -e + TEMP_DIR=$(mktemp -d) + git clone --depth 1 https://github.com/StackGuardian/sg-runner-autoscaler.git "$TEMP_DIR/repo" + cd "$TEMP_DIR/repo" + cp azure_requirements.txt requirements.txt + + # Create deployment package + zip -r "$TEMP_DIR/deploy.zip" . -x ".git/*" + + # Deploy using Azure CLI + az functionapp deployment source config-zip \ + --resource-group ${var.resource_group_name} \ + --name ${azurerm_function_app_flex_consumption.autoscaler.name} \ + --src "$TEMP_DIR/deploy.zip" \ + --build-remote true + + rm -rf "$TEMP_DIR" + EOT + } +} + +/*-------------------------------------------+ + | Role Assignments for Function Identity | + +-------------------------------------------*/ + +# Allow Function App to manage VM Scale Set +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 +} + +# Allow Function App to read VMSS instances +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 +} + +# Allow Function App to access storage +resource "azurerm_role_assignment" "storage_blob_contributor" { + scope = azurerm_storage_account.autoscaler.id + role_definition_name = "Storage Blob Data Contributor" + principal_id = azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id +} diff --git a/stackguardian_private_runner/azure/locals.tf b/stackguardian_private_runner/azure/locals.tf new file mode 100644 index 0000000..85cc3da --- /dev/null +++ b/stackguardian_private_runner/azure/locals.tf @@ -0,0 +1,31 @@ +data "external" "env" { + program = [ + "sh", + "-c", + "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\", \"sg_api_uri\": \"'$${SG_API_URI:-https://api.app.stackguardian.io}'\"}'" + ] +} + +data "azurerm_client_config" "current" {} + +locals { + sg_org_name = ( + var.stackguardian.org_name != "" + ? var.stackguardian.org_name + : data.external.env.result.sg_org_name + ) + sg_api_uri = data.external.env.result.sg_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 + ) + + # Sanitized prefix for Azure resources (lowercase, no special chars) + sanitized_prefix = replace(lower(var.override_names.global_prefix), "_", "-") + + # Storage account prefix (max 15 chars to leave room for 8-char random suffix + margin) + storage_account_prefix = substr(replace(local.sanitized_prefix, "-", ""), 0, 15) +} diff --git a/stackguardian_private_runner/azure/outputs.tf b/stackguardian_private_runner/azure/outputs.tf new file mode 100644 index 0000000..bc0ed22 --- /dev/null +++ b/stackguardian_private_runner/azure/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/provider.tf b/stackguardian_private_runner/azure/provider.tf new file mode 100644 index 0000000..8961116 --- /dev/null +++ b/stackguardian_private_runner/azure/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/storage.tf b/stackguardian_private_runner/azure/storage.tf new file mode 100644 index 0000000..82b411e --- /dev/null +++ b/stackguardian_private_runner/azure/storage.tf @@ -0,0 +1,42 @@ +/*-------------------------------------------+ + | 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 = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } +} + +# 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/variables.tf b/stackguardian_private_runner/azure/variables.tf new file mode 100644 index 0000000..e3ec9f7 --- /dev/null +++ b/stackguardian_private_runner/azure/variables.tf @@ -0,0 +1,156 @@ +/*-------------------+ + | 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 + org_name = optional(string, "") + }) + + validation { + condition = can(regex("^sgu_.*", var.stackguardian.api_key)) + error_message = "The api_key must be a valid StackGuardian API key starting with 'sgu_'." + } +} + +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." + } +} + +/*---------------------------+ + | Storage Backend Variables | + +---------------------------*/ +variable "storage" { + description = < Date: Tue, 25 Nov 2025 14:22:07 +0100 Subject: [PATCH 02/37] fix(SG-3995): Add missing network permissions. --- stackguardian_private_runner/azure/README.md | 30 +++++++++++++++++++ .../azure/function_autoscaler.tf | 8 +++++ .../azure/variables.tf | 2 +- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/stackguardian_private_runner/azure/README.md b/stackguardian_private_runner/azure/README.md index f9d5b24..4e7152f 100644 --- a/stackguardian_private_runner/azure/README.md +++ b/stackguardian_private_runner/azure/README.md @@ -196,6 +196,13 @@ 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 @@ -357,6 +364,21 @@ terraform apply ## Troubleshooting +### Common Errors + +#### 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" +``` + ### Check Function App Logs ```bash az monitor app-insights query \ @@ -365,6 +387,14 @@ az monitor app-insights query \ --analytics-query "traces | order by timestamp desc | take 50" ``` +### Check Exceptions in App Insights +```bash +az monitor app-insights query \ + --app \ + --resource-group \ + --analytics-query "exceptions | order by timestamp desc | take 10" +``` + ### Check Function Status ```bash az functionapp function list --name --resource-group diff --git a/stackguardian_private_runner/azure/function_autoscaler.tf b/stackguardian_private_runner/azure/function_autoscaler.tf index 802c4fc..6ddb4b4 100644 --- a/stackguardian_private_runner/azure/function_autoscaler.tf +++ b/stackguardian_private_runner/azure/function_autoscaler.tf @@ -149,3 +149,11 @@ resource "azurerm_role_assignment" "storage_blob_contributor" { role_definition_name = "Storage Blob Data Contributor" principal_id = azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id } + +# Allow Function App to join VMs to network resources (VNet subnets, NSGs) +# Required for VMSS scaling operations +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/variables.tf b/stackguardian_private_runner/azure/variables.tf index e3ec9f7..4e6574c 100644 --- a/stackguardian_private_runner/azure/variables.tf +++ b/stackguardian_private_runner/azure/variables.tf @@ -23,7 +23,7 @@ variable "stackguardian" { }) validation { - condition = can(regex("^sgu_.*", var.stackguardian.api_key)) + condition = can(regex("^sg[o|u]_.*", var.stackguardian.api_key)) error_message = "The api_key must be a valid StackGuardian API key starting with 'sgu_'." } } From fcfabe66a4ab2f52fa7e4d6c59a70321e12670c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 16 Feb 2026 13:06:58 +0100 Subject: [PATCH 03/37] SG-3995: Re-organize azure runner. --- .../azure/{ => autoscaler}/README.md | 34 +- .../{ => autoscaler}/function_autoscaler.tf | 15 +- .../azure/{ => autoscaler}/locals.tf | 7 + .../azure/{ => autoscaler}/outputs.tf | 0 .../azure/{ => autoscaler}/provider.tf | 0 .../azure/{ => autoscaler}/storage.tf | 0 .../azure/{ => autoscaler}/variables.tf | 3 + .../azure/azure_runner/data.tf | 2 + .../azure/azure_runner/locals.tf | 53 +++ .../azure/azure_runner/network.tf | 129 +++++++ .../azure/azure_runner/outputs.tf | 67 ++++ .../azure/azure_runner/provider.tf | 36 ++ .../templates/register_runner.sh.tpl | 36 ++ .../azure/azure_runner/variables.tf | 203 +++++++++++ .../azure/azure_runner/vm.tf | 70 ++++ .../azure/packer/image.pkr.hcl | 92 +++++ .../azure/packer/locals.tf | 20 + .../azure/packer/main.tf | 89 +++++ .../azure/packer/outputs.tf | 37 ++ .../azure/packer/packer_manifest.log | 241 ++++++++++++ .../azure/packer/provider.tf | 18 + .../azure/packer/scripts/build_image.sh | 128 +++++++ .../azure/packer/scripts/setup.sh | 343 ++++++++++++++++++ .../azure/packer/variables.tf | 116 ++++++ 24 files changed, 1730 insertions(+), 9 deletions(-) rename stackguardian_private_runner/azure/{ => autoscaler}/README.md (91%) rename stackguardian_private_runner/azure/{ => autoscaler}/function_autoscaler.tf (92%) rename stackguardian_private_runner/azure/{ => autoscaler}/locals.tf (84%) rename stackguardian_private_runner/azure/{ => autoscaler}/outputs.tf (100%) rename stackguardian_private_runner/azure/{ => autoscaler}/provider.tf (100%) rename stackguardian_private_runner/azure/{ => autoscaler}/storage.tf (100%) rename stackguardian_private_runner/azure/{ => autoscaler}/variables.tf (96%) create mode 100644 stackguardian_private_runner/azure/azure_runner/data.tf create mode 100644 stackguardian_private_runner/azure/azure_runner/locals.tf create mode 100644 stackguardian_private_runner/azure/azure_runner/network.tf create mode 100644 stackguardian_private_runner/azure/azure_runner/outputs.tf create mode 100644 stackguardian_private_runner/azure/azure_runner/provider.tf create mode 100644 stackguardian_private_runner/azure/azure_runner/templates/register_runner.sh.tpl create mode 100644 stackguardian_private_runner/azure/azure_runner/variables.tf create mode 100644 stackguardian_private_runner/azure/azure_runner/vm.tf create mode 100644 stackguardian_private_runner/azure/packer/image.pkr.hcl create mode 100644 stackguardian_private_runner/azure/packer/locals.tf create mode 100644 stackguardian_private_runner/azure/packer/main.tf create mode 100644 stackguardian_private_runner/azure/packer/outputs.tf create mode 100644 stackguardian_private_runner/azure/packer/packer_manifest.log create mode 100644 stackguardian_private_runner/azure/packer/provider.tf create mode 100644 stackguardian_private_runner/azure/packer/scripts/build_image.sh create mode 100644 stackguardian_private_runner/azure/packer/scripts/setup.sh create mode 100644 stackguardian_private_runner/azure/packer/variables.tf diff --git a/stackguardian_private_runner/azure/README.md b/stackguardian_private_runner/azure/autoscaler/README.md similarity index 91% rename from stackguardian_private_runner/azure/README.md rename to stackguardian_private_runner/azure/autoscaler/README.md index 4e7152f..de39b62 100644 --- a/stackguardian_private_runner/azure/README.md +++ b/stackguardian_private_runner/azure/autoscaler/README.md @@ -136,6 +136,8 @@ az functionapp create \ ### 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 \ @@ -144,7 +146,7 @@ az functionapp config appsettings set \ AZURE_SUBSCRIPTION_ID="$SUBSCRIPTION_ID" \ AZURE_RESOURCE_GROUP_NAME="$VMSS_RESOURCE_GROUP" \ AZURE_VMSS_NAME="$VMSS_NAME" \ - AZURE_BLOB_STORAGE_CONN_STRING="$STORAGE_CONN_STRING" \ + 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" \ @@ -164,6 +166,8 @@ az functionapp config appsettings set \ 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 @@ -340,6 +344,31 @@ terraform apply 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 | @@ -347,7 +376,8 @@ terraform apply | `AZURE_SUBSCRIPTION_ID` | Azure subscription ID | Required | | `AZURE_RESOURCE_GROUP_NAME` | VMSS resource group | Required | | `AZURE_VMSS_NAME` | VM Scale Set name | Required | -| `AZURE_BLOB_STORAGE_CONN_STRING` | Storage connection string | 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 | diff --git a/stackguardian_private_runner/azure/function_autoscaler.tf b/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf similarity index 92% rename from stackguardian_private_runner/azure/function_autoscaler.tf rename to stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf index 6ddb4b4..c4ce0b8 100644 --- a/stackguardian_private_runner/azure/function_autoscaler.tf +++ b/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf @@ -52,13 +52,14 @@ resource "azurerm_function_app_flex_consumption" "autoscaler" { app_settings = { # Azure configuration (matches azure_service.py expectations) - 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_BLOB_STORAGE_CONN_STRING = azurerm_storage_account.autoscaler.primary_connection_string - 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" + 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 (matches stackguardian_autoscaler.py expectations) SG_BASE_URI = local.sg_api_uri diff --git a/stackguardian_private_runner/azure/locals.tf b/stackguardian_private_runner/azure/autoscaler/locals.tf similarity index 84% rename from stackguardian_private_runner/azure/locals.tf rename to stackguardian_private_runner/azure/autoscaler/locals.tf index 85cc3da..1d73795 100644 --- a/stackguardian_private_runner/azure/locals.tf +++ b/stackguardian_private_runner/azure/autoscaler/locals.tf @@ -28,4 +28,11 @@ locals { # Storage account prefix (max 15 chars to leave room for 8-char random suffix + margin) storage_account_prefix = substr(replace(local.sanitized_prefix, "-", ""), 0, 15) + + # Storage URL: use explicit URL if provided (for private endpoints) + storage_account_url = ( + var.storage.account_url != "" + ? var.storage.account_url + : "" + ) } diff --git a/stackguardian_private_runner/azure/outputs.tf b/stackguardian_private_runner/azure/autoscaler/outputs.tf similarity index 100% rename from stackguardian_private_runner/azure/outputs.tf rename to stackguardian_private_runner/azure/autoscaler/outputs.tf diff --git a/stackguardian_private_runner/azure/provider.tf b/stackguardian_private_runner/azure/autoscaler/provider.tf similarity index 100% rename from stackguardian_private_runner/azure/provider.tf rename to stackguardian_private_runner/azure/autoscaler/provider.tf diff --git a/stackguardian_private_runner/azure/storage.tf b/stackguardian_private_runner/azure/autoscaler/storage.tf similarity index 100% rename from stackguardian_private_runner/azure/storage.tf rename to stackguardian_private_runner/azure/autoscaler/storage.tf diff --git a/stackguardian_private_runner/azure/variables.tf b/stackguardian_private_runner/azure/autoscaler/variables.tf similarity index 96% rename from stackguardian_private_runner/azure/variables.tf rename to stackguardian_private_runner/azure/autoscaler/variables.tf index 4e6574c..ceb134e 100644 --- a/stackguardian_private_runner/azure/variables.tf +++ b/stackguardian_private_runner/azure/autoscaler/variables.tf @@ -134,14 +134,17 @@ variable "storage" { - account_tier: Performance tier of the storage account (Standard or Premium) - account_replication_type: Replication strategy (LRS, GRS, RAGRS, ZRS) + - account_url: Optional explicit storage account URL (for private endpoints) EOT type = object({ account_tier = optional(string, "Standard") account_replication_type = optional(string, "LRS") + account_url = optional(string, "") }) default = { account_tier = "Standard" account_replication_type = "LRS" + account_url = "" } validation { 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..579b6de --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/locals.tf @@ -0,0 +1,53 @@ +# Extract SG org name and API URI from environment if not provided +data "external" "env" { + program = [ + "sh", + "-c", + "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\", \"sg_api_uri\": \"'$${SG_API_URI:-https://api.app.stackguardian.io}'\"}'" + ] +} + +locals { + # StackGuardian configuration - use provided values or extract from environment + sg_org_name = ( + var.stackguardian.org_name != "" + ? var.stackguardian.org_name + : data.external.env.result.sg_org_name + ) + sg_api_uri = ( + var.stackguardian.api_uri != "" + ? var.stackguardian.api_uri + : data.external.env.result.sg_api_uri + ) + + # Network mode logic + create_network = var.network.create_network + use_existing_network = !local.create_network + + # 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 + ) + + # Sanitized prefix for Azure naming + sanitized_prefix = replace(lower(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..fb11817 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/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}-vnet" + address_space = var.network.vnet_address_space + location = var.azure_location + resource_group_name = var.resource_group_name + + tags = { + Name = "${local.sanitized_prefix}-vnet" + purpose = "stackguardian-private-runner" + } +} + +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] +} + +/*-------------------------------------------+ + | 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 = { + Name = "${local.sanitized_prefix}-nsg" + purpose = "stackguardian-private-runner" + } +} + +/*-------------------------------------------+ + | 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 = { + Name = "${local.sanitized_prefix}-pip" + purpose = "stackguardian-private-runner" + } +} + +/*-------------------------------------------+ + | 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 = { + Name = "${local.sanitized_prefix}-nic" + purpose = "stackguardian-private-runner" + } +} + +# 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..b52b8d6 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/provider.tf @@ -0,0 +1,36 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.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/templates/register_runner.sh.tpl b/stackguardian_private_runner/azure/azure_runner/templates/register_runner.sh.tpl new file mode 100644 index 0000000..48f48ce --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/templates/register_runner.sh.tpl @@ -0,0 +1,36 @@ +#!/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" + +## 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..b83c470 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/variables.tf @@ -0,0 +1,203 @@ +/*------------------------+ + | 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 = "Firewall and SSH configuration for the Private Runner instance" + type = object({ + admin_username = optional(string, "azureuser") + ssh_public_key = optional(string, "") + generate_ssh_key = optional(bool, true) + ssh_access_rules = optional(map(string), {}) + additional_inbound_rules = optional(map(object({ + priority = number + direction = optional(string, "Inbound") + access = optional(string, "Allow") + protocol = string + source_port_range = optional(string, "*") + destination_port_range = string + source_address_prefix = string + destination_address_prefix = optional(string, "*") + })), {}) + }) + default = { + admin_username = "azureuser" + generate_ssh_key = true + } + + validation { + condition = ( + var.firewall.ssh_public_key != "" || + var.firewall.generate_ssh_key == true + ) + error_message = "Either provide ssh_public_key or set generate_ssh_key = true." + } +} + +/*-----------------------------------+ + | Runner Startup Variables | + +-----------------------------------*/ +variable "runner_startup_timeout" { + description = "Maximum time in seconds to wait for Docker to start before shutting down the instance" + type = number + default = 300 +} diff --git a/stackguardian_private_runner/azure/azure_runner/vm.tf b/stackguardian_private_runner/azure/azure_runner/vm.tf new file mode 100644 index 0000000..53f3eb5 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/vm.tf @@ -0,0 +1,70 @@ +/*-------------------------------------------+ + | SSH Key Generation (optional) | + +-------------------------------------------*/ +resource "tls_private_key" "ssh" { + count = local.use_generated_key ? 1 : 0 + + algorithm = "RSA" + rsa_bits = 4096 +} + +/*-------------------------------------------+ + | Azure Linux Virtual Machine | + +-------------------------------------------*/ +resource "azurerm_linux_virtual_machine" "this" { + name = local.vm_name + resource_group_name = var.resource_group_name + location = var.azure_location + size = var.vm_size + + admin_username = var.firewall.admin_username + disable_password_authentication = true + + admin_ssh_key { + username = var.firewall.admin_username + public_key = local.ssh_public_key + } + + network_interface_ids = [ + azurerm_network_interface.this.id + ] + + # Use custom image + source_image_id = var.vm_image_id + + os_disk { + name = "${local.sanitized_prefix}-osdisk" + caching = var.os_disk.caching + storage_account_type = var.os_disk.storage_account_type + disk_size_gb = var.os_disk.disk_size_gb + } + + # User-Assigned Managed Identity for storage backend access + identity { + type = "UserAssigned" + identity_ids = [var.storage_backend_identity_id] + } + + # Custom data for runner registration + 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) + } + ) + ) + + tags = { + Name = local.vm_name + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } + + lifecycle { + create_before_destroy = true + } +} 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..1ea8d15 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/image.pkr.hcl @@ -0,0 +1,92 @@ +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 "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 = "" } + +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 = "scripts/setup.sh" + environment_vars = [ + "OS_FAMILY=${var.os_family}", + "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}", + "USER_SCRIPT=${var.user_script}" + ] + } + + # 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..0e84db8 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/locals.tf @@ -0,0 +1,20 @@ +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 != "" +} diff --git a/stackguardian_private_runner/azure/packer/main.tf b/stackguardian_private_runner/azure/packer/main.tf new file mode 100644 index 0000000..16180c4 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/main.tf @@ -0,0 +1,89 @@ +/*-------------------------------------------+ + | 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 | + +-------------------------------------------*/ +resource "null_resource" "packer_build" { + provisioner "local-exec" { + working_dir = path.module + command = "sh scripts/build_image.sh" + environment = { + PACKER_VERSION = var.packer_config.version + AZURE_LOCATION = var.azure_location + RESOURCE_GROUP_NAME = local.resource_group_name + VM_SIZE = var.vm_size + IMAGE_PUBLISHER = var.os.publisher + IMAGE_OFFER = var.os.offer + IMAGE_SKU = var.os.sku + IMAGE_VERSION = var.os.version + IMAGE_NAME_PREFIX = var.image_name_prefix + OS_FAMILY = local.os_family + SSH_USERNAME = local.ssh_username + UPDATE_OS = var.os.update_os_before_install + USER_SCRIPT = var.os.user_script + TERRAFORM_VERSION = var.terraform.primary_version + TERRAFORM_VERSIONS = join(" ", var.terraform.additional_versions) + OPENTOFU_VERSION = var.opentofu.primary_version + OPENTOFU_VERSIONS = join(" ", var.opentofu.additional_versions) + VNET_NAME = var.network.vnet_name + SUBNET_NAME = var.network.subnet_name + VNET_RESOURCE_GROUP_NAME = var.network.resource_group_name + } + } + + triggers = { + timestamp = timestamp() + } + + depends_on = [azurerm_resource_group.packer] +} + +/*-------------------------------------------+ + | Parse the Image ID from Packer Output | + +-------------------------------------------*/ +data "external" "packer_image_id" { + working_dir = path.module + program = [ + "sh", + "-c", + "grep 'artifact,0,id' packer_manifest.log | tail -1 | cut -d, -f6 | xargs -I{} echo '{\"image_id\": \"{}\"}'" + ] + + depends_on = [null_resource.packer_build] +} + +/*-------------------------------------------+ + | Conditional Image Cleanup Resource | + +-------------------------------------------*/ +resource "null_resource" "image_cleanup" { + count = var.packer_config.cleanup_images_on_destroy ? 1 : 0 + + # Store image information as triggers so they're available during destroy + triggers = { + image_id = data.external.packer_image_id.result["image_id"] + resource_group_name = local.resource_group_name + script_path = "${path.module}/scripts/cleanup_image.sh" + } + + provisioner "local-exec" { + when = destroy + command = <<-EOT + echo "Deleting Azure managed image: ${self.triggers.image_id}" + az image delete --ids "${self.triggers.image_id}" || true + EOT + } + + depends_on = [null_resource.packer_build] +} diff --git a/stackguardian_private_runner/azure/packer/outputs.tf b/stackguardian_private_runner/azure/packer/outputs.tf new file mode 100644 index 0000000..6ef10b0 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/outputs.tf @@ -0,0 +1,37 @@ +/*----------------------------------+ + | Packer Azure Image Builder | + +----------------------------------*/ +output "image_id" { + description = "The resource ID of the created Azure managed image" + value = data.external.packer_image_id.result["image_id"] +} + +output "image_info" { + description = "Comprehensive image information for tracking and cleanup" + value = { + image_id = data.external.packer_image_id.result["image_id"] + location = var.azure_location + resource_group_name = local.resource_group_name + os_family = local.os_family + os_sku = var.os.sku + timestamp = formatdate("YYYY-MM-DD-hhmm", timestamp()) + 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 ${data.external.packer_image_id.result["image_id"]}" + delete_image = "az image delete --ids ${data.external.packer_image_id.result["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/packer_manifest.log b/stackguardian_private_runner/azure/packer/packer_manifest.log new file mode 100644 index 0000000..9fd785a --- /dev/null +++ b/stackguardian_private_runner/azure/packer/packer_manifest.log @@ -0,0 +1,241 @@ +1765884676,,ui,say,==> azure-arm.this: Running builder ... +1765884676,,ui,say,==> azure-arm.this: Creating Azure Resource Manager (ARM) client ... +1765884677,,ui,say,==> azure-arm.this: ARM Client successfully created +1765884678,,ui,say,==> azure-arm.this: Getting source image id for the deployment ... +1765884678,,ui,say,==> azure-arm.this: -> SourceImageName: '/subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/providers/Microsoft.Compute/locations/westeurope/publishers/Canonical/ArtifactTypes/vmimage/offers/0001-com-ubuntu-server-jammy/skus/22_04-lts-gen2/versions/latest' +1765884679,,ui,say,==> azure-arm.this: Creating resource group ... +1765884679,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' +1765884679,,ui,say,==> azure-arm.this: -> Location : 'westeurope' +1765884679,,ui,say,==> azure-arm.this: -> Tags : +1765884679,,ui,say,==> azure-arm.this: ->> os : ubuntu +1765884679,,ui,say,==> azure-arm.this: ->> purpose : stackguardian-private-runner +1765884680,,ui,say,==> azure-arm.this: Validating deployment template ... +1765884680,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' +1765884680,,ui,say,==> azure-arm.this: -> DeploymentName : 'pkrdpbor1pewn1z' +1765884682,,ui,say,==> azure-arm.this: Deploying deployment template ... +1765884682,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' +1765884682,,ui,say,==> azure-arm.this: -> DeploymentName : 'pkrdpbor1pewn1z' +1765884736,,ui,say,==> azure-arm.this: Getting the VM's IP address ... +1765884736,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' +1765884736,,ui,say,==> azure-arm.this: -> PublicIPAddressName : 'pkripbor1pewn1z' +1765884736,,ui,say,==> azure-arm.this: -> NicName : 'pkrnibor1pewn1z' +1765884736,,ui,say,==> azure-arm.this: -> Network Connection : 'PublicEndpoint' +1765884736,,ui,say,==> azure-arm.this: -> IP Address : '20.126.140.143' +1765884736,,ui,say,==> azure-arm.this: Querying the machine's properties ... +1765884736,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' +1765884736,,ui,say,==> azure-arm.this: -> ComputeName : 'pkrvmbor1pewn1z' +1765884736,,ui,say,==> azure-arm.this: -> Managed OS Disk : '/subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/resourceGroups/pkr-Resource-Group-bor1pewn1z/providers/Microsoft.Compute/disks/pkrosbor1pewn1z' +1765884736,,ui,say,==> azure-arm.this: Querying the machine's additional disks properties ... +1765884736,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' +1765884736,,ui,say,==> azure-arm.this: -> ComputeName : 'pkrvmbor1pewn1z' +1765884737,,ui,say,==> azure-arm.this: Waiting for SSH to become available... +1765884741,,ui,say,==> azure-arm.this: Connected to SSH! +1765884741,,ui,say,==> azure-arm.this: Provisioning with shell script: scripts/setup.sh +1765884742,,ui,say,==> azure-arm.this: >> Waiting for cloud-init to complete.. +1765884743,,ui,say,==> azure-arm.this: status: done +1765884743,,ui,say,==> azure-arm.this: >> Cloud-init completed. +1765884743,,ui,say,==> azure-arm.this: >> Waiting for apt locks.. +1765884743,,ui,say,==> azure-arm.this: Hit:1 http://azure.archive.ubuntu.com/ubuntu jammy InRelease +1765884743,,ui,say,==> azure-arm.this: Get:2 http://azure.archive.ubuntu.com/ubuntu jammy-updates InRelease [128 kB] +1765884743,,ui,say,==> azure-arm.this: Get:3 http://azure.archive.ubuntu.com/ubuntu jammy-backports InRelease [127 kB] +1765884743,,ui,say,==> azure-arm.this: Get:4 http://azure.archive.ubuntu.com/ubuntu jammy-security InRelease [129 kB] +1765884744,,ui,say,==> azure-arm.this: Get:5 http://azure.archive.ubuntu.com/ubuntu jammy/universe amd64 Packages [14.1 MB] +1765884744,,ui,say,==> azure-arm.this: Get:6 http://azure.archive.ubuntu.com/ubuntu jammy/universe Translation-en [5652 kB] +1765884744,,ui,say,==> azure-arm.this: Get:7 http://azure.archive.ubuntu.com/ubuntu jammy/universe amd64 c-n-f Metadata [286 kB] +1765884744,,ui,say,==> azure-arm.this: Get:8 http://azure.archive.ubuntu.com/ubuntu jammy/multiverse amd64 Packages [217 kB] +1765884744,,ui,say,==> azure-arm.this: Get:9 http://azure.archive.ubuntu.com/ubuntu jammy/multiverse Translation-en [112 kB] +1765884744,,ui,say,==> azure-arm.this: Get:10 http://azure.archive.ubuntu.com/ubuntu jammy/multiverse amd64 c-n-f Metadata [8372 B] +1765884744,,ui,say,==> azure-arm.this: Get:11 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages [3160 kB] +1765884744,,ui,say,==> azure-arm.this: Get:12 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main Translation-en [484 kB] +1765884744,,ui,say,==> azure-arm.this: Get:13 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 c-n-f Metadata [19.0 kB] +1765884744,,ui,say,==> azure-arm.this: Get:14 http://azure.archive.ubuntu.com/ubuntu jammy-updates/universe amd64 Packages [1244 kB] +1765884744,,ui,say,==> azure-arm.this: Get:15 http://azure.archive.ubuntu.com/ubuntu jammy-updates/universe Translation-en [310 kB] +1765884744,,ui,say,==> azure-arm.this: Get:16 http://azure.archive.ubuntu.com/ubuntu jammy-updates/universe amd64 c-n-f Metadata [30.0 kB] +1765884744,,ui,say,==> azure-arm.this: Get:17 http://azure.archive.ubuntu.com/ubuntu jammy-updates/multiverse amd64 Packages [57.6 kB] +1765884744,,ui,say,==> azure-arm.this: Get:18 http://azure.archive.ubuntu.com/ubuntu jammy-updates/multiverse Translation-en [13.2 kB] +1765884744,,ui,say,==> azure-arm.this: Get:19 http://azure.archive.ubuntu.com/ubuntu jammy-updates/multiverse amd64 c-n-f Metadata [600 B] +1765884744,,ui,say,==> azure-arm.this: Get:20 http://azure.archive.ubuntu.com/ubuntu jammy-backports/main amd64 Packages [69.4 kB] +1765884744,,ui,say,==> azure-arm.this: Get:21 http://azure.archive.ubuntu.com/ubuntu jammy-backports/main Translation-en [11.5 kB] +1765884744,,ui,say,==> azure-arm.this: Get:22 http://azure.archive.ubuntu.com/ubuntu jammy-backports/main amd64 c-n-f Metadata [412 B] +1765884744,,ui,say,==> azure-arm.this: Get:23 http://azure.archive.ubuntu.com/ubuntu jammy-backports/restricted amd64 c-n-f Metadata [116 B] +1765884744,,ui,say,==> azure-arm.this: Get:24 http://azure.archive.ubuntu.com/ubuntu jammy-backports/universe amd64 Packages [30.1 kB] +1765884744,,ui,say,==> azure-arm.this: Get:25 http://azure.archive.ubuntu.com/ubuntu jammy-backports/universe Translation-en [16.6 kB] +1765884744,,ui,say,==> azure-arm.this: Get:26 http://azure.archive.ubuntu.com/ubuntu jammy-backports/universe amd64 c-n-f Metadata [672 B] +1765884744,,ui,say,==> azure-arm.this: Get:27 http://azure.archive.ubuntu.com/ubuntu jammy-backports/multiverse amd64 c-n-f Metadata [116 B] +1765884744,,ui,say,==> azure-arm.this: Get:28 http://azure.archive.ubuntu.com/ubuntu jammy-security/main amd64 Packages [2899 kB] +1765884744,,ui,say,==> azure-arm.this: Get:29 http://azure.archive.ubuntu.com/ubuntu jammy-security/main Translation-en [417 kB] +1765884744,,ui,say,==> azure-arm.this: Get:30 http://azure.archive.ubuntu.com/ubuntu jammy-security/main amd64 c-n-f Metadata [14.0 kB] +1765884744,,ui,say,==> azure-arm.this: Get:31 http://azure.archive.ubuntu.com/ubuntu jammy-security/restricted amd64 Packages [4883 kB] +1765884745,,ui,say,==> azure-arm.this: Get:32 http://azure.archive.ubuntu.com/ubuntu jammy-security/restricted Translation-en [917 kB] +1765884745,,ui,say,==> azure-arm.this: Get:33 http://azure.archive.ubuntu.com/ubuntu jammy-security/universe amd64 Packages [1007 kB] +1765884745,,ui,say,==> azure-arm.this: Get:34 http://azure.archive.ubuntu.com/ubuntu jammy-security/universe Translation-en [221 kB] +1765884745,,ui,say,==> azure-arm.this: Get:35 http://azure.archive.ubuntu.com/ubuntu jammy-security/universe amd64 c-n-f Metadata [22.3 kB] +1765884745,,ui,say,==> azure-arm.this: Get:36 http://azure.archive.ubuntu.com/ubuntu jammy-security/multiverse amd64 Packages [50.5 kB] +1765884745,,ui,say,==> azure-arm.this: Get:37 http://azure.archive.ubuntu.com/ubuntu jammy-security/multiverse Translation-en [10.2 kB] +1765884745,,ui,say,==> azure-arm.this: Get:38 http://azure.archive.ubuntu.com/ubuntu jammy-security/multiverse amd64 c-n-f Metadata [376 B] +1765884764,,ui,say,==> azure-arm.this: Fetched 36.6 MB in 7s (4949 kB/s) +1765884766,,ui,say,==> azure-arm.this: Reading package lists... +1765884766,,ui,say,==> azure-arm.this: Reading package lists... +1765884767,,ui,say,==> azure-arm.this: Building dependency tree... +1765884767,,ui,say,==> azure-arm.this: Reading state information... +1765884767,,ui,say,==> azure-arm.this: cron is already the newest version (3.0pl1-137ubuntu3). +1765884767,,ui,say,==> azure-arm.this: cron set to manually installed. +1765884767,,ui,say,==> azure-arm.this: wget is already the newest version (1.21.2-2ubuntu1.1). +1765884767,,ui,say,==> azure-arm.this: wget set to manually installed. +1765884767,,ui,say,==> azure-arm.this: The following additional packages will be installed: +1765884767,,ui,say,==> azure-arm.this: bridge-utils containerd dns-root-data dnsmasq-base pigz runc ubuntu-fan +1765884767,,ui,say,==> azure-arm.this: Suggested packages: +1765884767,,ui,say,==> azure-arm.this: ifupdown aufs-tools cgroupfs-mount | cgroup-lite debootstrap docker-buildx +1765884767,,ui,say,==> azure-arm.this: docker-compose-v2 docker-doc rinse zfs-fuse | zfsutils zip +1765884767,,ui,say,==> azure-arm.this: The following NEW packages will be installed: +1765884767,,ui,say,==> azure-arm.this: bridge-utils containerd dns-root-data dnsmasq-base docker.io pigz runc +1765884767,,ui,say,==> azure-arm.this: ubuntu-fan unzip +1765884767,,ui,say,==> azure-arm.this: 0 upgraded%!(PACKER_COMMA) 9 newly installed%!(PACKER_COMMA) 0 to remove and 0 not upgraded. +1765884767,,ui,say,==> azure-arm.this: Need to get 76.5 MB of archives. +1765884767,,ui,say,==> azure-arm.this: After this operation%!(PACKER_COMMA) 289 MB of additional disk space will be used. +1765884767,,ui,say,==> azure-arm.this: Get:1 http://azure.archive.ubuntu.com/ubuntu jammy/universe amd64 pigz amd64 2.6-1 [63.6 kB] +1765884767,,ui,say,==> azure-arm.this: Get:2 http://azure.archive.ubuntu.com/ubuntu jammy/main amd64 bridge-utils amd64 1.7-1ubuntu3 [34.4 kB] +1765884767,,ui,say,==> azure-arm.this: Get:3 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 runc amd64 1.3.3-0ubuntu1~22.04.3 [8857 kB] +1765884767,,ui,say,==> azure-arm.this: Get:4 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 containerd amd64 1.7.28-0ubuntu1~22.04.1 [38.5 MB] +1765884770,,ui,say,==> azure-arm.this: Get:5 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 dns-root-data all 2024071801~ubuntu0.22.04.1 [6132 B] +1765884770,,ui,say,==> azure-arm.this: Get:6 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 dnsmasq-base amd64 2.90-0ubuntu0.22.04.1 [374 kB] +1765884770,,ui,say,==> azure-arm.this: Get:7 http://azure.archive.ubuntu.com/ubuntu jammy-updates/universe amd64 docker.io amd64 28.2.2-0ubuntu1~22.04.1 [28.4 MB] +1765884771,,ui,say,==> azure-arm.this: Get:8 http://azure.archive.ubuntu.com/ubuntu jammy/universe amd64 ubuntu-fan all 0.12.16 [35.2 kB] +1765884771,,ui,say,==> azure-arm.this: Get:9 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 unzip amd64 6.0-26ubuntu3.2 [175 kB] +1765884772,,ui,error,==> azure-arm.this: debconf: unable to initialize frontend: Dialog +1765884772,,ui,error,==> azure-arm.this: debconf: (Dialog frontend will not work on a dumb terminal%!(PACKER_COMMA) an emacs shell buffer%!(PACKER_COMMA) or without a controlling terminal.) +1765884772,,ui,error,==> azure-arm.this: debconf: falling back to frontend: Readline +1765884772,,ui,error,==> azure-arm.this: debconf: unable to initialize frontend: Readline +1765884772,,ui,error,==> azure-arm.this: debconf: (This frontend requires a controlling tty.) +1765884772,,ui,error,==> azure-arm.this: debconf: falling back to frontend: Teletype +1765884772,,ui,error,==> azure-arm.this: dpkg-preconfigure: unable to re-open stdin: +1765884772,,ui,say,==> azure-arm.this: Fetched 76.5 MB in 4s (18.4 MB/s) +1765884772,,ui,say,==> azure-arm.this: Selecting previously unselected package pigz. +1765884776,,ui,say,==> azure-arm.this: (Reading database ... 62847 files and directories currently installed.) +1765884776,,ui,say,==> azure-arm.this: Preparing to unpack .../0-pigz_2.6-1_amd64.deb ... +1765884776,,ui,say,==> azure-arm.this: Unpacking pigz (2.6-1) ... +1765884776,,ui,say,==> azure-arm.this: Selecting previously unselected package bridge-utils. +1765884776,,ui,say,==> azure-arm.this: Preparing to unpack .../1-bridge-utils_1.7-1ubuntu3_amd64.deb ... +1765884776,,ui,say,==> azure-arm.this: Unpacking bridge-utils (1.7-1ubuntu3) ... +1765884777,,ui,say,==> azure-arm.this: Selecting previously unselected package runc. +1765884777,,ui,say,==> azure-arm.this: Preparing to unpack .../2-runc_1.3.3-0ubuntu1~22.04.3_amd64.deb ... +1765884777,,ui,say,==> azure-arm.this: Unpacking runc (1.3.3-0ubuntu1~22.04.3) ... +1765884777,,ui,say,==> azure-arm.this: Selecting previously unselected package containerd. +1765884777,,ui,say,==> azure-arm.this: Preparing to unpack .../3-containerd_1.7.28-0ubuntu1~22.04.1_amd64.deb ... +1765884777,,ui,say,==> azure-arm.this: Unpacking containerd (1.7.28-0ubuntu1~22.04.1) ... +1765884780,,ui,say,==> azure-arm.this: Selecting previously unselected package dns-root-data. +1765884780,,ui,say,==> azure-arm.this: Preparing to unpack .../4-dns-root-data_2024071801~ubuntu0.22.04.1_all.deb ... +1765884780,,ui,say,==> azure-arm.this: Unpacking dns-root-data (2024071801~ubuntu0.22.04.1) ... +1765884780,,ui,say,==> azure-arm.this: Selecting previously unselected package dnsmasq-base. +1765884780,,ui,say,==> azure-arm.this: Preparing to unpack .../5-dnsmasq-base_2.90-0ubuntu0.22.04.1_amd64.deb ... +1765884780,,ui,say,==> azure-arm.this: Unpacking dnsmasq-base (2.90-0ubuntu0.22.04.1) ... +1765884781,,ui,say,==> azure-arm.this: Selecting previously unselected package docker.io. +1765884781,,ui,say,==> azure-arm.this: Preparing to unpack .../6-docker.io_28.2.2-0ubuntu1~22.04.1_amd64.deb ... +1765884781,,ui,say,==> azure-arm.this: Unpacking docker.io (28.2.2-0ubuntu1~22.04.1) ... +1765884783,,ui,say,==> azure-arm.this: Selecting previously unselected package ubuntu-fan. +1765884783,,ui,say,==> azure-arm.this: Preparing to unpack .../7-ubuntu-fan_0.12.16_all.deb ... +1765884783,,ui,say,==> azure-arm.this: Unpacking ubuntu-fan (0.12.16) ... +1765884783,,ui,say,==> azure-arm.this: Selecting previously unselected package unzip. +1765884783,,ui,say,==> azure-arm.this: Preparing to unpack .../8-unzip_6.0-26ubuntu3.2_amd64.deb ... +1765884783,,ui,say,==> azure-arm.this: Unpacking unzip (6.0-26ubuntu3.2) ... +1765884784,,ui,say,==> azure-arm.this: Setting up unzip (6.0-26ubuntu3.2) ... +1765884784,,ui,say,==> azure-arm.this: Setting up dnsmasq-base (2.90-0ubuntu0.22.04.1) ... +1765884784,,ui,say,==> azure-arm.this: Setting up runc (1.3.3-0ubuntu1~22.04.3) ... +1765884785,,ui,say,==> azure-arm.this: Setting up dns-root-data (2024071801~ubuntu0.22.04.1) ... +1765884785,,ui,say,==> azure-arm.this: Setting up bridge-utils (1.7-1ubuntu3) ... +1765884785,,ui,say,==> azure-arm.this: debconf: unable to initialize frontend: Dialog +1765884785,,ui,say,==> azure-arm.this: debconf: (Dialog frontend will not work on a dumb terminal%!(PACKER_COMMA) an emacs shell buffer%!(PACKER_COMMA) or without a controlling terminal.) +1765884785,,ui,say,==> azure-arm.this: debconf: falling back to frontend: Readline +1765884785,,ui,say,==> azure-arm.this: Setting up pigz (2.6-1) ... +1765884785,,ui,say,==> azure-arm.this: Setting up containerd (1.7.28-0ubuntu1~22.04.1) ... +1765884785,,ui,say,==> azure-arm.this: Created symlink /etc/systemd/system/multi-user.target.wants/containerd.service → /lib/systemd/system/containerd.service. +1765884787,,ui,say,==> azure-arm.this: Setting up ubuntu-fan (0.12.16) ... +1765884787,,ui,say,==> azure-arm.this: Created symlink /etc/systemd/system/multi-user.target.wants/ubuntu-fan.service → /lib/systemd/system/ubuntu-fan.service. +1765884788,,ui,say,==> azure-arm.this: Setting up docker.io (28.2.2-0ubuntu1~22.04.1) ... +1765884788,,ui,say,==> azure-arm.this: debconf: unable to initialize frontend: Dialog +1765884788,,ui,say,==> azure-arm.this: debconf: (Dialog frontend will not work on a dumb terminal%!(PACKER_COMMA) an emacs shell buffer%!(PACKER_COMMA) or without a controlling terminal.) +1765884788,,ui,say,==> azure-arm.this: debconf: falling back to frontend: Readline +1765884788,,ui,say,==> azure-arm.this: Adding group `docker' (GID 123) ... +1765884788,,ui,say,==> azure-arm.this: Done. +1765884789,,ui,say,==> azure-arm.this: Created symlink /etc/systemd/system/multi-user.target.wants/docker.service → /lib/systemd/system/docker.service. +1765884789,,ui,say,==> azure-arm.this: Created symlink /etc/systemd/system/sockets.target.wants/docker.socket → /lib/systemd/system/docker.socket. +1765884793,,ui,say,==> azure-arm.this: Processing triggers for dbus (1.12.20-2ubuntu4.1) ... +1765884793,,ui,say,==> azure-arm.this: Processing triggers for man-db (2.10.2-1) ... +1765884798,,ui,say,==> azure-arm.this: +1765884798,,ui,say,==> azure-arm.this: Running kernel seems to be up-to-date. +1765884798,,ui,say,==> azure-arm.this: +1765884798,,ui,say,==> azure-arm.this: No services need to be restarted. +1765884798,,ui,say,==> azure-arm.this: +1765884798,,ui,say,==> azure-arm.this: No containers need to be restarted. +1765884798,,ui,say,==> azure-arm.this: +1765884798,,ui,say,==> azure-arm.this: No user sessions are running outdated binaries. +1765884798,,ui,say,==> azure-arm.this: +1765884798,,ui,say,==> azure-arm.this: No VM guests are running outdated hypervisor (qemu) binaries on this host. +1765884800,,ui,say,==> azure-arm.this: >> Enabling cron.. +1765884800,,ui,error,==> azure-arm.this: Synchronizing state of cron.service with SysV service script with /lib/systemd/systemd-sysv-install. +1765884800,,ui,error,==> azure-arm.this: Executing: /lib/systemd/systemd-sysv-install enable cron +1765884801,,ui,say,==> azure-arm.this: >> Enabling docker.. +1765884801,,ui,say,==> azure-arm.this: >> Adding ubuntu to the docker group.. +1765884801,,ui,say,==> azure-arm.this: ## ---------- +1765884801,,ui,say,==> azure-arm.this: >> Fetching latest jq version.. +1765884802,,ui,say,==> azure-arm.this: >> Installing jq.. +1765884802,,ui,say,==> azure-arm.this: >> Downloading https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-linux-amd64.. +1765884802,,ui,say,==> azure-arm.this: >> Saved to jq-linux-amd64. +1765884802,,ui,say,==> azure-arm.this: >> Installed to /usr/bin/jq. +1765884802,,ui,say,==> azure-arm.this: >> Version: jq-1.8.1 +1765884802,,ui,say,==> azure-arm.this: ## ---------- +1765884802,,ui,say,==> azure-arm.this: >> Installing Terraform v1.5.7.. +1765884802,,ui,say,==> azure-arm.this: >> Downloading https://releases.hashicorp.com/terraform/1.5.7/terraform_1.5.7_linux_amd64.zip.. +1765884802,,ui,say,==> azure-arm.this: >> Saved to terraform_1.5.7_linux_amd64.zip. +1765884802,,ui,say,==> azure-arm.this: Archive: terraform_1.5.7_linux_amd64.zip +1765884803,,ui,say,==> azure-arm.this: inflating: terraform +1765884803,,ui,say,==> azure-arm.this: >> Installed to /usr/bin/terraform. +1765884803,,ui,say,==> azure-arm.this: ## ---------- +1765884803,,ui,say,==> azure-arm.this: >> Installing sg-runner.. +1765884803,,ui,say,==> azure-arm.this: >> Downloading https://api.github.com/repos/StackGuardian/sg-runner/tarball/v2.2.1.. +1765884804,,ui,say,==> azure-arm.this: >> Saved to runner.tar.gz. +1765884804,,ui,say,==> azure-arm.this: >> Installed to /usr/bin/sg-runner. +1765884804,,ui,say,==> azure-arm.this: ## ---------- +1765884804,,ui,say,==> azure-arm.this: >> Cleaning up image setup.. +1765884804,,ui,say,==> azure-arm.this: Removed temporary directory: /tmp/tmp.ogAPwQRMS1 +1765884804,,ui,say,==> azure-arm.this: Removed temporary directory: /tmp/tmp.uoOadEB7B8 +1765884804,,ui,say,==> azure-arm.this: Removed temporary directory: /tmp/tmp.3hNN5m4p7t +1765884804,,ui,say,==> azure-arm.this: Provisioning with shell script: /var/folders/vl/chc94nw176g44f98ptf4xkmh0000gn/T/packer-shell2691571155 +1765884806,,ui,error,==> azure-arm.this: /usr/sbin/waagent:27: DeprecationWarning: the imp module is deprecated in favour of importlib and slated for removal in Python 3.12; see the module's documentation for alternative uses +1765884806,,ui,error,==> azure-arm.this: import imp +1765884808,,ui,say,==> azure-arm.this: WARNING! The waagent service will be stopped. +1765884808,,ui,say,==> azure-arm.this: WARNING! Cached DHCP leases will be deleted. +1765884808,,ui,say,==> azure-arm.this: WARNING! root password will be disabled. You will not be able to login as root. +1765884808,,ui,say,==> azure-arm.this: WARNING! /etc/resolv.conf will NOT be removed%!(PACKER_COMMA) this is a behavior change to earlier versions of Ubuntu. +1765884808,,ui,say,==> azure-arm.this: WARNING! ubuntu account and entire home directory will be deleted. +1765884808,,ui,say,==> azure-arm.this: Powering off machine ... +1765884808,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' +1765884808,,ui,say,==> azure-arm.this: -> ComputeName : 'pkrvmbor1pewn1z' +1765884849,,ui,say,==> azure-arm.this: -> Compute ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' +1765884849,,ui,say,==> azure-arm.this: -> Compute Name : 'pkrvmbor1pewn1z' +1765884849,,ui,say,==> azure-arm.this: -> Compute Location : 'westeurope' +1765884849,,ui,say,==> azure-arm.this: Generalizing machine ... +1765884850,,ui,say,==> azure-arm.this: Capturing image ... +1765884850,,ui,say,==> azure-arm.this: -> Image ResourceGroupName : 'adis-runner' +1765884850,,ui,say,==> azure-arm.this: -> Image Name : 'sg-runner-ubuntu-22_04-lts-gen2-1765884676' +1765884850,,ui,say,==> azure-arm.this: -> Image Location : 'westeurope' +1765884861,,ui,say,==> azure-arm.this: \n==> azure-arm.this: Deleting Virtual Machine deployment and its attached resources... +1765884872,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Compute/virtualMachines : 'pkrvmbor1pewn1z' +1765884884,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Network/networkInterfaces : 'pkrnibor1pewn1z' +1765884894,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Network/publicIPAddresses : 'pkripbor1pewn1z' +1765884895,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Network/virtualNetworks : 'pkrvnbor1pewn1z' +1765884898,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Network/networkSecurityGroups : 'pkrsgbor1pewn1z' +1765884909,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Compute/disks : '/subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/resourceGroups/pkr-Resource-Group-bor1pewn1z/providers/Microsoft.Compute/disks/pkrosbor1pewn1z' +1765884909,,ui,say,==> azure-arm.this: Removing the created Deployment object: 'pkrdpbor1pewn1z' +1765884922,,ui,say,==> azure-arm.this: \n==> azure-arm.this: Cleanup requested%!(PACKER_COMMA) deleting resource group ... +1765884933,,ui,say,==> azure-arm.this: Resource group has been deleted. +1765884933,,ui,say,Build 'azure-arm.this' finished after 4 minutes 16 seconds. +1765884933,,ui,say,\n==> Wait completed after 4 minutes 16 seconds +1765884933,,ui,say,\n==> Builds finished. The artifacts of successful builds are: +1765884933,azure-arm.this,artifact-count,1 +1765884933,azure-arm.this,artifact,0,builder-id,Azure.ResourceManagement.VMImage +1765884933,azure-arm.this,artifact,0,id,/subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/resourceGroups/adis-runner/providers/Microsoft.Compute/images/sg-runner-ubuntu-22_04-lts-gen2-1765884676 +1765884933,azure-arm.this,artifact,0,string,Azure.ResourceManagement.VMImage:\n\nOSType: Linux\nManagedImageResourceGroupName: adis-runner\nManagedImageName: sg-runner-ubuntu-22_04-lts-gen2-1765884676\nManagedImageId: /subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/resourceGroups/adis-runner/providers/Microsoft.Compute/images/sg-runner-ubuntu-22_04-lts-gen2-1765884676\nManagedImageLocation: westeurope\n +1765884933,azure-arm.this,artifact,0,files-count,0 +1765884933,azure-arm.this,artifact,0,end +1765884933,,ui,say,--> azure-arm.this: Azure.ResourceManagement.VMImage:\n\nOSType: Linux\nManagedImageResourceGroupName: adis-runner\nManagedImageName: sg-runner-ubuntu-22_04-lts-gen2-1765884676\nManagedImageId: /subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/resourceGroups/adis-runner/providers/Microsoft.Compute/images/sg-runner-ubuntu-22_04-lts-gen2-1765884676\nManagedImageLocation: westeurope\n diff --git a/stackguardian_private_runner/azure/packer/provider.tf b/stackguardian_private_runner/azure/packer/provider.tf new file mode 100644 index 0000000..64dc031 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/provider.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0" + } + null = { + source = "hashicorp/null" + version = ">= 3.0" + } + external = { + source = "hashicorp/external" + version = ">= 2.0" + } + } +} diff --git a/stackguardian_private_runner/azure/packer/scripts/build_image.sh b/stackguardian_private_runner/azure/packer/scripts/build_image.sh new file mode 100644 index 0000000..f15f968 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/scripts/build_image.sh @@ -0,0 +1,128 @@ +#!/bin/sh + +set -e + +trap _cleanup EXIT INT TERM + +PACKER_EXECUTABLE="" +WORKING_DIR="" +TEMP_DIRS="" + +_cleanup() { #{{{ + echo "## ----------" + echo "Cleaning up Packer build.." + + if [ -n "$TEMP_DIRS" ]; then + for temp_dir in $TEMP_DIRS; do + if [ -d "$temp_dir" ]; then + rm -rf "$temp_dir" + echo "Removed temporary directory: $temp_dir" + fi + done + fi + + if [ -d "$WORKING_DIR" ]; then + rm -rf "$WORKING_DIR" + echo "Removed temporary directory: $WORKING_DIR" + fi + echo "## ----------" +} +#}}}: _cleanup + +_detect_arch() { #{{{ + machine="$(uname -m)" + + case "$machine" in + 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:]' +} +#}}}: _detect_os + +_wget_wrapper() { #{{{ + url="$1" + output_file="${2:-"${url##*/}"}" + + echo ">> Downloading ${url}.." + wget -q "$url" -O "$output_file" + echo ">> Saved to ${output_file}." +} +#}}}: _wget_wrapper + +_mktemp_directory() { #{{{ + WORKING_DIR="$(mktemp -d)" + if [ -n "$TEMP_DIRS" ]; then + TEMP_DIRS="$TEMP_DIRS $WORKING_DIR" + else + TEMP_DIRS="$WORKING_DIR" + fi +} +#}}}: _mktemp_directory + +_download_packer() { #{{{ + version="$PACKER_VERSION" + root_dir="$(pwd)" + + os_arch="$(_detect_arch)" + os_type="$(_detect_os)" + zip_name="packer_${version}_${os_type}_${os_arch}.zip" + base_url="https://releases.hashicorp.com/packer/${version}" + + download_url="${base_url}/${zip_name}" + + echo "## ----------" + echo ">> Downloading Packer v${version}.." + _mktemp_directory && cd "$WORKING_DIR" + + if _wget_wrapper "$download_url"; then + unzip "$zip_name" + PACKER_EXECUTABLE="$(realpath packer)" + cd "$root_dir" + + echo ">> Downloaded to ${PACKER_EXECUTABLE}." + echo "## ----------" + else + echo "ERROR: Failed to download from: $download_url" + exit 1 + fi +} +#}}}: _download_packer + +main() { #{{{ + _download_packer + + $PACKER_EXECUTABLE init ./image.pkr.hcl + $PACKER_EXECUTABLE build \ + -var "azure_location=$AZURE_LOCATION" \ + -var "resource_group_name=$RESOURCE_GROUP_NAME" \ + -var "vm_size=$VM_SIZE" \ + -var "image_publisher=$IMAGE_PUBLISHER" \ + -var "image_offer=$IMAGE_OFFER" \ + -var "image_sku=$IMAGE_SKU" \ + -var "image_version=$IMAGE_VERSION" \ + -var "image_name_prefix=$IMAGE_NAME_PREFIX" \ + -var "os_family=$OS_FAMILY" \ + -var "ssh_username=$SSH_USERNAME" \ + -var "update_os_before_install=$UPDATE_OS" \ + -var "terraform_version=$TERRAFORM_VERSION" \ + -var "terraform_versions=$TERRAFORM_VERSIONS" \ + -var "opentofu_version=$OPENTOFU_VERSION" \ + -var "opentofu_versions=$OPENTOFU_VERSIONS" \ + -var "user_script=$USER_SCRIPT" \ + -var "vnet_name=$VNET_NAME" \ + -var "subnet_name=$SUBNET_NAME" \ + -var "vnet_resource_group_name=$VNET_RESOURCE_GROUP_NAME" \ + -machine-readable \ + ./image.pkr.hcl | tee packer_manifest.log +} +#}}}: main + +main "$@" diff --git a/stackguardian_private_runner/azure/packer/scripts/setup.sh b/stackguardian_private_runner/azure/packer/scripts/setup.sh new file mode 100644 index 0000000..9b13565 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/scripts/setup.sh @@ -0,0 +1,343 @@ +#!/bin/sh + +set -e + +trap _cleanup EXIT INT TERM + +OS_ARCH="" +OS_TYPE="" + +WORKING_DIR="" +TEMP_DIRS="" + +_cleanup() { #{{{ + echo "## ----------" + echo ">> Cleaning up image setup.." + + if [ -n "$TEMP_DIRS" ]; then + for temp_dir in $TEMP_DIRS; do + if [ -d "$temp_dir" ]; then + rm -rf "$temp_dir" + echo "Removed temporary directory: $temp_dir" + fi + done + fi + + if [ -d "$WORKING_DIR" ]; then + rm -rf "$WORKING_DIR" + echo "Removed temporary directory: $WORKING_DIR" + fi +} +#}}}: _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 + sudo apt-get update + fi + sudo apt-get install -y \ + docker.io \ + unzip \ + cron \ + wget +} +#}}}: _apt_dependencies + +_dnf_dependencies() { #{{{ + if [ "$UPDATE_OS" = "true" ]; then + sudo dnf update -y + fi + sudo dnf install -y \ + dnf-plugins-core \ + unzip \ + cronie \ + wget + + sudo dnf config-manager \ + --add-repo "https://download.docker.com/linux/rhel/docker-ce.repo" + sudo dnf install -y \ + docker-ce \ + docker-ce-cli \ + containerd.io +} +#}}}: _dnf_dependencies + +_systemctl_enable() { #{{{ + for service in "$@"; do + echo ">> Enabling $service.." + sudo systemctl enable --now "$service" + done +} +#}}}: _systemctl_enable + +_usermod_add_to_group() { #{{{ + group="$1" + user="$2" + + echo ">> Adding ${user} to the ${group} group.." + sudo usermod -aG "$group" "$user" || true +} +#}}}: _usermod_add_to_group + +_wget_wrapper() { #{{{ + url="$1" + output_file="${2:-"${url##*/}"}" + + echo ">> Downloading ${url}.." + wget -q "$url" -O "$output_file" + echo ">> Saved to ${output_file}." +} +#}}}: _wget_wrapper + +_mktemp_directory() { #{{{ + WORKING_DIR="$(mktemp -d)" + if [ -n "$TEMP_DIRS" ]; then + TEMP_DIRS="$TEMP_DIRS $WORKING_DIR" + else + TEMP_DIRS="$WORKING_DIR" + fi +} +#}}}: _mktemp_directory + +_detect_arch() { #{{{ + machine="$(uname -m)" + + case "$machine" in + 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:]' +} +#}}}: _detect_os + +_get_latest_github_release() { #{{{ + repo="$1" + 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.*' +} +#}}}: _get_latest_github_release + +_install_jq() { #{{{ + os_arch="$OS_ARCH" + os_type="$OS_TYPE" + file_name="jq-${os_type}-${os_arch}" + + echo "## ----------" + echo ">> Fetching latest jq version.." + download_url="$(_get_latest_github_release "jqlang/jq" "$file_name")" + + if [ -z "$download_url" ]; then + echo "ERROR: Failed to fetch latest jq version" + exit 1 + fi + + echo ">> Installing jq.." + _mktemp_directory && cd "$WORKING_DIR" + + if _wget_wrapper "$download_url"; then + sudo chmod +x "$file_name" + sudo mv "$file_name" "/usr/bin/jq" + + echo ">> Installed to $(which jq)." + echo ">> Version: $(jq --version)" + else + echo "ERROR: Failed to download jq from: $download_url" + exit 1 + fi +} +#}}}: _install_jq + +_install_terraform() { #{{{ + if [ -z "$TERRAFORM_VERSION" ]; then + return + fi + + version="${1:-$TERRAFORM_VERSION}" + target_name="terraform" + + if [ -n "$1" ]; then + target_name="terraform$version" + fi + + os_arch="$OS_ARCH" + os_type="$OS_TYPE" + zip_name="terraform_${version}_${os_type}_${os_arch}.zip" + base_url="https://releases.hashicorp.com/terraform/${version}" + + download_url="${base_url}/${zip_name}" + + echo "## ----------" + echo ">> Installing Terraform v${version}.." + _mktemp_directory && cd "$WORKING_DIR" + + if _wget_wrapper "$download_url"; then + unzip "$zip_name" + sudo mv terraform "/usr/bin/$target_name" + + echo ">> Installed to $(which "$target_name")." + else + echo "ERROR: Failed to download Terraform v$version from: $download_url" + exit 1 + fi +} +#}}}: _install_terraform + +_install_terraform_versions() { #{{{ + versions_list="$TERRAFORM_VERSIONS" + + for version in $versions_list; do + _install_terraform "$version" + done +} +#}}}: _install_terraform_versions + +_install_opentofu() { #{{{ + if [ -z "$OPENTOFU_VERSION" ]; then + return + fi + + version="${1:-$OPENTOFU_VERSION}" + target_name="tofu" + + if [ -n "$1" ]; then + target_name="tofu$version" + fi + + os_arch="$OS_ARCH" + os_type="$OS_TYPE" + zip_name="tofu_${version}_${os_type}_${os_arch}.zip" + base_url="https://github.com/opentofu/opentofu/releases/download/v$version" + + download_url="${base_url}/${zip_name}" + + echo "## ----------" + echo ">> Installing OpenTofu v${version}.." + _mktemp_directory && cd "$WORKING_DIR" + + if _wget_wrapper "$download_url"; then + unzip "$zip_name" + sudo mv tofu "/usr/bin/$target_name" + + echo ">> Installed to $(which "$target_name")." + else + echo "ERROR: Failed to download OpenTofu v$version from: $download_url" + exit 1 + fi +} +#}}}: _install_opentofu + +_install_opentofu_versions() { #{{{ + versions_list="$OPENTOFU_VERSIONS" + + for version in $versions_list; do + _install_opentofu "$version" + done +} +#}}}: _install_opentofu_versions + +_install_sg_runner() { #{{{ + url="$(wget -qO- "https://api.github.com/repos/stackguardian/sg-runner/releases/latest" | jq -r '.tarball_url')" + runner_archive="runner.tar.gz" + + echo "## ----------" + echo ">> Installing sg-runner.." + + _mktemp_directory && cd "$WORKING_DIR" + + if _wget_wrapper "$url" "$runner_archive"; then + tar -xf "$runner_archive" + sudo cp -rf StackGuardian-sg-runner*/main.sh /usr/bin/sg-runner + + echo ">> Installed to $(which sg-runner)." + else + echo "ERROR: Failed to download from: $url" + exit 1 + fi +} +#}}}: _install_sg_runner + +_user_script_wrapper() { #{{{ + script="$USER_SCRIPT" + + if [ -n "$script" ]; then + echo ">> Preparing user environment.." + _mktemp_directory && cd "$WORKING_DIR" + + if ! sh -c "$script"; then + echo "ERROR: Script execution failed." + exit 1 + fi + + echo ">> User script completed successfully!" + fi +} +#}}}: _user_script_wrapper + +_handle_os_package_installation() { #{{{ + if [ "$OS_FAMILY" = "ubuntu" ]; then + _apt_dependencies + _systemctl_enable "cron" "docker" + _usermod_add_to_group "docker" "ubuntu" + elif [ "$OS_FAMILY" = "rhel" ]; then + _dnf_dependencies + _systemctl_enable "crond" "docker" + _usermod_add_to_group "docker" "azureuser" + else + echo "ERROR: Unsupported OS_FAMILY: $OS_FAMILY" + exit 1 + fi + +} +#}}}: _handle_os_package_installation + +main() { #{{{ + OS_ARCH="$(_detect_arch)" + OS_TYPE="$(_detect_os)" + + _handle_os_package_installation + + _install_jq + + _install_terraform + _install_terraform_versions + + _install_opentofu + _install_opentofu_versions + + _install_sg_runner + + _user_script_wrapper +} +#}}}: main + +main "$@" diff --git a/stackguardian_private_runner/azure/packer/variables.tf b/stackguardian_private_runner/azure/packer/variables.tf new file mode 100644 index 0000000..10b782d --- /dev/null +++ b/stackguardian_private_runner/azure/packer/variables.tf @@ -0,0 +1,116 @@ +/*-------------------+ + | General Variables | + +-------------------*/ +variable "azure_location" { + description = "The target Azure region to build the Private Runner image" + type = string + default = "westeurope" +} + +variable "resource_group_name" { + description = "The name of the resource group where the image will be stored" + type = string +} + +variable "create_resource_group" { + description = "Whether to create the resource group (if false, must already exist)" + type = bool + default = false +} + +variable "vm_size" { + description = "The Azure VM size for the Packer build process (min 2 vCPU, 4GB RAM recommended)" + type = string + default = "Standard_D2s_v3" +} + +/*----------------------------+ + | Image Build Network Settings | + +----------------------------*/ +variable "network" { + description = "Network configuration for the Packer build instance. Leave empty to let Packer create temporary networking." + type = object({ + vnet_name = optional(string, "") + subnet_name = optional(string, "") + resource_group_name = optional(string, "") + }) + default = {} +} + +/*---------------------------+ + | Operating System Settings | + +---------------------------*/ +variable "os" { + description = "Operating system configuration for the image" + type = object({ + publisher = string + offer = string + sku = string + version = optional(string, "latest") + update_os_before_install = optional(bool, true) + user_script = optional(string, "") + }) + default = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + version = "latest" + update_os_before_install = true + } + + validation { + condition = contains(["Canonical", "RedHat"], var.os.publisher) + error_message = "The os.publisher must be one of 'Canonical' or 'RedHat'." + } +} + +/*----------------------------------+ + | Packer Configuration Variables | + +----------------------------------*/ +variable "packer_config" { + description = "Packer build configuration" + type = object({ + version = optional(string, "1.14.1") + cleanup_images_on_destroy = optional(bool, true) + }) + default = { + version = "1.14.1" + cleanup_images_on_destroy = true + } +} + +variable "image_name_prefix" { + description = "Prefix for the generated image name" + type = string + default = "sg-runner" +} + +/*---------------------------------+ + | Terraform Installation Settings | + +---------------------------------*/ +variable "terraform" { + description = "Terraform installation configuration" + type = object({ + primary_version = optional(string, "") + additional_versions = optional(list(string), []) + }) + default = { + primary_version = "" + additional_versions = [] + } +} + +/*-------------------------------+ + | OpenTofu Installation Settings | + +-------------------------------*/ +variable "opentofu" { + description = "OpenTofu installation configuration" + type = object({ + primary_version = optional(string, "") + additional_versions = optional(list(string), []) + }) + default = { + primary_version = "" + additional_versions = [] + } +} From 86d9bffb6e8ce65e76b2d1bd736b2094935c46d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Tue, 24 Feb 2026 07:43:09 +0100 Subject: [PATCH 04/37] SG-3995: Update docs for autoscaler. --- .../azure/autoscaler/README.md | 436 +++++++++++++----- 1 file changed, 325 insertions(+), 111 deletions(-) diff --git a/stackguardian_private_runner/azure/autoscaler/README.md b/stackguardian_private_runner/azure/autoscaler/README.md index de39b62..e6ee133 100644 --- a/stackguardian_private_runner/azure/autoscaler/README.md +++ b/stackguardian_private_runner/azure/autoscaler/README.md @@ -1,15 +1,19 @@ -# Azure Private Runner Autoscaler +# StackGuardian Runner Autoscaler - Azure Module -Deploy an Azure Function-based autoscaler for managing StackGuardian Private Runners on an existing Azure VM Scale Set. +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 monitors StackGuardian's job queue and automatically scales your VM Scale Set: -- **Scale OUT**: When pending jobs exceed threshold, add VM instances -- **Scale IN**: When pending jobs fall below threshold, gracefully drain and remove instances -- **Cooldown**: Respects configurable cooldown periods between scaling operations +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. -## Architecture +### 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 +- **Role Assignments**: Managed identity with VMSS, storage, and network access + +### Architecture ``` ┌─────────────────────────────────────────────────────────────┐ @@ -39,18 +43,224 @@ The autoscaler monitors StackGuardian's job queue and automatically scales your ## Prerequisites -- Azure CLI authenticated (`az login`) -- Azure Functions Core Tools v4+ (`func --version`) - *optional, can use Azure CLI instead* -- Git -- Existing VM Scale Set with StackGuardian runner instances -- StackGuardian API Key (starts with `sgu_`) -- StackGuardian Runner Group name +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) | `""` | + +### 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" + } +} +``` + +#### 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 + +### Cleanup + +```bash +# Destroy the autoscaler +terraform destroy +``` --- -## Option 1: Manual Setup +## Manual Deployment (Azure CLI) -Step-by-step guide to deploy the autoscaler using Azure CLI. +For deployments without Terraform, follow this step-by-step guide using Azure CLI. ### Step 1: Set Variables @@ -260,86 +470,13 @@ az monitor app-insights query \ --- -## Option 2: Terraform Module (WIP) - -> **Note**: This Terraform module is a work in progress and automates the manual steps above. - -### Prerequisites - -- Terraform >= 1.0 -- Azure CLI authenticated (`az login`) -- Git - -### Usage - -```hcl -module "azure_autoscaler" { - source = "./stackguardian_private_runner/azure" - - resource_group_name = "my-existing-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" - } - - scaling = { - 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 - } -} -``` - -```bash -terraform init -terraform apply -``` - -### Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| `azure_location` | Azure region | `string` | `"westeurope"` | no | -| `resource_group_name` | Resource group name | `string` | n/a | yes | -| `stackguardian` | SG platform config | `object` | n/a | yes | -| `vmss` | VM Scale Set config | `object` | n/a | yes | -| `override_names` | Naming overrides | `object` | See defaults | no | -| `scaling` | Scaling parameters | `object` | See defaults | no | -| `storage` | Storage config | `object` | See defaults | no | - -### Outputs - -| Name | Description | -|------|-------------| -| `function_app_name` | Name of the Azure Function App | -| `function_app_default_hostname` | Default hostname | -| `storage_account_name` | Name of the Storage Account | - ---- - ## 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) + - 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 @@ -392,48 +529,125 @@ az functionapp config appsettings set \ | `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, role assignments, code deployment | +| `storage.tf` | Storage Account and blob containers | +| `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 Errors +### Common Issues -#### 401 Unauthorized (StackGuardian API) -**Symptoms**: Function executes but fails to communicate with StackGuardian API. +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" + ``` -**Cause**: Invalid or expired `SG_API_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) -**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" -``` +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 -### Check Function App Logs ```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 -```bash +# 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 -```bash +# Check Function status az functionapp function list --name --resource-group -``` -### Manually Trigger Function -```bash +# 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) From 8e9f081048754ed950c677ebb301c51d82cf2ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Tue, 24 Feb 2026 07:43:20 +0100 Subject: [PATCH 05/37] SG-3995: Add azure packer builder. --- .../azure/packer/README.md | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 stackguardian_private_runner/azure/packer/README.md diff --git a/stackguardian_private_runner/azure/packer/README.md b/stackguardian_private_runner/azure/packer/README.md new file mode 100644 index 0000000..bf40937 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/README.md @@ -0,0 +1,368 @@ +# StackGuardian Private Runner - Packer Image Builder (Azure) + +Build custom Azure Managed Images for StackGuardian Private Runner deployments with pre-installed dependencies and configurable tooling. + +## Overview + +This Terraform module automates the creation of custom Azure Managed Images using HashiCorp Packer. The resulting image includes Docker, Terraform, OpenTofu, and StackGuardian runner components, providing an optimized base image for Private Runner deployments. + +### What Gets Created + +- **Azure Managed Image**: Pre-configured image with all dependencies +- **Packer Build VM**: Temporary VM used during the build process (automatically terminated) +- **Resource Group** (optional): When `create_resource_group = true` + +### What Gets Installed on the Image + +- Docker (container runtime) +- jq (JSON processor) +- wget, unzip, curl +- cron (task scheduling) +- Terraform (optional, configurable versions) +- OpenTofu (optional, configurable versions) +- StackGuardian Runner (sg-runner binary) + +## Prerequisites + +- **Azure Subscription**: With Contributor permissions to create VMs and images +- **Azure CLI**: Authenticated (`az login`) +- **Terraform**: Version 1.0 or later +- **Network Access**: Packer creates temporary networking by default, or use an existing VNet/subnet + +## Quick Start + +### Step 1: Configure Variables + +Create a `terraform.tfvars` file: + +```hcl +azure_location = "westeurope" +resource_group_name = "my-image-rg" +``` + +### Step 2: Deploy + +```bash +terraform init +terraform plan +terraform apply +``` + +### Step 3: Retrieve Image ID + +```bash +terraform output image_id +``` + +### Basic Configuration Example + +```hcl +module "packer_image" { + source = "./azure/packer" + + azure_location = "westeurope" + resource_group_name = "my-image-rg" +} +``` + +## Configuration + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| `resource_group_name` | Resource group where the image will be stored | `string` | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `azure_location` | Azure region for image creation | `westeurope` | +| `create_resource_group` | Create the resource group (if false, must already exist) | `false` | +| `vm_size` | Azure VM size for the build process | `Standard_D2s_v3` | +| `os.publisher` | OS publisher (`Canonical` or `RedHat`) | `Canonical` | +| `os.offer` | OS offer | `0001-com-ubuntu-server-jammy` | +| `os.sku` | OS SKU | `22_04-lts-gen2` | +| `os.version` | OS version | `latest` | +| `os.update_os_before_install` | Update OS packages before installation | `true` | +| `os.user_script` | Custom script to run during provisioning | `""` | +| `packer_config.version` | Packer version to use | `1.14.1` | +| `packer_config.cleanup_images_on_destroy` | Auto-cleanup image on terraform destroy | `true` | +| `image_name_prefix` | Prefix for the generated image name | `sg-runner` | +| `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 | `[]` | +| `network.vnet_name` | Existing VNet name (empty = Packer creates temporary networking) | `""` | +| `network.subnet_name` | Existing subnet name | `""` | +| `network.resource_group_name` | Resource group of the existing VNet | `""` | + +### Configuration Examples + +#### Basic Configuration (Ubuntu Default) + +```hcl +module "packer_image" { + source = "./azure/packer" + + azure_location = "westeurope" + resource_group_name = "my-image-rg" +} +``` + +#### Ubuntu with Multiple Terraform Versions + +```hcl +module "packer_image" { + source = "./azure/packer" + + azure_location = "westeurope" + resource_group_name = "my-image-rg" + + os = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + update_os_before_install = true + } + + terraform = { + primary_version = "1.5.7" + additional_versions = ["1.4.6", "1.6.0", "1.7.0"] + } + + opentofu = { + primary_version = "1.8.0" + } +} +``` + +#### RHEL with Existing Network + +```hcl +module "packer_image" { + source = "./azure/packer" + + azure_location = "westeurope" + resource_group_name = "my-image-rg" + vm_size = "Standard_D4s_v3" + + os = { + publisher = "RedHat" + offer = "RHEL" + sku = "9_3" + update_os_before_install = true + } + + network = { + vnet_name = "my-existing-vnet" + subnet_name = "my-build-subnet" + resource_group_name = "my-network-rg" + } + + packer_config = { + version = "1.14.1" + cleanup_images_on_destroy = false + } +} +``` + +#### Custom User Script + +```hcl +module "packer_image" { + source = "./azure/packer" + + azure_location = "westeurope" + resource_group_name = "my-image-rg" + + os = { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + user_script = <<-EOF + #!/bin/bash + # Install additional tools + sudo apt-get install -y git + + # Configure custom settings + echo "export CUSTOM_VAR=value" >> ~/.bashrc + EOF + } +} +``` + +## Usage + +### Building the Image + +```bash +# Initialize Terraform +terraform init + +# Preview changes +terraform plan + +# Build the image +terraform apply +``` + +### Using the Image + +After creation, use the image ID with the Azure Single Runner module: + +```bash +# Get the image ID +IMAGE_ID=$(terraform output -raw image_id) + +# Deploy runners using this image +cd ../azure_runner +terraform apply -var="vm_image_id=$IMAGE_ID" +``` + +### Cleanup + +```bash +# Destroy and cleanup image (if cleanup_images_on_destroy = true) +terraform destroy +``` + +For manual cleanup: + +```bash +# List the image +terraform output -json cleanup_commands | jq -r '.list_image' + +# Delete the image +terraform output -json cleanup_commands | jq -r '.delete_image' + +# List all images with prefix +terraform output -json cleanup_commands | jq -r '.list_all' +``` + +## Architecture + +### Resource Organization + +| File | Purpose | +|------|---------| +| `main.tf` | Packer build orchestration, image cleanup logic | +| `variables.tf` | Input variable definitions and validation | +| `outputs.tf` | Output values (image ID, info, cleanup commands) | +| `locals.tf` | OS family detection, SSH username mapping, image naming | +| `provider.tf` | Azure and utility provider configuration | +| `image.pkr.hcl` | Packer HCL template for Azure image creation | +| `scripts/build_image.sh` | Shell script to execute Packer | +| `scripts/setup.sh` | Image provisioning script (package installation) | + +### Build Flow + +``` +terraform apply + | + v +[Execute Packer] --> null_resource.packer_build + | | + | v + | scripts/build_image.sh + | | + | v + | image.pkr.hcl (Packer template) + | | + | v + | scripts/setup.sh (on Azure VM) + | + v +[Parse Image ID] --> data.external.packer_image_id + | + v +[Output Image ID] +``` + +### Image Naming Convention + +Images are named following the pattern: +``` +{image_name_prefix}-{os_family}-{os_sku}-{timestamp} +``` + +Examples: +- `sg-runner-ubuntu-22_04-lts-gen2-20240115-1430` +- `sg-runner-rhel-9_3-20240115-1430` + +## Troubleshooting + +### Common Issues + +1. **Packer Build Fails** + - Check network connectivity (Packer creates temporary networking by default) + - If using existing VNet, verify subnet has internet access + - Review `packer_manifest.log` for detailed errors + +2. **Image Cleanup Fails** + - Verify Azure CLI credentials (`az login`) + - Check if the image is in use by a VM or VMSS + +3. **Terraform/OpenTofu Not Installed** + - Ensure version strings are valid (e.g., `1.5.7`, not `v1.5.7`) + - Check network access to download URLs + +4. **Permission Denied** + - Verify Azure CLI has Contributor role on the subscription or resource group + - Ensure the service principal can create VMs and images + +### Debugging Commands + +```bash +# View Packer build logs +cat packer_manifest.log + +# Check image status +az image show --ids $(terraform output -raw image_id) + +# List all images with prefix +az image list --resource-group \ + --query "[?starts_with(name, 'sg-runner')].{name:name, id:id}" -o table + +# Enable Terraform debug logging +export TF_LOG=DEBUG +terraform apply +``` + +## Outputs + +| Output | Description | +|--------|-------------| +| `image_id` | The resource ID of the created Azure Managed Image | +| `image_info` | Comprehensive image metadata (location, OS, timestamps, cleanup settings) | +| `resource_group_name` | The resource group name where the image is stored | +| `cleanup_commands` | Azure CLI commands for manual image cleanup | + +## Security Considerations + +- **OS Updates**: Recommended to enable `update_os_before_install` for security patches +- **Automatic Cleanup**: Configurable automatic image cleanup on destroy +- **Temporary Resources**: Build VM is automatically terminated after image creation +- **Network Isolation**: Packer creates temporary networking by default, or use an existing private VNet for enterprise environments + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.0 | +| azurerm | >= 3.0 | +| null | >= 3.0 | +| external | >= 2.0 | + +## Next Steps + +After building your image: + +1. **Deploy Private Runners**: Use the [Azure Single Runner](../azure_runner/) module with the created image ID +2. **Configure Runner Group**: Set up StackGuardian runner group using the `runner_group` module +3. **Set Up Autoscaling**: Deploy the [Azure Autoscaler](../autoscaler/) for automatic scaling + +## Support + +- [StackGuardian Documentation](https://docs.stackguardian.io) +- [GitHub Issues](https://github.com/StackGuardian/terraform-stackguardian-modules/issues) From 3d9e042bcf4dd4cdb2dc6e4f3b705f4497721293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Tue, 24 Feb 2026 07:43:35 +0100 Subject: [PATCH 06/37] SG-3995: Add azure runner module. --- .../azure/azure_runner/README.md | 342 ++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 stackguardian_private_runner/azure/azure_runner/README.md 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..62fa43f --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/README.md @@ -0,0 +1,342 @@ +# StackGuardian Private Runner - Azure Single Runner Module + +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 +- **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 environment if not provided) | `""` | +| `stackguardian.api_uri` | StackGuardian API endpoint | `""` (auto-detected) | +| `override_names.global_prefix` | Prefix for all resource names | `sg-runner` | +| `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.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 | `true` | +| `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 + } +} +``` + +#### 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` | Computed values, naming conventions, network mode logic | +| `data.tf` | Data sources for environment variable extraction | +| `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. + +Examples with 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-public-ip` + +## 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 +- **Auto-Generated Keys**: 4096-bit RSA key pair generated by default for strong encryption +- **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 + +## 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) From 68a9b972ea837c0010e5c931c91bebc669cbe7de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Tue, 24 Feb 2026 07:43:48 +0100 Subject: [PATCH 07/37] SG-3995: Update root README for private runner setup. --- stackguardian_private_runner/README.md | 274 +++++++++++++++++++++---- 1 file changed, 235 insertions(+), 39 deletions(-) diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index 4359d6d..87c8809 100644 --- a/stackguardian_private_runner/README.md +++ b/stackguardian_private_runner/README.md @@ -9,7 +9,9 @@ Deploy auto-scaling StackGuardian Private Runners on AWS or Azure. ## 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 @@ -19,7 +21,11 @@ This project provides four templates that work together to create a complete aut **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. ### Azure -3. **[Azure Module](azure/)** - Deploy an Azure Function-based autoscaler for existing VM Scale Sets + +1. **[Packer Image Builder](azure/packer/)** - Build custom Azure Managed Images with pre-installed dependencies +2. **[Runner Group](runner_group/)** - Create StackGuardian Runner Group (shared module) +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 @@ -199,6 +205,8 @@ Before starting, ensure you have: Each module has its own README with detailed configuration options: +### AWS Modules + | Module | Purpose | Configuration | |--------|---------|---------------| | [aws/packer](aws/packer/) | Build custom AMI | [README](aws/packer/README.md) | @@ -206,16 +214,27 @@ Each module has its own README with detailed configuration options: | [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 + +| Module | Purpose | Configuration | +|--------|---------|---------------| +| [azure/packer](azure/packer/) | Build custom Azure Managed Image | [README](azure/packer/README.md) | +| [runner_group](runner_group/) | Create Runner Group (shared) | [README](runner_group/README.md) | +| [azure/azure_runner](azure/azure_runner/) | Deploy Azure Linux VM runner | [README](azure/azure_runner/README.md) | +| [azure/autoscaler](azure/autoscaler/) | Deploy Azure Function autoscaler | [README](azure/autoscaler/README.md) | + ### 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 | @@ -225,17 +244,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:** @@ -243,20 +275,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 @@ -292,7 +324,47 @@ 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) +STORAGE_BACKEND_IDENTITY_ID=$(terraform output -raw storage_backend_identity_id) + +# Step 3: Deploy Azure Runner +cd ../azure/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" ``` @@ -300,40 +372,164 @@ echo "Runner Group: $RUNNER_GROUP_NAME" ## Azure Deployment Guide -For Azure deployments, the autoscaler manages an **existing** VM Scale Set with StackGuardian runners. +### 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 -See the **[Azure Module README](azure/README.md)** for complete instructions, including: -- Manual setup using Azure CLI -- Terraform module usage (WIP) +Navigate to the **Runner Group** module and create the StackGuardian runner group. -### Quick Start +```bash +cd ../../runner_group/ +# Or from root: cd runner_group/ +``` + +See [runner_group/README.md](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) +STORAGE_BACKEND_IDENTITY_ID=$(terraform output -raw storage_backend_identity_id) +``` + +### Step 3: Deploy Azure Runner + +Navigate to the **Azure Runner** module and deploy the runner VM. + +```bash +cd ../azure/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/..." # From Step 2 +``` + +**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 -module "azure_autoscaler" { - source = "./azure" - - 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" - } +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 From 59c0a1338ff0693334872412effed401c33a526c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 27 Apr 2026 13:10:33 +0200 Subject: [PATCH 08/37] SG-3995: Add Azure support for runner group. --- .../runner_group/README.md | 65 +++++++++++++-- .../runner_group/connector.tf | 37 ++++++++- .../runner_group/locals.tf | 63 +++++++++++--- .../runner_group/outputs.tf | 59 +++++++++---- .../runner_group/provider.tf | 16 +++- .../runner_group/runner_group.tf | 23 ++++-- .../runner_group/schemas/input_schema.json | 78 +++++++++++++++++- .../runner_group/schemas/ui_schema.json | 49 +++++++++-- .../runner_group/storage_backend.tf | 14 ++-- .../runner_group/storage_backend_azure.tf | 82 +++++++++++++++++++ .../runner_group/storage_backend_role.tf | 39 +++++++-- .../runner_group/variables.tf | 79 ++++++++++++++++-- 12 files changed, 535 insertions(+), 69 deletions(-) create mode 100644 stackguardian_private_runner/runner_group/storage_backend_azure.tf diff --git a/stackguardian_private_runner/runner_group/README.md b/stackguardian_private_runner/runner_group/README.md index 9486d78..acecf72 100644 --- a/stackguardian_private_runner/runner_group/README.md +++ b/stackguardian_private_runner/runner_group/README.md @@ -1,32 +1,45 @@ -# StackGuardian Runner Group - AWS Module +# StackGuardian Runner Group Module -This Terraform module creates a StackGuardian Runner Group with S3 storage backend and AWS connector integration for running private runners in your AWS environment. +This Terraform module creates a StackGuardian Runner Group with cloud storage backend and connector integration for running private runners in AWS or Azure environments. ## Overview -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. +The module provisions all necessary StackGuardian platform resources and cloud infrastructure to enable private runner execution. It creates a runner group on the StackGuardian platform, cloud storage for artifacts (S3 in AWS or Azure Blob Storage), and the necessary authentication connectors for secure access. ### What Gets Created +**StackGuardian Platform Resources:** - **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) +- **StackGuardian Connector**: Cloud-specific connector for secure storage access from the StackGuardian platform + - **AWS**: RBAC connector using cross-account IAM role for S3 access + - **Azure**: Storage connector using account credentials for Blob Storage access +- **Storage Backend**: Cloud-specific storage for runner artifacts (optional - can use existing storage) + - **AWS**: S3 bucket with CORS configuration + - **Azure**: Blob Storage account with container + +**Cloud Infrastructure (AWS only):** - **IAM Role**: Cross-account role for StackGuardian platform access to S3 - **IAM Policy**: Scoped permissions for S3 bucket operations ## Prerequisites - 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 +- Cloud credentials: + - **AWS**: AWS credentials with permissions to create S3 buckets and IAM roles + - **Azure**: Azure credentials with permissions to create storage accounts and containers +- Terraform >= 1.0 or OpenTofu >= 1.7 ## Quick Start ### Step 1: Configure Variables +#### AWS Example + Create a `terraform.tfvars` file: ```hcl +cloud_provider = "aws" + stackguardian = { api_key = "sgu_your_api_key_here" api_uri = "https://api.app.stackguardian.io" # EU1 or use US1 endpoint @@ -36,6 +49,23 @@ stackguardian = { aws_region = "eu-central-1" ``` +#### Azure Example + +Create a `terraform.tfvars` file: + +```hcl +cloud_provider = "azure" + +stackguardian = { + api_key = "sgu_your_api_key_here" + api_uri = "https://api.app.stackguardian.io" + org_name = "your-org-name" +} + +azure_location = "germanywestcentral" +azure_resource_group_name = "my-resource-group" +``` + ### Step 2: Deploy ```bash @@ -46,10 +76,14 @@ terraform apply ### Basic Configuration Example +#### AWS + ```hcl module "runner_group" { source = "./stackguardian_runner_group" + cloud_provider = "aws" + stackguardian = { api_key = "sgu_your_api_key" } @@ -58,6 +92,23 @@ module "runner_group" { } ``` +#### Azure + +```hcl +module "runner_group" { + source = "./stackguardian_runner_group" + + cloud_provider = "azure" + + stackguardian = { + api_key = "sgu_your_api_key" + } + + azure_location = "germanywestcentral" + azure_resource_group_name = "my-resource-group" +} +``` + ## Configuration ### Required Parameters diff --git a/stackguardian_private_runner/runner_group/connector.tf b/stackguardian_private_runner/runner_group/connector.tf index fb5423c..461c135 100644 --- a/stackguardian_private_runner/runner_group/connector.tf +++ b/stackguardian_private_runner/runner_group/connector.tf @@ -1,6 +1,9 @@ -# StackGuardian Connector +# StackGuardian Connector (AWS and Azure storage backend authentication) + +# AWS Connector — Uses RBAC role for S3 access +resource "stackguardian_connector" "aws" { + count = local.is_aws ? 1 : 0 -resource "stackguardian_connector" "this" { resource_name = local.connector_name description = "AWS connector for accessing Private Runner storage backend (S3 Bucket: ${local.s3_bucket_name})." @@ -8,11 +11,37 @@ resource "stackguardian_connector" "this" { 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 = aws_iam_role.storage_backend[0].arn + external_id = "${local.sg_org_name}:${random_string.connector_external_id[0].result}" duration_seconds = "3600" }] } tags = local.default_tags } + +# Azure Connector — Uses OIDC with auto-provisioned Service Principal +resource "stackguardian_connector" "azure" { + count = local.is_azure ? 1 : 0 + + resource_name = local.connector_name + description = "Azure OIDC connector for Private Runner storage backend" + + settings = { + kind = "AZURE_OIDC" + + config = [{ + arm_tenant_id = data.azurerm_client_config.current[0].tenant_id + arm_subscription_id = data.azurerm_client_config.current[0].subscription_id + arm_client_id = azuread_application.connector[0].client_id + }] + } + + tags = local.default_tags +} + +# State migration: moved block for backward compatibility +moved { + from = stackguardian_connector.this + to = stackguardian_connector.aws[0] +} diff --git a/stackguardian_private_runner/runner_group/locals.tf b/stackguardian_private_runner/runner_group/locals.tf index 45f9c5f..8f47a5c 100644 --- a/stackguardian_private_runner/runner_group/locals.tf +++ b/stackguardian_private_runner/runner_group/locals.tf @@ -6,9 +6,26 @@ data "external" "env" { ] } -data "aws_caller_identity" "current" {} +data "aws_caller_identity" "current" { + count = var.cloud_provider == "aws" ? 1 : 0 +} + +data "azurerm_client_config" "current" { + count = var.cloud_provider == "azure" ? 1 : 0 +} locals { + # Cloud provider booleans + is_aws = var.cloud_provider == "aws" + is_azure = var.cloud_provider == "azure" + + # Account identifier for resource naming + account_identifier = ( + local.is_aws + ? data.aws_caller_identity.current[0].account_id + : data.azurerm_client_config.current[0].subscription_id + ) + # StackGuardian configuration # Use nonsensitive() for non-secret fields to prevent sensitivity propagation sg_org_name = ( @@ -38,13 +55,13 @@ locals { 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}" + : "${local.effective_prefix}-runner-group-${local.account_identifier}" ) 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}" + : "${local.effective_prefix}-private-runner-backend-${local.account_identifier}" ) # Default tags (not editable by user) @@ -54,20 +71,44 @@ locals { local.sg_org_name ] - # S3 bucket name (created or existing) + # S3 bucket name / ARN (AWS only, empty for Azure) s3_bucket_name = ( - var.create_storage_backend - ? aws_s3_bucket.this[0].bucket - : var.existing_s3_bucket_name + local.is_aws + ? (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}" + local.is_aws + ? (var.create_storage_backend ? aws_s3_bucket.this[0].arn : "arn:aws:s3:::${local.s3_bucket_name}") + : "" + ) + + # Azure storage locals — derive from effective_prefix so org name flows into resource names + sanitized_prefix = replace(lower(local.effective_prefix), "_", "-") + storage_account_prefix = substr("stgbackend${replace(local.sanitized_prefix, "-", "")}", 0, 16) + + azure_storage_account_name = ( + local.is_azure + ? ( + var.create_storage_backend + ? azurerm_storage_account.this[0].name + : var.existing_azure_storage_account_name + ) + : "" + ) + + azure_storage_access_key = ( + local.is_azure + ? ( + var.create_storage_backend + ? azurerm_storage_account.this[0].primary_access_key + : var.existing_azure_storage_account_access_key + ) + : "" ) # Runner group outputs final_runner_group_name = stackguardian_runner_group.this.resource_name - final_connector_name = stackguardian_connector.this.resource_name + final_connector_name = local.is_aws ? stackguardian_connector.aws[0].resource_name : (local.is_azure ? stackguardian_connector.azure[0].resource_name : "") } diff --git a/stackguardian_private_runner/runner_group/outputs.tf b/stackguardian_private_runner/runner_group/outputs.tf index 677cf18..27476cd 100644 --- a/stackguardian_private_runner/runner_group/outputs.tf +++ b/stackguardian_private_runner/runner_group/outputs.tf @@ -23,44 +23,71 @@ output "runner_group_url" { } /*---------------------------------+ - | Connector Outputs | + | Connector Outputs (AWS & Azure) | +---------------------------------*/ output "connector_name" { - description = "The name of the StackGuardian connector" - value = stackguardian_connector.this.resource_name + description = "The name of the StackGuardian connector (AWS or Azure)" + value = local.is_aws ? stackguardian_connector.aws[0].resource_name : (local.is_azure ? stackguardian_connector.azure[0].resource_name : "") } output "connector_id" { - description = "The ID of the StackGuardian connector" - value = stackguardian_connector.this.resource_name + description = "The ID of the StackGuardian connector (AWS or Azure)" + value = local.is_aws ? stackguardian_connector.aws[0].resource_name : (local.is_azure ? stackguardian_connector.azure[0].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}" + description = "The external ID used for cross-account S3 access (AWS only)" + value = local.is_aws ? "${local.sg_org_name}:${random_string.connector_external_id[0].result}" : "" } /*---------------------------------+ - | Storage Backend Outputs | + | Storage Backend Outputs (AWS) | +---------------------------------*/ output "s3_bucket_name" { - description = "The name of the S3 bucket used for storage backend" + description = "The name of the S3 bucket used for storage backend (AWS only)" value = local.s3_bucket_name } output "s3_bucket_arn" { - description = "The ARN of the S3 bucket used for storage backend" + description = "The ARN of the S3 bucket used for storage backend (AWS only)" 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 + description = "The ARN of the IAM role for storage backend access (AWS only)" + value = local.is_aws ? aws_iam_role.storage_backend[0].arn : "" } output "storage_backend_role_name" { - description = "The name of the IAM role for storage backend access" - value = aws_iam_role.storage_backend.name + description = "The name of the IAM role for storage backend access (AWS only)" + value = local.is_aws ? aws_iam_role.storage_backend[0].name : "" +} + +/*---------------------------------+ + | Storage Backend Outputs (Azure) | + +---------------------------------*/ +output "azure_storage_account_name" { + description = "The name of the Azure Storage Account used for storage backend (Azure only)" + value = local.azure_storage_account_name +} + +output "azure_storage_access_key" { + description = "The access key for the Azure Storage Account (Azure only, sensitive)" + sensitive = true + value = local.azure_storage_access_key +} + +/*---------------------------------+ + | General Outputs | + +---------------------------------*/ +output "cloud_provider" { + description = "The cloud provider used for the storage backend" + value = var.cloud_provider +} + +output "azure_location" { + description = "The Azure region (Azure only)" + value = local.is_azure ? var.azure_location : "" } /*---------------------------------+ @@ -77,6 +104,6 @@ output "sg_api_uri" { } output "aws_region" { - description = "The AWS region" - value = var.aws_region + description = "The AWS region (AWS only)" + value = local.is_aws ? var.aws_region : "" } diff --git a/stackguardian_private_runner/runner_group/provider.tf b/stackguardian_private_runner/runner_group/provider.tf index 26f0fed..7ee2157 100644 --- a/stackguardian_private_runner/runner_group/provider.tf +++ b/stackguardian_private_runner/runner_group/provider.tf @@ -8,6 +8,14 @@ terraform { source = "hashicorp/aws" version = ">= 4.0" } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0" + } + azuread = { + source = "hashicorp/azuread" + version = ">= 2.0" + } external = { source = "hashicorp/external" version = ">= 2.0" @@ -20,7 +28,13 @@ terraform { } provider "aws" { - region = var.aws_region + region = var.aws_region + skip_credentials_validation = var.cloud_provider != "aws" + skip_requesting_account_id = var.cloud_provider != "aws" +} + +provider "azurerm" { + features {} } provider "stackguardian" { diff --git a/stackguardian_private_runner/runner_group/runner_group.tf b/stackguardian_private_runner/runner_group/runner_group.tf index 56b39db..a61587b 100644 --- a/stackguardian_private_runner/runner_group/runner_group.tf +++ b/stackguardian_private_runner/runner_group/runner_group.tf @@ -2,16 +2,27 @@ resource "stackguardian_runner_group" "this" { resource_name = local.runner_group_name - description = "Private Runner Group for AWS S3 storage backend" + 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 = var.aws_region + s3_bucket_name = local.s3_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_storage_account_name + azure_blob_storage_access_key = local.azure_storage_access_key + auth = { + integration_id = "/integrations/${stackguardian_connector.azure[0].resource_name}" } } diff --git a/stackguardian_private_runner/runner_group/schemas/input_schema.json b/stackguardian_private_runner/runner_group/schemas/input_schema.json index b10a7f0..83ddf67 100644 --- a/stackguardian_private_runner/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/runner_group/schemas/input_schema.json @@ -35,6 +35,13 @@ }, "required": ["api_key"] }, + "cloud_provider": { + "title": "Cloud Provider", + "type": "string", + "enum": ["aws", "azure"], + "enumNames": ["AWS", "Azure"], + "default": "aws" + }, "aws_region": { "title": "AWS Region", "type": "string", @@ -71,6 +78,16 @@ ], "default": "eu-central-1" }, + "azure_location": { + "title": "Azure Region", + "type": "string", + "default": "westeurope" + }, + "azure_resource_group_name": { + "title": "Azure Resource Group Name", + "type": "string", + "default": "" + }, "create_storage_backend": { "title": "Create Storage Backend", "type": "boolean", @@ -112,6 +129,37 @@ } }, "dependencies": { + "cloud_provider": { + "oneOf": [ + { + "properties": { + "cloud_provider": { + "enum": ["aws"] + }, + "aws_region": { + "title": "AWS Region", + "type": "string" + } + } + }, + { + "properties": { + "cloud_provider": { + "enum": ["azure"] + }, + "azure_location": { + "title": "Azure Region", + "type": "string" + }, + "azure_resource_group_name": { + "title": "Azure Resource Group Name", + "type": "string" + } + }, + "required": ["azure_resource_group_name"] + } + ] + }, "create_storage_backend": { "oneOf": [ { @@ -123,6 +171,25 @@ "title": "Force Destroy Storage Backend", "type": "boolean", "default": false + }, + "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 } } }, @@ -134,9 +201,16 @@ "existing_s3_bucket_name": { "title": "Existing S3 Bucket Name", "type": "string" + }, + "existing_azure_storage_account_name": { + "title": "Existing Azure Storage Account Name", + "type": "string" + }, + "existing_azure_storage_account_access_key": { + "title": "Existing Azure Storage Account Access Key", + "type": "string" } - }, - "required": ["existing_s3_bucket_name"] + } } ] } diff --git a/stackguardian_private_runner/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/runner_group/schemas/ui_schema.json index b15fcb5..eb4adb2 100644 --- a/stackguardian_private_runner/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/runner_group/schemas/ui_schema.json @@ -1,12 +1,18 @@ { "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:description": "Create a new StackGuardian Runner Group with cloud storage backend (AWS S3 or Azure Blob Storage). Default tags are automatically applied: 'StackGuardian Private Runner', runner group name, and organization name.", "ui:order": [ "stackguardian", + "cloud_provider", "aws_region", + "azure_location", + "azure_resource_group_name", "create_storage_backend", "existing_s3_bucket_name", + "existing_azure_storage_account_name", + "existing_azure_storage_account_access_key", "force_destroy_storage_backend", + "azure_storage", "override_names", "max_runners" ], @@ -27,21 +33,54 @@ "ui:description": "Your organization name. If not provided, will be extracted from environment." } }, + "cloud_provider": { + "ui:widget": "select", + "ui:description": "Select the cloud provider for the storage backend" + }, "aws_region": { "ui:widget": "select", "ui:description": "The target AWS Region for S3 bucket and IAM resources" }, + "azure_location": { + "ui:placeholder": "westeurope", + "ui:description": "The Azure region where storage resources will be deployed" + }, + "azure_resource_group_name": { + "ui:placeholder": "my-resource-group", + "ui:description": "The name of the existing Azure Resource Group for the storage account" + }, "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 storage backend (S3 bucket for AWS, Storage Account for Azure)" }, "existing_s3_bucket_name": { "ui:placeholder": "my-existing-bucket", - "ui:description": "Name of an existing S3 bucket to use as storage backend" + "ui:description": "Name of an existing S3 bucket to use as storage backend (AWS only)" + }, + "existing_azure_storage_account_name": { + "ui:placeholder": "myexistingstorageaccount", + "ui:description": "Name of an existing Azure Storage Account to use as storage backend (Azure only)" + }, + "existing_azure_storage_account_access_key": { + "ui:widget": "password", + "ui:description": "Access key for the existing Azure Storage Account (Azure only)" }, "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, AWS only)" + }, + "azure_storage": { + "ui:title": "Azure Storage Configuration", + "ui:description": "Configure Azure Storage Account settings (Azure only)", + "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" + } }, "override_names": { "ui:title": "Resource Naming Configuration", @@ -61,7 +100,7 @@ }, "connector_name": { "ui:placeholder": "(auto-generated)", - "ui:description": "Override the connector name. If empty, uses {effective_prefix}-private-runner-backend-{account_id}" + "ui:description": "Override the connector name (AWS only). If empty, uses {effective_prefix}-private-runner-backend-{account_id}" } }, "max_runners": { diff --git a/stackguardian_private_runner/runner_group/storage_backend.tf b/stackguardian_private_runner/runner_group/storage_backend.tf index a89ba4a..0b8b4b5 100644 --- a/stackguardian_private_runner/runner_group/storage_backend.tf +++ b/stackguardian_private_runner/runner_group/storage_backend.tf @@ -1,7 +1,7 @@ -# S3 Bucket for Storage Backend (only created when create_storage_backend = true) +# S3 Bucket for Storage Backend (AWS only, created when create_storage_backend = true) resource "random_string" "storage_backend_prefix" { - count = var.create_storage_backend ? 1 : 0 + count = local.is_aws && var.create_storage_backend ? 1 : 0 length = 8 special = false @@ -9,14 +9,14 @@ resource "random_string" "storage_backend_prefix" { } resource "aws_s3_bucket" "this" { - count = var.create_storage_backend ? 1 : 0 + count = local.is_aws && var.create_storage_backend ? 1 : 0 bucket = "${random_string.storage_backend_prefix[0].result}-private-runner-storage-backend" force_destroy = var.force_destroy_storage_backend } resource "aws_s3_bucket_public_access_block" "this" { - count = var.create_storage_backend ? 1 : 0 + count = local.is_aws && var.create_storage_backend ? 1 : 0 bucket = aws_s3_bucket.this[0].id @@ -27,7 +27,7 @@ resource "aws_s3_bucket_public_access_block" "this" { } resource "aws_s3_bucket_cors_configuration" "this" { - count = var.create_storage_backend ? 1 : 0 + count = local.is_aws && var.create_storage_backend ? 1 : 0 bucket = aws_s3_bucket.this[0].id @@ -41,9 +41,9 @@ 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 existing bucket, AWS only) data "aws_s3_bucket" "existing" { - count = var.create_storage_backend ? 0 : 1 + count = local.is_aws && !var.create_storage_backend ? 1 : 0 bucket = var.existing_s3_bucket_name } diff --git a/stackguardian_private_runner/runner_group/storage_backend_azure.tf b/stackguardian_private_runner/runner_group/storage_backend_azure.tf new file mode 100644 index 0000000..f5bfebd --- /dev/null +++ b/stackguardian_private_runner/runner_group/storage_backend_azure.tf @@ -0,0 +1,82 @@ +# Azure Blob Storage for Storage Backend (Azure only, created when create_storage_backend = true) + +resource "random_string" "azure_storage_suffix" { + count = local.is_azure && var.create_storage_backend ? 1 : 0 + + length = 8 + special = false + upper = false +} + +# Storage account name must be globally unique, 3-24 chars, lowercase alphanumeric only +resource "azurerm_storage_account" "this" { + count = local.is_azure && var.create_storage_backend ? 1 : 0 + + name = "${local.storage_account_prefix}${random_string.azure_storage_suffix[0].result}" + resource_group_name = var.azure_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 + + blob_properties { + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["GET", "HEAD", "PUT", "POST", "DELETE", "MERGE", "OPTIONS", "PATCH"] + allowed_origins = [replace(local.sg_api_uri, "api.", "")] + 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 = local.is_azure && var.create_storage_backend ? 1 : 0 + + name = "runner" + storage_account_id = azurerm_storage_account.this[0].id + container_access_type = "private" +} + +# Azure AD App Registration + Service Principal for OIDC connector + +resource "azuread_application" "connector" { + count = local.is_azure ? 1 : 0 + display_name = "${local.effective_prefix}-sg-connector" + + owners = [data.azurerm_client_config.current[0].object_id] +} + +resource "azuread_service_principal" "connector" { + count = local.is_azure ? 1 : 0 + client_id = azuread_application.connector[0].client_id + + owners = [data.azurerm_client_config.current[0].object_id] +} + +resource "azuread_application_federated_identity_credential" "connector" { + count = local.is_azure ? 1 : 0 + application_id = azuread_application.connector[0].id + display_name = "${local.effective_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 = local.is_azure && var.create_storage_backend ? 1 : 0 + scope = azurerm_storage_account.this[0].id + role_definition_name = "Storage Blob Data Reader" + principal_id = azuread_service_principal.connector[0].object_id +} diff --git a/stackguardian_private_runner/runner_group/storage_backend_role.tf b/stackguardian_private_runner/runner_group/storage_backend_role.tf index c4eaf9b..01f2015 100644 --- a/stackguardian_private_runner/runner_group/storage_backend_role.tf +++ b/stackguardian_private_runner/runner_group/storage_backend_role.tf @@ -1,12 +1,16 @@ -# IAM Role and Policy for Storage Backend Access +# IAM Role and Policy for Storage Backend Access (AWS only) resource "random_string" "connector_external_id" { + count = local.is_aws ? 1 : 0 + length = 24 special = false } # This IAM role is used by the StackGuardian platform and runners to access the S3 bucket resource "aws_iam_role" "storage_backend" { + count = local.is_aws ? 1 : 0 + name = "${local.effective_prefix}-private-runner-s3-role" assume_role_policy = jsonencode({ @@ -18,13 +22,13 @@ resource "aws_iam_role" "storage_backend" { AWS = [ "arn:aws:iam::163602625436:root", "arn:aws:iam::476299211833:root", - "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + "arn:aws:iam::${data.aws_caller_identity.current[0].account_id}:root" ] } Action = "sts:AssumeRole" Condition = { StringEquals = { - "sts:ExternalId" = "${local.sg_org_name}:${random_string.connector_external_id.result}" + "sts:ExternalId" = "${local.sg_org_name}:${random_string.connector_external_id[0].result}" } } } @@ -34,6 +38,8 @@ 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" { + count = local.is_aws ? 1 : 0 + name = "${local.effective_prefix}-runner-s3-policy" description = "Policy for access to the Storage Backend S3 Bucket" @@ -66,6 +72,29 @@ resource "aws_iam_policy" "storage_backend_access" { } resource "aws_iam_role_policy_attachment" "storage_backend" { - role = aws_iam_role.storage_backend.name - policy_arn = aws_iam_policy.storage_backend_access.arn + count = local.is_aws ? 1 : 0 + + role = aws_iam_role.storage_backend[0].name + policy_arn = aws_iam_policy.storage_backend_access[0].arn +} + +# State migration: moved blocks for backward compatibility +moved { + from = random_string.connector_external_id + to = random_string.connector_external_id[0] +} + +moved { + from = aws_iam_role.storage_backend + to = aws_iam_role.storage_backend[0] +} + +moved { + from = aws_iam_policy.storage_backend_access + to = aws_iam_policy.storage_backend_access[0] +} + +moved { + from = aws_iam_role_policy_attachment.storage_backend + to = aws_iam_role_policy_attachment.storage_backend[0] } diff --git a/stackguardian_private_runner/runner_group/variables.tf b/stackguardian_private_runner/runner_group/variables.tf index 04179f5..4ad6476 100644 --- a/stackguardian_private_runner/runner_group/variables.tf +++ b/stackguardian_private_runner/runner_group/variables.tf @@ -1,17 +1,31 @@ +/*---------------------------+ + | Cloud Provider Toggle | + +---------------------------*/ +variable "cloud_provider" { + description = "The cloud provider for the storage backend. Determines which resources are created (AWS S3 or Azure Blob Storage)." + type = string + default = "aws" + + validation { + condition = contains(["aws", "azure"], var.cloud_provider) + error_message = "The cloud_provider must be either 'aws' or 'azure'." + } +} + /*---------------------------+ | Storage Backend Options | +---------------------------*/ variable "create_storage_backend" { description = < Date: Mon, 27 Apr 2026 13:10:48 +0200 Subject: [PATCH 09/37] SG-3995: Azure private runner modules. --- .../azure/autoscaler/function_autoscaler.tf | 93 +++---- .../azure/autoscaler/locals.tf | 60 ++++- .../azure/autoscaler/outputs.tf | 16 +- .../azure/autoscaler/storage.tf | 7 +- .../azure/autoscaler/variables.tf | 15 +- .../azure/azure_runner/locals.tf | 17 +- .../azure/azure_runner/network.tf | 28 +- .../azure/azure_runner/variables.tf | 12 +- .../azure/azure_runner/vm.tf | 8 +- .../azure/packer/packer_manifest.log | 255 +----------------- 10 files changed, 170 insertions(+), 341 deletions(-) diff --git a/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf b/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf index c4ce0b8..6298406 100644 --- a/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf +++ b/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf @@ -10,10 +10,9 @@ resource "azurerm_service_plan" "autoscaler" { os_type = "Linux" sku_name = "FC1" # FlexConsumption plan - tags = { - purpose = "stackguardian-private-runner" - prefix = var.override_names.global_prefix - } + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-autoscaler-plan" + }) } # Application Insights for monitoring @@ -23,10 +22,9 @@ resource "azurerm_application_insights" "autoscaler" { location = var.azure_location application_type = "other" - tags = { - purpose = "stackguardian-private-runner" - prefix = var.override_names.global_prefix - } + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-autoscaler-insights" + }) } # Function App with Flex Consumption plan @@ -43,53 +41,22 @@ resource "azurerm_function_app_flex_consumption" "autoscaler" { # Storage configuration storage_container_type = "blobContainer" storage_container_endpoint = "${azurerm_storage_account.autoscaler.primary_blob_endpoint}deployments" - storage_authentication_type = "StorageAccountConnectionString" - storage_access_key = azurerm_storage_account.autoscaler.primary_access_key + 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 = { - # Azure configuration (matches azure_service.py expectations) - 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 (matches stackguardian_autoscaler.py expectations) - 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) - - # Function runtime settings - AzureWebJobsStorage = azurerm_storage_account.autoscaler.primary_connection_string - APPLICATIONINSIGHTS_CONNECTION_STRING = azurerm_application_insights.autoscaler.connection_string - } + app_settings = local.app_settings identity { type = "SystemAssigned" } - tags = { - purpose = "stackguardian-private-runner" - prefix = var.override_names.global_prefix - } + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-autoscaler" + }) } /*-------------------------------------------+ @@ -107,7 +74,7 @@ resource "null_resource" "deploy_function_code" { command = <<-EOT set -e TEMP_DIR=$(mktemp -d) - git clone --depth 1 https://github.com/StackGuardian/sg-runner-autoscaler.git "$TEMP_DIR/repo" + git clone --depth 1 --branch SG-3410-shared-autoscaler https://github.com/StackGuardian/sg-runner-autoscaler.git "$TEMP_DIR/repo" cd "$TEMP_DIR/repo" cp azure_requirements.txt requirements.txt @@ -115,11 +82,22 @@ resource "null_resource" "deploy_function_code" { 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 ${var.resource_group_name} \ - --name ${azurerm_function_app_flex_consumption.autoscaler.name} \ + --name ${nonsensitive(azurerm_function_app_flex_consumption.autoscaler.name)} \ --src "$TEMP_DIR/deploy.zip" \ - --build-remote true + --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 rm -rf "$TEMP_DIR" EOT @@ -145,9 +123,26 @@ resource "azurerm_role_assignment" "vmss_reader" { } # Allow Function App to access storage +# RBAC mode requires Storage Blob Data Owner for runtime host coordination; +# connection string mode only needs Storage Blob Data Contributor for app-level blob operations resource "azurerm_role_assignment" "storage_blob_contributor" { scope = azurerm_storage_account.autoscaler.id - role_definition_name = "Storage Blob Data Contributor" + 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 and Table roles required for 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 } diff --git a/stackguardian_private_runner/azure/autoscaler/locals.tf b/stackguardian_private_runner/azure/autoscaler/locals.tf index 1d73795..41328e1 100644 --- a/stackguardian_private_runner/azure/autoscaler/locals.tf +++ b/stackguardian_private_runner/azure/autoscaler/locals.tf @@ -23,11 +23,24 @@ locals { : var.resource_group_name ) - # Sanitized prefix for Azure resources (lowercase, no special chars) - sanitized_prefix = replace(lower(var.override_names.global_prefix), "_", "-") + # 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(replace(local.sanitized_prefix, "-", ""), 0, 15) + storage_account_prefix = substr("autoscaler${replace(local.sanitized_prefix, "-", "")}", 0, 16) # Storage URL: use explicit URL if provided (for private endpoints) storage_account_url = ( @@ -35,4 +48,45 @@ locals { ? 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) + + # 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 index bc0ed22..ec86319 100644 --- a/stackguardian_private_runner/azure/autoscaler/outputs.tf +++ b/stackguardian_private_runner/azure/autoscaler/outputs.tf @@ -3,22 +3,22 @@ +---------------------------------*/ output "function_app_name" { description = "The name of the Azure Function App that handles auto-scaling" - value = azurerm_function_app_flex_consumption.autoscaler.name + value = nonsensitive(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 + value = nonsensitive(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 + value = nonsensitive(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 + value = nonsensitive(azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id) } /*---------------------------------+ @@ -26,17 +26,17 @@ output "function_app_identity_principal_id" { +---------------------------------*/ output "storage_account_name" { description = "The name of the Storage Account used for autoscaler state" - value = azurerm_storage_account.autoscaler.name + value = nonsensitive(azurerm_storage_account.autoscaler.name) } output "storage_account_id" { description = "The ID of the Storage Account" - value = azurerm_storage_account.autoscaler.id + value = nonsensitive(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 + value = nonsensitive(azurerm_storage_container.autoscaler_state.name) } /*---------------------------------+ @@ -44,7 +44,7 @@ output "storage_container_name" { +---------------------------------*/ output "application_insights_name" { description = "The name of the Application Insights instance" - value = azurerm_application_insights.autoscaler.name + value = nonsensitive(azurerm_application_insights.autoscaler.name) } output "application_insights_instrumentation_key" { diff --git a/stackguardian_private_runner/azure/autoscaler/storage.tf b/stackguardian_private_runner/azure/autoscaler/storage.tf index 82b411e..a133090 100644 --- a/stackguardian_private_runner/azure/autoscaler/storage.tf +++ b/stackguardian_private_runner/azure/autoscaler/storage.tf @@ -21,10 +21,9 @@ resource "azurerm_storage_account" "autoscaler" { allow_nested_items_to_be_public = false public_network_access_enabled = true - tags = { - purpose = "stackguardian-private-runner" - prefix = var.override_names.global_prefix - } + tags = merge(local.common_tags, { + Name = "${local.storage_account_prefix}${random_string.storage_suffix.result}" + }) } # Container for autoscaler state diff --git a/stackguardian_private_runner/azure/autoscaler/variables.tf b/stackguardian_private_runner/azure/autoscaler/variables.tf index ceb134e..60f17fc 100644 --- a/stackguardian_private_runner/azure/autoscaler/variables.tf +++ b/stackguardian_private_runner/azure/autoscaler/variables.tf @@ -33,20 +33,18 @@ variable "override_names" { Configuration for overriding default resource names. - global_prefix: Prefix used for naming all Azure resources created by this module + - include_org_in_prefix: When true, appends org name to prefix (e.g., SG_RUNNER_demo-org) - runner_group_name: Override the default StackGuardian runner group name EOT type = object({ - global_prefix = string - runner_group_name = optional(string, "") + global_prefix = string + include_org_in_prefix = optional(bool, false) + runner_group_name = optional(string, "") }) default = { - global_prefix = "sg-runner" + global_prefix = "SG_RUNNER" } - validation { - condition = can(regex("^[a-z][a-z0-9-]*$", var.override_names.global_prefix)) - error_message = "The global_prefix must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens." - } } /*-------------------------------+ @@ -135,16 +133,19 @@ variable "storage" { - account_tier: Performance tier of the storage account (Standard or Premium) - account_replication_type: Replication strategy (LRS, GRS, RAGRS, ZRS) - account_url: Optional explicit storage account URL (for private endpoints) + - use_rbac: Use managed identity (RBAC) instead of connection strings for storage authentication EOT type = object({ account_tier = optional(string, "Standard") account_replication_type = optional(string, "LRS") account_url = optional(string, "") + use_rbac = optional(bool, false) }) default = { account_tier = "Standard" account_replication_type = "LRS" account_url = "" + use_rbac = false } validation { diff --git a/stackguardian_private_runner/azure/azure_runner/locals.tf b/stackguardian_private_runner/azure/azure_runner/locals.tf index 579b6de..ce1f12c 100644 --- a/stackguardian_private_runner/azure/azure_runner/locals.tf +++ b/stackguardian_private_runner/azure/azure_runner/locals.tf @@ -39,8 +39,21 @@ locals { : var.firewall.ssh_public_key ) - # Sanitized prefix for Azure naming - sanitized_prefix = replace(lower(var.override_names.global_prefix), "_", "-") + # Computed prefix with optional org name (matches AWS pattern) + effective_prefix = ( + var.override_names.include_org_in_prefix && var.override_names.org_name != "" + ? "${var.override_names.global_prefix}_${var.override_names.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" diff --git a/stackguardian_private_runner/azure/azure_runner/network.tf b/stackguardian_private_runner/azure/azure_runner/network.tf index fb11817..4492205 100644 --- a/stackguardian_private_runner/azure/azure_runner/network.tf +++ b/stackguardian_private_runner/azure/azure_runner/network.tf @@ -9,10 +9,9 @@ resource "azurerm_virtual_network" "this" { location = var.azure_location resource_group_name = var.resource_group_name - tags = { - Name = "${local.sanitized_prefix}-vnet" - purpose = "stackguardian-private-runner" - } + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-vnet" + }) } resource "azurerm_subnet" "this" { @@ -77,10 +76,9 @@ resource "azurerm_network_security_group" "this" { destination_address_prefix = "*" } - tags = { - Name = "${local.sanitized_prefix}-nsg" - purpose = "stackguardian-private-runner" - } + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-nsg" + }) } /*-------------------------------------------+ @@ -95,10 +93,9 @@ resource "azurerm_public_ip" "this" { allocation_method = "Static" sku = "Standard" - tags = { - Name = "${local.sanitized_prefix}-pip" - purpose = "stackguardian-private-runner" - } + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-pip" + }) } /*-------------------------------------------+ @@ -116,10 +113,9 @@ resource "azurerm_network_interface" "this" { public_ip_address_id = var.network.associate_public_ip ? azurerm_public_ip.this[0].id : null } - tags = { - Name = "${local.sanitized_prefix}-nic" - purpose = "stackguardian-private-runner" - } + tags = merge(local.common_tags, { + Name = "${local.sanitized_prefix}-nic" + }) } # Associate NSG with NIC diff --git a/stackguardian_private_runner/azure/azure_runner/variables.tf b/stackguardian_private_runner/azure/azure_runner/variables.tf index b83c470..670d806 100644 --- a/stackguardian_private_runner/azure/azure_runner/variables.tf +++ b/stackguardian_private_runner/azure/azure_runner/variables.tf @@ -75,18 +75,18 @@ variable "override_names" { Configuration for overriding default resource names. - global_prefix: Prefix used for naming all Azure resources created by this module + - include_org_in_prefix: When true, appends org name to prefix (e.g., SG_RUNNER_demo-org) + - org_name: Organization name to include in prefix (since this module doesn't resolve it from environment) EOT type = object({ - global_prefix = string + global_prefix = string + include_org_in_prefix = optional(bool, false) + org_name = optional(string, "") }) default = { - global_prefix = "sg-runner" + global_prefix = "SG_RUNNER" } - validation { - condition = can(regex("^[a-zA-Z][a-zA-Z0-9-_]*$", var.override_names.global_prefix)) - error_message = "The global_prefix must start with a letter and contain only letters, numbers, hyphens, and underscores." - } } /*-----------------------+ diff --git a/stackguardian_private_runner/azure/azure_runner/vm.tf b/stackguardian_private_runner/azure/azure_runner/vm.tf index 53f3eb5..05d99a1 100644 --- a/stackguardian_private_runner/azure/azure_runner/vm.tf +++ b/stackguardian_private_runner/azure/azure_runner/vm.tf @@ -58,11 +58,9 @@ resource "azurerm_linux_virtual_machine" "this" { ) ) - tags = { - Name = local.vm_name - purpose = "stackguardian-private-runner" - prefix = var.override_names.global_prefix - } + tags = merge(local.common_tags, { + Name = local.vm_name + }) lifecycle { create_before_destroy = true diff --git a/stackguardian_private_runner/azure/packer/packer_manifest.log b/stackguardian_private_runner/azure/packer/packer_manifest.log index 9fd785a..c50ff99 100644 --- a/stackguardian_private_runner/azure/packer/packer_manifest.log +++ b/stackguardian_private_runner/azure/packer/packer_manifest.log @@ -1,241 +1,14 @@ -1765884676,,ui,say,==> azure-arm.this: Running builder ... -1765884676,,ui,say,==> azure-arm.this: Creating Azure Resource Manager (ARM) client ... -1765884677,,ui,say,==> azure-arm.this: ARM Client successfully created -1765884678,,ui,say,==> azure-arm.this: Getting source image id for the deployment ... -1765884678,,ui,say,==> azure-arm.this: -> SourceImageName: '/subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/providers/Microsoft.Compute/locations/westeurope/publishers/Canonical/ArtifactTypes/vmimage/offers/0001-com-ubuntu-server-jammy/skus/22_04-lts-gen2/versions/latest' -1765884679,,ui,say,==> azure-arm.this: Creating resource group ... -1765884679,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' -1765884679,,ui,say,==> azure-arm.this: -> Location : 'westeurope' -1765884679,,ui,say,==> azure-arm.this: -> Tags : -1765884679,,ui,say,==> azure-arm.this: ->> os : ubuntu -1765884679,,ui,say,==> azure-arm.this: ->> purpose : stackguardian-private-runner -1765884680,,ui,say,==> azure-arm.this: Validating deployment template ... -1765884680,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' -1765884680,,ui,say,==> azure-arm.this: -> DeploymentName : 'pkrdpbor1pewn1z' -1765884682,,ui,say,==> azure-arm.this: Deploying deployment template ... -1765884682,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' -1765884682,,ui,say,==> azure-arm.this: -> DeploymentName : 'pkrdpbor1pewn1z' -1765884736,,ui,say,==> azure-arm.this: Getting the VM's IP address ... -1765884736,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' -1765884736,,ui,say,==> azure-arm.this: -> PublicIPAddressName : 'pkripbor1pewn1z' -1765884736,,ui,say,==> azure-arm.this: -> NicName : 'pkrnibor1pewn1z' -1765884736,,ui,say,==> azure-arm.this: -> Network Connection : 'PublicEndpoint' -1765884736,,ui,say,==> azure-arm.this: -> IP Address : '20.126.140.143' -1765884736,,ui,say,==> azure-arm.this: Querying the machine's properties ... -1765884736,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' -1765884736,,ui,say,==> azure-arm.this: -> ComputeName : 'pkrvmbor1pewn1z' -1765884736,,ui,say,==> azure-arm.this: -> Managed OS Disk : '/subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/resourceGroups/pkr-Resource-Group-bor1pewn1z/providers/Microsoft.Compute/disks/pkrosbor1pewn1z' -1765884736,,ui,say,==> azure-arm.this: Querying the machine's additional disks properties ... -1765884736,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' -1765884736,,ui,say,==> azure-arm.this: -> ComputeName : 'pkrvmbor1pewn1z' -1765884737,,ui,say,==> azure-arm.this: Waiting for SSH to become available... -1765884741,,ui,say,==> azure-arm.this: Connected to SSH! -1765884741,,ui,say,==> azure-arm.this: Provisioning with shell script: scripts/setup.sh -1765884742,,ui,say,==> azure-arm.this: >> Waiting for cloud-init to complete.. -1765884743,,ui,say,==> azure-arm.this: status: done -1765884743,,ui,say,==> azure-arm.this: >> Cloud-init completed. -1765884743,,ui,say,==> azure-arm.this: >> Waiting for apt locks.. -1765884743,,ui,say,==> azure-arm.this: Hit:1 http://azure.archive.ubuntu.com/ubuntu jammy InRelease -1765884743,,ui,say,==> azure-arm.this: Get:2 http://azure.archive.ubuntu.com/ubuntu jammy-updates InRelease [128 kB] -1765884743,,ui,say,==> azure-arm.this: Get:3 http://azure.archive.ubuntu.com/ubuntu jammy-backports InRelease [127 kB] -1765884743,,ui,say,==> azure-arm.this: Get:4 http://azure.archive.ubuntu.com/ubuntu jammy-security InRelease [129 kB] -1765884744,,ui,say,==> azure-arm.this: Get:5 http://azure.archive.ubuntu.com/ubuntu jammy/universe amd64 Packages [14.1 MB] -1765884744,,ui,say,==> azure-arm.this: Get:6 http://azure.archive.ubuntu.com/ubuntu jammy/universe Translation-en [5652 kB] -1765884744,,ui,say,==> azure-arm.this: Get:7 http://azure.archive.ubuntu.com/ubuntu jammy/universe amd64 c-n-f Metadata [286 kB] -1765884744,,ui,say,==> azure-arm.this: Get:8 http://azure.archive.ubuntu.com/ubuntu jammy/multiverse amd64 Packages [217 kB] -1765884744,,ui,say,==> azure-arm.this: Get:9 http://azure.archive.ubuntu.com/ubuntu jammy/multiverse Translation-en [112 kB] -1765884744,,ui,say,==> azure-arm.this: Get:10 http://azure.archive.ubuntu.com/ubuntu jammy/multiverse amd64 c-n-f Metadata [8372 B] -1765884744,,ui,say,==> azure-arm.this: Get:11 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages [3160 kB] -1765884744,,ui,say,==> azure-arm.this: Get:12 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main Translation-en [484 kB] -1765884744,,ui,say,==> azure-arm.this: Get:13 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 c-n-f Metadata [19.0 kB] -1765884744,,ui,say,==> azure-arm.this: Get:14 http://azure.archive.ubuntu.com/ubuntu jammy-updates/universe amd64 Packages [1244 kB] -1765884744,,ui,say,==> azure-arm.this: Get:15 http://azure.archive.ubuntu.com/ubuntu jammy-updates/universe Translation-en [310 kB] -1765884744,,ui,say,==> azure-arm.this: Get:16 http://azure.archive.ubuntu.com/ubuntu jammy-updates/universe amd64 c-n-f Metadata [30.0 kB] -1765884744,,ui,say,==> azure-arm.this: Get:17 http://azure.archive.ubuntu.com/ubuntu jammy-updates/multiverse amd64 Packages [57.6 kB] -1765884744,,ui,say,==> azure-arm.this: Get:18 http://azure.archive.ubuntu.com/ubuntu jammy-updates/multiverse Translation-en [13.2 kB] -1765884744,,ui,say,==> azure-arm.this: Get:19 http://azure.archive.ubuntu.com/ubuntu jammy-updates/multiverse amd64 c-n-f Metadata [600 B] -1765884744,,ui,say,==> azure-arm.this: Get:20 http://azure.archive.ubuntu.com/ubuntu jammy-backports/main amd64 Packages [69.4 kB] -1765884744,,ui,say,==> azure-arm.this: Get:21 http://azure.archive.ubuntu.com/ubuntu jammy-backports/main Translation-en [11.5 kB] -1765884744,,ui,say,==> azure-arm.this: Get:22 http://azure.archive.ubuntu.com/ubuntu jammy-backports/main amd64 c-n-f Metadata [412 B] -1765884744,,ui,say,==> azure-arm.this: Get:23 http://azure.archive.ubuntu.com/ubuntu jammy-backports/restricted amd64 c-n-f Metadata [116 B] -1765884744,,ui,say,==> azure-arm.this: Get:24 http://azure.archive.ubuntu.com/ubuntu jammy-backports/universe amd64 Packages [30.1 kB] -1765884744,,ui,say,==> azure-arm.this: Get:25 http://azure.archive.ubuntu.com/ubuntu jammy-backports/universe Translation-en [16.6 kB] -1765884744,,ui,say,==> azure-arm.this: Get:26 http://azure.archive.ubuntu.com/ubuntu jammy-backports/universe amd64 c-n-f Metadata [672 B] -1765884744,,ui,say,==> azure-arm.this: Get:27 http://azure.archive.ubuntu.com/ubuntu jammy-backports/multiverse amd64 c-n-f Metadata [116 B] -1765884744,,ui,say,==> azure-arm.this: Get:28 http://azure.archive.ubuntu.com/ubuntu jammy-security/main amd64 Packages [2899 kB] -1765884744,,ui,say,==> azure-arm.this: Get:29 http://azure.archive.ubuntu.com/ubuntu jammy-security/main Translation-en [417 kB] -1765884744,,ui,say,==> azure-arm.this: Get:30 http://azure.archive.ubuntu.com/ubuntu jammy-security/main amd64 c-n-f Metadata [14.0 kB] -1765884744,,ui,say,==> azure-arm.this: Get:31 http://azure.archive.ubuntu.com/ubuntu jammy-security/restricted amd64 Packages [4883 kB] -1765884745,,ui,say,==> azure-arm.this: Get:32 http://azure.archive.ubuntu.com/ubuntu jammy-security/restricted Translation-en [917 kB] -1765884745,,ui,say,==> azure-arm.this: Get:33 http://azure.archive.ubuntu.com/ubuntu jammy-security/universe amd64 Packages [1007 kB] -1765884745,,ui,say,==> azure-arm.this: Get:34 http://azure.archive.ubuntu.com/ubuntu jammy-security/universe Translation-en [221 kB] -1765884745,,ui,say,==> azure-arm.this: Get:35 http://azure.archive.ubuntu.com/ubuntu jammy-security/universe amd64 c-n-f Metadata [22.3 kB] -1765884745,,ui,say,==> azure-arm.this: Get:36 http://azure.archive.ubuntu.com/ubuntu jammy-security/multiverse amd64 Packages [50.5 kB] -1765884745,,ui,say,==> azure-arm.this: Get:37 http://azure.archive.ubuntu.com/ubuntu jammy-security/multiverse Translation-en [10.2 kB] -1765884745,,ui,say,==> azure-arm.this: Get:38 http://azure.archive.ubuntu.com/ubuntu jammy-security/multiverse amd64 c-n-f Metadata [376 B] -1765884764,,ui,say,==> azure-arm.this: Fetched 36.6 MB in 7s (4949 kB/s) -1765884766,,ui,say,==> azure-arm.this: Reading package lists... -1765884766,,ui,say,==> azure-arm.this: Reading package lists... -1765884767,,ui,say,==> azure-arm.this: Building dependency tree... -1765884767,,ui,say,==> azure-arm.this: Reading state information... -1765884767,,ui,say,==> azure-arm.this: cron is already the newest version (3.0pl1-137ubuntu3). -1765884767,,ui,say,==> azure-arm.this: cron set to manually installed. -1765884767,,ui,say,==> azure-arm.this: wget is already the newest version (1.21.2-2ubuntu1.1). -1765884767,,ui,say,==> azure-arm.this: wget set to manually installed. -1765884767,,ui,say,==> azure-arm.this: The following additional packages will be installed: -1765884767,,ui,say,==> azure-arm.this: bridge-utils containerd dns-root-data dnsmasq-base pigz runc ubuntu-fan -1765884767,,ui,say,==> azure-arm.this: Suggested packages: -1765884767,,ui,say,==> azure-arm.this: ifupdown aufs-tools cgroupfs-mount | cgroup-lite debootstrap docker-buildx -1765884767,,ui,say,==> azure-arm.this: docker-compose-v2 docker-doc rinse zfs-fuse | zfsutils zip -1765884767,,ui,say,==> azure-arm.this: The following NEW packages will be installed: -1765884767,,ui,say,==> azure-arm.this: bridge-utils containerd dns-root-data dnsmasq-base docker.io pigz runc -1765884767,,ui,say,==> azure-arm.this: ubuntu-fan unzip -1765884767,,ui,say,==> azure-arm.this: 0 upgraded%!(PACKER_COMMA) 9 newly installed%!(PACKER_COMMA) 0 to remove and 0 not upgraded. -1765884767,,ui,say,==> azure-arm.this: Need to get 76.5 MB of archives. -1765884767,,ui,say,==> azure-arm.this: After this operation%!(PACKER_COMMA) 289 MB of additional disk space will be used. -1765884767,,ui,say,==> azure-arm.this: Get:1 http://azure.archive.ubuntu.com/ubuntu jammy/universe amd64 pigz amd64 2.6-1 [63.6 kB] -1765884767,,ui,say,==> azure-arm.this: Get:2 http://azure.archive.ubuntu.com/ubuntu jammy/main amd64 bridge-utils amd64 1.7-1ubuntu3 [34.4 kB] -1765884767,,ui,say,==> azure-arm.this: Get:3 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 runc amd64 1.3.3-0ubuntu1~22.04.3 [8857 kB] -1765884767,,ui,say,==> azure-arm.this: Get:4 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 containerd amd64 1.7.28-0ubuntu1~22.04.1 [38.5 MB] -1765884770,,ui,say,==> azure-arm.this: Get:5 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 dns-root-data all 2024071801~ubuntu0.22.04.1 [6132 B] -1765884770,,ui,say,==> azure-arm.this: Get:6 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 dnsmasq-base amd64 2.90-0ubuntu0.22.04.1 [374 kB] -1765884770,,ui,say,==> azure-arm.this: Get:7 http://azure.archive.ubuntu.com/ubuntu jammy-updates/universe amd64 docker.io amd64 28.2.2-0ubuntu1~22.04.1 [28.4 MB] -1765884771,,ui,say,==> azure-arm.this: Get:8 http://azure.archive.ubuntu.com/ubuntu jammy/universe amd64 ubuntu-fan all 0.12.16 [35.2 kB] -1765884771,,ui,say,==> azure-arm.this: Get:9 http://azure.archive.ubuntu.com/ubuntu jammy-updates/main amd64 unzip amd64 6.0-26ubuntu3.2 [175 kB] -1765884772,,ui,error,==> azure-arm.this: debconf: unable to initialize frontend: Dialog -1765884772,,ui,error,==> azure-arm.this: debconf: (Dialog frontend will not work on a dumb terminal%!(PACKER_COMMA) an emacs shell buffer%!(PACKER_COMMA) or without a controlling terminal.) -1765884772,,ui,error,==> azure-arm.this: debconf: falling back to frontend: Readline -1765884772,,ui,error,==> azure-arm.this: debconf: unable to initialize frontend: Readline -1765884772,,ui,error,==> azure-arm.this: debconf: (This frontend requires a controlling tty.) -1765884772,,ui,error,==> azure-arm.this: debconf: falling back to frontend: Teletype -1765884772,,ui,error,==> azure-arm.this: dpkg-preconfigure: unable to re-open stdin: -1765884772,,ui,say,==> azure-arm.this: Fetched 76.5 MB in 4s (18.4 MB/s) -1765884772,,ui,say,==> azure-arm.this: Selecting previously unselected package pigz. -1765884776,,ui,say,==> azure-arm.this: (Reading database ... 62847 files and directories currently installed.) -1765884776,,ui,say,==> azure-arm.this: Preparing to unpack .../0-pigz_2.6-1_amd64.deb ... -1765884776,,ui,say,==> azure-arm.this: Unpacking pigz (2.6-1) ... -1765884776,,ui,say,==> azure-arm.this: Selecting previously unselected package bridge-utils. -1765884776,,ui,say,==> azure-arm.this: Preparing to unpack .../1-bridge-utils_1.7-1ubuntu3_amd64.deb ... -1765884776,,ui,say,==> azure-arm.this: Unpacking bridge-utils (1.7-1ubuntu3) ... -1765884777,,ui,say,==> azure-arm.this: Selecting previously unselected package runc. -1765884777,,ui,say,==> azure-arm.this: Preparing to unpack .../2-runc_1.3.3-0ubuntu1~22.04.3_amd64.deb ... -1765884777,,ui,say,==> azure-arm.this: Unpacking runc (1.3.3-0ubuntu1~22.04.3) ... -1765884777,,ui,say,==> azure-arm.this: Selecting previously unselected package containerd. -1765884777,,ui,say,==> azure-arm.this: Preparing to unpack .../3-containerd_1.7.28-0ubuntu1~22.04.1_amd64.deb ... -1765884777,,ui,say,==> azure-arm.this: Unpacking containerd (1.7.28-0ubuntu1~22.04.1) ... -1765884780,,ui,say,==> azure-arm.this: Selecting previously unselected package dns-root-data. -1765884780,,ui,say,==> azure-arm.this: Preparing to unpack .../4-dns-root-data_2024071801~ubuntu0.22.04.1_all.deb ... -1765884780,,ui,say,==> azure-arm.this: Unpacking dns-root-data (2024071801~ubuntu0.22.04.1) ... -1765884780,,ui,say,==> azure-arm.this: Selecting previously unselected package dnsmasq-base. -1765884780,,ui,say,==> azure-arm.this: Preparing to unpack .../5-dnsmasq-base_2.90-0ubuntu0.22.04.1_amd64.deb ... -1765884780,,ui,say,==> azure-arm.this: Unpacking dnsmasq-base (2.90-0ubuntu0.22.04.1) ... -1765884781,,ui,say,==> azure-arm.this: Selecting previously unselected package docker.io. -1765884781,,ui,say,==> azure-arm.this: Preparing to unpack .../6-docker.io_28.2.2-0ubuntu1~22.04.1_amd64.deb ... -1765884781,,ui,say,==> azure-arm.this: Unpacking docker.io (28.2.2-0ubuntu1~22.04.1) ... -1765884783,,ui,say,==> azure-arm.this: Selecting previously unselected package ubuntu-fan. -1765884783,,ui,say,==> azure-arm.this: Preparing to unpack .../7-ubuntu-fan_0.12.16_all.deb ... -1765884783,,ui,say,==> azure-arm.this: Unpacking ubuntu-fan (0.12.16) ... -1765884783,,ui,say,==> azure-arm.this: Selecting previously unselected package unzip. -1765884783,,ui,say,==> azure-arm.this: Preparing to unpack .../8-unzip_6.0-26ubuntu3.2_amd64.deb ... -1765884783,,ui,say,==> azure-arm.this: Unpacking unzip (6.0-26ubuntu3.2) ... -1765884784,,ui,say,==> azure-arm.this: Setting up unzip (6.0-26ubuntu3.2) ... -1765884784,,ui,say,==> azure-arm.this: Setting up dnsmasq-base (2.90-0ubuntu0.22.04.1) ... -1765884784,,ui,say,==> azure-arm.this: Setting up runc (1.3.3-0ubuntu1~22.04.3) ... -1765884785,,ui,say,==> azure-arm.this: Setting up dns-root-data (2024071801~ubuntu0.22.04.1) ... -1765884785,,ui,say,==> azure-arm.this: Setting up bridge-utils (1.7-1ubuntu3) ... -1765884785,,ui,say,==> azure-arm.this: debconf: unable to initialize frontend: Dialog -1765884785,,ui,say,==> azure-arm.this: debconf: (Dialog frontend will not work on a dumb terminal%!(PACKER_COMMA) an emacs shell buffer%!(PACKER_COMMA) or without a controlling terminal.) -1765884785,,ui,say,==> azure-arm.this: debconf: falling back to frontend: Readline -1765884785,,ui,say,==> azure-arm.this: Setting up pigz (2.6-1) ... -1765884785,,ui,say,==> azure-arm.this: Setting up containerd (1.7.28-0ubuntu1~22.04.1) ... -1765884785,,ui,say,==> azure-arm.this: Created symlink /etc/systemd/system/multi-user.target.wants/containerd.service → /lib/systemd/system/containerd.service. -1765884787,,ui,say,==> azure-arm.this: Setting up ubuntu-fan (0.12.16) ... -1765884787,,ui,say,==> azure-arm.this: Created symlink /etc/systemd/system/multi-user.target.wants/ubuntu-fan.service → /lib/systemd/system/ubuntu-fan.service. -1765884788,,ui,say,==> azure-arm.this: Setting up docker.io (28.2.2-0ubuntu1~22.04.1) ... -1765884788,,ui,say,==> azure-arm.this: debconf: unable to initialize frontend: Dialog -1765884788,,ui,say,==> azure-arm.this: debconf: (Dialog frontend will not work on a dumb terminal%!(PACKER_COMMA) an emacs shell buffer%!(PACKER_COMMA) or without a controlling terminal.) -1765884788,,ui,say,==> azure-arm.this: debconf: falling back to frontend: Readline -1765884788,,ui,say,==> azure-arm.this: Adding group `docker' (GID 123) ... -1765884788,,ui,say,==> azure-arm.this: Done. -1765884789,,ui,say,==> azure-arm.this: Created symlink /etc/systemd/system/multi-user.target.wants/docker.service → /lib/systemd/system/docker.service. -1765884789,,ui,say,==> azure-arm.this: Created symlink /etc/systemd/system/sockets.target.wants/docker.socket → /lib/systemd/system/docker.socket. -1765884793,,ui,say,==> azure-arm.this: Processing triggers for dbus (1.12.20-2ubuntu4.1) ... -1765884793,,ui,say,==> azure-arm.this: Processing triggers for man-db (2.10.2-1) ... -1765884798,,ui,say,==> azure-arm.this: -1765884798,,ui,say,==> azure-arm.this: Running kernel seems to be up-to-date. -1765884798,,ui,say,==> azure-arm.this: -1765884798,,ui,say,==> azure-arm.this: No services need to be restarted. -1765884798,,ui,say,==> azure-arm.this: -1765884798,,ui,say,==> azure-arm.this: No containers need to be restarted. -1765884798,,ui,say,==> azure-arm.this: -1765884798,,ui,say,==> azure-arm.this: No user sessions are running outdated binaries. -1765884798,,ui,say,==> azure-arm.this: -1765884798,,ui,say,==> azure-arm.this: No VM guests are running outdated hypervisor (qemu) binaries on this host. -1765884800,,ui,say,==> azure-arm.this: >> Enabling cron.. -1765884800,,ui,error,==> azure-arm.this: Synchronizing state of cron.service with SysV service script with /lib/systemd/systemd-sysv-install. -1765884800,,ui,error,==> azure-arm.this: Executing: /lib/systemd/systemd-sysv-install enable cron -1765884801,,ui,say,==> azure-arm.this: >> Enabling docker.. -1765884801,,ui,say,==> azure-arm.this: >> Adding ubuntu to the docker group.. -1765884801,,ui,say,==> azure-arm.this: ## ---------- -1765884801,,ui,say,==> azure-arm.this: >> Fetching latest jq version.. -1765884802,,ui,say,==> azure-arm.this: >> Installing jq.. -1765884802,,ui,say,==> azure-arm.this: >> Downloading https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-linux-amd64.. -1765884802,,ui,say,==> azure-arm.this: >> Saved to jq-linux-amd64. -1765884802,,ui,say,==> azure-arm.this: >> Installed to /usr/bin/jq. -1765884802,,ui,say,==> azure-arm.this: >> Version: jq-1.8.1 -1765884802,,ui,say,==> azure-arm.this: ## ---------- -1765884802,,ui,say,==> azure-arm.this: >> Installing Terraform v1.5.7.. -1765884802,,ui,say,==> azure-arm.this: >> Downloading https://releases.hashicorp.com/terraform/1.5.7/terraform_1.5.7_linux_amd64.zip.. -1765884802,,ui,say,==> azure-arm.this: >> Saved to terraform_1.5.7_linux_amd64.zip. -1765884802,,ui,say,==> azure-arm.this: Archive: terraform_1.5.7_linux_amd64.zip -1765884803,,ui,say,==> azure-arm.this: inflating: terraform -1765884803,,ui,say,==> azure-arm.this: >> Installed to /usr/bin/terraform. -1765884803,,ui,say,==> azure-arm.this: ## ---------- -1765884803,,ui,say,==> azure-arm.this: >> Installing sg-runner.. -1765884803,,ui,say,==> azure-arm.this: >> Downloading https://api.github.com/repos/StackGuardian/sg-runner/tarball/v2.2.1.. -1765884804,,ui,say,==> azure-arm.this: >> Saved to runner.tar.gz. -1765884804,,ui,say,==> azure-arm.this: >> Installed to /usr/bin/sg-runner. -1765884804,,ui,say,==> azure-arm.this: ## ---------- -1765884804,,ui,say,==> azure-arm.this: >> Cleaning up image setup.. -1765884804,,ui,say,==> azure-arm.this: Removed temporary directory: /tmp/tmp.ogAPwQRMS1 -1765884804,,ui,say,==> azure-arm.this: Removed temporary directory: /tmp/tmp.uoOadEB7B8 -1765884804,,ui,say,==> azure-arm.this: Removed temporary directory: /tmp/tmp.3hNN5m4p7t -1765884804,,ui,say,==> azure-arm.this: Provisioning with shell script: /var/folders/vl/chc94nw176g44f98ptf4xkmh0000gn/T/packer-shell2691571155 -1765884806,,ui,error,==> azure-arm.this: /usr/sbin/waagent:27: DeprecationWarning: the imp module is deprecated in favour of importlib and slated for removal in Python 3.12; see the module's documentation for alternative uses -1765884806,,ui,error,==> azure-arm.this: import imp -1765884808,,ui,say,==> azure-arm.this: WARNING! The waagent service will be stopped. -1765884808,,ui,say,==> azure-arm.this: WARNING! Cached DHCP leases will be deleted. -1765884808,,ui,say,==> azure-arm.this: WARNING! root password will be disabled. You will not be able to login as root. -1765884808,,ui,say,==> azure-arm.this: WARNING! /etc/resolv.conf will NOT be removed%!(PACKER_COMMA) this is a behavior change to earlier versions of Ubuntu. -1765884808,,ui,say,==> azure-arm.this: WARNING! ubuntu account and entire home directory will be deleted. -1765884808,,ui,say,==> azure-arm.this: Powering off machine ... -1765884808,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' -1765884808,,ui,say,==> azure-arm.this: -> ComputeName : 'pkrvmbor1pewn1z' -1765884849,,ui,say,==> azure-arm.this: -> Compute ResourceGroupName : 'pkr-Resource-Group-bor1pewn1z' -1765884849,,ui,say,==> azure-arm.this: -> Compute Name : 'pkrvmbor1pewn1z' -1765884849,,ui,say,==> azure-arm.this: -> Compute Location : 'westeurope' -1765884849,,ui,say,==> azure-arm.this: Generalizing machine ... -1765884850,,ui,say,==> azure-arm.this: Capturing image ... -1765884850,,ui,say,==> azure-arm.this: -> Image ResourceGroupName : 'adis-runner' -1765884850,,ui,say,==> azure-arm.this: -> Image Name : 'sg-runner-ubuntu-22_04-lts-gen2-1765884676' -1765884850,,ui,say,==> azure-arm.this: -> Image Location : 'westeurope' -1765884861,,ui,say,==> azure-arm.this: \n==> azure-arm.this: Deleting Virtual Machine deployment and its attached resources... -1765884872,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Compute/virtualMachines : 'pkrvmbor1pewn1z' -1765884884,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Network/networkInterfaces : 'pkrnibor1pewn1z' -1765884894,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Network/publicIPAddresses : 'pkripbor1pewn1z' -1765884895,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Network/virtualNetworks : 'pkrvnbor1pewn1z' -1765884898,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Network/networkSecurityGroups : 'pkrsgbor1pewn1z' -1765884909,,ui,say,==> azure-arm.this: Deleted -> Microsoft.Compute/disks : '/subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/resourceGroups/pkr-Resource-Group-bor1pewn1z/providers/Microsoft.Compute/disks/pkrosbor1pewn1z' -1765884909,,ui,say,==> azure-arm.this: Removing the created Deployment object: 'pkrdpbor1pewn1z' -1765884922,,ui,say,==> azure-arm.this: \n==> azure-arm.this: Cleanup requested%!(PACKER_COMMA) deleting resource group ... -1765884933,,ui,say,==> azure-arm.this: Resource group has been deleted. -1765884933,,ui,say,Build 'azure-arm.this' finished after 4 minutes 16 seconds. -1765884933,,ui,say,\n==> Wait completed after 4 minutes 16 seconds -1765884933,,ui,say,\n==> Builds finished. The artifacts of successful builds are: -1765884933,azure-arm.this,artifact-count,1 -1765884933,azure-arm.this,artifact,0,builder-id,Azure.ResourceManagement.VMImage -1765884933,azure-arm.this,artifact,0,id,/subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/resourceGroups/adis-runner/providers/Microsoft.Compute/images/sg-runner-ubuntu-22_04-lts-gen2-1765884676 -1765884933,azure-arm.this,artifact,0,string,Azure.ResourceManagement.VMImage:\n\nOSType: Linux\nManagedImageResourceGroupName: adis-runner\nManagedImageName: sg-runner-ubuntu-22_04-lts-gen2-1765884676\nManagedImageId: /subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/resourceGroups/adis-runner/providers/Microsoft.Compute/images/sg-runner-ubuntu-22_04-lts-gen2-1765884676\nManagedImageLocation: westeurope\n -1765884933,azure-arm.this,artifact,0,files-count,0 -1765884933,azure-arm.this,artifact,0,end -1765884933,,ui,say,--> azure-arm.this: Azure.ResourceManagement.VMImage:\n\nOSType: Linux\nManagedImageResourceGroupName: adis-runner\nManagedImageName: sg-runner-ubuntu-22_04-lts-gen2-1765884676\nManagedImageId: /subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/resourceGroups/adis-runner/providers/Microsoft.Compute/images/sg-runner-ubuntu-22_04-lts-gen2-1765884676\nManagedImageLocation: westeurope\n +1771931887,,ui,say,==> azure-arm.this: Running builder ... +1771931887,,ui,say,==> azure-arm.this: Creating Azure Resource Manager (ARM) client ... +1771931888,,ui,say,==> azure-arm.this: ARM Client successfully created +1771931888,,ui,say,==> azure-arm.this: Getting source image id for the deployment ... +1771931888,,ui,say,==> azure-arm.this: -> SourceImageName: '/subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/providers/Microsoft.Compute/locations/germanywestcentral/publishers/Canonical/ArtifactTypes/vmimage/offers/0001-com-ubuntu-server-jammy/skus/22_04-lts-gen2/versions/latest' +1771931889,,ui,say,==> azure-arm.this: Creating resource group ... +1771931889,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-a5b77kpjnq' +1771931889,,ui,say,==> azure-arm.this: -> Location : 'germanywestcentral' +1771931889,,ui,say,==> azure-arm.this: -> Tags : +1771931889,,ui,say,==> azure-arm.this: ->> os : ubuntu +1771931889,,ui,say,==> azure-arm.this: ->> purpose : stackguardian-private-runner +1771931891,,ui,say,==> azure-arm.this: Validating deployment template ... +1771931891,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-a5b77kpjnq' +1771931891,,ui,say,==> azure-arm.this: -> DeploymentName : 'pkrdpa5b77kpjnq' From 3e538af9ae1acff07de1bf49c99e1b471ccc647d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 27 Apr 2026 13:54:09 +0200 Subject: [PATCH 10/37] SG-3995: New Azure VMSS module. --- .../azure/vmss/README.md | 40 +++ .../azure/vmss/data.tf | 1 + .../azure/vmss/locals.tf | 60 ++++ .../azure/vmss/network.tf | 125 ++++++++ .../azure/vmss/outputs.tf | 57 ++++ .../azure/vmss/provider.tf | 26 ++ .../vmss/templates/register_runner.sh.tpl | 46 +++ .../azure/vmss/variables.tf | 270 ++++++++++++++++++ .../azure/vmss/vmss.tf | 80 ++++++ 9 files changed, 705 insertions(+) create mode 100644 stackguardian_private_runner/azure/vmss/README.md create mode 100644 stackguardian_private_runner/azure/vmss/data.tf create mode 100644 stackguardian_private_runner/azure/vmss/locals.tf create mode 100644 stackguardian_private_runner/azure/vmss/network.tf create mode 100644 stackguardian_private_runner/azure/vmss/outputs.tf create mode 100644 stackguardian_private_runner/azure/vmss/provider.tf create mode 100644 stackguardian_private_runner/azure/vmss/templates/register_runner.sh.tpl create mode 100644 stackguardian_private_runner/azure/vmss/variables.tf create mode 100644 stackguardian_private_runner/azure/vmss/vmss.tf diff --git a/stackguardian_private_runner/azure/vmss/README.md b/stackguardian_private_runner/azure/vmss/README.md new file mode 100644 index 0000000..0126890 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/README.md @@ -0,0 +1,40 @@ +# Azure VM Scale Set — StackGuardian Private Runner + +Provisions a Linux VM Scale Set whose instances boot from a custom image +(produced by `azure/packer`) and self-register with the StackGuardian +platform. The scale set is meant to be paired with `azure/autoscaler`, +which scales it in/out based on pending-job count. + +## What this module creates + +- `azurerm_linux_virtual_machine_scale_set` — runs the runner image +- VNet + Subnet (optional, when `network.create_network = true`) +- NSG + rules +- NAT Gateway + Public IP (optional, when `network.create_network_infrastructure = true`) + +## Wiring with `azure/autoscaler` + +Pass this module's outputs into the autoscaler: + +```hcl +module "vmss" { + source = "../vmss" + # ... +} + +module "autoscaler" { + source = "../autoscaler" + + vmss = { + name = module.vmss.vmss_name + resource_group_name = module.vmss.vmss_resource_group_name + } + # ... +} +``` + +## Notes + +- `instances` is `ignore_changes`d after first apply so the autoscaler can drive count without Terraform fighting it. +- `upgrade_mode = "Manual"` — image/SKU changes do **not** roll existing instances; trigger an instance refresh explicitly when you ship a new image. +- SSH key generation is opt-in (`firewall.generate_ssh_key = true`) to avoid storing private keys in Terraform state by default. Prefer providing your own `firewall.ssh_public_key`. 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..b51b507 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/locals.tf @@ -0,0 +1,60 @@ +data "external" "env" { + program = [ + "sh", + "-c", + "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\", \"sg_api_uri\": \"'$${SG_API_URI:-https://api.app.stackguardian.io}'\"}'" + ] +} + +locals { + # StackGuardian configuration - use provided values or extract from environment + sg_org_name = ( + var.stackguardian.org_name != "" + ? var.stackguardian.org_name + : data.external.env.result.sg_org_name + ) + sg_api_uri = ( + var.stackguardian.api_uri != "" + ? var.stackguardian.api_uri + : data.external.env.result.sg_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 + + # 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 && var.override_names.org_name != "" + ? "${var.override_names.global_prefix}_${var.override_names.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..6289518 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/network.tf @@ -0,0 +1,125 @@ +/*-------------------------------------------+ + | 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] +} + +/*-------------------------------------------+ + | 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..29e2ad8 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/provider.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.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/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..1c4f91c --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/variables.tf @@ -0,0 +1,270 @@ +/*--------------------------------+ + | 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)." + } +} + +/*-----------------------------------+ + | 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..e57e6ae --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/vmss.tf @@ -0,0 +1,80 @@ +/*-------------------------------------------+ + | 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 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. +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 = "Manual" + + tags = merge(local.common_tags, { + Name = local.vmss_name + }) + + lifecycle { + # Autoscaler drives instance count after first apply. + ignore_changes = [instances] + } +} From 0a43ad9cc98513f88ba44c60842416c90838e952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 27 Apr 2026 13:54:23 +0200 Subject: [PATCH 11/37] SG-3995: Update packer module. --- .../azure/packer/image.pkr.hcl | 4 +- .../azure/packer/main.tf | 47 ++++---- .../azure/packer/scripts/build_image.sh | 1 + .../azure/packer/scripts/cleanup_image.sh | 79 +++++++++++++ .../azure/packer/scripts/setup.sh | 111 +++++++++++++++++- .../azure/packer/variables.tf | 12 +- 6 files changed, 226 insertions(+), 28 deletions(-) create mode 100755 stackguardian_private_runner/azure/packer/scripts/cleanup_image.sh diff --git a/stackguardian_private_runner/azure/packer/image.pkr.hcl b/stackguardian_private_runner/azure/packer/image.pkr.hcl index 1ea8d15..d9409d7 100644 --- a/stackguardian_private_runner/azure/packer/image.pkr.hcl +++ b/stackguardian_private_runner/azure/packer/image.pkr.hcl @@ -17,6 +17,7 @@ variable "user_script" {} variable "vnet_name" { default = "" } variable "subnet_name" { default = "" } variable "vnet_resource_group_name" { default = "" } +variable "proxy_url" { default = "" } packer { required_plugins { @@ -74,7 +75,8 @@ build { "TERRAFORM_VERSIONS=${var.terraform_versions}", "OPENTOFU_VERSION=${var.opentofu_version}", "OPENTOFU_VERSIONS=${var.opentofu_versions}", - "USER_SCRIPT=${var.user_script}" + "USER_SCRIPT=${var.user_script}", + "PROXY_URL=${var.proxy_url}" ] } diff --git a/stackguardian_private_runner/azure/packer/main.tf b/stackguardian_private_runner/azure/packer/main.tf index 16180c4..0a8410b 100644 --- a/stackguardian_private_runner/azure/packer/main.tf +++ b/stackguardian_private_runner/azure/packer/main.tf @@ -20,26 +20,27 @@ resource "null_resource" "packer_build" { working_dir = path.module command = "sh scripts/build_image.sh" environment = { - PACKER_VERSION = var.packer_config.version - AZURE_LOCATION = var.azure_location - RESOURCE_GROUP_NAME = local.resource_group_name - VM_SIZE = var.vm_size - IMAGE_PUBLISHER = var.os.publisher - IMAGE_OFFER = var.os.offer - IMAGE_SKU = var.os.sku - IMAGE_VERSION = var.os.version - IMAGE_NAME_PREFIX = var.image_name_prefix - OS_FAMILY = local.os_family - SSH_USERNAME = local.ssh_username - UPDATE_OS = var.os.update_os_before_install - USER_SCRIPT = var.os.user_script - TERRAFORM_VERSION = var.terraform.primary_version - TERRAFORM_VERSIONS = join(" ", var.terraform.additional_versions) - OPENTOFU_VERSION = var.opentofu.primary_version - OPENTOFU_VERSIONS = join(" ", var.opentofu.additional_versions) - VNET_NAME = var.network.vnet_name - SUBNET_NAME = var.network.subnet_name + PACKER_VERSION = var.packer_config.version + AZURE_LOCATION = var.azure_location + RESOURCE_GROUP_NAME = local.resource_group_name + VM_SIZE = var.vm_size + IMAGE_PUBLISHER = var.os.publisher + IMAGE_OFFER = var.os.offer + IMAGE_SKU = var.os.sku + IMAGE_VERSION = var.os.version + IMAGE_NAME_PREFIX = var.image_name_prefix + OS_FAMILY = local.os_family + SSH_USERNAME = local.ssh_username + UPDATE_OS = var.os.update_os_before_install + USER_SCRIPT = var.os.user_script + TERRAFORM_VERSION = var.terraform.primary_version + TERRAFORM_VERSIONS = join(" ", var.terraform.additional_versions) + OPENTOFU_VERSION = var.opentofu.primary_version + OPENTOFU_VERSIONS = join(" ", var.opentofu.additional_versions) + VNET_NAME = var.network.vnet_name + SUBNET_NAME = var.network.subnet_name VNET_RESOURCE_GROUP_NAME = var.network.resource_group_name + PROXY_URL = var.network.proxy_url } } @@ -79,10 +80,10 @@ resource "null_resource" "image_cleanup" { provisioner "local-exec" { when = destroy - command = <<-EOT - echo "Deleting Azure managed image: ${self.triggers.image_id}" - az image delete --ids "${self.triggers.image_id}" || true - EOT + command = "sh ${self.triggers.script_path}" + environment = { + TARGET_IMAGE_ID = self.triggers.image_id + } } depends_on = [null_resource.packer_build] diff --git a/stackguardian_private_runner/azure/packer/scripts/build_image.sh b/stackguardian_private_runner/azure/packer/scripts/build_image.sh index f15f968..82be4c2 100644 --- a/stackguardian_private_runner/azure/packer/scripts/build_image.sh +++ b/stackguardian_private_runner/azure/packer/scripts/build_image.sh @@ -120,6 +120,7 @@ main() { #{{{ -var "vnet_name=$VNET_NAME" \ -var "subnet_name=$SUBNET_NAME" \ -var "vnet_resource_group_name=$VNET_RESOURCE_GROUP_NAME" \ + -var "proxy_url=$PROXY_URL" \ -machine-readable \ ./image.pkr.hcl | tee packer_manifest.log } 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/scripts/setup.sh b/stackguardian_private_runner/azure/packer/scripts/setup.sh index 9b13565..04f9011 100644 --- a/stackguardian_private_runner/azure/packer/scripts/setup.sh +++ b/stackguardian_private_runner/azure/packer/scripts/setup.sh @@ -10,6 +10,26 @@ OS_TYPE="" WORKING_DIR="" TEMP_DIRS="" +# Configure proxy settings if provided. +# Sets shell + wget proxy globally; package-manager proxy is configured +# inside the per-OS dependency function so the file lands in the right place. +_configure_proxy() { #{{{ + if [ -n "$PROXY_URL" ]; then + 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" + + { + echo "http_proxy = $PROXY_URL" + echo "https_proxy = $PROXY_URL" + echo "use_proxy = on" + } >> ~/.wgetrc + fi +} +#}}}: _configure_proxy + _cleanup() { #{{{ echo "## ----------" echo ">> Cleaning up image setup.." @@ -51,6 +71,10 @@ _apt_dependencies() { #{{{ done if [ "$UPDATE_OS" = "true" ]; then + 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-get update fi sudo apt-get install -y \ @@ -63,6 +87,9 @@ _apt_dependencies() { #{{{ _dnf_dependencies() { #{{{ if [ "$UPDATE_OS" = "true" ]; then + if [ -n "$PROXY_URL" ]; then + echo "proxy=$PROXY_URL" | sudo tee -a /etc/dnf/dnf.conf + fi sudo dnf update -y fi sudo dnf install -y \ @@ -266,12 +293,29 @@ _install_opentofu_versions() { #{{{ #}}}: _install_opentofu_versions _install_sg_runner() { #{{{ - url="$(wget -qO- "https://api.github.com/repos/stackguardian/sg-runner/releases/latest" | jq -r '.tarball_url')" runner_archive="runner.tar.gz" + github_api_base="https://api.github.com/repos/stackguardian/sg-runner" echo "## ----------" echo ">> Installing sg-runner.." + 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')" + if [ -z "$url" ]; then + echo ">> No pre-release found, falling back to latest stable.." + url="$(wget -qO- "${github_api_base}/releases/latest" | jq -r '.tarball_url')" + fi + else + echo ">> Fetching latest stable release.." + url="$(wget -qO- "${github_api_base}/releases/latest" | jq -r '.tarball_url')" + fi + + if [ -z "$url" ]; then + echo "ERROR: Failed to fetch sg-runner release URL" + exit 1 + fi + _mktemp_directory && cd "$WORKING_DIR" if _wget_wrapper "$url" "$runner_archive"; then @@ -283,9 +327,71 @@ _install_sg_runner() { #{{{ echo "ERROR: Failed to download from: $url" exit 1 fi + + # 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 +_install_sg_runner_update() { #{{{ + echo "## ----------" + echo ">> Installing sg-runner-update script.." + + 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" + +SG_RUNNER_PRE_RELEASE="false" +if [ -f "$CONFIG_FILE" ]; then + . "$CONFIG_FILE" +fi + +if [ -n "$1" ]; then + echo ">> Downloading sg-runner ref: $1" + url="${GITHUB_API_BASE}/tarball/$1" +else + 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')" + if [ -z "$url" ]; then + echo ">> No pre-release found, falling back to latest stable.." + url="$(wget -qO- "${GITHUB_API_BASE}/releases/latest" | jq -r '.tarball_url')" + fi + else + echo ">> Fetching latest stable release.." + url="$(wget -qO- "${GITHUB_API_BASE}/releases/latest" | jq -r '.tarball_url')" + fi +fi + +if [ -z "$url" ]; then + echo "ERROR: Failed to determine download URL" + exit 1 +fi + +TEMP_DIR="$(mktemp -d)" +trap "rm -rf '$TEMP_DIR'" EXIT + +cd "$TEMP_DIR" +echo ">> Downloading from: $url" +wget -q "$url" -O runner.tar.gz + +tar -xf runner.tar.gz +sudo cp -rf StackGuardian-sg-runner*/main.sh /usr/bin/sg-runner + +echo ">> sg-runner updated successfully!" +echo ">> Installed to: $(which sg-runner)" +SCRIPT_EOF + + sudo chmod +x /usr/bin/sg-runner-update + echo ">> Installed sg-runner-update to /usr/bin/sg-runner-update" +} +#}}}: _install_sg_runner_update + _user_script_wrapper() { #{{{ script="$USER_SCRIPT" @@ -324,6 +430,8 @@ main() { #{{{ OS_ARCH="$(_detect_arch)" OS_TYPE="$(_detect_os)" + _configure_proxy + _handle_os_package_installation _install_jq @@ -335,6 +443,7 @@ main() { #{{{ _install_opentofu_versions _install_sg_runner + _install_sg_runner_update _user_script_wrapper } diff --git a/stackguardian_private_runner/azure/packer/variables.tf b/stackguardian_private_runner/azure/packer/variables.tf index 10b782d..1a1bc1e 100644 --- a/stackguardian_private_runner/azure/packer/variables.tf +++ b/stackguardian_private_runner/azure/packer/variables.tf @@ -28,11 +28,17 @@ variable "vm_size" { | Image Build Network Settings | +----------------------------*/ variable "network" { - description = "Network configuration for the Packer build instance. Leave empty to let Packer create temporary networking." + description = < Date: Mon, 27 Apr 2026 13:54:32 +0200 Subject: [PATCH 12/37] SG-3995: Update azure_runner module. --- .../azure/azure_runner/locals.tf | 4 ++ .../azure/azure_runner/network.tf | 49 +++++++++++++++++ .../templates/register_runner.sh.tpl | 10 ++++ .../azure/azure_runner/variables.tf | 52 ++++++++++++++----- .../azure/azure_runner/vm.tf | 1 + 5 files changed, 104 insertions(+), 12 deletions(-) diff --git a/stackguardian_private_runner/azure/azure_runner/locals.tf b/stackguardian_private_runner/azure/azure_runner/locals.tf index ce1f12c..61b2e7e 100644 --- a/stackguardian_private_runner/azure/azure_runner/locals.tf +++ b/stackguardian_private_runner/azure/azure_runner/locals.tf @@ -24,6 +24,10 @@ locals { 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 + # Subnet ID (created or existing) subnet_id = ( local.create_network diff --git a/stackguardian_private_runner/azure/azure_runner/network.tf b/stackguardian_private_runner/azure/azure_runner/network.tf index 4492205..396b67c 100644 --- a/stackguardian_private_runner/azure/azure_runner/network.tf +++ b/stackguardian_private_runner/azure/azure_runner/network.tf @@ -81,6 +81,55 @@ resource "azurerm_network_security_group" "this" { }) } +/*-------------------------------------------+ + | 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) | +-------------------------------------------*/ 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 index 48f48ce..f854039 100644 --- a/stackguardian_private_runner/azure/azure_runner/templates/register_runner.sh.tpl +++ b/stackguardian_private_runner/azure/azure_runner/templates/register_runner.sh.tpl @@ -6,6 +6,16 @@ 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. diff --git a/stackguardian_private_runner/azure/azure_runner/variables.tf b/stackguardian_private_runner/azure/azure_runner/variables.tf index 670d806..53297d3 100644 --- a/stackguardian_private_runner/azure/azure_runner/variables.tf +++ b/stackguardian_private_runner/azure/azure_runner/variables.tf @@ -13,7 +13,7 @@ variable "vm_image_id" { The image must have: docker, cron, jq, and sg-runner installed. Example: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/images/{name} EOT - type = string + type = string validation { condition = can(regex("^/subscriptions/", var.vm_image_id)) @@ -61,10 +61,24 @@ variable "stackguardian" { description = "StackGuardian platform configuration for runner registration" type = object({ api_key = string + api_uri = optional(string, "https://api.app.stackguardian.io") org_name = optional(string, "") - api_uri = 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)." + } } /*-------------------+ @@ -105,16 +119,21 @@ variable "network" { - vnet_address_space: Address space for new VNet (when create_network = true) - subnet_address_prefix: Address prefix for new subnet (when create_network = true) - associate_public_ip: Whether to assign public IP to the VM + - create_network_infrastructure: Whether to create a NAT Gateway (with public IP) and associate it with the subnet for outbound internet access from a private subnet. + When disabled, ensure the subnet has its own route to the internet (NAT, firewall, ExpressRoute, etc.) for StackGuardian platform connectivity. + - proxy_url: HTTP proxy URL for private network deployments (e.g., http://proxy.example.com:8080) - additional_nsg_ids: Additional NSG IDs to associate with the NIC EOT type = object({ - create_network = optional(bool, false) - vnet_id = optional(string, "") - subnet_id = optional(string, "") - vnet_address_space = optional(list(string), ["10.0.0.0/16"]) - subnet_address_prefix = optional(string, "10.0.1.0/24") - associate_public_ip = optional(bool, false) - additional_nsg_ids = optional(list(string), []) + create_network = optional(bool, false) + vnet_id = optional(string, "") + subnet_id = optional(string, "") + vnet_address_space = optional(list(string), ["10.0.0.0/16"]) + subnet_address_prefix = optional(string, "10.0.1.0/24") + associate_public_ip = optional(bool, false) + create_network_infrastructure = optional(bool, false) + proxy_url = optional(string, "") + additional_nsg_ids = optional(list(string), []) }) validation { @@ -162,11 +181,20 @@ variable "os_disk" { | SSH Connection Variables | +------------------------------*/ variable "firewall" { - description = "Firewall and SSH configuration for the Private Runner instance" + description = < Date: Mon, 27 Apr 2026 13:54:51 +0200 Subject: [PATCH 13/37] SG-3995: Update azure autoscaler module. --- .../azure/autoscaler/function_autoscaler.tf | 49 ----------------- .../azure/autoscaler/locals.tf | 17 +++--- .../azure/autoscaler/rbac.tf | 53 +++++++++++++++++++ .../azure/autoscaler/variables.tf | 32 ++++++++++- 4 files changed, 94 insertions(+), 57 deletions(-) create mode 100644 stackguardian_private_runner/azure/autoscaler/rbac.tf diff --git a/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf b/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf index 6298406..5cb8b68 100644 --- a/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf +++ b/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf @@ -104,52 +104,3 @@ resource "null_resource" "deploy_function_code" { } } -/*-------------------------------------------+ - | Role Assignments for Function Identity | - +-------------------------------------------*/ - -# Allow Function App to manage VM Scale Set -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 -} - -# Allow Function App to read VMSS instances -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 -} - -# Allow Function App to access storage -# RBAC mode requires Storage Blob Data Owner for runtime host coordination; -# connection string mode only needs Storage Blob Data Contributor for app-level blob operations -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 and Table roles required for 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 -} - -# Allow Function App to join VMs to network resources (VNet subnets, NSGs) -# Required for VMSS scaling operations -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/locals.tf b/stackguardian_private_runner/azure/autoscaler/locals.tf index 41328e1..29bc369 100644 --- a/stackguardian_private_runner/azure/autoscaler/locals.tf +++ b/stackguardian_private_runner/azure/autoscaler/locals.tf @@ -52,12 +52,12 @@ locals { # 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 + 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" @@ -76,6 +76,9 @@ locals { 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 @@ -84,7 +87,7 @@ locals { # 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 } 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/variables.tf b/stackguardian_private_runner/azure/autoscaler/variables.tf index 60f17fc..789adc3 100644 --- a/stackguardian_private_runner/azure/autoscaler/variables.tf +++ b/stackguardian_private_runner/azure/autoscaler/variables.tf @@ -67,7 +67,19 @@ variable "vmss" { | Azure Function Autoscaling Vars | +-----------------------------------*/ variable "scaling" { - description = "Auto scaling configuration for the Private Runner" + description = <= 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)." + } } /*---------------------------+ From 3505fef27e83deeb641e4b39cb3620cf147e2348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 27 Apr 2026 13:55:59 +0200 Subject: [PATCH 14/37] SG-3995: Update runner_group. --- .../runner_group/runner_group.tf | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/stackguardian_private_runner/runner_group/runner_group.tf b/stackguardian_private_runner/runner_group/runner_group.tf index a61587b..cd94716 100644 --- a/stackguardian_private_runner/runner_group/runner_group.tf +++ b/stackguardian_private_runner/runner_group/runner_group.tf @@ -7,20 +7,20 @@ resource "stackguardian_runner_group" "this" { max_number_of_runners = var.max_runners storage_backend_config = local.is_aws ? { - type = "aws_s3" - aws_region = var.aws_region - s3_bucket_name = local.s3_bucket_name - azure_blob_storage_account_name = null - azure_blob_storage_access_key = null + type = "aws_s3" + aws_region = var.aws_region + s3_bucket_name = local.s3_bucket_name + azure_blob_storage_account_name = null + azure_blob_storage_access_key = null auth = { 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_storage_account_name - azure_blob_storage_access_key = local.azure_storage_access_key + } : { + type = "azure_blob_storage" + aws_region = null + s3_bucket_name = null + azure_blob_storage_account_name = local.azure_storage_account_name + azure_blob_storage_access_key = local.azure_storage_access_key auth = { integration_id = "/integrations/${stackguardian_connector.azure[0].resource_name}" } From 64ace3ef2518ce3db553027c97794dd380d3abea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Tue, 28 Apr 2026 11:58:34 +0200 Subject: [PATCH 15/37] SG-3995: Update runner_group docs and schema form. --- .../runner_group/DOCUMENTATION.md | 98 +++--- .../runner_group/README.md | 295 ++++++++++-------- .../runner_group/schemas/input_schema.json | 277 +++++++++------- .../runner_group/schemas/ui_schema.json | 2 +- 4 files changed, 403 insertions(+), 269 deletions(-) diff --git a/stackguardian_private_runner/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/runner_group/DOCUMENTATION.md index 1d12f97..acbebc0 100644 --- a/stackguardian_private_runner/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/runner_group/DOCUMENTATION.md @@ -1,23 +1,30 @@ -# StackGuardian Runner Group - AWS Template +# StackGuardian Runner Group - AWS or Azure Template -Deploy a StackGuardian Runner Group with AWS S3 storage backend directly from the StackGuardian platform. +Deploy a StackGuardian Runner Group with a cloud storage backend (AWS S3 or Azure Blob Storage) 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. +This template provisions everything required to run private runners against either AWS or Azure. It creates a runner group on the StackGuardian platform, sets up a private storage backend for workflow artifacts, and configures secure access between StackGuardian and the chosen cloud account. Default tags ("StackGuardian Private Runner", the runner group name, and the organization name) are applied automatically to the StackGuardian resources. ### 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 +**Always:** +- **Runner Group** — A dedicated group on the StackGuardian platform to organize your private runners. +- **Cloud Connector** — Secure integration between StackGuardian and your cloud account (AWS RBAC role for AWS; OIDC federation with an Azure AD application for Azure). + +**For AWS:** +- **S3 Storage Bucket** — Private bucket for workflow outputs and artifacts (or an existing bucket). +- **IAM Access Role** — Cross-account role with an external ID for secure platform access. + +**For Azure:** +- **Azure Storage Account + private "runner" container** — Storage for workflow outputs and artifacts (or an existing storage account). +- **Azure AD application + service principal** — Identity for the OIDC connector, granted `Storage Blob Data Reader` on the storage account. ## 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 +- A StackGuardian API key for your organization. +- For AWS: AWS account credentials in your StackGuardian workspace with permissions to create S3 buckets and IAM roles. +- For Azure: Azure account credentials in your StackGuardian workspace with permissions to create Storage Accounts, Azure AD applications, service principals, and role assignments — plus an **existing Azure Resource Group** to host the storage account. ## Template Parameters @@ -25,57 +32,74 @@ This template sets up everything you need to run private runners in your AWS env | Parameter | Description | Type | |-----------|-------------|------| -| API Key | Your organization's API key on the StackGuardian Platform | Password | +| API Key | Your organization's API key on the StackGuardian Platform (`sgu_*`/`sgo_*`) or a secret reference (`${secret::SECRET_NAME}`) | Password | + +When **Cloud Provider** is set to **Azure** and **Create Storage Backend** is enabled, **Azure Resource Group Name** is also required. ### 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 | +| API Region | Your StackGuardian platform region (EU1 / US1 / DASH) | EU1 - Europe | +| Organization Name | Your organization name (auto-detected from environment if omitted) | Auto-detected | +| Cloud Provider | Cloud provider for the storage backend (AWS or Azure) | AWS | | 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 | +| Azure Region | The Azure region where storage resources will be deployed | westeurope | +| Azure Resource Group Name | Name of the existing Azure Resource Group for the storage account | — | +| Create Storage Backend | Whether to create a new storage backend (S3 bucket for AWS, Storage Account for Azure) | Enabled | +| Existing S3 Bucket Name | Name of an existing S3 bucket to use (AWS, when not creating new) | — | +| Existing Azure Storage Account Name | Name of an existing Azure Storage Account to use (Azure, when not creating new) | — | +| Existing Azure Storage Account Access Key | Access key for the existing Azure Storage Account (Azure, sensitive) | — | +| Force Destroy Storage Backend | Delete all data in the S3 bucket on destroy (AWS only, use with caution) | Disabled | +| Azure Storage — Account Tier | Performance tier of the Azure Storage Account (Standard / Premium) | Standard | +| Azure Storage — Replication Type | Replication strategy of the Azure Storage Account (LRS / GRS / RAGRS / ZRS) | LRS | +| Global Prefix | Prefix used for naming all resources | SG_RUNNER | +| Include Organization Name in Prefix | Append the org name to the prefix (e.g. `SG_RUNNER_demo-org`) | Disabled | | Runner Group Name Override | Custom name for the runner group | Auto-generated | -| Connector Name Override | Custom name for the AWS connector | Auto-generated | +| Connector Name Override | Custom name for the cloud 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). +**Cloud Provider**: The Cloud Provider toggle drives every other Azure / AWS option. Switching it after deployment will recreate cloud resources, so choose carefully up front. + +**Azure Resource Group**: The template **does not create an Azure Resource Group**. You must point Azure Resource Group Name at an existing one when creating a new Azure storage backend. + +**API Key Security**: The API key is stored securely and used only to authenticate with the StackGuardian platform. It must be `sgu_*` (user key), `sgo_*` (organization key), or a `${secret::SECRET_NAME}` reference. -**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. +**Storage Backend Options**: You can either create a new storage backend (recommended) or point to an existing one. When using an existing S3 bucket or Azure Storage Account, ensure it has the appropriate permissions and CORS configuration. -**Resource Naming**: By default, resources are named using the pattern `SG_RUNNER-{type}-{aws_account_id}`. You can customize this using the naming options. +**Resource Naming**: By default, resources use the pattern `SG_RUNNER-{type}-{account_or_subscription_id}`. Customize via the naming options if you need stable, project-specific names. -**Data Retention**: The `Force Destroy Storage Backend` option will delete all data when the template is destroyed. Leave this disabled to protect your data. +**Data Retention**: **Force Destroy Storage Backend** (AWS only) deletes all bucket contents on destroy. Leave it disabled to protect your data. The Azure Storage Account is always destroyed on `terraform destroy` along with its contents — back up anything you need first. ## 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 | +| Runner Group Name | Name of the created runner group, used in workflow configurations | +| Runner Group Token | Authentication token for registering runners (sensitive) | +| Runner Group URL | Direct link to manage the runner group in the StackGuardian console | +| Connector Name | Name of the AWS or Azure connector integration | +| S3 Bucket Name | Name of the storage bucket (AWS only) | +| Storage Backend Role ARN | IAM role ARN required by AWS runner instances (AWS only) | +| Azure Storage Account Name | Name of the Azure Storage Account (Azure only) | +| Azure Storage Access Key | Access key for the Azure Storage Account (Azure only, sensitive) | ## 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 +- **Private storage** — S3 bucket has public access blocked; Azure Storage Account disables nested public items and enforces TLS 1.2 minimum. +- **Scoped access** — AWS IAM policy grants only the S3 actions runners need; Azure service principal is granted only `Storage Blob Data Reader` on the storage account. +- **Cross-account / federated identity** — AWS uses a cross-account role with an external ID; Azure uses OIDC federation, so no long-lived secret is stored on the platform. +- **CORS protection** — Both backends accept requests only from the StackGuardian platform origin. +- **Sensitive output protection** — Runner registration tokens and Azure storage access keys are marked sensitive in module outputs. ## 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 +1. **Deploy Runners** — Pass the runner group name, token, and storage details to the matching runner template: + - **AWS**: `runner_group_name`, `runner_group_token`, `s3_bucket_name`, `storage_backend_role_arn` → AWS Autoscaled Runner / AWS Runner. + - **Azure**: `runner_group_name`, `runner_group_token`, `azure_storage_account_name`, `azure_storage_access_key` → Azure VMSS Autoscaled Runner. +2. **Configure Workflows** — Reference the runner group in your workflow configurations to execute jobs on private runners. +3. **Monitor Runners** — Open 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 acecf72..b130b2e 100644 --- a/stackguardian_private_runner/runner_group/README.md +++ b/stackguardian_private_runner/runner_group/README.md @@ -1,57 +1,55 @@ -# StackGuardian Runner Group Module +# StackGuardian Runner Group - AWS or Azure Module -This Terraform module creates a StackGuardian Runner Group with cloud storage backend and connector integration for running private runners in AWS or Azure environments. +This Terraform module provisions a StackGuardian Runner Group with a cloud storage backend (AWS S3 or Azure Blob Storage) and the corresponding StackGuardian connector for secure access from the StackGuardian platform. ## Overview -The module provisions all necessary StackGuardian platform resources and cloud infrastructure to enable private runner execution. It creates a runner group on the StackGuardian platform, cloud storage for artifacts (S3 in AWS or Azure Blob Storage), and the necessary authentication connectors for secure access. +The module creates everything required to host private runners against either AWS or Azure. A single `cloud_provider` toggle drives which storage backend, connector kind, and authentication path are provisioned. AWS deployments use a cross-account IAM role (RBAC connector); Azure deployments use OIDC federation with an auto-provisioned Azure AD application and service principal. ### What Gets Created -**StackGuardian Platform Resources:** -- **StackGuardian Runner Group**: Platform resource for organizing and managing private runners -- **StackGuardian Connector**: Cloud-specific connector for secure storage access from the StackGuardian platform - - **AWS**: RBAC connector using cross-account IAM role for S3 access - - **Azure**: Storage connector using account credentials for Blob Storage access -- **Storage Backend**: Cloud-specific storage for runner artifacts (optional - can use existing storage) - - **AWS**: S3 bucket with CORS configuration - - **Azure**: Blob Storage account with container +**StackGuardian Platform Resources (always):** +- **StackGuardian Runner Group** — Platform resource for organizing private runners, with `max_number_of_runners`, default tags, and the resolved storage backend configuration. +- **StackGuardian Connector** — Cloud-specific connector for storage access: + - **AWS**: `AWS_RBAC` connector using cross-account IAM role + external ID. + - **Azure**: `AZURE_OIDC` connector using federated identity from a SG-issued OIDC token. -**Cloud Infrastructure (AWS only):** -- **IAM Role**: Cross-account role for StackGuardian platform access to S3 -- **IAM Policy**: Scoped permissions for S3 bucket operations +**AWS-only resources (when `cloud_provider = "aws"`):** +- **S3 bucket** with public access block and CORS limited to the StackGuardian platform origin (created when `create_storage_backend = true`). +- **IAM role + policy** scoped to the bucket; trust policy allows StackGuardian AWS accounts (`163602625436`, `476299211833`) and the caller's account, gated by an external ID (`{org_name}:{24-char-random}`). + +**Azure-only resources (when `cloud_provider = "azure"`):** +- **Storage Account + private `runner` blob container** with TLS 1.2 minimum and CORS limited to the StackGuardian platform origin (created when `create_storage_backend = true`). +- **Azure AD application + service principal** for the OIDC connector. +- **Federated identity credential** issued by the StackGuardian API URI for the org subject `/orgs/{org_name}`. +- **`Storage Blob Data Reader` role assignment** scoped to the storage account. ## Prerequisites -- StackGuardian API key (starts with `sgu_` for user keys or `sgo_` for organization keys) -- Cloud credentials: - - **AWS**: AWS credentials with permissions to create S3 buckets and IAM roles - - **Azure**: Azure credentials with permissions to create storage accounts and containers -- Terraform >= 1.0 or OpenTofu >= 1.7 +- StackGuardian API key (`sgu_*` user key, `sgo_*` org key, or a `${secret::SECRET_NAME}` reference). +- Terraform >= 1.0 or OpenTofu >= 1.7. +- For AWS: AWS credentials with permissions to create S3 buckets and IAM roles. +- For Azure: Azure credentials (CLI / SP) with permissions to create Storage Accounts, Azure AD applications, service principals, and role assignments. An **existing Azure Resource Group** is required when `create_storage_backend = true`. ## Quick Start ### Step 1: Configure Variables -#### AWS Example - -Create a `terraform.tfvars` file: +#### AWS Example — `terraform.tfvars` ```hcl cloud_provider = "aws" 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 + api_uri = "https://api.app.stackguardian.io" + org_name = "your-org-name" } aws_region = "eu-central-1" ``` -#### Azure Example - -Create a `terraform.tfvars` file: +#### Azure Example — `terraform.tfvars` ```hcl cloud_provider = "azure" @@ -62,7 +60,7 @@ stackguardian = { org_name = "your-org-name" } -azure_location = "germanywestcentral" +azure_location = "westeurope" azure_resource_group_name = "my-resource-group" ``` @@ -74,7 +72,7 @@ terraform plan terraform apply ``` -### Basic Configuration Example +### Basic Configuration Examples #### AWS @@ -104,7 +102,7 @@ module "runner_group" { api_key = "sgu_your_api_key" } - azure_location = "germanywestcentral" + azure_location = "westeurope" azure_resource_group_name = "my-resource-group" } ``` @@ -115,79 +113,127 @@ module "runner_group" { | Parameter | Description | Type | |-----------|-------------|------| -| `stackguardian.api_key` | StackGuardian API key (must start with `sgu_` or `sgo_`) | `string` | +| `stackguardian.api_key` | StackGuardian API key (must start with `sgu_` or `sgo_`) | `string` (sensitive) | + +When `cloud_provider = "azure"` and `create_storage_backend = true`, `azure_resource_group_name` is also effectively required (the Storage Account creation will fail without an existing resource group). ### Optional Parameters | 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` | +| `cloud_provider` | Cloud provider for the storage backend (`aws` or `azure`) | `aws` | +| `stackguardian.api_uri` | StackGuardian API endpoint (EU1 / US1 / DASH) | `https://api.app.stackguardian.io` | +| `stackguardian.org_name` | Organization name; falls back to `SG_ORG_ID` env var | `""` | +| `aws_region` | Target AWS region (used when `cloud_provider = "aws"`) | `eu-central-1` | +| `azure_location` | Azure region (used when `cloud_provider = "azure"`) | `westeurope` | +| `azure_resource_group_name` | Existing Azure Resource Group for the Storage Account | `""` | +| `create_storage_backend` | Create a new storage backend (S3 bucket / Storage Account) | `true` | +| `existing_s3_bucket_name` | Existing S3 bucket name (AWS, when `create_storage_backend = false`) | `""` | +| `existing_azure_storage_account_name` | Existing Azure Storage Account name (Azure, when `create_storage_backend = false`) | `""` | +| `existing_azure_storage_account_access_key` | Access key for the existing Azure Storage Account (sensitive) | `""` | +| `force_destroy_storage_backend` | Force destroy the S3 bucket on `terraform destroy` (AWS only) | `false` | +| `azure_storage.account_tier` | Storage Account performance tier (`Standard` / `Premium`) | `Standard` | +| `azure_storage.account_replication_type` | Replication strategy (`LRS` / `GRS` / `RAGRS` / `ZRS`) | `LRS` | | `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` | +| `override_names.include_org_in_prefix` | Append org name to the prefix (e.g. `SG_RUNNER_demo-org`) | `false` | +| `override_names.runner_group_name` | Override the runner group name | Auto-generated | +| `override_names.connector_name` | Override the connector name | Auto-generated | +| `max_runners` | Maximum runners allowed in the runner group (>= 1) | `3` | ### Configuration Examples -#### Basic Configuration +#### AWS — Advanced ```hcl module "runner_group" { source = "./stackguardian_runner_group" + cloud_provider = "aws" + + 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" + } +} +``` + +#### AWS — Using an Existing S3 Bucket + +```hcl +module "runner_group" { + source = "./stackguardian_runner_group" + + cloud_provider = "aws" + stackguardian = { api_key = var.sg_api_key } - aws_region = "eu-central-1" + aws_region = "eu-central-1" + create_storage_backend = false + existing_s3_bucket_name = "my-existing-bucket" } ``` -#### Advanced Configuration +#### Azure — Advanced ```hcl module "runner_group" { source = "./stackguardian_runner_group" + cloud_provider = "azure" + 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 + azure_location = "germanywestcentral" + azure_resource_group_name = "rg-stackguardian" + + azure_storage = { + account_tier = "Standard" + account_replication_type = "ZRS" + } + + 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 +#### Azure — Using an Existing Storage Account ```hcl module "runner_group" { source = "./stackguardian_runner_group" + cloud_provider = "azure" + stackguardian = { api_key = var.sg_api_key } - aws_region = "eu-central-1" - create_storage_backend = false - existing_s3_bucket_name = "my-existing-bucket" + azure_location = "westeurope" + create_storage_backend = false + existing_azure_storage_account_name = "myexistingstorage" + existing_azure_storage_account_access_key = var.azure_storage_key } ``` @@ -196,28 +242,23 @@ module "runner_group" { ### 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 +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`. +**Warning (AWS)**: With `force_destroy_storage_backend = false` (default), the S3 bucket will not be deleted while it contains objects. Empty the bucket or set `force_destroy_storage_backend = true`. + +**Warning (Azure)**: The Storage Account is deleted along with all blob containers and contents. The Azure AD application and service principal are also removed. ## Architecture @@ -225,66 +266,63 @@ terraform destroy | 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) | +| `provider.tf` | `terraform { required_providers }` and provider blocks (AWS, Azure RM, Azure AD, StackGuardian, external, random) | +| `variables.tf` | Input variable definitions and validations | +| `locals.tf` | Computed values, naming, and per-cloud branching | +| `data.tf` | Data sources: SG runner group token, env extraction, AWS caller identity, Azure client config | +| `runner_group.tf` | StackGuardian runner group resource (selects AWS or Azure storage backend config) | +| `connector.tf` | StackGuardian connector — AWS RBAC and Azure OIDC variants | +| `storage_backend.tf` | AWS S3 bucket, public access block, CORS configuration | +| `storage_backend_role.tf` | AWS IAM role, policy, and external ID | +| `storage_backend_azure.tf` | Azure Storage Account, blob container, Azure AD app/SP, federated identity, role assignment | | `outputs.tf` | Module outputs | ### Resource Naming Convention -Resources are named using the pattern: `{prefix}-{resource-type}-{account_id}` +Resources follow `{effective_prefix}-{resource-type}-{account_identifier}`: -- 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` +- `effective_prefix`: `global_prefix` (default `SG_RUNNER`); when `include_org_in_prefix = true` and an org name is available, becomes `{global_prefix}_{org_name}`. +- `account_identifier`: AWS account ID (AWS) or Azure subscription ID (Azure). -### Security Model +Examples: +- Runner group: `SG_RUNNER-runner-group-123456789012` +- AWS connector: `SG_RUNNER-private-runner-backend-123456789012` +- AWS IAM role: `SG_RUNNER-private-runner-s3-role` +- Azure storage account: `stgbackend{prefix}{8-char-random}` (lowercase, max 24 chars) +- Azure AD application: `SG_RUNNER-sg-connector` -The module implements secure cross-account access: +### Security Model -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 +- **AWS cross-account access**: IAM role trust policy allows StackGuardian platform accounts and the caller's account to assume the role; an external ID (`{org_name}:{24-char-random}`) prevents confused-deputy attacks. IAM policy is scoped to the specific bucket and required S3 actions only. The bucket has public access blocked and CORS limited to the SG platform origin. +- **Azure OIDC federation**: A federated identity credential issued by `var.stackguardian.api_uri` for subject `/orgs/{org_name}` lets the SG platform assume the service principal — no static secret. The SP is granted only `Storage Blob Data Reader` on the storage account. Storage Account enforces TLS 1.2 minimum and disables nested public items; CORS is limited to the SG platform origin. ## 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 - + - Ensure the API key matches `^(sg[uo]_.*|\$\{secret::[A-Za-z0-9_-]+\})$` — i.e. starts with `sgu_` / `sgo_` or is a `${secret::...}` reference. 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` + - Provide `stackguardian.org_name` explicitly, or set `SG_ORG_ID` in the environment (the module extracts everything after the last `/`). +3. **AWS — S3 bucket already exists** + - Bucket names are globally unique; the module uses an 8-char random prefix when creating new buckets. To use an existing bucket, set `create_storage_backend = false` and `existing_s3_bucket_name`. +4. **AWS — Permission denied on destroy** + - Empty the bucket or set `force_destroy_storage_backend = true`. +5. **Azure — Resource group not found** + - `azure_resource_group_name` must reference an **existing** resource group; the module does not create one. +6. **Azure — Existing storage account access key invalid** + - When `create_storage_backend = false`, `existing_azure_storage_account_access_key` must be a primary or secondary key of `existing_azure_storage_account_name`. +7. **Azure — Insufficient privileges to register an Azure AD application** + - The OIDC connector creates an Azure AD application + SP. The caller needs Application.ReadWrite.OwnedBy or equivalent. ### Debugging Commands ```bash -# Check Terraform state terraform state list +terraform state show stackguardian_runner_group.this +terraform state show 'stackguardian_connector.aws[0]' # AWS +terraform state show 'stackguardian_connector.azure[0]' # Azure -# View specific resource -terraform state show module.runner_group.stackguardian_runner_group.this - -# Enable debug logging export TF_LOG=DEBUG terraform apply ``` @@ -293,36 +331,43 @@ terraform apply | Output | Description | |--------|-------------| -| `runner_group_name` | The name of the StackGuardian runner group | -| `runner_group_id` | The ID of the StackGuardian runner group | +| `runner_group_name` | Name of the StackGuardian runner group | +| `runner_group_id` | 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 | +| `runner_group_url` | Direct URL to the runner group in the StackGuardian web console | +| `connector_name` | Name of the StackGuardian connector (AWS or Azure) | +| `connector_id` | ID of the StackGuardian connector (AWS or Azure) | +| `connector_external_id` | External ID for cross-account S3 access (AWS only; empty on Azure) | +| `s3_bucket_name` | Name of the S3 bucket (AWS only) | +| `s3_bucket_arn` | ARN of the S3 bucket (AWS only) | +| `storage_backend_role_arn` | ARN of the IAM role for storage backend access (AWS only) | +| `storage_backend_role_name` | Name of the IAM role (AWS only) | +| `azure_storage_account_name` | Azure Storage Account name (Azure only) | +| `azure_storage_access_key` | Azure Storage Account primary access key (Azure only, sensitive) | +| `cloud_provider` | The cloud provider used for the storage backend | +| `azure_location` | Azure region (Azure only) | +| `aws_region` | AWS region (AWS only) | +| `sg_org_name` | StackGuardian organization name | +| `sg_api_uri` | StackGuardian API URI | ## 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 +- **API Key Storage** — keep the StackGuardian API key in a secrets manager or use the `${secret::...}` reference syntax. +- **AWS IAM least privilege** — the generated IAM policy grants only the S3 actions required by runners on the specific bucket; the trust policy is gated by an external ID. +- **AWS bucket hardening** — public access is blocked; CORS allows only the StackGuardian platform origin. +- **Azure OIDC** — no long-lived secrets are stored; the connector uses federated identity for the SG org subject. +- **Azure storage hardening** — TLS 1.2 minimum, nested public items disabled, CORS limited to the SG platform origin, blob container is private. +- **Sensitive outputs** — `runner_group_token` and `azure_storage_access_key` are marked sensitive; treat them accordingly when wiring into downstream modules. ## Requirements | Name | Version | |------|---------| | terraform | >= 1.0 | -| aws | >= 4.0 | | stackguardian | >= 1.3.3 | +| aws | >= 4.0 | +| azurerm | >= 3.0 | +| azuread | >= 2.0 | | external | >= 2.0 | | random | >= 3.0 | @@ -330,9 +375,9 @@ terraform apply 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 +1. Use `runner_group_name` and `runner_group_token` with the runner deployment modules (`aws_autoscaled_runner`, `aws_runner`, or the Azure VMSS autoscaler) to register runners. +2. Pass `storage_backend_role_arn` + `s3_bucket_name` (AWS) or `azure_storage_account_name` + `azure_storage_access_key` (Azure) into the runner modules. +3. Open the runner group in the StackGuardian web console using `runner_group_url`. ## Support diff --git a/stackguardian_private_runner/runner_group/schemas/input_schema.json b/stackguardian_private_runner/runner_group/schemas/input_schema.json index 83ddf67..3993fd8 100644 --- a/stackguardian_private_runner/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/runner_group/schemas/input_schema.json @@ -25,7 +25,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", @@ -42,52 +43,6 @@ "enumNames": ["AWS", "Azure"], "default": "aws" }, - "aws_region": { - "title": "AWS Region", - "type": "string", - "enum": [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "af-south-1", - "ap-east-1", - "ap-south-1", - "ap-south-2", - "ap-southeast-1", - "ap-southeast-2", - "ap-southeast-3", - "ap-southeast-4", - "ap-northeast-1", - "ap-northeast-2", - "ap-northeast-3", - "ca-central-1", - "ca-west-1", - "eu-central-1", - "eu-central-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-south-1", - "eu-south-2", - "eu-north-1", - "il-central-1", - "me-south-1", - "me-central-1", - "sa-east-1" - ], - "default": "eu-central-1" - }, - "azure_location": { - "title": "Azure Region", - "type": "string", - "default": "westeurope" - }, - "azure_resource_group_name": { - "title": "Azure Resource Group Name", - "type": "string", - "default": "" - }, "create_storage_backend": { "title": "Create Storage Backend", "type": "boolean", @@ -133,82 +88,192 @@ "oneOf": [ { "properties": { - "cloud_provider": { - "enum": ["aws"] - }, + "cloud_provider": { "enum": ["aws"] }, "aws_region": { "title": "AWS Region", - "type": "string" + "type": "string", + "enum": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "af-south-1", + "ap-east-1", + "ap-south-1", + "ap-south-2", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ca-central-1", + "ca-west-1", + "eu-central-1", + "eu-central-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-south-1", + "eu-south-2", + "eu-north-1", + "il-central-1", + "me-south-1", + "me-central-1", + "sa-east-1" + ], + "default": "eu-central-1" + } + }, + "dependencies": { + "create_storage_backend": { + "oneOf": [ + { + "properties": { + "create_storage_backend": { "enum": [true] }, + "force_destroy_storage_backend": { + "title": "Force Destroy Storage Backend", + "type": "boolean", + "default": false + } + } + }, + { + "properties": { + "create_storage_backend": { "enum": [false] }, + "existing_s3_bucket_name": { + "title": "Existing S3 Bucket Name", + "type": "string", + "minLength": 1 + } + }, + "required": ["existing_s3_bucket_name"] + } + ] } } }, { "properties": { - "cloud_provider": { - "enum": ["azure"] - }, + "cloud_provider": { "enum": ["azure"] }, "azure_location": { "title": "Azure Region", - "type": "string" + "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" }, "azure_resource_group_name": { "title": "Azure Resource Group Name", - "type": "string" + "type": "string", + "minLength": 1 } }, - "required": ["azure_resource_group_name"] - } - ] - }, - "create_storage_backend": { - "oneOf": [ - { - "properties": { + "required": ["azure_resource_group_name"], + "dependencies": { "create_storage_backend": { - "enum": [true] - }, - "force_destroy_storage_backend": { - "title": "Force Destroy Storage Backend", - "type": "boolean", - "default": false - }, - "azure_storage": { - "title": "Azure Storage Configuration", - "type": "object", - "properties": { - "account_tier": { - "title": "Account Tier", - "type": "string", - "enum": ["Standard", "Premium"], - "default": "Standard" + "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 + } + } }, - "account_replication_type": { - "title": "Replication Type", - "type": "string", - "enum": ["LRS", "GRS", "RAGRS", "ZRS"], - "default": "LRS" + { + "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" + ] } - }, - "additionalProperties": false - } - } - }, - { - "properties": { - "create_storage_backend": { - "enum": [false] - }, - "existing_s3_bucket_name": { - "title": "Existing S3 Bucket Name", - "type": "string" - }, - "existing_azure_storage_account_name": { - "title": "Existing Azure Storage Account Name", - "type": "string" - }, - "existing_azure_storage_account_access_key": { - "title": "Existing Azure Storage Account Access Key", - "type": "string" + ] } } } diff --git a/stackguardian_private_runner/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/runner_group/schemas/ui_schema.json index eb4adb2..055c30b 100644 --- a/stackguardian_private_runner/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/runner_group/schemas/ui_schema.json @@ -62,7 +62,7 @@ "ui:description": "Name of an existing Azure Storage Account to use as storage backend (Azure only)" }, "existing_azure_storage_account_access_key": { - "ui:widget": "password", + "ui:placeholder": "primary or secondary access key", "ui:description": "Access key for the existing Azure Storage Account (Azure only)" }, "force_destroy_storage_backend": { From afd812681f96853eae758cb04e08884c73865f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Tue, 28 Apr 2026 13:44:02 +0200 Subject: [PATCH 16/37] SG-3995: Update runner_group to auto-create resource group. --- .../runner_group/locals.tf | 20 ++++++++++++ .../runner_group/outputs.tf | 32 ++++++++++++------- .../runner_group/storage_backend_azure.tf | 29 ++++++++++++++++- .../runner_group/variables.tf | 16 +++++++++- 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/stackguardian_private_runner/runner_group/locals.tf b/stackguardian_private_runner/runner_group/locals.tf index 8f47a5c..d4b7a1c 100644 --- a/stackguardian_private_runner/runner_group/locals.tf +++ b/stackguardian_private_runner/runner_group/locals.tf @@ -88,6 +88,26 @@ locals { sanitized_prefix = replace(lower(local.effective_prefix), "_", "-") 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_azure_rg_name = ( + var.azure_resource_group_name != "" + ? var.azure_resource_group_name + : "${local.sanitized_prefix}-rg-${local.account_identifier}" + ) + + # 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. + azure_resource_group_name = ( + local.is_azure + ? ( + var.create_azure_resource_group + ? azurerm_resource_group.this[0].name + : var.azure_resource_group_name + ) + : "" + ) + azure_storage_account_name = ( local.is_azure ? ( diff --git a/stackguardian_private_runner/runner_group/outputs.tf b/stackguardian_private_runner/runner_group/outputs.tf index 27476cd..43bd16c 100644 --- a/stackguardian_private_runner/runner_group/outputs.tf +++ b/stackguardian_private_runner/runner_group/outputs.tf @@ -27,17 +27,17 @@ output "runner_group_url" { +---------------------------------*/ output "connector_name" { description = "The name of the StackGuardian connector (AWS or Azure)" - value = local.is_aws ? stackguardian_connector.aws[0].resource_name : (local.is_azure ? stackguardian_connector.azure[0].resource_name : "") + value = local.is_aws ? stackguardian_connector.aws[0].resource_name : (local.is_azure ? stackguardian_connector.azure[0].resource_name : null) } output "connector_id" { description = "The ID of the StackGuardian connector (AWS or Azure)" - value = local.is_aws ? stackguardian_connector.aws[0].resource_name : (local.is_azure ? stackguardian_connector.azure[0].resource_name : "") + value = local.is_aws ? stackguardian_connector.aws[0].resource_name : (local.is_azure ? stackguardian_connector.azure[0].resource_name : null) } output "connector_external_id" { description = "The external ID used for cross-account S3 access (AWS only)" - value = local.is_aws ? "${local.sg_org_name}:${random_string.connector_external_id[0].result}" : "" + value = local.is_aws ? "${local.sg_org_name}:${random_string.connector_external_id[0].result}" : null } /*---------------------------------+ @@ -45,36 +45,46 @@ output "connector_external_id" { +---------------------------------*/ output "s3_bucket_name" { description = "The name of the S3 bucket used for storage backend (AWS only)" - value = local.s3_bucket_name + value = local.is_aws ? local.s3_bucket_name : null } output "s3_bucket_arn" { description = "The ARN of the S3 bucket used for storage backend (AWS only)" - value = local.s3_bucket_arn + value = local.is_aws ? local.s3_bucket_arn : null } output "storage_backend_role_arn" { description = "The ARN of the IAM role for storage backend access (AWS only)" - value = local.is_aws ? aws_iam_role.storage_backend[0].arn : "" + value = local.is_aws ? aws_iam_role.storage_backend[0].arn : null } output "storage_backend_role_name" { description = "The name of the IAM role for storage backend access (AWS only)" - value = local.is_aws ? aws_iam_role.storage_backend[0].name : "" + value = local.is_aws ? aws_iam_role.storage_backend[0].name : null } /*---------------------------------+ | Storage Backend Outputs (Azure) | +---------------------------------*/ +output "azure_resource_group_name" { + description = "The name of the Azure Resource Group containing the storage account (Azure only). Pass this to downstream azure/* modules' resource_group_name input." + value = local.is_azure ? local.azure_resource_group_name : null +} + +output "azure_resource_group_location" { + description = "The location of the Azure Resource Group (Azure only)." + value = local.is_azure ? var.azure_location : null +} + output "azure_storage_account_name" { description = "The name of the Azure Storage Account used for storage backend (Azure only)" - value = local.azure_storage_account_name + value = local.is_azure ? local.azure_storage_account_name : null } output "azure_storage_access_key" { description = "The access key for the Azure Storage Account (Azure only, sensitive)" sensitive = true - value = local.azure_storage_access_key + value = local.is_azure ? local.azure_storage_access_key : null } /*---------------------------------+ @@ -87,7 +97,7 @@ output "cloud_provider" { output "azure_location" { description = "The Azure region (Azure only)" - value = local.is_azure ? var.azure_location : "" + value = local.is_azure ? var.azure_location : null } /*---------------------------------+ @@ -105,5 +115,5 @@ output "sg_api_uri" { output "aws_region" { description = "The AWS region (AWS only)" - value = local.is_aws ? var.aws_region : "" + value = local.is_aws ? var.aws_region : null } diff --git a/stackguardian_private_runner/runner_group/storage_backend_azure.tf b/stackguardian_private_runner/runner_group/storage_backend_azure.tf index f5bfebd..6d7d8be 100644 --- a/stackguardian_private_runner/runner_group/storage_backend_azure.tf +++ b/stackguardian_private_runner/runner_group/storage_backend_azure.tf @@ -1,3 +1,23 @@ +# Azure Resource Group (Azure only, created when create_azure_resource_group = true) +resource "azurerm_resource_group" "this" { + count = local.is_azure && var.create_azure_resource_group ? 1 : 0 + + name = local.desired_azure_rg_name + location = var.azure_location + + tags = { + purpose = "stackguardian-private-runner" + prefix = var.override_names.global_prefix + } + + lifecycle { + precondition { + condition = local.desired_azure_rg_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 (Azure only, created when create_storage_backend = true) resource "random_string" "azure_storage_suffix" { @@ -13,7 +33,7 @@ resource "azurerm_storage_account" "this" { count = local.is_azure && var.create_storage_backend ? 1 : 0 name = "${local.storage_account_prefix}${random_string.azure_storage_suffix[0].result}" - resource_group_name = var.azure_resource_group_name + resource_group_name = local.azure_resource_group_name location = var.azure_location account_tier = var.azure_storage.account_tier account_replication_type = var.azure_storage.account_replication_type @@ -23,6 +43,13 @@ resource "azurerm_storage_account" "this" { allow_nested_items_to_be_public = false public_network_access_enabled = true + lifecycle { + precondition { + condition = local.azure_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 = ["*"] diff --git a/stackguardian_private_runner/runner_group/variables.tf b/stackguardian_private_runner/runner_group/variables.tf index 4ad6476..00ef4e5 100644 --- a/stackguardian_private_runner/runner_group/variables.tf +++ b/stackguardian_private_runner/runner_group/variables.tf @@ -49,8 +49,22 @@ variable "azure_location" { default = "westeurope" } +variable "create_azure_resource_group" { + description = < Date: Tue, 28 Apr 2026 14:21:14 +0200 Subject: [PATCH 17/37] SG-3995: Auto create azure role assignment for blob. --- stackguardian_private_runner/runner_group/outputs.tf | 5 +++++ .../runner_group/storage_backend_azure.tf | 2 +- stackguardian_private_runner/runner_group/variables.tf | 9 +++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/stackguardian_private_runner/runner_group/outputs.tf b/stackguardian_private_runner/runner_group/outputs.tf index 43bd16c..a9533c6 100644 --- a/stackguardian_private_runner/runner_group/outputs.tf +++ b/stackguardian_private_runner/runner_group/outputs.tf @@ -76,6 +76,11 @@ output "azure_resource_group_location" { value = local.is_azure ? var.azure_location : null } +output "azure_connector_service_principal_object_id" { + description = "Object ID of the OIDC connector service principal (Azure only). Use this to create the 'Storage Blob Data Reader' role assignment out of band when create_blob_reader_role_assignment = false." + value = local.is_azure ? azuread_service_principal.connector[0].object_id : null +} + output "azure_storage_account_name" { description = "The name of the Azure Storage Account used for storage backend (Azure only)" value = local.is_azure ? local.azure_storage_account_name : null diff --git a/stackguardian_private_runner/runner_group/storage_backend_azure.tf b/stackguardian_private_runner/runner_group/storage_backend_azure.tf index 6d7d8be..27918e4 100644 --- a/stackguardian_private_runner/runner_group/storage_backend_azure.tf +++ b/stackguardian_private_runner/runner_group/storage_backend_azure.tf @@ -102,7 +102,7 @@ resource "azuread_application_federated_identity_credential" "connector" { # Grant the SP "Storage Blob Data Reader" on the storage account resource "azurerm_role_assignment" "connector_blob_reader" { - count = local.is_azure && var.create_storage_backend ? 1 : 0 + count = local.is_azure && 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[0].object_id diff --git a/stackguardian_private_runner/runner_group/variables.tf b/stackguardian_private_runner/runner_group/variables.tf index 00ef4e5..0edb699 100644 --- a/stackguardian_private_runner/runner_group/variables.tf +++ b/stackguardian_private_runner/runner_group/variables.tf @@ -49,6 +49,15 @@ variable "azure_location" { default = "westeurope" } +variable "create_blob_reader_role_assignment" { + description = < Date: Mon, 24 Aug 2026 13:16:25 +0200 Subject: [PATCH 18/37] SG-3995: Add azure example and configurable api_uri for autoscaler. --- .../azure/autoscaler/locals.tf | 2 +- .../azure/autoscaler/variables.tf | 14 +- .../examples/azure/.gitignore | 4 + .../examples/azure/locals.tf | 12 ++ .../examples/azure/main.tf | 158 ++++++++++++++++++ .../examples/azure/outputs.tf | 48 ++++++ .../examples/azure/provider.tf | 18 ++ .../azure/templates/install_runner.sh.tpl | 74 ++++++++ .../examples/azure/troubleshooting.md | 130 ++++++++++++++ .../examples/azure/variables.tf | 154 +++++++++++++++++ 10 files changed, 611 insertions(+), 3 deletions(-) create mode 100644 stackguardian_private_runner/examples/azure/.gitignore create mode 100644 stackguardian_private_runner/examples/azure/locals.tf create mode 100644 stackguardian_private_runner/examples/azure/main.tf create mode 100644 stackguardian_private_runner/examples/azure/outputs.tf create mode 100644 stackguardian_private_runner/examples/azure/provider.tf create mode 100644 stackguardian_private_runner/examples/azure/templates/install_runner.sh.tpl create mode 100644 stackguardian_private_runner/examples/azure/troubleshooting.md create mode 100644 stackguardian_private_runner/examples/azure/variables.tf diff --git a/stackguardian_private_runner/azure/autoscaler/locals.tf b/stackguardian_private_runner/azure/autoscaler/locals.tf index 29bc369..c2b75c6 100644 --- a/stackguardian_private_runner/azure/autoscaler/locals.tf +++ b/stackguardian_private_runner/azure/autoscaler/locals.tf @@ -14,7 +14,7 @@ locals { ? var.stackguardian.org_name : data.external.env.result.sg_org_name ) - sg_api_uri = data.external.env.result.sg_api_uri + sg_api_uri = var.stackguardian.api_uri # Resource group for VMSS (defaults to main resource group if not specified) vmss_resource_group = ( diff --git a/stackguardian_private_runner/azure/autoscaler/variables.tf b/stackguardian_private_runner/azure/autoscaler/variables.tf index 789adc3..5237789 100644 --- a/stackguardian_private_runner/azure/autoscaler/variables.tf +++ b/stackguardian_private_runner/azure/autoscaler/variables.tf @@ -19,12 +19,22 @@ variable "stackguardian" { description = "StackGuardian platform configuration" type = object({ api_key = string + api_uri = optional(string, "https://api.app.stackguardian.io") org_name = optional(string, "") }) validation { - condition = can(regex("^sg[o|u]_.*", var.stackguardian.api_key)) - error_message = "The api_key must be a valid StackGuardian API key starting with 'sgu_'." + 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)." } } diff --git a/stackguardian_private_runner/examples/azure/.gitignore b/stackguardian_private_runner/examples/azure/.gitignore new file mode 100644 index 0000000..fe3cca7 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/.gitignore @@ -0,0 +1,4 @@ +# Plan artifacts - may embed credentials from the tfvars +tfplan +tofuplan +*.tfplan diff --git a/stackguardian_private_runner/examples/azure/locals.tf b/stackguardian_private_runner/examples/azure/locals.tf new file mode 100644 index 0000000..09b1d35 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/locals.tf @@ -0,0 +1,12 @@ +locals { + sanitized_prefix = replace(lower(var.prefix), "_", "-") + vm_name = "${local.sanitized_prefix}-runner" + + common_tags = { + purpose = "stackguardian-private-runner" + prefix = var.prefix + } + + # Whether to include org name in resource name prefix + include_org_in_prefix = false +} diff --git a/stackguardian_private_runner/examples/azure/main.tf b/stackguardian_private_runner/examples/azure/main.tf new file mode 100644 index 0000000..ea71dcb --- /dev/null +++ b/stackguardian_private_runner/examples/azure/main.tf @@ -0,0 +1,158 @@ +/*============================================================+ + | Stage 0: Runner Group | + | Creates StackGuardian runner group + Azure storage backend | + +============================================================*/ +module "runner_group" { + source = "../../runner_group" + + cloud_provider = "azure" + azure_location = var.azure_location + create_azure_resource_group = false + azure_resource_group_name = var.runner_storage_resource_group_name + azure_storage = var.azure_storage + max_runners = var.max_runners + + stackguardian = var.stackguardian + + override_names = { + global_prefix = var.prefix + include_org_in_prefix = local.include_org_in_prefix + } +} + +/*============================================================+ + | Stage 1: Networking | + | VNet, Subnet, NSG (inbound SSH + all outbound) | + +============================================================*/ + +resource "azurerm_virtual_network" "this" { + name = "${local.sanitized_prefix}-vnet" + address_space = var.network.vnet_address_space + location = var.azure_location + resource_group_name = var.compute_resource_group_name + + tags = local.common_tags +} + +resource "azurerm_subnet" "this" { + name = "${local.sanitized_prefix}-subnet" + resource_group_name = var.compute_resource_group_name + virtual_network_name = azurerm_virtual_network.this.name + address_prefixes = [var.network.subnet_address_prefix] +} + +resource "azurerm_network_security_group" "this" { + name = "${local.sanitized_prefix}-nsg" + location = var.azure_location + resource_group_name = var.compute_resource_group_name + + security_rule { + name = "AllowSSHInbound" + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = var.ssh_source_address_prefix + 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 = local.common_tags +} + +resource "azurerm_subnet_network_security_group_association" "this" { + subnet_id = azurerm_subnet.this.id + network_security_group_id = azurerm_network_security_group.this.id +} + +/*============================================================+ + | Stage 2: Single RHEL Runner VM | + | Marketplace RHEL 9.8 image; Docker + sg-runner installed | + | at first boot via custom_data, then registers. | + +============================================================*/ + +resource "azurerm_public_ip" "this" { + name = "${local.sanitized_prefix}-pip" + location = var.azure_location + resource_group_name = var.compute_resource_group_name + allocation_method = "Static" + sku = "Standard" + + tags = local.common_tags +} + +resource "azurerm_network_interface" "this" { + name = "${local.sanitized_prefix}-nic" + location = var.azure_location + resource_group_name = var.compute_resource_group_name + + ip_configuration { + name = "internal" + subnet_id = azurerm_subnet.this.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.this.id + } + + tags = local.common_tags +} + +resource "azurerm_linux_virtual_machine" "this" { + name = local.vm_name + resource_group_name = var.compute_resource_group_name + location = var.azure_location + size = var.vm_size + admin_username = var.admin_username + + network_interface_ids = [azurerm_network_interface.this.id] + disable_password_authentication = true + + admin_ssh_key { + username = var.admin_username + public_key = var.admin_ssh_public_key + } + + source_image_reference { + publisher = var.rhel_image.publisher + offer = var.rhel_image.offer + sku = var.rhel_image.sku + version = var.rhel_image.version + } + + os_disk { + caching = var.vm_os_disk.caching + storage_account_type = var.vm_os_disk.storage_account_type + disk_size_gb = var.vm_os_disk.disk_size_gb + } + + custom_data = base64encode( + templatefile("${path.module}/templates/install_runner.sh.tpl", + { + sg_org_name = module.runner_group.sg_org_name + sg_api_uri = module.runner_group.sg_api_uri + sg_runner_group_name = module.runner_group.runner_group_name + sg_runner_group_token = module.runner_group.runner_group_token + docker_version = var.docker_version + sg_runner_version = var.sg_runner_version + admin_username = var.admin_username + startup_timeout = tostring(var.runner_startup_timeout) + } + ) + ) + + tags = merge(local.common_tags, { + Name = local.vm_name + }) +} diff --git a/stackguardian_private_runner/examples/azure/outputs.tf b/stackguardian_private_runner/examples/azure/outputs.tf new file mode 100644 index 0000000..e72f761 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/outputs.tf @@ -0,0 +1,48 @@ +/*---------------------------------+ + | 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_url" { + description = "Direct URL to the runner group in the StackGuardian web console" + value = module.runner_group.runner_group_url +} + +output "azure_storage_account_name" { + description = "The name of the Azure Storage Account used for the runner group storage backend" + value = module.runner_group.azure_storage_account_name +} + +/*---------------------------------+ + | Runner VM Outputs | + +---------------------------------*/ +output "vm_name" { + description = "The name of the runner VM" + value = azurerm_linux_virtual_machine.this.name +} + +output "vm_public_ip" { + description = "The public IP address of the runner VM" + value = azurerm_public_ip.this.ip_address +} + +output "ssh_command" { + description = "Ready-to-use SSH command to attach to the runner VM" + value = "ssh ${var.admin_username}@${azurerm_public_ip.this.ip_address}" +} + +/*---------------------------------+ + | Network Outputs | + +---------------------------------*/ +output "vnet_id" { + description = "The ID of the created Virtual Network" + value = azurerm_virtual_network.this.id +} + +output "subnet_id" { + description = "The ID of the created Subnet" + value = azurerm_subnet.this.id +} diff --git a/stackguardian_private_runner/examples/azure/provider.tf b/stackguardian_private_runner/examples/azure/provider.tf new file mode 100644 index 0000000..f826b1b --- /dev/null +++ b/stackguardian_private_runner/examples/azure/provider.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0" + } + azuread = { + source = "hashicorp/azuread" + version = ">= 2.0" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/stackguardian_private_runner/examples/azure/templates/install_runner.sh.tpl b/stackguardian_private_runner/examples/azure/templates/install_runner.sh.tpl new file mode 100644 index 0000000..dfb77fc --- /dev/null +++ b/stackguardian_private_runner/examples/azure/templates/install_runner.sh.tpl @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# +# Replicates a production StackGuardian private runner host for investigation: +# - RHEL 9.x +# - Docker pinned to a specific version (production: 29.5.2 / build 79eb04c) +# - sg-runner pinned to a specific release tag (production: "Installationsscript v2.2.1") +# +# Mirrors the RHEL path of the Packer image setup +# (azure/packer/scripts/setup.sh) but pins versions instead of installing latest, +# then registers the runner. + +set -euo pipefail + +LOG=/var/log/sg_runner_startup.log +exec > >(tee -a "$LOG") 2>&1 + +echo ">> [replica] starting install on: $(cat /etc/redhat-release 2>/dev/null || echo unknown)" + +# 1. Base dependencies (matches _dnf_dependencies) +dnf install -y dnf-plugins-core unzip cronie wget + +# 2. Docker repo + PINNED engine (production: ${docker_version} / build 79eb04c) +dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo +DOCKER_VERSION="${docker_version}" +# RHEL package strings look like "3:29.5.2-1.el9"; match the version with a glob so the +# epoch / dist suffix don't have to be hardcoded. +dnf install -y \ + "docker-ce-*$${DOCKER_VERSION}*" \ + "docker-ce-cli-*$${DOCKER_VERSION}*" \ + containerd.io + +# 3. Enable services + docker group (matches _systemctl_enable / _usermod_add_to_group) +systemctl enable --now crond docker +usermod -aG docker ${admin_username} || true + +# 4. jq (sg-runner depends on it) +ARCH=amd64 +JQ_URL=$(wget -qO- https://api.github.com/repos/jqlang/jq/releases/latest \ + | grep browser_download_url | grep "jq-linux-$${ARCH}" | head -1 | cut -d'"' -f4) +wget -qO /usr/bin/jq "$${JQ_URL}" +chmod +x /usr/bin/jq + +# 5. sg-runner PINNED to the production tag (Installationsscript ${sg_runner_version}) +TMP=$(mktemp -d) +wget -qO "$${TMP}/runner.tar.gz" \ + "https://api.github.com/repos/stackguardian/sg-runner/tarball/${sg_runner_version}" +tar -xf "$${TMP}/runner.tar.gz" -C "$${TMP}" +cp -rf "$${TMP}"/StackGuardian-sg-runner*/main.sh /usr/bin/sg-runner +chmod +x /usr/bin/sg-runner +rm -rf "$${TMP}" +echo ">> sg-runner installed: $(which sg-runner)" + +# 6. Wait for Docker (same guard as the original register_runner.sh.tpl) +timeout="${startup_timeout}" +counter=0 +until systemctl is-active --quiet docker; do + echo ">> Docker not ready.. trying again in 1 second." + sleep 1 + counter=$((counter + 1)) + if [ $counter -ge $timeout ]; then + echo ">> ERROR: Docker failed to start after $${timeout} seconds." + exit 1 + fi +done +echo ">> Docker ready: $(docker --version)" + +# 7. Register the 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}" + +echo ">> StackGuardian Private Runner registration complete." diff --git a/stackguardian_private_runner/examples/azure/troubleshooting.md b/stackguardian_private_runner/examples/azure/troubleshooting.md new file mode 100644 index 0000000..91f2d91 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/troubleshooting.md @@ -0,0 +1,130 @@ +# Private Runner Troubleshooting — ECS agent terminally exits after successful registration + +How to approach a customer's private runner that **registers successfully but then fails to run +workflows**, where the ECS agent logs end in a terminal exit. + +## Symptom + +Runner registers fine, but workflows never pick up. The `ecs-agent` container is not running, and +its log ends with: + +``` +level=info msg="Restored from checkpoint file" containerInstanceARN="arn:aws:ecs:::container-instance//" +level=info msg="Cluster was successfully restored" cluster="" +level=error msg="Unable to register as a container instance with ECS" error="... RegisterContainerInstance ... StatusCode: 400 ... ClientException: Referenced container instance not registered." +level=critical msg="Agent will terminally exit, unable to register container instance" +``` + +## Root cause + +The StackGuardian private runner runs the **Amazon ECS agent in external mode** +(`ECS_EXTERNAL=true`) to execute workflow tasks. The agent **persists its registration state** to: + +- `/var/lib/ecs/data/agent.db` (boltdb; newer agents) — mounted into the container as + `ECS_DATADIR=/data/` +- (older agents used `/var/lib/ecs/data/ecs_agent_data.json`) + +On startup the agent reads that state, finds the **container-instance ARN from a previous +registration**, and tries to **re-register that same ARN**. If that instance was deregistered on +the ECS / StackGuardian side, ECS returns `400 ... not registered` and the agent **terminally +exits**. Registration "succeeding" earlier doesn't help — the agent can't come back up, so no tasks +run. + +This is **state-dependent, not version-dependent.** OS / Docker / installation-script versions are +irrelevant — a matching spec box with clean state works fine. + +### When the state goes stale + +The local `agent.db` outlives the server-side container instance when, between registrations: + +- the runner group is deleted/recreated, +- the node token is rotated, +- the instance is pruned for being offline, +- or `register` is re-run **without** local cleanup, + +…and then the host **reboots or the `ecs` service / `ecs-agent` container restarts**. + +## Noise to ignore in the log + +These are normal and are **not** the failure: + +- `Unable to fetch user data: blackholed`, `Not able to get EC2 Instance ID from IMDS`, + `Unable to get Availability Zone` — expected on an **external** (non-EC2) instance; it uses + `/rotatingcreds` instead of IMDS. +- The wall of `Docker client version 1.17 … 1.39 is too old … Minimum supported API version is +1.40`, followed by `Setting minimum docker API version newMinAPIVersion=1.40` — expected with + Docker 28/29.x. The agent negotiates and continues. A **healthy** runner logs the same lines. + +The only line that matters is the `critical … terminally exit` on `RegisterContainerInstance`. + +## Diagnose (read-only first — confirm before changing anything) + +```bash +# 1. Is the agent actually down, and what does it say? +sudo docker ps -a --filter name=ecs-agent --format '{{.Names}}\t{{.Status}}' +sudo docker logs --tail=60 ecs-agent + +# 2. Does the persisted state hold a stale ARN matching the one in the 400 error? +sudo strings /var/lib/ecs/data/agent.db 2>/dev/null \ + | grep -o 'container-instance/[^"]*' | sort -u +# (older agents:) +sudo cat /var/lib/ecs/data/ecs_agent_data.json 2>/dev/null \ + | jq '{Cluster: .Data.Cluster, ContainerInstanceArn: .Data.ContainerInstanceArn}' + +# 3. Confirm cluster/external config +sudo grep -E 'ECS_CLUSTER|ECS_EXTERNAL|ECS_DATADIR' /etc/ecs/ecs.config +``` + +If the ARN from step 2 equals the `` in the `400 ... not registered` error, it is +conclusively the stale-state issue. + +## Fix + +**Preferred — supported deregister/reregister cycle.** `deregister -f/--force` runs the script's +`clean_local_setup`, which removes `/var/lib/ecs`, `/etc/ecs`, cached creds, the SSM managed +instance dir, etc., so the next `register` comes up as a fresh instance: + +```bash +sudo sg-runner deregister -f \ + --organization "" --runner-group "" --sg-node-token "" + +sudo sg-runner register \ + --organization "" --runner-group "" --sg-node-token "" +``` + +**Minimal fallback** — just clear the stale checkpoint and let the agent register anew: + +```bash +sudo docker stop ecs-agent +sudo rm -f /var/lib/ecs/data/agent.db # older: /var/lib/ecs/data/ecs_agent_data.json +sudo systemctl restart ecs # or: sudo docker start ecs-agent +sudo docker logs -f ecs-agent # expect a NEW registration, no 400 +``` + +### Verify recovery + +```bash +sudo docker ps --filter name=ecs-agent --format '{{.Names}}\t{{.Status}}' # Up (healthy) +sudo docker inspect ecs-agent --format '{{.State.Health.Status}}' # healthy +``` + +Then trigger a workflow against the runner group and confirm it picks up. + +## Prevention + +- Always use `sg-runner deregister -f` (local cleanup) **before** re-registering a host or before + deleting/recreating its runner group. Re-registering over stale state is what plants the bug. +- After any server-side removal of an instance/runner group, treat the host as needing a clean + re-register, not just a reboot. + +## Reproducing it deliberately (for a captured repro) + +A spec-matched box alone will not reproduce it. To force it: + +1. Register a runner normally; confirm `ecs-agent` is `Up (healthy)`. +2. Deregister **that container instance** on the StackGuardian/ECS side (or delete & recreate the + runner group) **without** running local cleanup on the host. +3. `sudo systemctl restart ecs` (or reboot the host). + +The agent restores the now-dead ARN from `agent.db`, re-registration returns `400 ... not +registered`, and it terminally exits — identical to the customer log. diff --git a/stackguardian_private_runner/examples/azure/variables.tf b/stackguardian_private_runner/examples/azure/variables.tf new file mode 100644 index 0000000..5cb0bfb --- /dev/null +++ b/stackguardian_private_runner/examples/azure/variables.tf @@ -0,0 +1,154 @@ +/*-----------------------------------+ + | StackGuardian Platform 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_' or 'sgo_'." + } +} + +/*-------------------------------------------+ + | StackGuardian Runner Group Configuration | + +-------------------------------------------*/ +variable "runner_storage_resource_group_name" { + description = "The Azure Resource Group where the runner_group storage account is created" + type = string +} + +variable "azure_storage" { + description = "Azure Storage Account configuration for the runner group storage backend" + type = object({ + account_tier = optional(string, "Standard") + account_replication_type = optional(string, "LRS") + }) + default = {} +} + +variable "max_runners" { + description = "Maximum number of runners for the runner group" + type = number + default = 3 +} + +/*-------------------+ + | Azure Variables | + +-------------------*/ +variable "azure_location" { + description = "The Azure region where resources will be deployed" + type = string + default = "westeurope" +} + +variable "compute_resource_group_name" { + description = "The Azure Resource Group where the runner VM and network are created (must already exist)" + type = string +} + +/*--------------------------+ + | VM Image Variables | + +--------------------------*/ +variable "rhel_image" { + description = < --publisher RedHat --offer RHEL --all -o table + EOT + type = object({ + publisher = optional(string, "RedHat") + offer = optional(string, "RHEL") + sku = optional(string, "9_8") + version = optional(string, "latest") + }) + default = {} +} + +variable "docker_version" { + description = "Docker engine version to pin (matches the production host, e.g. 29.5.2)" + type = string + default = "29.5.2" +} + +variable "sg_runner_version" { + description = "sg-runner release tag to pin (the 'Installationsscript' version, e.g. v2.2.1)" + type = string + default = "v2.2.1" +} + +/*--------------------------+ + | VM Variables | + +--------------------------*/ +variable "vm_size" { + description = "The Azure VM size for the runner VM" + type = string + default = "Standard_D4s_v3" +} + +variable "admin_username" { + description = "Admin username for the runner VM" + type = string + default = "azureuser" +} + +variable "admin_ssh_public_key" { + description = "SSH public key for admin access to the runner VM" + type = string +} + +variable "vm_os_disk" { + description = "OS disk configuration for the runner VM" + type = object({ + caching = optional(string, "ReadWrite") + storage_account_type = optional(string, "Premium_LRS") + disk_size_gb = optional(number, 50) + }) + default = {} +} + +/*--------------------------+ + | Network Variables | + +--------------------------*/ +variable "network" { + description = < Date: Mon, 24 Aug 2026 13:16:29 +0200 Subject: [PATCH 19/37] SG-3995: Update template docs and schemas. --- .../aws/autoscaler/schemas/input_schema.json | 1 - .../schemas/input_schema.json | 1 - .../aws/packer/schemas/input_schema.json | 1 - .../single_runner/schemas/input_schema.json | 1 - .../azure/autoscaler/DOCUMENTATION.md | 92 +++++ .../autoscaler/schemas/input_schema.json | 223 +++++++++++ .../azure/autoscaler/schemas/ui_schema.json | 142 +++++++ .../azure_runner/schemas/input_schema.json | 295 +++++++++++++++ .../azure/azure_runner/schemas/ui_schema.json | 178 +++++++++ .../azure/packer/DOCUMENTATION.md | 77 ++++ .../azure/packer/README.md | 349 ++++++------------ .../azure/packer/schemas/input_schema.json | 179 +++++++++ .../azure/packer/schemas/ui_schema.json | 126 +++++++ .../azure/vmss/DOCUMENTATION.md | 100 +++++ .../azure/vmss/README.md | 286 ++++++++++++-- .../azure/vmss/schemas/input_schema.json | 315 ++++++++++++++++ .../azure/vmss/schemas/ui_schema.json | 187 ++++++++++ .../runner_group/DOCUMENTATION.md | 14 +- .../runner_group/README.md | 34 +- .../runner_group/schemas/input_schema.json | 14 +- .../runner_group/schemas/ui_schema.json | 14 +- 21 files changed, 2346 insertions(+), 283 deletions(-) create mode 100644 stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md create mode 100644 stackguardian_private_runner/azure/autoscaler/schemas/input_schema.json create mode 100644 stackguardian_private_runner/azure/autoscaler/schemas/ui_schema.json create mode 100644 stackguardian_private_runner/azure/azure_runner/schemas/input_schema.json create mode 100644 stackguardian_private_runner/azure/azure_runner/schemas/ui_schema.json create mode 100644 stackguardian_private_runner/azure/packer/DOCUMENTATION.md create mode 100644 stackguardian_private_runner/azure/packer/schemas/input_schema.json create mode 100644 stackguardian_private_runner/azure/packer/schemas/ui_schema.json create mode 100644 stackguardian_private_runner/azure/vmss/DOCUMENTATION.md create mode 100644 stackguardian_private_runner/azure/vmss/schemas/input_schema.json create mode 100644 stackguardian_private_runner/azure/vmss/schemas/ui_schema.json diff --git a/stackguardian_private_runner/aws/autoscaler/schemas/input_schema.json b/stackguardian_private_runner/aws/autoscaler/schemas/input_schema.json index 4ba904b..e8ea315 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", 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/packer/schemas/input_schema.json b/stackguardian_private_runner/aws/packer/schemas/input_schema.json index f681d30..195e4ec 100644 --- a/stackguardian_private_runner/aws/packer/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/packer/schemas/input_schema.json @@ -1,6 +1,5 @@ { "type": "object", - "additionalProperties": false, "properties": { "aws_region": { "title": "AWS Region", 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/azure/autoscaler/DOCUMENTATION.md b/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md new file mode 100644 index 0000000..1011961 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md @@ -0,0 +1,92 @@ +# 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 +- **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 | + +## 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. + +## 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 | + +## 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 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/schemas/input_schema.json b/stackguardian_private_runner/azure/autoscaler/schemas/input_schema.json new file mode 100644 index 0000000..a81fa62 --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/schemas/input_schema.json @@ -0,0 +1,223 @@ +{ + "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 + } + } + } + }, + "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..e3beeeb --- /dev/null +++ b/stackguardian_private_runner/azure/autoscaler/schemas/ui_schema.json @@ -0,0 +1,142 @@ +{ + "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" + ], + "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" + } + } +} 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..c91fe73 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/schemas/input_schema.json @@ -0,0 +1,295 @@ +{ + "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 + }, + "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" + } + } + } + ] + } + } + }, + "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 + }, + "org_name": { + "title": "Org Name (for prefix)", + "type": "string", + "default": "" + } + }, + "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..52c6dc3 --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/schemas/ui_schema.json @@ -0,0 +1,178 @@ +{ + "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", + "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." + }, + "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", "org_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": "radio", + "ui:options": { "inline": true }, + "ui:description": "When true, appends the org name to the prefix (e.g. SG_RUNNER_demo-org)." + }, + "org_name": { + "ui:placeholder": "demo-org", + "ui:description": "Organization name to include in the prefix when include_org_in_prefix is true." + } + } +} diff --git a/stackguardian_private_runner/azure/packer/DOCUMENTATION.md b/stackguardian_private_runner/azure/packer/DOCUMENTATION.md new file mode 100644 index 0000000..b5d094b --- /dev/null +++ b/stackguardian_private_runner/azure/packer/DOCUMENTATION.md @@ -0,0 +1,77 @@ +# StackGuardian Private Runner Image Builder - Azure Template + +Deploy this template on the StackGuardian platform to build a custom Azure managed image preloaded with the StackGuardian Private Runner agent, ready to be consumed by the Azure autoscaler. + +## Overview + +This template produces a reusable Azure managed image so your private runners boot fast with the agent, Terraform, and OpenTofu already installed. The image is built by HashiCorp Packer in your own Azure subscription, lands in a resource group you control, and is automatically removed when the workflow is destroyed. + +### What This Template Creates + +- **Azure Managed Image** — your custom Private Runner image, tagged with OS family and timestamp. +- **Resource Group** *(optional)* — created for you when `Create Resource Group` is enabled; otherwise the existing one is reused. +- **Automatic cleanup hook** — deletes the image from Azure when the workflow is destroyed (can be disabled). + +## Prerequisites + +- Azure credentials configured on the StackGuardian runner (via `az login`, service principal env vars, or managed identity). +- Permission in the target subscription to create managed images (and the resource group, if you let the template create it). +- Outbound network access from Azure to package mirrors during the build, or an HTTP proxy reachable from the build VNet. + +## Template Parameters + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| `resource_group_name` | The name of the resource group where the image will be stored | `string` | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `azure_location` | The target Azure region to build the Private Runner image | `westeurope` | +| `create_resource_group` | Create the resource group as part of this deployment. If disabled, it must already exist. | `false` | +| `vm_size` | The Azure VM size used by Packer during the build (min 2 vCPU, 4GB RAM recommended) | `Standard_D2s_v3` | +| `network.vnet_name` | Name of an existing virtual network to use for the build VM | `""` | +| `network.subnet_name` | Name of an existing subnet inside the VNet above | `""` | +| `network.resource_group_name` | Resource group containing the existing VNet (if different from the image resource group) | `""` | +| `network.proxy_url` | HTTP proxy URL forwarded to the build VM during image creation | `""` | +| `os.publisher` | Image publisher: Canonical (Ubuntu) or RedHat (RHEL) | `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 (use `latest` to always pull the newest) | `latest` | +| `os.update_os_before_install` | Run a full OS package update before installing the runner agent | `true` | +| `os.user_script` | Optional shell script run on the build VM after the runner agent is installed | `""` | +| `packer_config.version` | Packer version installed by the build script | `1.14.1` | +| `packer_config.cleanup_images_on_destroy` | Delete the managed image when the workflow is destroyed | `true` | +| `image_name_prefix` | Prefix used for the generated image name | `sg-runner` | +| `terraform.primary_version` | Default Terraform version available on the runner (leave empty to skip) | `""` | +| `terraform.additional_versions` | Extra Terraform versions to install alongside the primary version | `[]` | +| `opentofu.primary_version` | Default OpenTofu version available on the runner (leave empty to skip) | `""` | +| `opentofu.additional_versions` | Extra OpenTofu versions to install alongside the primary version | `[]` | + +## Important Notes + +**Image is rebuilt on every run**: The template is wired so that each apply produces a fresh image with a new timestamp. This is intentional — version bumps to Terraform, OpenTofu, the user script, or the OS automatically take effect on the next runner. + +**Cleanup on destroy**: When `cleanup_images_on_destroy` is left at its default (`true`), destroying the workflow deletes the image from Azure. Disable it only if you need the image to survive workflow teardown — orphaned images will accumulate in the resource group otherwise. + +**Existing VNet usage**: To pin the build VM to your own network, fill in **all three** of `network.vnet_name`, `network.subnet_name`, and `network.resource_group_name`. If any one is left blank, Packer falls back to creating temporary networking for the build. + +**Supported OS families**: Only `Canonical` (Ubuntu) and `RedHat` (RHEL) are validated. The runner agent and tooling installers assume one of these two families. + +## Outputs + +| Output | Description | +|--------|-------------| +| `image_id` | Resource ID of the created Azure managed image — pass this to the Azure runner template | +| `image_info` | Image metadata: ID, location, resource group, OS family/SKU, build timestamp, name prefix, cleanup settings | +| `resource_group_name` | Resource group where the image is stored | + +## Security Features + +- Image stays inside your Azure subscription and resource group — nothing is published to a shared gallery. +- No inbound ports are exposed during the build; Packer uses ephemeral SSH over your subnet (or a temporary one). +- HTTP proxy support via `network.proxy_url` for restricted-egress environments. +- `os.user_script` lets you inject custom hardening, CA certificates, or extra tooling without modifying the template. diff --git a/stackguardian_private_runner/azure/packer/README.md b/stackguardian_private_runner/azure/packer/README.md index bf40937..7e97cb4 100644 --- a/stackguardian_private_runner/azure/packer/README.md +++ b/stackguardian_private_runner/azure/packer/README.md @@ -1,46 +1,35 @@ -# StackGuardian Private Runner - Packer Image Builder (Azure) +# StackGuardian Private Runner Image Builder - Azure Module -Build custom Azure Managed Images for StackGuardian Private Runner deployments with pre-installed dependencies and configurable tooling. +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 Terraform module automates the creation of custom Azure Managed Images using HashiCorp Packer. The resulting image includes Docker, Terraform, OpenTofu, and StackGuardian runner components, providing an optimized base image for Private Runner deployments. +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 -- **Azure Managed Image**: Pre-configured image with all dependencies -- **Packer Build VM**: Temporary VM used during the build process (automatically terminated) -- **Resource Group** (optional): When `create_resource_group = true` - -### What Gets Installed on the Image - -- Docker (container runtime) -- jq (JSON processor) -- wget, unzip, curl -- cron (task scheduling) -- Terraform (optional, configurable versions) -- OpenTofu (optional, configurable versions) -- StackGuardian Runner (sg-runner binary) +- **`azurerm_resource_group`** (optional, count-gated by `create_resource_group`): destination resource group for the image. +- **`null_resource.packer_build`**: runs `scripts/build_image.sh`, which installs Packer, renders `image.pkr.hcl`, and triggers the build. Re-runs on every apply (timestamp trigger). +- **`data.external.packer_image_id`**: parses `packer_manifest.log` to extract the resource ID of the freshly built managed image. +- **`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 -- **Azure Subscription**: With Contributor permissions to create VMs and images -- **Azure CLI**: Authenticated (`az login`) -- **Terraform**: Version 1.0 or later -- **Network Access**: Packer creates temporary networking by default, or use an existing VNet/subnet +- 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 — `scripts/setup.sh` uses them to bootstrap Packer (default version `1.14.1`). ## Quick Start -### Step 1: Configure Variables +### Step 1: Configure Azure credentials -Create a `terraform.tfvars` file: - -```hcl -azure_location = "westeurope" -resource_group_name = "my-image-rg" +```bash +az login +az account set --subscription "" ``` -### Step 2: Deploy +### Step 2: Apply the module ```bash terraform init @@ -48,20 +37,14 @@ terraform plan terraform apply ``` -### Step 3: Retrieve Image ID - -```bash -terraform output image_id -``` - ### Basic Configuration Example ```hcl -module "packer_image" { - source = "./azure/packer" +module "private_runner_image" { + source = "./packer" - azure_location = "westeurope" - resource_group_name = "my-image-rg" + resource_group_name = "sg-runner-images-rg" + create_resource_group = true } ``` @@ -77,273 +60,173 @@ module "packer_image" { | Parameter | Description | Default | |-----------|-------------|---------| -| `azure_location` | Azure region for image creation | `westeurope` | -| `create_resource_group` | Create the resource group (if false, must already exist) | `false` | -| `vm_size` | Azure VM size for the build process | `Standard_D2s_v3` | -| `os.publisher` | OS publisher (`Canonical` or `RedHat`) | `Canonical` | -| `os.offer` | OS offer | `0001-com-ubuntu-server-jammy` | -| `os.sku` | OS SKU | `22_04-lts-gen2` | -| `os.version` | OS version | `latest` | -| `os.update_os_before_install` | Update OS packages before installation | `true` | -| `os.user_script` | Custom script to run during provisioning | `""` | -| `packer_config.version` | Packer version to use | `1.14.1` | -| `packer_config.cleanup_images_on_destroy` | Auto-cleanup image on terraform destroy | `true` | -| `image_name_prefix` | Prefix for the generated image name | `sg-runner` | -| `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 | `[]` | -| `network.vnet_name` | Existing VNet name (empty = Packer creates temporary networking) | `""` | -| `network.subnet_name` | Existing subnet name | `""` | -| `network.resource_group_name` | Resource group of the existing VNet | `""` | +| `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 `scripts/setup.sh` | `"1.14.1"` | +| `packer_config.cleanup_images_on_destroy` | Delete the image on `terraform destroy` | `true` | +| `image_name_prefix` | Prefix for the generated image name | `"sg-runner"` | +| `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 | `[]` | ### Configuration Examples -#### Basic Configuration (Ubuntu Default) +#### Basic Configuration ```hcl -module "packer_image" { - source = "./azure/packer" +module "private_runner_image" { + source = "./packer" - azure_location = "westeurope" - resource_group_name = "my-image-rg" + resource_group_name = "sg-runner-images-rg" + create_resource_group = true } ``` -#### Ubuntu with Multiple Terraform Versions +#### Advanced Configuration ```hcl -module "packer_image" { - source = "./azure/packer" - - azure_location = "westeurope" - resource_group_name = "my-image-rg" - - os = { - publisher = "Canonical" - offer = "0001-com-ubuntu-server-jammy" - sku = "22_04-lts-gen2" - update_os_before_install = true - } +module "private_runner_image" { + source = "./packer" - terraform = { - primary_version = "1.5.7" - additional_versions = ["1.4.6", "1.6.0", "1.7.0"] - } + 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" - opentofu = { - primary_version = "1.8.0" + network = { + vnet_name = "shared-vnet" + subnet_name = "build-subnet" + resource_group_name = "shared-network-rg" + proxy_url = "http://proxy.internal:8080" } -} -``` - -#### RHEL with Existing Network - -```hcl -module "packer_image" { - source = "./azure/packer" - - azure_location = "westeurope" - resource_group_name = "my-image-rg" - vm_size = "Standard_D4s_v3" os = { publisher = "RedHat" offer = "RHEL" - sku = "9_3" + sku = "9-lvm-gen2" + version = "latest" update_os_before_install = true - } - - network = { - vnet_name = "my-existing-vnet" - subnet_name = "my-build-subnet" - resource_group_name = "my-network-rg" + user_script = file("${path.module}/hardening.sh") } packer_config = { version = "1.14.1" - cleanup_images_on_destroy = false + cleanup_images_on_destroy = true } -} -``` - -#### Custom User Script - -```hcl -module "packer_image" { - source = "./azure/packer" - azure_location = "westeurope" - resource_group_name = "my-image-rg" + terraform = { + primary_version = "1.6.6" + additional_versions = ["1.5.7"] + } - os = { - publisher = "Canonical" - offer = "0001-com-ubuntu-server-jammy" - sku = "22_04-lts-gen2" - user_script = <<-EOF - #!/bin/bash - # Install additional tools - sudo apt-get install -y git - - # Configure custom settings - echo "export CUSTOM_VAR=value" >> ~/.bashrc - EOF + opentofu = { + primary_version = "1.8.0" + additional_versions = ["1.7.3"] } } ``` ## Usage -### Building the Image - ```bash -# Initialize Terraform terraform init - -# Preview changes +terraform validate terraform plan - -# Build the image terraform apply ``` -### Using the Image - -After creation, use the image ID with the Azure Single Runner module: - -```bash -# Get the image ID -IMAGE_ID=$(terraform output -raw image_id) - -# Deploy runners using this image -cd ../azure_runner -terraform apply -var="vm_image_id=$IMAGE_ID" -``` +Each `apply` re-runs the Packer build (the resource has a `timestamp()` trigger), so version bumps in `terraform`/`opentofu`/`os` automatically produce a fresh image. ### Cleanup ```bash -# Destroy and cleanup image (if cleanup_images_on_destroy = true) terraform destroy ``` -For manual cleanup: - -```bash -# List the image -terraform output -json cleanup_commands | jq -r '.list_image' - -# Delete the image -terraform output -json cleanup_commands | jq -r '.delete_image' - -# List all images with prefix -terraform output -json cleanup_commands | jq -r '.list_all' -``` +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 -| File | Purpose | -|------|---------| -| `main.tf` | Packer build orchestration, image cleanup logic | -| `variables.tf` | Input variable definitions and validation | -| `outputs.tf` | Output values (image ID, info, cleanup commands) | -| `locals.tf` | OS family detection, SSH username mapping, image naming | -| `provider.tf` | Azure and utility provider configuration | -| `image.pkr.hcl` | Packer HCL template for Azure image creation | -| `scripts/build_image.sh` | Shell script to execute Packer | -| `scripts/setup.sh` | Image provisioning script (package installation) | - -### Build Flow +- `image.pkr.hcl` — Packer template (azure-arm builder + provisioners). +- `main.tf` — Terraform resources (RG, build, manifest parsing, 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. +- `scripts/setup.sh` — installs Packer at `packer_config.version`. +- `scripts/build_image.sh` — orchestrates the build and writes `packer_manifest.log`. +- `scripts/cleanup_image.sh` — destroy-time image deletion. -``` -terraform apply - | - v -[Execute Packer] --> null_resource.packer_build - | | - | v - | scripts/build_image.sh - | | - | v - | image.pkr.hcl (Packer template) - | | - | v - | scripts/setup.sh (on Azure VM) - | - v -[Parse Image ID] --> data.external.packer_image_id - | - v -[Output Image ID] -``` +### Resource Naming Convention -### Image Naming Convention - -Images are named following the pattern: -``` -{image_name_prefix}-{os_family}-{os_sku}-{timestamp} -``` - -Examples: -- `sg-runner-ubuntu-22_04-lts-gen2-20240115-1430` -- `sg-runner-rhel-9_3-20240115-1430` +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. **Packer Build Fails** - - Check network connectivity (Packer creates temporary networking by default) - - If using existing VNet, verify subnet has internet access - - Review `packer_manifest.log` for detailed errors +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. **Image Cleanup Fails** - - Verify Azure CLI credentials (`az login`) - - Check if the image is in use by a VM or VMSS +2. **Packer install fails in `scripts/setup.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. **Terraform/OpenTofu Not Installed** - - Ensure version strings are valid (e.g., `1.5.7`, not `v1.5.7`) - - Check network access to download URLs +3. **`packer_manifest.log` empty / `image_id` is blank** + - The build failed before producing an artifact. Inspect the Terraform `local-exec` output and re-run the script manually with the same env vars to surface the Packer error. -4. **Permission Denied** - - Verify Azure CLI has Contributor role on the subscription or resource group - - Ensure the service principal can create VMs and images +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 -# View Packer build logs -cat packer_manifest.log +# Tail the latest build output +tail -f packer_manifest.log -# Check image status -az image show --ids $(terraform output -raw image_id) +# Re-run the build script manually +sh scripts/build_image.sh -# List all images with prefix -az image list --resource-group \ - --query "[?starts_with(name, 'sg-runner')].{name:name, id:id}" -o table +# Inspect the produced image +az image show --ids "$(terraform output -raw image_id)" -# Enable Terraform debug logging -export TF_LOG=DEBUG -terraform apply +# 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` | The resource ID of the created Azure Managed Image | -| `image_info` | Comprehensive image metadata (location, OS, timestamps, cleanup settings) | -| `resource_group_name` | The resource group name where the image is stored | -| `cleanup_commands` | Azure CLI commands for manual image cleanup | +| `image_id` | Resource ID of the created Azure managed image | +| `image_info` | Comprehensive image metadata (id, location, RG, OS family/SKU, timestamp, 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 -- **OS Updates**: Recommended to enable `update_os_before_install` for security patches -- **Automatic Cleanup**: Configurable automatic image cleanup on destroy -- **Temporary Resources**: Build VM is automatically terminated after image creation -- **Network Isolation**: Packer creates temporary networking by default, or use an existing private VNet for enterprise environments +- 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 @@ -356,13 +239,9 @@ terraform apply ## Next Steps -After building your image: - -1. **Deploy Private Runners**: Use the [Azure Single Runner](../azure_runner/) module with the created image ID -2. **Configure Runner Group**: Set up StackGuardian runner group using the `runner_group` module -3. **Set Up Autoscaling**: Deploy the [Azure Autoscaler](../autoscaler/) for automatic scaling +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 Documentation](https://docs.stackguardian.io) -- [GitHub Issues](https://github.com/StackGuardian/terraform-stackguardian-modules/issues) +- StackGuardian docs: +- Module source / issues: this repository 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..d16d588 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/schemas/input_schema.json @@ -0,0 +1,179 @@ +{ + "type": "object", + "properties": { + "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 + }, + "cleanup_images_on_destroy": { + "title": "Cleanup Images on Destroy", + "type": "boolean", + "default": true + } + }, + "default": { + "version": "1.14.1", + "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 + } + }, + "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..ef8200f --- /dev/null +++ b/stackguardian_private_runner/azure/packer/schemas/ui_schema.json @@ -0,0 +1,126 @@ +{ + "ui:order": [ + "azure_location", + "resource_group_name", + "create_resource_group", + "vm_size", + "network", + "os", + "packer_config", + "image_name_prefix", + "terraform", + "opentofu" + ], + "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" + }, + "cleanup_images_on_destroy": { + "ui:widget": "checkbox", + "ui:description": "**Warning:** Disabling this leaves orphaned managed images in the resource group when the workflow is destroyed. Re-enable to let Terraform delete the image on `destroy`." + } + }, + "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" + } + } + } +} diff --git a/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md b/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md new file mode 100644 index 0000000..45cfd0c --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md @@ -0,0 +1,100 @@ +# 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. + +## 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. | `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. | `[]` | +| 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` | +| 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 org name to the prefix (e.g. `SG_RUNNER_demo-org`). | `false` | +| Resource Naming → Org Name (for prefix) | Organization name to include in the prefix when "Include Org in Prefix" is enabled. | `""` | + +## 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. If you select "Create NAT Gateway", you must also enable "Create New VNet & Subnet". + +**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. | + +## 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 index 0126890..206ee5f 100644 --- a/stackguardian_private_runner/azure/vmss/README.md +++ b/stackguardian_private_runner/azure/vmss/README.md @@ -1,40 +1,278 @@ -# Azure VM Scale Set — StackGuardian Private Runner +# Private Runner VMSS - Azure Module -Provisions a Linux VM Scale Set whose instances boot from a custom image -(produced by `azure/packer`) and self-register with the StackGuardian -platform. The scale set is meant to be paired with `azure/autoscaler`, -which scales it in/out based on pending-job count. +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. -## What this module creates +## Overview -- `azurerm_linux_virtual_machine_scale_set` — runs the runner image -- VNet + Subnet (optional, when `network.create_network = true`) -- NSG + rules -- NAT Gateway + Public IP (optional, when `network.create_network_infrastructure = true`) +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. -## Wiring with `azure/autoscaler` +### What Gets Created -Pass this module's outputs into the autoscaler: +- **Linux VM Scale Set** with manual upgrade mode and a User-Assigned Managed Identity for storage backend access. +- **Network Security Group** with optional SSH allow rules and arbitrary additional inbound rules. +- **Virtual Network + Subnet** (only when `network.create_network = true`). +- **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). + +## 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 = "../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. | `false` | +| `network.proxy_url` | HTTP proxy URL for private network deployments. | `""` | +| `network.additional_nsg_ids` | Extra NSG IDs to associate. | `[]` | +| `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` | +| `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 org name to prefix. | `false` | +| `override_names.org_name` | Org name appended when above is true. | `""` | + +### Configuration Examples -module "autoscaler" { - source = "../autoscaler" +#### Create network + NAT Gateway - vmss = { - name = module.vmss.vmss_name - resource_group_name = module.vmss.vmss_resource_group_name +```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" + } } - # ... } ``` -## Notes +#### 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. + +### 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 `org_name` when `include_org_in_prefix = true`. + +## 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. **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`. + +5. **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 -- `instances` is `ignore_changes`d after first apply so the autoscaler can drive count without Terraform fighting it. -- `upgrade_mode = "Manual"` — image/SKU changes do **not** roll existing instances; trigger an instance refresh explicitly when you ship a new image. -- SSH key generation is opt-in (`firewall.generate_ssh_key = true`) to avoid storing private keys in Terraform state by default. Prefer providing your own `firewall.ssh_public_key`. +- 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/schemas/input_schema.json b/stackguardian_private_runner/azure/vmss/schemas/input_schema.json new file mode 100644 index 0000000..23d1ac1 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/schemas/input_schema.json @@ -0,0 +1,315 @@ +{ + "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": [] + } + }, + "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 + }, + "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 + }, + "org_name": { + "title": "Org Name (for prefix)", + "type": "string", + "default": "" + } + }, + "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..0292d87 --- /dev/null +++ b/stackguardian_private_runner/azure/vmss/schemas/ui_schema.json @@ -0,0 +1,187 @@ +{ + "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", + "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" + ], + "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/..." } + } + }, + "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." + } + }, + "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", "org_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": "radio", + "ui:options": { "inline": true }, + "ui:description": "When true, appends the org name to the prefix (e.g. SG_RUNNER_demo-org)." + }, + "org_name": { + "ui:placeholder": "demo-org", + "ui:description": "Organization name to include in the prefix when include_org_in_prefix is true." + } + } +} diff --git a/stackguardian_private_runner/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/runner_group/DOCUMENTATION.md index acbebc0..6eacefb 100644 --- a/stackguardian_private_runner/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/runner_group/DOCUMENTATION.md @@ -17,6 +17,7 @@ This template provisions everything required to run private runners against eith - **IAM Access Role** — Cross-account role with an external ID for secure platform access. **For Azure:** +- **Azure Resource Group** — A new resource group to host the storage account and act as the canonical RG for downstream Azure templates (or use an existing one). Exported as `azure_resource_group_name`. - **Azure Storage Account + private "runner" container** — Storage for workflow outputs and artifacts (or an existing storage account). - **Azure AD application + service principal** — Identity for the OIDC connector, granted `Storage Blob Data Reader` on the storage account. @@ -24,7 +25,7 @@ This template provisions everything required to run private runners against eith - A StackGuardian API key for your organization. - For AWS: AWS account credentials in your StackGuardian workspace with permissions to create S3 buckets and IAM roles. -- For Azure: Azure account credentials in your StackGuardian workspace with permissions to create Storage Accounts, Azure AD applications, service principals, and role assignments — plus an **existing Azure Resource Group** to host the storage account. +- For Azure: Azure account credentials in your StackGuardian workspace with permissions to create Resource Groups, Storage Accounts, Azure AD applications, service principals, and role assignments. By default the template creates a new Resource Group; disable **Create Azure Resource Group** to deploy into an existing one. ## Template Parameters @@ -34,7 +35,7 @@ This template provisions everything required to run private runners against eith |-----------|-------------|------| | API Key | Your organization's API key on the StackGuardian Platform (`sgu_*`/`sgo_*`) or a secret reference (`${secret::SECRET_NAME}`) | Password | -When **Cloud Provider** is set to **Azure** and **Create Storage Backend** is enabled, **Azure Resource Group Name** is also required. +When **Cloud Provider** is set to **Azure**, the template creates a new Resource Group by default. Disable **Create Azure Resource Group** and provide **Azure Resource Group Name** to deploy into an existing one. ### Optional Parameters @@ -45,7 +46,9 @@ When **Cloud Provider** is set to **Azure** and **Create Storage Backend** is en | Cloud Provider | Cloud provider for the storage backend (AWS or Azure) | AWS | | AWS Region | The target AWS Region for S3 bucket and IAM resources | eu-central-1 | | Azure Region | The Azure region where storage resources will be deployed | westeurope | -| Azure Resource Group Name | Name of the existing Azure Resource Group for the storage account | — | +| Create Azure Resource Group | Create a new Azure Resource Group for the storage account (Azure only) | Enabled | +| Create Blob Reader Role Assignment | Grant the OIDC connector SP `Storage Blob Data Reader` on the storage account (Azure only). Disable when the deploying identity lacks role-assignment write permission | Enabled | +| Azure Resource Group Name | Resource Group name. Optional override when creating; required when using an existing RG | — | | Create Storage Backend | Whether to create a new storage backend (S3 bucket for AWS, Storage Account for Azure) | Enabled | | Existing S3 Bucket Name | Name of an existing S3 bucket to use (AWS, when not creating new) | — | | Existing Azure Storage Account Name | Name of an existing Azure Storage Account to use (Azure, when not creating new) | — | @@ -63,7 +66,7 @@ When **Cloud Provider** is set to **Azure** and **Create Storage Backend** is en **Cloud Provider**: The Cloud Provider toggle drives every other Azure / AWS option. Switching it after deployment will recreate cloud resources, so choose carefully up front. -**Azure Resource Group**: The template **does not create an Azure Resource Group**. You must point Azure Resource Group Name at an existing one when creating a new Azure storage backend. +**Azure Resource Group**: By default the template **creates a new Azure Resource Group** and exports its name as `azure_resource_group_name` for downstream Azure templates to consume. Disable **Create Azure Resource Group** if you prefer to deploy into an existing one. **API Key Security**: The API key is stored securely and used only to authenticate with the StackGuardian platform. It must be `sgu_*` (user key), `sgo_*` (organization key), or a `${secret::SECRET_NAME}` reference. @@ -83,6 +86,9 @@ When **Cloud Provider** is set to **Azure** and **Create Storage Backend** is en | Connector Name | Name of the AWS or Azure connector integration | | S3 Bucket Name | Name of the storage bucket (AWS only) | | Storage Backend Role ARN | IAM role ARN required by AWS runner instances (AWS only) | +| Azure Resource Group Name | Name of the Azure Resource Group (Azure only) — feed into downstream Azure templates | +| Azure Resource Group Location | Location of the Azure Resource Group (Azure only) | +| Azure Connector Service Principal Object ID | Object ID of the OIDC connector SP (Azure only) — use to create the role assignment out of band when disabled | | Azure Storage Account Name | Name of the Azure Storage Account (Azure only) | | Azure Storage Access Key | Access key for the Azure Storage Account (Azure only, sensitive) | diff --git a/stackguardian_private_runner/runner_group/README.md b/stackguardian_private_runner/runner_group/README.md index b130b2e..fa27b94 100644 --- a/stackguardian_private_runner/runner_group/README.md +++ b/stackguardian_private_runner/runner_group/README.md @@ -19,17 +19,18 @@ The module creates everything required to host private runners against either AW - **IAM role + policy** scoped to the bucket; trust policy allows StackGuardian AWS accounts (`163602625436`, `476299211833`) and the caller's account, gated by an external ID (`{org_name}:{24-char-random}`). **Azure-only resources (when `cloud_provider = "azure"`):** +- **Resource Group** to host the storage account and act as the canonical RG for downstream Azure modules (created when `create_azure_resource_group = true`, the default). 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 (created when `create_storage_backend = true`). - **Azure AD application + service principal** for the OIDC connector. - **Federated identity credential** issued by the StackGuardian API URI for the org subject `/orgs/{org_name}`. -- **`Storage Blob Data Reader` role assignment** scoped to the storage account. +- **`Storage Blob Data Reader` role assignment** scoped to the storage account (created when `create_blob_reader_role_assignment = true`, the default — requires the Terraform identity to have `Microsoft.Authorization/roleAssignments/write`, e.g. `Owner` or `User Access Administrator`). ## Prerequisites - StackGuardian API key (`sgu_*` user key, `sgo_*` org key, or a `${secret::SECRET_NAME}` reference). - Terraform >= 1.0 or OpenTofu >= 1.7. - For AWS: AWS credentials with permissions to create S3 buckets and IAM roles. -- For Azure: Azure credentials (CLI / SP) with permissions to create Storage Accounts, Azure AD applications, service principals, and role assignments. An **existing Azure Resource Group** is required when `create_storage_backend = true`. +- For Azure: Azure credentials (CLI / SP) with permissions to create Resource Groups, Storage Accounts, Azure AD applications, service principals, and role assignments. By default the module creates a new Resource Group; set `create_azure_resource_group = false` and pass `azure_resource_group_name` to deploy into an existing one. ## Quick Start @@ -60,8 +61,10 @@ stackguardian = { org_name = "your-org-name" } -azure_location = "westeurope" -azure_resource_group_name = "my-resource-group" +azure_location = "westeurope" +# Optional — when omitted the module creates a new resource group named +# "{effective_prefix}-rg-{subscription_id}" (lowercased, dashes). +# azure_resource_group_name = "my-resource-group" ``` ### Step 2: Deploy @@ -102,8 +105,7 @@ module "runner_group" { api_key = "sgu_your_api_key" } - azure_location = "westeurope" - azure_resource_group_name = "my-resource-group" + azure_location = "westeurope" } ``` @@ -115,7 +117,7 @@ module "runner_group" { |-----------|-------------|------| | `stackguardian.api_key` | StackGuardian API key (must start with `sgu_` or `sgo_`) | `string` (sensitive) | -When `cloud_provider = "azure"` and `create_storage_backend = true`, `azure_resource_group_name` is also effectively required (the Storage Account creation will fail without an existing resource group). +When `cloud_provider = "azure"`, the module creates a new Azure Resource Group by default. Set `create_azure_resource_group = false` and provide `azure_resource_group_name` to deploy into an existing resource group instead. ### Optional Parameters @@ -126,7 +128,9 @@ When `cloud_provider = "azure"` and `create_storage_backend = true`, `azure_reso | `stackguardian.org_name` | Organization name; falls back to `SG_ORG_ID` env var | `""` | | `aws_region` | Target AWS region (used when `cloud_provider = "aws"`) | `eu-central-1` | | `azure_location` | Azure region (used when `cloud_provider = "azure"`) | `westeurope` | -| `azure_resource_group_name` | Existing Azure Resource Group for the Storage Account | `""` | +| `create_azure_resource_group` | Create a new Azure Resource Group for the storage account (Azure only) | `true` | +| `create_blob_reader_role_assignment` | Grant the OIDC connector SP `Storage Blob Data Reader` on the storage account (Azure only). Disable when the runner SP lacks role-assignment write permission | `true` | +| `azure_resource_group_name` | Resource Group name. Optional override when creating; required when using an existing RG | `""` | | `create_storage_backend` | Create a new storage backend (S3 bucket / Storage Account) | `true` | | `existing_s3_bucket_name` | Existing S3 bucket name (AWS, when `create_storage_backend = false`) | `""` | | `existing_azure_storage_account_name` | Existing Azure Storage Account name (Azure, when `create_storage_backend = false`) | `""` | @@ -201,8 +205,9 @@ module "runner_group" { org_name = "my-organization" } - azure_location = "germanywestcentral" - azure_resource_group_name = "rg-stackguardian" + azure_location = "germanywestcentral" + create_azure_resource_group = true + azure_resource_group_name = "rg-stackguardian" # optional name override for the new RG azure_storage = { account_tier = "Standard" @@ -231,6 +236,8 @@ module "runner_group" { } azure_location = "westeurope" + create_azure_resource_group = false + azure_resource_group_name = "my-existing-rg" create_storage_backend = false existing_azure_storage_account_name = "myexistingstorage" existing_azure_storage_account_access_key = var.azure_storage_key @@ -309,11 +316,13 @@ Examples: 4. **AWS — Permission denied on destroy** - Empty the bucket or set `force_destroy_storage_backend = true`. 5. **Azure — Resource group not found** - - `azure_resource_group_name` must reference an **existing** resource group; the module does not create one. + - When `create_azure_resource_group = false`, `azure_resource_group_name` must reference an **existing** resource group. With the default `create_azure_resource_group = true`, the module creates the RG itself. 6. **Azure — Existing storage account access key invalid** - When `create_storage_backend = false`, `existing_azure_storage_account_access_key` must be a primary or secondary key of `existing_azure_storage_account_name`. 7. **Azure — Insufficient privileges to register an Azure AD application** - The OIDC connector creates an Azure AD application + SP. The caller needs Application.ReadWrite.OwnedBy or equivalent. +8. **Azure — `AuthorizationFailed` on `Microsoft.Authorization/roleAssignments/write`** + - The Terraform identity lacks permission to create role assignments. Either grant it `Owner` / `User Access Administrator` at the subscription or RG scope, or set `create_blob_reader_role_assignment = false` and create the role assignment out of band using `azure_connector_service_principal_object_id` and `azure_storage_account_name`. ### Debugging Commands @@ -342,6 +351,9 @@ terraform apply | `s3_bucket_arn` | ARN of the S3 bucket (AWS only) | | `storage_backend_role_arn` | ARN of the IAM role for storage backend access (AWS only) | | `storage_backend_role_name` | Name of the IAM role (AWS only) | +| `azure_resource_group_name` | Azure Resource Group name (Azure only) — pass to downstream `azure/*` modules | +| `azure_resource_group_location` | Azure Resource Group location (Azure only) | +| `azure_connector_service_principal_object_id` | Object ID of the OIDC connector service principal (Azure only) — use to create the role assignment out of band when `create_blob_reader_role_assignment = false` | | `azure_storage_account_name` | Azure Storage Account name (Azure only) | | `azure_storage_access_key` | Azure Storage Account primary access key (Azure only, sensitive) | | `cloud_provider` | The cloud provider used for the storage backend | diff --git a/stackguardian_private_runner/runner_group/schemas/input_schema.json b/stackguardian_private_runner/runner_group/schemas/input_schema.json index 3993fd8..de4e80c 100644 --- a/stackguardian_private_runner/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/runner_group/schemas/input_schema.json @@ -1,6 +1,5 @@ { "type": "object", - "additionalProperties": false, "properties": { "stackguardian": { "title": "StackGuardian Configuration", @@ -220,13 +219,22 @@ ], "default": "westeurope" }, + "create_azure_resource_group": { + "title": "Create Azure Resource Group", + "type": "boolean", + "default": true + }, + "create_blob_reader_role_assignment": { + "title": "Create Blob Reader Role Assignment", + "type": "boolean", + "default": true + }, "azure_resource_group_name": { "title": "Azure Resource Group Name", "type": "string", - "minLength": 1 + "default": "" } }, - "required": ["azure_resource_group_name"], "dependencies": { "create_storage_backend": { "oneOf": [ diff --git a/stackguardian_private_runner/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/runner_group/schemas/ui_schema.json index 055c30b..033ab85 100644 --- a/stackguardian_private_runner/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/runner_group/schemas/ui_schema.json @@ -6,7 +6,9 @@ "cloud_provider", "aws_region", "azure_location", + "create_azure_resource_group", "azure_resource_group_name", + "create_blob_reader_role_assignment", "create_storage_backend", "existing_s3_bucket_name", "existing_azure_storage_account_name", @@ -45,9 +47,17 @@ "ui:placeholder": "westeurope", "ui:description": "The Azure region where storage resources will be deployed" }, + "create_azure_resource_group": { + "ui:widget": "checkbox", + "ui:description": "Create a new Azure Resource Group for the storage account (Azure only). Disable to deploy into 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 (Azure only). Disable when the identity running Terraform lacks role-assignment write permission; you must then create the assignment out of band." + }, "azure_resource_group_name": { - "ui:placeholder": "my-resource-group", - "ui:description": "The name of the existing Azure Resource Group for the storage account" + "ui:placeholder": "(auto-generated when creating)", + "ui:description": "Optional name override when creating the resource group; required when using an existing resource group." }, "create_storage_backend": { "ui:widget": "checkbox", From 6914e4e804bc77461c918a84aba09067d1ad53f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 24 Aug 2026 14:00:22 +0200 Subject: [PATCH 20/37] SG-3995: Build azure image once and record id in state. --- .../azure/packer/.gitignore | 6 + .../azure/packer/DOCUMENTATION.md | 13 +- .../azure/packer/README.md | 51 ++++++- .../azure/packer/locals.tf | 5 + .../azure/packer/main.tf | 33 ++++- .../azure/packer/outputs.tf | 17 ++- .../azure/packer/packer_manifest.log | 14 -- .../azure/packer/provider.tf | 7 +- .../azure/packer/schemas/input_schema.json | 6 + .../azure/packer/schemas/ui_schema.json | 6 +- .../azure/packer/terraform.tfvars.tpl | 131 ++++++++++++++++++ .../azure/packer/variables.tf | 8 +- 12 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 stackguardian_private_runner/azure/packer/.gitignore delete mode 100644 stackguardian_private_runner/azure/packer/packer_manifest.log create mode 100644 stackguardian_private_runner/azure/packer/terraform.tfvars.tpl diff --git a/stackguardian_private_runner/azure/packer/.gitignore b/stackguardian_private_runner/azure/packer/.gitignore new file mode 100644 index 0000000..8a25fe7 --- /dev/null +++ b/stackguardian_private_runner/azure/packer/.gitignore @@ -0,0 +1,6 @@ +# Packer build artifacts +packer_manifest.log +LICENSE.txt +packer +packer*.zip* +*tfplan* diff --git a/stackguardian_private_runner/azure/packer/DOCUMENTATION.md b/stackguardian_private_runner/azure/packer/DOCUMENTATION.md index b5d094b..7567675 100644 --- a/stackguardian_private_runner/azure/packer/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/packer/DOCUMENTATION.md @@ -4,11 +4,11 @@ Deploy this template on the StackGuardian platform to build a custom Azure manag ## Overview -This template produces a reusable Azure managed image so your private runners boot fast with the agent, Terraform, and OpenTofu already installed. The image is built by HashiCorp Packer in your own Azure subscription, lands in a resource group you control, and is automatically removed when the workflow is destroyed. +This template produces a reusable Azure managed image so your private runners boot fast with the agent, Terraform, and OpenTofu already installed. The image is built by HashiCorp Packer in your own Azure subscription, lands in a resource group you control, and is automatically removed when the workflow is destroyed. It is built on the first deployment only and reused on every run after that, so repeated runs cost no build time. ### What This Template Creates -- **Azure Managed Image** — your custom Private Runner image, tagged with OS family and timestamp. +- **Azure Managed Image** — your custom Private Runner image, tagged with OS family and timestamp. Built once, then recorded in state and reused. - **Resource Group** *(optional)* — created for you when `Create Resource Group` is enabled; otherwise the existing one is reused. - **Automatic cleanup hook** — deletes the image from Azure when the workflow is destroyed (can be disabled). @@ -44,6 +44,7 @@ This template produces a reusable Azure managed image so your private runners bo | `os.update_os_before_install` | Run a full OS package update before installing the runner agent | `true` | | `os.user_script` | Optional shell script run on the build VM after the runner agent is installed | `""` | | `packer_config.version` | Packer version installed by the build script | `1.14.1` | +| `packer_config.rebuild_image_token` | Change to any new value to build a fresh image once; leaving it unchanged never rebuilds | `""` | | `packer_config.cleanup_images_on_destroy` | Delete the managed image when the workflow is destroyed | `true` | | `image_name_prefix` | Prefix used for the generated image name | `sg-runner` | | `terraform.primary_version` | Default Terraform version available on the runner (leave empty to skip) | `""` | @@ -53,9 +54,9 @@ This template produces a reusable Azure managed image so your private runners bo ## Important Notes -**Image is rebuilt on every run**: The template is wired so that each apply produces a fresh image with a new timestamp. This is intentional — version bumps to Terraform, OpenTofu, the user script, or the OS automatically take effect on the next runner. +**Image Reuse**: The image is built on the first deployment only. Its resource 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 image — after changing the OS, the user script, or the Terraform/OpenTofu versions — set *Rebuild Image Token* to any new value. Leaving the token unchanged never rebuilds. -**Cleanup on destroy**: When `cleanup_images_on_destroy` is left at its default (`true`), destroying the workflow deletes the image from Azure. Disable it only if you need the image to survive workflow teardown — orphaned images will accumulate in the resource group otherwise. +**Cleanup on destroy**: When `cleanup_images_on_destroy` is left at its default (`true`), destroying the workflow deletes the image this deployment built, and a rebuild deletes the image it supersedes. Images built by other deployments are never touched. Disable it only if you need the image to survive workflow teardown — orphaned images will accumulate in the resource group otherwise. **Existing VNet usage**: To pin the build VM to your own network, fill in **all three** of `network.vnet_name`, `network.subnet_name`, and `network.resource_group_name`. If any one is left blank, Packer falls back to creating temporary networking for the build. @@ -65,8 +66,8 @@ This template produces a reusable Azure managed image so your private runners bo | Output | Description | |--------|-------------| -| `image_id` | Resource ID of the created Azure managed image — pass this to the Azure runner template | -| `image_info` | Image metadata: ID, location, resource group, OS family/SKU, build timestamp, name prefix, cleanup settings | +| `image_id` | Resource ID of the Azure managed image built by this deployment and recorded in state — pass this to the Azure runner template | +| `image_info` | Image metadata: ID, location, resource group, OS family/SKU, image name, name prefix, cleanup settings | | `resource_group_name` | Resource group where the image is stored | ## Security Features diff --git a/stackguardian_private_runner/azure/packer/README.md b/stackguardian_private_runner/azure/packer/README.md index 7e97cb4..971223f 100644 --- a/stackguardian_private_runner/azure/packer/README.md +++ b/stackguardian_private_runner/azure/packer/README.md @@ -9,8 +9,9 @@ This module provisions an Azure managed image that the sibling `azure_runner` au ### What Gets Created - **`azurerm_resource_group`** (optional, count-gated by `create_resource_group`): destination resource group for the image. -- **`null_resource.packer_build`**: runs `scripts/build_image.sh`, which installs Packer, renders `image.pkr.hcl`, and triggers the build. Re-runs on every apply (timestamp trigger). +- **`null_resource.packer_build`**: runs `scripts/build_image.sh`, which installs Packer, renders `image.pkr.hcl`, and triggers the build. 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 @@ -74,6 +75,7 @@ module "private_runner_image" { | `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 `scripts/setup.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"` | | `terraform.primary_version` | Default Terraform version pre-installed | `""` | @@ -81,6 +83,39 @@ module "private_runner_image" { | `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. + ### Configuration Examples #### Basic Configuration @@ -124,6 +159,7 @@ module "private_runner_image" { packer_config = { version = "1.14.1" + rebuild_image_token = "2026-07-30-tofu-1.11" cleanup_images_on_destroy = true } @@ -148,7 +184,7 @@ terraform plan terraform apply ``` -Each `apply` re-runs the Packer build (the resource has a `timestamp()` trigger), so version bumps in `terraform`/`opentofu`/`os` automatically produce a fresh image. +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 @@ -163,7 +199,7 @@ When `packer_config.cleanup_images_on_destroy = true` (default), the destroy pro ### Resource Organization - `image.pkr.hcl` — Packer template (azure-arm builder + provisioners). -- `main.tf` — Terraform resources (RG, build, manifest parsing, cleanup). +- `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. @@ -186,8 +222,9 @@ Image name follows: `{image_name_prefix}-{os_family}-{os.sku}` where `os_family` 2. **Packer install fails in `scripts/setup.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. **`packer_manifest.log` empty / `image_id` is blank** - - The build failed before producing an artifact. Inspect the Terraform `local-exec` output and re-run the script manually with the same env vars to surface the Packer error. +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. @@ -217,7 +254,7 @@ az image list --resource-group "$(terraform output -raw resource_group_name)" \ | Output | Description | |--------|-------------| | `image_id` | Resource ID of the created Azure managed image | -| `image_info` | Comprehensive image metadata (id, location, RG, OS family/SKU, timestamp, prefix, cleanup settings) | +| `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 | @@ -232,7 +269,7 @@ az image list --resource-group "$(terraform output -raw resource_group_name)" \ | Name | Version | |------|---------| -| terraform | >= 1.0 | +| terraform | >= 1.4.0 (`terraform_data`) | | azurerm | >= 3.0 | | null | >= 3.0 | | external | >= 2.0 | diff --git a/stackguardian_private_runner/azure/packer/locals.tf b/stackguardian_private_runner/azure/packer/locals.tf index 0e84db8..7a81055 100644 --- a/stackguardian_private_runner/azure/packer/locals.tf +++ b/stackguardian_private_runner/azure/packer/locals.tf @@ -17,4 +17,9 @@ locals { # Network configuration (empty strings mean Packer creates temporary networking) use_existing_network = var.network.vnet_name != "" && var.network.subnet_name != "" + + # 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. + image_id = terraform_data.image_id.output } diff --git a/stackguardian_private_runner/azure/packer/main.tf b/stackguardian_private_runner/azure/packer/main.tf index 0a8410b..74af675 100644 --- a/stackguardian_private_runner/azure/packer/main.tf +++ b/stackguardian_private_runner/azure/packer/main.tf @@ -15,6 +15,10 @@ resource "azurerm_resource_group" "packer" { /*-------------------------------------------+ | 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" { provisioner "local-exec" { working_dir = path.module @@ -45,7 +49,7 @@ resource "null_resource" "packer_build" { } triggers = { - timestamp = timestamp() + rebuild_token = var.packer_config.rebuild_image_token } depends_on = [azurerm_resource_group.packer] @@ -54,26 +58,49 @@ resource "null_resource" "packer_build" { /*-------------------------------------------+ | 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" { working_dir = path.module program = [ "sh", "-c", - "grep 'artifact,0,id' packer_manifest.log | tail -1 | cut -d, -f6 | xargs -I{} echo '{\"image_id\": \"{}\"}'" + "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" { + input = data.external.packer_image_id.result["image_id"] + + lifecycle { + ignore_changes = [input] + replace_triggered_by = [null_resource.packer_build] + } +} + /*-------------------------------------------+ | 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. resource "null_resource" "image_cleanup" { count = var.packer_config.cleanup_images_on_destroy ? 1 : 0 # Store image information as triggers so they're available during destroy triggers = { - image_id = data.external.packer_image_id.result["image_id"] + image_id = local.image_id resource_group_name = local.resource_group_name script_path = "${path.module}/scripts/cleanup_image.sh" } diff --git a/stackguardian_private_runner/azure/packer/outputs.tf b/stackguardian_private_runner/azure/packer/outputs.tf index 6ef10b0..0051c89 100644 --- a/stackguardian_private_runner/azure/packer/outputs.tf +++ b/stackguardian_private_runner/azure/packer/outputs.tf @@ -2,19 +2,24 @@ | Packer Azure Image Builder | +----------------------------------*/ output "image_id" { - description = "The resource ID of the created Azure managed image" - value = data.external.packer_image_id.result["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 = data.external.packer_image_id.result["image_id"] + 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 - timestamp = formatdate("YYYY-MM-DD-hhmm", timestamp()) + image_name = local.image_name image_name_prefix = var.image_name_prefix cleanup_settings = { automatic_cleanup = var.packer_config.cleanup_images_on_destroy @@ -30,8 +35,8 @@ output "resource_group_name" { output "cleanup_commands" { description = "Azure CLI commands for manual image cleanup" value = { - list_image = "az image show --ids ${data.external.packer_image_id.result["image_id"]}" - delete_image = "az image delete --ids ${data.external.packer_image_id.result["image_id"]}" + 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/packer_manifest.log b/stackguardian_private_runner/azure/packer/packer_manifest.log deleted file mode 100644 index c50ff99..0000000 --- a/stackguardian_private_runner/azure/packer/packer_manifest.log +++ /dev/null @@ -1,14 +0,0 @@ -1771931887,,ui,say,==> azure-arm.this: Running builder ... -1771931887,,ui,say,==> azure-arm.this: Creating Azure Resource Manager (ARM) client ... -1771931888,,ui,say,==> azure-arm.this: ARM Client successfully created -1771931888,,ui,say,==> azure-arm.this: Getting source image id for the deployment ... -1771931888,,ui,say,==> azure-arm.this: -> SourceImageName: '/subscriptions/a97621d8-9158-4681-81b6-38b1222afba4/providers/Microsoft.Compute/locations/germanywestcentral/publishers/Canonical/ArtifactTypes/vmimage/offers/0001-com-ubuntu-server-jammy/skus/22_04-lts-gen2/versions/latest' -1771931889,,ui,say,==> azure-arm.this: Creating resource group ... -1771931889,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-a5b77kpjnq' -1771931889,,ui,say,==> azure-arm.this: -> Location : 'germanywestcentral' -1771931889,,ui,say,==> azure-arm.this: -> Tags : -1771931889,,ui,say,==> azure-arm.this: ->> os : ubuntu -1771931889,,ui,say,==> azure-arm.this: ->> purpose : stackguardian-private-runner -1771931891,,ui,say,==> azure-arm.this: Validating deployment template ... -1771931891,,ui,say,==> azure-arm.this: -> ResourceGroupName : 'pkr-Resource-Group-a5b77kpjnq' -1771931891,,ui,say,==> azure-arm.this: -> DeploymentName : 'pkrdpa5b77kpjnq' diff --git a/stackguardian_private_runner/azure/packer/provider.tf b/stackguardian_private_runner/azure/packer/provider.tf index 64dc031..a5d0197 100644 --- a/stackguardian_private_runner/azure/packer/provider.tf +++ b/stackguardian_private_runner/azure/packer/provider.tf @@ -1,5 +1,6 @@ terraform { - required_version = ">= 1.0" + # terraform_data (used to record the built image ID in state) needs 1.4+ + required_version = ">= 1.4.0" required_providers { azurerm = { @@ -16,3 +17,7 @@ terraform { } } } + +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 index d16d588..06b5819 100644 --- a/stackguardian_private_runner/azure/packer/schemas/input_schema.json +++ b/stackguardian_private_runner/azure/packer/schemas/input_schema.json @@ -107,6 +107,11 @@ "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", @@ -115,6 +120,7 @@ }, "default": { "version": "1.14.1", + "rebuild_image_token": "", "cleanup_images_on_destroy": true }, "additionalProperties": false diff --git a/stackguardian_private_runner/azure/packer/schemas/ui_schema.json b/stackguardian_private_runner/azure/packer/schemas/ui_schema.json index ef8200f..85b422a 100644 --- a/stackguardian_private_runner/azure/packer/schemas/ui_schema.json +++ b/stackguardian_private_runner/azure/packer/schemas/ui_schema.json @@ -86,9 +86,13 @@ "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": "**Warning:** Disabling this leaves orphaned managed images in the resource group when the workflow is destroyed. Re-enable to let Terraform delete the image on `destroy`." + "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": { 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..116054e --- /dev/null +++ b/stackguardian_private_runner/azure/packer/terraform.tfvars.tpl @@ -0,0 +1,131 @@ +/*-------------------+ + | 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" + +/*------------------------------+ + | 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 = < Date: Mon, 24 Aug 2026 14:00:28 +0200 Subject: [PATCH 21/37] SG-3995: Make azure autoscaler code source configurable. --- .../azure/autoscaler/DOCUMENTATION.md | 9 ++- .../azure/autoscaler/README.md | 33 +++++++++- .../azure/autoscaler/function_autoscaler.tf | 62 ++++++++----------- .../azure/autoscaler/locals.tf | 11 ++-- .../azure/autoscaler/outputs.tf | 16 ++--- .../autoscaler/schemas/input_schema.json | 23 +++++++ .../azure/autoscaler/schemas/ui_schema.json | 21 ++++++- .../autoscaler/scripts/deploy_function.sh | 40 ++++++++++++ .../azure/autoscaler/variables.tf | 33 ++++++++++ 9 files changed, 194 insertions(+), 54 deletions(-) create mode 100644 stackguardian_private_runner/azure/autoscaler/scripts/deploy_function.sh diff --git a/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md b/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md index 1011961..6b3d65f 100644 --- a/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md @@ -10,7 +10,7 @@ This template creates an intelligent autoscaling system that monitors your Stack - **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 +- **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 @@ -58,6 +58,9 @@ Before using this template, you need: | 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 @@ -71,6 +74,8 @@ Before using this template, you need: **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 | @@ -88,5 +93,5 @@ Before using this template, you need: - 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 stored as a Function App setting (encrypted at rest) +- 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 index e6ee133..10b2015 100644 --- a/stackguardian_private_runner/azure/autoscaler/README.md +++ b/stackguardian_private_runner/azure/autoscaler/README.md @@ -10,7 +10,7 @@ The autoscaler module provides intelligent scaling for StackGuardian Private Run - **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 +- **Application Insights**: Monitoring, logging, and alerting (30-day retention by default) - **Role Assignments**: Managed identity with VMSS, storage, and network access ### Architecture @@ -120,6 +120,10 @@ module "azure_autoscaler" { | `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 @@ -184,6 +188,13 @@ module "azure_autoscaler" { 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" + } } ``` @@ -249,6 +260,22 @@ Default behavior: - 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 @@ -538,8 +565,10 @@ az functionapp config appsettings set \ | `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, role assignments, code deployment | +| `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 diff --git a/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf b/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf index 5cb8b68..fe53734 100644 --- a/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf +++ b/stackguardian_private_runner/azure/autoscaler/function_autoscaler.tf @@ -21,6 +21,7 @@ resource "azurerm_application_insights" "autoscaler" { 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" @@ -62,45 +63,32 @@ resource "azurerm_function_app_flex_consumption" "autoscaler" { /*-------------------------------------------+ | Automatic Code Deployment | +-------------------------------------------*/ -# Clones the autoscaler repo and deploys using func CLI -resource "null_resource" "deploy_function_code" { - depends_on = [azurerm_function_app_flex_consumption.autoscaler] - triggers = { - function_app_id = azurerm_function_app_flex_consumption.autoscaler.id - } - - provisioner "local-exec" { - command = <<-EOT - set -e - TEMP_DIR=$(mktemp -d) - git clone --depth 1 --branch SG-3410-shared-autoscaler https://github.com/StackGuardian/sg-runner-autoscaler.git "$TEMP_DIR/repo" - cd "$TEMP_DIR/repo" - cp azure_requirements.txt requirements.txt - - # Create deployment package - zip -r "$TEMP_DIR/deploy.zip" . -x ".git/*" +# 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)\\\"}\"" + ] +} - # 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 ${var.resource_group_name} \ - --name ${nonsensitive(azurerm_function_app_flex_consumption.autoscaler.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 +# 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") + ] - rm -rf "$TEMP_DIR" - EOT + 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 index c2b75c6..1b6e978 100644 --- a/stackguardian_private_runner/azure/autoscaler/locals.tf +++ b/stackguardian_private_runner/azure/autoscaler/locals.tf @@ -1,20 +1,23 @@ +# Extract SG org name from environment if not provided data "external" "env" { program = [ "sh", "-c", - "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\", \"sg_api_uri\": \"'$${SG_API_URI:-https://api.app.stackguardian.io}'\"}'" + "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 = ( - var.stackguardian.org_name != "" - ? var.stackguardian.org_name + nonsensitive(var.stackguardian.org_name) != "" + ? nonsensitive(var.stackguardian.org_name) : data.external.env.result.sg_org_name ) - sg_api_uri = var.stackguardian.api_uri + sg_api_uri = nonsensitive(var.stackguardian.api_uri) # Resource group for VMSS (defaults to main resource group if not specified) vmss_resource_group = ( diff --git a/stackguardian_private_runner/azure/autoscaler/outputs.tf b/stackguardian_private_runner/azure/autoscaler/outputs.tf index ec86319..bc0ed22 100644 --- a/stackguardian_private_runner/azure/autoscaler/outputs.tf +++ b/stackguardian_private_runner/azure/autoscaler/outputs.tf @@ -3,22 +3,22 @@ +---------------------------------*/ output "function_app_name" { description = "The name of the Azure Function App that handles auto-scaling" - value = nonsensitive(azurerm_function_app_flex_consumption.autoscaler.name) + value = azurerm_function_app_flex_consumption.autoscaler.name } output "function_app_id" { description = "The ID of the Azure Function App" - value = nonsensitive(azurerm_function_app_flex_consumption.autoscaler.id) + value = azurerm_function_app_flex_consumption.autoscaler.id } output "function_app_default_hostname" { description = "The default hostname of the Azure Function App" - value = nonsensitive(azurerm_function_app_flex_consumption.autoscaler.default_hostname) + 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 = nonsensitive(azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id) + value = azurerm_function_app_flex_consumption.autoscaler.identity[0].principal_id } /*---------------------------------+ @@ -26,17 +26,17 @@ output "function_app_identity_principal_id" { +---------------------------------*/ output "storage_account_name" { description = "The name of the Storage Account used for autoscaler state" - value = nonsensitive(azurerm_storage_account.autoscaler.name) + value = azurerm_storage_account.autoscaler.name } output "storage_account_id" { description = "The ID of the Storage Account" - value = nonsensitive(azurerm_storage_account.autoscaler.id) + value = azurerm_storage_account.autoscaler.id } output "storage_container_name" { description = "The name of the blob container for autoscaler state" - value = nonsensitive(azurerm_storage_container.autoscaler_state.name) + value = azurerm_storage_container.autoscaler_state.name } /*---------------------------------+ @@ -44,7 +44,7 @@ output "storage_container_name" { +---------------------------------*/ output "application_insights_name" { description = "The name of the Application Insights instance" - value = nonsensitive(azurerm_application_insights.autoscaler.name) + value = azurerm_application_insights.autoscaler.name } output "application_insights_instrumentation_key" { diff --git a/stackguardian_private_runner/azure/autoscaler/schemas/input_schema.json b/stackguardian_private_runner/azure/autoscaler/schemas/input_schema.json index a81fa62..da53865 100644 --- a/stackguardian_private_runner/azure/autoscaler/schemas/input_schema.json +++ b/stackguardian_private_runner/azure/autoscaler/schemas/input_schema.json @@ -217,6 +217,29 @@ "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 index e3beeeb..af78c57 100644 --- a/stackguardian_private_runner/azure/autoscaler/schemas/ui_schema.json +++ b/stackguardian_private_runner/azure/autoscaler/schemas/ui_schema.json @@ -8,7 +8,9 @@ "vmss", "scaling", "override_names", - "storage" + "storage", + "application_insights_retention_in_days", + "autoscaler_repo" ], "stackguardian": { "ui:title": "StackGuardian Configuration", @@ -138,5 +140,22 @@ "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/variables.tf b/stackguardian_private_runner/azure/autoscaler/variables.tf index 5237789..098dc95 100644 --- a/stackguardian_private_runner/azure/autoscaler/variables.tf +++ b/stackguardian_private_runner/azure/autoscaler/variables.tf @@ -22,6 +22,7 @@ variable "stackguardian" { 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)) @@ -198,3 +199,35 @@ variable "storage" { error_message = "The account_replication_type must be one of: LRS, GRS, RAGRS, ZRS." } } + +/*-----------------------------------+ + | Monitoring Configuration | + +-----------------------------------*/ +variable "application_insights_retention_in_days" { + description = < Date: Mon, 24 Aug 2026 14:00:34 +0200 Subject: [PATCH 22/37] SG-3995: Align azure vmss and runner with shared config pattern. --- .../azure/azure_runner/DOCUMENTATION.md | 127 ++++++++++++++++ .../azure/azure_runner/README.md | 58 ++++++-- .../azure/azure_runner/locals.tf | 26 ++-- .../azure/azure_runner/network.tf | 3 + .../azure/azure_runner/provider.tf | 6 +- .../azure_runner/schemas/input_schema.json | 17 ++- .../azure/azure_runner/schemas/ui_schema.json | 13 +- .../azure/azure_runner/variables.tf | 10 +- .../azure/vmss/DOCUMENTATION.md | 20 ++- .../azure/vmss/README.md | 73 ++++++++- .../azure/vmss/locals.tf | 29 ++-- .../azure/vmss/network.tf | 4 + .../azure/vmss/provider.tf | 6 +- .../azure/vmss/schemas/input_schema.json | 95 +++++++++++- .../azure/vmss/schemas/ui_schema.json | 76 +++++++++- .../azure/vmss/variables.tf | 138 +++++++++++++++++- .../azure/vmss/vmss.tf | 53 ++++++- 17 files changed, 674 insertions(+), 80 deletions(-) create mode 100644 stackguardian_private_runner/azure/azure_runner/DOCUMENTATION.md diff --git a/stackguardian_private_runner/azure/azure_runner/DOCUMENTATION.md b/stackguardian_private_runner/azure/azure_runner/DOCUMENTATION.md new file mode 100644 index 0000000..dd1414a --- /dev/null +++ b/stackguardian_private_runner/azure/azure_runner/DOCUMENTATION.md @@ -0,0 +1,127 @@ +# Private Runner - Azure Single VM Template + +Deploy a standalone StackGuardian Private Runner on Azure as a single Linux Virtual Machine. The VM boots from a pre-baked custom image and registers itself with your StackGuardian organization automatically. + +## Overview + +This template gives you one StackGuardian runner running in your own Azure subscription. The runner picks up jobs from your StackGuardian platform and executes them inside your network - so credentials, source code, and outputs never leave your perimeter. Use it when you want a fixed, predictable runner; for a horizontally-scalable pool, use the companion **VMSS** and **Autoscaler** templates instead. + +### What This Template Creates + +- **Linux Virtual Machine** - The single instance that runs StackGuardian jobs. +- **Network Interface** - Attached to your subnet, with an optional public IP. +- **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 runner into. +- **NAT Gateway with public IP** (optional) - Outbound internet access for a runner deployed in a private subnet. +- **VNet service endpoints** (optional) - Reach Azure services over the Azure backbone from the created subnet. +- **Managed identity binding** - The runner uses 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. + +## 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 this instance 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 the runner 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 run environment. | `""` | +| Azure Region | Azure region where the VM and supporting resources are deployed. | `westeurope` | +| VM Size | VM SKU for the runner 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 → Associate Public IP | Assign a public IP to the VM. Disable for fully private deployments. | `false` | +| Network → Create NAT Gateway | Create a NAT Gateway with public IP and associate it with the (created) subnet for outbound internet access. | `false` | +| Network → Service Endpoints | Azure VNet service endpoints to enable on the created subnet (e.g. `Microsoft.Storage`, `Microsoft.KeyVault`). Only applies when creating the subnet. | `[]` | +| Network → Proxy URL | Optional HTTP proxy URL for private network deployments. | `""` | +| Network → Additional NSG IDs | Additional NSG resource IDs to associate with the network interface. | `[]` | +| 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 the VM. | `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). | `{}` | +| Runner Startup Timeout (seconds) | Maximum seconds to wait for Docker to start before shutting down the 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 org name to the prefix (e.g. `SG_RUNNER_demo-org`). | `false` | + +## Important Notes + +**Runner image**: The VM boots from a pre-baked custom image. If the image is missing Docker or the SG runner agent, the instance will self-shutdown after the startup timeout. Always rebuild the image via the Packer template after changes. + +**Single instance**: This template deploys exactly one runner and does not scale. Jobs queue behind each other once the runner is busy. For elastic capacity, deploy the **VMSS** template and drive it with the **Autoscaler** template. + +**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" both apply only to a subnet this template creates; for an existing subnet, configure those on the subnet directly. + +**Network connectivity**: The runner needs outbound HTTPS (port 443) access to reach StackGuardian. For private deployments: +- Enable NAT Gateway creation, or +- Configure a proxy URL, or +- Route outbound traffic through your own firewall / ExpressRoute + +**Service endpoints**: Enabling `Microsoft.Storage` (and friends) keeps traffic between the runner and those Azure services on the Azure backbone rather than the public internet. Remember to allow the subnet on the target resource's network rules - the endpoint alone does not grant access. + +**Resource naming**: Resource names use the global prefix, lowercased with underscores replaced by hyphens (`SG_RUNNER` → `sg-runner-*`). When "Include Org in Prefix" is enabled, the org name is appended to the prefix first. + +**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 | +|--------|-------------| +| VM ID | Resource ID of the deployed Linux Virtual Machine. | +| VM Name | Name of the deployed Linux Virtual Machine. | +| VM Private IP | Internal IP address of the runner. | +| VM Public IP | External IP address (only when "Associate Public IP" is enabled). | +| Network Interface ID | Resource ID of the network interface attached to the VM. | +| Network Security Group ID | Resource ID of the NSG created by this template. | +| VNet ID | The VNet hosting the runner (created or existing). | +| Subnet ID | The subnet hosting the runner (created or existing). | +| SSH Public Key | SSH public key configured on the VM. | +| SSH Private Key | Generated RSA private key (only when "Generate SSH Key" is enabled). Sensitive. | +| Storage Backend Identity ID | Resource ID of the managed identity the runner uses for storage backend access. | + +## Security Features + +- NSG denies all inbound by default; only the rules you explicitly add are opened. +- Password authentication is disabled on the VM - SSH public key only. +- 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). +- Optional VNet service endpoints keep Azure service traffic off the public internet. +- Custom-image-only boot - no plaintext bootstrap of the runner agent over the network. + +## Usage + +After deployment: + +1. The runner automatically registers with your StackGuardian organization. +2. Navigate to **Orchestrator > 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 index 62fa43f..b9ad77f 100644 --- a/stackguardian_private_runner/azure/azure_runner/README.md +++ b/stackguardian_private_runner/azure/azure_runner/README.md @@ -12,7 +12,7 @@ This Terraform module provisions a single Azure Linux VM-based private runner fo - **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 +- **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 @@ -89,20 +89,24 @@ module "azure_runner" { |-----------|-------------|---------| | `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 environment if not provided) | `""` | -| `stackguardian.api_uri` | StackGuardian API endpoint | `""` (auto-detected) | -| `override_names.global_prefix` | Prefix for all resource names | `sg-runner` | +| `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 | `true` | +| `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` | @@ -161,6 +165,34 @@ module "azure_runner" { } ``` +#### 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 @@ -231,8 +263,8 @@ terraform destroy |------|---------| | `provider.tf` | Azure, StackGuardian, and utility provider configuration | | `variables.tf` | Input variable definitions and validation | -| `locals.tf` | Computed values, naming conventions, network mode logic | -| `data.tf` | Data sources for environment variable extraction | +| `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 | @@ -242,15 +274,18 @@ terraform destroy Resources are named using the pattern: `{sanitized_prefix}-{resource-type}` -The `global_prefix` is lowercased with underscores replaced by hyphens. +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 default prefix `sg-runner`: +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-public-ip` +- Public IP: `sg-runner-pip` +- NAT Gateway: `sg-runner-natgw` ## Troubleshooting @@ -312,10 +347,11 @@ az vm show --resource-group --name --query provisioningState ## Security Considerations - **SSH-Only Authentication**: Password authentication is disabled; only SSH key-based access is allowed -- **Auto-Generated Keys**: 4096-bit RSA key pair generated by default for strong encryption +- **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 diff --git a/stackguardian_private_runner/azure/azure_runner/locals.tf b/stackguardian_private_runner/azure/azure_runner/locals.tf index 61b2e7e..8111644 100644 --- a/stackguardian_private_runner/azure/azure_runner/locals.tf +++ b/stackguardian_private_runner/azure/azure_runner/locals.tf @@ -1,24 +1,21 @@ -# Extract SG org name and API URI from environment if not provided +# Extract SG org name from environment if not provided data "external" "env" { program = [ "sh", "-c", - "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\", \"sg_api_uri\": \"'$${SG_API_URI:-https://api.app.stackguardian.io}'\"}'" + "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 = ( - var.stackguardian.org_name != "" - ? var.stackguardian.org_name + nonsensitive(var.stackguardian.org_name) != "" + ? nonsensitive(var.stackguardian.org_name) : data.external.env.result.sg_org_name ) - sg_api_uri = ( - var.stackguardian.api_uri != "" - ? var.stackguardian.api_uri - : data.external.env.result.sg_api_uri - ) + sg_api_uri = nonsensitive(var.stackguardian.api_uri) # Network mode logic create_network = var.network.create_network @@ -28,6 +25,13 @@ locals { # (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 @@ -45,8 +49,8 @@ locals { # Computed prefix with optional org name (matches AWS pattern) effective_prefix = ( - var.override_names.include_org_in_prefix && var.override_names.org_name != "" - ? "${var.override_names.global_prefix}_${var.override_names.org_name}" + 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 ) diff --git a/stackguardian_private_runner/azure/azure_runner/network.tf b/stackguardian_private_runner/azure/azure_runner/network.tf index 396b67c..f84548d 100644 --- a/stackguardian_private_runner/azure/azure_runner/network.tf +++ b/stackguardian_private_runner/azure/azure_runner/network.tf @@ -21,6 +21,9 @@ resource "azurerm_subnet" "this" { 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 } /*-------------------------------------------+ diff --git a/stackguardian_private_runner/azure/azure_runner/provider.tf b/stackguardian_private_runner/azure/azure_runner/provider.tf index b52b8d6..44ed0d8 100644 --- a/stackguardian_private_runner/azure/azure_runner/provider.tf +++ b/stackguardian_private_runner/azure/azure_runner/provider.tf @@ -3,8 +3,10 @@ terraform { required_providers { azurerm = { - source = "hashicorp/azurerm" - version = ">= 3.0" + 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" diff --git a/stackguardian_private_runner/azure/azure_runner/schemas/input_schema.json b/stackguardian_private_runner/azure/azure_runner/schemas/input_schema.json index c91fe73..2202fc0 100644 --- a/stackguardian_private_runner/azure/azure_runner/schemas/input_schema.json +++ b/stackguardian_private_runner/azure/azure_runner/schemas/input_schema.json @@ -111,6 +111,12 @@ "type": "boolean", "default": false }, + "service_endpoints": { + "title": "Service Endpoints", + "type": "array", + "items": { "type": "string" }, + "default": [] + }, "proxy_url": { "title": "Proxy URL", "type": "string", @@ -155,6 +161,12 @@ "title": "Subnet Address Prefix", "type": "string", "default": "10.0.1.0/24" + }, + "service_endpoints": { + "title": "Service Endpoints", + "type": "array", + "items": { "type": "string" }, + "default": [] } } } @@ -273,11 +285,6 @@ "title": "Include Org in Prefix", "type": "boolean", "default": false - }, - "org_name": { - "title": "Org Name (for prefix)", - "type": "string", - "default": "" } }, "required": ["global_prefix"], diff --git a/stackguardian_private_runner/azure/azure_runner/schemas/ui_schema.json b/stackguardian_private_runner/azure/azure_runner/schemas/ui_schema.json index 52c6dc3..acbd79e 100644 --- a/stackguardian_private_runner/azure/azure_runner/schemas/ui_schema.json +++ b/stackguardian_private_runner/azure/azure_runner/schemas/ui_schema.json @@ -69,6 +69,7 @@ "subnet_address_prefix", "associate_public_ip", "create_network_infrastructure", + "service_endpoints", "proxy_url", "additional_nsg_ids" ], @@ -103,6 +104,10 @@ "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." @@ -160,7 +165,7 @@ "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", "org_name"], + "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." @@ -168,11 +173,7 @@ "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)." - }, - "org_name": { - "ui:placeholder": "demo-org", - "ui:description": "Organization name to include in the prefix when include_org_in_prefix is 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/variables.tf b/stackguardian_private_runner/azure/azure_runner/variables.tf index 53297d3..d0f778f 100644 --- a/stackguardian_private_runner/azure/azure_runner/variables.tf +++ b/stackguardian_private_runner/azure/azure_runner/variables.tf @@ -89,13 +89,12 @@ variable "override_names" { Configuration for overriding default resource names. - global_prefix: Prefix used for naming all Azure resources created by this module - - include_org_in_prefix: When true, appends org name to prefix (e.g., SG_RUNNER_demo-org) - - org_name: Organization name to include in prefix (since this module doesn't resolve it from environment) + - include_org_in_prefix: When true, appends org name to prefix (e.g., SG_RUNNER_demo-org). + The org name is taken from stackguardian.org_name (or the SG_ORG_ID environment variable). EOT type = object({ global_prefix = string include_org_in_prefix = optional(bool, false) - org_name = optional(string, "") }) default = { global_prefix = "SG_RUNNER" @@ -121,6 +120,10 @@ variable "network" { - associate_public_ip: Whether to assign public IP to the VM - create_network_infrastructure: Whether to create a NAT Gateway (with public IP) and associate it with the subnet for outbound internet access from a private subnet. When disabled, ensure the subnet has its own route to the internet (NAT, firewall, ExpressRoute, etc.) for StackGuardian platform connectivity. + - service_endpoints: (Optional) Azure VNet service endpoints to enable on the created subnet + (e.g. ["Microsoft.Storage", "Microsoft.KeyVault"]). Lets the runner reach those services over + the Azure backbone instead of the public internet. Only applies when create_network = true; + for an existing subnet, configure the service endpoints on that subnet directly. - proxy_url: HTTP proxy URL for private network deployments (e.g., http://proxy.example.com:8080) - additional_nsg_ids: Additional NSG IDs to associate with the NIC EOT @@ -132,6 +135,7 @@ variable "network" { subnet_address_prefix = optional(string, "10.0.1.0/24") associate_public_ip = optional(bool, false) create_network_infrastructure = optional(bool, false) + service_endpoints = optional(list(string), []) proxy_url = optional(string, "") additional_nsg_ids = optional(list(string), []) }) diff --git a/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md b/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md index 45cfd0c..b70e9bb 100644 --- a/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md @@ -14,6 +14,7 @@ This template gives you a horizontally-scalable pool of StackGuardian runners ru - **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 @@ -49,9 +50,10 @@ This template gives you a horizontally-scalable pool of StackGuardian runners ru | 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. | `false` | +| 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` | @@ -63,10 +65,18 @@ This template gives you a horizontally-scalable pool of StackGuardian runners ru | 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 org name to the prefix (e.g. `SG_RUNNER_demo-org`). | `false` | -| Resource Naming → Org Name (for prefix) | Organization name to include in the prefix when "Include Org in Prefix" is enabled. | `""` | +| 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 @@ -76,7 +86,9 @@ This template gives you a horizontally-scalable pool of StackGuardian runners ru **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. If you select "Create NAT Gateway", you must also enable "Create New VNet & Subnet". +**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. diff --git a/stackguardian_private_runner/azure/vmss/README.md b/stackguardian_private_runner/azure/vmss/README.md index 206ee5f..393db72 100644 --- a/stackguardian_private_runner/azure/vmss/README.md +++ b/stackguardian_private_runner/azure/vmss/README.md @@ -8,11 +8,12 @@ This module is the Azure counterpart to the AWS ASG-based runner. It provisions ### What Gets Created -- **Linux VM Scale Set** with manual upgrade mode and a User-Assigned Managed Identity for storage backend access. +- **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`). +- **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 @@ -101,9 +102,10 @@ module "vmss" { | `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. | `false` | +| `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` | @@ -115,10 +117,18 @@ module "vmss" { | `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 org name to prefix. | `false` | -| `override_names.org_name` | Org name appended when above is true. | `""` | +| `override_names.include_org_in_prefix` | Append the org name (from `stackguardian.org_name`) to the prefix. | `false` | ### Configuration Examples @@ -145,6 +155,44 @@ module "vmss" { } ``` +#### 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 @@ -172,6 +220,12 @@ terraform apply 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 @@ -197,7 +251,7 @@ When `firewall.generate_ssh_key = true`, the generated private key is destroyed ### Resource Naming Convention -Resources are named `-vmss-` where `sanitized_prefix` is built from `override_names.global_prefix`, optionally suffixed with `org_name` when `include_org_in_prefix = true`. +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 @@ -210,12 +264,15 @@ Resources are named `-vmss-` where `sanitized_prefix` is 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. **Instance starts but doesn't register with StackGuardian** +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`. -5. **Instance shuts itself down after 5 minutes** +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 diff --git a/stackguardian_private_runner/azure/vmss/locals.tf b/stackguardian_private_runner/azure/vmss/locals.tf index b51b507..0e23192 100644 --- a/stackguardian_private_runner/azure/vmss/locals.tf +++ b/stackguardian_private_runner/azure/vmss/locals.tf @@ -1,23 +1,21 @@ +# Extract SG org name from environment if not provided data "external" "env" { program = [ "sh", "-c", - "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\", \"sg_api_uri\": \"'$${SG_API_URI:-https://api.app.stackguardian.io}'\"}'" + "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 = ( - var.stackguardian.org_name != "" - ? var.stackguardian.org_name + nonsensitive(var.stackguardian.org_name) != "" + ? nonsensitive(var.stackguardian.org_name) : data.external.env.result.sg_org_name ) - sg_api_uri = ( - var.stackguardian.api_uri != "" - ? var.stackguardian.api_uri - : data.external.env.result.sg_api_uri - ) + sg_api_uri = nonsensitive(var.stackguardian.api_uri) # Network mode logic create_network = var.network.create_network @@ -32,6 +30,13 @@ locals { # 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 = ( @@ -40,10 +45,14 @@ locals { : 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 && var.override_names.org_name != "" - ? "${var.override_names.global_prefix}_${var.override_names.org_name}" + 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 ) diff --git a/stackguardian_private_runner/azure/vmss/network.tf b/stackguardian_private_runner/azure/vmss/network.tf index 6289518..d8d60a0 100644 --- a/stackguardian_private_runner/azure/vmss/network.tf +++ b/stackguardian_private_runner/azure/vmss/network.tf @@ -21,6 +21,10 @@ resource "azurerm_subnet" "this" { 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 } /*-------------------------------------------+ diff --git a/stackguardian_private_runner/azure/vmss/provider.tf b/stackguardian_private_runner/azure/vmss/provider.tf index 29e2ad8..8d214ed 100644 --- a/stackguardian_private_runner/azure/vmss/provider.tf +++ b/stackguardian_private_runner/azure/vmss/provider.tf @@ -3,8 +3,10 @@ terraform { required_providers { azurerm = { - source = "hashicorp/azurerm" - version = ">= 3.0" + 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" diff --git a/stackguardian_private_runner/azure/vmss/schemas/input_schema.json b/stackguardian_private_runner/azure/vmss/schemas/input_schema.json index 23d1ac1..828eb09 100644 --- a/stackguardian_private_runner/azure/vmss/schemas/input_schema.json +++ b/stackguardian_private_runner/azure/vmss/schemas/input_schema.json @@ -116,6 +116,12 @@ "type": "array", "items": { "type": "string" }, "default": [] + }, + "service_endpoints": { + "title": "Service Endpoints", + "type": "array", + "items": { "type": "string" }, + "default": [] } }, "dependencies": { @@ -274,6 +280,90 @@ }, "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", @@ -293,11 +383,6 @@ "title": "Include Org in Prefix", "type": "boolean", "default": false - }, - "org_name": { - "title": "Org Name (for prefix)", - "type": "string", - "default": "" } }, "required": ["global_prefix"], diff --git a/stackguardian_private_runner/azure/vmss/schemas/ui_schema.json b/stackguardian_private_runner/azure/vmss/schemas/ui_schema.json index 0292d87..02fdb88 100644 --- a/stackguardian_private_runner/azure/vmss/schemas/ui_schema.json +++ b/stackguardian_private_runner/azure/vmss/schemas/ui_schema.json @@ -12,6 +12,7 @@ "os_disk", "firewall", "scaling", + "upgrade_policy", "runner_startup_timeout", "override_names" ], @@ -70,7 +71,8 @@ "subnet_address_prefix", "create_network_infrastructure", "proxy_url", - "additional_nsg_ids" + "additional_nsg_ids", + "service_endpoints" ], "create_network": { "ui:widget": "radio", @@ -105,6 +107,10 @@ "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": { @@ -163,13 +169,73 @@ "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", "org_name"], + "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." @@ -177,11 +243,7 @@ "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)." - }, - "org_name": { - "ui:placeholder": "demo-org", - "ui:description": "Organization name to include in the prefix when include_org_in_prefix is 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/variables.tf b/stackguardian_private_runner/azure/vmss/variables.tf index 1c4f91c..deb8eff 100644 --- a/stackguardian_private_runner/azure/vmss/variables.tf +++ b/stackguardian_private_runner/azure/vmss/variables.tf @@ -89,13 +89,13 @@ variable "override_names" { Configuration for overriding default resource names. - global_prefix: Prefix used for naming all Azure resources created by this module - - include_org_in_prefix: When true, appends org name to prefix (e.g., SG_RUNNER_demo-org) - - org_name: Organization name to include in prefix (since this module doesn't resolve it from environment) + - include_org_in_prefix: When true, appends the org name to the prefix (e.g., SG_RUNNER_demo-org). + The org name always comes from stackguardian.org_name, falling back to the SG_ORG_ID + environment variable - there is no separate override here. EOT type = object({ global_prefix = string include_org_in_prefix = optional(bool, false) - org_name = optional(string, "") }) default = { global_prefix = "SG_RUNNER" @@ -119,8 +119,15 @@ variable "network" { - subnet_address_prefix: Address prefix for new subnet (when create_network = true) - create_network_infrastructure: Create a NAT Gateway (with public IP) and associate it with the (created) subnet for outbound internet access. + Only takes effect together with create_network = true - the module never + attaches a NAT Gateway to a subnet it does not own. - proxy_url: HTTP proxy URL for private network deployments - additional_nsg_ids: Additional NSG IDs to associate with each instance + - service_endpoints: (Optional) VNet service endpoints to enable on the subnet this + module creates, so runners reach Azure PaaS over the Azure backbone instead of the + public internet. Typical values: Microsoft.Storage, Microsoft.KeyVault, + Microsoft.ContainerRegistry. Ignored when bringing an existing subnet - add the + endpoints on that subnet yourself. EOT type = object({ create_network = optional(bool, false) @@ -131,6 +138,7 @@ variable "network" { create_network_infrastructure = optional(bool, false) proxy_url = optional(string, "") additional_nsg_ids = optional(list(string), []) + service_endpoints = optional(list(string), []) }) validation { @@ -140,6 +148,13 @@ variable "network" { ) error_message = "Either set create_network = true, or provide both vnet_id and subnet_id." } + + validation { + condition = alltrue([ + for endpoint in var.network.service_endpoints : can(regex("^Microsoft\\.[A-Za-z]+(\\.[A-Za-z]+)?$", endpoint)) + ]) + error_message = "Each service_endpoints entry must be an Azure service endpoint name such as 'Microsoft.Storage' or 'Microsoft.KeyVault'." + } } /*-----------------------+ @@ -260,6 +275,123 @@ variable "scaling" { } } +/*--------------------------+ + | 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 | +-----------------------------------*/ diff --git a/stackguardian_private_runner/azure/vmss/vmss.tf b/stackguardian_private_runner/azure/vmss/vmss.tf index e57e6ae..aba684a 100644 --- a/stackguardian_private_runner/azure/vmss/vmss.tf +++ b/stackguardian_private_runner/azure/vmss/vmss.tf @@ -11,9 +11,11 @@ resource "tls_private_key" "ssh" { /*-------------------------------------------+ | Linux Virtual Machine Scale Set | +-------------------------------------------*/ -# Manual upgrade policy mirrors aws_launch_template + ASG pattern: changes -# to the SKU/image require explicit instance refresh, which the autoscaler +# 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 @@ -67,7 +69,52 @@ resource "azurerm_linux_virtual_machine_scale_set" "this" { ) ) - upgrade_mode = "Manual" + 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 From 75dd51807b5b029623444b31831e6e7c558cdda1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 24 Aug 2026 14:00:40 +0200 Subject: [PATCH 23/37] SG-3995: Backport azure improvements to aws modules. --- .../aws/autoscaler/DOCUMENTATION.md | 14 ++- .../aws/autoscaler/README.md | 37 ++++++-- .../aws/autoscaler/iam.tf | 36 ++++++-- .../aws/autoscaler/lambda.tf | 10 +++ .../aws/autoscaler/locals.tf | 18 ++++ .../aws/autoscaler/provider.tf | 3 + .../aws/autoscaler/scheduler.tf | 5 +- .../aws/autoscaler/schemas/input_schema.json | 24 ++++++ .../aws/autoscaler/schemas/ui_schema.json | 22 ++++- .../aws/autoscaler/variables.tf | 43 +++++++++- .../aws/autoscaling_group/DOCUMENTATION.md | 2 +- .../aws/autoscaling_group/README.md | 4 +- .../aws/autoscaling_group/autoscaling.tf | 1 + .../aws/autoscaling_group/provider.tf | 4 + .../templates/register_runner.sh.tpl | 10 +++ .../aws/packer/DOCUMENTATION.md | 2 +- .../aws/packer/README.md | 16 +++- .../aws/packer/TERRAFORM_DESTROY_GUIDE.md | 15 +++- .../aws/packer/scripts/cleanup_amis.sh | 85 ++++++++++++++++--- .../aws/single_runner/DOCUMENTATION.md | 2 + .../aws/single_runner/README.md | 2 +- .../aws/single_runner/ec2.tf | 1 + .../aws/single_runner/provider.tf | 3 + .../templates/register_runner.sh.tpl | 10 +++ 24 files changed, 327 insertions(+), 42 deletions(-) diff --git a/stackguardian_private_runner/aws/autoscaler/DOCUMENTATION.md b/stackguardian_private_runner/aws/autoscaler/DOCUMENTATION.md index ee7c896..c777f14 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 | @@ -78,6 +84,6 @@ Before using this template, you need: ## 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..b68f674 100644 --- a/stackguardian_private_runner/aws/autoscaler/README.md +++ b/stackguardian_private_runner/aws/autoscaler/README.md @@ -4,15 +4,17 @@ This Terraform module deploys a Lambda-based autoscaler that monitors StackGuard ## 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 +95,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 +154,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 +170,11 @@ module "autoscaler" { timeout = 120 memory_size = 256 } + + tags = { + environment = "production" + owner = "platform-team" + } } ``` @@ -184,9 +198,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 +244,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 +301,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 +309,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 e8ea315..a40816a 100644 --- a/stackguardian_private_runner/aws/autoscaler/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/autoscaler/schemas/input_schema.json @@ -117,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", @@ -147,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 @@ -196,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..e499469 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. diff --git a/stackguardian_private_runner/aws/autoscaling_group/README.md b/stackguardian_private_runner/aws/autoscaling_group/README.md index dac8926..e58908e 100644 --- a/stackguardian_private_runner/aws/autoscaling_group/README.md +++ b/stackguardian_private_runner/aws/autoscaling_group/README.md @@ -100,7 +100,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 +314,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/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..cc0724e 100644 --- a/stackguardian_private_runner/aws/packer/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/packer/DOCUMENTATION.md @@ -69,7 +69,7 @@ Before deploying this template: **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..0e010b8 100644 --- a/stackguardian_private_runner/aws/packer/README.md +++ b/stackguardian_private_runner/aws/packer/README.md @@ -289,6 +289,20 @@ terraform apply -var="ami_id=$AMI_ID" 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 @@ -438,7 +452,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/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/single_runner/DOCUMENTATION.md b/stackguardian_private_runner/aws/single_runner/DOCUMENTATION.md index 5106d3a..41e1da5 100644 --- a/stackguardian_private_runner/aws/single_runner/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/single_runner/DOCUMENTATION.md @@ -70,6 +70,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..18b83fa 100644 --- a/stackguardian_private_runner/aws/single_runner/README.md +++ b/stackguardian_private_runner/aws/single_runner/README.md @@ -304,7 +304,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/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. From aa4c8fcc929777ed1298972f07828ce241fd15ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 24 Aug 2026 14:00:49 +0200 Subject: [PATCH 24/37] SG-3995: Add azure tree docs and quickstart example. --- .../azure/DOCUMENTATION.md | 348 ++++++++++++++ .../examples/azure/.gitignore | 3 +- .../examples/azure/quickstart/.gitignore | 4 + .../examples/azure/quickstart/README.md | 455 ++++++++++++++++++ .../examples/azure/quickstart/locals.tf | 10 + .../examples/azure/quickstart/main.tf | 165 +++++++ .../examples/azure/quickstart/outputs.tf | 87 ++++ .../azure/quickstart/terraform.tfvars.tpl | 128 +++++ .../examples/azure/quickstart/variables.tf | 234 +++++++++ .../examples/azure/standalone-vm/.gitignore | 4 + .../examples/azure/standalone-vm/README.md | 135 ++++++ .../azure/{ => standalone-vm}/locals.tf | 0 .../azure/{ => standalone-vm}/main.tf | 2 +- .../azure/{ => standalone-vm}/outputs.tf | 0 .../azure/{ => standalone-vm}/provider.tf | 4 +- .../templates/install_runner.sh.tpl | 0 .../{ => standalone-vm}/troubleshooting.md | 0 .../azure/{ => standalone-vm}/variables.tf | 0 18 files changed, 1576 insertions(+), 3 deletions(-) create mode 100644 stackguardian_private_runner/azure/DOCUMENTATION.md create mode 100644 stackguardian_private_runner/examples/azure/quickstart/.gitignore create mode 100644 stackguardian_private_runner/examples/azure/quickstart/README.md create mode 100644 stackguardian_private_runner/examples/azure/quickstart/locals.tf create mode 100644 stackguardian_private_runner/examples/azure/quickstart/main.tf create mode 100644 stackguardian_private_runner/examples/azure/quickstart/outputs.tf create mode 100644 stackguardian_private_runner/examples/azure/quickstart/terraform.tfvars.tpl create mode 100644 stackguardian_private_runner/examples/azure/quickstart/variables.tf create mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/.gitignore create mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/README.md rename stackguardian_private_runner/examples/azure/{ => standalone-vm}/locals.tf (100%) rename stackguardian_private_runner/examples/azure/{ => standalone-vm}/main.tf (99%) rename stackguardian_private_runner/examples/azure/{ => standalone-vm}/outputs.tf (100%) rename stackguardian_private_runner/examples/azure/{ => standalone-vm}/provider.tf (57%) rename stackguardian_private_runner/examples/azure/{ => standalone-vm}/templates/install_runner.sh.tpl (100%) rename stackguardian_private_runner/examples/azure/{ => standalone-vm}/troubleshooting.md (100%) rename stackguardian_private_runner/examples/azure/{ => standalone-vm}/variables.tf (100%) diff --git a/stackguardian_private_runner/azure/DOCUMENTATION.md b/stackguardian_private_runner/azure/DOCUMENTATION.md new file mode 100644 index 0000000..2f3381f --- /dev/null +++ b/stackguardian_private_runner/azure/DOCUMENTATION.md @@ -0,0 +1,348 @@ +# 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 four Azure templates plus the shared `runner_group` template, 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 `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 | +|-----------|-------------|---------| +| 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 | `[]` | + +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 (shared) + +Create a StackGuardian Runner Group with an Azure Blob Storage backend and an Entra ID OIDC connector. This is the shared `runner_group/` template at the repository root, driven into Azure mode with `cloud_provider = "azure"`; the same template serves the AWS stack. + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| cloud_provider | Must be set to `azure` (defaults to `aws`) | string | +| 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 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) | +| max_runners | Maximum number of runners allowed in the group | `3` | + +`override_names.connector_name` exists but only names the AWS connector; the Azure connector name is derived from the effective prefix. + +### 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_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_name`, 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 `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/examples/azure/.gitignore b/stackguardian_private_runner/examples/azure/.gitignore index fe3cca7..ebab1bc 100644 --- a/stackguardian_private_runner/examples/azure/.gitignore +++ b/stackguardian_private_runner/examples/azure/.gitignore @@ -1,4 +1,5 @@ -# Plan artifacts - may embed credentials from the tfvars +# Local state and plan artifacts from running the examples in place. +# Plan files may embed credentials from the tfvars. tfplan tofuplan *.tfplan diff --git a/stackguardian_private_runner/examples/azure/quickstart/.gitignore b/stackguardian_private_runner/examples/azure/quickstart/.gitignore new file mode 100644 index 0000000..fe3cca7 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/quickstart/.gitignore @@ -0,0 +1,4 @@ +# Plan artifacts - may embed credentials from the tfvars +tfplan +tofuplan +*.tfplan diff --git a/stackguardian_private_runner/examples/azure/quickstart/README.md b/stackguardian_private_runner/examples/azure/quickstart/README.md new file mode 100644 index 0000000..52f53d8 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/quickstart/README.md @@ -0,0 +1,455 @@ +# StackGuardian Private Runner - Azure Quickstart + +Zero to a registered, running Private Runner on Azure in a single `apply`. + +This example wires the three building-block modules together into one root module, +so you configure a handful of values once instead of running three deployments and +hand-copying outputs between them. + +> Deploying a **single runner** on a newly created VNet with a public IP. For an +> autoscaled fleet, use the `azure/vmss` and `azure/autoscaler` modules directly - +> see the [top-level README](../../../README.md). + +For a no-Packer variant that installs everything at first boot, see +[`../standalone-vm`](../standalone-vm). + +## 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) │ + │ • temp build VM + temp │ + │ 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 │ + │ • VNet + subnet │ + │ • NSG (all egress, no │ + │ ingress unless SSH is │ + │ configured) │ + │ • static public IP + NIC │ + │ • 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. + +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 | +| **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 | + +### Azure Permissions + +The identity running this needs, at minimum: + +- **Contributor** on the subscription or target scope - resource groups, storage + accounts, images, VMs, VNets, NSGs, public IPs, managed identities +- **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` and `stackguardian.org_name`. 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 | + +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 | +| `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 | +| `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 | + +> **`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 | + +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**. + +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. + +## Networking + +This example creates a **new VNet and subnet** for the runner and attaches a static +public IP, relying on Azure's default outbound route for internet access. Packer, by +default, builds on its own throwaway VNet that it removes when the build finishes. + +The runner's NSG allows **all egress** and **no ingress**. SSH is opened only if you +set `firewall.ssh_access_rules`. + +### Service Endpoints + +If you lock the storage account down to specific subnets, or you want the runner's +blob traffic to stay on the Azure backbone rather than crossing the public internet: + +```hcl +network = { + service_endpoints = ["Microsoft.Storage"] +} +``` + +These apply to the subnet this example creates. The default is `[]`, which is fine +for the default storage account configuration (`public_network_access_enabled = true` +with no network rules). + +### Building Inside an Existing VNet + +If the build VM must sit in your network - 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" +} +``` + +## The Storage Backend Identity + +The runner authenticates to the storage account with a **User-Assigned Managed +Identity**. The `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 built by Packer and recorded in state | +| `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 | +| `ssh_command` | Ready-to-paste SSH command | +| `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 | +| `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 | +| 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 +``` + +[`../standalone-vm/troubleshooting.md`](../standalone-vm/troubleshooting.md) covers a +separate failure mode - the ECS agent terminally exiting after a successful +registration because of a stale container-instance ARN. It applies to any private +runner, including this one. + +## Limitations + +This example trades flexibility for a short path to a working runner: + +- **Public IP only.** `create_network_infrastructure` (NAT gateway), `proxy_url`, + and attaching to an existing VNet/subnet are supported by `azure/azure_runner` but + are not exposed here. Use the module directly when you need a private subnet, NAT + gateway, or proxy. +- **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..317d8d1 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/quickstart/main.tf @@ -0,0 +1,165 @@ +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 = "../../../runner_group" + + cloud_provider = "azure" + azure_location = var.azure_location + + stackguardian = var.stackguardian + + override_names = var.override_names + + # 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. +# ------------------------------------------------------- +module "packer" { + source = "../../../azure/packer" + + 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 + + # Empty vnet/subnet: Packer creates and tears down its own temporary networking + network = var.packer_network + + os = var.os + packer_config = var.packer_config + image_name_prefix = var.image_name_prefix + 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 +} + +# Resolve the storage account created by the runner group; the module exposes its +# name but not the resource ID needed to scope the role assignment. +data "azurerm_storage_account" "backend" { + name = module.runner_group.azure_storage_account_name + resource_group_name = module.runner_group.azure_resource_group_name +} + +# 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 = data.azurerm_storage_account.backend.id + role_definition_name = "Storage Blob Data Contributor" + principal_id = azurerm_user_assigned_identity.storage_backend.principal_id +} + +# ------------------------------------------------------- +# Module 3: Single Runner VM +# Deploys the private runner from the image built above, +# 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 + } + + network = { + create_network = true + vnet_address_space = var.network.vnet_address_space + subnet_address_prefix = var.network.subnet_address_prefix + service_endpoints = var.network.service_endpoints + associate_public_ip = true + } + + 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..4cc3dd0 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/quickstart/outputs.tf @@ -0,0 +1,87 @@ +/*---------------------------------+ + | 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 built by Packer and recorded in state" + 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 "ssh_command" { + description = "Ready-to-use SSH command, once firewall.ssh_access_rules opens port 22" + value = "ssh ${var.firewall.admin_username}@${module.azure_runner.vm_public_ip}" +} + +output "ssh_private_key" { + description = "Generated SSH private key, when firewall.generate_ssh_key is true" + value = module.azure_runner.ssh_private_key + sensitive = true +} diff --git a/stackguardian_private_runner/examples/azure/quickstart/terraform.tfvars.tpl b/stackguardian_private_runner/examples/azure/quickstart/terraform.tfvars.tpl new file mode 100644 index 0000000..e845c34 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/quickstart/terraform.tfvars.tpl @@ -0,0 +1,128 @@ +# ============================================================ +# StackGuardian Private Runner - Azure Quickstart +# ============================================================ +# Copy this file to terraform.tfvars and fill in your values. +# Everything commented out is optional and shown with its default. +# ============================================================ + +# --- Required: StackGuardian credentials --- +stackguardian = { + api_key = "sgu_xxxxxxxxxxxxxxxxxxxxx" # Your SG API key + org_name = "my-org" # Your SG organization name + # api_uri = "https://api.app.stackguardian.io" # EU1 (default) + # api_uri = "https://api.us.stackguardian.io" # US1 +} + +# --- Required in practice: SSH access --- +# Password auth is always disabled, so the VM needs a key. Supply your own +# public key here; the alternative, generate_ssh_key = true, is the default +# only so the example applies out of the box - it puts the private key in state. +firewall = { + ssh_public_key = "ssh-ed25519 AAAA..." + # admin_username = "azureuser" + # + # Nothing is open inbound unless you add a rule. Port 22 from one address: + # ssh_access_rules = { + # "my-ip" = "203.0.113.10/32" + # } + # + # additional_inbound_rules = { + # "custom" = { + # priority = 200 + # protocol = "Tcp" + # destination_port_range = "8080" + # source_address_prefix = "10.0.0.0/8" + # } + # } +} + +# --- Optional: Azure placement --- +# azure_location = "westeurope" +# +# One resource group holds the storage backend, the managed image, and the VM. +# Leave empty to derive the name from the prefix and subscription ID. +# azure_resource_group_name = "" + +# --- Optional: Resource naming --- +# override_names = { +# global_prefix = "SG_RUNNER" +# include_org_in_prefix = false +# runner_group_name = "" # default: "-runner-group-" +# connector_name = "" # default: "-private-runner-backend-" +# } + +# --- Optional: Runner group and storage backend --- +# max_runners = 3 +# azure_storage = { +# account_tier = "Standard" +# account_replication_type = "LRS" +#} +# +# Set to false if the identity running OpenTofu cannot write role assignments +# (Contributor without User Access Administrator). You must then grant +# "Storage Blob Data Reader" to the connector service principal and +# "Storage Blob Data Contributor" to the runner identity yourself. +# create_role_assignments = true + +# --- Optional: Image build --- +# packer_vm_size = "Standard_D2s_v3" +# image_name_prefix = "sg-runner" +# +# Packer builds the image on the first apply only. Later plans reuse it, so the +# runner keeps the same image. To build a new one, change the token below to +# any new value: +# packer_config = { +# version = "1.14.1" +# rebuild_image_token = "2026-08-24" +# cleanup_images_on_destroy = true +# } +# +# Base Marketplace image. 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 +# } +# +# terraform = { +# primary_version = "1.9.8" +# additional_versions = ["1.8.5"] +# } +# opentofu = { +# primary_version = "1.8.8" +# } +# +# By default Packer builds on its own throwaway VNet. Point it at an existing +# one when the build must run inside your network: +# packer_network = { +# vnet_name = "my-vnet" +# subnet_name = "build-subnet" +# resource_group_name = "my-network-rg" +# proxy_url = "" +# } + +# --- Optional: Runner VM --- +# runner_vm_size = "Standard_D4s_v3" +# os_disk = { +# caching = "ReadWrite" +# storage_account_type = "Premium_LRS" +# disk_size_gb = 100 +# } +# Seconds to wait for Docker to come up before the VM shuts itself down. +# Raise it if a custom user_script makes first boot slow. +# runner_startup_timeout = 300 + +# --- Optional: Network --- +# A new VNet and subnet are created for the runner, with a public IP attached. +# +# service_endpoints routes the listed Azure services over the Azure backbone +# instead of the public internet. Add "Microsoft.Storage" if your storage +# account restricts public network access. +# network = { +# vnet_address_space = ["10.0.0.0/16"] +# subnet_address_prefix = "10.0.1.0/24" +# service_endpoints = [] +# } diff --git a/stackguardian_private_runner/examples/azure/quickstart/variables.tf b/stackguardian_private_runner/examples/azure/quickstart/variables.tf new file mode 100644 index 0000000..6db5dc5 --- /dev/null +++ b/stackguardian_private_runner/examples/azure/quickstart/variables.tf @@ -0,0 +1,234 @@ +/*-----------------------------------+ + | StackGuardian Platform Variables | + +-----------------------------------*/ +variable "stackguardian" { + description = "StackGuardian platform configuration (api_key, api_uri, org_name)" + type = object({ + api_key = string + api_uri = optional(string, "https://api.app.stackguardian.io") + org_name = optional(string, "") + }) + sensitive = true +} + +/*---------------------+ + | Azure Configuration | + +---------------------*/ +variable "azure_location" { + description = "Azure region for all resources" + type = string + default = "westeurope" +} + +variable "azure_resource_group_name" { + description = <= 1.0** (or Terraform >= 1.4) | | +| **Azure credentials** | Via `az login` or `ARM_*` environment variables | +| **StackGuardian API key** | Org-scoped key that can create runner groups and connectors | +| **Two existing resource groups** | One for the storage backend, one for the VM and network | +| **An SSH public key** | Required - password auth is disabled on the VM | + +## Quick start + +```bash +tofu init +tofu plan -out=tofuplan +tofu apply tofuplan +``` + +A minimal `terraform.tfvars`: + +```hcl +stackguardian = { + api_key = "sgu_xxxxxxxxxxxxxxxxxxxxx" + org_name = "my-org" +} + +runner_storage_resource_group_name = "sg-runner-storage-rg" +compute_resource_group_name = "sg-runner-compute-rg" +azure_location = "westeurope" + +admin_ssh_public_key = "ssh-ed25519 AAAA..." +ssh_source_address_prefix = "203.0.113.10/32" +``` + +Then: + +```bash +tofu output ssh_command # ssh azureuser@ +tofu output runner_group_url # confirm the runner shows up in the console +``` + +Every other variable is documented in [`variables.tf`](variables.tf) with a default. + +Verify the SKU you pin actually exists in your region before applying: + +```bash +az vm image list --location westeurope --publisher RedHat --offer RHEL --all -o table +``` + +## Checking the boot + +The install script logs everything it does: + +```bash +sudo tail -f /var/log/sg_runner_startup.log # install + registration +sudo tail -f /var/log/cloud-init-output.log # full custom_data run +systemctl status docker +docker ps +``` + +If the VM comes up but never appears in the runner group, that log is the first place to look - +the install runs at boot, so failures show up there rather than in the Tofu output. + +## Also here + +[`troubleshooting.md`](troubleshooting.md) documents an ECS-agent failure mode +(agent terminally exits after a successful registration because `/var/lib/ecs/data/agent.db` +holds a stale container-instance ARN). It applies to any private runner, not just this example. + +## Limitations + +- **SSH is open by default** to `ssh_source_address_prefix`. Narrow it to a single address. +- **Installs at every boot.** Slower and less repeatable than a pre-baked image. +- **No managed identity.** The VM gets no storage-backend identity, so it is not a faithful + reproduction of a supported deployment. +- **Local state.** No backend is configured. diff --git a/stackguardian_private_runner/examples/azure/locals.tf b/stackguardian_private_runner/examples/azure/standalone-vm/locals.tf similarity index 100% rename from stackguardian_private_runner/examples/azure/locals.tf rename to stackguardian_private_runner/examples/azure/standalone-vm/locals.tf diff --git a/stackguardian_private_runner/examples/azure/main.tf b/stackguardian_private_runner/examples/azure/standalone-vm/main.tf similarity index 99% rename from stackguardian_private_runner/examples/azure/main.tf rename to stackguardian_private_runner/examples/azure/standalone-vm/main.tf index ea71dcb..eb73a79 100644 --- a/stackguardian_private_runner/examples/azure/main.tf +++ b/stackguardian_private_runner/examples/azure/standalone-vm/main.tf @@ -3,7 +3,7 @@ | Creates StackGuardian runner group + Azure storage backend | +============================================================*/ module "runner_group" { - source = "../../runner_group" + source = "../../../runner_group" cloud_provider = "azure" azure_location = var.azure_location diff --git a/stackguardian_private_runner/examples/azure/outputs.tf b/stackguardian_private_runner/examples/azure/standalone-vm/outputs.tf similarity index 100% rename from stackguardian_private_runner/examples/azure/outputs.tf rename to stackguardian_private_runner/examples/azure/standalone-vm/outputs.tf diff --git a/stackguardian_private_runner/examples/azure/provider.tf b/stackguardian_private_runner/examples/azure/standalone-vm/provider.tf similarity index 57% rename from stackguardian_private_runner/examples/azure/provider.tf rename to stackguardian_private_runner/examples/azure/standalone-vm/provider.tf index f826b1b..0f821de 100644 --- a/stackguardian_private_runner/examples/azure/provider.tf +++ b/stackguardian_private_runner/examples/azure/standalone-vm/provider.tf @@ -2,9 +2,11 @@ terraform { required_version = ">= 1.0" required_providers { + # Pinned to 4.x: the runner_group module is written against azurerm 4.x but + # only constrains ">= 3.0", so the ceiling has to live in the root module. azurerm = { source = "hashicorp/azurerm" - version = ">= 3.0" + version = "~> 4.0" } azuread = { source = "hashicorp/azuread" diff --git a/stackguardian_private_runner/examples/azure/templates/install_runner.sh.tpl b/stackguardian_private_runner/examples/azure/standalone-vm/templates/install_runner.sh.tpl similarity index 100% rename from stackguardian_private_runner/examples/azure/templates/install_runner.sh.tpl rename to stackguardian_private_runner/examples/azure/standalone-vm/templates/install_runner.sh.tpl diff --git a/stackguardian_private_runner/examples/azure/troubleshooting.md b/stackguardian_private_runner/examples/azure/standalone-vm/troubleshooting.md similarity index 100% rename from stackguardian_private_runner/examples/azure/troubleshooting.md rename to stackguardian_private_runner/examples/azure/standalone-vm/troubleshooting.md diff --git a/stackguardian_private_runner/examples/azure/variables.tf b/stackguardian_private_runner/examples/azure/standalone-vm/variables.tf similarity index 100% rename from stackguardian_private_runner/examples/azure/variables.tf rename to stackguardian_private_runner/examples/azure/standalone-vm/variables.tf From 5e99c6bae119ade65ea6110cf715bc6b148ace7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 24 Aug 2026 23:38:26 +0200 Subject: [PATCH 25/37] SG-3995: Drop committed ami dump and tighten gitignore. ami_cleanup_info.txt was a dated DescribeImages dump from a local destroy run that got committed by accident. Nothing generates it any more. Also ignore tofuplan, which only the per-example gitignores covered. --- stackguardian_private_runner/.gitignore | 4 +++ .../aws/packer/ami_cleanup_info.txt | 30 ------------------- 2 files changed, 4 insertions(+), 30 deletions(-) delete mode 100644 stackguardian_private_runner/aws/packer/ami_cleanup_info.txt 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/aws/packer/ami_cleanup_info.txt b/stackguardian_private_runner/aws/packer/ami_cleanup_info.txt deleted file mode 100644 index c4504c6..0000000 --- a/stackguardian_private_runner/aws/packer/ami_cleanup_info.txt +++ /dev/null @@ -1,30 +0,0 @@ -# AMI Cleanup Information - Thu Aug 28 13:59:59 CEST 2025 -# Generated before terraform destroy -# Use this information to manually clean up AMIs if needed -# Region: eu-central-1 - ------------------------------------------------------------------------------------------------------------- -| DescribeImages | -+-----------------------+----------------------------------------+----------------------------+------------+ -| ami-00967d6bd40d8ac24| SG-RUNNER-ami-rhel9.4-1755940253 | 2025-08-23T09:15:05.000Z | available | -| ami-0c954b75beb1d9bc1| SG-RUNNER-ami-amazon-1755782518 | 2025-08-21T13:23:58.000Z | available | -| ami-093ba2e506b8a51ba| SG-RUNNER-ami-rhel9.4-1755873744 | 2025-08-22T15:00:58.000Z | available | -| ami-0dbb2ad6c6a2cb950| SG-RUNNER-ami-rhel9.4-1756050439 | 2025-08-24T15:52:51.000Z | available | -| ami-0188d261cd4906f41| SG-RUNNER-ami-ubuntu22.04-1755687374 | 2025-08-20T10:58:45.000Z | available | -| ami-06b122e706e2eb684| SG-RUNNER-ami-amazon-1754924209 | 2025-08-11T14:58:16.000Z | available | -| ami-0d49fa3c8d935b563| SG-RUNNER-ami-rhel9.4-1756049104 | 2025-08-24T15:29:13.000Z | available | -| ami-0dc5bf69279f5cf99| SG-RUNNER-ami-amazon-1755011017 | 2025-08-12T15:05:19.000Z | available | -| ami-0895ce6d152d62f51| SG-RUNNER-ami-rhel9.4-1755885098 | 2025-08-22T17:55:29.000Z | available | -| ami-024215e992ba1f5dd| SG-RUNNER-ami-ubuntu22.04-1755690103 | 2025-08-20T11:43:34.000Z | available | -| ami-034b27dde32a080d4| SG-RUNNER-ami-amazon-1756382002 | 2025-08-28T11:55:14.000Z | available | -| ami-0cdda754673c14c0e| SG-RUNNER-ami-ubuntu22.04-1755527702 | 2025-08-18T14:36:57.000Z | available | -+-----------------------+----------------------------------------+----------------------------+------------+ - -# Manual cleanup commands: -# To deregister AMIs (replace AMI_ID with actual AMI ID): -# aws ec2 deregister-image --region eu-central-1 --image-id AMI_ID - -# To delete associated snapshots (get snapshot IDs from AMI details): -# aws ec2 delete-snapshot --region eu-central-1 --snapshot-id SNAPSHOT_ID - -# Bulk cleanup script is available in scripts/cleanup_amis.sh From 3006df9ed56647586078402e07cb8cb5917580bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 24 Aug 2026 23:38:55 +0200 Subject: [PATCH 26/37] SG-3995: Remove azure standalone-vm example. The standalone-vm rig existed to reproduce a customer host on a stock marketplace image with pinned docker and sg-runner versions. It is not what the modules deploy - no managed identity, no custom image, SSH open by default - and the quickstart covers the supported path. Drops the two pointers to it from the quickstart readme, and the examples/azure gitignore that only existed because the rig was applied from that directory. --- .../examples/azure/.gitignore | 5 - .../examples/azure/quickstart/README.md | 8 - .../examples/azure/standalone-vm/.gitignore | 4 - .../examples/azure/standalone-vm/README.md | 135 --------------- .../examples/azure/standalone-vm/locals.tf | 12 -- .../examples/azure/standalone-vm/main.tf | 158 ------------------ .../examples/azure/standalone-vm/outputs.tf | 48 ------ .../examples/azure/standalone-vm/provider.tf | 20 --- .../templates/install_runner.sh.tpl | 74 -------- .../azure/standalone-vm/troubleshooting.md | 130 -------------- .../examples/azure/standalone-vm/variables.tf | 154 ----------------- 11 files changed, 748 deletions(-) delete mode 100644 stackguardian_private_runner/examples/azure/.gitignore delete mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/.gitignore delete mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/README.md delete mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/locals.tf delete mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/main.tf delete mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/outputs.tf delete mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/provider.tf delete mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/templates/install_runner.sh.tpl delete mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/troubleshooting.md delete mode 100644 stackguardian_private_runner/examples/azure/standalone-vm/variables.tf diff --git a/stackguardian_private_runner/examples/azure/.gitignore b/stackguardian_private_runner/examples/azure/.gitignore deleted file mode 100644 index ebab1bc..0000000 --- a/stackguardian_private_runner/examples/azure/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Local state and plan artifacts from running the examples in place. -# Plan files may embed credentials from the tfvars. -tfplan -tofuplan -*.tfplan diff --git a/stackguardian_private_runner/examples/azure/quickstart/README.md b/stackguardian_private_runner/examples/azure/quickstart/README.md index 52f53d8..7bf3bb2 100644 --- a/stackguardian_private_runner/examples/azure/quickstart/README.md +++ b/stackguardian_private_runner/examples/azure/quickstart/README.md @@ -10,9 +10,6 @@ hand-copying outputs between them. > autoscaled fleet, use the `azure/vmss` and `azure/autoscaler` modules directly - > see the [top-level README](../../../README.md). -For a no-Packer variant that installs everything at first boot, see -[`../standalone-vm`](../standalone-vm). - ## Contents - [What Gets Deployed](#what-gets-deployed) @@ -433,11 +430,6 @@ To force a rebuild without touching variables: tofu apply -replace=module.packer.null_resource.packer_build ``` -[`../standalone-vm/troubleshooting.md`](../standalone-vm/troubleshooting.md) covers a -separate failure mode - the ECS agent terminally exiting after a successful -registration because of a stale container-instance ARN. It applies to any private -runner, including this one. - ## Limitations This example trades flexibility for a short path to a working runner: diff --git a/stackguardian_private_runner/examples/azure/standalone-vm/.gitignore b/stackguardian_private_runner/examples/azure/standalone-vm/.gitignore deleted file mode 100644 index fe3cca7..0000000 --- a/stackguardian_private_runner/examples/azure/standalone-vm/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Plan artifacts - may embed credentials from the tfvars -tfplan -tofuplan -*.tfplan diff --git a/stackguardian_private_runner/examples/azure/standalone-vm/README.md b/stackguardian_private_runner/examples/azure/standalone-vm/README.md deleted file mode 100644 index 598faed..0000000 --- a/stackguardian_private_runner/examples/azure/standalone-vm/README.md +++ /dev/null @@ -1,135 +0,0 @@ -# StackGuardian Private Runner - Azure Standalone VM - -A single-file reproduction rig: a stock Azure Marketplace RHEL VM that installs Docker and -`sg-runner` on first boot, then registers with a StackGuardian runner group. - -It composes only the `runner_group` module. Everything else - VNet, subnet, NSG, public IP, NIC -and the `azurerm_linux_virtual_machine` - is declared inline here, and the runner software is -installed at boot by [`templates/install_runner.sh.tpl`](templates/install_runner.sh.tpl). - -## When to use this instead of the quickstart - -Use [`../quickstart`](../quickstart) for anything you intend to keep. It composes the real -`azure/packer` and `azure/azure_runner` modules and is the supported path to a production runner. - -Reach for this example when: - -| You want to | Why this one | -|-------------|--------------| -| Skip the Packer build entirely | No pre-baked image is needed - it boots a Marketplace image | -| Pin an exact Docker / `sg-runner` version | `docker_version` and `sg_runner_version` are installed verbatim at boot | -| Reproduce a customer environment | RHEL image, Docker version and runner version are all explicit knobs | -| Read the whole bootstrap in one place | The install script is a template in this directory, not baked into an image | - -The trade-off is start-up time and repeatability: every boot re-downloads and re-installs Docker -and `sg-runner`, so the VM takes minutes to become useful and a package-repo outage breaks the -boot. The quickstart bakes all of that into an image once, so its VMs come up ready. - -This example is also **not** what the runner modules deploy: it has no managed identity for the -storage backend, no custom image, and its NSG opens SSH by default. Do not treat differences -between it and a real deployment as bugs in the modules. - -## What gets deployed - -``` - module.runner_group ┌──────────────────────────────┐ - ────────────────────►│ StackGuardian control plane │ - │ │ • runner group │ - │ │ • AZURE_OIDC connector │ - │ └──────────────────────────────┘ - │ ┌──────────────────────────────┐ - └─────────────►│ Azure (storage backend) │ - │ • storage account + CORS │ - │ • "runner" blob container │ - │ • AAD app + SP (OIDC) │ - └──────────────────────────────┘ - - this root module ┌──────────────────────────────┐ - ────────────────────►│ VNet + subnet + NSG │ - │ public IP + NIC │ - │ RHEL VM (Marketplace image) │ - │ └─ custom_data installs │ - │ Docker + sg-runner, │ - │ then registers │ - └──────────────────────────────┘ -``` - -Both resource groups must already exist - this example creates neither -(`create_azure_resource_group = false` on the runner group module). - -## Prerequisites - -| Requirement | Notes | -|-------------|-------| -| **OpenTofu >= 1.0** (or Terraform >= 1.4) | | -| **Azure credentials** | Via `az login` or `ARM_*` environment variables | -| **StackGuardian API key** | Org-scoped key that can create runner groups and connectors | -| **Two existing resource groups** | One for the storage backend, one for the VM and network | -| **An SSH public key** | Required - password auth is disabled on the VM | - -## Quick start - -```bash -tofu init -tofu plan -out=tofuplan -tofu apply tofuplan -``` - -A minimal `terraform.tfvars`: - -```hcl -stackguardian = { - api_key = "sgu_xxxxxxxxxxxxxxxxxxxxx" - org_name = "my-org" -} - -runner_storage_resource_group_name = "sg-runner-storage-rg" -compute_resource_group_name = "sg-runner-compute-rg" -azure_location = "westeurope" - -admin_ssh_public_key = "ssh-ed25519 AAAA..." -ssh_source_address_prefix = "203.0.113.10/32" -``` - -Then: - -```bash -tofu output ssh_command # ssh azureuser@ -tofu output runner_group_url # confirm the runner shows up in the console -``` - -Every other variable is documented in [`variables.tf`](variables.tf) with a default. - -Verify the SKU you pin actually exists in your region before applying: - -```bash -az vm image list --location westeurope --publisher RedHat --offer RHEL --all -o table -``` - -## Checking the boot - -The install script logs everything it does: - -```bash -sudo tail -f /var/log/sg_runner_startup.log # install + registration -sudo tail -f /var/log/cloud-init-output.log # full custom_data run -systemctl status docker -docker ps -``` - -If the VM comes up but never appears in the runner group, that log is the first place to look - -the install runs at boot, so failures show up there rather than in the Tofu output. - -## Also here - -[`troubleshooting.md`](troubleshooting.md) documents an ECS-agent failure mode -(agent terminally exits after a successful registration because `/var/lib/ecs/data/agent.db` -holds a stale container-instance ARN). It applies to any private runner, not just this example. - -## Limitations - -- **SSH is open by default** to `ssh_source_address_prefix`. Narrow it to a single address. -- **Installs at every boot.** Slower and less repeatable than a pre-baked image. -- **No managed identity.** The VM gets no storage-backend identity, so it is not a faithful - reproduction of a supported deployment. -- **Local state.** No backend is configured. diff --git a/stackguardian_private_runner/examples/azure/standalone-vm/locals.tf b/stackguardian_private_runner/examples/azure/standalone-vm/locals.tf deleted file mode 100644 index 09b1d35..0000000 --- a/stackguardian_private_runner/examples/azure/standalone-vm/locals.tf +++ /dev/null @@ -1,12 +0,0 @@ -locals { - sanitized_prefix = replace(lower(var.prefix), "_", "-") - vm_name = "${local.sanitized_prefix}-runner" - - common_tags = { - purpose = "stackguardian-private-runner" - prefix = var.prefix - } - - # Whether to include org name in resource name prefix - include_org_in_prefix = false -} diff --git a/stackguardian_private_runner/examples/azure/standalone-vm/main.tf b/stackguardian_private_runner/examples/azure/standalone-vm/main.tf deleted file mode 100644 index eb73a79..0000000 --- a/stackguardian_private_runner/examples/azure/standalone-vm/main.tf +++ /dev/null @@ -1,158 +0,0 @@ -/*============================================================+ - | Stage 0: Runner Group | - | Creates StackGuardian runner group + Azure storage backend | - +============================================================*/ -module "runner_group" { - source = "../../../runner_group" - - cloud_provider = "azure" - azure_location = var.azure_location - create_azure_resource_group = false - azure_resource_group_name = var.runner_storage_resource_group_name - azure_storage = var.azure_storage - max_runners = var.max_runners - - stackguardian = var.stackguardian - - override_names = { - global_prefix = var.prefix - include_org_in_prefix = local.include_org_in_prefix - } -} - -/*============================================================+ - | Stage 1: Networking | - | VNet, Subnet, NSG (inbound SSH + all outbound) | - +============================================================*/ - -resource "azurerm_virtual_network" "this" { - name = "${local.sanitized_prefix}-vnet" - address_space = var.network.vnet_address_space - location = var.azure_location - resource_group_name = var.compute_resource_group_name - - tags = local.common_tags -} - -resource "azurerm_subnet" "this" { - name = "${local.sanitized_prefix}-subnet" - resource_group_name = var.compute_resource_group_name - virtual_network_name = azurerm_virtual_network.this.name - address_prefixes = [var.network.subnet_address_prefix] -} - -resource "azurerm_network_security_group" "this" { - name = "${local.sanitized_prefix}-nsg" - location = var.azure_location - resource_group_name = var.compute_resource_group_name - - security_rule { - name = "AllowSSHInbound" - priority = 100 - direction = "Inbound" - access = "Allow" - protocol = "Tcp" - source_port_range = "*" - destination_port_range = "22" - source_address_prefix = var.ssh_source_address_prefix - 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 = local.common_tags -} - -resource "azurerm_subnet_network_security_group_association" "this" { - subnet_id = azurerm_subnet.this.id - network_security_group_id = azurerm_network_security_group.this.id -} - -/*============================================================+ - | Stage 2: Single RHEL Runner VM | - | Marketplace RHEL 9.8 image; Docker + sg-runner installed | - | at first boot via custom_data, then registers. | - +============================================================*/ - -resource "azurerm_public_ip" "this" { - name = "${local.sanitized_prefix}-pip" - location = var.azure_location - resource_group_name = var.compute_resource_group_name - allocation_method = "Static" - sku = "Standard" - - tags = local.common_tags -} - -resource "azurerm_network_interface" "this" { - name = "${local.sanitized_prefix}-nic" - location = var.azure_location - resource_group_name = var.compute_resource_group_name - - ip_configuration { - name = "internal" - subnet_id = azurerm_subnet.this.id - private_ip_address_allocation = "Dynamic" - public_ip_address_id = azurerm_public_ip.this.id - } - - tags = local.common_tags -} - -resource "azurerm_linux_virtual_machine" "this" { - name = local.vm_name - resource_group_name = var.compute_resource_group_name - location = var.azure_location - size = var.vm_size - admin_username = var.admin_username - - network_interface_ids = [azurerm_network_interface.this.id] - disable_password_authentication = true - - admin_ssh_key { - username = var.admin_username - public_key = var.admin_ssh_public_key - } - - source_image_reference { - publisher = var.rhel_image.publisher - offer = var.rhel_image.offer - sku = var.rhel_image.sku - version = var.rhel_image.version - } - - os_disk { - caching = var.vm_os_disk.caching - storage_account_type = var.vm_os_disk.storage_account_type - disk_size_gb = var.vm_os_disk.disk_size_gb - } - - custom_data = base64encode( - templatefile("${path.module}/templates/install_runner.sh.tpl", - { - sg_org_name = module.runner_group.sg_org_name - sg_api_uri = module.runner_group.sg_api_uri - sg_runner_group_name = module.runner_group.runner_group_name - sg_runner_group_token = module.runner_group.runner_group_token - docker_version = var.docker_version - sg_runner_version = var.sg_runner_version - admin_username = var.admin_username - startup_timeout = tostring(var.runner_startup_timeout) - } - ) - ) - - tags = merge(local.common_tags, { - Name = local.vm_name - }) -} diff --git a/stackguardian_private_runner/examples/azure/standalone-vm/outputs.tf b/stackguardian_private_runner/examples/azure/standalone-vm/outputs.tf deleted file mode 100644 index e72f761..0000000 --- a/stackguardian_private_runner/examples/azure/standalone-vm/outputs.tf +++ /dev/null @@ -1,48 +0,0 @@ -/*---------------------------------+ - | 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_url" { - description = "Direct URL to the runner group in the StackGuardian web console" - value = module.runner_group.runner_group_url -} - -output "azure_storage_account_name" { - description = "The name of the Azure Storage Account used for the runner group storage backend" - value = module.runner_group.azure_storage_account_name -} - -/*---------------------------------+ - | Runner VM Outputs | - +---------------------------------*/ -output "vm_name" { - description = "The name of the runner VM" - value = azurerm_linux_virtual_machine.this.name -} - -output "vm_public_ip" { - description = "The public IP address of the runner VM" - value = azurerm_public_ip.this.ip_address -} - -output "ssh_command" { - description = "Ready-to-use SSH command to attach to the runner VM" - value = "ssh ${var.admin_username}@${azurerm_public_ip.this.ip_address}" -} - -/*---------------------------------+ - | Network Outputs | - +---------------------------------*/ -output "vnet_id" { - description = "The ID of the created Virtual Network" - value = azurerm_virtual_network.this.id -} - -output "subnet_id" { - description = "The ID of the created Subnet" - value = azurerm_subnet.this.id -} diff --git a/stackguardian_private_runner/examples/azure/standalone-vm/provider.tf b/stackguardian_private_runner/examples/azure/standalone-vm/provider.tf deleted file mode 100644 index 0f821de..0000000 --- a/stackguardian_private_runner/examples/azure/standalone-vm/provider.tf +++ /dev/null @@ -1,20 +0,0 @@ -terraform { - required_version = ">= 1.0" - - required_providers { - # Pinned to 4.x: the runner_group module is written against azurerm 4.x but - # only constrains ">= 3.0", so the ceiling has to live in the root module. - azurerm = { - source = "hashicorp/azurerm" - version = "~> 4.0" - } - azuread = { - source = "hashicorp/azuread" - version = ">= 2.0" - } - } -} - -provider "azurerm" { - features {} -} diff --git a/stackguardian_private_runner/examples/azure/standalone-vm/templates/install_runner.sh.tpl b/stackguardian_private_runner/examples/azure/standalone-vm/templates/install_runner.sh.tpl deleted file mode 100644 index dfb77fc..0000000 --- a/stackguardian_private_runner/examples/azure/standalone-vm/templates/install_runner.sh.tpl +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# -# Replicates a production StackGuardian private runner host for investigation: -# - RHEL 9.x -# - Docker pinned to a specific version (production: 29.5.2 / build 79eb04c) -# - sg-runner pinned to a specific release tag (production: "Installationsscript v2.2.1") -# -# Mirrors the RHEL path of the Packer image setup -# (azure/packer/scripts/setup.sh) but pins versions instead of installing latest, -# then registers the runner. - -set -euo pipefail - -LOG=/var/log/sg_runner_startup.log -exec > >(tee -a "$LOG") 2>&1 - -echo ">> [replica] starting install on: $(cat /etc/redhat-release 2>/dev/null || echo unknown)" - -# 1. Base dependencies (matches _dnf_dependencies) -dnf install -y dnf-plugins-core unzip cronie wget - -# 2. Docker repo + PINNED engine (production: ${docker_version} / build 79eb04c) -dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo -DOCKER_VERSION="${docker_version}" -# RHEL package strings look like "3:29.5.2-1.el9"; match the version with a glob so the -# epoch / dist suffix don't have to be hardcoded. -dnf install -y \ - "docker-ce-*$${DOCKER_VERSION}*" \ - "docker-ce-cli-*$${DOCKER_VERSION}*" \ - containerd.io - -# 3. Enable services + docker group (matches _systemctl_enable / _usermod_add_to_group) -systemctl enable --now crond docker -usermod -aG docker ${admin_username} || true - -# 4. jq (sg-runner depends on it) -ARCH=amd64 -JQ_URL=$(wget -qO- https://api.github.com/repos/jqlang/jq/releases/latest \ - | grep browser_download_url | grep "jq-linux-$${ARCH}" | head -1 | cut -d'"' -f4) -wget -qO /usr/bin/jq "$${JQ_URL}" -chmod +x /usr/bin/jq - -# 5. sg-runner PINNED to the production tag (Installationsscript ${sg_runner_version}) -TMP=$(mktemp -d) -wget -qO "$${TMP}/runner.tar.gz" \ - "https://api.github.com/repos/stackguardian/sg-runner/tarball/${sg_runner_version}" -tar -xf "$${TMP}/runner.tar.gz" -C "$${TMP}" -cp -rf "$${TMP}"/StackGuardian-sg-runner*/main.sh /usr/bin/sg-runner -chmod +x /usr/bin/sg-runner -rm -rf "$${TMP}" -echo ">> sg-runner installed: $(which sg-runner)" - -# 6. Wait for Docker (same guard as the original register_runner.sh.tpl) -timeout="${startup_timeout}" -counter=0 -until systemctl is-active --quiet docker; do - echo ">> Docker not ready.. trying again in 1 second." - sleep 1 - counter=$((counter + 1)) - if [ $counter -ge $timeout ]; then - echo ">> ERROR: Docker failed to start after $${timeout} seconds." - exit 1 - fi -done -echo ">> Docker ready: $(docker --version)" - -# 7. Register the 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}" - -echo ">> StackGuardian Private Runner registration complete." diff --git a/stackguardian_private_runner/examples/azure/standalone-vm/troubleshooting.md b/stackguardian_private_runner/examples/azure/standalone-vm/troubleshooting.md deleted file mode 100644 index 91f2d91..0000000 --- a/stackguardian_private_runner/examples/azure/standalone-vm/troubleshooting.md +++ /dev/null @@ -1,130 +0,0 @@ -# Private Runner Troubleshooting — ECS agent terminally exits after successful registration - -How to approach a customer's private runner that **registers successfully but then fails to run -workflows**, where the ECS agent logs end in a terminal exit. - -## Symptom - -Runner registers fine, but workflows never pick up. The `ecs-agent` container is not running, and -its log ends with: - -``` -level=info msg="Restored from checkpoint file" containerInstanceARN="arn:aws:ecs:::container-instance//" -level=info msg="Cluster was successfully restored" cluster="" -level=error msg="Unable to register as a container instance with ECS" error="... RegisterContainerInstance ... StatusCode: 400 ... ClientException: Referenced container instance not registered." -level=critical msg="Agent will terminally exit, unable to register container instance" -``` - -## Root cause - -The StackGuardian private runner runs the **Amazon ECS agent in external mode** -(`ECS_EXTERNAL=true`) to execute workflow tasks. The agent **persists its registration state** to: - -- `/var/lib/ecs/data/agent.db` (boltdb; newer agents) — mounted into the container as - `ECS_DATADIR=/data/` -- (older agents used `/var/lib/ecs/data/ecs_agent_data.json`) - -On startup the agent reads that state, finds the **container-instance ARN from a previous -registration**, and tries to **re-register that same ARN**. If that instance was deregistered on -the ECS / StackGuardian side, ECS returns `400 ... not registered` and the agent **terminally -exits**. Registration "succeeding" earlier doesn't help — the agent can't come back up, so no tasks -run. - -This is **state-dependent, not version-dependent.** OS / Docker / installation-script versions are -irrelevant — a matching spec box with clean state works fine. - -### When the state goes stale - -The local `agent.db` outlives the server-side container instance when, between registrations: - -- the runner group is deleted/recreated, -- the node token is rotated, -- the instance is pruned for being offline, -- or `register` is re-run **without** local cleanup, - -…and then the host **reboots or the `ecs` service / `ecs-agent` container restarts**. - -## Noise to ignore in the log - -These are normal and are **not** the failure: - -- `Unable to fetch user data: blackholed`, `Not able to get EC2 Instance ID from IMDS`, - `Unable to get Availability Zone` — expected on an **external** (non-EC2) instance; it uses - `/rotatingcreds` instead of IMDS. -- The wall of `Docker client version 1.17 … 1.39 is too old … Minimum supported API version is -1.40`, followed by `Setting minimum docker API version newMinAPIVersion=1.40` — expected with - Docker 28/29.x. The agent negotiates and continues. A **healthy** runner logs the same lines. - -The only line that matters is the `critical … terminally exit` on `RegisterContainerInstance`. - -## Diagnose (read-only first — confirm before changing anything) - -```bash -# 1. Is the agent actually down, and what does it say? -sudo docker ps -a --filter name=ecs-agent --format '{{.Names}}\t{{.Status}}' -sudo docker logs --tail=60 ecs-agent - -# 2. Does the persisted state hold a stale ARN matching the one in the 400 error? -sudo strings /var/lib/ecs/data/agent.db 2>/dev/null \ - | grep -o 'container-instance/[^"]*' | sort -u -# (older agents:) -sudo cat /var/lib/ecs/data/ecs_agent_data.json 2>/dev/null \ - | jq '{Cluster: .Data.Cluster, ContainerInstanceArn: .Data.ContainerInstanceArn}' - -# 3. Confirm cluster/external config -sudo grep -E 'ECS_CLUSTER|ECS_EXTERNAL|ECS_DATADIR' /etc/ecs/ecs.config -``` - -If the ARN from step 2 equals the `` in the `400 ... not registered` error, it is -conclusively the stale-state issue. - -## Fix - -**Preferred — supported deregister/reregister cycle.** `deregister -f/--force` runs the script's -`clean_local_setup`, which removes `/var/lib/ecs`, `/etc/ecs`, cached creds, the SSM managed -instance dir, etc., so the next `register` comes up as a fresh instance: - -```bash -sudo sg-runner deregister -f \ - --organization "" --runner-group "" --sg-node-token "" - -sudo sg-runner register \ - --organization "" --runner-group "" --sg-node-token "" -``` - -**Minimal fallback** — just clear the stale checkpoint and let the agent register anew: - -```bash -sudo docker stop ecs-agent -sudo rm -f /var/lib/ecs/data/agent.db # older: /var/lib/ecs/data/ecs_agent_data.json -sudo systemctl restart ecs # or: sudo docker start ecs-agent -sudo docker logs -f ecs-agent # expect a NEW registration, no 400 -``` - -### Verify recovery - -```bash -sudo docker ps --filter name=ecs-agent --format '{{.Names}}\t{{.Status}}' # Up (healthy) -sudo docker inspect ecs-agent --format '{{.State.Health.Status}}' # healthy -``` - -Then trigger a workflow against the runner group and confirm it picks up. - -## Prevention - -- Always use `sg-runner deregister -f` (local cleanup) **before** re-registering a host or before - deleting/recreating its runner group. Re-registering over stale state is what plants the bug. -- After any server-side removal of an instance/runner group, treat the host as needing a clean - re-register, not just a reboot. - -## Reproducing it deliberately (for a captured repro) - -A spec-matched box alone will not reproduce it. To force it: - -1. Register a runner normally; confirm `ecs-agent` is `Up (healthy)`. -2. Deregister **that container instance** on the StackGuardian/ECS side (or delete & recreate the - runner group) **without** running local cleanup on the host. -3. `sudo systemctl restart ecs` (or reboot the host). - -The agent restores the now-dead ARN from `agent.db`, re-registration returns `400 ... not -registered`, and it terminally exits — identical to the customer log. diff --git a/stackguardian_private_runner/examples/azure/standalone-vm/variables.tf b/stackguardian_private_runner/examples/azure/standalone-vm/variables.tf deleted file mode 100644 index 5cb0bfb..0000000 --- a/stackguardian_private_runner/examples/azure/standalone-vm/variables.tf +++ /dev/null @@ -1,154 +0,0 @@ -/*-----------------------------------+ - | StackGuardian Platform 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_' or 'sgo_'." - } -} - -/*-------------------------------------------+ - | StackGuardian Runner Group Configuration | - +-------------------------------------------*/ -variable "runner_storage_resource_group_name" { - description = "The Azure Resource Group where the runner_group storage account is created" - type = string -} - -variable "azure_storage" { - description = "Azure Storage Account configuration for the runner group storage backend" - type = object({ - account_tier = optional(string, "Standard") - account_replication_type = optional(string, "LRS") - }) - default = {} -} - -variable "max_runners" { - description = "Maximum number of runners for the runner group" - type = number - default = 3 -} - -/*-------------------+ - | Azure Variables | - +-------------------*/ -variable "azure_location" { - description = "The Azure region where resources will be deployed" - type = string - default = "westeurope" -} - -variable "compute_resource_group_name" { - description = "The Azure Resource Group where the runner VM and network are created (must already exist)" - type = string -} - -/*--------------------------+ - | VM Image Variables | - +--------------------------*/ -variable "rhel_image" { - description = < --publisher RedHat --offer RHEL --all -o table - EOT - type = object({ - publisher = optional(string, "RedHat") - offer = optional(string, "RHEL") - sku = optional(string, "9_8") - version = optional(string, "latest") - }) - default = {} -} - -variable "docker_version" { - description = "Docker engine version to pin (matches the production host, e.g. 29.5.2)" - type = string - default = "29.5.2" -} - -variable "sg_runner_version" { - description = "sg-runner release tag to pin (the 'Installationsscript' version, e.g. v2.2.1)" - type = string - default = "v2.2.1" -} - -/*--------------------------+ - | VM Variables | - +--------------------------*/ -variable "vm_size" { - description = "The Azure VM size for the runner VM" - type = string - default = "Standard_D4s_v3" -} - -variable "admin_username" { - description = "Admin username for the runner VM" - type = string - default = "azureuser" -} - -variable "admin_ssh_public_key" { - description = "SSH public key for admin access to the runner VM" - type = string -} - -variable "vm_os_disk" { - description = "OS disk configuration for the runner VM" - type = object({ - caching = optional(string, "ReadWrite") - storage_account_type = optional(string, "Premium_LRS") - disk_size_gb = optional(number, 50) - }) - default = {} -} - -/*--------------------------+ - | Network Variables | - +--------------------------*/ -variable "network" { - description = < Date: Mon, 24 Aug 2026 23:39:23 +0200 Subject: [PATCH 27/37] SG-3995: Split runner group into per-cloud modules. Provider requirements are static: there is no conditional required_providers, and count = 0 still installs and configures the provider. So the combined runner_group forced every caller to init and configure both clouds - an AWS user needed azurerm credentials to create an S3 bucket. That is what the skip_credentials_validation flags in its provider block were working around. The cloud resources move into aws/runner_group and azure/runner_group. What is left in runner_group is the platform side - runner group, connector, registration token - which needs no cloud provider at all. It takes a discriminated storage_backend object; both connector variants only ever consumed strings, so nothing cloud-specific crosses the seam. tofu providers now shows no azurerm/azuread under aws/runner_group and no aws under azure/runner_group. The child module also drops its provider block. A module that configures its own providers is a legacy module and cannot take count, for_each or depends_on - the azure quickstart already carries a comment about not being able to depend_on a role assignment for that reason. No moved blocks: nothing is deployed from this tree. Along the way: - azure/runner_group exposes azure_storage_account_id, so the quickstart no longer needs a data lookup to scope its role assignment. - override_names.connector_name now names the azure connector too. It always did in code; only the docs claimed it was AWS-only. - the aws quickstart pinned aws to registry.terraform.io/hashicorp/aws while the modules use hashicorp/aws. Under OpenTofu those resolve to two different providers and both were being locked. --- .../aws/runner_group/DOCUMENTATION.md | 109 +++++ .../aws/runner_group/README.md | 126 +++++ .../aws/runner_group/locals.tf | 64 +++ .../aws/runner_group/outputs.tf | 82 ++++ .../aws/runner_group/provider.tf | 33 ++ .../aws/runner_group/runner_group.tf | 26 + .../runner_group/schemas/input_schema.json | 142 ++++++ .../aws/runner_group/schemas/ui_schema.json | 70 +++ .../{ => aws}/runner_group/storage_backend.tf | 14 +- .../runner_group/storage_backend_role.tf | 39 +- .../aws/runner_group/variables.tf | 97 ++++ .../azure/runner_group/DOCUMENTATION.md | 143 ++++++ .../azure/runner_group/README.md | 157 ++++++ .../azure/runner_group/connector_identity.tf | 31 ++ .../azure/runner_group/locals.tf | 85 ++++ .../azure/runner_group/outputs.tf | 88 ++++ .../azure/runner_group/provider.tf | 39 ++ .../azure/runner_group/runner_group.tf | 27 ++ .../runner_group/schemas/input_schema.json | 206 ++++++++ .../runner_group/schemas/ui_schema.json | 62 +-- .../azure/runner_group/storage_backend.tf | 75 +++ .../azure/runner_group/variables.tf | 153 ++++++ .../examples/aws/quickstart/main.tf | 4 +- .../examples/azure/quickstart/README.md | 2 +- .../examples/azure/quickstart/main.tf | 12 +- .../runner_group/DOCUMENTATION.md | 111 ----- .../runner_group/README.md | 452 +++--------------- .../runner_group/connector.tf | 30 +- .../runner_group/locals.tf | 133 +----- .../runner_group/outputs.tf | 100 +--- .../runner_group/provider.tf | 39 +- .../runner_group/runner_group.tf | 10 +- .../runner_group/schemas/input_schema.json | 292 ----------- .../runner_group/storage_backend_azure.tf | 109 ----- .../runner_group/variables.tf | 210 +++----- 35 files changed, 1960 insertions(+), 1412 deletions(-) create mode 100644 stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md create mode 100644 stackguardian_private_runner/aws/runner_group/README.md create mode 100644 stackguardian_private_runner/aws/runner_group/locals.tf create mode 100644 stackguardian_private_runner/aws/runner_group/outputs.tf create mode 100644 stackguardian_private_runner/aws/runner_group/provider.tf create mode 100644 stackguardian_private_runner/aws/runner_group/runner_group.tf create mode 100644 stackguardian_private_runner/aws/runner_group/schemas/input_schema.json create mode 100644 stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json rename stackguardian_private_runner/{ => aws}/runner_group/storage_backend.tf (64%) rename stackguardian_private_runner/{ => aws}/runner_group/storage_backend_role.tf (63%) create mode 100644 stackguardian_private_runner/aws/runner_group/variables.tf create mode 100644 stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md create mode 100644 stackguardian_private_runner/azure/runner_group/README.md create mode 100644 stackguardian_private_runner/azure/runner_group/connector_identity.tf create mode 100644 stackguardian_private_runner/azure/runner_group/locals.tf create mode 100644 stackguardian_private_runner/azure/runner_group/outputs.tf create mode 100644 stackguardian_private_runner/azure/runner_group/provider.tf create mode 100644 stackguardian_private_runner/azure/runner_group/runner_group.tf create mode 100644 stackguardian_private_runner/azure/runner_group/schemas/input_schema.json rename stackguardian_private_runner/{ => azure}/runner_group/schemas/ui_schema.json (64%) create mode 100644 stackguardian_private_runner/azure/runner_group/storage_backend.tf create mode 100644 stackguardian_private_runner/azure/runner_group/variables.tf delete mode 100644 stackguardian_private_runner/runner_group/DOCUMENTATION.md delete mode 100644 stackguardian_private_runner/runner_group/schemas/input_schema.json delete mode 100644 stackguardian_private_runner/runner_group/storage_backend_azure.tf diff --git a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md new file mode 100644 index 0000000..f72fa08 --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md @@ -0,0 +1,109 @@ +# StackGuardian Runner Group - AWS Template + +Deploy a StackGuardian Runner Group with an S3 storage backend directly from the +StackGuardian platform. + +## Overview + +This template provisions everything required to run private runners on AWS: a runner +group on the StackGuardian platform, a private S3 bucket for workflow artifacts, and a +cross-account IAM role that lets StackGuardian and your runners reach it. Default tags +("StackGuardian Private Runner", the runner group name, and the organization name) are +applied automatically to the StackGuardian resources. + +Deploying on Azure instead? Use the **StackGuardian Runner Group - Azure** template. + +### What This Template Creates + +- **Runner Group** — A dedicated group on the StackGuardian platform to organize your + private runners. +- **S3 Storage Bucket** — Private bucket for workflow outputs and artifacts (or point at + an existing bucket). +- **IAM Access Role** — Cross-account role with an external ID for secure platform access. +- **AWS Connector** — `AWS_RBAC` integration between StackGuardian and your AWS account. + +## Prerequisites + +- A StackGuardian API key for your organization. +- AWS account credentials in your StackGuardian workspace with permissions to create S3 + buckets and IAM roles. + +## Template Parameters + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| API Key | Your organization's API key on the StackGuardian Platform (`sgu_*`/`sgo_*`) or a secret reference (`${secret::SECRET_NAME}`) | Password | + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| API Region | Your StackGuardian platform region (EU1 / US1 / DASH) | EU1 - Europe | +| Organization Name | Your organization name (auto-detected from environment if omitted) | Auto-detected | +| AWS Region | The target AWS Region for the S3 bucket and IAM resources | eu-central-1 | +| Create Storage Backend | Whether to create a new S3 bucket | Enabled | +| Existing S3 Bucket Name | Name of an existing S3 bucket to use (when not creating new) | — | +| Force Destroy Storage Backend | Delete all data in the S3 bucket on destroy (use with caution) | Disabled | +| Global Prefix | Prefix used for naming all resources | SG_RUNNER | +| Include Organization Name in Prefix | Append the org name to the prefix (e.g. `SG_RUNNER_demo-org`) | 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**: The API key is stored securely and used only to authenticate with +the StackGuardian platform. It must be `sgu_*` (user key), `sgo_*` (organization key), or +a `${secret::SECRET_NAME}` reference. + +**Storage Backend Options**: You can either create a new bucket (recommended) or point to +an existing one. When using an existing bucket, ensure it has the appropriate permissions +and CORS configuration. + +**Resource Naming**: By default, resources use the pattern +`SG_RUNNER-{type}-{account_id}`. Customize via the naming options if you need stable, +project-specific names. + +**Data Retention**: **Force Destroy Storage Backend** deletes all bucket contents on +destroy. Leave it disabled to protect your data. + +## Outputs + +| Output | Description | +|--------|-------------| +| Runner Group Name | Name of the created runner group, used in workflow configurations | +| Runner Group Token | Authentication token for registering runners (sensitive) | +| Runner Group URL | Direct link to manage the runner group in the StackGuardian console | +| Connector Name | Name of the AWS connector integration | +| Connector External ID | External ID enforced by the IAM role's trust policy | +| S3 Bucket Name | Name of the storage bucket | +| S3 Bucket ARN | ARN of the storage bucket | +| Storage Backend Role ARN | IAM role ARN required by AWS runner instances | +| Storage Backend Role Name | IAM role name for the storage backend | +| Runner Group ID | Identifier of the runner group (same value as the name) | +| Connector ID | Identifier of the AWS connector (same value as the name) | +| SG Org Name | Resolved StackGuardian organization name | +| SG API URI | Resolved StackGuardian API endpoint | + +## Security Features + +- **Private storage** — The S3 bucket has public access fully blocked. +- **Scoped access** — The IAM policy grants only the S3 actions runners need, on the one + bucket. +- **Cross-account role with external ID** — A leaked role ARN alone cannot be assumed. +- **CORS protection** — The bucket accepts browser requests only from the StackGuardian + platform origin. +- **Sensitive output protection** — The runner registration token is marked sensitive. + +## Usage + +After deploying this template, use the outputs to: + +1. **Deploy Runners** — Pass `runner_group_name`, `runner_group_token`, `s3_bucket_name`, + and `storage_backend_role_arn` to the AWS Autoscaled Runner / AWS Runner template. +2. **Configure Workflows** — Reference the runner group in your workflow configurations to + execute jobs on private runners. +3. **Monitor Runners** — Open the runner group URL to view runner status and manage the + group. diff --git a/stackguardian_private_runner/aws/runner_group/README.md b/stackguardian_private_runner/aws/runner_group/README.md new file mode 100644 index 0000000..f45b854 --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/README.md @@ -0,0 +1,126 @@ +# StackGuardian Runner Group - AWS + +> 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 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` | +| `override_names.runner_group_name` | Override the runner group name | (auto-generated) | +| `override_names.connector_name` | Override the connector name | (auto-generated) | +| `max_runners` | Maximum runners allowed in the group | `3` | + +### Naming + +With defaults, resources are named from `{effective_prefix}` (the `global_prefix`, +optionally suffixed with the org name) and the AWS account ID: + +- Runner group: `{effective_prefix}-runner-group-{account_id}` +- Connector: `{effective_prefix}-private-runner-backend-{account_id}` +- IAM role: `{effective_prefix}-private-runner-s3-role` +- S3 bucket: `{8-char-random}-private-runner-storage-backend` + +## 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..3c5be72 --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/locals.tf @@ -0,0 +1,64 @@ +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}" + ) + + # 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..a4c524b --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/runner_group.tf @@ -0,0 +1,26 @@ +# 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. + +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 + + 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/aws/runner_group/schemas/input_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json new file mode 100644 index 0000000..aee9df8 --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json @@ -0,0 +1,142 @@ +{ + "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"] + }, + "aws_region": { + "title": "AWS Region", + "type": "string", + "enum": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "af-south-1", + "ap-east-1", + "ap-south-1", + "ap-south-2", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ca-central-1", + "ca-west-1", + "eu-central-1", + "eu-central-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-south-1", + "eu-south-2", + "eu-north-1", + "il-central-1", + "me-south-1", + "me-central-1", + "sa-east-1" + ], + "default": "eu-central-1" + }, + "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" + }, + "include_org_in_prefix": { + "title": "Include Organization Name in Prefix", + "type": "boolean", + "default": false + }, + "runner_group_name": { + "title": "Runner Group Name Override", + "type": "string", + "default": "" + }, + "connector_name": { + "title": "Connector Name Override", + "type": "string", + "default": "" + } + }, + "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] }, + "force_destroy_storage_backend": { + "title": "Force Destroy Storage Backend", + "type": "boolean", + "default": false + } + } + }, + { + "properties": { + "create_storage_backend": { "enum": [false] }, + "existing_s3_bucket_name": { + "title": "Existing S3 Bucket Name", + "type": "string", + "minLength": 1 + } + }, + "required": ["existing_s3_bucket_name"] + } + ] + } + }, + "required": ["stackguardian"] +} diff --git a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json new file mode 100644 index 0000000..25ce8bb --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json @@ -0,0 +1,70 @@ +{ + "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", + "create_storage_backend", + "existing_s3_bucket_name", + "force_destroy_storage_backend", + "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." + } + }, + "aws_region": { + "ui:widget": "select", + "ui:description": "The target AWS Region for S3 bucket and IAM resources" + }, + "create_storage_backend": { + "ui:widget": "checkbox", + "ui:description": "Whether to create a new S3 bucket as the storage backend" + }, + "existing_s3_bucket_name": { + "ui:placeholder": "my-existing-bucket", + "ui:description": "Name of an existing S3 bucket to use as storage backend" + }, + "force_destroy_storage_backend": { + "ui:widget": "checkbox", + "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"], + "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)" + }, + "runner_group_name": { + "ui:placeholder": "(auto-generated)", + "ui:description": "Override the runner group name. If empty, uses {effective_prefix}-runner-group-{account_id}" + }, + "connector_name": { + "ui:placeholder": "(auto-generated)", + "ui:description": "Override the connector name. If empty, uses {effective_prefix}-private-runner-backend-{account_id}" + } + }, + "max_runners": { + "ui:description": "Maximum number of runners allowed in the runner group" + } +} diff --git a/stackguardian_private_runner/runner_group/storage_backend.tf b/stackguardian_private_runner/aws/runner_group/storage_backend.tf similarity index 64% rename from stackguardian_private_runner/runner_group/storage_backend.tf rename to stackguardian_private_runner/aws/runner_group/storage_backend.tf index 0b8b4b5..a2974ce 100644 --- a/stackguardian_private_runner/runner_group/storage_backend.tf +++ b/stackguardian_private_runner/aws/runner_group/storage_backend.tf @@ -1,7 +1,7 @@ -# S3 Bucket for Storage Backend (AWS 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 = local.is_aws && var.create_storage_backend ? 1 : 0 + count = var.create_storage_backend ? 1 : 0 length = 8 special = false @@ -9,14 +9,14 @@ resource "random_string" "storage_backend_prefix" { } resource "aws_s3_bucket" "this" { - count = local.is_aws && var.create_storage_backend ? 1 : 0 + count = var.create_storage_backend ? 1 : 0 bucket = "${random_string.storage_backend_prefix[0].result}-private-runner-storage-backend" force_destroy = var.force_destroy_storage_backend } resource "aws_s3_bucket_public_access_block" "this" { - count = local.is_aws && var.create_storage_backend ? 1 : 0 + count = var.create_storage_backend ? 1 : 0 bucket = aws_s3_bucket.this[0].id @@ -27,7 +27,7 @@ resource "aws_s3_bucket_public_access_block" "this" { } resource "aws_s3_bucket_cors_configuration" "this" { - count = local.is_aws && var.create_storage_backend ? 1 : 0 + count = var.create_storage_backend ? 1 : 0 bucket = aws_s3_bucket.this[0].id @@ -41,9 +41,9 @@ resource "aws_s3_bucket_cors_configuration" "this" { } } -# Data source for existing S3 bucket (when using existing bucket, AWS only) +# Data source for existing S3 bucket (when using an existing bucket) data "aws_s3_bucket" "existing" { - count = local.is_aws && !var.create_storage_backend ? 1 : 0 + count = var.create_storage_backend ? 0 : 1 bucket = var.existing_s3_bucket_name } 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 63% rename from stackguardian_private_runner/runner_group/storage_backend_role.tf rename to stackguardian_private_runner/aws/runner_group/storage_backend_role.tf index 01f2015..96e776e 100644 --- a/stackguardian_private_runner/runner_group/storage_backend_role.tf +++ b/stackguardian_private_runner/aws/runner_group/storage_backend_role.tf @@ -1,16 +1,12 @@ -# IAM Role and Policy for Storage Backend Access (AWS only) +# IAM Role and Policy for Storage Backend Access resource "random_string" "connector_external_id" { - count = local.is_aws ? 1 : 0 - length = 24 special = false } # This IAM role is used by the StackGuardian platform and runners to access the S3 bucket resource "aws_iam_role" "storage_backend" { - count = local.is_aws ? 1 : 0 - name = "${local.effective_prefix}-private-runner-s3-role" assume_role_policy = jsonencode({ @@ -22,13 +18,13 @@ resource "aws_iam_role" "storage_backend" { AWS = [ "arn:aws:iam::163602625436:root", "arn:aws:iam::476299211833:root", - "arn:aws:iam::${data.aws_caller_identity.current[0].account_id}:root" + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" ] } Action = "sts:AssumeRole" Condition = { StringEquals = { - "sts:ExternalId" = "${local.sg_org_name}:${random_string.connector_external_id[0].result}" + "sts:ExternalId" = local.connector_external_id } } } @@ -38,8 +34,6 @@ 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" { - count = local.is_aws ? 1 : 0 - name = "${local.effective_prefix}-runner-s3-policy" description = "Policy for access to the Storage Backend S3 Bucket" @@ -72,29 +66,6 @@ resource "aws_iam_policy" "storage_backend_access" { } resource "aws_iam_role_policy_attachment" "storage_backend" { - count = local.is_aws ? 1 : 0 - - role = aws_iam_role.storage_backend[0].name - policy_arn = aws_iam_policy.storage_backend_access[0].arn -} - -# State migration: moved blocks for backward compatibility -moved { - from = random_string.connector_external_id - to = random_string.connector_external_id[0] -} - -moved { - from = aws_iam_role.storage_backend - to = aws_iam_role.storage_backend[0] -} - -moved { - from = aws_iam_policy.storage_backend_access - to = aws_iam_policy.storage_backend_access[0] -} - -moved { - from = aws_iam_role_policy_attachment.storage_backend - to = aws_iam_role_policy_attachment.storage_backend[0] + role = aws_iam_role.storage_backend.name + policy_arn = aws_iam_policy.storage_backend_access.arn } 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..63dee87 --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/variables.tf @@ -0,0 +1,97 @@ +/*---------------------------+ + | 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/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md new file mode 100644 index 0000000..5f58734 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md @@ -0,0 +1,143 @@ +# StackGuardian Runner Group - Azure Template + +Deploy a StackGuardian Runner Group with an Azure Blob Storage backend directly from the +StackGuardian platform. + +## Overview + +This template provisions everything required to run private runners on Azure: a runner +group on the StackGuardian platform, a resource group and storage account for workflow +artifacts, and an Entra ID identity that lets StackGuardian reach them over OIDC — no +long-lived secret is stored on the platform. Default tags ("StackGuardian Private +Runner", the runner group name, and the organization name) are applied automatically to +the StackGuardian resources. + +Deploying on AWS instead? Use the **StackGuardian Runner Group - AWS** template. + +### What This Template Creates + +- **Runner Group** — A dedicated group on the StackGuardian platform to organize your + private runners. +- **Azure Resource Group** — A new resource group hosting the storage account and acting + as the canonical RG for downstream Azure templates (or use an existing one). Exported as + `azure_resource_group_name`. +- **Azure Storage Account + private "runner" container** — Storage for workflow outputs + and artifacts (or point at an existing storage account). +- **Entra ID application + service principal** — Identity for the OIDC connector, granted + `Storage Blob Data Reader` on the storage account. +- **Azure Connector** — `AZURE_OIDC` integration between StackGuardian and your + subscription. + +## Prerequisites + +- A StackGuardian API key for your organization. +- Azure account credentials in your StackGuardian workspace with permissions to create + Resource Groups, Storage Accounts, Entra ID applications, service principals, and role + assignments. By default the template creates a new Resource Group; disable **Create + Azure Resource Group** to deploy into an existing one. + +## Template Parameters + +### Required Parameters + +| Parameter | Description | Type | +|-----------|-------------|------| +| API Key | Your organization's API key on the StackGuardian Platform (`sgu_*`/`sgo_*`) or a secret reference (`${secret::SECRET_NAME}`) | Password | + +When **Create Azure Resource Group** is disabled, **Azure Resource Group Name** becomes +required. + +### Optional Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| API Region | Your StackGuardian platform region (EU1 / US1 / DASH) | EU1 - Europe | +| Organization Name | Your organization name (auto-detected from environment if omitted) | Auto-detected | +| Azure Region | The Azure region where storage resources will be deployed | westeurope | +| Create Azure Resource Group | Create a new Azure Resource Group for the storage account | Enabled | +| Azure Resource Group Name | Resource Group name. Optional override when creating; required when using an existing RG | — | +| Create Blob Reader Role Assignment | Grant the OIDC connector SP `Storage Blob Data Reader` on the storage account. Disable when the deploying identity lacks role-assignment write permission | Enabled | +| Create Storage Backend | Whether to create a new Azure Storage Account | Enabled | +| Azure Storage — Account Tier | Performance tier of the Storage Account (Standard / Premium) | Standard | +| Azure Storage — Replication Type | Replication strategy (LRS / GRS / RAGRS / ZRS) | LRS | +| Existing Azure Storage Account Name | Name of an existing Storage Account to use (when not creating new) | — | +| Existing Azure Storage Account Access Key | Access key for that account (sensitive) | — | +| Global Prefix | Prefix used for naming all resources | SG_RUNNER | +| Include Organization Name in Prefix | Append the org name to the prefix (e.g. `SG_RUNNER_demo-org`) | Disabled | +| Runner Group Name Override | Custom name for the runner group | Auto-generated | +| Connector Name Override | Custom name for the Azure connector | Auto-generated | +| Maximum Runners | Maximum number of runners allowed in the group | 3 | + +## Important Notes + +**Azure Resource Group**: By default the template **creates a new Resource Group** and +exports its name as `azure_resource_group_name` for downstream Azure templates to consume. +Disable **Create Azure Resource Group** if you prefer to deploy into an existing one. + +**Role Assignments**: Creating the `Storage Blob Data Reader` assignment requires +`Microsoft.Authorization/roleAssignments/write` (e.g. `Owner` or `User Access +Administrator`). If the deploying identity only has `Contributor`, disable **Create Blob +Reader Role Assignment** and create the assignment out of band using the +**Azure Connector Service Principal Object ID** output — runners cannot read from the +storage account until it exists. + +**API Key Security**: The API key is stored securely and used only to authenticate with +the StackGuardian platform. It must be `sgu_*` (user key), `sgo_*` (organization key), or +a `${secret::SECRET_NAME}` reference. + +**Storage Backend Options**: You can either create a new storage account (recommended) or +point to an existing one. When using an existing account, ensure it has the appropriate +permissions and CORS configuration, and a private container named `runner`. + +**Resource Naming**: By default, resources use the pattern +`SG_RUNNER-{type}-{subscription_id}`. Azure naming rules force some sanitization — the +prefix is lowercased and underscores become dashes, and the storage account name is +truncated to fit the 24-character global limit. + +**Data Retention**: The Azure Storage Account is destroyed along with its contents on +`terraform destroy` — back up anything you need first. + +## Outputs + +| Output | Description | +|--------|-------------| +| Runner Group Name | Name of the created runner group, used in workflow configurations | +| Runner Group Token | Authentication token for registering runners (sensitive) | +| Runner Group URL | Direct link to manage the runner group in the StackGuardian console | +| Connector Name | Name of the Azure connector integration | +| Azure Connector Service Principal Object ID | Object ID of the OIDC connector SP — use to create the role assignment out of band when disabled | +| Azure Resource Group Name | Name of the Resource Group — feed into downstream Azure templates | +| Azure Resource Group Location | Location of the Resource Group | +| Azure Storage Account Name | Name of the Storage Account | +| Azure Storage Account ID | Resource ID of the Storage Account — scope role assignments to it | +| Azure Storage Access Key | Access key for the Storage Account (sensitive) | +| Runner Group ID | Identifier of the runner group (same value as the name) | +| Connector ID | Identifier of the Azure connector (same value as the name) | +| Azure Location | Resolved Azure region | +| SG Org Name | Resolved StackGuardian organization name | +| SG API URI | Resolved StackGuardian API endpoint | + +## Security Features + +- **Private storage** — The Storage Account disables nested public items and enforces TLS + 1.2 minimum; the `runner` container is private. +- **Federated identity** — The connector uses OIDC federation, so no long-lived secret is + stored on the platform. +- **Scoped access** — The connector service principal is granted only `Storage Blob Data + Reader`, on the one storage account. +- **CORS protection** — The storage account accepts browser requests only from the + StackGuardian platform origin. +- **Sensitive output protection** — Runner registration tokens and storage access keys are + marked sensitive. + +## Usage + +After deploying this template, use the outputs to: + +1. **Deploy Runners** — Pass `runner_group_name`, `runner_group_token`, + `azure_resource_group_name`, `azure_storage_account_name`, and + `azure_storage_access_key` to the Azure Runner / Azure VMSS Autoscaled Runner template. +2. **Configure Workflows** — Reference the runner group in your workflow configurations to + execute jobs on private runners. +3. **Monitor Runners** — Open the runner group URL to view runner status and manage the + group. diff --git a/stackguardian_private_runner/azure/runner_group/README.md b/stackguardian_private_runner/azure/runner_group/README.md new file mode 100644 index 0000000..8bca621 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/README.md @@ -0,0 +1,157 @@ +# StackGuardian Runner Group - Azure + +> 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 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` | +| `override_names.runner_group_name` | Override the runner group name | (auto-generated) | +| `override_names.connector_name` | Override the connector name | (auto-generated) | +| `max_runners` | Maximum runners allowed in the group | `3` | + +### Naming + +With defaults, resources are named from `{effective_prefix}` (the `global_prefix`, +optionally suffixed with the org name) and the subscription ID. Azure resource naming +rules force some sanitization — the prefix is lowercased and underscores become dashes: + +- Runner group: `{effective_prefix}-runner-group-{subscription_id}` +- Connector: `{effective_prefix}-private-runner-backend-{subscription_id}` +- 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: `{effective_prefix}-sg-connector` + +## 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..1e464c3 --- /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 = "${local.effective_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 = "${local.effective_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..5baba41 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/locals.tf @@ -0,0 +1,85 @@ +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 + + # 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-${local.subscription_id}" + ) + + connector_name = ( + var.override_names.connector_name != "" + ? var.override_names.connector_name + : "${local.effective_prefix}-private-runner-backend-${local.subscription_id}" + ) + + # Azure storage locals — derive from effective_prefix so org name flows into resource names + sanitized_prefix = replace(lower(local.effective_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..2e4d136 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/runner_group.tf @@ -0,0 +1,27 @@ +# 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. + +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 + + 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..0e00cc8 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/schemas/input_schema.json @@ -0,0 +1,206 @@ +{ + "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" + }, + "include_org_in_prefix": { + "title": "Include Organization Name in Prefix", + "type": "boolean", + "default": false + }, + "runner_group_name": { + "title": "Runner Group Name Override", + "type": "string", + "default": "" + }, + "connector_name": { + "title": "Connector Name Override", + "type": "string", + "default": "" + } + }, + "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/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/azure/runner_group/schemas/ui_schema.json similarity index 64% rename from stackguardian_private_runner/runner_group/schemas/ui_schema.json rename to stackguardian_private_runner/azure/runner_group/schemas/ui_schema.json index 033ab85..151b473 100644 --- a/stackguardian_private_runner/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/azure/runner_group/schemas/ui_schema.json @@ -1,20 +1,16 @@ { - "ui:title": "StackGuardian Runner Group", - "ui:description": "Create a new StackGuardian Runner Group with cloud storage backend (AWS S3 or Azure Blob Storage). Default tags are automatically applied: 'StackGuardian Private Runner', runner group name, and organization name.", + "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", - "cloud_provider", - "aws_region", "azure_location", "create_azure_resource_group", "azure_resource_group_name", "create_blob_reader_role_assignment", "create_storage_backend", - "existing_s3_bucket_name", + "azure_storage", "existing_azure_storage_account_name", "existing_azure_storage_account_access_key", - "force_destroy_storage_backend", - "azure_storage", "override_names", "max_runners" ], @@ -35,53 +31,29 @@ "ui:description": "Your organization name. If not provided, will be extracted from environment." } }, - "cloud_provider": { - "ui:widget": "select", - "ui:description": "Select the cloud provider for the storage backend" - }, - "aws_region": { - "ui:widget": "select", - "ui:description": "The target AWS Region for S3 bucket and IAM resources" - }, "azure_location": { - "ui:placeholder": "westeurope", - "ui:description": "The Azure region where storage resources will be deployed" + "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 (Azure only). Disable to deploy into 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 (Azure only). Disable when the identity running Terraform lacks role-assignment write permission; you must then create the assignment out of band." + "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_storage_backend": { + "create_blob_reader_role_assignment": { "ui:widget": "checkbox", - "ui:description": "Whether to create a new storage backend (S3 bucket for AWS, Storage Account for Azure)" - }, - "existing_s3_bucket_name": { - "ui:placeholder": "my-existing-bucket", - "ui:description": "Name of an existing S3 bucket to use as storage backend (AWS only)" + "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." }, - "existing_azure_storage_account_name": { - "ui:placeholder": "myexistingstorageaccount", - "ui:description": "Name of an existing Azure Storage Account to use as storage backend (Azure only)" - }, - "existing_azure_storage_account_access_key": { - "ui:placeholder": "primary or secondary access key", - "ui:description": "Access key for the existing Azure Storage Account (Azure only)" - }, - "force_destroy_storage_backend": { + "create_storage_backend": { "ui:widget": "checkbox", - "ui:description": "Warning: Force destroy the S3 bucket on module destruction (deletes all data, AWS only)" + "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 (Azure only)", + "ui:description": "Configure Azure Storage Account settings", "ui:order": ["account_tier", "account_replication_type"], "account_tier": { "ui:widget": "select", @@ -92,6 +64,14 @@ "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)", @@ -106,11 +86,11 @@ }, "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": "Override the runner group name. If empty, uses {effective_prefix}-runner-group-{subscription_id}" }, "connector_name": { "ui:placeholder": "(auto-generated)", - "ui:description": "Override the connector name (AWS only). If empty, uses {effective_prefix}-private-runner-backend-{account_id}" + "ui:description": "Override the connector name. If empty, uses {effective_prefix}-private-runner-backend-{subscription_id}" } }, "max_runners": { 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..e8f95e5 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/variables.tf @@ -0,0 +1,153 @@ +/*---------------------------+ + | Storage Backend Options | + +---------------------------*/ +variable "create_storage_backend" { + description = <= 1 + error_message = "max_runners must be at least 1." + } +} diff --git a/stackguardian_private_runner/examples/aws/quickstart/main.tf b/stackguardian_private_runner/examples/aws/quickstart/main.tf index e96189e..2480297 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,7 +27,7 @@ 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 diff --git a/stackguardian_private_runner/examples/azure/quickstart/README.md b/stackguardian_private_runner/examples/azure/quickstart/README.md index 7bf3bb2..7858a62 100644 --- a/stackguardian_private_runner/examples/azure/quickstart/README.md +++ b/stackguardian_private_runner/examples/azure/quickstart/README.md @@ -288,7 +288,7 @@ packer_network = { ## The Storage Backend Identity The runner authenticates to the storage account with a **User-Assigned Managed -Identity**. The `runner_group` module does not create one - it registers an AAD +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 diff --git a/stackguardian_private_runner/examples/azure/quickstart/main.tf b/stackguardian_private_runner/examples/azure/quickstart/main.tf index 317d8d1..e6b0bcd 100644 --- a/stackguardian_private_runner/examples/azure/quickstart/main.tf +++ b/stackguardian_private_runner/examples/azure/quickstart/main.tf @@ -45,9 +45,8 @@ provider "azurerm" { # blob container, AAD app + OIDC connector # ------------------------------------------------------- module "runner_group" { - source = "../../../runner_group" + source = "../../../azure/runner_group" - cloud_provider = "azure" azure_location = var.azure_location stackguardian = var.stackguardian @@ -104,19 +103,12 @@ resource "azurerm_user_assigned_identity" "storage_backend" { tags = local.common_tags } -# Resolve the storage account created by the runner group; the module exposes its -# name but not the resource ID needed to scope the role assignment. -data "azurerm_storage_account" "backend" { - name = module.runner_group.azure_storage_account_name - resource_group_name = module.runner_group.azure_resource_group_name -} - # 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 = data.azurerm_storage_account.backend.id + 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 } diff --git a/stackguardian_private_runner/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/runner_group/DOCUMENTATION.md deleted file mode 100644 index 6eacefb..0000000 --- a/stackguardian_private_runner/runner_group/DOCUMENTATION.md +++ /dev/null @@ -1,111 +0,0 @@ -# StackGuardian Runner Group - AWS or Azure Template - -Deploy a StackGuardian Runner Group with a cloud storage backend (AWS S3 or Azure Blob Storage) directly from the StackGuardian platform. - -## Overview - -This template provisions everything required to run private runners against either AWS or Azure. It creates a runner group on the StackGuardian platform, sets up a private storage backend for workflow artifacts, and configures secure access between StackGuardian and the chosen cloud account. Default tags ("StackGuardian Private Runner", the runner group name, and the organization name) are applied automatically to the StackGuardian resources. - -### What This Template Creates - -**Always:** -- **Runner Group** — A dedicated group on the StackGuardian platform to organize your private runners. -- **Cloud Connector** — Secure integration between StackGuardian and your cloud account (AWS RBAC role for AWS; OIDC federation with an Azure AD application for Azure). - -**For AWS:** -- **S3 Storage Bucket** — Private bucket for workflow outputs and artifacts (or an existing bucket). -- **IAM Access Role** — Cross-account role with an external ID for secure platform access. - -**For Azure:** -- **Azure Resource Group** — A new resource group to host the storage account and act as the canonical RG for downstream Azure templates (or use an existing one). Exported as `azure_resource_group_name`. -- **Azure Storage Account + private "runner" container** — Storage for workflow outputs and artifacts (or an existing storage account). -- **Azure AD application + service principal** — Identity for the OIDC connector, granted `Storage Blob Data Reader` on the storage account. - -## Prerequisites - -- A StackGuardian API key for your organization. -- For AWS: AWS account credentials in your StackGuardian workspace with permissions to create S3 buckets and IAM roles. -- For Azure: Azure account credentials in your StackGuardian workspace with permissions to create Resource Groups, Storage Accounts, Azure AD applications, service principals, and role assignments. By default the template creates a new Resource Group; disable **Create Azure Resource Group** to deploy into an existing one. - -## Template Parameters - -### Required Parameters - -| Parameter | Description | Type | -|-----------|-------------|------| -| API Key | Your organization's API key on the StackGuardian Platform (`sgu_*`/`sgo_*`) or a secret reference (`${secret::SECRET_NAME}`) | Password | - -When **Cloud Provider** is set to **Azure**, the template creates a new Resource Group by default. Disable **Create Azure Resource Group** and provide **Azure Resource Group Name** to deploy into an existing one. - -### Optional Parameters - -| Parameter | Description | Default | -|-----------|-------------|---------| -| API Region | Your StackGuardian platform region (EU1 / US1 / DASH) | EU1 - Europe | -| Organization Name | Your organization name (auto-detected from environment if omitted) | Auto-detected | -| Cloud Provider | Cloud provider for the storage backend (AWS or Azure) | AWS | -| AWS Region | The target AWS Region for S3 bucket and IAM resources | eu-central-1 | -| Azure Region | The Azure region where storage resources will be deployed | westeurope | -| Create Azure Resource Group | Create a new Azure Resource Group for the storage account (Azure only) | Enabled | -| Create Blob Reader Role Assignment | Grant the OIDC connector SP `Storage Blob Data Reader` on the storage account (Azure only). Disable when the deploying identity lacks role-assignment write permission | Enabled | -| Azure Resource Group Name | Resource Group name. Optional override when creating; required when using an existing RG | — | -| Create Storage Backend | Whether to create a new storage backend (S3 bucket for AWS, Storage Account for Azure) | Enabled | -| Existing S3 Bucket Name | Name of an existing S3 bucket to use (AWS, when not creating new) | — | -| Existing Azure Storage Account Name | Name of an existing Azure Storage Account to use (Azure, when not creating new) | — | -| Existing Azure Storage Account Access Key | Access key for the existing Azure Storage Account (Azure, sensitive) | — | -| Force Destroy Storage Backend | Delete all data in the S3 bucket on destroy (AWS only, use with caution) | Disabled | -| Azure Storage — Account Tier | Performance tier of the Azure Storage Account (Standard / Premium) | Standard | -| Azure Storage — Replication Type | Replication strategy of the Azure Storage Account (LRS / GRS / RAGRS / ZRS) | LRS | -| Global Prefix | Prefix used for naming all resources | SG_RUNNER | -| Include Organization Name in Prefix | Append the org name to the prefix (e.g. `SG_RUNNER_demo-org`) | Disabled | -| Runner Group Name Override | Custom name for the runner group | Auto-generated | -| Connector Name Override | Custom name for the cloud connector | Auto-generated | -| Maximum Runners | Maximum number of runners allowed in the group | 3 | - -## Important Notes - -**Cloud Provider**: The Cloud Provider toggle drives every other Azure / AWS option. Switching it after deployment will recreate cloud resources, so choose carefully up front. - -**Azure Resource Group**: By default the template **creates a new Azure Resource Group** and exports its name as `azure_resource_group_name` for downstream Azure templates to consume. Disable **Create Azure Resource Group** if you prefer to deploy into an existing one. - -**API Key Security**: The API key is stored securely and used only to authenticate with the StackGuardian platform. It must be `sgu_*` (user key), `sgo_*` (organization key), or a `${secret::SECRET_NAME}` reference. - -**Storage Backend Options**: You can either create a new storage backend (recommended) or point to an existing one. When using an existing S3 bucket or Azure Storage Account, ensure it has the appropriate permissions and CORS configuration. - -**Resource Naming**: By default, resources use the pattern `SG_RUNNER-{type}-{account_or_subscription_id}`. Customize via the naming options if you need stable, project-specific names. - -**Data Retention**: **Force Destroy Storage Backend** (AWS only) deletes all bucket contents on destroy. Leave it disabled to protect your data. The Azure Storage Account is always destroyed on `terraform destroy` along with its contents — back up anything you need first. - -## Outputs - -| Output | Description | -|--------|-------------| -| Runner Group Name | Name of the created runner group, used in workflow configurations | -| Runner Group Token | Authentication token for registering runners (sensitive) | -| Runner Group URL | Direct link to manage the runner group in the StackGuardian console | -| Connector Name | Name of the AWS or Azure connector integration | -| S3 Bucket Name | Name of the storage bucket (AWS only) | -| Storage Backend Role ARN | IAM role ARN required by AWS runner instances (AWS only) | -| Azure Resource Group Name | Name of the Azure Resource Group (Azure only) — feed into downstream Azure templates | -| Azure Resource Group Location | Location of the Azure Resource Group (Azure only) | -| Azure Connector Service Principal Object ID | Object ID of the OIDC connector SP (Azure only) — use to create the role assignment out of band when disabled | -| Azure Storage Account Name | Name of the Azure Storage Account (Azure only) | -| Azure Storage Access Key | Access key for the Azure Storage Account (Azure only, sensitive) | - -## Security Features - -- **Private storage** — S3 bucket has public access blocked; Azure Storage Account disables nested public items and enforces TLS 1.2 minimum. -- **Scoped access** — AWS IAM policy grants only the S3 actions runners need; Azure service principal is granted only `Storage Blob Data Reader` on the storage account. -- **Cross-account / federated identity** — AWS uses a cross-account role with an external ID; Azure uses OIDC federation, so no long-lived secret is stored on the platform. -- **CORS protection** — Both backends accept requests only from the StackGuardian platform origin. -- **Sensitive output protection** — Runner registration tokens and Azure storage access keys are marked sensitive in module outputs. - -## Usage - -After deploying this template, use the outputs to: - -1. **Deploy Runners** — Pass the runner group name, token, and storage details to the matching runner template: - - **AWS**: `runner_group_name`, `runner_group_token`, `s3_bucket_name`, `storage_backend_role_arn` → AWS Autoscaled Runner / AWS Runner. - - **Azure**: `runner_group_name`, `runner_group_token`, `azure_storage_account_name`, `azure_storage_access_key` → Azure VMSS Autoscaled Runner. -2. **Configure Workflows** — Reference the runner group in your workflow configurations to execute jobs on private runners. -3. **Monitor Runners** — Open 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 fa27b94..06af5fc 100644 --- a/stackguardian_private_runner/runner_group/README.md +++ b/stackguardian_private_runner/runner_group/README.md @@ -1,398 +1,104 @@ -# StackGuardian Runner Group - AWS or Azure Module +# Runner Group (shared, cloud-agnostic) -This Terraform module provisions a StackGuardian Runner Group with a cloud storage backend (AWS S3 or Azure Blob Storage) and the corresponding StackGuardian connector for secure access from the StackGuardian platform. +> 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 creates everything required to host private runners against either AWS or Azure. A single `cloud_provider` toggle drives which storage backend, connector kind, and authentication path are provisioned. AWS deployments use a cross-account IAM role (RBAC connector); Azure deployments use OIDC federation with an auto-provisioned Azure AD application and service principal. +**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 Platform Resources (always):** -- **StackGuardian Runner Group** — Platform resource for organizing private runners, with `max_number_of_runners`, default tags, and the resolved storage backend configuration. -- **StackGuardian Connector** — Cloud-specific connector for storage access: - - **AWS**: `AWS_RBAC` connector using cross-account IAM role + external ID. - - **Azure**: `AZURE_OIDC` connector using federated identity from a SG-issued OIDC token. +## Why this module exists -**AWS-only resources (when `cloud_provider = "aws"`):** -- **S3 bucket** with public access block and CORS limited to the StackGuardian platform origin (created when `create_storage_backend = true`). -- **IAM role + policy** scoped to the bucket; trust policy allows StackGuardian AWS accounts (`163602625436`, `476299211833`) and the caller's account, gated by an external ID (`{org_name}:{24-char-random}`). +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. -**Azure-only resources (when `cloud_provider = "azure"`):** -- **Resource Group** to host the storage account and act as the canonical RG for downstream Azure modules (created when `create_azure_resource_group = true`, the default). 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 (created when `create_storage_backend = true`). -- **Azure AD application + service principal** for the OIDC connector. -- **Federated identity credential** issued by the StackGuardian API URI for the org subject `/orgs/{org_name}`. -- **`Storage Blob Data Reader` role assignment** scoped to the storage account (created when `create_blob_reader_role_assignment = true`, the default — requires the Terraform identity to have `Microsoft.Authorization/roleAssignments/write`, e.g. `Owner` or `User Access Administrator`). +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`. -## Prerequisites +## What it creates -- StackGuardian API key (`sgu_*` user key, `sgo_*` org key, or a `${secret::SECRET_NAME}` reference). -- Terraform >= 1.0 or OpenTofu >= 1.7. -- For AWS: AWS credentials with permissions to create S3 buckets and IAM roles. -- For Azure: Azure credentials (CLI / SP) with permissions to create Resource Groups, Storage Accounts, Azure AD applications, service principals, and role assignments. By default the module creates a new Resource Group; set `create_azure_resource_group = false` and pass `azure_resource_group_name` to deploy into an existing one. +- `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 -## Quick Start +Everything cloud-side — buckets, storage accounts, IAM roles, Entra ID apps — is created +by the caller and passed in as strings. -### Step 1: Configure Variables +## Interface -#### AWS Example — `terraform.tfvars` - -```hcl -cloud_provider = "aws" - -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" -``` - -#### Azure Example — `terraform.tfvars` - -```hcl -cloud_provider = "azure" - -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 new resource group named -# "{effective_prefix}-rg-{subscription_id}" (lowercased, dashes). -# azure_resource_group_name = "my-resource-group" -``` - -### Step 2: Deploy - -```bash -terraform init -terraform plan -terraform apply -``` - -### Basic Configuration Examples - -#### AWS +The caller resolves names and identities, then hands over a discriminated +`storage_backend` object: ```hcl module "runner_group" { - source = "./stackguardian_runner_group" - - cloud_provider = "aws" - - 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" } ``` -#### Azure - -```hcl -module "runner_group" { - source = "./stackguardian_runner_group" - - cloud_provider = "azure" - - stackguardian = { - api_key = "sgu_your_api_key" - } - - azure_location = "westeurope" -} -``` +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. -## Configuration +### Inputs -### Required Parameters +| 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 | -| Parameter | Description | Type | -|-----------|-------------|------| -| `stackguardian.api_key` | StackGuardian API key (must start with `sgu_` or `sgo_`) | `string` (sensitive) | - -When `cloud_provider = "azure"`, the module creates a new Azure Resource Group by default. Set `create_azure_resource_group = false` and provide `azure_resource_group_name` to deploy into an existing resource group instead. - -### Optional Parameters - -| Parameter | Description | Default | -|-----------|-------------|---------| -| `cloud_provider` | Cloud provider for the storage backend (`aws` or `azure`) | `aws` | -| `stackguardian.api_uri` | StackGuardian API endpoint (EU1 / US1 / DASH) | `https://api.app.stackguardian.io` | -| `stackguardian.org_name` | Organization name; falls back to `SG_ORG_ID` env var | `""` | -| `aws_region` | Target AWS region (used when `cloud_provider = "aws"`) | `eu-central-1` | -| `azure_location` | Azure region (used when `cloud_provider = "azure"`) | `westeurope` | -| `create_azure_resource_group` | Create a new Azure Resource Group for the storage account (Azure only) | `true` | -| `create_blob_reader_role_assignment` | Grant the OIDC connector SP `Storage Blob Data Reader` on the storage account (Azure only). Disable when the runner SP lacks role-assignment write permission | `true` | -| `azure_resource_group_name` | Resource Group name. Optional override when creating; required when using an existing RG | `""` | -| `create_storage_backend` | Create a new storage backend (S3 bucket / Storage Account) | `true` | -| `existing_s3_bucket_name` | Existing S3 bucket name (AWS, when `create_storage_backend = false`) | `""` | -| `existing_azure_storage_account_name` | Existing Azure Storage Account name (Azure, when `create_storage_backend = false`) | `""` | -| `existing_azure_storage_account_access_key` | Access key for the existing Azure Storage Account (sensitive) | `""` | -| `force_destroy_storage_backend` | Force destroy the S3 bucket on `terraform destroy` (AWS only) | `false` | -| `azure_storage.account_tier` | Storage Account performance tier (`Standard` / `Premium`) | `Standard` | -| `azure_storage.account_replication_type` | Replication strategy (`LRS` / `GRS` / `RAGRS` / `ZRS`) | `LRS` | -| `override_names.global_prefix` | Prefix for resource naming | `SG_RUNNER` | -| `override_names.include_org_in_prefix` | Append org name to the prefix (e.g. `SG_RUNNER_demo-org`) | `false` | -| `override_names.runner_group_name` | Override the runner group name | Auto-generated | -| `override_names.connector_name` | Override the connector name | Auto-generated | -| `max_runners` | Maximum runners allowed in the runner group (>= 1) | `3` | - -### Configuration Examples - -#### AWS — Advanced - -```hcl -module "runner_group" { - source = "./stackguardian_runner_group" - - cloud_provider = "aws" - - 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" - } -} -``` - -#### AWS — Using an Existing S3 Bucket - -```hcl -module "runner_group" { - source = "./stackguardian_runner_group" - - cloud_provider = "aws" - - stackguardian = { - api_key = var.sg_api_key - } - - aws_region = "eu-central-1" - create_storage_backend = false - existing_s3_bucket_name = "my-existing-bucket" -} -``` - -#### Azure — Advanced - -```hcl -module "runner_group" { - source = "./stackguardian_runner_group" - - cloud_provider = "azure" - - stackguardian = { - api_key = var.sg_api_key - org_name = "my-organization" - } - - azure_location = "germanywestcentral" - create_azure_resource_group = true - azure_resource_group_name = "rg-stackguardian" # optional name override for the new RG - - azure_storage = { - account_tier = "Standard" - account_replication_type = "ZRS" - } - - max_runners = 10 - - override_names = { - global_prefix = "PROD_RUNNER" - include_org_in_prefix = true - } -} -``` - -#### Azure — Using an Existing Storage Account - -```hcl -module "runner_group" { - source = "./stackguardian_runner_group" - - cloud_provider = "azure" - - stackguardian = { - api_key = var.sg_api_key - } - - azure_location = "westeurope" - create_azure_resource_group = false - azure_resource_group_name = "my-existing-rg" - create_storage_backend = false - existing_azure_storage_account_name = "myexistingstorage" - existing_azure_storage_account_access_key = var.azure_storage_key -} -``` - -## Usage - -### Deployment Commands - -```bash -terraform init -terraform plan -terraform apply - -terraform output runner_group_name -terraform output -raw runner_group_token # sensitive -``` - -### Cleanup - -```bash -terraform destroy -``` - -**Warning (AWS)**: With `force_destroy_storage_backend = false` (default), the S3 bucket will not be deleted while it contains objects. Empty the bucket or set `force_destroy_storage_backend = true`. - -**Warning (Azure)**: The Storage Account is deleted along with all blob containers and contents. The Azure AD application and service principal are also removed. - -## Architecture - -### Resource Organization - -| File | Purpose | -|------|---------| -| `provider.tf` | `terraform { required_providers }` and provider blocks (AWS, Azure RM, Azure AD, StackGuardian, external, random) | -| `variables.tf` | Input variable definitions and validations | -| `locals.tf` | Computed values, naming, and per-cloud branching | -| `data.tf` | Data sources: SG runner group token, env extraction, AWS caller identity, Azure client config | -| `runner_group.tf` | StackGuardian runner group resource (selects AWS or Azure storage backend config) | -| `connector.tf` | StackGuardian connector — AWS RBAC and Azure OIDC variants | -| `storage_backend.tf` | AWS S3 bucket, public access block, CORS configuration | -| `storage_backend_role.tf` | AWS IAM role, policy, and external ID | -| `storage_backend_azure.tf` | Azure Storage Account, blob container, Azure AD app/SP, federated identity, role assignment | -| `outputs.tf` | Module outputs | - -### Resource Naming Convention - -Resources follow `{effective_prefix}-{resource-type}-{account_identifier}`: - -- `effective_prefix`: `global_prefix` (default `SG_RUNNER`); when `include_org_in_prefix = true` and an org name is available, becomes `{global_prefix}_{org_name}`. -- `account_identifier`: AWS account ID (AWS) or Azure subscription ID (Azure). - -Examples: -- Runner group: `SG_RUNNER-runner-group-123456789012` -- AWS connector: `SG_RUNNER-private-runner-backend-123456789012` -- AWS IAM role: `SG_RUNNER-private-runner-s3-role` -- Azure storage account: `stgbackend{prefix}{8-char-random}` (lowercase, max 24 chars) -- Azure AD application: `SG_RUNNER-sg-connector` - -### Security Model - -- **AWS cross-account access**: IAM role trust policy allows StackGuardian platform accounts and the caller's account to assume the role; an external ID (`{org_name}:{24-char-random}`) prevents confused-deputy attacks. IAM policy is scoped to the specific bucket and required S3 actions only. The bucket has public access blocked and CORS limited to the SG platform origin. -- **Azure OIDC federation**: A federated identity credential issued by `var.stackguardian.api_uri` for subject `/orgs/{org_name}` lets the SG platform assume the service principal — no static secret. The SP is granted only `Storage Blob Data Reader` on the storage account. Storage Account enforces TLS 1.2 minimum and disables nested public items; CORS is limited to the SG platform origin. - -## Troubleshooting - -### Common Issues - -1. **API Key Validation Error** - - Ensure the API key matches `^(sg[uo]_.*|\$\{secret::[A-Za-z0-9_-]+\})$` — i.e. starts with `sgu_` / `sgo_` or is a `${secret::...}` reference. -2. **Organization Name Not Found** - - Provide `stackguardian.org_name` explicitly, or set `SG_ORG_ID` in the environment (the module extracts everything after the last `/`). -3. **AWS — S3 bucket already exists** - - Bucket names are globally unique; the module uses an 8-char random prefix when creating new buckets. To use an existing bucket, set `create_storage_backend = false` and `existing_s3_bucket_name`. -4. **AWS — Permission denied on destroy** - - Empty the bucket or set `force_destroy_storage_backend = true`. -5. **Azure — Resource group not found** - - When `create_azure_resource_group = false`, `azure_resource_group_name` must reference an **existing** resource group. With the default `create_azure_resource_group = true`, the module creates the RG itself. -6. **Azure — Existing storage account access key invalid** - - When `create_storage_backend = false`, `existing_azure_storage_account_access_key` must be a primary or secondary key of `existing_azure_storage_account_name`. -7. **Azure — Insufficient privileges to register an Azure AD application** - - The OIDC connector creates an Azure AD application + SP. The caller needs Application.ReadWrite.OwnedBy or equivalent. -8. **Azure — `AuthorizationFailed` on `Microsoft.Authorization/roleAssignments/write`** - - The Terraform identity lacks permission to create role assignments. Either grant it `Owner` / `User Access Administrator` at the subscription or RG scope, or set `create_blob_reader_role_assignment = false` and create the role assignment out of band using `azure_connector_service_principal_object_id` and `azure_storage_account_name`. - -### Debugging Commands - -```bash -terraform state list -terraform state show stackguardian_runner_group.this -terraform state show 'stackguardian_connector.aws[0]' # AWS -terraform state show 'stackguardian_connector.azure[0]' # Azure - -export TF_LOG=DEBUG -terraform apply -``` - -## Outputs +### Outputs | Output | Description | |--------|-------------| -| `runner_group_name` | Name of the StackGuardian runner group | -| `runner_group_id` | 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 StackGuardian web console | -| `connector_name` | Name of the StackGuardian connector (AWS or Azure) | -| `connector_id` | ID of the StackGuardian connector (AWS or Azure) | -| `connector_external_id` | External ID for cross-account S3 access (AWS only; empty on Azure) | -| `s3_bucket_name` | Name of the S3 bucket (AWS only) | -| `s3_bucket_arn` | ARN of the S3 bucket (AWS only) | -| `storage_backend_role_arn` | ARN of the IAM role for storage backend access (AWS only) | -| `storage_backend_role_name` | Name of the IAM role (AWS only) | -| `azure_resource_group_name` | Azure Resource Group name (Azure only) — pass to downstream `azure/*` modules | -| `azure_resource_group_location` | Azure Resource Group location (Azure only) | -| `azure_connector_service_principal_object_id` | Object ID of the OIDC connector service principal (Azure only) — use to create the role assignment out of band when `create_blob_reader_role_assignment = false` | -| `azure_storage_account_name` | Azure Storage Account name (Azure only) | -| `azure_storage_access_key` | Azure Storage Account primary access key (Azure only, sensitive) | -| `cloud_provider` | The cloud provider used for the storage backend | -| `azure_location` | Azure region (Azure only) | -| `aws_region` | AWS region (AWS only) | -| `sg_org_name` | StackGuardian organization name | -| `sg_api_uri` | StackGuardian API URI | - -## Security Considerations - -- **API Key Storage** — keep the StackGuardian API key in a secrets manager or use the `${secret::...}` reference syntax. -- **AWS IAM least privilege** — the generated IAM policy grants only the S3 actions required by runners on the specific bucket; the trust policy is gated by an external ID. -- **AWS bucket hardening** — public access is blocked; CORS allows only the StackGuardian platform origin. -- **Azure OIDC** — no long-lived secrets are stored; the connector uses federated identity for the SG org subject. -- **Azure storage hardening** — TLS 1.2 minimum, nested public items disabled, CORS limited to the SG platform origin, blob container is private. -- **Sensitive outputs** — `runner_group_token` and `azure_storage_access_key` are marked sensitive; treat them accordingly when wiring into downstream modules. - -## Requirements - -| Name | Version | -|------|---------| -| terraform | >= 1.0 | -| stackguardian | >= 1.3.3 | -| aws | >= 4.0 | -| azurerm | >= 3.0 | -| azuread | >= 2.0 | -| external | >= 2.0 | -| random | >= 3.0 | - -## Next Steps - -After deploying this module: - -1. Use `runner_group_name` and `runner_group_token` with the runner deployment modules (`aws_autoscaled_runner`, `aws_runner`, or the Azure VMSS autoscaler) to register runners. -2. Pass `storage_backend_role_arn` + `s3_bucket_name` (AWS) or `azure_storage_account_name` + `azure_storage_access_key` (Azure) into the runner modules. -3. Open the runner group in the StackGuardian web console using `runner_group_url`. - -## 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 461c135..4e4186c 100644 --- a/stackguardian_private_runner/runner_group/connector.tf +++ b/stackguardian_private_runner/runner_group/connector.tf @@ -1,18 +1,22 @@ -# StackGuardian Connector (AWS and Azure storage backend authentication) +# 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. # AWS Connector — Uses RBAC role for S3 access resource "stackguardian_connector" "aws" { count = local.is_aws ? 1 : 0 - resource_name = local.connector_name - description = "AWS connector for accessing Private Runner storage backend (S3 Bucket: ${local.s3_bucket_name})." + 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[0].arn - external_id = "${local.sg_org_name}:${random_string.connector_external_id[0].result}" + role_arn = local.aws_backend.role_arn + external_id = local.aws_backend.external_id duration_seconds = "3600" }] } @@ -20,28 +24,22 @@ resource "stackguardian_connector" "aws" { tags = local.default_tags } -# Azure Connector — Uses OIDC with auto-provisioned Service Principal +# Azure Connector — Uses OIDC with a Service Principal provisioned by the caller resource "stackguardian_connector" "azure" { count = local.is_azure ? 1 : 0 - resource_name = local.connector_name + resource_name = var.connector_name description = "Azure OIDC connector for Private Runner storage backend" settings = { kind = "AZURE_OIDC" config = [{ - arm_tenant_id = data.azurerm_client_config.current[0].tenant_id - arm_subscription_id = data.azurerm_client_config.current[0].subscription_id - arm_client_id = azuread_application.connector[0].client_id + 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 } - -# State migration: moved block for backward compatibility -moved { - from = stackguardian_connector.this - to = stackguardian_connector.aws[0] -} diff --git a/stackguardian_private_runner/runner_group/locals.tf b/stackguardian_private_runner/runner_group/locals.tf index d4b7a1c..75f7bcc 100644 --- a/stackguardian_private_runner/runner_group/locals.tf +++ b/stackguardian_private_runner/runner_group/locals.tf @@ -1,134 +1,15 @@ -data "external" "env" { - program = [ - "sh", - "-c", - "echo '{\"sg_org_name\": \"'$${SG_ORG_ID##*/}'\"}'" - ] -} - -data "aws_caller_identity" "current" { - count = var.cloud_provider == "aws" ? 1 : 0 -} - -data "azurerm_client_config" "current" { - count = var.cloud_provider == "azure" ? 1 : 0 -} - locals { - # Cloud provider booleans - is_aws = var.cloud_provider == "aws" - is_azure = var.cloud_provider == "azure" - - # Account identifier for resource naming - account_identifier = ( - local.is_aws - ? data.aws_caller_identity.current[0].account_id - : data.azurerm_client_config.current[0].subscription_id - ) - - # 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] + # Storage backend discriminator + is_aws = var.storage_backend.type == "aws_s3" + is_azure = var.storage_backend.type == "azure_blob_storage" - # 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-${local.account_identifier}" - ) - - connector_name = ( - var.override_names.connector_name != "" - ? var.override_names.connector_name - : "${local.effective_prefix}-private-runner-backend-${local.account_identifier}" - ) + aws_backend = var.storage_backend.aws + azure_backend = var.storage_backend.azure # Default tags (not editable by user) default_tags = [ "StackGuardian Private Runner", - local.runner_group_name, - local.sg_org_name + var.runner_group_name, + var.sg_org_name ] - - # S3 bucket name / ARN (AWS only, empty for Azure) - s3_bucket_name = ( - local.is_aws - ? (var.create_storage_backend ? aws_s3_bucket.this[0].bucket : var.existing_s3_bucket_name) - : "" - ) - - s3_bucket_arn = ( - local.is_aws - ? (var.create_storage_backend ? aws_s3_bucket.this[0].arn : "arn:aws:s3:::${local.s3_bucket_name}") - : "" - ) - - # Azure storage locals — derive from effective_prefix so org name flows into resource names - sanitized_prefix = replace(lower(local.effective_prefix), "_", "-") - 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_azure_rg_name = ( - var.azure_resource_group_name != "" - ? var.azure_resource_group_name - : "${local.sanitized_prefix}-rg-${local.account_identifier}" - ) - - # 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. - azure_resource_group_name = ( - local.is_azure - ? ( - var.create_azure_resource_group - ? azurerm_resource_group.this[0].name - : var.azure_resource_group_name - ) - : "" - ) - - azure_storage_account_name = ( - local.is_azure - ? ( - var.create_storage_backend - ? azurerm_storage_account.this[0].name - : var.existing_azure_storage_account_name - ) - : "" - ) - - azure_storage_access_key = ( - local.is_azure - ? ( - var.create_storage_backend - ? azurerm_storage_account.this[0].primary_access_key - : var.existing_azure_storage_account_access_key - ) - : "" - ) - - # Runner group outputs - final_runner_group_name = stackguardian_runner_group.this.resource_name - final_connector_name = local.is_aws ? stackguardian_connector.aws[0].resource_name : (local.is_azure ? stackguardian_connector.azure[0].resource_name : "") } diff --git a/stackguardian_private_runner/runner_group/outputs.tf b/stackguardian_private_runner/runner_group/outputs.tf index a9533c6..e085297 100644 --- a/stackguardian_private_runner/runner_group/outputs.tf +++ b/stackguardian_private_runner/runner_group/outputs.tf @@ -19,106 +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 (AWS & Azure) | + | Connector Outputs | +---------------------------------*/ output "connector_name" { - description = "The name of the StackGuardian connector (AWS or Azure)" - value = local.is_aws ? stackguardian_connector.aws[0].resource_name : (local.is_azure ? stackguardian_connector.azure[0].resource_name : null) + description = "The name of the StackGuardian connector" + 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 (AWS or Azure)" - value = local.is_aws ? stackguardian_connector.aws[0].resource_name : (local.is_azure ? stackguardian_connector.azure[0].resource_name : null) -} - -output "connector_external_id" { - description = "The external ID used for cross-account S3 access (AWS only)" - value = local.is_aws ? "${local.sg_org_name}:${random_string.connector_external_id[0].result}" : null -} - -/*---------------------------------+ - | Storage Backend Outputs (AWS) | - +---------------------------------*/ -output "s3_bucket_name" { - description = "The name of the S3 bucket used for storage backend (AWS only)" - value = local.is_aws ? local.s3_bucket_name : null -} - -output "s3_bucket_arn" { - description = "The ARN of the S3 bucket used for storage backend (AWS only)" - value = local.is_aws ? local.s3_bucket_arn : null -} - -output "storage_backend_role_arn" { - description = "The ARN of the IAM role for storage backend access (AWS only)" - value = local.is_aws ? aws_iam_role.storage_backend[0].arn : null -} - -output "storage_backend_role_name" { - description = "The name of the IAM role for storage backend access (AWS only)" - value = local.is_aws ? aws_iam_role.storage_backend[0].name : null -} - -/*---------------------------------+ - | Storage Backend Outputs (Azure) | - +---------------------------------*/ -output "azure_resource_group_name" { - description = "The name of the Azure Resource Group containing the storage account (Azure only). Pass this to downstream azure/* modules' resource_group_name input." - value = local.is_azure ? local.azure_resource_group_name : null -} - -output "azure_resource_group_location" { - description = "The location of the Azure Resource Group (Azure only)." - value = local.is_azure ? var.azure_location : null -} - -output "azure_connector_service_principal_object_id" { - description = "Object ID of the OIDC connector service principal (Azure only). Use this to create the 'Storage Blob Data Reader' role assignment out of band when create_blob_reader_role_assignment = false." - value = local.is_azure ? azuread_service_principal.connector[0].object_id : null -} - -output "azure_storage_account_name" { - description = "The name of the Azure Storage Account used for storage backend (Azure only)" - value = local.is_azure ? local.azure_storage_account_name : null -} - -output "azure_storage_access_key" { - description = "The access key for the Azure Storage Account (Azure only, sensitive)" - sensitive = true - value = local.is_azure ? local.azure_storage_access_key : null -} - -/*---------------------------------+ - | General Outputs | - +---------------------------------*/ -output "cloud_provider" { - description = "The cloud provider used for the storage backend" - value = var.cloud_provider -} - -output "azure_location" { - description = "The Azure region (Azure only)" - value = local.is_azure ? var.azure_location : null -} - -/*---------------------------------+ - | 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 (AWS only)" - value = local.is_aws ? var.aws_region : null + description = "The ID of the StackGuardian connector" + 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 7ee2157..c68e458 100644 --- a/stackguardian_private_runner/runner_group/provider.tf +++ b/stackguardian_private_runner/runner_group/provider.tf @@ -1,44 +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" - } - azurerm = { - source = "hashicorp/azurerm" - version = ">= 3.0" - } - azuread = { - source = "hashicorp/azuread" - version = ">= 2.0" - } - external = { - source = "hashicorp/external" - version = ">= 2.0" - } - random = { - source = "hashicorp/random" - version = ">= 3.0" - } } } - -provider "aws" { - region = var.aws_region - skip_credentials_validation = var.cloud_provider != "aws" - skip_requesting_account_id = var.cloud_provider != "aws" -} - -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/runner_group/runner_group.tf b/stackguardian_private_runner/runner_group/runner_group.tf index cd94716..485b42d 100644 --- a/stackguardian_private_runner/runner_group/runner_group.tf +++ b/stackguardian_private_runner/runner_group/runner_group.tf @@ -1,15 +1,15 @@ # StackGuardian Runner Group resource "stackguardian_runner_group" "this" { - resource_name = local.runner_group_name + 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 = local.is_aws ? { type = "aws_s3" - aws_region = var.aws_region - s3_bucket_name = local.s3_bucket_name + 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 = { @@ -19,8 +19,8 @@ resource "stackguardian_runner_group" "this" { type = "azure_blob_storage" aws_region = null s3_bucket_name = null - azure_blob_storage_account_name = local.azure_storage_account_name - azure_blob_storage_access_key = local.azure_storage_access_key + 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/schemas/input_schema.json b/stackguardian_private_runner/runner_group/schemas/input_schema.json deleted file mode 100644 index de4e80c..0000000 --- a/stackguardian_private_runner/runner_group/schemas/input_schema.json +++ /dev/null @@ -1,292 +0,0 @@ -{ - "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"] - }, - "cloud_provider": { - "title": "Cloud Provider", - "type": "string", - "enum": ["aws", "azure"], - "enumNames": ["AWS", "Azure"], - "default": "aws" - }, - "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" - }, - "include_org_in_prefix": { - "title": "Include Organization Name in Prefix", - "type": "boolean", - "default": false - }, - "runner_group_name": { - "title": "Runner Group Name Override", - "type": "string", - "default": "" - }, - "connector_name": { - "title": "Connector Name Override", - "type": "string", - "default": "" - } - }, - "required": ["global_prefix"], - "additionalProperties": false - }, - "max_runners": { - "title": "Maximum Runners", - "type": "integer", - "default": 3, - "minimum": 1 - } - }, - "dependencies": { - "cloud_provider": { - "oneOf": [ - { - "properties": { - "cloud_provider": { "enum": ["aws"] }, - "aws_region": { - "title": "AWS Region", - "type": "string", - "enum": [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "af-south-1", - "ap-east-1", - "ap-south-1", - "ap-south-2", - "ap-southeast-1", - "ap-southeast-2", - "ap-southeast-3", - "ap-southeast-4", - "ap-northeast-1", - "ap-northeast-2", - "ap-northeast-3", - "ca-central-1", - "ca-west-1", - "eu-central-1", - "eu-central-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-south-1", - "eu-south-2", - "eu-north-1", - "il-central-1", - "me-south-1", - "me-central-1", - "sa-east-1" - ], - "default": "eu-central-1" - } - }, - "dependencies": { - "create_storage_backend": { - "oneOf": [ - { - "properties": { - "create_storage_backend": { "enum": [true] }, - "force_destroy_storage_backend": { - "title": "Force Destroy Storage Backend", - "type": "boolean", - "default": false - } - } - }, - { - "properties": { - "create_storage_backend": { "enum": [false] }, - "existing_s3_bucket_name": { - "title": "Existing S3 Bucket Name", - "type": "string", - "minLength": 1 - } - }, - "required": ["existing_s3_bucket_name"] - } - ] - } - } - }, - { - "properties": { - "cloud_provider": { "enum": ["azure"] }, - "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 - }, - "create_blob_reader_role_assignment": { - "title": "Create Blob Reader Role Assignment", - "type": "boolean", - "default": true - }, - "azure_resource_group_name": { - "title": "Azure Resource Group Name", - "type": "string", - "default": "" - } - }, - "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/runner_group/storage_backend_azure.tf b/stackguardian_private_runner/runner_group/storage_backend_azure.tf deleted file mode 100644 index 27918e4..0000000 --- a/stackguardian_private_runner/runner_group/storage_backend_azure.tf +++ /dev/null @@ -1,109 +0,0 @@ -# Azure Resource Group (Azure only, created when create_azure_resource_group = true) -resource "azurerm_resource_group" "this" { - count = local.is_azure && var.create_azure_resource_group ? 1 : 0 - - name = local.desired_azure_rg_name - location = var.azure_location - - tags = { - purpose = "stackguardian-private-runner" - prefix = var.override_names.global_prefix - } - - lifecycle { - precondition { - condition = local.desired_azure_rg_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 (Azure only, created when create_storage_backend = true) - -resource "random_string" "azure_storage_suffix" { - count = local.is_azure && var.create_storage_backend ? 1 : 0 - - length = 8 - special = false - upper = false -} - -# Storage account name must be globally unique, 3-24 chars, lowercase alphanumeric only -resource "azurerm_storage_account" "this" { - count = local.is_azure && var.create_storage_backend ? 1 : 0 - - name = "${local.storage_account_prefix}${random_string.azure_storage_suffix[0].result}" - resource_group_name = local.azure_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.azure_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 = [replace(local.sg_api_uri, "api.", "")] - 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 = local.is_azure && var.create_storage_backend ? 1 : 0 - - name = "runner" - storage_account_id = azurerm_storage_account.this[0].id - container_access_type = "private" -} - -# Azure AD App Registration + Service Principal for OIDC connector - -resource "azuread_application" "connector" { - count = local.is_azure ? 1 : 0 - display_name = "${local.effective_prefix}-sg-connector" - - owners = [data.azurerm_client_config.current[0].object_id] -} - -resource "azuread_service_principal" "connector" { - count = local.is_azure ? 1 : 0 - client_id = azuread_application.connector[0].client_id - - owners = [data.azurerm_client_config.current[0].object_id] -} - -resource "azuread_application_federated_identity_credential" "connector" { - count = local.is_azure ? 1 : 0 - application_id = azuread_application.connector[0].id - display_name = "${local.effective_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 = local.is_azure && 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[0].object_id -} diff --git a/stackguardian_private_runner/runner_group/variables.tf b/stackguardian_private_runner/runner_group/variables.tf index 0edb699..1ca57bf 100644 --- a/stackguardian_private_runner/runner_group/variables.tf +++ b/stackguardian_private_runner/runner_group/variables.tf @@ -1,189 +1,89 @@ -/*---------------------------+ - | Cloud Provider Toggle | - +---------------------------*/ -variable "cloud_provider" { - description = "The cloud provider for the storage backend. Determines which resources are created (AWS S3 or Azure Blob Storage)." +/*-----------------------------------+ + | StackGuardian Platform Variables | + +-----------------------------------*/ +variable "sg_org_name" { + description = "StackGuardian organization name, resolved by the calling module." type = string - default = "aws" validation { - condition = contains(["aws", "azure"], var.cloud_provider) - error_message = "The cloud_provider must be either 'aws' or 'azure'." + condition = var.sg_org_name != "" + error_message = "sg_org_name must not be empty. Set stackguardian.org_name on the calling module or make sure SG_ORG_ID is exported." } } -/*---------------------------+ - | Storage Backend Options | - +---------------------------*/ -variable "create_storage_backend" { - description = <= 1 + error_message = "max_runners must be at least 1." } } -/*-----------------------------------+ - | StackGuardian Platform Variables | - +-----------------------------------*/ -variable "stackguardian" { - description = "StackGuardian platform configuration" +/*---------------------------+ + | Storage Backend | + +---------------------------*/ +variable "storage_backend" { + description = <= 1 - error_message = "max_runners must be at least 1." + condition = var.storage_backend.type != "azure_blob_storage" || var.storage_backend.azure != null + error_message = "storage_backend.azure is required when storage_backend.type = 'azure_blob_storage'." } } From 7e44148e358bbc4570d8a9d431849c08f65a21ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Mon, 24 Aug 2026 23:39:40 +0200 Subject: [PATCH 28/37] SG-3995: Align template docs with schemas and fix readme navigation. DOCUMENTATION.md is what a user reads while filling the nocode form, so its labels should match the schema titles they actually see. Several had drifted - "Delete EBS Snapshots" for Delete Snapshots, "Automatic AMI Cleanup" for Cleanup AMIs on Destroy, "Additional Security Groups" for Additional Security Group IDs. aws/single_runner was missing Runner Group Token entirely, which is in the schema's required list and is a password field. Outputs tables were also incomplete; adds the 24 outputs that were exposed but undocumented. Navigation: every DOCUMENTATION.md and the packer destroy guide were unreachable - nothing linked to them - and only the examples linked back to the root. Adds a breadcrumb to each module readme and links the stack overviews from the root. The root module tables were also missing aws/single_runner and azure/vmss outright. Adds those, the shared runner_group, and an examples table. Checked: schema fields and variables match 1:1 in all ten modules, every leaf parameter is documented, ui:order is complete, no stray ui_schema keys, and no broken or orphaned links. --- stackguardian_private_runner/README.md | 66 +++++++++++++------ .../aws/DOCUMENTATION.md | 3 +- .../aws/autoscaler/DOCUMENTATION.md | 2 + .../aws/autoscaler/README.md | 2 + .../aws/autoscaling_group/DOCUMENTATION.md | 4 ++ .../aws/autoscaling_group/README.md | 2 + .../aws/packer/DOCUMENTATION.md | 8 +-- .../aws/packer/README.md | 6 ++ .../aws/single_runner/DOCUMENTATION.md | 5 +- .../aws/single_runner/README.md | 2 + .../azure/DOCUMENTATION.md | 17 +++-- .../azure/autoscaler/DOCUMENTATION.md | 6 ++ .../azure/autoscaler/README.md | 2 + .../azure/azure_runner/README.md | 2 + .../azure/packer/DOCUMENTATION.md | 1 + .../azure/packer/README.md | 2 + .../azure/vmss/DOCUMENTATION.md | 2 + .../azure/vmss/README.md | 2 + 18 files changed, 99 insertions(+), 35 deletions(-) diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index 87c8809..d5057e6 100644 --- a/stackguardian_private_runner/README.md +++ b/stackguardian_private_runner/README.md @@ -14,7 +14,7 @@ This project provides Terraform modules that work together to create a complete ### 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 @@ -23,7 +23,7 @@ This project provides Terraform modules that work together to create a complete ### Azure 1. **[Packer Image Builder](azure/packer/)** - Build custom Azure Managed Images with pre-installed dependencies -2. **[Runner Group](runner_group/)** - Create StackGuardian Runner Group (shared module) +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 @@ -64,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:** @@ -92,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/ ``` @@ -207,22 +207,41 @@ 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) | -| [runner_group](runner_group/) | Create Runner Group (shared) | [README](runner_group/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 | + ### Common Required Parameters | Parameter | Description | Example | @@ -298,7 +317,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) @@ -306,7 +325,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" \ @@ -344,14 +363,17 @@ terraform init && terraform apply -auto-approve IMAGE_ID=$(terraform output -raw image_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) -STORAGE_BACKEND_IDENTITY_ID=$(terraform output -raw storage_backend_identity_id) +RESOURCE_GROUP_NAME=$(terraform output -raw azure_resource_group_name) # Step 3: Deploy Azure Runner -cd ../azure/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" \ @@ -412,11 +434,11 @@ echo "Image ID: $IMAGE_ID" Navigate to the **Runner Group** module and create the StackGuardian runner group. ```bash -cd ../../runner_group/ -# Or from root: cd runner_group/ +cd ../runner_group/ +# Or from root: cd azure/runner_group/ ``` -See [runner_group/README.md](runner_group/README.md) for full configuration options. +See [azure/runner_group/README.md](azure/runner_group/README.md) for full configuration options. **Deploy:** @@ -431,7 +453,8 @@ terraform apply ```bash RUNNER_GROUP_NAME=$(terraform output -raw runner_group_name) RUNNER_GROUP_TOKEN=$(terraform output -raw runner_group_token) -STORAGE_BACKEND_IDENTITY_ID=$(terraform output -raw storage_backend_identity_id) +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 @@ -439,7 +462,7 @@ STORAGE_BACKEND_IDENTITY_ID=$(terraform output -raw storage_backend_identity_id) Navigate to the **Azure Runner** module and deploy the runner VM. ```bash -cd ../azure/azure_runner/ +cd ../azure_runner/ # Or from root: cd azure/azure_runner/ ``` @@ -451,9 +474,14 @@ See [azure/azure_runner/README.md](azure/azure_runner/README.md) for full config 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/..." # 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 diff --git a/stackguardian_private_runner/aws/DOCUMENTATION.md b/stackguardian_private_runner/aws/DOCUMENTATION.md index 8c88d51..6aa5610 100644 --- a/stackguardian_private_runner/aws/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/DOCUMENTATION.md @@ -70,7 +70,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 @@ -103,6 +103,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 c777f14..8b277de 100644 --- a/stackguardian_private_runner/aws/autoscaler/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/autoscaler/DOCUMENTATION.md @@ -80,6 +80,8 @@ 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 diff --git a/stackguardian_private_runner/aws/autoscaler/README.md b/stackguardian_private_runner/aws/autoscaler/README.md index b68f674..af254ab 100644 --- a/stackguardian_private_runner/aws/autoscaler/README.md +++ b/stackguardian_private_runner/aws/autoscaler/README.md @@ -1,5 +1,7 @@ # 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 diff --git a/stackguardian_private_runner/aws/autoscaling_group/DOCUMENTATION.md b/stackguardian_private_runner/aws/autoscaling_group/DOCUMENTATION.md index e499469..c43ad74 100644 --- a/stackguardian_private_runner/aws/autoscaling_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/autoscaling_group/DOCUMENTATION.md @@ -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 e58908e..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 diff --git a/stackguardian_private_runner/aws/packer/DOCUMENTATION.md b/stackguardian_private_runner/aws/packer/DOCUMENTATION.md index cc0724e..8e6aca5 100644 --- a/stackguardian_private_runner/aws/packer/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/packer/DOCUMENTATION.md @@ -44,10 +44,10 @@ 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 | | 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 | diff --git a/stackguardian_private_runner/aws/packer/README.md b/stackguardian_private_runner/aws/packer/README.md index 0e010b8..e1c5ea3 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 @@ -284,6 +286,10 @@ 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 diff --git a/stackguardian_private_runner/aws/single_runner/DOCUMENTATION.md b/stackguardian_private_runner/aws/single_runner/DOCUMENTATION.md index 41e1da5..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` | diff --git a/stackguardian_private_runner/aws/single_runner/README.md b/stackguardian_private_runner/aws/single_runner/README.md index 18b83fa..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 diff --git a/stackguardian_private_runner/azure/DOCUMENTATION.md b/stackguardian_private_runner/azure/DOCUMENTATION.md index 2f3381f..a35b1d1 100644 --- a/stackguardian_private_runner/azure/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/DOCUMENTATION.md @@ -4,7 +4,7 @@ Deploy a complete auto-scaling StackGuardian Private Runner infrastructure on Az ## 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 four Azure templates plus the shared `runner_group` template, which work together to provide a fully managed runner infrastructure inside your own subscription. +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 @@ -20,7 +20,7 @@ This Stack deploys a production-ready private runner environment with custom man - 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 `runner_group` template can create one for you) +- 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. @@ -79,15 +79,14 @@ There is no `ssh_username` input — the build user is derived from `os.publishe --- -## Template 2: Runner Group (shared) +## Template 2: Runner Group -Create a StackGuardian Runner Group with an Azure Blob Storage backend and an Entra ID OIDC connector. This is the shared `runner_group/` template at the repository root, driven into Azure mode with `cloud_provider = "azure"`; the same template serves the AWS stack. +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 | |-----------|-------------|------| -| cloud_provider | Must be set to `azure` (defaults to `aws`) | string | | stackguardian.api_key | Your organization's API key (`sgo_*`/`sgu_*`) or secret reference | string | ### Optional Parameters @@ -108,10 +107,9 @@ Create a StackGuardian Runner Group with an Azure Blob Storage backend and an En | 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) | | max_runners | Maximum number of runners allowed in the group | `3` | -`override_names.connector_name` exists but only names the AWS connector; the Azure connector name is derived from the effective prefix. - ### Outputs | Output | Description | @@ -123,6 +121,7 @@ Create a StackGuardian Runner Group with an Azure Blob Storage backend and an En | 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 | @@ -301,9 +300,9 @@ The autoscaler template only manages a VM Scale Set, so this path is not autosca | 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_name`, 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. +**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 `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. +**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. diff --git a/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md b/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md index 6b3d65f..8912a19 100644 --- a/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/autoscaler/DOCUMENTATION.md @@ -86,6 +86,12 @@ Before using this template, you need: | 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 diff --git a/stackguardian_private_runner/azure/autoscaler/README.md b/stackguardian_private_runner/azure/autoscaler/README.md index 10b2015..0881aaf 100644 --- a/stackguardian_private_runner/azure/autoscaler/README.md +++ b/stackguardian_private_runner/azure/autoscaler/README.md @@ -1,5 +1,7 @@ # 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 diff --git a/stackguardian_private_runner/azure/azure_runner/README.md b/stackguardian_private_runner/azure/azure_runner/README.md index b9ad77f..9388eb9 100644 --- a/stackguardian_private_runner/azure/azure_runner/README.md +++ b/stackguardian_private_runner/azure/azure_runner/README.md @@ -1,5 +1,7 @@ # 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 diff --git a/stackguardian_private_runner/azure/packer/DOCUMENTATION.md b/stackguardian_private_runner/azure/packer/DOCUMENTATION.md index 7567675..46c6d3e 100644 --- a/stackguardian_private_runner/azure/packer/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/packer/DOCUMENTATION.md @@ -69,6 +69,7 @@ This template produces a reusable Azure managed image so your private runners bo | `image_id` | Resource ID of the Azure managed image built by this deployment and recorded in state — pass this to the Azure runner template | | `image_info` | Image metadata: ID, location, resource group, OS family/SKU, image name, name prefix, cleanup settings | | `resource_group_name` | Resource group where the image is stored | +| `cleanup_commands` | Ready-made Azure CLI commands for manual image cleanup | ## Security Features diff --git a/stackguardian_private_runner/azure/packer/README.md b/stackguardian_private_runner/azure/packer/README.md index 971223f..a50b7a4 100644 --- a/stackguardian_private_runner/azure/packer/README.md +++ b/stackguardian_private_runner/azure/packer/README.md @@ -1,5 +1,7 @@ # StackGuardian Private Runner Image Builder - Azure Module +> 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 diff --git a/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md b/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md index b70e9bb..f15c82f 100644 --- a/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/vmss/DOCUMENTATION.md @@ -102,6 +102,8 @@ This template gives you a horizontally-scalable pool of StackGuardian runners ru | 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 diff --git a/stackguardian_private_runner/azure/vmss/README.md b/stackguardian_private_runner/azure/vmss/README.md index 393db72..636ff5e 100644 --- a/stackguardian_private_runner/azure/vmss/README.md +++ b/stackguardian_private_runner/azure/vmss/README.md @@ -1,5 +1,7 @@ # 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 From c530ee4edf45021a2782e057d354d57c088235ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Fri, 28 Aug 2026 11:29:39 +0200 Subject: [PATCH 29/37] SG-3995: Share the packer build and setup scripts across clouds. aws/packer and azure/packer each carried their own copy of setup.sh and of a build script. The setup copies had drifted about 150 lines apart even though nothing in them is cloud-specific: what actually differed was OS-specific (apt vs yum vs dnf) or the image's admin user, which each copy hardcoded - ec2-user on one side, azureuser on the other. Both now run packer/scripts/setup.sh and packer/scripts/build.sh. The shared setup takes SSH_USERNAME from the caller and branches on OS_FAMILY only, so no cloud branches are left in it. The two build scripts differed only in the -var list they passed to packer build. That list is gone: Terraform sets PKR_VAR_ in the environment and Packer reads those natively, so the shared script needs just PACKER_VERSION and PACKER_TEMPLATE and never has to know a cloud's variable names. Along the way, each module gained the knob the other already had: - aws/packer takes ami_name_prefix, matching azure's image_name_prefix. It defaults to the previously hardcoded SG-RUNNER-ami, so existing AMIs still match runner_ami_name_pattern. - azure/packer takes sg_runner.pre_release, which aws already had and the shared setup.sh already knew how to install. --- .../aws/packer/README.md | 11 +- .../aws/packer/ami.pkr.hcl | 8 +- .../aws/packer/locals.tf | 2 +- .../aws/packer/main.tf | 46 +- .../aws/packer/schemas/input_schema.json | 6 + .../aws/packer/schemas/ui_schema.json | 5 + .../aws/packer/scripts/setup.sh | 467 ------------------ .../aws/packer/terraform.tfvars.tpl | 4 + .../aws/packer/variables.tf | 16 + .../azure/packer/README.md | 17 +- .../azure/packer/image.pkr.hcl | 6 +- .../azure/packer/main.tf | 50 +- .../azure/packer/schemas/input_schema.json | 27 +- .../azure/packer/schemas/ui_schema.json | 10 +- .../azure/packer/scripts/build_image.sh | 129 ----- .../azure/packer/terraform.tfvars.tpl | 6 + .../azure/packer/variables.tf | 21 + .../build_ami.sh => packer/scripts/build.sh} | 39 +- .../{azure => }/packer/scripts/setup.sh | 48 +- 19 files changed, 234 insertions(+), 684 deletions(-) delete mode 100755 stackguardian_private_runner/aws/packer/scripts/setup.sh delete mode 100644 stackguardian_private_runner/azure/packer/scripts/build_image.sh rename stackguardian_private_runner/{aws/packer/scripts/build_ami.sh => packer/scripts/build.sh} (70%) rename stackguardian_private_runner/{azure => }/packer/scripts/setup.sh (86%) mode change 100644 => 100755 diff --git a/stackguardian_private_runner/aws/packer/README.md b/stackguardian_private_runner/aws/packer/README.md index e1c5ea3..ad354c0 100644 --- a/stackguardian_private_runner/aws/packer/README.md +++ b/stackguardian_private_runner/aws/packer/README.md @@ -111,6 +111,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 | `""` | @@ -339,9 +340,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 @@ -357,13 +358,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) 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.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 } } diff --git a/stackguardian_private_runner/aws/packer/schemas/input_schema.json b/stackguardian_private_runner/aws/packer/schemas/input_schema.json index 195e4ec..666baa9 100644 --- a/stackguardian_private_runner/aws/packer/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/packer/schemas/input_schema.json @@ -228,6 +228,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..1d1a701 100644 --- a/stackguardian_private_runner/aws/packer/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/packer/schemas/ui_schema.json @@ -7,6 +7,7 @@ "os", "instance_type", "packer_config", + "ami_name_prefix", "terraform", "opentofu", "sg_runner" @@ -132,5 +133,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/setup.sh b/stackguardian_private_runner/aws/packer/scripts/setup.sh deleted file mode 100755 index 95722e3..0000000 --- a/stackguardian_private_runner/aws/packer/scripts/setup.sh +++ /dev/null @@ -1,467 +0,0 @@ -#!/bin/sh - -set -e - -trap _cleanup EXIT INT TERM - -OS_ARCH="" -OS_TYPE="" - -WORKING_DIR="" -TEMP_DIRS="" - -# Configure proxy settings if provided -_configure_proxy() { #{{{ - if [ -n "$PROXY_URL" ]; then - echo ">> Configuring proxy settings for $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 - fi -} -#}}}: _configure_proxy - -_cleanup() { #{{{ - echo "## ----------" - echo ">> Cleaning up AMI setup.." - - if [ -n "$TEMP_DIRS" ]; then - for temp_dir in $TEMP_DIRS; do - if [ -d "$temp_dir" ]; then - rm -rf "$temp_dir" - echo "Removed temporary directory: $temp_dir" - fi - done - fi - - if [ -d "$WORKING_DIR" ]; then - rm -rf "$WORKING_DIR" - echo "Removed temporary directory: $WORKING_DIR" - fi -} -#}}}: _cleanup - -_apt_dependencies() { #{{{ - 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 - fi - sudo apt install -y \ - docker.io \ - unzip \ - cron \ - wget -} -#}}}: _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 - sudo yum update -y - fi - sudo yum install -y \ - docker \ - unzip \ - cronie \ - gnupg2 \ - wget -} -#}}}: _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 - sudo dnf update -y - fi - sudo dnf install -y \ - dnf-plugins-core \ - unzip \ - cronie \ - wget - - sudo dnf config-manager \ - --add-repo "https://download.docker.com/linux/rhel/docker-ce.repo" - sudo dnf install -y \ - docker-ce \ - docker-ce-cli \ - containerd.io -} -#}}}: _dnf_dependencies - -_systemctl_enable() { #{{{ - for service in "$@"; do - echo ">> Enabling $service.." - sudo systemctl enable --now "$service" - done -} -#}}}: _systemctl_enable - -_usermod_add_to_group() { #{{{ - group="$1" - user="$2" - - echo ">> Adding ${user} to the ${group} group.." - sudo usermod -aG "$group" "$user" || true -} -#}}}: _usermod_add_to_group - -_wget_wrapper() { #{{{ - url="$1" - output_file="${2:-"${url##*/}"}" - - echo ">> Downloading ${url}.." - - # Add retry logic and timeout for private networks - if [ "$PRIVATE_NETWORK" = "true" ]; then - wget -q --timeout=60 --tries=3 --retry-connrefused "$url" -O "$output_file" - else - wget -q "$url" -O "$output_file" - fi - - echo ">> Saved to ${output_file}." -} -#}}}: _wget_wrapper - -_mktemp_directory() { #{{{ - WORKING_DIR="$(mktemp -d)" - if [ -n "$TEMP_DIRS" ]; then - TEMP_DIRS="$TEMP_DIRS $WORKING_DIR" - else - TEMP_DIRS="$WORKING_DIR" - fi -} -#}}}: _mktemp_directory - -_detect_arch() { #{{{ - machine="$(uname -m)" - - case "$machine" in - 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:]' -} -#}}}: _detect_os - -_get_latest_github_release() { #{{{ - repo="$1" - 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.*' -} -#}}}: _get_latest_github_release - -_install_jq() { #{{{ - os_arch="$OS_ARCH" - os_type="$OS_TYPE" - file_name="jq-${os_type}-${os_arch}" - - echo "## ----------" - echo ">> Fetching latest jq version.." - download_url="$(_get_latest_github_release "jqlang/jq" "$file_name")" - - if [ -z "$download_url" ]; then - echo "ERROR: Failed to fetch latest jq version" - exit 1 - fi - - echo ">> Installing jq.." - _mktemp_directory && cd "$WORKING_DIR" - - if _wget_wrapper "$download_url"; then - sudo chmod +x "$file_name" - sudo mv "$file_name" "/usr/bin/jq" - - echo ">> Installed to $(which jq)." - echo ">> Version: $(jq --version)" - else - echo "ERROR: Failed to download jq from: $download_url" - exit 1 - fi -} -#}}}: _install_jq - -_install_terraform() { #{{{ - if [ -z "$TERRAFORM_VERSION" ]; then - return - fi - - version="${1:-$TERRAFORM_VERSION}" - target_name="terraform" - - if [ -n "$1" ]; then - target_name="terraform$version" - fi - - os_arch="$OS_ARCH" - os_type="$OS_TYPE" - zip_name="terraform_${version}_${os_type}_${os_arch}.zip" - base_url="https://releases.hashicorp.com/terraform/${version}" - - download_url="${base_url}/${zip_name}" - - echo "## ----------" - echo ">> Installing Terraform v${version}.." - _mktemp_directory && cd "$WORKING_DIR" - - if _wget_wrapper "$download_url"; then - unzip "$zip_name" - sudo mv terraform "/usr/bin/$target_name" - - echo ">> Installed to $(which "$target_name")." - else - echo "ERROR: Failed to download Terraform v$version from: $download_url" - exit 1 - fi -} -#}}}: _install_terraform - -_install_terraform_versions() { #{{{ - versions_list="$TERRAFORM_VERSIONS" - - for version in $versions_list; do - _install_terraform "$version" - done -} -#}}}: _install_terraform_versions - -_install_opentofu() { #{{{ - if [ -z "$OPENTOFU_VERSION" ]; then - return - fi - - version="${1:-$OPENTOFU_VERSION}" - target_name="tofu" - - if [ -n "$1" ]; then - target_name="tofu$version" - fi - - os_arch="$OS_ARCH" - os_type="$OS_TYPE" - zip_name="tofu_${version}_${os_type}_${os_arch}.zip" - base_url="https://github.com/opentofu/opentofu/releases/download/v$version" - - download_url="${base_url}/${zip_name}" - - echo "## ----------" - echo ">> Installing OpenTofu v${version}.." - _mktemp_directory && cd "$WORKING_DIR" - - if _wget_wrapper "$download_url"; then - unzip "$zip_name" - sudo mv tofu "/usr/bin/$target_name" - - echo ">> Installed to $(which "$target_name")." - else - echo "ERROR: Failed to download OpenTofu v$version from: $download_url" - exit 1 - fi -} -#}}}: _install_opentofu - -_install_opentofu_versions() { #{{{ - versions_list="$OPENTOFU_VERSIONS" - - for version in $versions_list; do - _install_opentofu "$version" - done -} -#}}}: _install_opentofu_versions - -_install_sg_runner() { #{{{ - runner_archive="runner.tar.gz" - github_api_base="https://api.github.com/repos/stackguardian/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')" - if [ -z "$url" ]; then - echo ">> No pre-release found, falling back to latest stable.." - url="$(wget -qO- "${github_api_base}/releases/latest" | jq -r '.tarball_url')" - fi - else - echo ">> Fetching latest stable release.." - url="$(wget -qO- "${github_api_base}/releases/latest" | jq -r '.tarball_url')" - fi - - if [ -z "$url" ]; then - echo "ERROR: Failed to fetch sg-runner release URL" - exit 1 - fi - - _mktemp_directory && cd "$WORKING_DIR" - - if _wget_wrapper "$url" "$runner_archive"; then - tar -xf "$runner_archive" - sudo cp -rf StackGuardian-sg-runner*/main.sh /usr/bin/sg-runner - - echo ">> Installed to $(which sg-runner)." - else - echo "ERROR: Failed to download from: $url" - 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 - echo ">> Saved config to /etc/sg-runner.conf" -} -#}}}: _install_sg_runner - -_install_sg_runner_update() { #{{{ - echo "## ----------" - echo ">> Installing sg-runner-update script.." - - 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')" - if [ -z "$url" ]; then - echo ">> No pre-release found, falling back to latest stable.." - url="$(wget -qO- "${GITHUB_API_BASE}/releases/latest" | jq -r '.tarball_url')" - fi - else - echo ">> Fetching latest stable release.." - url="$(wget -qO- "${GITHUB_API_BASE}/releases/latest" | jq -r '.tarball_url')" - fi -fi - -if [ -z "$url" ]; then - echo "ERROR: Failed to determine download URL" - exit 1 -fi - -# Download and install -TEMP_DIR="$(mktemp -d)" -trap "rm -rf '$TEMP_DIR'" EXIT - -cd "$TEMP_DIR" -echo ">> Downloading from: $url" -wget -q "$url" -O runner.tar.gz - -tar -xf runner.tar.gz -sudo cp -rf StackGuardian-sg-runner*/main.sh /usr/bin/sg-runner - -echo ">> sg-runner updated successfully!" -echo ">> Installed to: $(which sg-runner)" -SCRIPT_EOF - - sudo chmod +x /usr/bin/sg-runner-update - echo ">> Installed sg-runner-update to /usr/bin/sg-runner-update" -} -#}}}: _install_sg_runner_update - -_user_script_wrapper() { #{{{ - script="$USER_SCRIPT" - - if [ -n "$script" ]; then - echo ">> Preparing user environment.." - _mktemp_directory && cd "$WORKING_DIR" - - if ! sh -c "$script"; then - echo "ERROR: Script execution failed." - exit 1 - fi - - echo ">> User script completed successfully!" - fi -} -#}}}: _user_script_wrapper - -_handle_os_package_installation() { #{{{ - if [ "$OS_FAMILY" = "ubuntu" ]; then - _apt_dependencies - _systemctl_enable "cron" "docker" - _usermod_add_to_group "docker" "ubuntu" - elif [ "$OS_FAMILY" = "amazon" ]; then - _yum_dependencies - _systemctl_enable "crond" "docker" - _usermod_add_to_group "docker" "ec2-user" - elif [ "$OS_FAMILY" = "rhel" ]; then - _dnf_dependencies - _systemctl_enable "crond" "docker" - _usermod_add_to_group "docker" "ec2-user" - else - echo "ERROR: Unsupported OS_FAMILY: $OS_FAMILY" - exit 1 - fi - -} -#}}}: _handle_os_family - -main() { #{{{ - OS_ARCH="$(_detect_arch)" - OS_TYPE="$(_detect_os)" - - # Configure proxy if in private network - _configure_proxy - - _handle_os_package_installation - - _install_jq - - _install_terraform - _install_terraform_versions - - _install_opentofu - _install_opentofu_versions - - _install_sg_runner - _install_sg_runner_update - - _user_script_wrapper -} -#}}}: main - -main "$@" 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..8672380 100644 --- a/stackguardian_private_runner/aws/packer/variables.tf +++ b/stackguardian_private_runner/aws/packer/variables.tf @@ -94,6 +94,22 @@ variable "packer_config" { /*---------------------------------+ | Terraform Installation Settings | +---------------------------------*/ +variable "ami_name_prefix" { + description = < 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 } } diff --git a/stackguardian_private_runner/azure/packer/schemas/input_schema.json b/stackguardian_private_runner/azure/packer/schemas/input_schema.json index 06b5819..17b0647 100644 --- a/stackguardian_private_runner/azure/packer/schemas/input_schema.json +++ b/stackguardian_private_runner/azure/packer/schemas/input_schema.json @@ -63,7 +63,10 @@ "publisher": { "title": "Publisher", "type": "string", - "enum": ["Canonical", "RedHat"], + "enum": [ + "Canonical", + "RedHat" + ], "default": "Canonical" }, "offer": { @@ -94,7 +97,11 @@ "default": "" } }, - "required": ["publisher", "offer", "sku"], + "required": [ + "publisher", + "offer", + "sku" + ], "additionalProperties": false }, "packer_config": { @@ -178,8 +185,22 @@ "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"], + "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 index 85b422a..cc09812 100644 --- a/stackguardian_private_runner/azure/packer/schemas/ui_schema.json +++ b/stackguardian_private_runner/azure/packer/schemas/ui_schema.json @@ -9,7 +9,8 @@ "packer_config", "image_name_prefix", "terraform", - "opentofu" + "opentofu", + "sg_runner" ], "azure_location": { "ui:description": "The target Azure region to build the Private Runner image", @@ -126,5 +127,12 @@ "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/build_image.sh b/stackguardian_private_runner/azure/packer/scripts/build_image.sh deleted file mode 100644 index 82be4c2..0000000 --- a/stackguardian_private_runner/azure/packer/scripts/build_image.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/bin/sh - -set -e - -trap _cleanup EXIT INT TERM - -PACKER_EXECUTABLE="" -WORKING_DIR="" -TEMP_DIRS="" - -_cleanup() { #{{{ - echo "## ----------" - echo "Cleaning up Packer build.." - - if [ -n "$TEMP_DIRS" ]; then - for temp_dir in $TEMP_DIRS; do - if [ -d "$temp_dir" ]; then - rm -rf "$temp_dir" - echo "Removed temporary directory: $temp_dir" - fi - done - fi - - if [ -d "$WORKING_DIR" ]; then - rm -rf "$WORKING_DIR" - echo "Removed temporary directory: $WORKING_DIR" - fi - echo "## ----------" -} -#}}}: _cleanup - -_detect_arch() { #{{{ - machine="$(uname -m)" - - case "$machine" in - 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:]' -} -#}}}: _detect_os - -_wget_wrapper() { #{{{ - url="$1" - output_file="${2:-"${url##*/}"}" - - echo ">> Downloading ${url}.." - wget -q "$url" -O "$output_file" - echo ">> Saved to ${output_file}." -} -#}}}: _wget_wrapper - -_mktemp_directory() { #{{{ - WORKING_DIR="$(mktemp -d)" - if [ -n "$TEMP_DIRS" ]; then - TEMP_DIRS="$TEMP_DIRS $WORKING_DIR" - else - TEMP_DIRS="$WORKING_DIR" - fi -} -#}}}: _mktemp_directory - -_download_packer() { #{{{ - version="$PACKER_VERSION" - root_dir="$(pwd)" - - os_arch="$(_detect_arch)" - os_type="$(_detect_os)" - zip_name="packer_${version}_${os_type}_${os_arch}.zip" - base_url="https://releases.hashicorp.com/packer/${version}" - - download_url="${base_url}/${zip_name}" - - echo "## ----------" - echo ">> Downloading Packer v${version}.." - _mktemp_directory && cd "$WORKING_DIR" - - if _wget_wrapper "$download_url"; then - unzip "$zip_name" - PACKER_EXECUTABLE="$(realpath packer)" - cd "$root_dir" - - echo ">> Downloaded to ${PACKER_EXECUTABLE}." - echo "## ----------" - else - echo "ERROR: Failed to download from: $download_url" - exit 1 - fi -} -#}}}: _download_packer - -main() { #{{{ - _download_packer - - $PACKER_EXECUTABLE init ./image.pkr.hcl - $PACKER_EXECUTABLE build \ - -var "azure_location=$AZURE_LOCATION" \ - -var "resource_group_name=$RESOURCE_GROUP_NAME" \ - -var "vm_size=$VM_SIZE" \ - -var "image_publisher=$IMAGE_PUBLISHER" \ - -var "image_offer=$IMAGE_OFFER" \ - -var "image_sku=$IMAGE_SKU" \ - -var "image_version=$IMAGE_VERSION" \ - -var "image_name_prefix=$IMAGE_NAME_PREFIX" \ - -var "os_family=$OS_FAMILY" \ - -var "ssh_username=$SSH_USERNAME" \ - -var "update_os_before_install=$UPDATE_OS" \ - -var "terraform_version=$TERRAFORM_VERSION" \ - -var "terraform_versions=$TERRAFORM_VERSIONS" \ - -var "opentofu_version=$OPENTOFU_VERSION" \ - -var "opentofu_versions=$OPENTOFU_VERSIONS" \ - -var "user_script=$USER_SCRIPT" \ - -var "vnet_name=$VNET_NAME" \ - -var "subnet_name=$SUBNET_NAME" \ - -var "vnet_resource_group_name=$VNET_RESOURCE_GROUP_NAME" \ - -var "proxy_url=$PROXY_URL" \ - -machine-readable \ - ./image.pkr.hcl | tee packer_manifest.log -} -#}}}: main - -main "$@" diff --git a/stackguardian_private_runner/azure/packer/terraform.tfvars.tpl b/stackguardian_private_runner/azure/packer/terraform.tfvars.tpl index 116054e..5633ac6 100644 --- a/stackguardian_private_runner/azure/packer/terraform.tfvars.tpl +++ b/stackguardian_private_runner/azure/packer/terraform.tfvars.tpl @@ -15,6 +15,12 @@ create_resource_group = false # ---, 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 | +------------------------------*/ diff --git a/stackguardian_private_runner/azure/packer/variables.tf b/stackguardian_private_runner/azure/packer/variables.tf index a8607e4..4c6e536 100644 --- a/stackguardian_private_runner/azure/packer/variables.tf +++ b/stackguardian_private_runner/azure/packer/variables.tf @@ -126,3 +126,24 @@ variable "opentofu" { additional_versions = [] } } + +/*---------------------------+ + | Runner Script Settings | + +---------------------------*/ +variable "sg_runner" { + description = <> Downloading ${url}.." - wget -q "$url" -O "$output_file" + + # 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 + wget -q "$url" -O "$output_file" + fi + echo ">> Saved to ${output_file}." } #}}}: _wget_wrapper @@ -410,14 +445,21 @@ _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" "$SSH_USERNAME" elif [ "$OS_FAMILY" = "rhel" ]; then _dnf_dependencies _systemctl_enable "crond" "docker" - _usermod_add_to_group "docker" "azureuser" + _usermod_add_to_group "docker" "$SSH_USERNAME" else echo "ERROR: Unsupported OS_FAMILY: $OS_FAMILY" exit 1 From 8e8bfe3e253e58ab6bd2e043ea8b3434bbc2df78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Fri, 28 Aug 2026 11:29:48 +0200 Subject: [PATCH 30/37] SG-3995: Let the packer modules hand back an existing image. aws/packer takes existing_ami_id and azure/packer takes existing_image_id. Set either one and the module creates nothing at all - no build instance or VM, no Packer download, no manifest parsing, no destroy-time cleanup - and the ami_id / image_id output returns the value it was given. The skip lives in the module rather than in a count on the module call because both modules declare their own provider, which rules out count, for_each and depends_on on the call. A caller wires module.packer.ami_id into its instance once and picks per deployment whether an image gets built. Every build resource is now count-gated on that, including the cleanup hook: the module never deregisters or deletes an image it did not build, so passing one in cannot destroy it on the next destroy. moved blocks cover the resources that gained a count, so a state written before this does not re-key and rebuild. --- .../aws/packer/README.md | 34 +++++++++++++++++++ .../aws/packer/locals.tf | 14 ++++++-- .../aws/packer/main.tf | 31 ++++++++++++++--- .../aws/packer/variables.tf | 18 ++++++++++ .../azure/packer/README.md | 31 +++++++++++++++++ .../azure/packer/locals.tf | 14 ++++++-- .../azure/packer/main.tf | 28 ++++++++++++--- .../azure/packer/variables.tf | 19 +++++++++++ 8 files changed, 177 insertions(+), 12 deletions(-) diff --git a/stackguardian_private_runner/aws/packer/README.md b/stackguardian_private_runner/aws/packer/README.md index ad354c0..b42c30d 100644 --- a/stackguardian_private_runner/aws/packer/README.md +++ b/stackguardian_private_runner/aws/packer/README.md @@ -98,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` | @@ -152,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) diff --git a/stackguardian_private_runner/aws/packer/locals.tf b/stackguardian_private_runner/aws/packer/locals.tf index ad013cf..0118893 100644 --- a/stackguardian_private_runner/aws/packer/locals.tf +++ b/stackguardian_private_runner/aws/packer/locals.tf @@ -28,8 +28,18 @@ locals { # Name given to AMIs built by this module (see ami.pkr.hcl) runner_ami_name_pattern = "${var.ami_name_prefix}-${var.os.family}${var.os.family != "amazon" ? var.os.version : ""}-*" + # Whether to build at all. An existing_ami_id turns every build resource off. + build_ami = var.existing_ami_id == "" + # The AMI built by this module, as recorded in state. Packer runs on the first # apply and then only when packer_config.rebuild_ami_token changes, so this - # value stays stable across re-plans. - ami_id = terraform_data.ami_id.output + # value stays stable across re-plans. Null when the build was skipped. + built_ami_id = one(terraform_data.ami_id[*].output) + + # What the module reports: the AMI it was handed, otherwise the one it built. + ami_id = ( + var.existing_ami_id != "" + ? var.existing_ami_id + : (local.built_ami_id == null ? "" : local.built_ami_id) + ) } diff --git a/stackguardian_private_runner/aws/packer/main.tf b/stackguardian_private_runner/aws/packer/main.tf index fef50db..4808987 100644 --- a/stackguardian_private_runner/aws/packer/main.tf +++ b/stackguardian_private_runner/aws/packer/main.tf @@ -1,5 +1,7 @@ # Fetch the latest AMI based on the OS family and version data "aws_ami" "this" { + count = local.build_ami ? 1 : 0 + most_recent = true owners = [local.ami_owners[var.os.family]] @@ -25,6 +27,8 @@ data "aws_ami" "this" { # packer_config.rebuild_ami_token to any new value to replace this resource and # build a fresh AMI; re-plans with an unchanged token do nothing. resource "null_resource" "packer_build" { + count = local.build_ami ? 1 : 0 + provisioner "local-exec" { command = "sh ../../packer/scripts/build.sh" working_dir = path.module @@ -35,7 +39,7 @@ resource "null_resource" "packer_build" { # Packer reads PKR_VAR_ 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.id + 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 : "" @@ -69,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", @@ -84,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]] } } @@ -96,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 = { @@ -118,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/variables.tf b/stackguardian_private_runner/aws/packer/variables.tf index 8672380..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 = < 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 diff --git a/stackguardian_private_runner/azure/packer/locals.tf b/stackguardian_private_runner/azure/packer/locals.tf index 7a81055..38475cd 100644 --- a/stackguardian_private_runner/azure/packer/locals.tf +++ b/stackguardian_private_runner/azure/packer/locals.tf @@ -18,8 +18,18 @@ locals { # 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. - image_id = terraform_data.image_id.output + # 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 index 4462e54..be96627 100644 --- a/stackguardian_private_runner/azure/packer/main.tf +++ b/stackguardian_private_runner/azure/packer/main.tf @@ -20,6 +20,8 @@ resource "azurerm_resource_group" "packer" { # 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" @@ -69,6 +71,8 @@ resource "null_resource" "packer_build" { # 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", @@ -87,11 +91,13 @@ data "external" "packer_image_id" { # keeps the recorded ID untouched by later plans, even if the build log is stale # or gone. resource "terraform_data" "image_id" { - input = data.external.packer_image_id.result["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] + replace_triggered_by = [null_resource.packer_build[0]] } } @@ -100,9 +106,10 @@ resource "terraform_data" "image_id" { +-------------------------------------------*/ # # 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. +# 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 = var.packer_config.cleanup_images_on_destroy ? 1 : 0 + 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 = { @@ -121,3 +128,16 @@ resource "null_resource" "image_cleanup" { 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/variables.tf b/stackguardian_private_runner/azure/packer/variables.tf index 4c6e536..b835626 100644 --- a/stackguardian_private_runner/azure/packer/variables.tf +++ b/stackguardian_private_runner/azure/packer/variables.tf @@ -1,6 +1,25 @@ /*-------------------+ | General Variables | +-------------------*/ +variable "existing_image_id" { + description = < Date: Fri, 28 Aug 2026 11:30:10 +0200 Subject: [PATCH 31/37] SG-3995: Name runner groups from a prefix and a short suffix. The runner group and the connector were named {prefix}-runner-group-{account_id} and {prefix}-private-runner-backend-{account_id}. On Azure that spent 36 of the name's characters on a subscription ID, which told a reader nothing they could not get from the deployment itself. Both are now {global_prefix}-{name}, or just {name} when global_prefix is empty. The name half is override_names.runner_group_name; left empty it is a 6-character random string, which is all the uniqueness a runner group needs. The connector shares the runner group's name - they live in separate API namespaces (/integrations/ vs runnergroups/), so there is nothing to clash with, and the pair is always created 1:1. What used to be in the name is a tag instead. The platform models tags as a flat list of strings with no keys, capped at 10, so the shared runner_group module always sets three - "StackGuardian Private Runner", "Managed by IaC", and the cloud - and takes up to seven more from the caller. The per-cloud modules pass the account or subscription ID, the region and the naming prefix. Dropped from the tag list: the org name (a runner group only ever lives in one org) and the runner group's own name. Both were pure duplication. "Managed by IaC" is deliberately tool-agnostic - this runs under both OpenTofu and Terraform, and the tag's job is "do not hand-edit this in the console". override_names.include_org_in_prefix goes with it. It only ever fed the prefix, and the org is not in the name any more. The VM-side modules keep their own copy of the field. Azure resource names are untouched: the resource group, storage account and Entra ID application still derive from the sanitized prefix. --- .../aws/runner_group/README.md | 39 +++++++++++--- .../aws/runner_group/locals.tf | 41 ++++++++++---- .../aws/runner_group/runner_group.tf | 12 +++++ .../runner_group/schemas/input_schema.json | 46 ++++++++++------ .../aws/runner_group/schemas/ui_schema.json | 22 ++++---- .../aws/runner_group/variables.tf | 23 ++++---- .../azure/runner_group/README.md | 43 +++++++++++---- .../azure/runner_group/locals.tf | 41 ++++++++++---- .../azure/runner_group/runner_group.tf | 12 +++++ .../runner_group/schemas/input_schema.json | 54 +++++++++++++------ .../azure/runner_group/schemas/ui_schema.json | 27 ++++++---- .../azure/runner_group/variables.tf | 25 +++++---- .../runner_group/locals.tf | 23 +++++--- .../runner_group/variables.tf | 19 +++++++ 14 files changed, 311 insertions(+), 116 deletions(-) diff --git a/stackguardian_private_runner/aws/runner_group/README.md b/stackguardian_private_runner/aws/runner_group/README.md index f45b854..97b1e50 100644 --- a/stackguardian_private_runner/aws/runner_group/README.md +++ b/stackguardian_private_runner/aws/runner_group/README.md @@ -81,22 +81,45 @@ module "runner_group" { | `create_storage_backend` | Create a new S3 bucket | `true` | | `existing_s3_bucket_name` | Existing bucket name (when `create_storage_backend = false`) | `""` | | `force_destroy_storage_backend` | Force destroy the bucket on `destroy` — deletes all objects | `false` | -| `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` | -| `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 | (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 -With defaults, resources are named from `{effective_prefix}` (the `global_prefix`, -optionally suffixed with the org name) and the AWS account ID: +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: `{effective_prefix}-runner-group-{account_id}` -- Connector: `{effective_prefix}-private-runner-backend-{account_id}` +- 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: `{effective_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 | diff --git a/stackguardian_private_runner/aws/runner_group/locals.tf b/stackguardian_private_runner/aws/runner_group/locals.tf index 3c5be72..a1ff236 100644 --- a/stackguardian_private_runner/aws/runner_group/locals.tf +++ b/stackguardian_private_runner/aws/runner_group/locals.tf @@ -27,26 +27,45 @@ locals { } 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 - ) + effective_prefix = var.override_names.global_prefix - # Resource naming - runner_group_name = ( + # 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 - : "${local.effective_prefix}-runner-group-${data.aws_caller_identity.current.account_id}" + : random_string.name_suffix.result ) - connector_name = ( + runner_group_name = ( + local.effective_prefix != "" + ? "${local.effective_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.effective_prefix}-private-runner-backend-${data.aws_caller_identity.current.account_id}" + : local.runner_group_base ) + connector_name = ( + local.effective_prefix != "" + ? "${local.effective_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, + local.effective_prefix, + var.aws_region, + ]) + # S3 bucket name / ARN s3_bucket_name = ( var.create_storage_backend diff --git a/stackguardian_private_runner/aws/runner_group/runner_group.tf b/stackguardian_private_runner/aws/runner_group/runner_group.tf index a4c524b..0ee874d 100644 --- a/stackguardian_private_runner/aws/runner_group/runner_group.tf +++ b/stackguardian_private_runner/aws/runner_group/runner_group.tf @@ -4,6 +4,16 @@ # 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" @@ -14,6 +24,8 @@ module "runner_group" { connector_name = local.connector_name max_runners = var.max_runners + tags = local.platform_tags + storage_backend = { type = "aws_s3" aws = { diff --git a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json index aee9df8..5a14947 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json @@ -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": { @@ -116,7 +118,11 @@ "oneOf": [ { "properties": { - "create_storage_backend": { "enum": [true] }, + "create_storage_backend": { + "enum": [ + true + ] + }, "force_destroy_storage_backend": { "title": "Force Destroy Storage Backend", "type": "boolean", @@ -126,17 +132,25 @@ }, { "properties": { - "create_storage_backend": { "enum": [false] }, + "create_storage_backend": { + "enum": [ + false + ] + }, "existing_s3_bucket_name": { "title": "Existing S3 Bucket Name", "type": "string", "minLength": 1 } }, - "required": ["existing_s3_bucket_name"] + "required": [ + "existing_s3_bucket_name" + ] } ] } }, - "required": ["stackguardian"] + "required": [ + "stackguardian" + ] } diff --git a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json index 25ce8bb..41def6f 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json @@ -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" @@ -46,22 +50,22 @@ "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/aws/runner_group/variables.tf b/stackguardian_private_runner/aws/runner_group/variables.tf index 63dee87..2839b1f 100644 --- a/stackguardian_private_runner/aws/runner_group/variables.tf +++ b/stackguardian_private_runner/aws/runner_group/variables.tf @@ -64,18 +64,23 @@ variable "aws_region" { variable "override_names" { description = < Date: Fri, 28 Aug 2026 11:30:16 +0200 Subject: [PATCH 32/37] SG-3995: Drop create_before_destroy from the azure runner vm. It never worked. The VM name, the OS disk name and the NIC are all singular, and a NIC can only ever be attached to one VM, so Azure rejects the replacement with "a resource with the ID ... already exists" before the original is torn down - the flag turned a destroy-then-create into a failed apply. Replacements are destroy-then-create now, which means the runner is briefly offline while it is rebuilt. The comment says so, so the next reader does not put the flag back. --- stackguardian_private_runner/azure/azure_runner/vm.tf | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/stackguardian_private_runner/azure/azure_runner/vm.tf b/stackguardian_private_runner/azure/azure_runner/vm.tf index f83e4fb..665a115 100644 --- a/stackguardian_private_runner/azure/azure_runner/vm.tf +++ b/stackguardian_private_runner/azure/azure_runner/vm.tf @@ -63,7 +63,10 @@ resource "azurerm_linux_virtual_machine" "this" { Name = local.vm_name }) - lifecycle { - create_before_destroy = true - } + # No create_before_destroy here on purpose. The VM name, the OS disk name and + # the NIC are all singular, and a NIC can only ever be attached to one VM, so + # standing a replacement up alongside the original is impossible - Azure + # rejects it with "a resource with the ID ... already exists" before the + # original is ever torn down. Replacements are destroy-then-create, which + # means the runner is briefly offline while it is rebuilt. } From c3d93cd79fe0524e9bbea12bc011f81802a280c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Fri, 28 Aug 2026 11:31:16 +0200 Subject: [PATCH 33/37] SG-3995: Attach the quickstarts to an existing network. Both quickstarts used to build their own networking - the azure one created a VNet and subnet, the aws one took a bare vpc_id and public_subnet_id and assumed a public path out. Neither matches how these get deployed: the network already exists, and it is not the example's to manage. Each now takes a network block naming what to attach to, looks it up with data sources, and creates only what is bound to the instance itself - the security group or NSG, the NIC, and a public IP unless network.associate_public_ip is turned off. A destroy leaves the network exactly as it was. Two plan-time checks, because Azure and AWS both report the mistake late and badly: - the aws subnet has a postcondition that it belongs to the given VPC, which otherwise only surfaces when the instance is created. - the azure VNet has one that its region matches azure_location. A NIC can only join a subnet in its own region and a VM can only boot from an image in its own region, and Azure reports the mismatch as "InvalidResourceReference ... was not found" on the NIC, halfway through the apply and after the image build. Both also gained a way to skip the build: ami_id / vm_image_id feed the packer module's existing_ami_id / existing_image_id, so one image can back several deployments, or be pinned to a known-good build. The ami_id / image_id output reports what the runner actually booted from either way. Along the way: - ssh_command falls back to the private IP when no public IP is attached, instead of rendering "ssh user@" with nothing after it. - the azure quickstart passes sg_runner through to the image build. - override_names is destructured for the runner group module, which no longer takes include_org_in_prefix. The VM modules still do. - subnet_id is exposed as an output, so what was attached to is visible without reading the tfvars. --- .../examples/aws/quickstart/README.md | 110 ++++++++++--- .../examples/aws/quickstart/locals.tf | 15 ++ .../examples/aws/quickstart/main.tf | 63 +++++-- .../examples/aws/quickstart/outputs.tf | 16 +- .../aws/quickstart/terraform.tfvars.tpl | 43 +++-- .../examples/aws/quickstart/variables.tf | 67 ++++++-- .../examples/azure/quickstart/README.md | 155 ++++++++++++++---- .../examples/azure/quickstart/main.tf | 57 ++++++- .../examples/azure/quickstart/outputs.tf | 15 +- .../azure/quickstart/terraform.tfvars.tpl | 53 ++++-- .../examples/azure/quickstart/variables.tf | 66 +++++++- 11 files changed, 533 insertions(+), 127 deletions(-) create mode 100644 stackguardian_private_runner/examples/aws/quickstart/locals.tf diff --git a/stackguardian_private_runner/examples/aws/quickstart/README.md b/stackguardian_private_runner/examples/aws/quickstart/README.md index 20c4e0d..b61bd5c 100644 --- a/stackguardian_private_runner/examples/aws/quickstart/README.md +++ b/stackguardian_private_runner/examples/aws/quickstart/README.md @@ -46,8 +46,8 @@ hand-copying outputs between them. ▼ module.packer ┌──────────────────────────────┐ ──────────────────►│ Packer build (first apply) │ - │ • temp EC2 build instance │ - │ (auto-terminated) │ + (skipped when │ • temp EC2 build instance │ + ami_id is set) │ (auto-terminated) │ │ • custom AMI: Docker, jq, │ │ cron, sg-runner, and │ │ optional Terraform/Tofu │ @@ -76,11 +76,11 @@ runner group token, then starts polling for work. | Requirement | Notes | |-------------|-------| | **OpenTofu >= 1.6** (or Terraform >= 1.4) | The packer module uses `terraform_data` | -| **Packer** | Installed automatically by the build script at the configured version | +| **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 2480297..a2ee5e5 100644 --- a/stackguardian_private_runner/examples/aws/quickstart/main.tf +++ b/stackguardian_private_runner/examples/aws/quickstart/main.tf @@ -32,38 +32,73 @@ module "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 = < Deploying a **single runner** on a newly created VNet with a public IP. For an -> autoscaled fleet, use the `azure/vmss` and `azure/autoscaler` modules directly - -> see the [top-level README](../../../README.md). +> 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 @@ -51,8 +52,8 @@ hand-copying outputs between them. ▼ module.packer ┌──────────────────────────────┐ ──────────────────►│ Packer build (first apply) │ - │ • temp build VM + temp │ - │ networking (auto-removed) │ + (skipped when │ • temp build VM + temp │ + vm_image_id set) │ networking (auto-removed) │ │ • managed image: Docker, │ │ jq, cron, sg-runner, and │ │ optional Terraform/Tofu │ @@ -72,18 +73,23 @@ hand-copying outputs between them. module.azure_runner ┌──────────────────────────────┐ │ Runner VM │ - │ • VNet + subnet │ + │ • NIC in your existing │ + │ subnet (looked up, not │ + │ created) │ │ • NSG (all egress, no │ │ ingress unless SSH is │ │ configured) │ - │ • static public IP + NIC │ + │ • 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. +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. @@ -93,18 +99,21 @@ runner group token, then starts polling for work. | 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 | +| **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, VNets, NSGs, public IPs, managed identities + 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) @@ -120,7 +129,8 @@ cp terraform.tfvars.tpl terraform.tfvars $EDITOR terraform.tfvars ``` -At minimum you must set `stackguardian.api_key` and `stackguardian.org_name`. Set +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** @@ -162,6 +172,9 @@ minute or two of the VM booting. |----------|------|-------------| | `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`. @@ -170,17 +183,19 @@ only because `firewall.generate_ssh_key` defaults to `true`. | Variable | Default | Description | |----------|---------|-------------| -| `azure_location` | `westeurope` | Region for all Azure resources | +| `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 @@ -201,9 +216,11 @@ only because `firewall.generate_ssh_key` defaults to `true`. | `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**. +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: @@ -247,33 +264,94 @@ every apply**. A rebuild does replace it, since the VM's source image changes. > image this deployment built - on destroy, and on the rebuild that supersedes it. > Images from other deployments are never deleted. -## Networking +### Bringing your own image -This example creates a **new VNet and subnet** for the runner and attaches a static -public IP, relying on Azure's default outbound route for internet access. Packer, by -default, builds on its own throwaway VNet that it removes when the build finishes. +Set `vm_image_id` and no image is built at all: -The runner's NSG allows **all egress** and **no ingress**. SSH is opened only if you -set `firewall.ssh_access_rules`. +```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. -### Service Endpoints +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. -If you lock the storage account down to specific subnets, or you want the runner's -blob traffic to stay on the Azure backbone rather than crossing the public internet: +> **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 = { - service_endpoints = ["Microsoft.Storage"] + vnet_name = "my-vnet" + subnet_name = "runner-subnet" + resource_group_name = "my-network-rg" } ``` -These apply to the subnet this example creates. The default is `[]`, which is fine -for the default storage account configuration (`public_network_access_enabled = true` -with no network rules). +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 - a proxy-only environment, or a policy +If the build VM must sit in your network too - a proxy-only environment, or a policy that forbids ad-hoc VNets: ```hcl @@ -285,6 +363,10 @@ packer_network = { } ``` +> 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 @@ -329,11 +411,12 @@ their own. | `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 built by Packer and recorded in state | +| `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 | -| `ssh_command` | Ready-to-paste SSH command | +| `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 @@ -416,9 +499,14 @@ resources including the resource group. | 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 | @@ -434,10 +522,11 @@ tofu apply -replace=module.packer.null_resource.packer_build This example trades flexibility for a short path to a working runner: -- **Public IP only.** `create_network_infrastructure` (NAT gateway), `proxy_url`, - and attaching to an existing VNet/subnet are supported by `azure/azure_runner` but - are not exposed here. Use the module directly when you need a private subnet, NAT - gateway, or proxy. +- **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`, diff --git a/stackguardian_private_runner/examples/azure/quickstart/main.tf b/stackguardian_private_runner/examples/azure/quickstart/main.tf index e6b0bcd..9dbce46 100644 --- a/stackguardian_private_runner/examples/azure/quickstart/main.tf +++ b/stackguardian_private_runner/examples/azure/quickstart/main.tf @@ -51,7 +51,13 @@ module "runner_group" { stackguardian = var.stackguardian - 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 + } # 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. @@ -68,10 +74,16 @@ module "runner_group" { # ------------------------------------------------------- # 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 @@ -79,12 +91,14 @@ module "packer" { resource_group_name = module.runner_group.azure_resource_group_name create_resource_group = false - # Empty vnet/subnet: Packer creates and tears down its own temporary networking + # 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 } @@ -113,9 +127,36 @@ resource "azurerm_role_assignment" "storage_backend" { 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 built above, +# Deploys the private runner from the image of Module 2, # using the runner group config from Module 1 # ------------------------------------------------------- module "azure_runner" { @@ -138,12 +179,12 @@ module "azure_runner" { 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 = { - create_network = true - vnet_address_space = var.network.vnet_address_space - subnet_address_prefix = var.network.subnet_address_prefix - service_endpoints = var.network.service_endpoints - associate_public_ip = true + 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 diff --git a/stackguardian_private_runner/examples/azure/quickstart/outputs.tf b/stackguardian_private_runner/examples/azure/quickstart/outputs.tf index 4cc3dd0..99aad32 100644 --- a/stackguardian_private_runner/examples/azure/quickstart/outputs.tf +++ b/stackguardian_private_runner/examples/azure/quickstart/outputs.tf @@ -43,7 +43,7 @@ output "storage_backend_identity_principal_id" { | Image Outputs | +---------------------------------*/ output "image_id" { - description = "Managed image built by Packer and recorded in state" + 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 } @@ -75,9 +75,18 @@ output "network_security_group_id" { 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 = "Ready-to-use SSH command, once firewall.ssh_access_rules opens port 22" - value = "ssh ${var.firewall.admin_username}@${module.azure_runner.vm_public_ip}" + description = <-runner-group-" -# connector_name = "" # default: "-private-runner-backend-" +# include_org_in_prefix = false # affects the VM/NSG 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 and storage backend --- @@ -65,9 +85,24 @@ firewall = { # create_role_assignments = true # --- Optional: Image build --- +# +# Already have a runner image? Set vm_image_id and nothing below is built or +# used - no Packer, no build VM, no destroy-time image cleanup. It has to live +# in azure_location 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 image. +# vm_image_id = "/subscriptions//resourceGroups//providers/Microsoft.Compute/images/" +# # packer_vm_size = "Standard_D2s_v3" # 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 none exists. Needs a rebuild to +# take effect - bump rebuild_image_token too. +# sg_runner = { +# pre_release = false +# } +# # Packer builds the image on the first apply only. Later plans reuse it, so the # runner keeps the same image. To build a new one, change the token below to # any new value: @@ -114,15 +149,3 @@ firewall = { # Seconds to wait for Docker to come up before the VM shuts itself down. # Raise it if a custom user_script makes first boot slow. # runner_startup_timeout = 300 - -# --- Optional: Network --- -# A new VNet and subnet are created for the runner, with a public IP attached. -# -# service_endpoints routes the listed Azure services over the Azure backbone -# instead of the public internet. Add "Microsoft.Storage" if your storage -# account restricts public network access. -# network = { -# vnet_address_space = ["10.0.0.0/16"] -# subnet_address_prefix = "10.0.1.0/24" -# service_endpoints = [] -# } diff --git a/stackguardian_private_runner/examples/azure/quickstart/variables.tf b/stackguardian_private_runner/examples/azure/quickstart/variables.tf index 6db5dc5..c3cc7c0 100644 --- a/stackguardian_private_runner/examples/azure/quickstart/variables.tf +++ b/stackguardian_private_runner/examples/azure/quickstart/variables.tf @@ -80,6 +80,26 @@ variable "create_role_assignments" { /*---------------------------+ | Image Build Settings | +---------------------------*/ +variable "vm_image_id" { + description = < Date: Fri, 28 Aug 2026 11:31:35 +0200 Subject: [PATCH 34/37] SG-3995: Add image-only examples for aws and azure. The quickstarts build an image and a runner in one apply, which is the wrong shape when the image is the deliverable - one team bakes it, and several deployments boot from it. examples/aws/packer and examples/azure/packer run just the packer module. No runner group, no connector, no instance. They output the ami_id / image_id to feed into the quickstarts' new ami_id / vm_image_id, plus the cleanup commands for removing the image by hand. Both take an existing network the same way the quickstarts now do. The aws one builds in a subnet you name; the azure one defaults to Packer's own throwaway networking and takes a VNet only if the build has to sit inside yours. Neither declares a provider - the packer modules configure their own - so these are thin root modules: a module call, variables, and outputs. --- stackguardian_private_runner/README.md | 2 + .../examples/aws/packer/.gitignore | 4 + .../examples/aws/packer/README.md | 159 ++++++++++++++++ .../examples/aws/packer/main.tf | 72 +++++++ .../examples/aws/packer/outputs.tf | 19 ++ .../examples/aws/packer/terraform.tfvars.tpl | 67 +++++++ .../examples/aws/packer/variables.tf | 131 +++++++++++++ .../examples/azure/packer/.gitignore | 4 + .../examples/azure/packer/README.md | 176 ++++++++++++++++++ .../examples/azure/packer/main.tf | 47 +++++ .../examples/azure/packer/outputs.tf | 19 ++ .../azure/packer/terraform.tfvars.tpl | 69 +++++++ .../examples/azure/packer/variables.tf | 139 ++++++++++++++ 13 files changed, 908 insertions(+) create mode 100644 stackguardian_private_runner/examples/aws/packer/.gitignore create mode 100644 stackguardian_private_runner/examples/aws/packer/README.md create mode 100644 stackguardian_private_runner/examples/aws/packer/main.tf create mode 100644 stackguardian_private_runner/examples/aws/packer/outputs.tf create mode 100644 stackguardian_private_runner/examples/aws/packer/terraform.tfvars.tpl create mode 100644 stackguardian_private_runner/examples/aws/packer/variables.tf create mode 100644 stackguardian_private_runner/examples/azure/packer/.gitignore create mode 100644 stackguardian_private_runner/examples/azure/packer/README.md create mode 100644 stackguardian_private_runner/examples/azure/packer/main.tf create mode 100644 stackguardian_private_runner/examples/azure/packer/outputs.tf create mode 100644 stackguardian_private_runner/examples/azure/packer/terraform.tfvars.tpl create mode 100644 stackguardian_private_runner/examples/azure/packer/variables.tf diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index d5057e6..da16b4b 100644 --- a/stackguardian_private_runner/README.md +++ b/stackguardian_private_runner/README.md @@ -241,6 +241,8 @@ Stack overview: [azure/DOCUMENTATION.md](azure/DOCUMENTATION.md) |---------|---------| | [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 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 = < 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 = < Date: Fri, 28 Aug 2026 11:58:56 +0200 Subject: [PATCH 35/37] SG-3995: Expose the existing image inputs in the packer schemas. existing_ami_id and existing_image_id were reachable only from HCL. A nocode deployment of either packer template had no way to skip the build, even though the variable, the validation and the readme section were all there. Both are now the first field in the form, since they decide whether anything below them applies at all. The patterns allow an empty value, matching the variables: empty means build. --- .../aws/packer/schemas/input_schema.json | 6 ++++++ .../aws/packer/schemas/ui_schema.json | 5 +++++ .../azure/packer/schemas/input_schema.json | 6 ++++++ .../azure/packer/schemas/ui_schema.json | 5 +++++ 4 files changed, 22 insertions(+) diff --git a/stackguardian_private_runner/aws/packer/schemas/input_schema.json b/stackguardian_private_runner/aws/packer/schemas/input_schema.json index 666baa9..265d858 100644 --- a/stackguardian_private_runner/aws/packer/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/packer/schemas/input_schema.json @@ -1,6 +1,12 @@ { "type": "object", "properties": { + "existing_ami_id": { + "title": "Existing AMI ID", + "type": "string", + "default": "", + "pattern": "^$|^ami-.*$" + }, "aws_region": { "title": "AWS Region", "type": "string", diff --git a/stackguardian_private_runner/aws/packer/schemas/ui_schema.json b/stackguardian_private_runner/aws/packer/schemas/ui_schema.json index 1d1a701..3f8fa47 100644 --- a/stackguardian_private_runner/aws/packer/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/packer/schemas/ui_schema.json @@ -2,6 +2,7 @@ "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", @@ -12,6 +13,10 @@ "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" diff --git a/stackguardian_private_runner/azure/packer/schemas/input_schema.json b/stackguardian_private_runner/azure/packer/schemas/input_schema.json index 17b0647..5086ce5 100644 --- a/stackguardian_private_runner/azure/packer/schemas/input_schema.json +++ b/stackguardian_private_runner/azure/packer/schemas/input_schema.json @@ -1,6 +1,12 @@ { "type": "object", "properties": { + "existing_image_id": { + "title": "Existing Image ID", + "type": "string", + "default": "", + "pattern": "^$|^/subscriptions/.*" + }, "azure_location": { "title": "Azure Location", "type": "string", diff --git a/stackguardian_private_runner/azure/packer/schemas/ui_schema.json b/stackguardian_private_runner/azure/packer/schemas/ui_schema.json index cc09812..00c8551 100644 --- a/stackguardian_private_runner/azure/packer/schemas/ui_schema.json +++ b/stackguardian_private_runner/azure/packer/schemas/ui_schema.json @@ -1,5 +1,6 @@ { "ui:order": [ + "existing_image_id", "azure_location", "resource_group_name", "create_resource_group", @@ -12,6 +13,10 @@ "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" From 68ca913b813c17c376ec32911fd3424b5c8a2a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Fri, 28 Aug 2026 12:01:11 +0200 Subject: [PATCH 36/37] SG-3995: Realign template docs with the schemas. DOCUMENTATION.md is what a user reads while filling the nocode form, so it has to track the schema. Three changes moved out from under it. Runner group naming. Both runner_group docs still listed *Include Organization Name in Prefix*, a field that no longer exists, and described the other two as "Overrides" defaulting to "Auto-generated". They are now the name half of {prefix}-{name}, defaulting to a 6-char random string and to the runner group's own name. The Resource Naming notes still claimed SG_RUNNER-{type}-{account_id} / {subscription_id}. Tags. Both overviews claimed the default tags were "StackGuardian Private Runner", the runner group name, and the organization name. The last two were dropped deliberately; what is applied now is the purpose marker, "Managed by IaC", the cloud, the account or subscription ID, the prefix and the region. Each doc gained a Tags note saying so, and saying why the org and the group's own name are absent. New packer inputs. existing_ami_id, existing_image_id, ami_name_prefix and sg_runner.pre_release were all undocumented. Each is in its module's table and in the matching stack table, and both packer docs gained a Skipping the Build note - including the warning about setting it on a deployment that already built an image, where the destroy-time cleanup would delete the very image being passed in. The include_org_in_prefix rows under Template 3 and Template 4 stay: the autoscaling group, VMSS and autoscaler modules still take it. --- .../aws/DOCUMENTATION.md | 13 +++++--- .../aws/packer/DOCUMENTATION.md | 4 +++ .../aws/runner_group/DOCUMENTATION.md | 30 ++++++++++++------ .../azure/DOCUMENTATION.md | 13 +++++--- .../azure/packer/DOCUMENTATION.md | 6 ++++ .../azure/runner_group/DOCUMENTATION.md | 31 ++++++++++++------- 6 files changed, 68 insertions(+), 29 deletions(-) diff --git a/stackguardian_private_runner/aws/DOCUMENTATION.md b/stackguardian_private_runner/aws/DOCUMENTATION.md index 6aa5610..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 | `""` | @@ -88,12 +90,15 @@ Create a StackGuardian Runner Group with S3 storage backend and AWS connector. T | 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 | diff --git a/stackguardian_private_runner/aws/packer/DOCUMENTATION.md b/stackguardian_private_runner/aws/packer/DOCUMENTATION.md index 8e6aca5..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 | @@ -48,6 +49,7 @@ Before deploying this template: | 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,6 +69,8 @@ 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. 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. diff --git a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md index f72fa08..66075aa 100644 --- a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md @@ -7,9 +7,8 @@ StackGuardian platform. This template provisions everything required to run private runners on AWS: a runner group on the StackGuardian platform, a private S3 bucket for workflow artifacts, and a -cross-account IAM role that lets StackGuardian and your runners reach it. Default tags -("StackGuardian Private Runner", the runner group name, and the organization name) are -applied automatically to the StackGuardian resources. +cross-account IAM role that lets StackGuardian and your runners reach it. Tags are +applied automatically to the StackGuardian resources — see **Tags** below. Deploying on Azure instead? Use the **StackGuardian Runner Group - Azure** template. @@ -46,10 +45,9 @@ Deploying on Azure instead? Use the **StackGuardian Runner Group - Azure** templ | Create Storage Backend | Whether to create a new S3 bucket | Enabled | | Existing S3 Bucket Name | Name of an existing S3 bucket to use (when not creating new) | — | | Force Destroy Storage Backend | Delete all data in the S3 bucket on destroy (use with caution) | Disabled | -| Global Prefix | Prefix used for naming all resources | SG_RUNNER | -| Include Organization Name in Prefix | Append the org name to the prefix (e.g. `SG_RUNNER_demo-org`) | Disabled | -| Runner Group Name Override | Custom name for the runner group | Auto-generated | -| Connector Name Override | Custom name for the AWS connector | Auto-generated | +| Global Prefix | Prefix for the runner group and connector names. Leave empty to omit it | SG_RUNNER | +| Runner Group Name | Name half of the runner group; the full name is `{prefix}-{name}` | 6-character random string | +| Connector Name | Name half of the connector | Same as the runner group | | Maximum Runners | Maximum number of runners allowed in the group | 3 | ## Important Notes @@ -62,9 +60,21 @@ a `${secret::SECRET_NAME}` reference. an existing one. When using an existing bucket, ensure it has the appropriate permissions and CORS configuration. -**Resource Naming**: By default, resources use the pattern -`SG_RUNNER-{type}-{account_id}`. Customize via the naming options if you need stable, -project-specific names. +**Resource Naming**: The runner group and the connector are named `{prefix}-{name}`, or +just `{name}` when **Global Prefix** is empty. Leave **Runner Group Name** empty and the +name half is a 6-character random string, which is all the uniqueness a runner group +needs. Set it when you want a stable, project-specific name. The connector shares the +runner group's name — they live in separate API namespaces, so there is nothing to clash +with. The AWS resources keep their own scheme: the IAM role is +`{prefix}-private-runner-s3-role` and the S3 bucket is +`{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. The runner group and the connector both get `StackGuardian Private Runner`, +`Managed by IaC`, `aws`, the AWS account ID, the **Global Prefix**, and the region. The +account ID is a tag rather than part of the name. The organization name and the runner +group's own name are deliberately not tagged: a runner group only ever lives in one org, +and its name is not information a tag adds. **Data Retention**: **Force Destroy Storage Backend** deletes all bucket contents on destroy. Leave it disabled to protect your data. diff --git a/stackguardian_private_runner/azure/DOCUMENTATION.md b/stackguardian_private_runner/azure/DOCUMENTATION.md index a35b1d1..8dfb17c 100644 --- a/stackguardian_private_runner/azure/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/DOCUMENTATION.md @@ -44,6 +44,7 @@ The image is built **once**. Packer runs on the first apply, the resulting image | 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` | @@ -65,6 +66,7 @@ The image is built **once**. Packer runs on the first apply, the resulting image | 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). @@ -104,12 +106,15 @@ Create a StackGuardian Runner Group with an Azure Blob Storage backend and an En | 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 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 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 | diff --git a/stackguardian_private_runner/azure/packer/DOCUMENTATION.md b/stackguardian_private_runner/azure/packer/DOCUMENTATION.md index 46c6d3e..e76fc6b 100644 --- a/stackguardian_private_runner/azure/packer/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/packer/DOCUMENTATION.md @@ -30,6 +30,7 @@ This template produces a reusable Azure managed image so your private runners bo | Parameter | Description | Default | |-----------|-------------|---------| +| `existing_image_id` | Hand back a managed image you already have instead of building one. When set, nothing is built and every other parameter below is ignored | `""` | | `azure_location` | The target Azure region to build the Private Runner image | `westeurope` | | `create_resource_group` | Create the resource group as part of this deployment. If disabled, it must already exist. | `false` | | `vm_size` | The Azure VM size used by Packer during the build (min 2 vCPU, 4GB RAM recommended) | `Standard_D2s_v3` | @@ -51,9 +52,14 @@ This template produces a reusable Azure managed image so your private runners bo | `terraform.additional_versions` | Extra Terraform versions to install alongside the primary version | `[]` | | `opentofu.primary_version` | Default OpenTofu version available on the runner (leave empty to skip) | `""` | | `opentofu.additional_versions` | Extra OpenTofu versions to install alongside the primary version | `[]` | +| `sg_runner.pre_release` | Bake the newest sg-runner pre-release into the image instead of the latest stable release (falls back to stable when none is published) | `false` | ## Important Notes +**Skipping the Build**: Set `existing_image_id` to reuse an image you already have. The template then creates nothing at all — no build VM, no Packer download, no cleanup on destroy — and reports that image as its `image_id` output. The image must live in `azure_location` and already carry Docker, cron, jq and sg-runner; nothing is checked before the VM tries to boot from it. Set it on a fresh deployment: adding it to one that already built an image tears down the build records, and the cleanup deletes the image that was built. + +**sg-runner Release Channel**: The image installs the latest stable sg-runner release by default. Set `sg_runner.pre_release` to bake in the newest pre-release instead — useful for validating upcoming runner changes, not recommended for production. If no pre-release is published, the build falls back to the latest stable release. On an existing deployment the change only takes effect once a new image is built, so set `packer_config.rebuild_image_token` to a new value as well. + **Image Reuse**: The image is built on the first deployment only. Its resource 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 image — after changing the OS, the user script, or the Terraform/OpenTofu versions — set *Rebuild Image Token* to any new value. Leaving the token unchanged never rebuilds. **Cleanup on destroy**: When `cleanup_images_on_destroy` is left at its default (`true`), destroying the workflow deletes the image this deployment built, and a rebuild deletes the image it supersedes. Images built by other deployments are never touched. Disable it only if you need the image to survive workflow teardown — orphaned images will accumulate in the resource group otherwise. diff --git a/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md index 5f58734..0a08f29 100644 --- a/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/azure/runner_group/DOCUMENTATION.md @@ -8,9 +8,8 @@ StackGuardian platform. This template provisions everything required to run private runners on Azure: a runner group on the StackGuardian platform, a resource group and storage account for workflow artifacts, and an Entra ID identity that lets StackGuardian reach them over OIDC — no -long-lived secret is stored on the platform. Default tags ("StackGuardian Private -Runner", the runner group name, and the organization name) are applied automatically to -the StackGuardian resources. +long-lived secret is stored on the platform. Tags are applied automatically to the +StackGuardian resources — see **Tags** below. Deploying on AWS instead? Use the **StackGuardian Runner Group - AWS** template. @@ -62,10 +61,9 @@ required. | Azure Storage — Replication Type | Replication strategy (LRS / GRS / RAGRS / ZRS) | LRS | | Existing Azure Storage Account Name | Name of an existing Storage Account to use (when not creating new) | — | | Existing Azure Storage Account Access Key | Access key for that account (sensitive) | — | -| Global Prefix | Prefix used for naming all resources | SG_RUNNER | -| Include Organization Name in Prefix | Append the org name to the prefix (e.g. `SG_RUNNER_demo-org`) | Disabled | -| Runner Group Name Override | Custom name for the runner group | Auto-generated | -| Connector Name Override | Custom name for the Azure connector | Auto-generated | +| Global Prefix | Prefix for the runner group and connector names. Leave empty to omit it | SG_RUNNER | +| Runner Group Name | Name half of the runner group; the full name is `{prefix}-{name}` | 6-character random string | +| Connector Name | Name half of the connector | Same as the runner group | | Maximum Runners | Maximum number of runners allowed in the group | 3 | ## Important Notes @@ -89,10 +87,21 @@ a `${secret::SECRET_NAME}` reference. point to an existing one. When using an existing account, ensure it has the appropriate permissions and CORS configuration, and a private container named `runner`. -**Resource Naming**: By default, resources use the pattern -`SG_RUNNER-{type}-{subscription_id}`. Azure naming rules force some sanitization — the -prefix is lowercased and underscores become dashes, and the storage account name is -truncated to fit the 24-character global limit. +**Resource Naming**: The runner group and the connector are named `{prefix}-{name}`, or +just `{name}` when **Global Prefix** is empty. Leave **Runner Group Name** empty and the +name half is a 6-character random string, which is all the uniqueness a runner group +needs. Set it when you want a stable, project-specific name. The connector shares the +runner group's name — they live in separate API namespaces, so there is nothing to clash +with. The subscription ID used to sit in these names and cost 36 characters; it is a tag +now. The Azure resources keep their own scheme, and Azure naming rules force some +sanitization — the prefix is lowercased and underscores become dashes, and the storage +account name is truncated to fit the 24-character global limit. + +**Tags**: The platform models tags as a flat list of strings — there are no keys — capped +at 10. The runner group and the connector both get `StackGuardian Private Runner`, +`Managed by IaC`, `azure`, the subscription ID, the **Global Prefix**, and the region. The +organization name and the runner group's own name are deliberately not tagged: a runner +group only ever lives in one org, and its name is not information a tag adds. **Data Retention**: The Azure Storage Account is destroyed along with its contents on `terraform destroy` — back up anything you need first. From 14d86bdeb6c8c6c6268723cf6f639a8c96dbb536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adis=20Halilovi=C4=87?= Date: Fri, 28 Aug 2026 12:04:30 +0200 Subject: [PATCH 37/37] SG-3995: Collapse effective_prefix in the runner group modules. Dropping include_org_in_prefix left it as effective_prefix = var.override_names.global_prefix - an alias that computed nothing and, worse, kept promising it did. "Effective" meant "the prefix after the org name is folded in", and nothing folds in any more. Both modules now read var.override_names.global_prefix directly, which is longer at the call site but says where the value comes from without a trip through locals.tf. Purely mechanical: the local was a one-line alias, so every name produced is byte-for-byte what it was. The other six modules keep their effective_prefix - autoscaling_group, autoscaler, single_runner, vmss, azure_runner and the azure autoscaler still take include_org_in_prefix, so theirs is a real computation. Also fixes the comment above azure's sanitized_prefix, which claimed the org name flowed into Azure resource names. --- .../aws/runner_group/README.md | 2 +- .../aws/runner_group/locals.tf | 12 +++++------- .../aws/runner_group/storage_backend_role.tf | 4 ++-- .../azure/runner_group/README.md | 2 +- .../azure/runner_group/connector_identity.tf | 4 ++-- .../azure/runner_group/locals.tf | 16 +++++++--------- .../azure/runner_group/variables.tf | 2 +- 7 files changed, 19 insertions(+), 23 deletions(-) diff --git a/stackguardian_private_runner/aws/runner_group/README.md b/stackguardian_private_runner/aws/runner_group/README.md index 97b1e50..2e7e971 100644 --- a/stackguardian_private_runner/aws/runner_group/README.md +++ b/stackguardian_private_runner/aws/runner_group/README.md @@ -99,7 +99,7 @@ which is all the uniqueness a runner group needs. The account ID is **not** in the name — it is a tag. AWS resources keep their own scheme: -- IAM role: `{effective_prefix}-private-runner-s3-role` +- IAM role: `{global_prefix}-private-runner-s3-role` - S3 bucket: `{8-char-random}-private-runner-storage-backend` diff --git a/stackguardian_private_runner/aws/runner_group/locals.tf b/stackguardian_private_runner/aws/runner_group/locals.tf index a1ff236..439f643 100644 --- a/stackguardian_private_runner/aws/runner_group/locals.tf +++ b/stackguardian_private_runner/aws/runner_group/locals.tf @@ -27,8 +27,6 @@ locals { } sg_app_uri = local.sg_app_uris[local.sg_api_uri] - effective_prefix = var.override_names.global_prefix - # 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 - @@ -40,8 +38,8 @@ locals { ) runner_group_name = ( - local.effective_prefix != "" - ? "${local.effective_prefix}-${local.runner_group_base}" + var.override_names.global_prefix != "" + ? "${var.override_names.global_prefix}-${local.runner_group_base}" : local.runner_group_base ) @@ -54,15 +52,15 @@ locals { ) connector_name = ( - local.effective_prefix != "" - ? "${local.effective_prefix}-${local.connector_base}" + 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, - local.effective_prefix, + var.override_names.global_prefix, var.aws_region, ]) diff --git a/stackguardian_private_runner/aws/runner_group/storage_backend_role.tf b/stackguardian_private_runner/aws/runner_group/storage_backend_role.tf index 96e776e..8df52b1 100644 --- a/stackguardian_private_runner/aws/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" @@ -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/azure/runner_group/README.md b/stackguardian_private_runner/azure/runner_group/README.md index 9e98f7f..dca826c 100644 --- a/stackguardian_private_runner/azure/runner_group/README.md +++ b/stackguardian_private_runner/azure/runner_group/README.md @@ -127,7 +127,7 @@ Azure resources keep their own scheme, since Azure naming rules force sanitizati - 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: `{effective_prefix}-sg-connector` +- Entra ID application: `{global_prefix}-sg-connector` ### Tags diff --git a/stackguardian_private_runner/azure/runner_group/connector_identity.tf b/stackguardian_private_runner/azure/runner_group/connector_identity.tf index 1e464c3..782d25a 100644 --- a/stackguardian_private_runner/azure/runner_group/connector_identity.tf +++ b/stackguardian_private_runner/azure/runner_group/connector_identity.tf @@ -2,7 +2,7 @@ # The connector itself is created by the shared runner_group module from these IDs. resource "azuread_application" "connector" { - display_name = "${local.effective_prefix}-sg-connector" + display_name = "${var.override_names.global_prefix}-sg-connector" owners = [data.azurerm_client_config.current.object_id] } @@ -15,7 +15,7 @@ resource "azuread_service_principal" "connector" { resource "azuread_application_federated_identity_credential" "connector" { application_id = azuread_application.connector.id - display_name = "${local.effective_prefix}-sg-oidc" + 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] diff --git a/stackguardian_private_runner/azure/runner_group/locals.tf b/stackguardian_private_runner/azure/runner_group/locals.tf index 967bba1..f381204 100644 --- a/stackguardian_private_runner/azure/runner_group/locals.tf +++ b/stackguardian_private_runner/azure/runner_group/locals.tf @@ -29,8 +29,6 @@ locals { subscription_id = data.azurerm_client_config.current.subscription_id - effective_prefix = var.override_names.global_prefix - # 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 @@ -42,8 +40,8 @@ locals { ) runner_group_name = ( - local.effective_prefix != "" - ? "${local.effective_prefix}-${local.runner_group_base}" + var.override_names.global_prefix != "" + ? "${var.override_names.global_prefix}-${local.runner_group_base}" : local.runner_group_base ) @@ -56,20 +54,20 @@ locals { ) connector_name = ( - local.effective_prefix != "" - ? "${local.effective_prefix}-${local.connector_base}" + 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, - local.effective_prefix, + var.override_names.global_prefix, var.azure_location, ]) - # Azure storage locals — derive from effective_prefix so org name flows into resource names - sanitized_prefix = replace(lower(local.effective_prefix), "_", "-") + # 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) diff --git a/stackguardian_private_runner/azure/runner_group/variables.tf b/stackguardian_private_runner/azure/runner_group/variables.tf index 3d9526d..17b9b04 100644 --- a/stackguardian_private_runner/azure/runner_group/variables.tf +++ b/stackguardian_private_runner/azure/runner_group/variables.tf @@ -72,7 +72,7 @@ variable "azure_resource_group_name" { description = <