diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 80af872e42..aa6e2b505d 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -72,6 +72,7 @@ jobs: - ephemeral - multi-runner - multi-runner-v2 + - multi-runner-scale-set - migration-test - termination-watcher services: diff --git a/README.md b/README.md index 9f5c9041b1..2b567bb13a 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | diff --git a/docs/multi-runner-v1-to-v2-configuration.md b/docs/multi-runner-v1-to-v2-configuration.md deleted file mode 100644 index ab968a3960..0000000000 --- a/docs/multi-runner-v1-to-v2-configuration.md +++ /dev/null @@ -1,311 +0,0 @@ -# Migrate the multi-runner configuration from v1 to v2 - -Multi-runner v2 changes the configuration contract so that shared defaults, -runner behavior, orchestration, and compute settings have clear ownership. -The v2 contract is nested and provider-oriented; it is not a rename of every -flat v1 variable. - -This page explains how to translate the configuration. Moving the existing -Terraform state is a separate step. After updating the configuration, follow -the [v1-to-v2 state migration runbook](multi-runner-v1-v2-migration.md) before -applying v2 to an existing deployment. - -## What changes in v2 - -The v1 interface spreads shared settings across module inputs and puts most -per-runner settings inside `multi_runner_config..runner_config`. - -The v2 interface has two levels: - -1. `global_config*` variables hold defaults shared by all runner lanes. -2. `multi_runner_config.` holds a lane's overrides and selects exactly - one orchestration provider and one compute provider. - -The current providers are: - -- `orchestration_provider.webhook` -- `compute_provider.aws.ec2` - -Global provider blocks supply defaults and shared settings. They do not select -the provider for a lane. Provider selection belongs inside each -`multi_runner_config.` entry. - -## Complete migration example - -The repository includes a complete side-by-side migration example in -[`examples/migration-test`](../examples/migration-test/). It keeps the same -lane key, resource prefix, and state backend while showing the v1 and v2 -configuration as sibling directories: - -- [v1 `main.tf`](../examples/migration-test/v1/main.tf) and [v2 `main.tf`](../examples/migration-test/v2/main.tf) -- [v1 `variables.tf`](../examples/migration-test/v1/variables.tf) and [v2 `variables.tf`](../examples/migration-test/v2/variables.tf) -- [v1 `v1.tfvars`](../examples/migration-test/v1/v1.tfvars) and [v2 `v2.tfvars`](../examples/migration-test/v2/v2.tfvars) -- [v1 `providers.tf`](../examples/migration-test/v1/providers.tf) and [v2 `providers.tf`](../examples/migration-test/v2/providers.tf) -- [migration test README](../examples/migration-test/README.md) - -Open the two `main.tf` files next to each other to see the configuration -translation: v1 uses the legacy flat `runner_config` shape, while v2 uses -global defaults plus nested runner, orchestration, and compute-provider -blocks. The example also shows the corresponding v1 and v2 variable files and -keeps the lane name (for example, `large`) unchanged. Use these files as a -reference when adapting an existing deployment; do not apply both directories -to the same deployment at the same time. After updating the real configuration, -run the [state migration procedure](multi-runner-v1-v2-migration.md) before -the first v2 plan. - -## Global configuration - -Use the following global variables for settings shared by multiple lanes: - -| v2 variable | Owns | -| --- | --- | -| `global_config` | Common tags, IAM role defaults, and runner identity defaults | -| `global_config_github` | Primary and additional GitHub Apps, GitHub Enterprise Server, and user agent | -| `global_config_lambda` | Lambda runtime, architecture, artifacts, networking, principals, and role defaults | -| `global_config_orchestration_provider` | Webhook defaults, queues, EventBridge, webhook Lambdas, pool, and scale settings | -| `global_config_ssm` | SSM paths, KMS key, parameter tags, and SSM housekeeper | -| `global_config_observability` | Logs, tracing, and metrics | -| `global_config_compute_provider` | Provider defaults such as EC2 networking, AMI housekeeping, runner binaries, and termination watching | - -For example, shared GitHub, runner, observability, webhook, and EC2 defaults -are configured like this: - -```hcl -experimental_features = ["multi-runner-v2"] - -global_config = { - tags = { - Environment = "production" - } - - runner = { - os = "linux" - architecture = "x64" - extra_labels = ["self-hosted"] - } -} - -global_config_github = { - app = { - id = var.github_app.id - key_base64 = var.github_app.key_base64 - webhook_secret = var.github_webhook_secret - } -} - -global_config_observability = { - logs = { - retention_in_days = 30 - } - metrics = { - enabled = true - } -} - -global_config_orchestration_provider = { - webhook = { - eventbridge = { - enabled = true - accept_events = ["workflow_job"] - } - } -} - -global_config_compute_provider = { - aws = { - ec2 = { - vpc_id = module.network.vpc_id - subnet_ids = module.network.private_subnet_ids - } - } -} -``` - -The other `global_config_*` variables follow the same ownership model. Put a -value in the global block when it is common to all lanes; put an override in a -lane only when that lane needs a different value. - -## Per-lane configuration - -Each v2 lane is keyed by the same logical runner name used in v1, but its -settings use canonical nested blocks: - -```hcl -multi_runner_config = { - large = { - runner = { - name_prefix = "large-" - extra_labels = ["large"] - } - - orchestration_provider = { - webhook = { - runner = { - ephemeral = true - maximum_count = 10 - } - - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "large"]] - } - - job_retry = { - enabled = true - } - } - } - - compute_provider = { - aws = { - ec2 = { - instance_types = ["m6i.large"] - } - } - } - } -} -``` - -Values are resolved as: - -```text -lane override > global default > provider/module default -``` - -Tags merge at the same levels. A lane override changes only that lane; it does -not change shared singleton resources or defaults for other lanes. - -## Common v1-to-v2 translations - -The following table covers the most common settings. The exact v1 path can be -either a root module variable or an attribute under -`multi_runner_config..runner_config`. - -| v1 setting | v2 setting | -| --- | --- | -| `github_app` | `global_config_github.app` | -| `additional_github_apps` | `global_config_github.additional_apps` | -| `vpc_id` and `subnet_ids` | `global_config_compute_provider.aws.ec2.vpc_id` and `.subnet_ids` | -| `runner_os` / `runner_config.runner_os` | `global_config.runner.os` or lane `runner.os` | -| `runner_architecture` / `runner_config.runner_architecture` | `global_config.runner.architecture` or lane `runner.architecture` | -| `runner_extra_labels` | `global_config.runner.extra_labels` or lane `runner.extra_labels` | -| `runner_group_name` | `global_config.runner.group_name` or lane `runner.group_name` | -| `runner_name_prefix` | `global_config.runner.name_prefix` or lane `runner.name_prefix` | -| `runner_as_root` / `runner_run_as` | `global_config.runner.run_as_root` / `.run_as` | -| `runner_hook_job_started` / `runner_hook_job_completed` | `global_config.runner.hooks.job_started` / `.job_completed` | -| `runner_iam_role_managed_policy_arns` | `global_config.runner.iam.managed_policy_arns` | -| `runner_metadata_options` | lane `compute_provider.aws.ec2.metadata_options` | -| `runner_ec2_tags` | lane `compute_provider.aws.ec2.tags` | -| `instance_types` | lane `compute_provider.aws.ec2.instance_types` | -| `ami` | lane `compute_provider.aws.ec2.ami` | -| `block_device_mappings` | lane `compute_provider.aws.ec2.block_device_mappings` | -| `enable_ephemeral_runners` | `orchestration_provider.webhook.runner.ephemeral` | -| `enable_jit_config` | `orchestration_provider.webhook.runner.jit_config_enabled` | -| `runners_maximum_count` | `orchestration_provider.webhook.runner.maximum_count` | -| `runner_boot_time_in_minutes` | `orchestration_provider.webhook.runner.boot_time_in_minutes` | -| `runner_matcher_config` / `matcherConfig` | `orchestration_provider.webhook.matcherConfig` | -| `pool_config` | `orchestration_provider.webhook.lambda.pool.config` | -| `job_retry` | `orchestration_provider.webhook.job_retry` | -| `scale_down_idle_confirmation_seconds` | `orchestration_provider.webhook.lambda.scale.down.idle_confirmation_seconds` | -| `idle_config` | `orchestration_provider.webhook.lambda.scale.down.idle_config` | -| `enable_cloudwatch_agent` and `cloudwatch_config` | lane `compute_provider.aws.ec2.cloudwatch_agent` | -| `enable_runner_binaries_syncer` | `global_config_compute_provider.aws.ec2.runner_binaries.enabled` or lane `compute_provider.aws.ec2.binaries_syncer.enabled` | -| `enable_ami_housekeeper` and related settings | `global_config_compute_provider.aws.ec2.ami.housekeeper` | -| termination watcher settings | `global_config_compute_provider.aws.ec2.instance_termination_watcher` | -| `log_level`, `log_class`, `logging_retention_in_days`, and tracing/metrics settings | `global_config_observability` | - -Settings that are specific to one lane should remain in that lane instead of -being copied into a global block. - -## Legacy and deprecated inputs - -The following shapes belong to the stable v1 interface and should not be used -for new v2 configuration: - -- Root-level flat runner, webhook, pool, retry, SSM, logging, and EC2 inputs. -- `github_app` and `additional_github_apps` instead of - `global_config_github`. -- `multi_runner_config..runner_config` and its flat attributes. -- v1 names such as `enable_ephemeral_runners`, `enable_jit_config`, - `runners_maximum_count`, `pool_config`, and `job_retry` when they are used - in the old location. - -These legacy inputs remain available for v1 compatibility while the v2 -interface is experimental. They are migration sources, not aliases that -should be mixed into a v2 lane. When v2 is enabled, a lane containing the -legacy `runner_config` object is rejected during validation. - -The v2 names use canonical ownership and enablement conventions. For example: - -```hcl -# v1 -multi_runner_config = { - large = { - runner_config = { - enable_ephemeral_runners = true - enable_jit_config = true - runners_maximum_count = 10 - instance_types = ["m6i.large"] - } - } -} - -# v2 -multi_runner_config = { - large = { - orchestration_provider = { - webhook = { - runner = { - ephemeral = true - jit_config_enabled = true - maximum_count = 10 - } - } - } - compute_provider = { - aws = { - ec2 = { - instance_types = ["m6i.large"] - } - } - } - } -} -``` - -## Feature gate and validation - -V2 is selected only by explicitly setting: - -```hcl -experimental_features = ["multi-runner-v2"] -``` - -When the feature is enabled, Terraform validates that: - -- The v2 GitHub App is complete under `global_config_github.app`. -- Every lane has a webhook orchestration provider and an AWS EC2 compute - provider. -- Each lane has EC2 instance types, a VPC, and at least one subnet. -- No lane uses the legacy `runner_config` object. - -Do not enable the feature flag until the configuration has been converted and -the state migration has been planned. The feature flag changes which module -resources are selected; it does not move existing state by itself. - -## Recommended migration order - -1. Copy the v1 configuration and convert it to the v2 nested contract. -2. Keep the same lane keys, resource prefix, AWS account, region, backend, and - root module address. -3. Validate the v2 configuration without applying it. -4. Use the [state migration runbook](multi-runner-v1-v2-migration.md) to back - up the v1 state and move the state addresses. -5. Enable `experimental_features = ["multi-runner-v2"]` and run a v2 plan. -6. Review the plan for unexpected replacements or resource recreation. -7. Apply v2 and run a second plan to confirm that it is empty. - -Changing the input names without moving state leaves Terraform unable to match -the old v1 addresses to the v2 resources. Conversely, moving state without -converting the configuration leaves the v2 validation and provider contract -unsatisfied. diff --git a/docs/multi-runner-v1-v2-migration.md b/docs/multi-runner-v1-v2-migration.md deleted file mode 100644 index 9671613d90..0000000000 --- a/docs/multi-runner-v1-v2-migration.md +++ /dev/null @@ -1,163 +0,0 @@ -# Migrate multi-runner v1 state to v2 - -The `scripts/migrate_multi_runner_state.py` utility moves Terraform state -addresses from the multi-runner v1 module layout to the v2 -`runner_configs` layout. It does not create, destroy, or modify AWS -resources. It runs the appropriate `state mv` commands so Terraform or -OpenTofu continues managing the existing resources after the configuration is -changed to v2. - -This procedure is intended for an existing deployment that uses the -multi-runner v1 configuration. - -## Before you start - -- Use the migration script from the same repository revision as the v2 module - configuration you will deploy. -- Schedule a maintenance window and make sure no other Terraform, OpenTofu, - or Terragrunt operation is running against the state. -- Confirm that the v1 configuration is initialized against the production - backend and that the backend configuration will remain the same during the - migration. -- Confirm that every v1 runner configuration is represented in the current - state. The script discovers dynamic keys from addresses such as - `module.runners["large"]`; it does not require the keys to be entered on the - command line. -- Ensure the identity running the command can read and update the state and - can acquire the backend lock. - -The state backup can contain secrets. Store it in a protected location with -encryption and access controls. The script refuses to overwrite an existing -backup path and writes a newly created backup with mode `0600`. - -## 1. Plan the migration - -Run the normal v1 plan first and confirm that it is understood and safe: - -```sh -terraform -chdir="/path/to/terraform-root" init -terraform -chdir="/path/to/terraform-root" plan -``` - -Run the migration script without `--apply` to produce a dry-run mapping: - -```sh -python3 /path/to/terraform-aws-github-runner/scripts/migrate_multi_runner_state.py \ - --working-directory /path/to/terraform-root \ - --tool terraform -``` - -Review every `source -> target` pair. The script reports the number of runner -keys, mappings, and generated moves. It exits without changing state unless -`--apply` is supplied. - -For OpenTofu, use `--tool tofu` and run the equivalent `tofu` commands. For a -Terragrunt-managed root, use `--tool terragrunt` and the directory containing -the Terragrunt configuration. - -## 2. Apply the state moves - -After reviewing the dry-run output, run the same command with a new backup -path and `--apply`: - -```sh -python3 /path/to/terraform-aws-github-runner/scripts/migrate_multi_runner_state.py \ - --working-directory /path/to/terraform-root \ - --tool terraform \ - --backup /path/to/protected-backups/multi-runner-v1-before-state-migration.tfstate \ - --apply -``` - -Without `--yes`, the script asks for the exact confirmation word `move`. -For an already reviewed, non-interactive run, add `--yes`: - -```sh -python3 /path/to/terraform-aws-github-runner/scripts/migrate_multi_runner_state.py \ - --working-directory /path/to/terraform-root \ - --tool terraform \ - --backup /path/to/protected-backups/multi-runner-v1-before-state-migration.tfstate \ - --apply \ - --yes -``` - -The backup is taken with ` state pull` immediately before the first -move. State moves are executed one at a time. If a move fails, the script -stops and reports that migration is incomplete; do not blindly rerun it. -Inspect the state and the backup first. - -The script refuses to continue if a destination address already exists. This -protects against overwriting an existing v2 state object. - -## 3. Switch the configuration to v2 - -After the state move completes, update the root module configuration to the -v2 contract while keeping the same state backend and root module address. Set -the explicit feature flag: - -```hcl -experimental_features = ["multi-runner-v2"] -``` - -Use the v2 `runner_configs` configuration and remove the v1-only configuration -from the root module. Then initialize and plan from the same working -directory: - -```sh -terraform -chdir="/path/to/terraform-root" init -terraform -chdir="/path/to/terraform-root" plan -detailed-exitcode -``` - -The plan should not propose destroying and recreating resources solely because -their module addresses changed. Review any remaining changes carefully; state -migration does not suppress genuine configuration changes, provider drift, or -backend changes. Apply only after the plan is understood: - -```sh -terraform -chdir="/path/to/terraform-root" apply -``` - -Run a second plan and expect exit code `0` for no changes: - -```sh -terraform -chdir="/path/to/terraform-root" plan -detailed-exitcode -``` - -Use the equivalent `tofu` or `terragrunt` commands when those tools manage -the deployment. - -!!! warning - - A backend configuration change is a separate operation. If initialization - reports that the backend changed, stop and resolve the backend migration - deliberately before running state moves. `init -migrate-state` does not - replace the v1-to-v2 address migration performed by this script. - -## Recovery - -If the migration stops part-way through or the post-migration plan is not -acceptable, stop further applies and preserve the current state for -investigation. The backup passed to `--backup` is a snapshot from before the -first move. Restoring it is an operator decision because `state push` can -replace the current remote state: - -```sh -terraform -chdir="/path/to/terraform-root" state push \ - /path/to/protected-backups/multi-runner-v1-before-state-migration.tfstate -``` - -Only restore after confirming the backup is the intended state, no newer -changes must be retained, and the backend is locked. Use `tofu state push` or -`terragrunt state push` for those tools. After a restore, return to the v1 -configuration before planning again. - -## Command reference - -```text -python3 scripts/migrate_multi_runner_state.py [options] - ---working-directory PATH State working directory (default: current directory) ---tool TOOL terragrunt, terraform, or tofu (default: terragrunt) ---backup PATH State pull backup path used before --apply ---apply Execute the generated state moves ---yes Skip the interactive confirmation for --apply -``` diff --git a/examples/base/README.md b/examples/base/README.md index 30655f2e5f..4c457865a4 100644 --- a/examples/base/README.md +++ b/examples/base/README.md @@ -3,7 +3,7 @@ | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers diff --git a/examples/base/versions.tf b/examples/base/versions.tf index cd80d2792c..b8ede4f3b6 100644 --- a/examples/base/versions.tf +++ b/examples/base/versions.tf @@ -5,5 +5,5 @@ terraform { version = ">= 6.21" # ensure backwards compatibility with v6.x } } - required_version = ">= 1.5.6" + required_version = ">= 1" } diff --git a/examples/dedicated-mac-hosts/README.md b/examples/dedicated-mac-hosts/README.md index 807eaf46fd..a8d018879b 100644 --- a/examples/dedicated-mac-hosts/README.md +++ b/examples/dedicated-mac-hosts/README.md @@ -3,7 +3,7 @@ | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers diff --git a/examples/dedicated-mac-hosts/versions.tf b/examples/dedicated-mac-hosts/versions.tf index 9ed7eae0d0..af69406fbd 100644 --- a/examples/dedicated-mac-hosts/versions.tf +++ b/examples/dedicated-mac-hosts/versions.tf @@ -6,5 +6,5 @@ terraform { } } - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" } diff --git a/examples/default/README.md b/examples/default/README.md index 84fb995a15..2f2a0d2b54 100644 --- a/examples/default/README.md +++ b/examples/default/README.md @@ -33,7 +33,7 @@ terraform output -raw webhook_secret | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | diff --git a/examples/default/versions.tf b/examples/default/versions.tf index 6af69ab915..666b978aac 100644 --- a/examples/default/versions.tf +++ b/examples/default/versions.tf @@ -13,5 +13,5 @@ terraform { version = "~> 3.0" } } - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" } diff --git a/examples/ephemeral/README.md b/examples/ephemeral/README.md index b0e7f79a12..890ea32f84 100644 --- a/examples/ephemeral/README.md +++ b/examples/ephemeral/README.md @@ -32,7 +32,7 @@ terraform output webhook_secret | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | diff --git a/examples/ephemeral/versions.tf b/examples/ephemeral/versions.tf index 6af69ab915..666b978aac 100644 --- a/examples/ephemeral/versions.tf +++ b/examples/ephemeral/versions.tf @@ -13,5 +13,5 @@ terraform { version = "~> 3.0" } } - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" } diff --git a/examples/external-managed-ssm-secrets/README.md b/examples/external-managed-ssm-secrets/README.md index 6161882d3a..af9c95a38c 100644 --- a/examples/external-managed-ssm-secrets/README.md +++ b/examples/external-managed-ssm-secrets/README.md @@ -79,7 +79,7 @@ terraform output -raw webhook_secret | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | diff --git a/examples/external-managed-ssm-secrets/versions.tf b/examples/external-managed-ssm-secrets/versions.tf index 6af69ab915..666b978aac 100644 --- a/examples/external-managed-ssm-secrets/versions.tf +++ b/examples/external-managed-ssm-secrets/versions.tf @@ -13,5 +13,5 @@ terraform { version = "~> 3.0" } } - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" } diff --git a/examples/lambdas-download/README.md b/examples/lambdas-download/README.md index 5423ac18b6..93ace73e95 100644 --- a/examples/lambdas-download/README.md +++ b/examples/lambdas-download/README.md @@ -12,7 +12,7 @@ terraform apply -var=module_version= | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1 | ## Providers @@ -39,4 +39,4 @@ No resources. | Name | Description | |------|-------------| | [files](#output\_files) | n/a | - + \ No newline at end of file diff --git a/examples/lambdas-download/versions.tf b/examples/lambdas-download/versions.tf index c5673044db..c934712b56 100644 --- a/examples/lambdas-download/versions.tf +++ b/examples/lambdas-download/versions.tf @@ -1,3 +1,3 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1" } diff --git a/examples/migration-test/README.md b/examples/migration-test/README.md deleted file mode 100644 index cb3dafa869..0000000000 --- a/examples/migration-test/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Multi-runner state migration test - -This example keeps the same root module address while switching its -`multi_runner_config` from the v1 contract to the v2 contract. The v1 -configuration is in `v1/` and the v2 configuration is in `v2/`. It enables the -AMI housekeeper, SSM housekeeper, runner-binaries syncer, pool, job retry, -EventBridge, metrics, tracing, termination watcher, and the EC2 runner -features that exercise the v1-to-v2 resource topology. - -The MiniStack lifecycle test performs this sequence without editing the -example files: - -1. Apply the `v1/` configuration with `v1.tfvars`. -2. Run `scripts/migrate_multi_runner_state.py` against the resulting v1 state. -3. Snapshot the IAM statements from v1, including role trust policies and inline - policies, grouped by IAM role. -4. Plan the `v2/` configuration with `v2.tfvars` using the shared - `migration.tfstate` file and verify that only v2 validation records are - new. The plan is parsed so only explicitly expected migration differences - are ignored. Inline IAM role policies are checked separately by the IAM - comparison. -5. Apply v2, compare the consolidated IAM statements grouped by IAM role with - the v1 snapshot, and run the same parsed plan check again. - -Run it with: - -```sh -tests/ministack/run-migration-test.sh apply -``` - - -## Requirements - -| Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | -| [aws](#requirement\_aws) | >= 6.33 | -| [random](#requirement\_random) | ~> 3.0 | - -## Providers - -| Name | Version | -|------|---------| -| [random](#provider\_random) | 3.9.1 | - -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [base](#module\_base) | ../base | n/a | -| [runners](#module\_runners) | ../../modules/multi-runner | n/a | -| [webhook\_github\_app](#module\_webhook\_github\_app) | ../../modules/webhook-github-app | n/a | - -## Resources - -| Name | Type | -|------|------| -| [random_id.webhook_secret](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [aws\_region](#input\_aws\_region) | AWS region. | `string` | `"eu-west-1"` | no | -| [environment](#input\_environment) | Environment name used as the resource prefix. | `string` | `"migration-test"` | no | -| [github\_app](#input\_github\_app) | Test-only GitHub App values used by the MiniStack fixture. |
object({
id = string
key_base64 = string
})
| n/a | yes | - -## Outputs - -No outputs. - diff --git a/examples/migration-test/compare_iam_role_policies.py b/examples/migration-test/compare_iam_role_policies.py deleted file mode 100644 index 2a4e7c8d6f..0000000000 --- a/examples/migration-test/compare_iam_role_policies.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 -"""Snapshot and compare IAM statements grouped by role.""" - -from __future__ import annotations - -import argparse -import difflib -import json -import os -import subprocess -import sys -from pathlib import Path -from typing import Any -from urllib.parse import unquote_plus - - -MIGRATION_RUNNER_PARAMETER_PATH = "" - - -def aws(*arguments: str) -> dict[str, Any]: - endpoint = os.environ.get("AWS_ENDPOINT_URL") - region = os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION") - command = ["aws"] - if endpoint: - command.extend(["--endpoint-url", endpoint]) - if region: - command.extend(["--region", region]) - command.extend(["iam", *arguments, "--output", "json"]) - result = subprocess.run(command, check=True, capture_output=True, text=True) - return json.loads(result.stdout) - - -def canonical(value: Any) -> Any: - if isinstance(value, dict): - return {key: canonical(value[key]) for key in sorted(value)} - if isinstance(value, list): - values = [canonical(item) for item in value] - return sorted(values, key=lambda item: json.dumps(item, sort_keys=True)) - return value - - -def policy_document(value: Any) -> Any: - if isinstance(value, dict): - return canonical(value) - if not isinstance(value, str): - raise TypeError(f"Unexpected IAM policy document type: {type(value).__name__}") - - decoded = unquote_plus(value) - return canonical(json.loads(decoded)) - - -def values(value: Any) -> list[Any]: - if value is None: - return [None] - return value if isinstance(value, list) else [value] - - -def permission_entries(document: dict[str, Any]) -> set[str]: - statements = values(document.get("Statement", [])) - entries: set[str] = set() - for statement in statements: - if not isinstance(statement, dict): - raise TypeError("Unexpected IAM statement type") - - base = { - key: canonical(value) - for key, value in statement.items() - if key not in {"Action", "NotAction", "Resource", "NotResource", "Sid"} - } - actions = values(statement.get("Action", statement.get("NotAction"))) - resources = values(statement.get("Resource", statement.get("NotResource"))) - action_key = "Action" if "Action" in statement else "NotAction" - resource_key = "Resource" if "Resource" in statement else "NotResource" - - for action in actions: - for resource in resources: - entry = dict(base) - if action is not None: - entry[action_key] = action - if resource is not None: - entry[resource_key] = resource - entries.add(json.dumps(canonical(entry), sort_keys=True)) - return entries - - -def is_scaling_role(role_name: str) -> bool: - return "-scale-up-lambda-" in role_name or "-scale-down-lambda-" in role_name - - -def is_runner_parameter_resource(value: Any) -> bool: - if value == "*": - return True - if not isinstance(value, str): - return False - return any( - value.endswith(suffix) - for suffix in ( - "/runners/config", - "/runners/config/*", - "/runners/config/ami_id", - "/runners/tokens", - "/runners/tokens/*", - ) - ) - - -def normalize_known_v1_v2_fixes(role_name: str, entry: str) -> str: - permission = json.loads(entry) - - if is_scaling_role(role_name): - condition = permission.get("Condition") - if isinstance(condition, dict): - normalized_condition: dict[str, Any] = {} - for operator, clauses in condition.items(): - if isinstance(clauses, dict): - normalized_condition[operator] = { - ( - "ec2:ResourceTag/ghr:environment" - if key == "ec2:ResourceTag/gh:environment" - else key - ): value - for key, value in clauses.items() - } - else: - normalized_condition[operator] = clauses - permission["Condition"] = normalized_condition - - action = permission.get("Action") - resource = permission.get("Resource") - if ( - is_runner_parameter_resource(resource) - and ( - ( - "-scale-up-lambda-" in role_name - and action - in { - "ssm:AddTagsToResource", - "ssm:GetParameter", - "ssm:GetParameters", - "ssm:PutParameter", - } - ) - or ( - "-pool-lambda-" in role_name - and action - in {"ssm:AddTagsToResource", "ssm:PutParameter"} - ) - ) - ): - permission["Resource"] = MIGRATION_RUNNER_PARAMETER_PATH - - return json.dumps(canonical(permission), sort_keys=True) - - -def normalized_permissions(role_name: str, role: dict[str, Any]) -> list[dict[str, Any]]: - normalized = { - normalize_known_v1_v2_fixes(role_name, json.dumps(permission)) - for permission in role["permissions"] - } - return [json.loads(permission) for permission in sorted(normalized)] - - -def snapshot() -> dict[str, Any]: - roles_snapshot: dict[str, Any] = {} - roles = aws("list-roles").get("Roles", []) - for role in sorted(roles, key=lambda item: item["RoleName"]): - role_name = role["RoleName"] - permissions: set[str] = set() - assume_role_policy = role.get("AssumeRolePolicyDocument") - if assume_role_policy: - permissions.update( - permission_entries(policy_document(assume_role_policy)) - ) - policy_names = aws("list-role-policies", "--role-name", role_name).get("PolicyNames", []) - for policy_name in sorted(policy_names): - response = aws( - "get-role-policy", - "--role-name", - role_name, - "--policy-name", - policy_name, - ) - document = policy_document(response["PolicyDocument"]) - permissions.update(permission_entries(document)) - if permissions: - roles_snapshot[role_name] = { - "permissions": [json.loads(value) for value in sorted(permissions)] - } - return {"roles": roles_snapshot} - - -def write_snapshot(path: Path) -> None: - path.write_text(json.dumps(snapshot(), indent=2, sort_keys=True) + "\n", encoding="utf-8") - - -def compare(first_path: Path, second_path: Path) -> int: - first = json.loads(first_path.read_text(encoding="utf-8"))["roles"] - second = json.loads(second_path.read_text(encoding="utf-8"))["roles"] - first_keys = set(first) - second_keys = set(second) - - differences = False - for key in sorted(first_keys - second_keys): - differences = True - print(f"IAM role removed after migration: {key}", file=sys.stderr) - for key in sorted(second_keys - first_keys): - differences = True - print(f"IAM role added after migration: {key}", file=sys.stderr) - for key in sorted(first_keys & second_keys): - first_permissions = normalized_permissions(key, first[key]) - second_permissions = normalized_permissions(key, second[key]) - if first_permissions == second_permissions: - continue - differences = True - before = json.dumps( - {"permissions": first_permissions}, indent=2, sort_keys=True - ).splitlines(keepends=True) - after = json.dumps( - {"permissions": second_permissions}, indent=2, sort_keys=True - ).splitlines(keepends=True) - print(f"IAM role permissions changed after migration: {key}", file=sys.stderr) - print( - "".join( - difflib.unified_diff( - before, - after, - fromfile=f"v1/{key}/permissions", - tofile=f"v2/{key}/permissions", - ) - ), - file=sys.stderr, - ) - - if differences: - return 1 - print("IAM role permissions are unchanged after migration.") - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - snapshot_parser = subparsers.add_parser("snapshot", help="Write an IAM policy snapshot.") - snapshot_parser.add_argument("output", type=Path) - - compare_parser = subparsers.add_parser("compare", help="Compare two IAM policy snapshots.") - compare_parser.add_argument("first", type=Path) - compare_parser.add_argument("second", type=Path) - - arguments = parser.parse_args() - if arguments.command == "snapshot": - write_snapshot(arguments.output) - return 0 - return compare(arguments.first, arguments.second) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/migration-test/filter_migration_plan.py b/examples/migration-test/filter_migration_plan.py deleted file mode 100644 index 98b0f538c0..0000000000 --- a/examples/migration-test/filter_migration_plan.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -"""Report unexpected changes from a migration-test Terraform plan.""" - -from __future__ import annotations - -import copy -import json -import sys -from typing import Any - - -IGNORED_RESOURCE_TYPES = { - # Inline role policies are validated by compare_iam_role_policies.py. - "aws_iam_role_policy", -} -IGNORED_RESOURCE_ADDRESS_SUFFIXES = { - # The v1 and v2 queue policies intentionally use different statements. - ".aws_sqs_queue_policy.job_retry_check_queue_policy", - # The v2 launch template adds propagated tags and reports computed versions. - ".aws_launch_template.runner", -} -IGNORED_TAG_KEYS = {"Name", "ghr:ssm_config_path"} -IGNORED_LAMBDA_ENVIRONMENT_KEYS = { - ".aws_lambda_function.job_retry": { - "NODE_TLS_REJECT_UNAUTHORIZED", - "RUNNER_NAME_PREFIX", - "USER_AGENT", - }, - ".aws_lambda_function.pool": { - "SSM_PARAMETER_STORE_TAGS", - }, - ".aws_lambda_function.scale_up": { - "SSM_PARAMETER_STORE_TAGS", - }, -} - - -def remove_assume_role_sids(value: Any) -> Any: - if not isinstance(value, str): - return value - try: - policy = json.loads(value) - except json.JSONDecodeError: - return value - if not isinstance(policy, dict): - return value - - statements = policy.get("Statement") - if isinstance(statements, dict): - statements = [statements] - if not isinstance(statements, list): - return value - - normalized_statements = [] - for statement in statements: - if isinstance(statement, dict): - statement = dict(statement) - statement.pop("Sid", None) - normalized_statements.append(statement) - policy = dict(policy) - policy["Statement"] = normalized_statements - return json.dumps(policy, sort_keys=True, separators=(",", ":")) - - -def without_expected_changes( - value: Any, resource_type: str, resource_address: str -) -> Any: - if not isinstance(value, dict): - return value - - normalized = copy.deepcopy(value) - for attribute in ("tags", "tags_all"): - tags = normalized.get(attribute) - if isinstance(tags, dict): - normalized[attribute] = { - key: tag_value - for key, tag_value in tags.items() - if key not in IGNORED_TAG_KEYS - } - - if resource_type == "aws_lambda_event_source_mapping" and resource_address.endswith( - ".aws_lambda_event_source_mapping.job_retry" - ): - normalized.pop("tags", None) - normalized.pop("tags_all", None) - - if resource_type == "aws_lambda_function": - for attribute in ("filename", "last_modified"): - normalized.pop(attribute, None) - for address_suffix, environment_keys in IGNORED_LAMBDA_ENVIRONMENT_KEYS.items(): - if not resource_address.endswith(address_suffix): - continue - environment = normalized.get("environment") - environments = environment if isinstance(environment, list) else [environment] - normalized_environments = [] - for environment_block in environments: - if not isinstance(environment_block, dict): - normalized_environments.append(environment_block) - continue - environment_block = copy.deepcopy(environment_block) - variables = environment_block.get("variables") - if isinstance(variables, dict): - environment_block["variables"] = { - key: variable_value - for key, variable_value in variables.items() - if key not in environment_keys - } - normalized_environments.append(environment_block) - if isinstance(environment, list): - normalized["environment"] = normalized_environments - elif normalized_environments: - normalized["environment"] = normalized_environments[0] - - if resource_type == "aws_iam_role" and resource_address.endswith( - ".aws_iam_role.job_retry" - ): - normalized["assume_role_policy"] = remove_assume_role_sids( - normalized.get("assume_role_policy") - ) - - return normalized - - -def is_ignored(resource: dict[str, Any]) -> bool: - address = resource.get("address", "") - return resource.get("type") in IGNORED_RESOURCE_TYPES or any( - address.endswith(suffix) for suffix in IGNORED_RESOURCE_ADDRESS_SUFFIXES - ) - - -def main() -> int: - unexpected = [] - for resource in json.load(sys.stdin).get("resource_changes", []): - address = resource.get("address", "") - resource_type = resource.get("type", "") - if resource.get("mode") != "managed" or resource_type == "terraform_data": - continue - if not address.startswith("module.runners.") or is_ignored(resource): - continue - - actions = resource.get("change", {}).get("actions", []) - if actions == ["no-op"]: - continue - change = resource.get("change", {}) - before = without_expected_changes(change.get("before"), resource_type, address) - after = without_expected_changes(change.get("after"), resource_type, address) - if before == after: - continue - unexpected.append(f"{address}: {','.join(actions)}") - - if unexpected: - print("Migration changed infrastructure resources:", file=sys.stderr) - print("\n".join(f" {change}" for change in unexpected), file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/migration-test/v1/.terraform.lock.hcl b/examples/migration-test/v1/.terraform.lock.hcl deleted file mode 100644 index e9af82f2fc..0000000000 --- a/examples/migration-test/v1/.terraform.lock.hcl +++ /dev/null @@ -1,68 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/aws" { - version = "6.64.0" - constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" - hashes = [ - "h1:2fTLxzUDmp/KVIHbIeLTB4bIzWHx8E6Dw+1ALLUi+Yw=", - "zh:07172315d67bc9781240272759cdfc7bd32b7e72384a56862c2c1da3cca99a81", - "zh:154ce7d2659de9a59ddfe96d7cab41a9ddc2cb267a7d4bcdf4e737ff2ffdec06", - "zh:17324d4335a7a7ac01cc23eded530775606680ff53b47cb74a3cb95d1121f836", - "zh:307ab92324ec5a61b124881ab8cac1d9e316f4527dfd0e1b59794c229407eb4e", - "zh:31e25f1903661332e36a95283042dd3ec50b47c186db00663fbd976a11e6a6b2", - "zh:3311d9f3bd12a24886027dbe73859dcd1e67bd0e3046227a338cf2c7ca04d18e", - "zh:37916156a3aac3b29be3acebd15d53145ea4ab5d4aaa825eaebe75481fa00500", - "zh:4158cb8c38b3ac6aa98eb15935ec6bd7c30838d85d2b00acc9812df8382ae908", - "zh:5bfb9499c66d9db5b34dc5c60f426a1ab1baa5457ce2aefebca826a9c3f92fb0", - "zh:6eb29ead5a4aca3b1f35812e7e8c75419180e1928e479b458f206861277736db", - "zh:7a82b6dd0c0cdef8045a4adfbddd36acb86b6b23fcbed8e189c2d71f7dc4a502", - "zh:9556bd792032c3f7e73ea4dd08cec88dc1327f5a4a57d79c30ba844ae2b9a3c0", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:c5234180464cb800c83a41f57462742b802c150ad7d4417626fcd9cb511c01d2", - "zh:cd776b83b1f7b36635957350afe7ce28ba4e4ea3a5e2deb00d13dbd3b35d9d40", - "zh:fb583a7b791c6f915b86573d04f05ddbf7f1a5e4120c5d8a7450a3086c1225c4", - ] -} - -provider "registry.terraform.io/hashicorp/null" { - version = "3.3.2" - constraints = "~> 3.0, ~> 3.2" - hashes = [ - "h1:IQ1qrkht1sC1nibUR+AJ3ulryyhVDHfCHZhoJi0sg2Y=", - "zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08", - "zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55", - "zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27", - "zh:64a8f9ce7852d9efc5b464c12306c946366d59f5e2757def97969c9fd64bd1d6", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:92a374fb736a52f465283326d0a5bf4f495132eb99be209dfb4c75ec803fe8db", - "zh:98da9c42785d27a50f0604758bcb61a30f6278b9f2acd92bb3b2046e0e71916c", - "zh:b0f7896fae554729cdf4a24ac06359a050cff5817e6cd8597cba8a4ae01a7409", - "zh:bc8179ee35d67c72fb03012e7023b9f9816f033a7ec4109c001dd6d29752e812", - "zh:d23a598f713bfb6098bc003571d7de90b5a33b78f9be240488252fe5f3c2a60d", - "zh:d2855b922ea345dbd89ea287e4c6c4757e38bc0aaffeb2b79aa0b8004f9c53ff", - "zh:d3a60422bc6a2f9244d076c5222c07060c826ef91bdbaf4634cb752b86057473", - "zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba", - ] -} - -provider "registry.terraform.io/hashicorp/random" { - version = "3.9.1" - constraints = "~> 3.0" - hashes = [ - "h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=", - "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", - "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", - "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", - "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", - "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", - "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", - "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", - "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", - "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", - "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", - "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", - ] -} diff --git a/examples/migration-test/v1/.terraform.lock.hcl.tofu b/examples/migration-test/v1/.terraform.lock.hcl.tofu deleted file mode 100644 index b46e280e48..0000000000 --- a/examples/migration-test/v1/.terraform.lock.hcl.tofu +++ /dev/null @@ -1,113 +0,0 @@ -# This file is maintained automatically by "tofu init". -# Manual edits may be lost in future updates. - -provider "registry.opentofu.org/hashicorp/aws" { - version = "6.64.0" - constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" - hashes = [ - "h1:/G38+XhC1mBVkmeWdtk/wk7lX2BxviJ2XZ70dpoaKKQ=", - "h1:7BzHdGCBG5usqOIhfBq89dkdUopnSo+qe9qRCKDSRHc=", - "h1:8AgY9Hc5/R5j97WgCcDSlbuKk0pjk3vkp7oz4mtGVY8=", - "h1:DKdOy/0RfYLxpzAXBPWTO5Eusvqx5UoGxiq2J0E6DY4=", - "h1:KSwetpR4S2eUsKmHftt73Cbx72lPWYET4V+Ej05rnkI=", - "h1:MEi5Ecge1Uwx/DRGfdVDzV5Q/soRxKh6dBHxUjGdaDQ=", - "h1:VqjWicgPZW32+YnSe0Lo78qq8/24I8XNV+E9d/lBz/4=", - "h1:WBgbFHdg/3ekWoAH6UeKiwfk6iqLr1f7TX9R/mJUK8M=", - "h1:YisB3zMV5Kh6p5/eVuPAAPEmudD/UqGN4C/V3zRtAq4=", - "h1:bG5dXqR4mSlcebUG+anerOWYDyeaScZJeLSJk0cYBfE=", - "h1:iosW/imG2pc4La7qdeM/rK6ldMXhcU6YVW7tjqwNXtI=", - "h1:nKE1gnLZxIoqukQ1YI9EUdmrQIUeAN4PWb5ecN8U9K8=", - "h1:x0hJO5+On8FaKExr4p2cNJhWsNWFZq1EiDD6CfVwy2E=", - "h1:yPH75sRH+f3aJlJAloOL/BikeZV6/0GP8VQvnJoMRKM=", - "h1:zCWB5ZD98/ZC0a50HTGoC/fTAseh189xxCFEL5Mt7r4=", - "zh:06e09ced9480ae12578122f7a25758a15d8fe684da0f6a0a61b9bc2f4a4918ad", - "zh:2035805f0ed8bf81d493e7a52f22965b3d5d402687a95d1caa8c4b1b348c1264", - "zh:25fe72a3d6a330eab6c8957f9e6bdf297ffdce95fa059fef30b80da764bee6b2", - "zh:49df644d19e39b9947e84609260028687057191ddd941783c0211386ade53040", - "zh:4d8438a5d25f18eb376c8375c70f81afb79d0fc1e63ebb6df1d0e02287964dde", - "zh:5cd9717e819506132126a896e959cd4cf1bb213c033c37777c9d01a593937e2c", - "zh:6955caa4f435373ae870de31bdda85e51c60b68c51a4206df5a21b853bcefe21", - "zh:82a413500c35241745e097797610d2bff57c26e29f34ca711182fdde5c265d13", - "zh:831f78acce42a759a977769b0409387ec13ff64b4f46c24eb7e7662e0f352525", - "zh:88648a159119a0435bf86c6cd1f7482dc43dfa2eb742f9b29053da1ee9fdabd8", - "zh:9fc745d71a2e36dbdae0ee69be70675509e5a9dec1a3c5a9be6007e568d78c07", - "zh:bf6d11d6ed1655f61f70eb2906e5d1f7ff5e78b6539dcfb3116ed6f8960c9e20", - "zh:ca17a6ca363afe930ad3474966d39cb549b7f1e5efdca909972dd26f90eefc89", - "zh:d7e9cc87ada1314e6d8ecc5849385a8f8f45757bd2c145b8657f015c65e5078d", - "zh:eddb4d6d86700788d132ba2a83d306646ccb2a3a0cec0a0ab3e215307c61d8f2", - ] -} - -provider "registry.opentofu.org/hashicorp/null" { - version = "3.3.2" - constraints = "~> 3.0, ~> 3.2" - hashes = [ - "h1:1T+00cjQNmRAHAz9xjEBFpf5wRRb0IBuXS/W8ke5BWs=", - "h1:46gmIYe+klib6TlHKSqEkMLjvnzVWiCB2NYA2zR8MX8=", - "h1:7WQ3wjfaeqnXxq+a8cYiYeWUnMTgY1JcuX+z7sZd72s=", - "h1:MVM+vkVtW/YyKfn111pyho0y87I4TekaNMbBLkn0/C8=", - "h1:QBcIbI2Dp4v6Iui37pn4qmw8YeiFLbSWcJuzZVl/65Y=", - "h1:SsVKTUR+vgLaC1YnoDa2fnYpzREcgNgWRcu5x+vwjHA=", - "h1:WUaeuTNn9w6UXZ9cMq4+qZy4ZAr71B9NcUDEXjqfdKs=", - "h1:WtEaA7alasNwEQ4L3+KyQtbkSOPsexzJ4LUZ7PKaycI=", - "h1:WxS7rjYIZ1WQc4GkICch8XrbxoSY8TjUfLbPDo6oEcQ=", - "h1:Ysvc/FPvcwk+iMg7IcLkqZhT/KhtZTYji+UBqMlcTs4=", - "h1:ZLjbXnfVcRvS/DAN3BcNebOsnOcs3Nx6mJpFCj4dZ2c=", - "h1:fAmvQjIGyqdGMc+v/fUEINCyuU4iaKSzsV8PWsOnmAc=", - "h1:jwkbEtf3S7W+Bl4soynNkUHFfK/4I/H74urHY758XVw=", - "h1:qY1sKzlxNTp/dqZR23bM4egmVMRlaQudLlBYraMt1pw=", - "h1:t8H1KNwJQwKE/GqpHeRxOWgMk0Yv35qbBTBzqB/rhr0=", - "zh:09e94b0b7dfc0c6450c247517b5410546039c758e513d89b588af6df70c3d57d", - "zh:0b72497b6fd79a2b04785b64890a565a8cc7b06ded95da05e6dab2f3b8a02d58", - "zh:1c0ee6f81f7bcdec8d568a145a450eb57a6f1cfe5e48943375d1af22ed54151e", - "zh:1c6899b475f035d352af1e7f33dc30beab8b8e3784f8cb55a2cc4a11997fbd66", - "zh:43e57a2a56e9874604501bdebe431bb573fb77d2c5f4d7598ab30727dc0e90ea", - "zh:49cf2f36298a5ac3ac8d80ceb87466e6a99d2c021005bcd9e3a79f2314fd0a13", - "zh:665be40d2c7f3d768b8f39a041371526d4b7b396b4c11e162a1886212da176c9", - "zh:6bb1583d88ddb38b1c6b4624e25ad414ddd8bf65b0dd9c074580847311f83924", - "zh:71d64453bdc795667e9841d7c90e3fef6ff157e0598d51dac4bb1e4583b85407", - "zh:73ac02bc3b680e1ea75aab24ec2a359c8f0a021f73d43b2feae0a899e75a93ad", - "zh:b2b777ee07b910e7345df85321fab6c9a25c33ecb56129758dda8fadaba09fe4", - "zh:bb984d52880749a49e509e3b243804869e1af40ee2d34322a30f5f340f8d8dbd", - "zh:e0724bc083527343b4a4099fd4f95511e49a0e113416cdba58a64446742b68b1", - "zh:e391c14e367cd64d986ddc8d81f2db76d49600ce0521720618ff1ecc0decde7f", - "zh:e95c1af8e8e9967d678cfa7c77ca229c863a68f7c9a85318bf08f26628afe338", - ] -} - -provider "registry.opentofu.org/hashicorp/random" { - version = "3.9.1" - constraints = "~> 3.0" - hashes = [ - "h1:38E2VQmQDhws/3AL3D/EzBGuCseepyZRIswAOx8CqoQ=", - "h1:7+qv9kpOpBC9EUPCubnPxh603tu3l9EIMMBkpbt1H1Y=", - "h1:CEQeHfnUDB3uqAkKoEWfWgbj+kpoQHgcuPbAjPzbh+U=", - "h1:HPYO9tf8KUSHqSdz1uOL97MLeaVHPaWPY1JW6tKU19E=", - "h1:KYXiC06Pr3WJcIUbDq9MgdAbInO5zcRyHFqV1x5UcJg=", - "h1:MygjbYH8CrPv8RUe75tZAFmrFNIzQLT45fiyYx7u2tI=", - "h1:RMSARNOw4qZx+VmHYnVMGljsFVfCV1P+nJjKrN2XIOI=", - "h1:U/71jbSbfsfVLxWpSlhyVHh/DnQXQhjoJCGkyongkBA=", - "h1:WwLLvRE1q95CTGxyTjKpctXJ0ooVs9d22JAQS9fC3uo=", - "h1:ZtRBSqoyfQAhngjUjM0NRPtj6NdSJ/JBENFwT8D276s=", - "h1:cfxedZLduhHD1UtqQDjAQZNAEhr/bWDAZ4nU9rSdySg=", - "h1:i45mo4de0QKOreStMqUQ7qyZL3ucFq42l178Fm5/hMU=", - "h1:v3SAJKN4D3dOM95xKwgKGIWELe5nUBbyXdb5HNz7icw=", - "h1:v3vTk/STekrzNc6NG3jL9/05zhvF1QVCgyRRbvSHPcI=", - "h1:zHgFWtRBgOycqhw8HdLSvMVNWGJtIjOAPYOcAdE7cL0=", - "zh:09aaf19b0d22726d2378e0e89fbbefc183494d7bd585759d6c4e69ba50951a2f", - "zh:31575ca9bc0db20337096d178ea73bce3ebca343ed071c67f78cf39f800c9ec6", - "zh:624fb6ed552abc34a5aaac41e76a373da65ac08e524b09b672f29c60e6ac896a", - "zh:6a4760d55132b9750ac1a04f6fc32e247034daa999f71452dba9cbca225a529a", - "zh:768a6047cfb8958e7b0b120c580aa3de6624a7fbb2c56ad6df85cd559ed26ec7", - "zh:8983c788ba660bcb587e64ff9c3e4323515caf78facbe0abe6432e7aff8df893", - "zh:8d570eb026a4f00b58a1d36be0ce3c13adf4d973efcd4162b05cb295bbc14257", - "zh:a2259540854d5f699c36b89244fb202ebb2c219b64669a51072687d04fb47152", - "zh:aaa51d905b0e80a28e02f9bee2cf6c91ffade7389d77ab9198aa12809ed04955", - "zh:afb60995e98573facddfb47baedf7e288408680eb00b5d3df570611758947c72", - "zh:b9a46d852ce53fa037f47537a7de53f37b759ccf211600b7ba44c66ba4b616b7", - "zh:bafcfeeefcd0dfefeff120b655b45edb0497c4717534ffe5201b3cb556d1ffe6", - "zh:c3ac24d397eae054aca2290e20943e0c767592cc661c890850c25ac01829308d", - "zh:eafba4127ebadcc5ed0e427935c66fb5e2da7cfdaae39a66d52f4a50d51faf1e", - "zh:f39d4bce213ed9bba3474bad468136af08ff6c4c33adaafcc10c1f78067adfe3", - ] -} diff --git a/examples/migration-test/v1/README.md b/examples/migration-test/v1/README.md deleted file mode 100644 index fbe9d46a9d..0000000000 --- a/examples/migration-test/v1/README.md +++ /dev/null @@ -1,41 +0,0 @@ - -## Requirements - -| Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | -| [aws](#requirement\_aws) | >= 6.33 | -| [random](#requirement\_random) | ~> 3.0 | - -## Providers - -| Name | Version | -|------|---------| -| [random](#provider\_random) | 3.9.1 | - -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [base](#module\_base) | ../../base | n/a | -| [runners](#module\_runners) | ../../../modules/multi-runner | n/a | -| [webhook\_github\_app](#module\_webhook\_github\_app) | ../../../modules/webhook-github-app | n/a | - -## Resources - -| Name | Type | -|------|------| -| [random_id.webhook_secret](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [aws\_region](#input\_aws\_region) | AWS region. | `string` | `"eu-west-1"` | no | -| [environment](#input\_environment) | Environment name used as the resource prefix. | `string` | `"migration-test"` | no | -| [github\_app](#input\_github\_app) | Test-only GitHub App values used by the MiniStack fixture. |
object({
id = string
key_base64 = string
})
| n/a | yes | - -## Outputs - -No outputs. - \ No newline at end of file diff --git a/examples/migration-test/v1/main.tf b/examples/migration-test/v1/main.tf deleted file mode 100644 index 9522b2105b..0000000000 --- a/examples/migration-test/v1/main.tf +++ /dev/null @@ -1,197 +0,0 @@ -locals { - environment = var.environment - ami = { - filter = { - name = ["migration-test-linux"] - state = ["available"] - } - owners = ["self"] - } - - pool_config = [{ - schedule_expression = "cron(0 0 * * ? *)" - schedule_expression_timezone = "UTC" - size = 1 - }] - - multi_runner_config = { - large = { - matcherConfig = { - exactMatch = false - labelMatchers = [["self-hosted", "linux", "x64", "migration"]] - priority = 10 - } - runner_config = { - runner_os = "linux" - runner_architecture = "x64" - runner_name_prefix = "migration-" - runner_extra_labels = ["migration"] - runner_group_name = "migration" - instance_types = ["m5.large"] - runners_maximum_count = 2 - - ami = local.ami - create_service_linked_role_spot = true - enable_ephemeral_runners = true - enable_jit_config = true - enable_job_queued_check = true - enable_on_demand_failover_for_errors = ["InsufficientInstanceCapacity"] - enable_runner_binaries_syncer = true - enable_ssm_on_runners = true - enable_runner_detailed_monitoring = true - enable_cloudwatch_agent = true - cloudwatch_config = "{\"metrics\":{}}" - delay_webhook_event = 5 - scale_down_schedule_expression = "cron(* * * * ? *)" - minimum_running_time_in_minutes = 1 - scale_down_idle_confirmation_seconds = 10 - lambda_event_source_mapping_batch_size = 25 - lambda_event_source_mapping_maximum_batching_window_in_seconds = 10 - runner_metadata_options = { - instance_metadata_tags = "disabled" - http_endpoint = "enabled" - http_tokens = "optional" - http_put_response_hop_limit = 1 - } - pool_config = local.pool_config - pool_runner_owner = "migration-test" - job_retry = { - enable = true - delay_in_seconds = 60 - delay_backoff = 2 - max_attempts = 2 - lambda_memory_size = 256 - lambda_timeout = 30 - } - } - } - } -} - -resource "random_id" "webhook_secret" { - byte_length = 20 -} - -module "base" { - source = "../../base" - - prefix = local.environment - aws_region = var.aws_region -} - -module "runners" { - source = "../../../modules/multi-runner" - - prefix = local.environment - aws_region = var.aws_region - - vpc_id = module.base.vpc.vpc_id - subnet_ids = module.base.vpc.private_subnets - - experimental_features = [] - multi_runner_config = local.multi_runner_config - - tags = { - Example = "migration-test" - Feature = "state-migration" - } - - github_app = { - id = var.github_app.id - key_base64 = var.github_app.key_base64 - webhook_secret = random_id.webhook_secret.hex - } - - additional_github_apps = [{ - id = "1" - key_base64 = "ministack-invalid-additional-key" - installation_id = "2" - }] - - enable_ami_housekeeper = true - ami_housekeeper_lambda_memory_size = 300 - ami_housekeeper_lambda_timeout = 120 - ami_housekeeper_lambda_schedule_expression = "rate(1 day)" - ami_housekeeper_cleanup_config = { - minimumDaysOld = 1 - dryRun = true - amiFilters = [{ - Name = "name" - Values = ["migration-test-*"] - }] - } - - enable_managed_runner_security_group = true - runners_scale_up_lambda_timeout = 45 - runners_scale_down_lambda_timeout = 70 - scale_up_lambda_memory_size = 768 - scale_down_lambda_memory_size = 640 - webhook_lambda_memory_size = 384 - webhook_lambda_timeout = 20 - pool_lambda_timeout = 90 - pool_lambda_reserved_concurrent_executions = 2 - lambda_event_source_mapping_batch_size = 25 - lambda_event_source_mapping_maximum_batching_window_in_seconds = 10 - lambda_architecture = "arm64" - - eventbridge = { - enable = true - accept_events = ["workflow_job"] - } - - runners_ssm_housekeeper = { - schedule_expression = "rate(12 hours)" - enabled = true - lambda_memory_size = 640 - lambda_timeout = 75 - config = { - minimumDaysOld = 3 - dryRun = true - } - } - - metrics = { - enable = true - namespace = "MigrationTest" - metric = { - enable_github_app_rate_limit = true - enable_job_retry = true - enable_spot_termination_warning = true - } - } - - tracing_config = { - mode = "Active" - capture_http_requests = true - capture_error = true - } - - log_level = "debug" - logging_retention_in_days = 30 - log_class = "STANDARD" - - instance_termination_watcher = { - enable = true - features = { - enable_runner_deregistration = true - enable_spot_termination_handler = true - enable_spot_termination_notification_watcher = true - } - } - - runner_binaries_syncer_memory_size = 256 - runner_binaries_syncer_lambda_timeout = 300 - state_event_rule_binaries_syncer = "ENABLED" -} - -module "webhook_github_app" { - source = "../../../modules/webhook-github-app" - depends_on = [module.runners] - - github_app = { - id = var.github_app.id - key_base64 = var.github_app.key_base64 - webhook_secret = random_id.webhook_secret.hex - } - webhook_endpoint = module.runners.webhook.endpoint -} diff --git a/examples/migration-test/v1/providers.tf b/examples/migration-test/v1/providers.tf deleted file mode 100644 index f24f950b27..0000000000 --- a/examples/migration-test/v1/providers.tf +++ /dev/null @@ -1,9 +0,0 @@ -provider "aws" { - region = var.aws_region - - default_tags { - tags = { - Example = var.environment - } - } -} diff --git a/examples/migration-test/v1/v1.tfvars b/examples/migration-test/v1/v1.tfvars deleted file mode 100644 index de69c5d91e..0000000000 --- a/examples/migration-test/v1/v1.tfvars +++ /dev/null @@ -1,7 +0,0 @@ -environment = "migration-test" -aws_region = "eu-west-1" - -github_app = { - id = "0" - key_base64 = "ministack-invalid-key" -} diff --git a/examples/migration-test/v1/variables.tf b/examples/migration-test/v1/variables.tf deleted file mode 100644 index af2e9a7aae..0000000000 --- a/examples/migration-test/v1/variables.tf +++ /dev/null @@ -1,20 +0,0 @@ -variable "environment" { - description = "Environment name used as the resource prefix." - type = string - default = "migration-test" -} - -variable "aws_region" { - description = "AWS region." - type = string - default = "eu-west-1" -} - -variable "github_app" { - description = "Test-only GitHub App values used by the MiniStack fixture." - type = object({ - id = string - key_base64 = string - }) - sensitive = true -} diff --git a/examples/migration-test/v2/.terraform.lock.hcl b/examples/migration-test/v2/.terraform.lock.hcl deleted file mode 100644 index e9af82f2fc..0000000000 --- a/examples/migration-test/v2/.terraform.lock.hcl +++ /dev/null @@ -1,68 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/aws" { - version = "6.64.0" - constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" - hashes = [ - "h1:2fTLxzUDmp/KVIHbIeLTB4bIzWHx8E6Dw+1ALLUi+Yw=", - "zh:07172315d67bc9781240272759cdfc7bd32b7e72384a56862c2c1da3cca99a81", - "zh:154ce7d2659de9a59ddfe96d7cab41a9ddc2cb267a7d4bcdf4e737ff2ffdec06", - "zh:17324d4335a7a7ac01cc23eded530775606680ff53b47cb74a3cb95d1121f836", - "zh:307ab92324ec5a61b124881ab8cac1d9e316f4527dfd0e1b59794c229407eb4e", - "zh:31e25f1903661332e36a95283042dd3ec50b47c186db00663fbd976a11e6a6b2", - "zh:3311d9f3bd12a24886027dbe73859dcd1e67bd0e3046227a338cf2c7ca04d18e", - "zh:37916156a3aac3b29be3acebd15d53145ea4ab5d4aaa825eaebe75481fa00500", - "zh:4158cb8c38b3ac6aa98eb15935ec6bd7c30838d85d2b00acc9812df8382ae908", - "zh:5bfb9499c66d9db5b34dc5c60f426a1ab1baa5457ce2aefebca826a9c3f92fb0", - "zh:6eb29ead5a4aca3b1f35812e7e8c75419180e1928e479b458f206861277736db", - "zh:7a82b6dd0c0cdef8045a4adfbddd36acb86b6b23fcbed8e189c2d71f7dc4a502", - "zh:9556bd792032c3f7e73ea4dd08cec88dc1327f5a4a57d79c30ba844ae2b9a3c0", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:c5234180464cb800c83a41f57462742b802c150ad7d4417626fcd9cb511c01d2", - "zh:cd776b83b1f7b36635957350afe7ce28ba4e4ea3a5e2deb00d13dbd3b35d9d40", - "zh:fb583a7b791c6f915b86573d04f05ddbf7f1a5e4120c5d8a7450a3086c1225c4", - ] -} - -provider "registry.terraform.io/hashicorp/null" { - version = "3.3.2" - constraints = "~> 3.0, ~> 3.2" - hashes = [ - "h1:IQ1qrkht1sC1nibUR+AJ3ulryyhVDHfCHZhoJi0sg2Y=", - "zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08", - "zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55", - "zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27", - "zh:64a8f9ce7852d9efc5b464c12306c946366d59f5e2757def97969c9fd64bd1d6", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:92a374fb736a52f465283326d0a5bf4f495132eb99be209dfb4c75ec803fe8db", - "zh:98da9c42785d27a50f0604758bcb61a30f6278b9f2acd92bb3b2046e0e71916c", - "zh:b0f7896fae554729cdf4a24ac06359a050cff5817e6cd8597cba8a4ae01a7409", - "zh:bc8179ee35d67c72fb03012e7023b9f9816f033a7ec4109c001dd6d29752e812", - "zh:d23a598f713bfb6098bc003571d7de90b5a33b78f9be240488252fe5f3c2a60d", - "zh:d2855b922ea345dbd89ea287e4c6c4757e38bc0aaffeb2b79aa0b8004f9c53ff", - "zh:d3a60422bc6a2f9244d076c5222c07060c826ef91bdbaf4634cb752b86057473", - "zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba", - ] -} - -provider "registry.terraform.io/hashicorp/random" { - version = "3.9.1" - constraints = "~> 3.0" - hashes = [ - "h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=", - "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", - "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", - "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", - "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", - "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", - "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", - "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", - "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", - "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", - "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", - "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", - ] -} diff --git a/examples/migration-test/v2/.terraform.lock.hcl.tofu b/examples/migration-test/v2/.terraform.lock.hcl.tofu deleted file mode 100644 index b46e280e48..0000000000 --- a/examples/migration-test/v2/.terraform.lock.hcl.tofu +++ /dev/null @@ -1,113 +0,0 @@ -# This file is maintained automatically by "tofu init". -# Manual edits may be lost in future updates. - -provider "registry.opentofu.org/hashicorp/aws" { - version = "6.64.0" - constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" - hashes = [ - "h1:/G38+XhC1mBVkmeWdtk/wk7lX2BxviJ2XZ70dpoaKKQ=", - "h1:7BzHdGCBG5usqOIhfBq89dkdUopnSo+qe9qRCKDSRHc=", - "h1:8AgY9Hc5/R5j97WgCcDSlbuKk0pjk3vkp7oz4mtGVY8=", - "h1:DKdOy/0RfYLxpzAXBPWTO5Eusvqx5UoGxiq2J0E6DY4=", - "h1:KSwetpR4S2eUsKmHftt73Cbx72lPWYET4V+Ej05rnkI=", - "h1:MEi5Ecge1Uwx/DRGfdVDzV5Q/soRxKh6dBHxUjGdaDQ=", - "h1:VqjWicgPZW32+YnSe0Lo78qq8/24I8XNV+E9d/lBz/4=", - "h1:WBgbFHdg/3ekWoAH6UeKiwfk6iqLr1f7TX9R/mJUK8M=", - "h1:YisB3zMV5Kh6p5/eVuPAAPEmudD/UqGN4C/V3zRtAq4=", - "h1:bG5dXqR4mSlcebUG+anerOWYDyeaScZJeLSJk0cYBfE=", - "h1:iosW/imG2pc4La7qdeM/rK6ldMXhcU6YVW7tjqwNXtI=", - "h1:nKE1gnLZxIoqukQ1YI9EUdmrQIUeAN4PWb5ecN8U9K8=", - "h1:x0hJO5+On8FaKExr4p2cNJhWsNWFZq1EiDD6CfVwy2E=", - "h1:yPH75sRH+f3aJlJAloOL/BikeZV6/0GP8VQvnJoMRKM=", - "h1:zCWB5ZD98/ZC0a50HTGoC/fTAseh189xxCFEL5Mt7r4=", - "zh:06e09ced9480ae12578122f7a25758a15d8fe684da0f6a0a61b9bc2f4a4918ad", - "zh:2035805f0ed8bf81d493e7a52f22965b3d5d402687a95d1caa8c4b1b348c1264", - "zh:25fe72a3d6a330eab6c8957f9e6bdf297ffdce95fa059fef30b80da764bee6b2", - "zh:49df644d19e39b9947e84609260028687057191ddd941783c0211386ade53040", - "zh:4d8438a5d25f18eb376c8375c70f81afb79d0fc1e63ebb6df1d0e02287964dde", - "zh:5cd9717e819506132126a896e959cd4cf1bb213c033c37777c9d01a593937e2c", - "zh:6955caa4f435373ae870de31bdda85e51c60b68c51a4206df5a21b853bcefe21", - "zh:82a413500c35241745e097797610d2bff57c26e29f34ca711182fdde5c265d13", - "zh:831f78acce42a759a977769b0409387ec13ff64b4f46c24eb7e7662e0f352525", - "zh:88648a159119a0435bf86c6cd1f7482dc43dfa2eb742f9b29053da1ee9fdabd8", - "zh:9fc745d71a2e36dbdae0ee69be70675509e5a9dec1a3c5a9be6007e568d78c07", - "zh:bf6d11d6ed1655f61f70eb2906e5d1f7ff5e78b6539dcfb3116ed6f8960c9e20", - "zh:ca17a6ca363afe930ad3474966d39cb549b7f1e5efdca909972dd26f90eefc89", - "zh:d7e9cc87ada1314e6d8ecc5849385a8f8f45757bd2c145b8657f015c65e5078d", - "zh:eddb4d6d86700788d132ba2a83d306646ccb2a3a0cec0a0ab3e215307c61d8f2", - ] -} - -provider "registry.opentofu.org/hashicorp/null" { - version = "3.3.2" - constraints = "~> 3.0, ~> 3.2" - hashes = [ - "h1:1T+00cjQNmRAHAz9xjEBFpf5wRRb0IBuXS/W8ke5BWs=", - "h1:46gmIYe+klib6TlHKSqEkMLjvnzVWiCB2NYA2zR8MX8=", - "h1:7WQ3wjfaeqnXxq+a8cYiYeWUnMTgY1JcuX+z7sZd72s=", - "h1:MVM+vkVtW/YyKfn111pyho0y87I4TekaNMbBLkn0/C8=", - "h1:QBcIbI2Dp4v6Iui37pn4qmw8YeiFLbSWcJuzZVl/65Y=", - "h1:SsVKTUR+vgLaC1YnoDa2fnYpzREcgNgWRcu5x+vwjHA=", - "h1:WUaeuTNn9w6UXZ9cMq4+qZy4ZAr71B9NcUDEXjqfdKs=", - "h1:WtEaA7alasNwEQ4L3+KyQtbkSOPsexzJ4LUZ7PKaycI=", - "h1:WxS7rjYIZ1WQc4GkICch8XrbxoSY8TjUfLbPDo6oEcQ=", - "h1:Ysvc/FPvcwk+iMg7IcLkqZhT/KhtZTYji+UBqMlcTs4=", - "h1:ZLjbXnfVcRvS/DAN3BcNebOsnOcs3Nx6mJpFCj4dZ2c=", - "h1:fAmvQjIGyqdGMc+v/fUEINCyuU4iaKSzsV8PWsOnmAc=", - "h1:jwkbEtf3S7W+Bl4soynNkUHFfK/4I/H74urHY758XVw=", - "h1:qY1sKzlxNTp/dqZR23bM4egmVMRlaQudLlBYraMt1pw=", - "h1:t8H1KNwJQwKE/GqpHeRxOWgMk0Yv35qbBTBzqB/rhr0=", - "zh:09e94b0b7dfc0c6450c247517b5410546039c758e513d89b588af6df70c3d57d", - "zh:0b72497b6fd79a2b04785b64890a565a8cc7b06ded95da05e6dab2f3b8a02d58", - "zh:1c0ee6f81f7bcdec8d568a145a450eb57a6f1cfe5e48943375d1af22ed54151e", - "zh:1c6899b475f035d352af1e7f33dc30beab8b8e3784f8cb55a2cc4a11997fbd66", - "zh:43e57a2a56e9874604501bdebe431bb573fb77d2c5f4d7598ab30727dc0e90ea", - "zh:49cf2f36298a5ac3ac8d80ceb87466e6a99d2c021005bcd9e3a79f2314fd0a13", - "zh:665be40d2c7f3d768b8f39a041371526d4b7b396b4c11e162a1886212da176c9", - "zh:6bb1583d88ddb38b1c6b4624e25ad414ddd8bf65b0dd9c074580847311f83924", - "zh:71d64453bdc795667e9841d7c90e3fef6ff157e0598d51dac4bb1e4583b85407", - "zh:73ac02bc3b680e1ea75aab24ec2a359c8f0a021f73d43b2feae0a899e75a93ad", - "zh:b2b777ee07b910e7345df85321fab6c9a25c33ecb56129758dda8fadaba09fe4", - "zh:bb984d52880749a49e509e3b243804869e1af40ee2d34322a30f5f340f8d8dbd", - "zh:e0724bc083527343b4a4099fd4f95511e49a0e113416cdba58a64446742b68b1", - "zh:e391c14e367cd64d986ddc8d81f2db76d49600ce0521720618ff1ecc0decde7f", - "zh:e95c1af8e8e9967d678cfa7c77ca229c863a68f7c9a85318bf08f26628afe338", - ] -} - -provider "registry.opentofu.org/hashicorp/random" { - version = "3.9.1" - constraints = "~> 3.0" - hashes = [ - "h1:38E2VQmQDhws/3AL3D/EzBGuCseepyZRIswAOx8CqoQ=", - "h1:7+qv9kpOpBC9EUPCubnPxh603tu3l9EIMMBkpbt1H1Y=", - "h1:CEQeHfnUDB3uqAkKoEWfWgbj+kpoQHgcuPbAjPzbh+U=", - "h1:HPYO9tf8KUSHqSdz1uOL97MLeaVHPaWPY1JW6tKU19E=", - "h1:KYXiC06Pr3WJcIUbDq9MgdAbInO5zcRyHFqV1x5UcJg=", - "h1:MygjbYH8CrPv8RUe75tZAFmrFNIzQLT45fiyYx7u2tI=", - "h1:RMSARNOw4qZx+VmHYnVMGljsFVfCV1P+nJjKrN2XIOI=", - "h1:U/71jbSbfsfVLxWpSlhyVHh/DnQXQhjoJCGkyongkBA=", - "h1:WwLLvRE1q95CTGxyTjKpctXJ0ooVs9d22JAQS9fC3uo=", - "h1:ZtRBSqoyfQAhngjUjM0NRPtj6NdSJ/JBENFwT8D276s=", - "h1:cfxedZLduhHD1UtqQDjAQZNAEhr/bWDAZ4nU9rSdySg=", - "h1:i45mo4de0QKOreStMqUQ7qyZL3ucFq42l178Fm5/hMU=", - "h1:v3SAJKN4D3dOM95xKwgKGIWELe5nUBbyXdb5HNz7icw=", - "h1:v3vTk/STekrzNc6NG3jL9/05zhvF1QVCgyRRbvSHPcI=", - "h1:zHgFWtRBgOycqhw8HdLSvMVNWGJtIjOAPYOcAdE7cL0=", - "zh:09aaf19b0d22726d2378e0e89fbbefc183494d7bd585759d6c4e69ba50951a2f", - "zh:31575ca9bc0db20337096d178ea73bce3ebca343ed071c67f78cf39f800c9ec6", - "zh:624fb6ed552abc34a5aaac41e76a373da65ac08e524b09b672f29c60e6ac896a", - "zh:6a4760d55132b9750ac1a04f6fc32e247034daa999f71452dba9cbca225a529a", - "zh:768a6047cfb8958e7b0b120c580aa3de6624a7fbb2c56ad6df85cd559ed26ec7", - "zh:8983c788ba660bcb587e64ff9c3e4323515caf78facbe0abe6432e7aff8df893", - "zh:8d570eb026a4f00b58a1d36be0ce3c13adf4d973efcd4162b05cb295bbc14257", - "zh:a2259540854d5f699c36b89244fb202ebb2c219b64669a51072687d04fb47152", - "zh:aaa51d905b0e80a28e02f9bee2cf6c91ffade7389d77ab9198aa12809ed04955", - "zh:afb60995e98573facddfb47baedf7e288408680eb00b5d3df570611758947c72", - "zh:b9a46d852ce53fa037f47537a7de53f37b759ccf211600b7ba44c66ba4b616b7", - "zh:bafcfeeefcd0dfefeff120b655b45edb0497c4717534ffe5201b3cb556d1ffe6", - "zh:c3ac24d397eae054aca2290e20943e0c767592cc661c890850c25ac01829308d", - "zh:eafba4127ebadcc5ed0e427935c66fb5e2da7cfdaae39a66d52f4a50d51faf1e", - "zh:f39d4bce213ed9bba3474bad468136af08ff6c4c33adaafcc10c1f78067adfe3", - ] -} diff --git a/examples/migration-test/v2/README.md b/examples/migration-test/v2/README.md deleted file mode 100644 index fbe9d46a9d..0000000000 --- a/examples/migration-test/v2/README.md +++ /dev/null @@ -1,41 +0,0 @@ - -## Requirements - -| Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | -| [aws](#requirement\_aws) | >= 6.33 | -| [random](#requirement\_random) | ~> 3.0 | - -## Providers - -| Name | Version | -|------|---------| -| [random](#provider\_random) | 3.9.1 | - -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [base](#module\_base) | ../../base | n/a | -| [runners](#module\_runners) | ../../../modules/multi-runner | n/a | -| [webhook\_github\_app](#module\_webhook\_github\_app) | ../../../modules/webhook-github-app | n/a | - -## Resources - -| Name | Type | -|------|------| -| [random_id.webhook_secret](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [aws\_region](#input\_aws\_region) | AWS region. | `string` | `"eu-west-1"` | no | -| [environment](#input\_environment) | Environment name used as the resource prefix. | `string` | `"migration-test"` | no | -| [github\_app](#input\_github\_app) | Test-only GitHub App values used by the MiniStack fixture. |
object({
id = string
key_base64 = string
})
| n/a | yes | - -## Outputs - -No outputs. - \ No newline at end of file diff --git a/examples/migration-test/v2/main.tf b/examples/migration-test/v2/main.tf deleted file mode 100644 index c11330dffb..0000000000 --- a/examples/migration-test/v2/main.tf +++ /dev/null @@ -1,300 +0,0 @@ -locals { - environment = var.environment - ami = { - filter = { - name = ["migration-test-linux"] - state = ["available"] - } - owners = ["self"] - } - - pool_config = [{ - schedule_expression = "cron(0 0 * * ? *)" - schedule_expression_timezone = "UTC" - size = 1 - }] - - multi_runner_config = { - large = { - runner = { - os = "linux" - architecture = "x64" - name_prefix = "migration-" - extra_labels = ["migration"] - group_name = "migration" - disable_default_labels = false - run_as_root = false - run_as = "ec2-user" - auto_update_disabled = false - } - orchestration_provider = { - webhook = { - runner = { - boot_time_in_minutes = 5 - ephemeral = true - jit_config_enabled = true - maximum_count = 2 - } - matcherConfig = { - exactMatch = false - labelMatchers = [["self-hosted", "linux", "x64", "migration"]] - priority = 10 - } - queue = { - delay_webhook_event = 5 - job_queue_retention_in_seconds = 86400 - visibility_timeout_seconds = 45 - } - lambda = { - scale = { - up = { - memory_size = 768 - timeout = 45 - reserved_concurrent_executions = 1 - job_queued_check_enabled = true - event_source_mapping = { - batch_size = 25 - maximum_batching_window_in_seconds = 10 - } - } - down = { - memory_size = 640 - timeout = 70 - schedule_expression = "cron(* * * * ? *)" - minimum_running_time_in_minutes = 1 - idle_confirmation_seconds = 10 - } - } - pool = { - timeout = 90 - reserved_concurrent_executions = 2 - config = local.pool_config - runner_owner = "migration-test" - } - } - job_retry = { - enabled = true - delay_in_seconds = 60 - delay_backoff = 2 - max_attempts = 2 - lambda = { - memory_size = 256 - reserved_concurrent_executions = -1 - timeout = 30 - } - } - } - } - compute_provider = { - aws = { - ec2 = { - ami = local.ami - instance_types = ["m5.large"] - create_service_linked_role_spot = true - ssm_enabled = true - detailed_monitoring_enabled = true - binaries_syncer = { enabled = true } - cloudwatch_agent = { - enabled = true - config = "{\"metrics\":{}}" - } - metadata_options = { - instance_metadata_tags = "disabled" - http_endpoint = "enabled" - http_tokens = "optional" - http_put_response_hop_limit = 1 - } - on_demand_failover_for_errors = ["InsufficientInstanceCapacity"] - } - } - } - } - } -} - -resource "random_id" "webhook_secret" { - byte_length = 20 -} - -module "base" { - source = "../../base" - - prefix = local.environment - aws_region = var.aws_region -} - -module "runners" { - source = "../../../modules/multi-runner" - - prefix = local.environment - aws_region = var.aws_region - - experimental_features = ["multi-runner-v2"] - multi_runner_config = local.multi_runner_config - - global_config = { - tags = { - Example = "migration-test" - Feature = "state-migration" - } - runner = { - os = "linux" - architecture = "x64" - } - } - - global_config_github = { - app = { - id = var.github_app.id - key_base64 = var.github_app.key_base64 - webhook_secret = random_id.webhook_secret.hex - } - additional_apps = [{ - id = "1" - key_base64 = "ministack-invalid-additional-key" - installation_id = "2" - }] - } - - global_config_lambda = { - architecture = "arm64" - } - - global_config_orchestration_provider = { - webhook = { - eventbridge = { - enabled = true - accept_events = ["workflow_job"] - } - lambda = { - scale = { - up = { - memory_size = 768 - timeout = 45 - } - down = { - memory_size = 640 - timeout = 70 - } - } - webhook = { - memory_size = 384 - timeout = 20 - } - pool = { - memory_size = 512 - timeout = 90 - } - } - queue = { - visibility_timeout_seconds = 45 - } - } - } - - global_config_ssm = { - paths = { - root = "/github-action-runners/migration-test" - app = "app" - webhook = "webhook" - tokens = "runners/tokens" - config = "runners/config" - } - housekeeper = { - schedule_expression = "rate(12 hours)" - state = "ENABLED" - lambda = { - memory_size = 640 - timeout = 75 - } - config = { - minimumDaysOld = 3 - dryRun = true - } - } - } - - global_config_observability = { - logs = { - level = "debug" - retention_in_days = 30 - class = "STANDARD" - } - tracing = { - mode = "Active" - capture_http_requests = true - capture_error = true - } - metrics = { - enabled = true - namespace = "MigrationTest" - metric = { - github_app_rate_limit = { enabled = true } - job_retry = { enabled = true } - spot_termination_warning = { enabled = true } - } - } - } - - global_config_compute_provider = { - aws = { - ec2 = { - vpc_id = module.base.vpc.vpc_id - subnet_ids = module.base.vpc.private_subnets - ami = { - housekeeper = { - enabled = true - cleanup_config = { - minimumDaysOld = 1 - dryRun = true - amiFilters = [{ - Name = "name" - Values = ["migration-test-*"] - }] - } - lambda = { - memory_size = 300 - timeout = 120 - } - schedule = { - expression = "rate(1 day)" - } - } - } - instance_termination_watcher = { - enabled = true - features = { - runner_deregistration = { enabled = true } - spot_termination_handler = { enabled = true } - spot_termination_notification_watcher = { enabled = true } - } - } - runner_binaries = { - enabled = true - syncer = { - lambda = { - memory_size = 256 - timeout = 300 - } - schedule = { - expression = "cron(27 * * * ? *)" - state = "ENABLED" - } - } - } - } - } - } -} - -module "webhook_github_app" { - source = "../../../modules/webhook-github-app" - depends_on = [module.runners] - - github_app = { - id = var.github_app.id - key_base64 = var.github_app.key_base64 - webhook_secret = random_id.webhook_secret.hex - } - webhook_endpoint = module.runners.webhook.endpoint -} diff --git a/examples/migration-test/v2/providers.tf b/examples/migration-test/v2/providers.tf deleted file mode 100644 index f24f950b27..0000000000 --- a/examples/migration-test/v2/providers.tf +++ /dev/null @@ -1,9 +0,0 @@ -provider "aws" { - region = var.aws_region - - default_tags { - tags = { - Example = var.environment - } - } -} diff --git a/examples/migration-test/v2/v2.tfvars b/examples/migration-test/v2/v2.tfvars deleted file mode 100644 index de69c5d91e..0000000000 --- a/examples/migration-test/v2/v2.tfvars +++ /dev/null @@ -1,7 +0,0 @@ -environment = "migration-test" -aws_region = "eu-west-1" - -github_app = { - id = "0" - key_base64 = "ministack-invalid-key" -} diff --git a/examples/migration-test/v2/variables.tf b/examples/migration-test/v2/variables.tf deleted file mode 100644 index af2e9a7aae..0000000000 --- a/examples/migration-test/v2/variables.tf +++ /dev/null @@ -1,20 +0,0 @@ -variable "environment" { - description = "Environment name used as the resource prefix." - type = string - default = "migration-test" -} - -variable "aws_region" { - description = "AWS region." - type = string - default = "eu-west-1" -} - -variable "github_app" { - description = "Test-only GitHub App values used by the MiniStack fixture." - type = object({ - id = string - key_base64 = string - }) - sensitive = true -} diff --git a/examples/multi-runner-scale-set/.terraform.lock.hcl b/examples/multi-runner-scale-set/.terraform.lock.hcl new file mode 100644 index 0000000000..c96d2b19bf --- /dev/null +++ b/examples/multi-runner-scale-set/.terraform.lock.hcl @@ -0,0 +1,93 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" + hashes = [ + "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=", + "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=", + "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611", + "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d", + "zh:06e0b58b2d1eddb5137fc86bee7ad2d07953c0bc3f57cccfc5ae0d2456068a3a", + "zh:07221735d61ababed84734e5ffcfc5bd59d01f29f029166ba5f2175895dceed1", + "zh:1a72db00583112bdb8c19b213a78a3f5de754fffc08f07e061f4e326289fab7d", + "zh:32968e74a53b03e97a084dc7050c22ef661fb5b3ea8a44f5a63e47bc45ad0e7c", + "zh:4b357dfe4b820e3e4acd2881cff8288b2186491e63416751f0d12692ba478ceb", + "zh:81e30884d7de686265e7d87bb92527e802878c65a378470ede2a1e9f4e40ccc9", + "zh:82e137297f6a5a08b9ce2138f7aabea245ad99495d9d9eff502f752d6ca90dbd", + "zh:8eb83b67099f0ea9df238a979dff933ff50ce06a2e3ff05a48556a10f10dd204", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:d0ba30886cbe41850fee689f51ef9088578f323cfd21817bb409951d43c465eb", + "zh:dd48e7089784454bc03d713e9057f5ca0ea1613bd402125054a51894957b7925", + "zh:f250fa81e54cf60fcb0e9c0fc4ac043f1ecc2ac24967f628b3609364fcab3d04", + "zh:f38fc09fc25a8d2cf89a4d4cd6a5ef7cb1aad72798dbdcad58b8876b6a551a54", + "zh:f7c7380fdf126e1901f2084588dbfd724c76cb131ccfa795a541219111103c06", + ] +} + +provider "registry.terraform.io/hashicorp/local" { + version = "2.9.0" + constraints = "~> 2.0" + hashes = [ + "h1:9rBZCMNpxKwMlRbWH2QpwD3kqUCAejdOZQ/aiiDObXQ=", + "h1:m24fjcInWvTVZ1XSo2MaNuKPe+X/gfG8SIi09rA7a7M=", + "zh:0baa4566cf77f1ff52f4293d1c8536202dd23edc197c3196413a28343c3ac3a0", + "zh:16b5559c3c07088ddad11a9bb9e9c0799999363c2958e9a5be2bcbbf2cd9ca64", + "zh:197c79015a10d1cce904a8ea722cbc750c42aeae2da53f44a6a0751d9fd1aa90", + "zh:29d0b03e5343a80677ebfeb2e2c31cbe4b1f65e736e53417454a4277fec2544c", + "zh:4896bfa6cf1d2fd562b47ef2e87f47862ae92a04f8ad5d764380f0c6653473b8", + "zh:531f8529cbca49f681883e57761a05a8398afaef6d1ab0d205d26bf12f4428e8", + "zh:6aaf5011d83161c86d2bfb80c0923ec934e578288758da2f37acb7aec129004b", + "zh:7430275253d3d3c40aa6179e0ec0d63212874dbbc06c5a51b9d07ec590f9756c", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:be17dc611e95e26cdf6cad79dfccf1064f0e32032a2efeb939a9bbe7fb1cbfe9", + "zh:f0e3b0aa644202e1d79d2000dca91f6019425da71e9800fa23f27e51c034f195", + "zh:f62bae4519e4ead49182ddc8afe8cf61e2a4c3ba3973b0fbba967736a2696aa3", + "zh:fcafa360a5b0b96244f26f4e3a6d642b716a376557142c2442ff2fb12d11da18", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.3.1" + constraints = "~> 3.0, ~> 3.2" + hashes = [ + "h1:TuxJq10DVnRP7c5HBZPyyvQGcckNVfijyU1eXEu5e4M=", + "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=", + "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44", + "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451", + "zh:14b3fa4372754b54844b41d5dbd4671a292d8d6828b90169061feb4d7b15dd05", + "zh:56a4daaa3212f57b764bf3d1f333141c6610c5f21abb240e0111221f7c7fa4d4", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7e888a026dbacd2474a42264227ae35f639780f0f0c613529d10a95cd61988b3", + "zh:85a53646267e87d600df7124e4767ffde9bba3b6356d45d961618bdd68131cc7", + "zh:8ffa0e9c7c39b2ab0905b472465d6e35ef0b776b3f6273bb34c150340b61bff1", + "zh:9846510a1841530d4403f4818e233f91e3b3bade7441047599fbf800742f65be", + "zh:afa98d44860875f037c6def0a7e6ff208e042712ba771f620482b143cd336891", + "zh:bdca130d9ef27488ae0b13bc8fd8019e8bbdd4f2ceff29da066bd333165d68c5", + "zh:cb3b94cbca88210dd0d1f11e2b8a89333f48c3857faf8f70f589072ce7c28610", + "zh:f0c0ba87925fe32f84b80f7513b1efb1b0866f51f899ba825e95ad59ff09b018", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/examples/multi-runner-scale-set/.terraform.lock.hcl.tofu b/examples/multi-runner-scale-set/.terraform.lock.hcl.tofu new file mode 100644 index 0000000000..577519c08d --- /dev/null +++ b/examples/multi-runner-scale-set/.terraform.lock.hcl.tofu @@ -0,0 +1,150 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.63.0" + constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" + hashes = [ + "h1:1jhQJPHOPu2mzDG/ke3tK8PNcEqQHA4vhF05WWlM/yg=", + "h1:3+pvT0KN/bkJ6TBuExj+gxptEozhnpo80Ztblwq85eo=", + "h1:5aTequ87wZS7Mh4dEIayDGKcFdaFgHtw74NtqY5Idi0=", + "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", + "h1:OTjECFWTDxsjcUfOKCNBp75Z5lGrW/KplRDsjTZYT2g=", + "h1:b8LORLOKMOOl+nK1M2UhCjELSjjziClJuAv6hYuySHs=", + "h1:bUfTX1giRLOyfDbBvsDbwR3tJmsTFRWcOTQdj2npDWA=", + "h1:dzs4kwx+itVGAH7yEOyeoWcE3LNRMnWtlt4ROgyAa0M=", + "h1:lnjou+SiwpYJ+j9PXWozXPHSPlhxIZb0RqpsSEBzfGw=", + "h1:pqzUeHAQj9NctgkwaynaF2aB+3QiZXcoslzMGjT743w=", + "h1:qTXEWOWxA6sfUpC29UXrsbHnNzWH7+j1RTUVG4YCm+U=", + "h1:qdHKOKt/ISn9RLjUe22OZBpN3F7H2DFeHJL/CSc2x8E=", + "h1:tpNzIZBzzUW7/kLU3BhYf3jhdO5uNwYfNmgC9B8kvMM=", + "h1:uVVlFgjg6GyxJLbCsTO1+R5fTNbZ73mLpVpSd0mMrFk=", + "h1:xGJsV5IFf7c11cXzJrsY40hiJCghp4odT0eJyTyAUYY=", + "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", + "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", + "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", + "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", + "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", + "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", + "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", + "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", + "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", + "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", + "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", + "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", + "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", + "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", + "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", + ] +} + +provider "registry.opentofu.org/hashicorp/local" { + version = "2.9.0" + constraints = "~> 2.0" + hashes = [ + "h1:1dtKYW/5a1qob3yneL6WzOlnSGfYtJ6a2XeejCk9yb4=", + "h1:5NseXq5wU8O20ersTtV4ocrLYFFtgFr7n0pRLO1W2Rw=", + "h1:5d22ZPPK4iiygPbwRz/PJF5Es/0axVpMlPRpCR0Padw=", + "h1:AnwyolirmIlBMjH6+tV8bKkvT+5axJNYxi2y2IguiX4=", + "h1:PBp+HeseY021Fw3sLznCG27idgwPoff4cBuNmKgPL2w=", + "h1:VDxIhe4GbzdOCdmt7mQaqdwERQW6GSI7Roonts42Gr0=", + "h1:ZO6eWWnf8LjjV1q/JNeL9WLtZ6fwIttOnyN5LjCNSEo=", + "h1:dPIAf8oUAz+vW2E0iZunMvpuPddRZIztRsPSY1u+VnY=", + "h1:fwTDVG9AhFVKQZIb1EXkHv4FqzsZNlLWgkyPGDmZZEE=", + "h1:kDc465XPC7/6XFCjrMC4mTqhA9ef0FHKuJ3ZgfGNfeg=", + "h1:kGbjxrI2P8MHeyVtE1U3Q1TbyF71ExnHxtkrE+Aj6UU=", + "h1:kcoK6Afbsj54u9zaEqpecWAFKytqjBijtguCNwV3d4M=", + "h1:rxomJjDwOo+YZ+WIPc25FqEgsz9orh/2MCyUcZmFjvw=", + "h1:t0CMn/Rkwquw8l2yQ+O4ApzbMZfY2UazbsDnZygzACA=", + "h1:tJwgm2BS4xCGlElCDQEFXQoefY9Y4t0JdSKTtsPBbBo=", + "zh:13ef7ecd1e397ec5b20ea588508dd3e3b8d6c50d809ae76b079abf9dd8d02e4b", + "zh:2190c9325980076489ce02b0f5dd2c0b91fc8711cefa99e714d8619a32827ad1", + "zh:2a0cfc5600730093705071707e4a4e4e953e7d9091859e0f66b46daa1060dd5d", + "zh:2ff53eac1af43ab9a2248a0e53c963d46e19cf04bc4c3f323591cfcebb218252", + "zh:4ebc3dee700f60af9da29970052fd02fa947813162b224716862dc9d7f1f7542", + "zh:5fe6dab84ceeaa8eb3f1567c5f05578333370c472240ca5c5bfc25e92d4d5586", + "zh:66bbec16367bbf440045502c9779b11f4ac5b022c8d8d17afe12d431950838b5", + "zh:7641e5c2e4b529e869cde29ab5b1de2fd1091489eb745b19ac2709bd7f4dfd84", + "zh:855bfba0756d17ce07595ff57d7cf664443d1495127cb88fb063362734b8b22a", + "zh:aaec10f237921d60c581d1b7a66f0a8a8019d9802dc04af11b5b981f6682e01d", + "zh:e460835a38ffa1e74f6929904bfd14ef473d217fd537b7ce834abe5ce5e2ce07", + "zh:ecc4295215db0e4aea3c9329611c31e09a853e1ae207d56742403bd4f5516703", + "zh:ee6d9fae63a612072e00402894e14826af7a3351c235b9c5b423b7629a77ca29", + "zh:f2b5c8db74aa7ebcf7cd423672358437d42401675069ef67b01ff910054e49d5", + "zh:f5aff74d3eb96d4592c7bca5cd3ea89b469e84efbf382944bd0f844a57059c09", + ] +} + +provider "registry.opentofu.org/hashicorp/null" { + version = "3.3.1" + constraints = "~> 3.0, ~> 3.2" + hashes = [ + "h1:2wld81FnmHW0WVgy081sIfokCr2+NuatS8yjeLEet7Y=", + "h1:AClQjJ6X22V4qcRgcYSxiXCMmp2pz0G8WVQC7wAx66o=", + "h1:AY3XQbuviNd2X5VhHYEbhNta1m/CG3JD2BKFKhCt1Y4=", + "h1:CUOZUd7H11lsU+4tISlnYIiP5BqnX8IDwFCVqfLJyAg=", + "h1:JIfV0nA/pLWnIFGscvTfuavQCn2NeHxJBeb6UUg/joA=", + "h1:RejAh+nyCwqDGExGln2Kb4Ro5LyHak0eJe0P9g8CHPc=", + "h1:SHOuTZjYymsmy4asuRq6NC3yW+zdVZOOt4f5nrb+EPM=", + "h1:WwPat/gT4gO8GvvKNdSkkXWVD65JppLJfqKOt9HhOqQ=", + "h1:Z3hXVLrOyaRiiLmmL5UCOdcRMguwjN1x5TYNdmBDgls=", + "h1:dd78Ad5HdfPzPts7A9qIxfitXhAriV/qza38fr2ukjk=", + "h1:dyVb++KwDdybzLTE6bf7GZiVQ31iWsgKPWmhTQ8G42k=", + "h1:gD8ZH6WWe+5gg5+y8SpLWGPUDzSxcQ3HKP8IDM/wW3I=", + "h1:juXCww0zRQKFTDZoKqYR0+Sn1lu99oeL6pr0Jh6LWx0=", + "h1:kFAySmtsshyNV7IhIrEdASzVcvwy68eeZCVC66P7yNk=", + "h1:nS5azDopRisB2NInwDx3Hrfg2FdVt8Gw0gTQzC0rd70=", + "zh:164eb061d84e01759f391265865fb31828083d0a06b25f7af7e094cbdb18c799", + "zh:1bb9b669a82b52c0cba2860c71e9ee6699ef302f28cb8ed06f572d39bc6c7c4f", + "zh:1ea9b31a8f29302122c1e8d673693f3ac270336dae560af803cd1117265a469a", + "zh:238bd463cb0154fb935dc331da40c0a9cbe5db9cee615ae5f35ccad5eed7dc41", + "zh:30ef2b7384cf7e20f33fe75754b54cf669d59816f3ad4fc73bfb2b26fb6735e9", + "zh:35b5cded16e4b57c207d03ee0979b14baf486fa520e6edb7a2eecf18f1b85471", + "zh:3dc840d13a50cd215c7540573f27e2b61f739ba90aee5b7c3846079aa0ab5534", + "zh:3f9309a18db608f975d5691fcb47a6e14d77199156a52e9c39dcafe3737f2b07", + "zh:44263a219f7dbd1848b545d080110b4f7d0495e77b71cd3c7a0b5ec52a09accb", + "zh:4dec54aa5f445eeea035bbd4839bcded5e47ecd07cba0e70c5a09e9272cb592f", + "zh:5e8fb319d7c6d6c4566a18b9d0c91580b4901a96acd7fdc476bfc79f074368e2", + "zh:b0e8b6d41834b57fcfbb5ca00da52ccb757e1a95b6a2d546c0dae8bfbeca1cdf", + "zh:bbde4c3a1dcc1718027a61a4cdf661619d17af1b58df1038fe27bcf43c3dc29b", + "zh:c4140fff9f692baf29236557f706f9515f93229413438527d764023a82301da3", + "zh:f8e9d83184e4bbeb97c6f0d569833007c48ba5a7ff334def201df4991d03a962", + ] +} + +provider "registry.opentofu.org/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:8EQU5KSxezcjo/phRSe69rDOI0lk4pSaggj7FsskYp8=", + "h1:Lw9im2VBBJQ3RyAbHPQ0rcvcmmcZWm3x+kIOpN+Tv9s=", + "h1:U8KXqGCoNI9/guYbTvzgdtVk3fRthoG0UXwm1JoEpIs=", + "h1:YXaVd4p6qXPPVaxIBaIDNXmBwT02ZqDn0qD+tYpw8sA=", + "h1:cOpc03fphEt/G9Rfc4jLL/fW0D7tgvlXqiDKPF4vuww=", + "h1:g09RR7T1xWkeGrZwWvWMT9ncJrFGr1k3CBD585UmO7w=", + "h1:gGDdPPibmw2EWROx+sh1RGLjR5+nPwZyrf6/N9jXfeM=", + "h1:haE7/nXCOhXKP4oXeEnER3t5CaVQWqujz4nBnpeTUv4=", + "h1:ieSVpfZS2lKuMr05ph0QsOVpCzg7uk3cgKBaXR+Ikug=", + "h1:ig2s1IS9IzehorRjvVAnKIsUUj8fkgyxct1L/kswcc4=", + "h1:j3lS+ZEERFnoab8t1ppDrScGVP/cgWbzlCrEYKTCXYw=", + "h1:lxezrKmOiQIySHAM+os8qLVq7hqufDr8h3Hpzvsk+78=", + "h1:lzRqBJAG+NETxHbEZUJ/YP3RMEjZBinTX7VmgH3lw60=", + "h1:tdSNWK5ApqUsgbdYieyeYLTu6nIZUV3hR1oFqUfAuGo=", + "h1:xedet8yH/zI2CfdxsGlK0nlFWc/Bp61yrWsEa3fHB8g=", + "zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc", + "zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a", + "zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2", + "zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1", + "zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9", + "zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d", + "zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae", + "zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a", + "zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261", + "zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c", + "zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627", + "zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e", + "zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1", + "zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5", + "zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64", + ] +} diff --git a/examples/multi-runner-scale-set/README.md b/examples/multi-runner-scale-set/README.md new file mode 100644 index 0000000000..d7119a30be --- /dev/null +++ b/examples/multi-runner-scale-set/README.md @@ -0,0 +1,84 @@ +# Multi-runner scale-set example + +This example demonstrates the experimental multi-runner v2 interface. Shared +defaults are configured with `global_config*` variables, while +each runner lane uses `multi_runner_config` for its matcher, +runner lifecycle, and compute-provider settings. + +The example creates four lanes from one deployment: + +- Linux ARM64 Amazon Linux runners. +- Ephemeral Linux x64 Amazon Linux runners with job retry enabled. +- Linux x64 runners managed by a GitHub Actions scale set. +- Windows x64 Server Core 2022 runners. + +The v2 interface keeps provider-owned settings inside the selected provider +configuration. For example, VPC and subnet settings are under +`global_config_compute_provider.aws.ec2`, while the per-lane +instance types and AMI filter are under each lane's compute provider block. + +The scale-set lane uses `orchestration_provider.scale_set`. Its controller +network is configured under the global scale-set block and its GitHub +installation ID is provided by `var.github_app`. + +Configure the GitHub App variables before applying: + +```bash +terraform init +terraform apply \ + -var='github_app={id="123456",key_base64="...",installation_id="123456789"}' \ + -var='scale_set={name="linux-scale-set",container={image="ghcr.io/github-aws-runners/terraform-aws-github-runner-scale-set-service@sha256:"}}' +``` + +The `github_app` value is sensitive and should be supplied through a secure +variable source in real deployments rather than committed to configuration. +The GitHub App must be installed for the configured GitHub account. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [aws](#requirement\_aws) | >= 6.33 | +| [local](#requirement\_local) | ~> 2.0 | +| [random](#requirement\_random) | ~> 3.0 | + +## Providers + +| Name | Version | +|------|---------| +| [random](#provider\_random) | 3.9.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [base](#module\_base) | ../base | n/a | +| [runners](#module\_runners) | ../../modules/multi-runner | n/a | +| [webhook\_github\_app](#module\_webhook\_github\_app) | ../../modules/webhook-github-app | n/a | + +## Resources + +| Name | Type | +|------|------| +| [random_id.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [ami](#input\_ami) | Optional AMI configuration keyed by runner lane. |
map(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}))
| `{}` | no | +| [aws\_region](#input\_aws\_region) | AWS region to deploy to. | `string` | `"eu-west-1"` | no | +| [environment](#input\_environment) | Environment name, used as prefix. | `string` | `null` | no | +| [github\_app](#input\_github\_app) | GitHub App ID, base64-encoded private key, and installation ID. |
object({
id = string
key_base64 = string
installation_id = optional(string, null)
})
| n/a | yes | +| [runner\_binaries\_enabled](#input\_runner\_binaries\_enabled) | Whether runner binary synchronization is enabled. | `bool` | `true` | no | +| [scale\_set](#input\_scale\_set) | GitHub Actions scale-set configuration. |
object({
name = string
container = optional(object({
image = optional(string, null)
}), {})
})
| n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [webhook\_endpoint](#output\_webhook\_endpoint) | n/a | +| [webhook\_secret](#output\_webhook\_secret) | n/a | + diff --git a/examples/multi-runner-scale-set/main.tf b/examples/multi-runner-scale-set/main.tf new file mode 100644 index 0000000000..3bf1fd02a4 --- /dev/null +++ b/examples/multi-runner-scale-set/main.tf @@ -0,0 +1,208 @@ +locals { + environment = var.environment != null ? var.environment : "multi-runner-v2" + aws_region = var.aws_region +} + +resource "random_id" "random" { + byte_length = 20 +} + +module "base" { + source = "../base" + + prefix = local.environment + aws_region = local.aws_region +} + +module "runners" { + source = "../../modules/multi-runner" + + prefix = local.environment + aws_region = local.aws_region + + experimental_features = ["multi-runner-v2"] + + global_config = { + tags = { + Example = local.environment + Project = "ProjectX" + } + runner = { + os = "linux" + architecture = "x64" + extra_labels = ["v2"] + } + } + + global_config_github = { + app = { + key_base64 = var.github_app.key_base64 + id = var.github_app.id + installation_id = var.github_app.installation_id + webhook_secret = random_id.random.hex + } + } + + global_config_lambda = { + architecture = "arm64" + } + + global_config_orchestration_provider = { + webhook = { + eventbridge = { + enabled = true + accept_events = ["workflow_job"] + } + } + scale_set = { + grouping = { + strategy = "runner_config" + } + container = var.scale_set.container + network = { + vpc_id = module.base.vpc.vpc_id + subnet_ids = module.base.vpc.private_subnets + } + } + } + + global_config_compute_provider = { + aws = { + ec2 = { + vpc_id = module.base.vpc.vpc_id + subnet_ids = module.base.vpc.private_subnets + ssm_enabled = true + runner_binaries = { + enabled = var.runner_binaries_enabled + } + } + } + } + + multi_runner_config = { + linux-arm64 = { + runner = { + architecture = "arm64" + name_prefix = "amazon-arm64-" + extra_labels = ["amazon"] + } + orchestration_provider = { + webhook = { + runner = { + maximum_count = 1 + } + matcherConfig = { + exactMatch = true + labelMatchers = [["self-hosted", "linux", "arm64", "amazon"]] + } + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["t4g.large", "c6g.large"] + ami = lookup(var.ami, "linux-arm64", null) + } + } + } + } + + linux-x64 = { + runner = { + name_prefix = "amazon-x64-" + extra_labels = ["amazon"] + } + orchestration_provider = { + webhook = { + runner = { + ephemeral = true + maximum_count = 1 + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "amazon"]] + exactMatch = false + priority = 1 + } + queue = { + delay_webhook_event = 0 + } + job_retry = { + enabled = true + } + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5a.large", "m5ad.large"] + ami = lookup(var.ami, "linux-x64", null) + } + } + } + } + + linux-scale-set = { + runner = { + name_prefix = "scale-set-" + extra_labels = ["scale-set"] + } + orchestration_provider = { + scale_set = { + name = var.scale_set.name + runner = { + min_runners = 0 + max_runners = 10 + boot_time_in_minutes = 10 + } + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large"] + ami = lookup(var.ami, "linux-scale-set", null) + } + } + } + } + + windows-x64 = { + runner = { + os = "windows" + name_prefix = "windows-x64-" + } + orchestration_provider = { + webhook = { + runner = { + boot_time_in_minutes = 20 + maximum_count = 1 + } + matcherConfig = { + exactMatch = true + labelMatchers = [["self-hosted", "windows", "x64", "servercore-2022"]] + } + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large", "c5.large"] + ami = lookup(var.ami, "windows-x64", null) + } + } + } + } + } +} + +module "webhook_github_app" { + source = "../../modules/webhook-github-app" + depends_on = [module.runners] + + github_app = { + key_base64 = var.github_app.key_base64 + id = var.github_app.id + webhook_secret = random_id.random.hex + } + webhook_endpoint = module.runners.webhook.endpoint +} diff --git a/examples/multi-runner-scale-set/outputs.tf b/examples/multi-runner-scale-set/outputs.tf new file mode 100644 index 0000000000..1feaf2e671 --- /dev/null +++ b/examples/multi-runner-scale-set/outputs.tf @@ -0,0 +1,8 @@ +output "webhook_endpoint" { + value = module.runners.webhook.endpoint +} + +output "webhook_secret" { + sensitive = true + value = random_id.random.hex +} diff --git a/examples/multi-runner-scale-set/providers.tf b/examples/multi-runner-scale-set/providers.tf new file mode 100644 index 0000000000..eca2fe96a7 --- /dev/null +++ b/examples/multi-runner-scale-set/providers.tf @@ -0,0 +1,9 @@ +provider "aws" { + region = local.aws_region + + default_tags { + tags = { + Example = local.environment + } + } +} diff --git a/examples/multi-runner-scale-set/variables.tf b/examples/multi-runner-scale-set/variables.tf new file mode 100644 index 0000000000..c41fea1186 --- /dev/null +++ b/examples/multi-runner-scale-set/variables.tf @@ -0,0 +1,58 @@ +variable "github_app" { + description = "GitHub App ID, base64-encoded private key, and installation ID." + + type = object({ + id = string + key_base64 = string + installation_id = optional(string, null) + }) + sensitive = true +} + +variable "scale_set" { + description = "GitHub Actions scale-set configuration." + + type = object({ + name = string + container = optional(object({ + image = optional(string, null) + }), {}) + }) +} + +variable "environment" { + description = "Environment name, used as prefix." + + type = string + default = null +} + +variable "aws_region" { + description = "AWS region to deploy to." + + type = string + default = "eu-west-1" +} + +variable "runner_binaries_enabled" { + description = "Whether runner binary synchronization is enabled." + + type = bool + default = true +} + +variable "ami" { + description = "Optional AMI configuration keyed by runner lane." + + type = map(object({ + filter = optional(map(list(string)), { state = ["available"] }) + owners = optional(list(string), ["amazon"]) + id_ssm_parameter = optional(object({ + arn = string + }), null) + kms_key = optional(object({ + arn = string + }), null) + })) + default = {} +} diff --git a/examples/migration-test/v1/versions.tf b/examples/multi-runner-scale-set/versions.tf similarity index 64% rename from examples/migration-test/v1/versions.tf rename to examples/multi-runner-scale-set/versions.tf index 0d1c1d303f..1dfb3e5774 100644 --- a/examples/migration-test/v1/versions.tf +++ b/examples/multi-runner-scale-set/versions.tf @@ -1,18 +1,17 @@ terraform { - backend "local" { - path = "../migration.tfstate" - } - required_providers { aws = { source = "hashicorp/aws" version = ">= 6.33" } + local = { + source = "hashicorp/local" + version = "~> 2.0" + } random = { source = "hashicorp/random" version = "~> 3.0" } } - - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" } diff --git a/examples/multi-runner-v2/README.md b/examples/multi-runner-v2/README.md index 2755c8fcf5..eafe371463 100644 --- a/examples/multi-runner-v2/README.md +++ b/examples/multi-runner-v2/README.md @@ -34,7 +34,7 @@ variable source in real deployments rather than committed to configuration. | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | diff --git a/examples/multi-runner-v2/main.tf b/examples/multi-runner-v2/main.tf index 4f63974f11..fd21d351ac 100644 --- a/examples/multi-runner-v2/main.tf +++ b/examples/multi-runner-v2/main.tf @@ -20,8 +20,6 @@ module "runners" { prefix = local.environment aws_region = local.aws_region - experimental_features = ["multi-runner-v2"] - global_config = { tags = { Example = local.environment diff --git a/examples/multi-runner-v2/versions.tf b/examples/multi-runner-v2/versions.tf index 6af69ab915..1dfb3e5774 100644 --- a/examples/multi-runner-v2/versions.tf +++ b/examples/multi-runner-v2/versions.tf @@ -13,5 +13,5 @@ terraform { version = "~> 3.0" } } - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" } diff --git a/examples/multi-runner/README.md b/examples/multi-runner/README.md index c3983203cb..960030c099 100644 --- a/examples/multi-runner/README.md +++ b/examples/multi-runner/README.md @@ -56,7 +56,7 @@ terraform output -raw webhook_secret | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | diff --git a/examples/multi-runner/versions.tf b/examples/multi-runner/versions.tf index 6af69ab915..1dfb3e5774 100644 --- a/examples/multi-runner/versions.tf +++ b/examples/multi-runner/versions.tf @@ -13,5 +13,5 @@ terraform { version = "~> 3.0" } } - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" } diff --git a/examples/permissions-boundary/README.md b/examples/permissions-boundary/README.md index 14e745e33e..0258865ac9 100644 --- a/examples/permissions-boundary/README.md +++ b/examples/permissions-boundary/README.md @@ -34,7 +34,7 @@ terraform apply | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | diff --git a/examples/permissions-boundary/setup/README.md b/examples/permissions-boundary/setup/README.md index 996f61fe28..c2ff948e93 100644 --- a/examples/permissions-boundary/setup/README.md +++ b/examples/permissions-boundary/setup/README.md @@ -3,7 +3,7 @@ | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | @@ -36,4 +36,4 @@ No inputs. |------|-------------| | [boundary](#output\_boundary) | n/a | | [role](#output\_role) | n/a | - + \ No newline at end of file diff --git a/examples/permissions-boundary/setup/versions.tf b/examples/permissions-boundary/setup/versions.tf index dea142464e..af642af83b 100644 --- a/examples/permissions-boundary/setup/versions.tf +++ b/examples/permissions-boundary/setup/versions.tf @@ -13,5 +13,5 @@ terraform { version = "~> 3.0" } } - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" } diff --git a/examples/permissions-boundary/versions.tf b/examples/permissions-boundary/versions.tf index 6af69ab915..666b978aac 100644 --- a/examples/permissions-boundary/versions.tf +++ b/examples/permissions-boundary/versions.tf @@ -13,5 +13,5 @@ terraform { version = "~> 3.0" } } - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" } diff --git a/examples/prebuilt/README.md b/examples/prebuilt/README.md index 8852fd0c92..66157bb52f 100644 --- a/examples/prebuilt/README.md +++ b/examples/prebuilt/README.md @@ -77,7 +77,7 @@ terraform output webhook_secret | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | | [local](#requirement\_local) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | diff --git a/examples/prebuilt/versions.tf b/examples/prebuilt/versions.tf index 6af69ab915..666b978aac 100644 --- a/examples/prebuilt/versions.tf +++ b/examples/prebuilt/versions.tf @@ -13,5 +13,5 @@ terraform { version = "~> 3.0" } } - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" } diff --git a/examples/termination-watcher/README.md b/examples/termination-watcher/README.md index 1a26fab494..98f2c8783b 100644 --- a/examples/termination-watcher/README.md +++ b/examples/termination-watcher/README.md @@ -27,7 +27,7 @@ Once a Spot instance is terminated a log line and metric will be updated. Spot i | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1 | ## Providers diff --git a/examples/termination-watcher/versions.tf b/examples/termination-watcher/versions.tf index c5673044db..c934712b56 100644 --- a/examples/termination-watcher/versions.tf +++ b/examples/termination-watcher/versions.tf @@ -1,3 +1,3 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1" } diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts deleted file mode 100644 index 9436700ed6..0000000000 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { isJobQueued, createStartRunnerConfig } from './github-runner'; -import { metricGitHubAppRateLimit } from '../github/rate-limit'; -import type { ActionRequestMessage, CreateGitHubRunnerConfig } from './types'; -import type { RunnerConfigStore } from '@aws-github-runner/storage-providers'; -import type { Octokit } from '@octokit/rest'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -vi.mock('../github/rate-limit', () => ({ - metricGitHubAppRateLimit: vi.fn(), -})); - -const mockedMetricGitHubAppRateLimit = vi.mocked(metricGitHubAppRateLimit); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe('Test isJobQueued rate-limit metric on error', () => { - const payload: ActionRequestMessage = { - id: 1, - eventType: 'workflow_job', - repositoryName: 'hello-world', - repositoryOwner: 'octo-org', - installationId: 1, - repoOwnerType: 'Organization', - }; - - it('records the rate-limit metric on success (regression guard)', async () => { - const client = { - actions: { - getJobForWorkflowRun: vi.fn().mockResolvedValue({ - data: { status: 'queued' }, - headers: { 'x-ratelimit-remaining': '10' }, - }), - }, - } as unknown as Octokit; - - await expect(isJobQueued(client, payload, 0)).resolves.toBe(true); - expect(mockedMetricGitHubAppRateLimit).toHaveBeenCalledWith({ 'x-ratelimit-remaining': '10' }, 0); - }); - - it('records the rate-limit metric using the error response headers when the call is rate-limited', async () => { - const rateLimitError = Object.assign(new Error('rate limit exceeded'), { - status: 403, - response: { headers: { 'x-ratelimit-remaining': '0' } }, - }); - const client = { - actions: { - getJobForWorkflowRun: vi.fn().mockRejectedValue(rateLimitError), - }, - } as unknown as Octokit; - - await expect(isJobQueued(client, payload, 1)).rejects.toBe(rateLimitError); - expect(mockedMetricGitHubAppRateLimit).toHaveBeenCalledWith({ 'x-ratelimit-remaining': '0' }, 1); - }); - - it('does not call the metric when the error carries no response headers', async () => { - const networkError = new Error('socket hang up'); - const client = { - actions: { - getJobForWorkflowRun: vi.fn().mockRejectedValue(networkError), - }, - } as unknown as Octokit; - - await expect(isJobQueued(client, payload, 0)).rejects.toBe(networkError); - expect(mockedMetricGitHubAppRateLimit).not.toHaveBeenCalled(); - }); -}); - -describe('Test createJitConfig rate-limit metric on error', () => { - const githubRunnerConfig: CreateGitHubRunnerConfig = { - appIndex: 2, - ephemeral: true, - enableJitConfig: true, - runnerLabels: 'self-hosted', - runnerGroup: 'Default', - runnerNamePrefix: 'test-', - runnerOwner: 'octo-org/hello-world', - runnerType: 'Repo', - disableAutoUpdate: false, - }; - - const runnerConfigStore = { create: vi.fn() } as unknown as RunnerConfigStore; - - it('records the rate-limit metric using the error response headers when JIT config generation is rate-limited', async () => { - const rateLimitError = Object.assign(new Error('rate limit exceeded'), { - status: 403, - response: { headers: { 'x-ratelimit-remaining': '0' } }, - }); - const client = { - actions: { - generateRunnerJitconfigForRepo: vi.fn().mockRejectedValue(rateLimitError), - }, - } as unknown as Octokit; - - const failedRunnerIds = await createStartRunnerConfig(githubRunnerConfig, ['i-1'], client, { - runnerConfigStore, - }); - - expect(failedRunnerIds).toEqual(['i-1']); - expect(mockedMetricGitHubAppRateLimit).toHaveBeenCalledWith({ 'x-ratelimit-remaining': '0' }, 2); - }); -}); diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index f968fa3008..b344012149 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -7,7 +7,6 @@ import { type RunnerGroupCacheStore, } from '@aws-github-runner/storage-providers'; import { Octokit } from '@octokit/rest'; -import type { ResponseHeaders } from '@octokit/types'; import { getStoredInstallationId } from '../github/auth'; import { metricGitHubAppRateLimit } from '../github/rate-limit'; @@ -127,11 +126,6 @@ export async function getInstallationId( return resolveInstallationId(githubAppClient, enableOrgLevel, payload); } -// Extracts rate-limit headers off a failed request so a blocked call can still be measured. -function getErrorHeaders(error: unknown): ResponseHeaders | undefined { - return (error as { response?: { headers?: ResponseHeaders } })?.response?.headers; -} - // Raised when the queued-check is asked about an event type it cannot interpret. // Distinct from an API failure: no amount of retrying makes a check_run event // answerable, so callers must not treat this as a transient fault. @@ -149,20 +143,14 @@ export async function isJobQueued( ): Promise { let isQueued = false; if (payload.eventType === 'workflow_job') { - try { - const jobForWorkflowRun = await githubInstallationClient.actions.getJobForWorkflowRun({ - job_id: payload.id, - owner: payload.repositoryOwner, - repo: payload.repositoryName, - }); - metricGitHubAppRateLimit(jobForWorkflowRun.headers, appIndex); - isQueued = jobForWorkflowRun.data.status === 'queued'; - logger.debug(`The job ${payload.id} is${isQueued ? ' ' : 'not'} queued`); - } catch (error) { - const headers = getErrorHeaders(error); - if (headers) metricGitHubAppRateLimit(headers, appIndex); - throw error; - } + const jobForWorkflowRun = await githubInstallationClient.actions.getJobForWorkflowRun({ + job_id: payload.id, + owner: payload.repositoryOwner, + repo: payload.repositoryName, + }); + metricGitHubAppRateLimit(jobForWorkflowRun.headers, appIndex); + isQueued = jobForWorkflowRun.data.status === 'queued'; + logger.debug(`The job ${payload.id} is${isQueued ? ' ' : 'not'} queued`); } else { throw new UnsupportedEventError(payload.eventType); } @@ -333,8 +321,6 @@ async function createJitConfig( await delay(delayMilliseconds); } } catch (error) { - const headers = getErrorHeaders(error); - if (headers) metricGitHubAppRateLimit(headers, githubRunnerConfig.appIndex); failedRunnerIds.push(runnerId); logger.warn('Failed to create JIT config for instance, continuing with remaining instances', { instance: runnerId, diff --git a/mkdocs.yaml b/mkdocs.yaml index 4026d30d6e..f9680b9d2b 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -56,8 +56,6 @@ markdown_extensions: nav: - Introduction: index.md - Configuration: configuration.md - - Multi-runner v1 to v2 configuration: multi-runner-v1-to-v2-configuration.md - - Multi-runner v1 to v2 migration: multi-runner-v1-v2-migration.md - Getting started: getting-started.md - Security: security.md - Architecture decisions: diff --git a/modules/ami-housekeeper/README.md b/modules/ami-housekeeper/README.md index c8b36899a0..711a72b39d 100644 --- a/modules/ami-housekeeper/README.md +++ b/modules/ami-housekeeper/README.md @@ -66,7 +66,7 @@ yarn run dist | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers diff --git a/modules/ami-housekeeper/versions.tf b/modules/ami-housekeeper/versions.tf index 1238b79cc3..42a40b33fd 100644 --- a/modules/ami-housekeeper/versions.tf +++ b/modules/ami-housekeeper/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/compute-providers/aws/ec2/README.md b/modules/compute-providers/aws/ec2/README.md index ec44a82fa8..aa2ad83651 100644 --- a/modules/compute-providers/aws/ec2/README.md +++ b/modules/compute-providers/aws/ec2/README.md @@ -11,7 +11,7 @@ EC2 is the only active compute provider. The parent runner configuration selects | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/compute-providers/aws/ec2/outputs.tf b/modules/compute-providers/aws/ec2/outputs.tf index 422383df0f..83b2647fca 100644 --- a/modules/compute-providers/aws/ec2/outputs.tf +++ b/modules/compute-providers/aws/ec2/outputs.tf @@ -16,6 +16,8 @@ output "resources" { output "provider" { description = "Nested EC2 compute-provider contract consumed by runner-config." value = { + type = "ec2" + capabilities = { scale_set = local.scale_set_capability } environment_variables = local.provider_environment_variables policies = local.provider_policies resources = local.provider_resources diff --git a/modules/compute-providers/aws/ec2/scale-set.tf b/modules/compute-providers/aws/ec2/scale-set.tf new file mode 100644 index 0000000000..dcbefd55f4 --- /dev/null +++ b/modules/compute-providers/aws/ec2/scale-set.tf @@ -0,0 +1,257 @@ +# Provider-owned runtime and IAM fragments for the additive scale-set +# orchestration capability. GitHub credentials, GitHub scope, desired capacity, +# and boot timeout remain orchestration-owned and are not serialized here. +locals { + scale_set_ec2_instance_criteria = merge( + { + instanceTypes = var.config.instance_types + targetCapacityType = var.config.instance_target_capacity_type + instanceAllocationStrategy = var.config.instance_allocation_strategy + }, + var.config.instance_type_priorities == null ? {} : { + instanceTypePriorities = var.config.instance_type_priorities + }, + var.config.instance_max_spot_price == null ? {} : { + maxSpotPrice = var.config.instance_max_spot_price + }, + ) + + scale_set_runtime_configuration = merge( + { + region = var.aws_region + environment = var.prefix + runnerNamePrefix = var.runner.name_prefix + jitConfigParameterPath = "${var.ssm.paths.root}/${var.ssm.paths.tokens}" + subnets = var.config.subnet_ids + launchTemplateName = aws_launch_template.runner.name + ec2instanceCriteria = local.scale_set_ec2_instance_criteria + onDemandFailoverOnError = var.config.on_demand_failover_for_errors + useDedicatedHost = var.config.use_dedicated_host + ssmParameterTags = [ + for key in sort(keys(local.ssm_parameter_tags)) : { + Key = key + Value = local.ssm_parameter_tags[key] + } + ] + }, + local.ami_id_ssm_external ? { + amiIdSsmParameterName = local.ami_id_ssm_parameter_name + } : {}, + ) + + scale_set_owned_instance_conditions = [ + { + test = "StringEquals" + variable = "ec2:ResourceTag/ghr:Application" + values = toset(["github-action-runner"]) + }, + { + test = "StringEquals" + variable = "ec2:ResourceTag/ghr:created_by" + values = toset(["scale-set-service"]) + }, + { + test = "StringEquals" + variable = "ec2:ResourceTag/ghr:environment" + values = toset([var.prefix]) + }, + ] + + scale_set_owned_request_conditions = [ + { + test = "StringEquals" + variable = "aws:RequestTag/ghr:Application" + values = toset(["github-action-runner"]) + }, + { + test = "StringEquals" + variable = "aws:RequestTag/ghr:created_by" + values = toset(["scale-set-service"]) + }, + { + test = "StringEquals" + variable = "aws:RequestTag/ghr:environment" + values = toset([var.prefix]) + }, + ] + + scale_set_launch_dependency_resources = toset(concat( + [ + "arn:${var.aws_partition}:ec2:${var.aws_region}::image/*", + "arn:${var.aws_partition}:ec2:${var.aws_region}:*:snapshot/*", + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:dedicated-host/*", + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:network-interface/*", + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:placement-group/*", + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:security-group/*", + aws_launch_template.runner.arn, + ], + [ + for subnet_id in var.config.subnet_ids : + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:subnet/${subnet_id}" + ], + var.config.key_name == null ? [] : [ + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:key-pair/${var.config.key_name}", + ], + )) + + scale_set_create_fleet_dependency_resources = toset(concat( + [ + "arn:${var.aws_partition}:ec2:${var.aws_region}::image/*", + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:placement-group/*", + aws_launch_template.runner.arn, + ], + [ + for subnet_id in var.config.subnet_ids : + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:subnet/${subnet_id}" + ], + )) + + scale_set_iam_statements = merge( + { + describe_ec2 = { + actions = toset([ + "ec2:DescribeInstances", + "ec2:DescribeLaunchTemplateVersions", + "ec2:DescribeTags", + ]) + # These EC2 Describe APIs do not support resource-level permissions. + resources = toset(["*"]) + conditions = [] + } + create_fleet_dependencies = { + actions = toset(["ec2:CreateFleet"]) + resources = local.scale_set_create_fleet_dependency_resources + conditions = [] + } + create_owned_fleet_capacity = { + actions = toset(["ec2:CreateFleet"]) + resources = toset([ + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:fleet/*", + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*", + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:volume/*", + ]) + conditions = local.scale_set_owned_request_conditions + } + run_instances_dependencies = { + actions = toset(["ec2:RunInstances"]) + resources = local.scale_set_launch_dependency_resources + conditions = [] + } + run_owned_instances = { + actions = toset(["ec2:RunInstances"]) + resources = toset([ + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*", + "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:volume/*", + ]) + conditions = local.scale_set_owned_request_conditions + } + tag_runners_on_create = { + actions = toset(["ec2:CreateTags"]) + resources = toset(["arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*/*"]) + conditions = [ + { + test = "StringEquals" + variable = "ec2:CreateAction" + values = toset(["CreateFleet", "RunInstances"]) + }, + ] + } + update_owned_runner_tags = { + actions = toset(["ec2:CreateTags"]) + resources = toset(["arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*"]) + conditions = concat(local.scale_set_owned_instance_conditions, [ + { + test = "ForAllValues:StringEquals" + variable = "aws:TagKeys" + values = toset([ + "ghr:github_runner_id", + "ghr:runner_name", + "ghr:scale_set_state", + ]) + }, + ]) + } + terminate_owned_runners = { + actions = toset(["ec2:TerminateInstances"]) + resources = toset(["arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*"]) + conditions = local.scale_set_owned_instance_conditions + } + pass_runner_role = { + actions = toset(["iam:PassRole"]) + resources = toset([var.runner.iam.role.arn]) + conditions = [ + { + test = "StringEquals" + variable = "iam:PassedToService" + values = toset(["ec2.amazonaws.com"]) + }, + ] + } + publish_runner_jit_configuration = { + actions = toset([ + "ssm:AddTagsToResource", + "ssm:DeleteParameter", + "ssm:PutParameter", + ]) + resources = toset([ + "${local.ssm_parameter_arn_prefix}${var.ssm.paths.root}/${var.ssm.paths.tokens}/*", + ]) + conditions = [] + } + read_ami_parameter = { + actions = toset([ + "ssm:GetParameter", + "ssm:GetParameters", + ]) + resources = toset([ + local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : local.ami_id_ssm_parameter_arn, + ]) + conditions = [] + } + }, + local.ami_kms_key_enabled ? { + use_ami_kms_key = { + actions = toset([ + "kms:Decrypt", + "kms:DescribeKey", + "kms:ReEncryptFrom", + "kms:ReEncryptTo", + ]) + resources = toset([local.ami_kms_key_arn]) + conditions = [] + } + create_ami_kms_grant = { + actions = toset(["kms:CreateGrant"]) + resources = toset([local.ami_kms_key_arn]) + conditions = [ + { + test = "Bool" + variable = "kms:GrantIsForAWSResource" + values = toset(["true"]) + }, + ] + } + } : {}, + var.config.create_service_linked_role_spot ? { + create_spot_service_linked_role = { + actions = toset(["iam:CreateServiceLinkedRole"]) + resources = toset([ + "arn:${var.aws_partition}:iam::${data.aws_caller_identity.current.account_id}:role/aws-service-role/spot.amazonaws.com/AWSServiceRoleForEC2Spot", + ]) + conditions = [ + { + test = "StringEquals" + variable = "iam:AWSServiceName" + values = toset(["spot.amazonaws.com"]) + }, + ] + } + } : {}, + ) + + scale_set_capability = { + configuration_json = jsonencode(local.scale_set_runtime_configuration) + environment_variables = {} + iam_statements = local.scale_set_iam_statements + } +} diff --git a/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl b/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl index bc92537279..99feb7ef8e 100644 --- a/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl @@ -138,6 +138,15 @@ run "separates_control_plane_contract_from_ec2_resources" { error_message = "An external AMI SSM parameter must enable the pool managed policy attachment at plan time." } + assert { + condition = ( + contains(output.provider.capabilities.scale_set.iam_statements.read_ami_parameter.actions, "ssm:GetParameter") + && contains(output.provider.capabilities.scale_set.iam_statements.read_ami_parameter.actions, "ssm:GetParameters") + && contains(output.provider.capabilities.scale_set.iam_statements.read_ami_parameter.resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id") + ) + error_message = "The scale-set compute role must read an external AMI parameter with both single and batched SSM actions." + } + assert { condition = ( contains(flatten([ @@ -199,6 +208,26 @@ run "separates_control_plane_contract_from_ec2_resources" { } +run "includes_managed_ami_read_in_scale_set_contract" { + command = plan + + variables { + config = merge(var.config, { + ami = merge(var.config.ami, { + id_ssm_parameter = null + }) + }) + } + + assert { + condition = ( + contains(output.provider.capabilities.scale_set.iam_statements.read_ami_parameter.actions, "ssm:GetParameters") + && output.provider.capabilities.scale_set.iam_statements.read_ami_parameter.resources != toset([]) + ) + error_message = "The scale-set compute role must read the module-managed AMI parameter." + } +} + run "accepts_partial_typed_compute_options" { command = plan diff --git a/modules/compute-providers/aws/ec2/trust-policy/README.md b/modules/compute-providers/aws/ec2/trust-policy/README.md index 88490814a9..f73dc49b9b 100644 --- a/modules/compute-providers/aws/ec2/trust-policy/README.md +++ b/modules/compute-providers/aws/ec2/trust-policy/README.md @@ -7,7 +7,7 @@ This internal submodule builds the EC2 runner-role trust policy independently fr | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/compute-providers/aws/ec2/trust-policy/versions.tf b/modules/compute-providers/aws/ec2/trust-policy/versions.tf index 0bedc91fd5..3ef011ea0a 100644 --- a/modules/compute-providers/aws/ec2/trust-policy/versions.tf +++ b/modules/compute-providers/aws/ec2/trust-policy/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/compute-providers/aws/ec2/versions.tf b/modules/compute-providers/aws/ec2/versions.tf index 0bedc91fd5..3ef011ea0a 100644 --- a/modules/compute-providers/aws/ec2/versions.tf +++ b/modules/compute-providers/aws/ec2/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/download-lambda/README.md b/modules/download-lambda/README.md index 60d4c830f2..9971618089 100644 --- a/modules/download-lambda/README.md +++ b/modules/download-lambda/README.md @@ -29,7 +29,7 @@ module "lambdas" { | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [null](#requirement\_null) | ~> 3 | @@ -60,4 +60,4 @@ No modules. | Name | Description | |------|-------------| | [files](#output\_files) | n/a | - + \ No newline at end of file diff --git a/modules/download-lambda/versions.tf b/modules/download-lambda/versions.tf index b134d62d56..6bc038a353 100644 --- a/modules/download-lambda/versions.tf +++ b/modules/download-lambda/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/lambda/README.md b/modules/lambda/README.md index 340f4caee4..19e9c2a072 100644 --- a/modules/lambda/README.md +++ b/modules/lambda/README.md @@ -9,7 +9,7 @@ Generic module to create lambda functions | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers diff --git a/modules/lambda/versions.tf b/modules/lambda/versions.tf index 1238b79cc3..42a40b33fd 100644 --- a/modules/lambda/versions.tf +++ b/modules/lambda/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 2866af815e..d5a8e9c8ee 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -4,7 +4,7 @@ This module creates many runners with one or more GitHub Apps. The module utilizes the internal modules and deploys parts of the stack for each runner defined. -Terraform 1.5.6 or later is required. Terraform 1.5.5 and earlier are no longer supported by this module. +Terraform 1.4 or later is required. Terraform 1.3 and earlier are no longer supported by this module. ### GitHub App round-robin @@ -101,7 +101,7 @@ module "multi-runner" { | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -119,6 +119,7 @@ module "multi-runner" { |------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | +| [orchestration\_scale\_set](#module\_orchestration\_scale\_set) | ../orchestration-providers/scale-set | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | | [runner\_configs](#module\_runner\_configs) | ../runner-config | n/a | | [runners](#module\_runners) | ../runners | n/a | @@ -160,13 +161,13 @@ module "multi-runner" { | [experimental\_features](#input\_experimental\_features) | Explicit acknowledgement for opt-in features whose schemas may change
while experimental. Set to ["multi-runner-v2"] when using the v2
provider-boundary configuration. This flag will become a deprecated no-op
for one release when the feature graduates. | `set(string)` | `[]` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. .However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | -| [github\_app](#input\_github\_app) | GitHub app parameters for the stable v1 interface, see your github app.
Omit this value when using the experimental v2 interface and provide the
app through `global_config_github` instead.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| `{}` | no | +| [github\_app](#input\_github\_app) | GitHub app parameters for the stable v1 interface, see your github app.
Omit this value when using the experimental v2 interface and provide the
app through `global_config_github` instead.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
installation_id = optional(string)
installation_id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| `{}` | no | | [global\_config](#input\_global\_config) | Global defaults shared by all runner lanes.

global\_config = {
tags: "Tags applied to resources created for all runner lanes."
roles: {
path: "IAM path used for roles created for runner resources."
permissions\_boundary: "Optional IAM permissions boundary ARN applied to created roles."
}
runner: {
os: "Default operating system for runners."
architecture: "Default runner architecture."
disable\_default\_labels: "Whether to omit the default operating-system, architecture, and self-hosted labels."
extra\_labels: "Additional labels applied to all runners."
group\_name: "Default GitHub runner group."
name\_prefix: "Prefix for runner names."
run\_as\_root: "Whether the GitHub Actions runner executes as root."
run\_as: "User that runs the GitHub Actions agent when it is not running as root."
auto\_update\_disabled: "Whether automatic GitHub Actions runner updates are disabled."
tags: "Tags applied to runner resources."
hooks: {
job\_started: "Script executed when a job starts on a runner."
job\_completed: "Script executed when a job completes on a runner."
}
iam: {
role.arn: "Existing IAM role ARN to use for runners."
managed\_policy\_arns: "Managed policy ARNs attached to the runner IAM role."
additional\_trust\_policy\_json: "Additional trust policy JSON merged into the runner role trust policy."
path: "IAM path used for the runner role."
permissions\_boundary: "Optional IAM permissions boundary ARN for the runner role."
}
}
} |
object({
tags = optional(map(string), {})

roles = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, false)
extra_labels = optional(list(string), [])
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})
})
| `{}` | no | | [global\_config\_compute\_provider](#input\_global\_config\_compute\_provider) | Global compute-provider configuration shared by all runner lanes.

global\_config\_compute\_provider = {
selections: "Compute-provider selections keyed by namespace."
selections.namespace: "Provider namespace used to resolve a compute implementation."
selections.type: "Compute-provider type selected for the namespace."
aws.ec2.vpc\_id: "Default VPC for EC2 runners."
aws.ec2.subnet\_ids: "Default subnets for EC2 runners."
aws.ec2.managed\_security\_group\_enabled: "Whether the module manages the default runner security group."
aws.ec2.egress\_rules: "Egress rules for the managed runner security group."
aws.ec2.egress\_rules.cidr\_blocks: "IPv4 CIDR blocks allowed by an egress rule."
aws.ec2.egress\_rules.ipv6\_cidr\_blocks: "IPv6 CIDR blocks allowed by an egress rule."
aws.ec2.egress\_rules.prefix\_list\_ids: "AWS prefix lists allowed by an egress rule."
aws.ec2.egress\_rules.from\_port: "Start of the egress port range."
aws.ec2.egress\_rules.protocol: "Protocol for the egress rule."
aws.ec2.egress\_rules.security\_groups: "Referenced security groups allowed by an egress rule."
aws.ec2.egress\_rules.self: "Whether the security group itself is allowed by an egress rule."
aws.ec2.egress\_rules.to\_port: "End of the egress port range."
aws.ec2.egress\_rules.description: "Description of the egress rule."
aws.ec2.additional\_security\_group\_ids: "Additional security groups attached to EC2 runners."
aws.ec2.cloudwatch\_agent.config: "CloudWatch Agent configuration for EC2 runners."
aws.ec2.instance\_profile\_path: "IAM path used for the EC2 instance profile."
aws.ec2.key\_name: "EC2 key pair name assigned to runner instances."
aws.ec2.associate\_public\_ipv4\_address: "Whether runner instances receive a public IPv4 address."
aws.ec2.tags: "Tags applied to EC2 runner resources."
aws.ec2.ami.housekeeper.enabled: "Whether AMI cleanup is enabled."
aws.ec2.ami.housekeeper.cleanup\_config.maxItems: "Maximum number of AMIs retained by cleanup."
aws.ec2.ami.housekeeper.cleanup\_config.minimumDaysOld: "Minimum AMI age in days before cleanup."
aws.ec2.ami.housekeeper.cleanup\_config.amiFilters: "AMI filters used to select AMIs for cleanup."
aws.ec2.ami.housekeeper.cleanup\_config.amiFilters.Name: "AMI filter name."
aws.ec2.ami.housekeeper.cleanup\_config.amiFilters.Values: "Values matched by the AMI filter."
aws.ec2.ami.housekeeper.cleanup\_config.launchTemplateNames: "Launch template names associated with AMIs eligible for cleanup."
aws.ec2.ami.housekeeper.cleanup\_config.ssmParameterNames: "SSM parameter names associated with AMIs eligible for cleanup."
aws.ec2.ami.housekeeper.cleanup\_config.dryRun: "Whether AMI cleanup reports changes without deleting AMIs."
aws.ec2.ami.housekeeper.artifact.zip: "Local ZIP artifact used for the AMI housekeeper Lambda."
aws.ec2.ami.housekeeper.artifact.s3.key: "S3 object key for the AMI housekeeper Lambda artifact."
aws.ec2.ami.housekeeper.artifact.s3.object\_version: "Optional S3 object version for the AMI housekeeper artifact."
aws.ec2.ami.housekeeper.lambda.memory\_size: "Memory allocated to the AMI housekeeper Lambda."
aws.ec2.ami.housekeeper.lambda.timeout: "Timeout in seconds for the AMI housekeeper Lambda."
aws.ec2.ami.housekeeper.schedule.expression: "Schedule expression for AMI cleanup."
aws.ec2.instance\_termination\_watcher.enabled: "Whether the instance termination watcher is enabled."
aws.ec2.instance\_termination\_watcher.features.runner\_deregistration.enabled: "Whether terminated runners are deregistered."
aws.ec2.instance\_termination\_watcher.features.spot\_termination\_handler.enabled: "Whether spot termination events trigger runner handling."
aws.ec2.instance\_termination\_watcher.features.spot\_termination\_notification\_watcher.enabled: "Whether spot termination notification monitoring is enabled."
aws.ec2.instance\_termination\_watcher.environment\_variables: "Environment variables passed to the termination watcher."
aws.ec2.instance\_termination\_watcher.artifact.zip: "Local ZIP artifact used for the termination watcher Lambda."
aws.ec2.instance\_termination\_watcher.artifact.s3.key: "S3 object key for the termination watcher Lambda artifact."
aws.ec2.instance\_termination\_watcher.artifact.s3.object\_version: "Optional S3 object version for the termination watcher artifact."
aws.ec2.instance\_termination\_watcher.lambda.memory\_size: "Memory allocated to the termination watcher Lambda."
aws.ec2.instance\_termination\_watcher.lambda.timeout: "Timeout in seconds for the termination watcher Lambda."
aws.ec2.runner\_binaries.enabled: "Whether runner binary synchronization is enabled."
aws.ec2.runner\_binaries.s3.encryption.enabled: "Whether runner-binary S3 encryption is enabled."
aws.ec2.runner\_binaries.s3.encryption.bucket\_key\_enabled: "Whether an S3 bucket key is used for KMS encryption."
aws.ec2.runner\_binaries.s3.encryption.sse\_algorithm: "S3 server-side encryption algorithm."
aws.ec2.runner\_binaries.s3.encryption.kms\_master\_key\_id: "KMS key ID used for runner-binary S3 encryption."
aws.ec2.runner\_binaries.s3.tags: "Tags applied to the runner-binary S3 bucket."
aws.ec2.runner\_binaries.s3.versioning: "S3 versioning state for the runner-binary bucket."
aws.ec2.runner\_binaries.s3.logging.bucket: "S3 bucket receiving runner-binary access logs."
aws.ec2.runner\_binaries.s3.logging.prefix: "Prefix for runner-binary S3 access logs."
aws.ec2.runner\_binaries.syncer.artifact.zip: "Local ZIP artifact used for the runner-binary syncer Lambda."
aws.ec2.runner\_binaries.syncer.artifact.s3.key: "S3 object key for the runner-binary syncer artifact."
aws.ec2.runner\_binaries.syncer.artifact.s3.object\_version: "Optional S3 object version for the runner-binary syncer artifact."
aws.ec2.runner\_binaries.syncer.lambda.memory\_size: "Memory allocated to the runner-binary syncer Lambda."
aws.ec2.runner\_binaries.syncer.lambda.timeout: "Timeout in seconds for the runner-binary syncer Lambda."
aws.ec2.runner\_binaries.syncer.schedule.expression: "Schedule expression for runner-binary synchronization."
aws.ec2.runner\_binaries.syncer.schedule.state: "EventBridge rule state for runner-binary synchronization."
} |
object({
selections = optional(map(object({
namespace = string
type = string
})), null)
aws = optional(object({
ec2 = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, true)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
additional_security_group_ids = optional(list(string), [])
cloudwatch_agent = optional(object({
config = optional(string, null)
}), {})
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, false)
tags = optional(map(string), {})
ami = optional(object({
housekeeper = optional(object({
enabled = optional(bool, false)
cleanup_config = optional(object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
}), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(11 7 * * ? *)")
}), {})
}), {})
}), {})
instance_termination_watcher = optional(object({
enabled = optional(bool, false)
features = optional(object({
runner_deregistration = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_handler = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_notification_watcher = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
environment_variables = optional(map(string), {})
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
}), {})
runner_binaries = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
encryption = optional(object({
enabled = optional(bool, true)
bucket_key_enabled = optional(bool, null)
sse_algorithm = optional(string, "AES256")
kms_master_key_id = optional(string, null)
}), {})
tags = optional(map(string), {})
versioning = optional(string, "Disabled")
logging = optional(object({
bucket = optional(string, null)
prefix = optional(string, null)
}), {})
}), {})
syncer = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
lambda = optional(object({
memory_size = optional(number, 256)
timeout = optional(number, 300)
}), {})
schedule = optional(object({
expression = optional(string, "cron(27 * * * ? *)")
state = optional(string, "ENABLED")
}), {})
}), {})
}), {})
}), {})
}), {})
})
| `{}` | no | -| [global\_config\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.

global\_config\_github = {
app: {
key\_base64: "Base64-encoded GitHub App private key."
key\_base64\_ssm: "SSM parameter containing the Base64-encoded GitHub App private key."
key\_base64\_ssm.arn: "ARN of the SSM parameter containing the GitHub App private key."
key\_base64\_ssm.name: "Name of the SSM parameter containing the GitHub App private key."
id: "GitHub App ID."
id\_ssm: "SSM parameter containing the GitHub App ID."
id\_ssm.arn: "ARN of the SSM parameter containing the GitHub App ID."
id\_ssm.name: "Name of the SSM parameter containing the GitHub App ID."
webhook\_secret: "GitHub App webhook secret."
webhook\_secret\_ssm: "SSM parameter containing the GitHub App webhook secret."
webhook\_secret\_ssm.arn: "ARN of the SSM parameter containing the GitHub App webhook secret."
webhook\_secret\_ssm.name: "Name of the SSM parameter containing the GitHub App webhook secret."
}
additional\_apps: "Additional GitHub Apps used to distribute GitHub API requests."
additional\_apps.key\_base64: "Base64-encoded private key for an additional GitHub App."
additional\_apps.key\_base64\_ssm: "SSM parameter containing an additional App private key."
additional\_apps.key\_base64\_ssm.arn: "ARN of the SSM parameter containing an additional App private key."
additional\_apps.key\_base64\_ssm.name: "Name of the SSM parameter containing an additional App private key."
additional\_apps.id: "ID of an additional GitHub App."
additional\_apps.id\_ssm: "SSM parameter containing an additional GitHub App ID."
additional\_apps.id\_ssm.arn: "ARN of the SSM parameter containing an additional GitHub App ID."
additional\_apps.id\_ssm.name: "Name of the SSM parameter containing an additional GitHub App ID."
additional\_apps.installation\_id: "Optional installation ID for an additional GitHub App."
additional\_apps.installation\_id\_ssm: "SSM parameter containing an additional App installation ID."
additional\_apps.installation\_id\_ssm.arn: "ARN of the SSM parameter containing an additional App installation ID."
additional\_apps.installation\_id\_ssm.name: "Name of the SSM parameter containing an additional App installation ID."
enterprise\_server.url: "GitHub Enterprise Server URL."
enterprise\_server.ssl\_verify: "Whether to verify the GitHub Enterprise Server TLS certificate."
user\_agent: "User-Agent value sent with GitHub API requests."
} |
object({
app = optional(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}), null)
additional_apps = optional(list(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({ arn = string, name = string }))
id = optional(string)
id_ssm = optional(object({ arn = string, name = string }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, "github-aws-runners")
})
| `{}` | no | +| [global\_config\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.

global\_config\_github = {
app: {
key\_base64: "Base64-encoded GitHub App private key."
key\_base64\_ssm: "SSM parameter containing the Base64-encoded GitHub App private key."
key\_base64\_ssm.arn: "ARN of the SSM parameter containing the GitHub App private key."
key\_base64\_ssm.name: "Name of the SSM parameter containing the GitHub App private key."
id: "GitHub App ID."
id\_ssm: "SSM parameter containing the GitHub App ID."
id\_ssm.arn: "ARN of the SSM parameter containing the GitHub App ID."
id\_ssm.name: "Name of the SSM parameter containing the GitHub App ID."
installation\_id: "GitHub App installation ID for the primary scale-set installation."
installation\_id\_ssm: "SSM parameter containing the primary GitHub App installation ID."
installation\_id\_ssm.arn: "ARN of the SSM parameter containing the primary GitHub App installation ID."
installation\_id\_ssm.name: "Name of the SSM parameter containing the primary GitHub App installation ID."
webhook\_secret: "GitHub App webhook secret."
webhook\_secret\_ssm: "SSM parameter containing the GitHub App webhook secret."
webhook\_secret\_ssm.arn: "ARN of the SSM parameter containing the GitHub App webhook secret."
webhook\_secret\_ssm.name: "Name of the SSM parameter containing the GitHub App webhook secret."
}
additional\_apps: "Additional GitHub Apps used to distribute GitHub API requests."
additional\_apps.key\_base64: "Base64-encoded private key for an additional GitHub App."
additional\_apps.key\_base64\_ssm: "SSM parameter containing an additional App private key."
additional\_apps.key\_base64\_ssm.arn: "ARN of the SSM parameter containing an additional App private key."
additional\_apps.key\_base64\_ssm.name: "Name of the SSM parameter containing an additional App private key."
additional\_apps.id: "ID of an additional GitHub App."
additional\_apps.id\_ssm: "SSM parameter containing an additional GitHub App ID."
additional\_apps.id\_ssm.arn: "ARN of the SSM parameter containing an additional GitHub App ID."
additional\_apps.id\_ssm.name: "Name of the SSM parameter containing an additional GitHub App ID."
additional\_apps.installation\_id: "Optional installation ID for an additional GitHub App."
additional\_apps.installation\_id\_ssm: "SSM parameter containing an additional App installation ID."
additional\_apps.installation\_id\_ssm.arn: "ARN of the SSM parameter containing an additional App installation ID."
additional\_apps.installation\_id\_ssm.name: "Name of the SSM parameter containing an additional App installation ID."
enterprise\_server.url: "GitHub Enterprise Server URL."
enterprise\_server.ssl\_verify: "Whether to verify the GitHub Enterprise Server TLS certificate."
runner\_owner: "GitHub organization or owner/repository path for organization- or repository-level scale-set registration."
runner\_registration\_level: "GitHub scale-set registration scope: organization or repository."
user\_agent: "User-Agent value sent with GitHub API requests."
} |
object({
app = optional(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
installation_id = optional(string)
installation_id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}), null)
additional_apps = optional(list(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({ arn = string, name = string }))
id = optional(string)
id_ssm = optional(object({ arn = string, name = string }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})), [])
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
runner_owner = optional(string, null)
runner_registration_level = optional(string, "organization")
user_agent = optional(string, "github-aws-runners")
})
| `{}` | no | | [global\_config\_lambda](#input\_global\_config\_lambda) | Global Lambda configuration shared by all runner lanes.

global\_config\_lambda = {
artifact.s3.bucket: "S3 bucket containing Lambda deployment artifacts."
runtime: "Default Lambda runtime."
architecture: "Default Lambda instruction-set architecture."
principals: "Additional AWS principals allowed to invoke the Lambda functions."
principals.type: "Principal type, such as AWS account, service, or organization."
principals.identifiers: "Identifiers allowed for the principal type."
subnet\_ids: "Subnets used by Lambda functions."
security\_group\_ids: "Security groups attached to Lambda functions."
tags: "Tags applied to Lambda functions and related resources."
role.path: "IAM path used for Lambda execution roles."
role.permissions\_boundary: "Optional IAM permissions boundary ARN for Lambda execution roles."
} |
object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| `{}` | no | | [global\_config\_observability](#input\_global\_config\_observability) | Global observability configuration shared by all runner lanes.

global\_config\_observability = {
logs.level: "Log level for module resources."
logs.retention\_in\_days: "CloudWatch log retention period in days."
logs.kms\_key\_id: "KMS key ID used to encrypt CloudWatch log groups."
logs.class: "CloudWatch log group class."
logs.tags: "Tags applied to CloudWatch log groups."
tracing.mode: "Tracing mode used by instrumented resources."
tracing.capture\_http\_requests: "Whether HTTP requests are captured by tracing."
tracing.capture\_error: "Whether errors are captured by tracing."
metrics.enabled: "Whether module metrics are enabled."
metrics.namespace: "CloudWatch namespace used for module metrics."
metrics.metric.github\_app\_rate\_limit.enabled: "Whether GitHub App rate-limit metrics are emitted."
metrics.metric.job\_retry.enabled: "Whether job-retry metrics are emitted."
metrics.metric.spot\_termination\_warning.enabled: "Whether spot-termination warning metrics are emitted."
} |
object({
logs = optional(object({
level = optional(string, "info")
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(object({
enabled = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, true)
}), {})
job_retry = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
}), {})
})
| `{}` | no | -| [global\_config\_orchestration\_provider](#input\_global\_config\_orchestration\_provider) | Global orchestration-provider configuration shared by all runner lanes.

global\_config\_orchestration\_provider = {
webhook: {
queue\_selection\_strategy: "Strategy used to select the build queue for a webhook event."
eventbridge.enabled: "Whether EventBridge integration is enabled for webhook events."
eventbridge.accept\_events: "Event types accepted by the EventBridge integration."
matcher\_config\_parameter\_store\_tier: "SSM Parameter Store tier used for matcher configuration."
runner.boot\_time\_in\_minutes: "Expected runner boot time used by orchestration."
runner.ephemeral: "Whether runners created by the orchestration provider are ephemeral."
runner.jit\_config\_enabled: "Whether JIT runner configuration is enabled."
runner.maximum\_count: "Maximum number of runners that orchestration may create."
github.repository\_white\_list: "Repositories allowed to use the webhook configuration."
lambda.artifact.zip: "Local ZIP artifact used for orchestration Lambda functions."
lambda.artifact.s3.key: "S3 object key for the orchestration Lambda artifact."
lambda.artifact.s3.object\_version: "Optional S3 object version for the orchestration Lambda artifact."
lambda.scale.up.memory\_size: "Memory allocated to the scale-up Lambda."
lambda.scale.up.timeout: "Timeout in seconds for the scale-up Lambda."
lambda.scale.up.reserved\_concurrent\_executions: "Reserved concurrent executions for the scale-up Lambda."
lambda.scale.up.job\_queued\_check\_enabled: "Whether the scale-up Lambda checks queued jobs."
lambda.scale.up.event\_source\_mapping.batch\_size: "Maximum records passed to one scale-up Lambda invocation."
lambda.scale.up.event\_source\_mapping.maximum\_batching\_window\_in\_seconds: "Maximum time to batch records before invoking the scale-up Lambda."
lambda.scale.up.tags: "Tags applied to the scale-up Lambda."
lambda.scale.down.memory\_size: "Memory allocated to the scale-down Lambda."
lambda.scale.down.timeout: "Timeout in seconds for the scale-down Lambda."
lambda.scale.down.schedule\_expression: "Schedule expression for scale-down processing."
lambda.scale.down.minimum\_running\_time\_in\_minutes: "Minimum runner lifetime before scale-down."
lambda.scale.down.idle\_confirmation\_seconds: "Seconds a runner must consistently report not-busy before scale-down terminates it; 0 disables the confirmation window."
lambda.scale.down.idle\_config: "Scheduled minimum idle-runner pool settings."
lambda.scale.down.idle\_config.cron: "Cron expression defining when the idle-runner count applies."
lambda.scale.down.idle\_config.timeZone: "Time zone used to evaluate the idle-runner schedule."
lambda.scale.down.idle\_config.idleCount: "Minimum number of idle runners maintained during the schedule."
lambda.scale.down.idle\_config.evictionStrategy: "Strategy used when evicting idle runners."
lambda.scale.down.tags: "Tags applied to the scale-down Lambda."
lambda.webhook.artifact.zip: "Local ZIP artifact used for the webhook Lambda."
lambda.webhook.artifact.s3.key: "S3 object key for the webhook Lambda artifact."
lambda.webhook.artifact.s3.object\_version: "Optional S3 object version for the webhook Lambda artifact."
lambda.webhook.api\_gateway\_access\_log\_settings: "API Gateway access-log destination and format."
lambda.webhook.api\_gateway\_access\_log\_settings.destination\_arn: "ARN of the API Gateway access-log destination."
lambda.webhook.api\_gateway\_access\_log\_settings.format: "API Gateway access-log format."
lambda.webhook.memory\_size: "Memory allocated to the webhook Lambda."
lambda.webhook.timeout: "Timeout in seconds for the webhook Lambda."
lambda.webhook.tags: "Tags applied to the webhook Lambda."
lambda.pool.memory\_size: "Memory allocated to the pool Lambda."
lambda.pool.timeout: "Timeout in seconds for the pool Lambda."
lambda.pool.reserved\_concurrent\_executions: "Reserved concurrent executions for the pool Lambda."
lambda.pool.config: "Scheduled runner-pool size configuration."
lambda.pool.config.schedule\_expression: "Schedule expression for the pool size."
lambda.pool.config.schedule\_expression\_timezone: "Time zone used to evaluate the pool schedule."
lambda.pool.config.size: "Runner pool size applied by the schedule."
lambda.pool.include\_busy\_runners: "Whether busy runners are included in pool sizing."
lambda.pool.runner\_owner: "GitHub organization that owns the runner pool."
lambda.pool.tags: "Tags applied to the pool Lambda."
queue.delay\_webhook\_event: "Seconds a webhook event remains invisible in the build queue before processing."
queue.job\_queue\_retention\_in\_seconds: "Seconds a queued job is retained before it is purged."
queue.visibility\_timeout\_seconds: "Build queue visibility timeout in seconds."
queue.redrive\_build\_queue.enabled: "Whether the build queue dead-letter queue is enabled."
queue.redrive\_build\_queue.maxReceiveCount: "Maximum receives before a message is moved to the dead-letter queue."
queue.tags: "Tags applied to build queues."
queue.encryption.kms\_data\_key\_reuse\_period\_seconds: "KMS data-key reuse period for queue encryption."
queue.encryption.kms\_master\_key\_id: "KMS key ID used for queue encryption."
queue.encryption.sqs\_managed\_sse\_enabled: "Whether SQS-managed server-side encryption is enabled."
}
} |
object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enabled = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, 0)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})
}), {})
})
| `{}` | no | +| [global\_config\_orchestration\_provider](#input\_global\_config\_orchestration\_provider) | Global orchestration-provider configuration shared by all runner lanes.

global\_config\_orchestration\_provider = {
webhook: {
queue\_selection\_strategy: "Strategy used to select the build queue for a webhook event."
eventbridge.enabled: "Whether EventBridge integration is enabled for webhook events."
eventbridge.accept\_events: "Event types accepted by the EventBridge integration."
matcher\_config\_parameter\_store\_tier: "SSM Parameter Store tier used for matcher configuration."
runner.boot\_time\_in\_minutes: "Expected runner boot time used by orchestration."
runner.ephemeral: "Whether runners created by the orchestration provider are ephemeral."
runner.jit\_config\_enabled: "Whether JIT runner configuration is enabled."
runner.maximum\_count: "Maximum number of runners that orchestration may create."
github.repository\_white\_list: "Repositories allowed to use the webhook configuration."
lambda.artifact.zip: "Local ZIP artifact used for orchestration Lambda functions."
lambda.artifact.s3.key: "S3 object key for the orchestration Lambda artifact."
lambda.artifact.s3.object\_version: "Optional S3 object version for the orchestration Lambda artifact."
lambda.scale.up.memory\_size: "Memory allocated to the scale-up Lambda."
lambda.scale.up.timeout: "Timeout in seconds for the scale-up Lambda."
lambda.scale.up.reserved\_concurrent\_executions: "Reserved concurrent executions for the scale-up Lambda."
lambda.scale.up.job\_queued\_check\_enabled: "Whether the scale-up Lambda checks queued jobs."
lambda.scale.up.event\_source\_mapping.batch\_size: "Maximum records passed to one scale-up Lambda invocation."
lambda.scale.up.event\_source\_mapping.maximum\_batching\_window\_in\_seconds: "Maximum time to batch records before invoking the scale-up Lambda."
lambda.scale.up.tags: "Tags applied to the scale-up Lambda."
lambda.scale.down.memory\_size: "Memory allocated to the scale-down Lambda."
lambda.scale.down.timeout: "Timeout in seconds for the scale-down Lambda."
lambda.scale.down.schedule\_expression: "Schedule expression for scale-down processing."
lambda.scale.down.minimum\_running\_time\_in\_minutes: "Minimum runner lifetime before scale-down."
lambda.scale.down.idle\_confirmation\_seconds: "Seconds a runner must consistently report not-busy before scale-down terminates it; 0 disables the confirmation window."
lambda.scale.down.idle\_config: "Scheduled minimum idle-runner pool settings."
lambda.scale.down.idle\_config.cron: "Cron expression defining when the idle-runner count applies."
lambda.scale.down.idle\_config.timeZone: "Time zone used to evaluate the idle-runner schedule."
lambda.scale.down.idle\_config.idleCount: "Minimum number of idle runners maintained during the schedule."
lambda.scale.down.idle\_config.evictionStrategy: "Strategy used when evicting idle runners."
lambda.scale.down.tags: "Tags applied to the scale-down Lambda."
lambda.webhook.artifact.zip: "Local ZIP artifact used for the webhook Lambda."
lambda.webhook.artifact.s3.key: "S3 object key for the webhook Lambda artifact."
lambda.webhook.artifact.s3.object\_version: "Optional S3 object version for the webhook Lambda artifact."
lambda.webhook.api\_gateway\_access\_log\_settings: "API Gateway access-log destination and format."
lambda.webhook.api\_gateway\_access\_log\_settings.destination\_arn: "ARN of the API Gateway access-log destination."
lambda.webhook.api\_gateway\_access\_log\_settings.format: "API Gateway access-log format."
lambda.webhook.memory\_size: "Memory allocated to the webhook Lambda."
lambda.webhook.timeout: "Timeout in seconds for the webhook Lambda."
lambda.webhook.tags: "Tags applied to the webhook Lambda."
lambda.pool.memory\_size: "Memory allocated to the pool Lambda."
lambda.pool.timeout: "Timeout in seconds for the pool Lambda."
lambda.pool.reserved\_concurrent\_executions: "Reserved concurrent executions for the pool Lambda."
lambda.pool.config: "Scheduled runner-pool size configuration."
lambda.pool.config.schedule\_expression: "Schedule expression for the pool size."
lambda.pool.config.schedule\_expression\_timezone: "Time zone used to evaluate the pool schedule."
lambda.pool.config.size: "Runner pool size applied by the schedule."
lambda.pool.include\_busy\_runners: "Whether busy runners are included in pool sizing."
lambda.pool.runner\_owner: "GitHub organization that owns the runner pool."
lambda.pool.tags: "Tags applied to the pool Lambda."
queue.delay\_webhook\_event: "Seconds a webhook event remains invisible in the build queue before processing."
queue.job\_queue\_retention\_in\_seconds: "Seconds a queued job is retained before it is purged."
queue.visibility\_timeout\_seconds: "Build queue visibility timeout in seconds."
queue.redrive\_build\_queue.enabled: "Whether the build queue dead-letter queue is enabled."
queue.redrive\_build\_queue.maxReceiveCount: "Maximum receives before a message is moved to the dead-letter queue."
queue.tags: "Tags applied to build queues."
queue.encryption.kms\_data\_key\_reuse\_period\_seconds: "KMS data-key reuse period for queue encryption."
queue.encryption.kms\_master\_key\_id: "KMS key ID used for queue encryption."
queue.encryption.sqs\_managed\_sse\_enabled: "Whether SQS-managed server-side encryption is enabled."
}
} |
object({
webhook = optional(object({
queue_selection_strategy = optional(string, "first")
eventbridge = optional(object({
enabled = optional(bool, true)
accept_events = optional(list(string), [])
}), {})
matcher_config_parameter_store_tier = optional(string, "Standard")
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
repository_white_list = optional(list(string), [])
}), {})

lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 30)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, 0)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
webhook = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
api_gateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
memory_size = optional(number, 256)
timeout = optional(number, 10)
tags = optional(map(string), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

queue = optional(object({
delay_webhook_event = optional(number, 30)
job_queue_retention_in_seconds = optional(number, 86400)
visibility_timeout_seconds = optional(number, 180)
redrive_build_queue = optional(object({
enabled = optional(bool, false)
maxReceiveCount = optional(number, null)
}), {
enabled = false
maxReceiveCount = null
})
tags = optional(map(string), {})
encryption = optional(object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
}), {
kms_data_key_reuse_period_seconds = null
kms_master_key_id = null
sqs_managed_sse_enabled = true
})
}), {})

}), {})

scale_set = optional(object({
grouping = optional(object({
strategy = optional(string, "compute_provider")
custom = optional(object({
groups = map(object({
runner_configs = set(string)
}))
}), null)
}), {})
container = optional(object({
image = optional(string, null)
user = optional(string, "10001:10001")
health_port = optional(number, 8080)
health_path = optional(string, "/healthz")
health_check_command = optional(list(string), null)
health_check_interval = optional(number, 30)
health_check_timeout = optional(number, 5)
health_check_retries = optional(number, 3)
health_check_start_period = optional(number, 30)
health_stale_after_seconds = optional(number, 180)
shutdown_timeout_seconds = optional(number, 110)
session_close_timeout_seconds = optional(number, 10)
reconnect_initial_backoff_seconds = optional(number, 1)
reconnect_max_backoff_seconds = optional(number, 30)
stop_timeout_seconds = optional(number, 120)
}), {})
config_store = optional(object({
path_prefix = optional(string, null)
tier = optional(string, "Standard")
tags = optional(map(string), {})
}), {})
ecs = optional(object({
cluster = optional(object({
mode = optional(string, "managed")
arn = optional(string, null)
name = optional(string, null)
container_insights = optional(bool, true)
}), {})
task = optional(object({
cpu = optional(number, 512)
memory = optional(number, 1024)
cpu_architecture = optional(string, "X86_64")
ephemeral_storage = optional(object({
size_in_gib = number
}), null)
}), {})
service = optional(object({
platform_version = optional(string, "LATEST")
}), {})
iam = optional(object({
path = optional(string, "/")
permissions_boundary = optional(string, null)
}), {})
}), {})
network = optional(object({
vpc_id = optional(string, null)
subnet_ids = optional(set(string), null)
https_egress = optional(object({
ipv4_cidrs = optional(set(string), ["0.0.0.0/0"])
ipv6_cidrs = optional(set(string), [])
}), {})
}), {})
logging = optional(object({
retention_in_days = optional(number, 30)
kms_key_arn = optional(string, null)
log_group_class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tags = optional(map(string), {})
}), {})
})
| `{}` | no | | [global\_config\_ssm](#input\_global\_config\_ssm) | Global SSM configuration shared by all runner lanes.

global\_config\_ssm = {
paths.root: "Root path for SSM parameters."
paths.app: "Path segment for application parameters."
paths.webhook: "Path segment for webhook parameters."
paths.tokens: "Path segment for runner token parameters."
paths.config: "Path segment for runner configuration parameters."
kms\_key\_id: "KMS key ID used to encrypt SSM parameters."
tags: "Tags applied to SSM resources."
parameters.tags: "Tags applied to runner configuration parameters."
housekeeper.schedule\_expression: "Schedule for the SSM parameter housekeeper."
housekeeper.state: "EventBridge rule state for the SSM parameter housekeeper."
housekeeper.tags: "Tags applied to the SSM housekeeper resources."
housekeeper.lambda.artifact.zip: "Local ZIP artifact used for the SSM housekeeper Lambda."
housekeeper.lambda.artifact.s3.key: "S3 object key for the SSM housekeeper Lambda artifact."
housekeeper.lambda.artifact.s3.object\_version: "Optional S3 object version for the SSM housekeeper artifact."
housekeeper.lambda.memory\_size: "Memory allocated to the SSM housekeeper Lambda."
housekeeper.lambda.timeout: "Timeout in seconds for the SSM housekeeper Lambda."
housekeeper.config.tokenPath: "Parameter path containing runner tokens to clean up."
housekeeper.config.minimumDaysOld: "Minimum age in days before an old token is eligible for cleanup."
housekeeper.config.dryRun: "Whether the SSM housekeeper reports cleanup without deleting parameters."
} |
object({
paths = optional(object({
root = optional(string, null)
app = optional(string, "app")
webhook = optional(string, "webhook")
tokens = optional(string, "runners/tokens")
config = optional(string, "runners/config")
}), {})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
})
| `{}` | no | | [iam\_overrides](#input\_iam\_overrides) | This map provides the possibility to override some IAM defaults. The following attributes are supported: `instance_profile_name` overrides the instance profile name used in the launch template. `runner_role_arn` overrides the IAM role ARN used for the runner instances. |
object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false,
"override_runner_role": false,
"runner_role_arn": null
}
| no | | [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no | @@ -188,7 +189,7 @@ module "multi-runner" { | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no | | [matcher\_config\_parameter\_store\_tier](#input\_matcher\_config\_parameter\_store\_tier) | The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`. | `string` | `"Standard"` | no | | [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. |
object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
})
| `{}` | no | -| [multi\_runner\_config](#input\_multi\_runner\_config) | Accepts either the stable v1 runner configuration shape or the provider-boundary v2 shape. Entries with `runner_config` use the v1 shape; entries without `runner_config` use the v2 shape. A v2 entry does not need matcher configuration. A v2 entry must be acknowledged with `experimental_features = ["multi-runner-v2"]`; the v2 shape is experimental and may change before graduation.

multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux, osx, windows)."
runner\_architecture: "The platform architecture of the runner instance\_type."
runner\_metadata\_options: "(Optional) Metadata options for the ec2 runner instances."
ami: "(Optional) AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place."
create\_service\_linked\_role\_spot: (Optional) create the serviced linked role for spot instances that is required by the scale-up lambda.
credit\_specification: "(Optional) The credit specification of the runner instance\_type. Can be unset, `standard` or `unlimited`.
delay\_webhook\_event: "The number of seconds the event accepted by the webhook is invisible on the queue before the scale up lambda will receive the event."
disable\_runner\_autoupdate: "Disable the auto update of the github runner agent. Be aware there is a grace period of 30 days, see also the [GitHub article](https://github.blog/changelog/2022-02-01-github-actions-self-hosted-runners-can-now-disable-automatic-updates/)"
ebs\_optimized: "The EC2 EBS optimized configuration."
enable\_ephemeral\_runners: "Enable ephemeral runners, runners will only be used once."
enable\_job\_queued\_check: Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
enable\_on\_demand\_failover\_for\_errors: "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later."
scale\_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
instance\_types: "List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux, macOS Sequoia for osx, Windows Server Core for win)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
scale\_down\_idle\_confirmation\_seconds: "Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale, so a single not-busy reading is not sufficient evidence a runner is idle. 0 keeps the previous single-reading behaviour."
runner\_disable\_default\_labels: "Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` disables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_reserved\_concurrent\_executions: "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
enable\_jit\_config: "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
license\_specifications: "Optional EC2 License Manager license configuration ARNs for the runner launch template. Required for macOS dedicated-host runners when the host resource group uses a Mac dedicated host license configuration."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
job\_retry: "Experimental! Can be removed / changed without trigger a major release. Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app."
pool\_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
# V2 contract
tags: "Tags applied to resources created for this runner configuration."
runner: "Runner settings such as the operating system, architecture, labels, hooks, runner group, name prefix, and IAM role configuration."
lambda: "Lambda settings such as runtime, architecture, networking, tags, and execution-role options for this runner configuration."
# Webhook, queue, and scale-up/scale-down orchestration settings.
orchestration\_provider: {
webhook: {
matcherConfig: "Label matching and dynamic-label policy used to route workflow jobs to this runner configuration."
runner: "Runner lifecycle settings including boot time, ephemeral mode, JIT configuration, and maximum runner count."
queue: "Build queue delay, retention, visibility timeout, redrive, and tags."
}
}
ssm: "SSM parameter paths, tags, and housekeeper settings for runner configuration storage."
observability: "Logging, tracing, and metric settings for the resources in this runner configuration."
# Compute settings for the runner provider.
compute\_provider: {
aws: {
ec2: "AWS EC2 runner settings, including AMI selection, instance types, capacity strategy, VPC and subnet placement, storage, user data, and runner access."
}
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
map(object({
# V1 contract
runner_config = optional(object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
scale_down_idle_confirmation_seconds = optional(number, 0)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
}), null)
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})

# V2 Contract
tags = optional(map(string), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
organization_runners = optional(bool, false)
}), {})
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
}), null)
queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})
lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enabled = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
}), {})
}), {})
}), {})

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
}), {})
}), {})
}))
| `{}` | no | +| [multi\_runner\_config](#input\_multi\_runner\_config) | Accepts either the stable v1 runner configuration shape or the provider-boundary v2 shape. Entries with `runner_config` use the v1 shape; entries without `runner_config` use the v2 shape. A v2 entry does not need matcher configuration. A v2 entry must be acknowledged with `experimental_features = ["multi-runner-v2"]`; the v2 shape is experimental and may change before graduation.

multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux, osx, windows)."
runner\_architecture: "The platform architecture of the runner instance\_type."
runner\_metadata\_options: "(Optional) Metadata options for the ec2 runner instances."
ami: "(Optional) AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place."
create\_service\_linked\_role\_spot: (Optional) create the serviced linked role for spot instances that is required by the scale-up lambda.
credit\_specification: "(Optional) The credit specification of the runner instance\_type. Can be unset, `standard` or `unlimited`.
delay\_webhook\_event: "The number of seconds the event accepted by the webhook is invisible on the queue before the scale up lambda will receive the event."
disable\_runner\_autoupdate: "Disable the auto update of the github runner agent. Be aware there is a grace period of 30 days, see also the [GitHub article](https://github.blog/changelog/2022-02-01-github-actions-self-hosted-runners-can-now-disable-automatic-updates/)"
ebs\_optimized: "The EC2 EBS optimized configuration."
enable\_ephemeral\_runners: "Enable ephemeral runners, runners will only be used once."
enable\_job\_queued\_check: Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
enable\_on\_demand\_failover\_for\_errors: "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later."
scale\_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
instance\_types: "List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux, macOS Sequoia for osx, Windows Server Core for win)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
scale\_down\_idle\_confirmation\_seconds: "Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale, so a single not-busy reading is not sufficient evidence a runner is idle. 0 keeps the previous single-reading behaviour."
runner\_disable\_default\_labels: "Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` disables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_reserved\_concurrent\_executions: "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
enable\_jit\_config: "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
license\_specifications: "Optional EC2 License Manager license configuration ARNs for the runner launch template. Required for macOS dedicated-host runners when the host resource group uses a Mac dedicated host license configuration."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
job\_retry: "Experimental! Can be removed / changed without trigger a major release. Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app."
pool\_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
# V2 contract
tags: "Tags applied to resources created for this runner configuration."
runner: "Runner settings such as the operating system, architecture, labels, hooks, runner group, name prefix, and IAM role configuration."
lambda: "Lambda settings such as runtime, architecture, networking, tags, and execution-role options for this runner configuration."
# Webhook, queue, and scale-up/scale-down orchestration settings.
orchestration\_provider: {
webhook: {
matcherConfig: "Label matching and dynamic-label policy used to route workflow jobs to this runner configuration."
runner: "Runner lifecycle settings including boot time, ephemeral mode, JIT configuration, and maximum runner count."
queue: "Build queue delay, retention, visibility timeout, redrive, and tags."
}
}
ssm: "SSM parameter paths, tags, and housekeeper settings for runner configuration storage."
observability: "Logging, tracing, and metric settings for the resources in this runner configuration."
# Compute settings for the runner provider.
compute\_provider: {
aws: {
ec2: "AWS EC2 runner settings, including AMI selection, instance types, capacity strategy, VPC and subnet placement, storage, user data, and runner access."
}
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
map(object({
# V1 contract
runner_config = optional(object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
scale_down_idle_confirmation_seconds = optional(number, 0)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
}), null)
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
}), null)
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})

# V2 Contract
tags = optional(map(string), {})

runner = optional(object({
os = optional(string, null)
architecture = optional(string, null)
disable_default_labels = optional(bool, null)
extra_labels = optional(list(string), null)
group_name = optional(string, null)
name_prefix = optional(string, null)
run_as_root = optional(bool, null)
run_as = optional(string, null)
auto_update_disabled = optional(bool, null)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, null)
job_completed = optional(string, null)
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), null)
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

lambda = optional(object({
runtime = optional(string, null)
architecture = optional(string, null)
subnet_ids = optional(list(string), null)
security_group_ids = optional(list(string), null)
tags = optional(map(string), {})
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
}), {})

orchestration_provider = optional(object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})
github = optional(object({
organization_runners = optional(bool, false)
}), {})
matcherConfig = optional(object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
}), null)
queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})
lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
scale_set = optional(object({
name = string
runner = optional(object({
min_runners = optional(number, 0)
max_runners = optional(number, 10)
boot_time_in_minutes = optional(number, 10)
}), {})
}), null)
}), {})

ssm = optional(object({
paths = optional(object({
root = optional(string, null)
tokens = optional(string, null)
config = optional(string, null)
}), {})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, null)
state = optional(string, null)
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, null)
timeout = optional(number, null)
}), {})
config = optional(object({
tokenPath = optional(string, null)
minimumDaysOld = optional(number, null)
dryRun = optional(bool, null)
}), {})
}), {})
}), {})

observability = optional(object({
logs = optional(object({
level = optional(string, null)
retention_in_days = optional(number, null)
kms_key_id = optional(string, null)
class = optional(string, null)
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, null)
capture_error = optional(bool, null)
}), {})
metrics = optional(object({
enabled = optional(bool, null)
namespace = optional(string, null)
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, null)
}), {})
job_retry = optional(object({
enabled = optional(bool, null)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, null)
}), {})
}), {})
}), {})
}), {})

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
}), {})
}), {})
}))
| `{}` | no | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | @@ -239,6 +240,7 @@ module "multi-runner" { | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | | [runners\_map](#output\_runners\_map) | n/a | | [runners\_map\_v2](#output\_runners\_map\_v2) | n/a | +| [scale\_set](#output\_scale\_set) | Shared scale-set orchestration resources, or null when no runner configuration selects scale\_set. | | [ssm\_parameters](#output\_ssm\_parameters) | n/a | | [webhook](#output\_webhook) | n/a | diff --git a/modules/multi-runner/config.experimental.effective.tf b/modules/multi-runner/config.experimental.effective.tf index 1a53a16520..4e54b5070b 100644 --- a/modules/multi-runner/config.experimental.effective.tf +++ b/modules/multi-runner/config.experimental.effective.tf @@ -16,8 +16,10 @@ locals { }) github = { - enterprise_server = local.normalized_config.github.enterprise_server - user_agent = local.normalized_config.github.user_agent + enterprise_server = local.normalized_config.github.enterprise_server + runner_owner = local.normalized_config.github.runner_owner + runner_registration_level = local.normalized_config.github.runner_registration_level + user_agent = local.normalized_config.github.user_agent } lambda = merge(v.lambda, { @@ -35,6 +37,7 @@ locals { artifact = local.normalized_config.orchestration_provider.webhook.lambda.artifact }) }) + scale_set = v.orchestration_provider.scale_set } ssm = merge(v.ssm, { diff --git a/modules/multi-runner/config.experimental.resolved.tf b/modules/multi-runner/config.experimental.resolved.tf index fc29e81a8d..5753b19c8b 100644 --- a/modules/multi-runner/config.experimental.resolved.tf +++ b/modules/multi-runner/config.experimental.resolved.tf @@ -44,7 +44,7 @@ locals { multi_runner_config = local.stable_to_v2_multi_runner_config } - use_v2_config = contains(var.experimental_features, "multi-runner-v2") + use_v2_config = length(local.v2_multi_runner_config) > 0 normalized_config = local.use_v2_config ? local.v2_config : local.stable_to_v2 } @@ -295,6 +295,7 @@ locals { tags = merge(local.normalized_config.orchestration_provider.webhook.queue.tags, v.orchestration_provider.webhook.queue.tags) }) }) + scale_set = v.orchestration_provider.scale_set } ssm = merge(v.ssm, { diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index 301d37ec4d..fb8f09b96e 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -38,7 +38,9 @@ locals { url = var.ghes_url ssl_verify = var.ghes_ssl_verify } - user_agent = var.user_agent + runner_owner = null + runner_registration_level = "organization" + user_agent = var.user_agent } stable_to_v2_lambda = { @@ -141,6 +143,7 @@ locals { encryption = var.queue_encryption } } + scale_set = null } stable_to_v2_ssm = { @@ -427,6 +430,7 @@ locals { } } } + scale_set = null } ssm = { diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 23d51b7f65..99a41d5207 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -3,8 +3,9 @@ locals { "ghr:environment" = var.prefix }) - primary_app_id = coalesce(local.effective_config.github.app.id_ssm, module.ssm.parameters.github_app_id) - primary_app_key_base64 = coalesce(local.effective_config.github.app.key_base64_ssm, module.ssm.parameters.github_app_key_base64) + primary_app_id = coalesce(local.effective_config.github.app.id_ssm, module.ssm.parameters.github_app_id) + primary_app_key_base64 = coalesce(local.effective_config.github.app.key_base64_ssm, module.ssm.parameters.github_app_key_base64) + primary_app_installation_id = local.effective_config.github.app.installation_id_ssm != null ? local.effective_config.github.app.installation_id_ssm : module.ssm.parameters.github_app_installation_id github_app_parameters = { id = local.primary_app_id diff --git a/modules/multi-runner/orchestration-provider.scale-set.tf b/modules/multi-runner/orchestration-provider.scale-set.tf new file mode 100644 index 0000000000..f18a7c1230 --- /dev/null +++ b/modules/multi-runner/orchestration-provider.scale-set.tf @@ -0,0 +1,62 @@ +locals { + scale_set_runner_configs = { + for runner_name, runner_config in local.effective_config.multi_runner_config : runner_name => { + github = { + enterprise_server = local.effective_config.github.enterprise_server + app = { + app_id = { + name = local.primary_app_id.name + arn = local.primary_app_id.arn + kms_key_arn = local.effective_config.ssm.kms_key_id + } + private_key = { + name = local.primary_app_key_base64.name + arn = local.primary_app_key_base64.arn + kms_key_arn = local.effective_config.ssm.kms_key_id + } + installation_id = local.primary_app_installation_id == null ? null : { + name = local.primary_app_installation_id.name + arn = local.primary_app_installation_id.arn + kms_key_arn = local.effective_config.ssm.kms_key_id + } + } + runner_owner = local.effective_config.github.runner_owner + runner_registration_level = local.effective_config.github.runner_registration_level + user_agent = local.effective_config.github.user_agent + } + scale_set = { + name = runner_config.orchestration_provider.scale_set.name + runner = { + labels = runner_config.runner.labels + group_name = runner_config.runner.group_name + min_runners = runner_config.orchestration_provider.scale_set.runner.min_runners + max_runners = runner_config.orchestration_provider.scale_set.runner.max_runners + boot_time_in_minutes = runner_config.orchestration_provider.scale_set.runner.boot_time_in_minutes + } + } + compute_provider = module.runner_configs[runner_name].compute_provider_contract + } + if runner_config.orchestration_provider.scale_set != null + } +} + +module "orchestration_scale_set" { + source = "../orchestration-providers/scale-set" + count = length(local.scale_set_runner_configs) > 0 && local.primary_app_installation_id != null ? 1 : 0 + + prefix = var.prefix + log_level = var.global_config_observability.logs.level + runner_configs = local.scale_set_runner_configs + + grouping = try(local.effective_config.orchestration_provider.scale_set.grouping, {}) + container = try(local.effective_config.orchestration_provider.scale_set.container, {}) + config_store = try(local.effective_config.orchestration_provider.scale_set.config_store, {}) + ecs = try(local.effective_config.orchestration_provider.scale_set.ecs, {}) + network = try(local.effective_config.orchestration_provider.scale_set.network, {}) + logging = try(local.effective_config.orchestration_provider.scale_set.logging, {}) + tags = merge( + local.effective_config.tags, + try(local.effective_config.orchestration_provider.scale_set.tags, {}), + { "ghr:environment" = var.prefix }, + ) +} diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index 6b7632ffc6..2ec43c8c56 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -20,7 +20,6 @@ output "runners_map" { } } } - output "runners_map_v2" { value = { for runner_key, runner in module.runner_configs : runner_key => { runner = runner.runner @@ -33,6 +32,16 @@ output "runners_map_v2" { } } +output "scale_set" { + description = "Shared scale-set orchestration resources, or null when no runner configuration selects scale_set." + value = length(module.orchestration_scale_set) == 0 ? null : { + cluster = module.orchestration_scale_set[0].cluster + controller_groups = module.orchestration_scale_set[0].controller_groups + reconciler_config_parameters = module.orchestration_scale_set[0].reconciler_config_parameters + resolved_container_image = module.orchestration_scale_set[0].resolved_container_image + } +} + output "binaries_syncer_map" { value = { for runner_binary_key, runner_binary in module.runner_binaries : runner_binary_key => { lambda = runner_binary.lambda diff --git a/modules/multi-runner/queues.tf b/modules/multi-runner/queues.tf index 0f57020571..c969bc6881 100644 --- a/modules/multi-runner/queues.tf +++ b/modules/multi-runner/queues.tf @@ -26,8 +26,15 @@ data "aws_iam_policy_document" "deny_insecure_transport" { } } +locals { + webhook_queue_configs = { + for config, values in local.effective_config.multi_runner_config : config => values + if values.orchestration_provider.webhook != null + } +} + resource "aws_sqs_queue" "queued_builds" { - for_each = local.effective_config.multi_runner_config + for_each = local.webhook_queue_configs name = "${var.prefix}-${each.key}-queued-builds" delay_seconds = each.value.orchestration_provider.webhook.queue.delay_webhook_event visibility_timeout_seconds = each.value.orchestration_provider.webhook.queue.visibility_timeout_seconds @@ -50,14 +57,14 @@ resource "aws_sqs_queue" "queued_builds" { } resource "aws_sqs_queue_policy" "build_queue_policy" { - for_each = local.effective_config.multi_runner_config + for_each = local.webhook_queue_configs queue_url = aws_sqs_queue.queued_builds[each.key].id policy = data.aws_iam_policy_document.deny_insecure_transport.json } resource "aws_sqs_queue" "queued_builds_dlq" { for_each = { - for config, values in local.effective_config.multi_runner_config : config => values + for config, values in local.webhook_queue_configs : config => values if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled } name = "${var.prefix}-${each.key}-queued-builds_dead_letter" @@ -74,7 +81,7 @@ resource "aws_sqs_queue" "queued_builds_dlq" { resource "aws_sqs_queue_policy" "build_queue_dlq_policy" { for_each = { - for config, values in local.effective_config.multi_runner_config : config => values + for config, values in local.webhook_queue_configs : config => values if values.orchestration_provider.webhook.queue.redrive_build_queue.enabled } queue_url = aws_sqs_queue.queued_builds_dlq[each.key].id diff --git a/modules/multi-runner/runners.experimental.tf b/modules/multi-runner/runners.experimental.tf index 55c850eb84..78349b733b 100644 --- a/modules/multi-runner/runners.experimental.tf +++ b/modules/multi-runner/runners.experimental.tf @@ -10,17 +10,24 @@ module "runner_configs" { prefix = "${var.prefix}-${each.key}" tags = merge( - local.effective_config.tags, each.value.tags, - { "ghr:environment" = "${var.prefix}-${each.key}" }, + { "ghr:environment" = var.prefix }, ) runner = each.value.runner github = merge(each.value.github, { app_parameters = { - id = local.github_app_parameters.id - key_base64 = local.github_app_parameters.key_base64 - additional_apps_manifest = local.github_app_parameters.additional_apps_manifest - additional_app_parameter_arns = local.github_app_parameters.additional_app_parameter_arns + id = concat( + [local.primary_app_id], + [for app in module.ssm.additional_app_parameters : app.id], + ) + key_base64 = concat( + [local.primary_app_key_base64], + [for app in module.ssm.additional_app_parameters : app.key_base64], + ) + installation_id = concat( + [null], + [for app in module.ssm.additional_app_parameters : app.installation_id], + ) } }) lambda = each.value.lambda @@ -37,6 +44,7 @@ module "runner_configs" { lambda = each.value.orchestration_provider.webhook.lambda job_retry = each.value.orchestration_provider.webhook.job_retry } + scale_set = each.value.orchestration_provider.scale_set } ssm = each.value.ssm observability = each.value.observability diff --git a/modules/multi-runner/tests/config-effective.tftest.hcl b/modules/multi-runner/tests/config-effective.tftest.hcl index 392e64a762..0c7a6fe06e 100644 --- a/modules/multi-runner/tests/config-effective.tftest.hcl +++ b/modules/multi-runner/tests/config-effective.tftest.hcl @@ -278,10 +278,10 @@ run "v2_effective_config_contains_derived_values" { assert { condition = ( - module.runner_configs["lane"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-action-runners/github-actions/app/github_app_id" - && module.runner_configs["lane"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-action-runners/github-actions/app/github_app_key_base64" - && module.runner_configs["lane"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APPS_MANIFEST_NAME"] == "/github-action-runners/github-actions/app/additional_github_apps_manifest" + module.runner_configs["lane"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-action-runners/github-actions/app/github_app_id:/tests/github-app/additional-0/id:/tests/github-app/additional-1/id" + && module.runner_configs["lane"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-action-runners/github-actions/app/github_app_key_base64:/tests/github-app/additional-0/key:/tests/github-app/additional-1/key" + && module.runner_configs["lane"].scale_up.lambda.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == ":/tests/github-app/additional-0/installation-id:" ) - error_message = "The v2 runner-config adapter must pass the primary GitHub App parameters and additional-app manifest." + error_message = "The v2 runner-config adapter must preserve primary and additional GitHub App parameter ordering." } } diff --git a/modules/multi-runner/tests/config-resolution.tftest.hcl b/modules/multi-runner/tests/config-resolution.tftest.hcl index 40aa71a426..7886f2139d 100644 --- a/modules/multi-runner/tests/config-resolution.tftest.hcl +++ b/modules/multi-runner/tests/config-resolution.tftest.hcl @@ -65,9 +65,10 @@ variables { global_config_github = { app = { - key_base64 = "experimental-app-key" - id = "experimental-app-id" - webhook_secret = "experimental-webhook-secret" + key_base64 = "experimental-app-key" + id = "experimental-app-id" + installation_id = "experimental-app-installation" + webhook_secret = "experimental-webhook-secret" } } @@ -392,6 +393,8 @@ run "v2_inputs_resolve_lane_over_global" { && keys(module.runner_configs) == ["lane"] && length(output.runners_map) == 0 && keys(output.runners_map_v2) == ["lane"] + && keys(aws_sqs_queue.queued_builds) == ["lane"] + && keys(aws_sqs_queue_policy.build_queue_policy) == ["lane"] ) error_message = "Experimental v2 configurations must route through module.runner_configs and skip the legacy runners module." } @@ -448,68 +451,392 @@ run "v2_inputs_do_not_require_legacy_arguments" { } } -run "v2_inputs_require_experimental_feature" { +run "scale_set_only_lane_omits_webhook_queues" { command = plan - expect_failures = [terraform_data.validate_v1] + variables { + experimental_features = ["multi-runner-v2"] + + global_config_github = { + app = { + key_base64 = "experimental-app-key" + id = "experimental-app-id" + installation_id = "experimental-app-installation" + webhook_secret = "experimental-webhook-secret" + } + runner_owner = "example" + runner_registration_level = "organization" + } + + global_config_orchestration_provider = { + webhook = { + eventbridge = { + enabled = false + } + lambda = { + artifact = { + s3 = { + key = "scale-runners.zip" + } + } + webhook = { + artifact = { + s3 = { + key = "scale-webhook.zip" + } + } + } + } + } + scale_set = { + network = { + vpc_id = "vpc-scale-set" + subnet_ids = ["subnet-scale-set"] + } + } + } + + multi_runner_config = { + scale = { + runner = { + os = "linux" + architecture = "x64" + } + orchestration_provider = { + webhook = null + scale_set = { + name = "scale-only" + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large"] + vpc_id = "vpc-scale-set" + subnet_ids = ["subnet-scale-set"] + binaries_syncer = { + enabled = false + } + } + } + } + } + } + } + + assert { + condition = ( + local.resolved_config.multi_runner_config["scale"].orchestration_provider.webhook == null + && local.resolved_config.multi_runner_config["scale"].orchestration_provider.scale_set.name == "scale-only" + && keys(aws_sqs_queue.queued_builds) == [] + && keys(aws_sqs_queue_policy.build_queue_policy) == [] + && keys(aws_sqs_queue.queued_builds_dlq) == [] + && keys(aws_sqs_queue_policy.build_queue_dlq_policy) == [] + && length(module.orchestration_scale_set) == 1 + ) + error_message = "A scale-set-only lane must not create or access webhook SQS resources." + } +} + +run "mixed_webhook_and_scale_set_lanes_create_webhook_queues_only_for_webhook" { + command = plan variables { - vpc_id = "vpc-stable" - subnet_ids = ["subnet-stable"] + experimental_features = ["multi-runner-v2"] - github_app = { - key_base64 = "stable-app-key" - id = "stable-app-id" - webhook_secret = "stable-webhook-secret" + global_config_github = { + app = { + key_base64 = "experimental-app-key" + id = "experimental-app-id" + installation_id = "experimental-app-installation" + webhook_secret = "experimental-webhook-secret" + } + runner_owner = "example" + runner_registration_level = "organization" } - lambda_s3_bucket = "test-lambda-artifacts" - runners_lambda_zip = "README.md" - runners_lambda_s3_key = "runners.zip" - webhook_lambda_s3_key = "webhook.zip" - syncer_lambda_s3_key = "runner-binaries-syncer.zip" + global_config_orchestration_provider = { + webhook = { + eventbridge = { + enabled = false + } + lambda = { + artifact = { + s3 = { + key = "mixed-runners.zip" + } + } + webhook = { + artifact = { + s3 = { + key = "mixed-webhook.zip" + } + } + } + } + } + scale_set = { + network = { + vpc_id = "vpc-scale-set" + subnet_ids = ["subnet-scale-set"] + } + } + } + + multi_runner_config = { + webhook = { + orchestration_provider = { + webhook = { + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + scale_set = null + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large"] + vpc_id = "vpc-webhook" + subnet_ids = ["subnet-webhook"] + binaries_syncer = { + enabled = false + } + } + } + } + } + scale = { + orchestration_provider = { + webhook = null + scale_set = { + name = "scale-mixed" + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large"] + vpc_id = "vpc-scale-set" + subnet_ids = ["subnet-scale-set"] + binaries_syncer = { + enabled = false + } + } + } + } + } + } + } + + assert { + condition = ( + keys(aws_sqs_queue.queued_builds) == ["webhook"] + && keys(aws_sqs_queue_policy.build_queue_policy) == ["webhook"] + && keys(module.runner_configs) == ["scale", "webhook"] + ) + error_message = "Mixed provider lanes must create webhook queues only for the webhook lane while routing both lanes through v2 runner configs." + } +} + +run "scale_set_lane_requires_owner_for_non_enterprise_registration" { + command = plan + + plan_options { + target = [terraform_data.validate_v2] + } + + variables { + experimental_features = ["multi-runner-v2"] + + global_config_github = { + app = { + key_base64 = "experimental-app-key" + id = "experimental-app-id" + webhook_secret = "experimental-webhook-secret" + } + runner_registration_level = "organization" + } multi_runner_config = { - lane = {} + scale = { + orchestration_provider = { + webhook = null + scale_set = { + name = "scale-missing-owner" + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large"] + vpc_id = "vpc-scale-set" + subnet_ids = ["subnet-scale-set"] + } + } + } + } } } + + expect_failures = [terraform_data.validate_v2] } -run "v2_inputs_reject_legacy_runner_config" { +run "scale_set_lane_requires_installation_id" { command = plan + variables { + experimental_features = ["multi-runner-v2"] + + global_config_github = { + app = { + key_base64 = "experimental-app-key" + id = "experimental-app-id" + webhook_secret = "experimental-webhook-secret" + } + runner_owner = "example" + runner_registration_level = "organization" + } + + multi_runner_config = { + scale = { + runner = { + os = "linux" + architecture = "x64" + } + orchestration_provider = { + webhook = null + scale_set = { + name = "scale-missing-installation" + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large"] + vpc_id = "vpc-scale-set" + subnet_ids = ["subnet-scale-set"] + } + } + } + } + } + } + expect_failures = [terraform_data.validate_v2] +} + +run "scale_set_queue_for_each_keys_are_plan_known" { + command = plan + + plan_options { + target = [aws_sqs_queue.queued_builds, aws_sqs_queue.queued_builds_dlq] + } variables { experimental_features = ["multi-runner-v2"] - global_config_compute_provider = { - aws = { - ec2 = { - vpc_id = "vpc-v2" - subnet_ids = ["subnet-v2"] - runner_binaries = { - enabled = false + global_config_orchestration_provider = { + scale_set = { + network = { + vpc_id = "vpc-scale-set" + subnet_ids = ["subnet-scale-set"] + } + } + } + + multi_runner_config = { + scale = { + orchestration_provider = { + webhook = null + scale_set = { + name = "scale-plan-known" + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large"] + vpc_id = "vpc-scale-set" + subnet_ids = ["subnet-scale-set"] + binaries_syncer = { + enabled = false + } + } } } } } + } + + assert { + condition = ( + keys(aws_sqs_queue.queued_builds) == [] + && keys(aws_sqs_queue.queued_builds_dlq) == [] + ) + error_message = "Webhook queue for_each keys must be known and empty for a scale-set-only plan." + } +} + +run "v2_lane_requires_exactly_one_orchestration_provider" { + command = plan + + plan_options { + target = [terraform_data.validate_v2] + } + + variables { + experimental_features = ["multi-runner-v2"] multi_runner_config = { - lane = { - runner_config = { - runner_os = "linux" - runner_architecture = "x64" - instance_types = ["m5.large"] - runners_maximum_count = 1 - enable_runner_binaries_syncer = false - vpc_id = "vpc-legacy" - subnet_ids = ["subnet-legacy"] + missing = { + orchestration_provider = {} + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large"] + vpc_id = "vpc-missing-provider" + subnet_ids = ["subnet-missing-provider"] + } + } } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64"]] + } + } + } + + expect_failures = [terraform_data.validate_v2] +} + +run "v2_lane_rejects_multiple_orchestration_providers" { + command = plan + + plan_options { + target = [terraform_data.validate_v2] + } + + variables { + experimental_features = ["multi-runner-v2"] + + multi_runner_config = { + multiple = { + orchestration_provider = { + webhook = {} + scale_set = { + name = "multiple-providers" + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = ["m5.large"] + vpc_id = "vpc-multiple-providers" + subnet_ids = ["subnet-multiple-providers"] + } + } } } } } + + expect_failures = [terraform_data.validate_v2] } diff --git a/modules/multi-runner/validations.tf b/modules/multi-runner/validations.tf index 257d479b56..49dea05b2b 100644 --- a/modules/multi-runner/validations.tf +++ b/modules/multi-runner/validations.tf @@ -37,11 +37,6 @@ resource "terraform_data" "validate_v1" { ) error_message = "Stable v1 configuration requires github_app, vpc_id, subnet_ids, and multi_runner_config." } - - precondition { - condition = length(local.v2_multi_runner_config) == 0 - error_message = "Stable v1 configuration cannot use v2 runner lanes unless multi-runner-v2 is explicitly enabled." - } } } @@ -72,22 +67,52 @@ resource "terraform_data" "validate_v2" { precondition { condition = alltrue([ - for config in var.multi_runner_config : try(config.runner_config == null, true) + for config in local.resolved_config.multi_runner_config : ( + try(config.compute_provider.aws.ec2 != null, false) && + try(length(config.compute_provider.aws.ec2.instance_types) > 0, false) && + try(config.compute_provider.aws.ec2.vpc_id != null, false) && + try(length(config.compute_provider.aws.ec2.subnet_ids) > 0, false) + ) ]) - error_message = "Experimental v2 configuration cannot use legacy runner_config entries in multi_runner_config." + error_message = "Each experimental v2 runner lane requires the supported aws.ec2 compute provider with instance_types, vpc_id, and at least one subnet." } precondition { condition = alltrue([ for config in local.resolved_config.multi_runner_config : ( - try(config.orchestration_provider.webhook != null, false) && - try(config.compute_provider.aws.ec2 != null, false) && - try(length(config.compute_provider.aws.ec2.instance_types) > 0, false) && - try(config.compute_provider.aws.ec2.vpc_id != null, false) && - try(length(config.compute_provider.aws.ec2.subnet_ids) > 0, false) + try(config.orchestration_provider.webhook != null, false) != + try(config.orchestration_provider.scale_set != null, false) + ) + ]) + error_message = "Each experimental v2 runner lane requires exactly one orchestration provider: webhook or scale_set." + } + + precondition { + condition = alltrue([ + for config in local.resolved_config.multi_runner_config : ( + try(config.orchestration_provider.scale_set, null) == null ? true : ( + contains([ + "organization", + "repository", + ], try(var.global_config_github.runner_registration_level, null)) && + try(var.global_config_github.runner_owner, null) != null + ) + ) + ]) + error_message = "Scale-set lanes require global_config_github.runner_registration_level to be organization or repository; runner_owner must be set for organization and repository registration." + } + + precondition { + condition = alltrue([ + for config in local.resolved_config.multi_runner_config : ( + try(config.orchestration_provider.scale_set, null) == null ? true : ( + try(var.global_config_github.app.installation_id, null) != null || + try(var.global_config_github.app.installation_id_ssm, null) != null + ) ) ]) - error_message = "Each experimental v2 runner lane requires a webhook provider, EC2 instance_types, vpc_id, and at least one subnet." + error_message = "Scale-set lanes require global_config_github.app.installation_id or global_config_github.app.installation_id_ssm." } + } } diff --git a/modules/multi-runner/variables.experimental.github.tf b/modules/multi-runner/variables.experimental.github.tf index 6a783f7d59..79ea3a65cf 100644 --- a/modules/multi-runner/variables.experimental.github.tf +++ b/modules/multi-runner/variables.experimental.github.tf @@ -13,6 +13,10 @@ variable "global_config_github" { id_ssm: "SSM parameter containing the GitHub App ID." id_ssm.arn: "ARN of the SSM parameter containing the GitHub App ID." id_ssm.name: "Name of the SSM parameter containing the GitHub App ID." + installation_id: "GitHub App installation ID for the primary scale-set installation." + installation_id_ssm: "SSM parameter containing the primary GitHub App installation ID." + installation_id_ssm.arn: "ARN of the SSM parameter containing the primary GitHub App installation ID." + installation_id_ssm.name: "Name of the SSM parameter containing the primary GitHub App installation ID." webhook_secret: "GitHub App webhook secret." webhook_secret_ssm: "SSM parameter containing the GitHub App webhook secret." webhook_secret_ssm.arn: "ARN of the SSM parameter containing the GitHub App webhook secret." @@ -33,6 +37,8 @@ variable "global_config_github" { additional_apps.installation_id_ssm.name: "Name of the SSM parameter containing an additional App installation ID." enterprise_server.url: "GitHub Enterprise Server URL." enterprise_server.ssl_verify: "Whether to verify the GitHub Enterprise Server TLS certificate." + runner_owner: "GitHub organization or owner/repository path for organization- or repository-level scale-set registration." + runner_registration_level: "GitHub scale-set registration scope: organization or repository." user_agent: "User-Agent value sent with GitHub API requests." } EOT @@ -48,6 +54,11 @@ variable "global_config_github" { arn = string name = string })) + installation_id = optional(string) + installation_id_ssm = optional(object({ + arn = string + name = string + })) webhook_secret = optional(string) webhook_secret_ssm = optional(object({ arn = string @@ -66,7 +77,9 @@ variable "global_config_github" { url = optional(string, null) ssl_verify = optional(bool, true) }), {}) - user_agent = optional(string, "github-aws-runners") + runner_owner = optional(string, null) + runner_registration_level = optional(string, "organization") + user_agent = optional(string, "github-aws-runners") }) default = {} } diff --git a/modules/multi-runner/variables.experimental.orchestration-provider.tf b/modules/multi-runner/variables.experimental.orchestration-provider.tf index 9919d3645e..41a51d6204 100644 --- a/modules/multi-runner/variables.experimental.orchestration-provider.tf +++ b/modules/multi-runner/variables.experimental.orchestration-provider.tf @@ -173,6 +173,78 @@ variable "global_config_orchestration_provider" { sqs_managed_sse_enabled = true }) }), {}) + + }), {}) + + scale_set = optional(object({ + grouping = optional(object({ + strategy = optional(string, "compute_provider") + custom = optional(object({ + groups = map(object({ + runner_configs = set(string) + })) + }), null) + }), {}) + container = optional(object({ + image = optional(string, null) + user = optional(string, "10001:10001") + health_port = optional(number, 8080) + health_path = optional(string, "/healthz") + health_check_command = optional(list(string), null) + health_check_interval = optional(number, 30) + health_check_timeout = optional(number, 5) + health_check_retries = optional(number, 3) + health_check_start_period = optional(number, 30) + health_stale_after_seconds = optional(number, 180) + shutdown_timeout_seconds = optional(number, 110) + session_close_timeout_seconds = optional(number, 10) + reconnect_initial_backoff_seconds = optional(number, 1) + reconnect_max_backoff_seconds = optional(number, 30) + stop_timeout_seconds = optional(number, 120) + }), {}) + config_store = optional(object({ + path_prefix = optional(string, null) + tier = optional(string, "Standard") + tags = optional(map(string), {}) + }), {}) + ecs = optional(object({ + cluster = optional(object({ + mode = optional(string, "managed") + arn = optional(string, null) + name = optional(string, null) + container_insights = optional(bool, true) + }), {}) + task = optional(object({ + cpu = optional(number, 512) + memory = optional(number, 1024) + cpu_architecture = optional(string, "X86_64") + ephemeral_storage = optional(object({ + size_in_gib = number + }), null) + }), {}) + service = optional(object({ + platform_version = optional(string, "LATEST") + }), {}) + iam = optional(object({ + path = optional(string, "/") + permissions_boundary = optional(string, null) + }), {}) + }), {}) + network = optional(object({ + vpc_id = optional(string, null) + subnet_ids = optional(set(string), null) + https_egress = optional(object({ + ipv4_cidrs = optional(set(string), ["0.0.0.0/0"]) + ipv6_cidrs = optional(set(string), []) + }), {}) + }), {}) + logging = optional(object({ + retention_in_days = optional(number, 30) + kms_key_arn = optional(string, null) + log_group_class = optional(string, "STANDARD") + tags = optional(map(string), {}) + }), {}) + tags = optional(map(string), {}) }), {}) }) default = {} diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf index 793e126e4e..bf47576d67 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -19,6 +19,11 @@ variable "github_app" { arn = string name = string })) + installation_id = optional(string) + installation_id_ssm = optional(object({ + arn = string + name = string + })) webhook_secret = optional(string) webhook_secret_ssm = optional(object({ arn = string @@ -386,6 +391,14 @@ variable "multi_runner_config" { }), {}) }), {}) }), null) + scale_set = optional(object({ + name = string + runner = optional(object({ + min_runners = optional(number, 0) + max_runners = optional(number, 10) + boot_time_in_minutes = optional(number, 10) + }), {}) + }), null) }), {}) ssm = optional(object({ diff --git a/modules/multi-runner/versions.tf b/modules/multi-runner/versions.tf index 224f3e7c76..f149f015a4 100644 --- a/modules/multi-runner/versions.tf +++ b/modules/multi-runner/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.4" required_providers { aws = { diff --git a/modules/orchestration-providers/scale-set/README.md b/modules/orchestration-providers/scale-set/README.md new file mode 100644 index 0000000000..495a8cc314 --- /dev/null +++ b/modules/orchestration-providers/scale-set/README.md @@ -0,0 +1,256 @@ +# Scale-set orchestration provider + +This internal module deploys long-running GitHub Actions runner scale-set controllers on ECS Fargate. It creates one deployment unit per resolved **controller group**: + +```text +1 ECS service +1 task definition +1 running task during normal operation +1 application container +1 ScaleSetController supervising N independent reconcilers +``` + +Each reconciler still owns exactly one GitHub scale-set identity and one message session. Grouping only packs reconcilers into a shared task; it does not merge scale-set identity, session state, or compute-provider behavior. It does, however, intentionally union task IAM permissions and failure/deployment blast radius across all members of that controller group. + +This foundation adopts scale sets that were created elsewhere. It passes the configured name to the controller, which captures the scale-set and runner-group identifiers dynamically from GitHub; it does not create or delete the GitHub scale-set resource. The complete compute-provider contract must likewise come from its Terraform adapter; until that adapter and the public runner-config selection are wired, this internal module is not an end-to-end deployment interface. + +The normalized `(githubConfigUrl, scale_set.name)` ownership tuple must be globally unique across all groups. `runner_registration_level` selects organization or repository scope; `runner_owner` supplies the corresponding path appended to the GitHub server URL. A null enterprise-server URL resolves to GitHub.com. Enterprise-level registration is not supported by this module. Duplicate detection normalizes URL case, one trailing slash, and an explicit default `:443` port, so equivalent spellings cannot accidentally deploy two services against one GitHub message session. Scale-set names may repeat under different GitHub scopes. + +## Grouping + +`grouping.strategy` selects a plan-known grouping implementation: + +- `compute_provider` (default): one group per `compute_provider_contracts[*].type`. +- `runner_config`: one group per runner-config key. +- `custom`: explicit groups whose membership covers every runner config exactly once. + +```hcl +grouping = { + strategy = "custom" + custom = { + groups = { + general = { + runner_configs = ["linux-small", "linux-large"] + } + critical = { + runner_configs = ["production"] + } + } + } +} +``` + +Group names and memberships become Terraform `for_each` identities and must be known during planning. A group may contain at most 1000 runner configs, matching the service loader limit. Additional grouping algorithms can be added later by producing the same internal `map(list(runner_config_name))` shape. + +## Compute-provider capability boundary + +`compute_provider_contracts` is keyed exactly like `runner_configs`. A compute provider implements the scale-set desired-capacity interface by returning: + +```hcl +{ + type = "ec2" # plan-known grouping and runtime registry key + capabilities = { + scale_set = { + configuration_json = local.provider_owned_runtime_configuration + environment_variables = local.provider_owned_non_secret_environment + iam_statements = local.provider_owned_compute_role_statements + } + } +} +``` + +The symbolic locals above represent outputs from the selected compute-provider Terraform adapter; callers should not recreate the provider payload by hand. The provider-specific adapter owns the runtime configuration schema and the complete IAM statement set. This orchestration module treats configuration JSON as an opaque, non-secret object and combines only the selected group's statements into the corresponding compute role. Provider-owned process environment variables are also non-secret: duplicate names within a group must resolve to the same value, and reserved runtime names cannot be overridden. Runner-config-specific values stay in the SSM reconciler document, while credentials stay behind SSM references. Wildcard IAM actions are rejected. The rendered controller and compute-role policies are each checked against AWS's 10,240-byte inline role-policy quota with an explicit split-the-group error; group splitting remains the escape hatch when the union is too large or too broad. + +## Configuration delivery + +For the current ECS deployment, each task receives one bounded `SCALE_SET_CONTROLLER_MANIFEST` environment variable. Its value is JSON with this shape: + +```text +{ + "version": 1, + "groupName": "ec2", + "revision": "", + "reconcilers": [ + { + "schemaVersion": 1, + "runnerConfigName": "linux-small", + "runnerGroupName": "Default", + "scaleSetName": "linux-small", + "githubConfigUrl": "https://github.com/example", + "githubApp": { + "appIdParameterName": "/github/app-id", + "privateKeyParameterName": "/github/private-key", + "installationIdParameterName": "/github/installation-id" + }, + "computeProvider": { + "type": "ec2", + "configuration": {} + }, + "minRunners": 0, + "maxRunners": 20, + "bootTimeoutMinutes": 10, + "sessionOwner": "ec2.linux-small", + "workFolder": "_work", + "forceGhes": false, + "sslVerify": true + } + ] +} +``` + +Each leaf is the flat `ScaleSetReconcilerConfig` consumed by the service. GitHub credential values never enter the manifest; it contains only the exact Parameter Store names used by the runtime. The manifest source is mutually exclusive with the service's SSM group-path source, so the task does not receive `SCALE_SET_CONTROLLER_GROUP_CONFIG_PATH` or `SCALE_SET_CONTROLLER_GROUP_CONFIG_REVISION`. + +The module also keeps the per-reconciler SSM parameters available for the grouped configuration path while that delivery mode is being phased in. The task currently uses the manifest environment variable. + +Terraform derives `sessionOwner` locally as `.`; if that would exceed the runtime's 256-character limit, the module truncates both readable components and appends a deterministic hash. + +The manifest must remain within the ECS task-definition size budget. Use the SSM group-path delivery mode for larger groups once it is enabled by the deployment configuration. + +The individual reconciler object has this shape: + +```json +{ + "schemaVersion": 1, + "runnerConfigName": "linux-small", + "runnerGroupName": "Default", + "githubConfigUrl": "https://github.com/example", + "scaleSetName": "linux-small", + "minRunners": 0, + "maxRunners": 20, + "bootTimeoutMinutes": 10, + "sessionOwner": "ec2.linux-small", + "workFolder": "_work", + "forceGhes": false, + "sslVerify": true, + "githubApp": { + "appIdParameterName": "/github/app-id", + "privateKeyParameterName": "/github/private-key", + "installationIdParameterName": "/github/installation-id" + }, + "computeProvider": { + "type": "ec2", + "configuration": {} + } +} +``` + +GitHub credential **values** never enter Terraform configuration, task definitions, or controller-config parameters. Each leaf carries only three Parameter Store names. The task role can read the exact credential parameter ARNs for its group and decrypt only explicitly declared KMS keys. + +## Container image + +The convenience default is: + +```text +ghcr.io/github-aws-runners/terraform-aws-github-runner-scale-set-service:latest +``` + +ECS `versionConsistency` is enabled so all tasks in a deployment resolve a tag consistently. Production callers should set `container.image` to the digest published with a release: + +```hcl +container = { + image = "ghcr.io/github-aws-runners/terraform-aws-github-runner-scale-set-service@sha256:" +} +``` + +Public registry images need no pull permission. For a private ECR override, set `container.image` to the ECR image URI. The module grants the ECS task execution role wildcard ECR layer-pull permissions plus the unavoidable resource-unscoped `ecr:GetAuthorizationToken` action. The application task role is not used for image pulls. Repository-side access policy remains owned by the ECR module that owns the repository. + +For the official GHCR default, verify an anonymous pull after the first package publish. Package visibility may inherit repository or organization settings and must not be inferred only from a successful authenticated workflow push. + +## ECS and security behavior + +- A managed ECS cluster is created by default. Set `ecs.cluster.mode = "external"` and pass `ecs.cluster.arn` to reuse a cluster. The mode must be known at plan time; the ARN may be computed. +- Every group gets a separate service, task definition, task role, execution role, log group, and security group. +- `desired_count` is fixed at one. Deployment percentages are `minimum = 0` and `maximum = 100`, preventing old and new tasks from overlapping while session leasing is unavailable. +- The ECS deployment circuit breaker and rollback are enabled. +- Tasks run in supplied private subnets with public IP assignment disabled. Managed security groups have no ingress and allow only TCP/443 egress. The IPv4 Internet default is intended for controlled NAT/firewall paths and can be narrowed. +- The application container runs with a numeric non-root UID/GID, a read-only root filesystem, init enabled, no privilege, and all Linux capabilities dropped. +- ECS probes `/healthz` for liveness, and `container.health_path` accepts only that endpoint. `/readyz` remains an application readiness signal; reconnecting to GitHub should not cause ECS to restart every reconciler in a group. +- CloudWatch encrypts logs at rest with an AWS-owned key by default. Set `logging.kms_key_arn` for a customer-managed key and ensure its key policy allows the regional CloudWatch Logs service. + +## Plan-shape requirements + +The following values control `for_each`, dynamic IAM statements, or resource ownership and must be known during planning: + +- runner-config map keys; +- compute-contract map keys and provider `type`; +- grouping strategy, custom group keys, and membership; +- IAM statement keys and optional KMS/ECR wrapper presence; +- optional ECS ephemeral-storage wrapper presence; +- managed versus external cluster mode. + +Inner values such as scale-set names, SSM/KMS ARNs, provider configuration values, IAM actions/resources, and an external cluster ARN may be computed. Nullable computed values should be placed inside a plan-known wrapper rather than used as the wrapper itself. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [terraform](#provider\_terraform) | n/a | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_log_group.controller](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_ecs_cluster.controller](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster) | resource | +| [aws_ecs_service.controller](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource | +| [aws_ecs_task_definition.controller](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource | +| [aws_iam_role.compute](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.compute](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_security_group.controller](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_ssm_parameter.reconciler_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [terraform_data.validate_compute_role_policy](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_config_store](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_contract](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_group_task_policy](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_grouping](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [terraform_data.validate_runtime](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.compute](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.compute_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.task_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [config\_store](#input\_config\_store) | Non-secret controller configuration storage. The module writes one SSM String parameter per reconciler below `path_prefix//`. The task receives only its group path and a SHA-256 revision, then loads the group with `GetParametersByPath`.

Standard parameters are limited to 4096 encoded bytes and Advanced parameters to 8192 encoded bytes. Null `path_prefix` resolves to `//scale-set-controller`. |
object({
path_prefix = optional(string, null)
tier = optional(string, "Standard")
tags = optional(map(string), {})
})
| `{}` | no | +| [container](#input\_container) | Scale-set controller image and runtime settings. A null image uses the internal official convenience image; production callers should use the release digest. Filesystem and Linux capability hardening are enforced by the module; health\_path is fixed at /healthz, the ECS liveness endpoint. |
object({
image = optional(string, null)
user = optional(string, "10001:10001")
health_port = optional(number, 8080)
health_path = optional(string, "/healthz")
health_check_command = optional(list(string), null)
health_check_interval = optional(number, 30)
health_check_timeout = optional(number, 5)
health_check_retries = optional(number, 3)
health_check_start_period = optional(number, 30)
health_stale_after_seconds = optional(number, 180)
shutdown_timeout_seconds = optional(number, 110)
session_close_timeout_seconds = optional(number, 10)
reconnect_initial_backoff_seconds = optional(number, 1)
reconnect_max_backoff_seconds = optional(number, 30)
stop_timeout_seconds = optional(number, 120)
})
| `{}` | no | +| [ecs](#input\_ecs) | ECS substrate configuration. A managed cluster is created by default. For an external cluster, set `cluster.mode = "external"` and pass its ARN; the mode must be plan-known while the ARN may be computed. |
object({
cluster = optional(object({
mode = optional(string, "managed")
arn = optional(string, null)
name = optional(string, null)
container_insights = optional(bool, true)
}), {})
task = optional(object({
cpu = optional(number, 512)
memory = optional(number, 1024)
cpu_architecture = optional(string, "X86_64")
ephemeral_storage = optional(object({
size_in_gib = number
}), null)
}), {})
service = optional(object({
platform_version = optional(string, "LATEST")
}), {})
iam = optional(object({
path = optional(string, "/")
permissions_boundary = optional(string, null)
}), {})
})
| `{}` | no | +| [grouping](#input\_grouping) | Packing strategy for scale-set reconcilers. `compute_provider` creates one controller group per compute-provider type and is the default. `runner_config` creates one group per runner config. `custom` uses `custom.groups`; custom membership must cover every runner config exactly once.

The strategy, custom group keys, and memberships select Terraform `for_each` instances and must be known during planning. |
object({
strategy = optional(string, "compute_provider")
custom = optional(object({
groups = map(object({
runner_configs = set(string)
}))
}), null)
})
| `{}` | no | +| [log\_level](#input\_log\_level) | Logging level for the scale-set controller container. | `string` | `"info"` | no | +| [logging](#input\_logging) | CloudWatch Logs configuration. CloudWatch encrypts logs at rest with an AWS-owned key by default; set `kms_key_arn` to use a customer-managed key. |
object({
retention_in_days = optional(number, 30)
kms_key_arn = optional(string, null)
log_group_class = optional(string, "STANDARD")
tags = optional(map(string), {})
})
| `{}` | no | +| [network](#input\_network) | Private Fargate networking. Tasks never receive public IP addresses and the managed security groups have no ingress. HTTPS egress defaults to IPv4 Internet access because GitHub endpoints cannot be represented as security-group destinations; route it through controlled NAT, firewall, or proxy infrastructure when required. |
object({
vpc_id = string
subnet_ids = set(string)
https_egress = optional(object({
ipv4_cidrs = optional(set(string), ["0.0.0.0/0"])
ipv6_cidrs = optional(set(string), [])
}), {})
})
| n/a | yes | +| [prefix](#input\_prefix) | Stable prefix used for scale-set controller resources. | `string` | `"github-actions"` | no | +| [runner\_configs](#input\_runner\_configs) | Normalized scale-set runner configurations keyed by stable runner-config name.

Map keys must be known during planning. Credential values are never accepted: `github.app` contains only the exact GitHub App Parameter Store references used by the runtime. `github.enterprise_server` and `github.user_agent` carry the global GitHub settings needed to render each reconciler configuration. `scale_set.runner.group_name` selects the GitHub runner group. `runner_registration_level` selects organization or repository registration, and `runner_owner` supplies the corresponding organization or owner/repository path. Enterprise-level registration is not supported by this module. `compute_provider` carries the provider-neutral scale-set capability contract for this runner configuration. Parameter and optional KMS ARNs, scale-set names, and other inner values may remain unknown until apply. |
map(object({
github = object({
enterprise_server = object({
url = optional(string, null)
ssl_verify = optional(bool, true)
})
app = object({
app_id = object({
name = string
arn = string
kms_key_arn = optional(string, null)
})
private_key = object({
name = string
arn = string
kms_key_arn = optional(string, null)
})
installation_id = object({
name = string
arn = string
kms_key_arn = optional(string, null)
})
})
runner_owner = string
runner_registration_level = string
user_agent = string
})
scale_set = object({
name = string
runner = optional(object({
labels = optional(list(string), [])
group_name = optional(string, "Default")
min_runners = optional(number, 0)
max_runners = optional(number, 10)
boot_time_in_minutes = optional(number, 10)
}), {})
})
compute_provider = object({
type = string
capabilities = object({
scale_set = object({
role_arn = optional(string, null)
configuration_json = optional(string, "{}")
environment_variables = optional(map(string), {})
iam_statements = optional(map(object({
actions = set(string)
resources = set(string)
conditions = optional(list(object({
test = string
variable = string
values = set(string)
})), [])
})), {})
})
})
})
}))
| n/a | yes | +| [tags](#input\_tags) | Tags applied to scale-set orchestration resources. | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [cluster](#output\_cluster) | Managed or external ECS cluster selected for all controller groups. | +| [controller\_groups](#output\_controller\_groups) | Controller-group resources keyed by stable resolved group name. | +| [reconciler\_config\_parameters](#output\_reconciler\_config\_parameters) | Non-secret SSM controller configuration parameters keyed by `/`. Values are intentionally not exposed. | +| [resolved\_container\_image](#output\_resolved\_container\_image) | Container image reference selected for the controller task definitions. | + diff --git a/modules/orchestration-providers/scale-set/cluster.tf b/modules/orchestration-providers/scale-set/cluster.tf new file mode 100644 index 0000000000..6c9a586da8 --- /dev/null +++ b/modules/orchestration-providers/scale-set/cluster.tf @@ -0,0 +1,14 @@ +resource "aws_ecs_cluster" "controller" { + count = var.ecs.cluster.mode == "managed" ? 1 : 0 + + name = coalesce(var.ecs.cluster.name, "${var.prefix}-scale-set") + + setting { + name = "containerInsights" + value = var.ecs.cluster.container_insights ? "enabled" : "disabled" + } + + tags = local.common_tags + + depends_on = [terraform_data.validate_runtime] +} diff --git a/modules/orchestration-providers/scale-set/config-store.tf b/modules/orchestration-providers/scale-set/config-store.tf new file mode 100644 index 0000000000..86cb42a73a --- /dev/null +++ b/modules/orchestration-providers/scale-set/config-store.tf @@ -0,0 +1,27 @@ +resource "aws_ssm_parameter" "reconciler_config" { + for_each = local.reconciler_configs + + name = "${local.config_store_path_prefix}/${each.value.group_name}/${each.value.runner_name}" + description = "Non-secret scale-set reconciler configuration for ${each.value.runner_name}" + type = "String" + tier = var.config_store.tier + value = local.reconciler_config_json[each.key] + + tags = merge( + local.group_tags[each.value.group_name], + var.config_store.tags, + ) + + lifecycle { + precondition { + condition = local.reconciler_config_bytes[each.key] <= local.config_store_max_bytes + error_message = "The encoded reconciler configuration exceeds the selected Parameter Store tier limit." + } + } + + depends_on = [ + terraform_data.validate_contract, + terraform_data.validate_grouping, + terraform_data.validate_config_store, + ] +} diff --git a/modules/orchestration-providers/scale-set/data.tf b/modules/orchestration-providers/scale-set/data.tf new file mode 100644 index 0000000000..99b50de05a --- /dev/null +++ b/modules/orchestration-providers/scale-set/data.tf @@ -0,0 +1,5 @@ +data "aws_caller_identity" "current" {} + +data "aws_partition" "current" {} + +data "aws_region" "current" {} diff --git a/modules/orchestration-providers/scale-set/iam.tf b/modules/orchestration-providers/scale-set/iam.tf new file mode 100644 index 0000000000..6065fff396 --- /dev/null +++ b/modules/orchestration-providers/scale-set/iam.tf @@ -0,0 +1,217 @@ +data "aws_iam_policy_document" "task_assume_role" { + statement { + sid = "AllowEcsTasks" + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "aws:SourceAccount" + values = [data.aws_caller_identity.current.account_id] + } + + condition { + test = "ArnLike" + variable = "aws:SourceArn" + values = [format( + "arn:%s:ecs:%s:%s:*", + data.aws_partition.current.partition, + data.aws_region.current.region, + data.aws_caller_identity.current.account_id, + )] + } + } +} + +resource "aws_iam_role" "task" { + for_each = local.controller_groups + + name = "${local.group_resource_names[each.key]}-task" + path = var.ecs.iam.path + permissions_boundary = var.ecs.iam.permissions_boundary + assume_role_policy = data.aws_iam_policy_document.task_assume_role.json + tags = local.group_tags[each.key] + + depends_on = [ + terraform_data.validate_contract, + terraform_data.validate_grouping, + terraform_data.validate_runtime, + ] +} + +data "aws_iam_policy_document" "task" { + for_each = local.controller_groups + + source_policy_documents = [local.group_github_kms_policy_json[each.key]] + + statement { + sid = "ReadControllerGroupConfig" + effect = "Allow" + actions = ["ssm:GetParametersByPath"] + resources = [local.group_config_path_arns[each.key]] + } + + statement { + sid = "ReadGitHubAppParameters" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + resources = [for parameter in local.group_github_parameters[each.key] : parameter.arn] + } + + statement { + sid = "AssumeComputeProviderRoles" + effect = "Allow" + actions = ["sts:AssumeRole"] + resources = [for runner_name in local.controller_groups[each.key] : local.compute_role_arns["${each.key}/${runner_name}"]] + } +} + +resource "aws_iam_role_policy" "task" { + for_each = local.controller_groups + + name = "scale-set-controller" + role = aws_iam_role.task[each.key].name + policy = data.aws_iam_policy_document.task[each.key].json + + depends_on = [terraform_data.validate_group_task_policy] +} + +data "aws_iam_policy_document" "compute_assume_role" { + for_each = local.compute_role_configs + + statement { + sid = "AllowScaleSetTask" + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "AWS" + identifiers = [format( + "arn:%s:iam::%s:root", + data.aws_partition.current.partition, + data.aws_caller_identity.current.account_id, + )] + } + + condition { + test = "ArnEquals" + variable = "aws:PrincipalArn" + values = [format( + "arn:%s:iam::%s:role%s%s-task", + data.aws_partition.current.partition, + data.aws_caller_identity.current.account_id, + var.ecs.iam.path, + local.group_resource_names[each.value.group_name], + )] + } + } +} + +resource "aws_iam_role" "compute" { + for_each = local.compute_role_configs + + name = "${local.group_resource_names[each.value.group_name]}-compute-${substr(sha256(each.key), 0, 8)}" + path = var.ecs.iam.path + permissions_boundary = var.ecs.iam.permissions_boundary + assume_role_policy = data.aws_iam_policy_document.compute_assume_role[each.key].json + tags = local.group_tags[each.value.group_name] +} + +data "aws_iam_policy_document" "compute" { + for_each = local.compute_role_configs + + dynamic "statement" { + for_each = local.reconciler_compute_iam_statements[each.key] + + content { + effect = "Allow" + actions = statement.value.actions + resources = statement.value.resources + + dynamic "condition" { + for_each = statement.value.conditions + + content { + test = condition.value.test + variable = condition.value.variable + values = condition.value.values + } + } + } + } +} + +resource "aws_iam_role_policy" "compute" { + for_each = local.compute_role_configs + + name = "scale-set-compute" + role = aws_iam_role.compute[each.key].name + policy = data.aws_iam_policy_document.compute[each.key].json + + depends_on = [terraform_data.validate_compute_role_policy] +} + +resource "aws_iam_role" "execution" { + for_each = local.controller_groups + + name = "${local.group_resource_names[each.key]}-exec" + path = var.ecs.iam.path + permissions_boundary = var.ecs.iam.permissions_boundary + assume_role_policy = data.aws_iam_policy_document.task_assume_role.json + tags = local.group_tags[each.key] + + depends_on = [ + terraform_data.validate_contract, + terraform_data.validate_grouping, + terraform_data.validate_runtime, + ] +} + +data "aws_iam_policy_document" "execution" { + for_each = local.controller_groups + + statement { + sid = "WriteControllerLogs" + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + resources = ["${aws_cloudwatch_log_group.controller[each.key].arn}:*"] + } + + statement { + sid = "PullPrivateEcrImage" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + ] + resources = ["*"] + } + + statement { + # ECR does not support resource-level permissions for authorization tokens. + sid = "AuthorizePrivateEcrPull" + effect = "Allow" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } +} + +resource "aws_iam_role_policy" "execution" { + for_each = local.controller_groups + + name = "scale-set-controller-execution" + role = aws_iam_role.execution[each.key].name + policy = data.aws_iam_policy_document.execution[each.key].json +} diff --git a/modules/orchestration-providers/scale-set/locals.tf b/modules/orchestration-providers/scale-set/locals.tf new file mode 100644 index 0000000000..523b6b5a26 --- /dev/null +++ b/modules/orchestration-providers/scale-set/locals.tf @@ -0,0 +1,254 @@ +locals { + github_config_urls = { + for runner_name, runner_config in var.runner_configs : runner_name => format( + "%s%s", + trimsuffix(coalesce(runner_config.github.enterprise_server.url, "https://github.com"), "/"), + runner_config.github.runner_owner == null ? "" : "/${runner_config.github.runner_owner}", + ) + } + declared_custom_groups = var.grouping.strategy == "custom" && var.grouping.custom != null ? { + for group_name, group in var.grouping.custom.groups : group_name => sort(tolist(group.runner_configs)) + } : {} + + controller_groups = ( + var.grouping.strategy == "compute_provider" ? { + for provider_type in distinct([ + for runner_name in keys(var.runner_configs) : var.runner_configs[runner_name].compute_provider.type + ]) : provider_type => [ + for runner_name in keys(var.runner_configs) : runner_name + if var.runner_configs[runner_name].compute_provider.type == provider_type + ] + } : + var.grouping.strategy == "runner_config" ? { + for runner_name in keys(var.runner_configs) : runner_name => [runner_name] + } : + var.grouping.strategy == "custom" ? { + for group_name, runner_names in local.declared_custom_groups : group_name => [ + for runner_name in runner_names : runner_name + if contains(keys(var.runner_configs), runner_name) + ] + } : + {} + ) + + group_resource_names = { + for group_name in keys(local.controller_groups) : group_name => format( + "%s-ss-%s-%s", + var.prefix, + substr(replace(lower(group_name), "/[^a-z0-9_-]/", "-"), 0, 14), + substr(sha256(group_name), 0, 8), + ) + } + + official_container_image = "ghcr.io/github-aws-runners/terraform-aws-github-runner-scale-set-service:latest" + resolved_container_image = coalesce(var.container.image, local.official_container_image) + + resolved_health_check_command = var.container.health_check_command != null ? var.container.health_check_command : [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:${var.container.health_port}${var.container.health_path}').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + + reconciler_configs = merge([ + for group_name, runner_names in local.controller_groups : { + for runner_name in runner_names : "${group_name}/${runner_name}" => { + group_name = group_name + runner_name = runner_name + value = merge({ + schemaVersion = 1 + runnerConfigName = runner_name + runnerGroupName = var.runner_configs[runner_name].scale_set.runner.group_name + runnerLabels = var.runner_configs[runner_name].scale_set.runner.labels + githubConfigUrl = local.github_config_urls[runner_name] + scaleSetName = var.runner_configs[runner_name].scale_set.name + minRunners = var.runner_configs[runner_name].scale_set.runner.min_runners + maxRunners = var.runner_configs[runner_name].scale_set.runner.max_runners + bootTimeoutMinutes = var.runner_configs[runner_name].scale_set.runner.boot_time_in_minutes + workFolder = "_work" + sslVerify = var.runner_configs[runner_name].github.enterprise_server.ssl_verify + forceGhes = var.runner_configs[runner_name].github.enterprise_server.url != null + sessionOwner = ( + length("${group_name}.${runner_name}") <= 256 + ? "${group_name}.${runner_name}" + : "${substr(group_name, 0, 119)}.${substr(runner_name, 0, 119)}.${substr(sha256(format("%s.%s", group_name, runner_name)), 0, 16)}" + ) + githubApp = { + appIdParameterName = var.runner_configs[runner_name].github.app.app_id.name + privateKeyParameterName = var.runner_configs[runner_name].github.app.private_key.name + installationIdParameterName = var.runner_configs[runner_name].github.app.installation_id.name + } + computeProvider = { + type = var.runner_configs[runner_name].compute_provider.type + roleArn = local.compute_role_arns["${group_name}/${runner_name}"] + configuration = jsondecode(var.runner_configs[runner_name].compute_provider.capabilities.scale_set.configuration_json) + } + userAgent = var.runner_configs[runner_name].github.user_agent + }) + } + } + ]...) + + config_store_path_prefix = coalesce(var.config_store.path_prefix, "/${var.prefix}/scale-set-controller") + group_config_paths = { + for group_name in keys(local.controller_groups) : group_name => "${local.config_store_path_prefix}/${group_name}" + } + group_config_revisions = { + for group_name, runner_names in local.controller_groups : group_name => sha256(jsonencode({ + for runner_name in runner_names : runner_name => local.reconciler_configs["${group_name}/${runner_name}"].value + })) + } + + group_controller_manifests = { + for group_name, runner_names in local.controller_groups : group_name => jsonencode({ + version = 1 + groupName = group_name + revision = local.group_config_revisions[group_name] + reconcilers = [ + for runner_name in runner_names : local.reconciler_configs["${group_name}/${runner_name}"].value + ] + }) + } + group_github_parameters = { + for group_name, runner_names in local.controller_groups : group_name => flatten([ + for runner_name in runner_names : [ + { + arn = var.runner_configs[runner_name].github.app.app_id.arn + kms_key_arn = var.runner_configs[runner_name].github.app.app_id.kms_key_arn + }, + { + arn = var.runner_configs[runner_name].github.app.private_key.arn + kms_key_arn = var.runner_configs[runner_name].github.app.private_key.kms_key_arn + }, + { + arn = var.runner_configs[runner_name].github.app.installation_id.arn + kms_key_arn = var.runner_configs[runner_name].github.app.installation_id.kms_key_arn + }, + ] + ]) + } + + group_github_kms_policy_json = { + for group_name, parameters in local.group_github_parameters : group_name => jsonencode({ + Version = "2012-10-17" + Statement = length(compact([for parameter in parameters : parameter.kms_key_arn])) == 0 ? [] : [{ + Sid = "DecryptGitHubAppParameters" + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = distinct(compact([for parameter in parameters : parameter.kms_key_arn])) + }] + }) + } + + compute_role_configs = { + for config_key in flatten([ + for group_name, runner_names in local.controller_groups : [ + for runner_name in runner_names : { + key = "${group_name}/${runner_name}" + group_name = group_name + runner_name = runner_name + } + ] + ]) : config_key.key => config_key + if var.runner_configs[config_key.runner_name].compute_provider.capabilities.scale_set.role_arn == null + } + + compute_role_arns = { + for config in flatten([ + for group_name, runner_names in local.controller_groups : [ + for runner_name in runner_names : { + key = "${group_name}/${runner_name}" + group_name = group_name + runner_name = runner_name + } + ] + ]) : config.key => ( + var.runner_configs[config.runner_name].compute_provider.capabilities.scale_set.role_arn != null + ? var.runner_configs[config.runner_name].compute_provider.capabilities.scale_set.role_arn + : format( + "arn:%s:iam::%s:role%s%s-compute-%s", + data.aws_partition.current.partition, + data.aws_caller_identity.current.account_id, + var.ecs.iam.path, + local.group_resource_names[config.group_name], + substr(sha256(config.key), 0, 8), + ) + ) + } + + reconciler_compute_iam_statements = { + for config_key, config in local.compute_role_configs : config_key => { + for statement_name, statement in var.runner_configs[config.runner_name].compute_provider.capabilities.scale_set.iam_statements : + statement_name => statement + } + } + + group_compute_environment_entries = { + for group_name, runner_names in local.controller_groups : group_name => flatten([ + for runner_name in runner_names : [ + for name, value in var.runner_configs[runner_name].compute_provider.capabilities.scale_set.environment_variables : { + runner_name = runner_name + name = name + value = value + } + ] + ]) + } + + group_compute_environment_variables = { + for group_name, entries in local.group_compute_environment_entries : group_name => merge([ + for entry in entries : { (entry.name) = entry.value } + ]...) + } + + config_store_max_bytes = var.config_store.tier == "Advanced" ? 8192 : 4096 + + reconciler_config_json = { + for config_key, config in local.reconciler_configs : config_key => jsonencode(config.value) + } + reconciler_config_bytes = { + for config_key, config_json in local.reconciler_config_json : config_key => ( + floor(length(base64encode(config_json)) * 3 / 4) - + (endswith(base64encode(config_json), "==") ? 2 : endswith(base64encode(config_json), "=") ? 1 : 0) + ) + } + + cluster_arn = var.ecs.cluster.mode == "managed" ? aws_ecs_cluster.controller[0].arn : var.ecs.cluster.arn + + group_config_path_arns = { + for group_name, config_path in local.group_config_paths : group_name => format( + "arn:%s:ssm:%s:%s:parameter%s/*", + data.aws_partition.current.partition, + data.aws_region.current.region, + data.aws_caller_identity.current.account_id, + config_path, + ) + } + + fargate_memory_by_cpu = { + 256 = [512, 1024, 2048] + 512 = [1024, 2048, 3072, 4096] + 1024 = range(2048, 9216, 1024) + 2048 = range(4096, 17408, 1024) + 4096 = range(8192, 31744, 1024) + 8192 = range(16384, 65536, 4096) + 16384 = range(32768, 131072, 8192) + } + + common_tags = merge( + { + "ghr:component" = "scale-set-controller" + }, + var.tags, + ) + + group_tags = { + for group_name, resource_name in local.group_resource_names : group_name => merge( + local.common_tags, + { + Name = resource_name + "ghr:controller-group" = group_name + }, + ) + } +} diff --git a/modules/orchestration-providers/scale-set/logging.tf b/modules/orchestration-providers/scale-set/logging.tf new file mode 100644 index 0000000000..974106316a --- /dev/null +++ b/modules/orchestration-providers/scale-set/logging.tf @@ -0,0 +1,19 @@ +resource "aws_cloudwatch_log_group" "controller" { + for_each = local.controller_groups + + name = "/aws/ecs/${local.group_resource_names[each.key]}" + retention_in_days = var.logging.retention_in_days + kms_key_id = var.logging.kms_key_arn + log_group_class = var.logging.log_group_class + + tags = merge( + local.group_tags[each.key], + var.logging.tags, + ) + + depends_on = [ + terraform_data.validate_contract, + terraform_data.validate_grouping, + terraform_data.validate_runtime, + ] +} diff --git a/modules/orchestration-providers/scale-set/networking.tf b/modules/orchestration-providers/scale-set/networking.tf new file mode 100644 index 0000000000..5cd9d11203 --- /dev/null +++ b/modules/orchestration-providers/scale-set/networking.tf @@ -0,0 +1,27 @@ +resource "aws_security_group" "controller" { + for_each = local.controller_groups + + name = local.group_resource_names[each.key] + description = "Private scale-set controller ${each.key}; no ingress and HTTPS-only egress" + vpc_id = var.network.vpc_id + + ingress = [] + + egress { + description = "HTTPS to GitHub and AWS APIs" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = sort(tolist(var.network.https_egress.ipv4_cidrs)) + ipv6_cidr_blocks = sort(tolist(var.network.https_egress.ipv6_cidrs)) + } + + revoke_rules_on_delete = true + tags = local.group_tags[each.key] + + depends_on = [ + terraform_data.validate_contract, + terraform_data.validate_grouping, + terraform_data.validate_runtime, + ] +} diff --git a/modules/orchestration-providers/scale-set/outputs.tf b/modules/orchestration-providers/scale-set/outputs.tf new file mode 100644 index 0000000000..fd9d1b8c57 --- /dev/null +++ b/modules/orchestration-providers/scale-set/outputs.tf @@ -0,0 +1,58 @@ +output "cluster" { + description = "Managed or external ECS cluster selected for all controller groups." + value = { + arn = local.cluster_arn + managed = var.ecs.cluster.mode == "managed" + } +} + +output "controller_groups" { + description = "Controller-group resources keyed by stable resolved group name." + value = { + for group_name, runner_names in local.controller_groups : group_name => { + runner_configs = runner_names + config_path = local.group_config_paths[group_name] + config_revision = local.group_config_revisions[group_name] + service = { + id = aws_ecs_service.controller[group_name].id + name = aws_ecs_service.controller[group_name].name + } + task_definition = { + arn = aws_ecs_task_definition.controller[group_name].arn + family = aws_ecs_task_definition.controller[group_name].family + } + task_role = { + arn = aws_iam_role.task[group_name].arn + name = aws_iam_role.task[group_name].name + } + execution_role = { + arn = aws_iam_role.execution[group_name].arn + name = aws_iam_role.execution[group_name].name + } + log_group = { + arn = aws_cloudwatch_log_group.controller[group_name].arn + name = aws_cloudwatch_log_group.controller[group_name].name + } + security_group = { + arn = aws_security_group.controller[group_name].arn + id = aws_security_group.controller[group_name].id + } + } + } +} + +output "reconciler_config_parameters" { + description = "Non-secret SSM controller configuration parameters keyed by `/`. Values are intentionally not exposed." + value = { + for config_key, parameter in aws_ssm_parameter.reconciler_config : config_key => { + arn = parameter.arn + name = parameter.name + tier = parameter.tier + } + } +} + +output "resolved_container_image" { + description = "Container image reference selected for the controller task definitions." + value = local.resolved_container_image +} diff --git a/modules/orchestration-providers/scale-set/service.tf b/modules/orchestration-providers/scale-set/service.tf new file mode 100644 index 0000000000..953ebe4058 --- /dev/null +++ b/modules/orchestration-providers/scale-set/service.tf @@ -0,0 +1,40 @@ +resource "aws_ecs_service" "controller" { + for_each = local.controller_groups + + name = local.group_resource_names[each.key] + cluster = local.cluster_arn + task_definition = aws_ecs_task_definition.controller[each.key].arn + desired_count = 1 + launch_type = "FARGATE" + platform_version = var.ecs.service.platform_version + + scheduling_strategy = "REPLICA" + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + enable_ecs_managed_tags = true + enable_execute_command = false + propagate_tags = "SERVICE" + + deployment_circuit_breaker { + enable = true + rollback = true + } + + deployment_controller { + type = "ECS" + } + + network_configuration { + assign_public_ip = false + security_groups = [aws_security_group.controller[each.key].id] + subnets = sort(tolist(var.network.subnet_ids)) + } + + tags = local.group_tags[each.key] + + depends_on = [ + aws_iam_role_policy.execution, + aws_iam_role_policy.task, + aws_iam_role_policy.compute, + ] +} diff --git a/modules/orchestration-providers/scale-set/task.tf b/modules/orchestration-providers/scale-set/task.tf new file mode 100644 index 0000000000..b1e6f44006 --- /dev/null +++ b/modules/orchestration-providers/scale-set/task.tf @@ -0,0 +1,128 @@ +resource "aws_ecs_task_definition" "controller" { + for_each = local.controller_groups + + family = local.group_resource_names[each.key] + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.ecs.task.cpu) + memory = tostring(var.ecs.task.memory) + task_role_arn = aws_iam_role.task[each.key].arn + execution_role_arn = aws_iam_role.execution[each.key].arn + + runtime_platform { + cpu_architecture = var.ecs.task.cpu_architecture + operating_system_family = "LINUX" + } + + dynamic "ephemeral_storage" { + for_each = var.ecs.task.ephemeral_storage == null ? [] : [var.ecs.task.ephemeral_storage] + + content { + size_in_gib = ephemeral_storage.value.size_in_gib + } + } + + container_definitions = jsonencode([ + { + name = "scale-set-controller" + image = local.resolved_container_image + essential = true + user = var.container.user + privileged = false + readonlyRootFilesystem = true + stopTimeout = var.container.stop_timeout_seconds + versionConsistency = "enabled" + linuxParameters = { + initProcessEnabled = true + capabilities = { + drop = ["ALL"] + } + } + environment = concat( + [ + { + name = "LOG_LEVEL" + value = var.log_level + }, + { + name = "POWERTOOLS_SERVICE_NAME" + value = "scale-set-controller" + }, + { + name = "POWERTOOLS_LOG_LEVEL" + value = upper(var.log_level) + }, + { + name = "SCALE_SET_CONTROLLER_MANIFEST" + value = local.group_controller_manifests[each.key] + }, + { + name = "AWS_XRAY_CONTEXT_MISSING" + value = "IGNORE_ERROR" + }, + { + name = "AWS_REGION" + value = data.aws_region.current.region + }, + { + name = "AWS_DEFAULT_REGION" + value = data.aws_region.current.region + }, + { + name = "SCALE_SET_HEALTH_PORT" + value = tostring(var.container.health_port) + }, + { + name = "SCALE_SET_HEALTH_STALE_AFTER_SECONDS" + value = tostring(var.container.health_stale_after_seconds) + }, + { + name = "SCALE_SET_SHUTDOWN_TIMEOUT_SECONDS" + value = tostring(var.container.shutdown_timeout_seconds) + }, + { + name = "SCALE_SET_SESSION_CLOSE_TIMEOUT_SECONDS" + value = tostring(var.container.session_close_timeout_seconds) + }, + { + name = "SCALE_SET_RECONNECT_INITIAL_BACKOFF_SECONDS" + value = tostring(var.container.reconnect_initial_backoff_seconds) + }, + { + name = "SCALE_SET_RECONNECT_MAX_BACKOFF_SECONDS" + value = tostring(var.container.reconnect_max_backoff_seconds) + }, + ], + [ + for name in sort(keys(local.group_compute_environment_variables[each.key])) : { + name = name + value = local.group_compute_environment_variables[each.key][name] + } + ], + ) + healthCheck = { + command = local.resolved_health_check_command + interval = var.container.health_check_interval + timeout = var.container.health_check_timeout + retries = var.container.health_check_retries + startPeriod = var.container.health_check_start_period + } + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.controller[each.key].name + "awslogs-region" = data.aws_region.current.region + "awslogs-stream-prefix" = "controller" + } + } + } + ]) + + tags = local.group_tags[each.key] + + depends_on = [ + aws_iam_role_policy.execution, + aws_iam_role_policy.task, + aws_iam_role_policy.compute, + ] +} diff --git a/modules/orchestration-providers/scale-set/tests/computed-inputs.tftest.hcl b/modules/orchestration-providers/scale-set/tests/computed-inputs.tftest.hcl new file mode 100644 index 0000000000..22640ccd7f --- /dev/null +++ b/modules/orchestration-providers/scale-set/tests/computed-inputs.tftest.hcl @@ -0,0 +1,49 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + } + } + + mock_data "aws_region" { + defaults = { + region = "eu-west-1" + } + } + + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/computed-test" + } + } +} + +run "plans_with_computed_values_inside_known_wrappers" { + command = plan + + module { + source = "./tests/fixtures/computed-inputs" + } + + assert { + condition = ( + toset(keys(output.controller_groups)) == toset(["ec2"]) && + toset(output.controller_groups.ec2.runner_configs) == toset(["computed"]) && + !output.cluster.managed && + toset(keys(output.reconciler_config_parameters)) == toset(["ec2/computed"]) + ) + error_message = "Computed inner values and explicit nulls must not affect group, ownership, IAM-wrapper, or cluster resource shape." + } +} diff --git a/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/README.md b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/README.md new file mode 100644 index 0000000000..25516ecf3a --- /dev/null +++ b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/README.md @@ -0,0 +1,38 @@ + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +|------|---------| +| [terraform](#provider\_terraform) | n/a | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [subject](#module\_subject) | ../../.. | n/a | + +## Resources + +| Name | Type | +|------|------| +| [terraform_data.computed](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | + +## Inputs + +No inputs. + +## Outputs + +| Name | Description | +|------|-------------| +| [cluster](#output\_cluster) | n/a | +| [controller\_groups](#output\_controller\_groups) | n/a | +| [reconciler\_config\_parameters](#output\_reconciler\_config\_parameters) | n/a | + diff --git a/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/main.tf b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/main.tf new file mode 100644 index 0000000000..db8ae2b225 --- /dev/null +++ b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/main.tf @@ -0,0 +1,100 @@ +resource "terraform_data" "computed" { + input = { + external_cluster_arn = "arn:aws:ecs:eu-west-1:123456789012:cluster/external" + app_id_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/computed/app-id" + private_key_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/computed/private-key" + installation_id_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/computed/installation-id" + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/33333333-3333-3333-3333-333333333333" + launch_template_name = "lt-computed" + action = "ec2:RunInstances" + resource = "arn:aws:ec2:eu-west-1:123456789012:launch-template/lt-computed" + } +} + +module "subject" { + source = "../../.." + + prefix = "computed-test" + + runner_configs = { + computed = { + github = { + enterprise_server = {} + app = { + app_id = { + name = "/github/computed/app-id" + arn = terraform_data.computed.output.app_id_arn + } + private_key = { + name = "/github/computed/private-key" + arn = terraform_data.computed.output.private_key_arn + kms_key_arn = terraform_data.computed.output.kms_key_arn + } + installation_id = { + name = "/github/computed/installation-id" + arn = terraform_data.computed.output.installation_id_arn + } + } + runner_owner = "example" + runner_registration_level = "organization" + user_agent = "scale-set-test" + } + scale_set = { + name = "computed" + } + compute_provider = { + type = "ec2" + capabilities = { + scale_set = { + configuration_json = jsonencode({ + region = "eu-west-1" + environment = "computed-test" + runnerOwner = "example" + runnerType = "Org" + runnerNamePrefix = "computed-" + jitConfigParameterPath = "/computed-test/runners/tokens" + subnets = ["subnet-12345678"] + launchTemplateName = terraform_data.computed.output.launch_template_name + ec2instanceCriteria = { + instanceTypes = ["m7i.large"] + targetCapacityType = "on-demand" + instanceAllocationStrategy = "lowest-price" + } + scaleErrors = [] + }) + iam_statements = { + run_instances = { + actions = [terraform_data.computed.output.action] + resources = [terraform_data.computed.output.resource] + } + } + } + } + } + } + } + + ecs = { + cluster = { + mode = "external" + arn = terraform_data.computed.output.external_cluster_arn + } + } + + network = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + } +} + +output "controller_groups" { + value = module.subject.controller_groups +} + +output "cluster" { + value = module.subject.cluster +} + +output "reconciler_config_parameters" { + value = module.subject.reconciler_config_parameters +} diff --git a/examples/migration-test/v2/versions.tf b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/versions.tf similarity index 51% rename from examples/migration-test/v2/versions.tf rename to modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/versions.tf index 0d1c1d303f..0bedc91fd5 100644 --- a/examples/migration-test/v2/versions.tf +++ b/modules/orchestration-providers/scale-set/tests/fixtures/computed-inputs/versions.tf @@ -1,18 +1,10 @@ terraform { - backend "local" { - path = "../migration.tfstate" - } + required_version = ">= 1.5.6" required_providers { aws = { source = "hashicorp/aws" version = ">= 6.33" } - random = { - source = "hashicorp/random" - version = "~> 3.0" - } } - - required_version = ">= 1.5.6" } diff --git a/modules/orchestration-providers/scale-set/tests/scale-set.tftest.hcl b/modules/orchestration-providers/scale-set/tests/scale-set.tftest.hcl new file mode 100644 index 0000000000..870a73b9b2 --- /dev/null +++ b/modules/orchestration-providers/scale-set/tests/scale-set.tftest.hcl @@ -0,0 +1,1137 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + } + } + + mock_data "aws_region" { + defaults = { + region = "eu-west-1" + } + } + + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/scale-set-test" + } + } + + mock_resource "aws_ecs_cluster" { + defaults = { + arn = "arn:aws:ecs:eu-west-1:123456789012:cluster/scale-set-test" + } + } +} + +variables { + prefix = "scale-set-test" + + runner_configs = { + linux-small = { + github = { + enterprise_server = { + ssl_verify = false + } + app = { + app_id = { + name = "/github/linux-small/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-small/app-id" + } + private_key = { + name = "/github/linux-small/private-key" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-small/private-key" + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111" + } + installation_id = { + name = "/github/linux-small/installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-small/installation-id" + } + } + runner_owner = "example" + runner_registration_level = "organization" + user_agent = "scale-set-test" + } + scale_set = { + name = "linux-small" + runner = { + group_name = "stable-group" + min_runners = 1 + max_runners = 10 + } + } + compute_provider = { + type = "ec2" + capabilities = { + scale_set = { + configuration_json = jsonencode({ + region = "eu-west-1" + environment = "scale-set-test" + runnerOwner = "example" + runnerType = "Org" + runnerNamePrefix = "small-" + jitConfigParameterPath = "/scale-set-test/runners/tokens" + subnets = ["subnet-11111111"] + launchTemplateName = "lt-small" + ec2instanceCriteria = { + instanceTypes = ["m7i.large"] + targetCapacityType = "on-demand" + instanceAllocationStrategy = "lowest-price" + } + scaleErrors = [] + }) + environment_variables = { + EC2_CONTROLLER_MODE = "grouped" + } + iam_statements = { + run_instances = { + actions = ["ec2:RunInstances"] + resources = ["arn:aws:ec2:eu-west-1:123456789012:launch-template/lt-small"] + } + read_ami = { + actions = ["ssm:GetParameters"] + resources = ["arn:aws:ssm:eu-west-1:123456789012:parameter/scale-set-test/runners/config/ami_id"] + } + } + } + } + } + } + linux-large = { + github = { + enterprise_server = { + url = "https://github.example.test" + } + app = { + app_id = { + name = "/github/linux-large/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-large/app-id" + } + private_key = { + name = "/github/linux-large/private-key" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-large/private-key" + } + installation_id = { + name = "/github/linux-large/installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-large/installation-id" + } + } + runner_owner = "example" + runner_registration_level = "organization" + user_agent = "scale-set-test" + } + scale_set = { + name = "linux-large" + runner = { + min_runners = 0 + max_runners = 20 + } + } + compute_provider = { + type = "ec2" + capabilities = { + scale_set = { + configuration_json = jsonencode({ + region = "eu-west-1" + environment = "scale-set-test" + runnerOwner = "example" + runnerType = "Org" + runnerNamePrefix = "large-" + jitConfigParameterPath = "/scale-set-test/runners/tokens" + subnets = ["subnet-22222222"] + launchTemplateName = "lt-large" + ec2instanceCriteria = { + instanceTypes = ["m7i.xlarge"] + targetCapacityType = "on-demand" + instanceAllocationStrategy = "lowest-price" + } + scaleErrors = [] + }) + environment_variables = { + EC2_CONTROLLER_MODE = "grouped" + } + iam_statements = { + run_instances = { + actions = ["ec2:RunInstances"] + resources = ["arn:aws:ec2:eu-west-1:123456789012:launch-template/lt-large"] + } + } + } + } + } + } + microvm = { + github = { + enterprise_server = {} + app = { + app_id = { + name = "/github/microvm/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/microvm/app-id" + } + private_key = { + name = "/github/microvm/private-key" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/microvm/private-key" + } + installation_id = { + name = "/github/microvm/installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/microvm/installation-id" + } + } + runner_owner = "example/repository" + runner_registration_level = "repository" + user_agent = "scale-set-test" + } + scale_set = { + name = "microvm" + runner = { + min_runners = 0 + max_runners = 5 + } + } + compute_provider = { + # Future provider used only to prove grouping remains provider-neutral. + type = "microvm" + capabilities = { + scale_set = { + configuration_json = jsonencode({ image_arn = "arn:aws:lambda:eu-west-1:123456789012:runtime-management-config:microvm" }) + iam_statements = { + run_microvm = { + actions = ["lambda:InvokeFunction"] + resources = ["arn:aws:lambda:eu-west-1:123456789012:function:microvm"] + } + } + } + } + } + } + } + + network = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-11111111", "subnet-22222222"] + } + + logging = { + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/22222222-2222-2222-2222-222222222222" + } + + tags = { + Test = "scale-set" + } +} + +run "groups_by_compute_provider_and_hardens_each_task" { + command = plan + + assert { + condition = ( + toset(keys(output.controller_groups)) == toset(["ec2", "microvm"]) && + toset(output.controller_groups["ec2"].runner_configs) == toset(["linux-small", "linux-large"]) && + toset(output.controller_groups["microvm"].runner_configs) == toset(["microvm"]) + ) + error_message = "The default strategy must create one controller group per compute-provider type." + } + + assert { + condition = ( + length(aws_ecs_service.controller) == 2 && + length(aws_ecs_task_definition.controller) == 2 && + length(aws_iam_role.task) == 2 && + length(aws_cloudwatch_log_group.controller) == 2 && + length(aws_security_group.controller) == 2 && + length(aws_ssm_parameter.reconciler_config) == 3 + ) + error_message = "Every group must own one service, task definition, task role, log group, and security group while every reconciler gets one config parameter." + } + + assert { + condition = alltrue([ + for service in values(aws_ecs_service.controller) : ( + service.desired_count == 1 && + service.deployment_minimum_healthy_percent == 0 && + service.deployment_maximum_percent == 100 && + service.deployment_circuit_breaker[0].enable && + service.deployment_circuit_breaker[0].rollback && + !service.network_configuration[0].assign_public_ip && + length(service.network_configuration[0].security_groups) == 1 + ) + ]) + error_message = "Services must run one private task and use stop-first deployment with circuit-breaker rollback." + } + + assert { + condition = alltrue([ + for task in values(aws_ecs_task_definition.controller) : ( + length(jsondecode(task.container_definitions)) == 1 && + jsondecode(task.container_definitions)[0].image == "ghcr.io/github-aws-runners/terraform-aws-github-runner-scale-set-service:latest" && + jsondecode(task.container_definitions)[0].versionConsistency == "enabled" && + jsondecode(task.container_definitions)[0].readonlyRootFilesystem && + !jsondecode(task.container_definitions)[0].privileged && + jsondecode(task.container_definitions)[0].user == "10001:10001" && + jsondecode(task.container_definitions)[0].linuxParameters.capabilities.drop == ["ALL"] && + jsondecode(task.container_definitions)[0].healthCheck.command[3] == "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" && + one([for entry in jsondecode(task.container_definitions)[0].environment : entry.value if entry.name == "LOG_LEVEL"]) == "info" && + contains([for entry in jsondecode(task.container_definitions)[0].environment : entry.name], "SCALE_SET_CONTROLLER_MANIFEST") && + one([for entry in jsondecode(task.container_definitions)[0].environment : entry.value if entry.name == "AWS_XRAY_CONTEXT_MISSING"]) == "IGNORE_ERROR" && + one([for entry in jsondecode(task.container_definitions)[0].environment : entry.value if entry.name == "AWS_REGION"]) == "eu-west-1" && + one([for entry in jsondecode(task.container_definitions)[0].environment : entry.value if entry.name == "AWS_DEFAULT_REGION"]) == "eu-west-1" && + !contains([for entry in jsondecode(task.container_definitions)[0].environment : entry.name], "SCALE_SET_CONTROLLER_GROUP_CONFIG_PATH") && + !contains([for entry in jsondecode(task.container_definitions)[0].environment : entry.name], "SCALE_SET_CONTROLLER_GROUP_CONFIG_REVISION") + ) + ]) + error_message = "Each task definition must contain one hardened controller container using manifest configuration and /healthz liveness." + } + + assert { + condition = ( + contains(flatten([ + for task in values(aws_ecs_task_definition.controller) : [ + for entry in jsondecode(task.container_definitions)[0].environment : [ + for reconciler in jsondecode(entry.value).reconcilers : reconciler.runnerGroupName + ] + if entry.name == "SCALE_SET_CONTROLLER_MANIFEST" + ] + ]), "stable-group") && + alltrue([ + for task in values(aws_ecs_task_definition.controller) : alltrue([ + for entry in jsondecode(task.container_definitions)[0].environment : entry.name != "SCALE_SET_CONTROLLER_MANIFEST" || ( + jsondecode(entry.value).version == 1 && + jsondecode(entry.value).groupName == one([ + for group_name in keys(local.controller_groups) : group_name + if local.group_controller_manifests[group_name] == entry.value + ]) && + length(jsondecode(entry.value).reconcilers) > 0 && + alltrue([ + for reconciler in jsondecode(entry.value).reconcilers : ( + reconciler.schemaVersion == 1 && + reconciler.runnerConfigName != null && + reconciler.runnerGroupName != null && + reconciler.scaleSetName != null && + reconciler.githubConfigUrl != null && + reconciler.githubApp.appIdParameterName != null && + reconciler.githubApp.privateKeyParameterName != null && + reconciler.computeProvider.type != null && + reconciler.computeProvider.roleArn != null && + reconciler.computeProvider.configuration != null && + reconciler.minRunners != null && + reconciler.maxRunners != null && + reconciler.bootTimeoutMinutes != null && + reconciler.sessionOwner != null && + reconciler.workFolder != null && + reconciler.forceGhes != null && + reconciler.sslVerify != null + ) + ]) + ) + ]) + ]) + ) + error_message = "Each ECS task must receive a versioned ScaleSetControllerManifest with complete reconciler configuration." + } + + assert { + condition = one([ + for entry in jsondecode(aws_ecs_task_definition.controller["ec2"].container_definitions)[0].environment : + entry.value if entry.name == "EC2_CONTROLLER_MODE" + ]) == "grouped" + error_message = "Provider-owned non-secret environment variables must be merged into their controller group task." + } + + assert { + condition = ( + length(aws_security_group.controller["ec2"].ingress) == 0 && + length(aws_security_group.controller["ec2"].egress) == 1 && + one(aws_security_group.controller["ec2"].egress).from_port == 443 && + one(aws_security_group.controller["ec2"].egress).to_port == 443 && + aws_cloudwatch_log_group.controller["ec2"].kms_key_id == var.logging.kms_key_arn + ) + error_message = "Controller networking must have no ingress and only HTTPS egress, and logs must honor customer-managed encryption." + } + + assert { + condition = ( + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).schemaVersion == 1 && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).runnerConfigName == "linux-small" && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).githubConfigUrl == "https://github.com/example" && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).scaleSetName == "linux-small" && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).bootTimeoutMinutes == 10 && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).sslVerify == false && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).forceGhes == false && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).userAgent == "scale-set-test" && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).githubApp.privateKeyParameterName == "/github/linux-small/private-key" && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-large"].value)).githubConfigUrl == "https://github.example.test/example" && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-large"].value)).forceGhes == true && + !contains(keys(jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value))), "runnerConfig") + ) + error_message = "Each SSM leaf must use the frozen flat reconciler schema and contain references instead of GitHub credential values." + } + + assert { + condition = ( + contains(flatten([for statement in data.aws_iam_policy_document.task["ec2"].statement : statement.resources]), "arn:aws:ssm:eu-west-1:123456789012:parameter/github/linux-small/private-key") && + !contains(flatten([for statement in data.aws_iam_policy_document.task["ec2"].statement : statement.resources]), "arn:aws:ssm:eu-west-1:123456789012:parameter/github/microvm/private-key") && + contains(jsondecode(local.group_github_kms_policy_json["ec2"]).Statement[0].Resource, "arn:aws:kms:eu-west-1:123456789012:key/11111111-1111-1111-1111-111111111111") && + length(jsondecode(local.group_github_kms_policy_json["microvm"]).Statement) == 0 && + contains(flatten([for statement in data.aws_iam_policy_document.task["ec2"].statement : statement.resources]), "arn:aws:ssm:eu-west-1:123456789012:parameter/scale-set-test/scale-set-controller/ec2/*") + && contains(flatten([for statement in data.aws_iam_policy_document.task["ec2"].statement : statement.actions]), "sts:AssumeRole") && + !contains(flatten([for statement in data.aws_iam_policy_document.task["ec2"].statement : statement.resources]), "arn:aws:ssm:eu-west-1:123456789012:parameter/scale-set-test/runners/config/ami_id") && + contains(flatten([for statement in data.aws_iam_policy_document.compute["ec2/linux-small"].statement : statement.actions]), "ssm:GetParameters") && + contains(flatten([for statement in data.aws_iam_policy_document.compute["ec2/linux-small"].statement : statement.resources]), "arn:aws:ssm:eu-west-1:123456789012:parameter/scale-set-test/runners/config/ami_id") + ) + error_message = "Controller IAM must contain only controller permissions, while provider permissions such as AMI SSM reads must be attached to the compute role." + } +} + +run "supports_one_group_per_runner_config" { + command = plan + + variables { + grouping = { + strategy = "runner_config" + } + container = { + image = "ghcr.io/example/scale-set-controller@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + + assert { + condition = ( + toset(keys(output.controller_groups)) == toset(["linux-small", "linux-large", "microvm"]) && + length(aws_ecs_service.controller) == 3 && + output.resolved_container_image == "ghcr.io/example/scale-set-controller@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + error_message = "runner_config grouping must create one independently deployable controller task per runner config and honor an image override." + } +} + +run "grants_execution_role_ecr_pull_permissions" { + command = plan + + variables { + container = { + image = "999999999999.dkr.ecr.eu-west-1.amazonaws.com/scale-set-controller@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + + assert { + condition = ( + contains(flatten([ + for statement in data.aws_iam_policy_document.execution["ec2"].statement : statement.resources + ]), "*") && + contains(flatten([ + for statement in data.aws_iam_policy_document.execution["ec2"].statement : statement.actions + ]), "ecr:GetAuthorizationToken") && + contains(flatten([ + for statement in data.aws_iam_policy_document.execution["ec2"].statement : statement.actions + ]), "ecr:BatchCheckLayerAvailability") && + contains(flatten([ + for statement in data.aws_iam_policy_document.execution["ec2"].statement : statement.actions + ]), "ecr:BatchGetImage") && + contains(flatten([ + for statement in data.aws_iam_policy_document.execution["ec2"].statement : statement.actions + ]), "ecr:GetDownloadUrlForLayer") + ) + error_message = "The ECS execution role must have wildcard ECR pull permissions, including the authorization-token permission." + } +} + +run "supports_exact_custom_groups" { + command = plan + + variables { + grouping = { + strategy = "custom" + custom = { + groups = { + general = { + runner_configs = ["linux-small", "microvm"] + } + isolated = { + runner_configs = ["linux-large"] + } + } + } + } + } + + assert { + condition = ( + toset(keys(output.controller_groups)) == toset(["general", "isolated"]) && + toset(output.controller_groups.general.runner_configs) == toset(["linux-small", "microvm"]) && + toset(output.controller_groups.isolated.runner_configs) == toset(["linux-large"]) + ) + error_message = "Custom grouping must preserve the exact declared assignment." + } +} + +run "rejects_duplicate_custom_membership" { + command = plan + + plan_options { + target = [terraform_data.validate_grouping] + } + + variables { + grouping = { + strategy = "custom" + custom = { + groups = { + first = { + runner_configs = ["linux-small", "linux-large"] + } + second = { + runner_configs = ["linux-small", "microvm"] + } + } + } + } + } + + expect_failures = [terraform_data.validate_grouping] +} + +run "rejects_incomplete_custom_membership" { + command = plan + + plan_options { + target = [terraform_data.validate_grouping] + } + + variables { + grouping = { + strategy = "custom" + custom = { + groups = { + partial = { + runner_configs = ["linux-small", "linux-large"] + } + } + } + } + } + + expect_failures = [terraform_data.validate_grouping] +} + +run "rejects_readiness_path_as_ecs_liveness" { + command = plan + + plan_options { + target = [terraform_data.validate_runtime] + } + + variables { + container = { + health_path = "/readyz" + } + } + + expect_failures = [terraform_data.validate_runtime] +} + +run "rejects_oversized_standard_parameter" { + command = plan + + plan_options { + target = [terraform_data.validate_config_store] + } + + variables { + runner_configs = merge(var.runner_configs, { + linux-small = merge(var.runner_configs.linux-small, { + compute_provider = merge(var.runner_configs.linux-small.compute_provider, { + capabilities = { + scale_set = merge(var.runner_configs.linux-small.compute_provider.capabilities.scale_set, { + configuration_json = jsonencode({ payload = join("", [for index in range(1000) : "xxxxxx"]) }) + }) + } + }) + }) + }) + } + + expect_failures = [terraform_data.validate_config_store] +} + +run "accepts_advanced_parameter_within_eight_kib" { + command = plan + + plan_options { + target = [terraform_data.validate_config_store] + } + + variables { + config_store = { + tier = "Advanced" + } + runner_configs = merge(var.runner_configs, { + linux-small = merge(var.runner_configs.linux-small, { + compute_provider = merge(var.runner_configs.linux-small.compute_provider, { + capabilities = { + scale_set = merge(var.runner_configs.linux-small.compute_provider.capabilities.scale_set, { + configuration_json = jsonencode({ payload = join("", [for index in range(800) : "xxxxxx"]) }) + }) + } + }) + }) + }) + } + + assert { + condition = local.reconciler_config_bytes["ec2/linux-small"] > 4096 && local.reconciler_config_bytes["ec2/linux-small"] <= 8192 + error_message = "Advanced Parameter Store tier must accept reconciler JSON between four and eight KiB." + } +} + +run "assembles_github_config_url_from_registration_scope_and_owner" { + command = apply + + variables { + runner_configs = merge(var.runner_configs, { + linux-small = merge(var.runner_configs.linux-small, { + github = merge(var.runner_configs.linux-small.github, { + runner_registration_level = "organization" + runner_owner = "example" + }) + }) + linux-large = merge(var.runner_configs.linux-large, { + github = merge(var.runner_configs.linux-large.github, { + runner_registration_level = "organization" + runner_owner = "example" + }) + }) + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + runner_registration_level = "repository" + runner_owner = "example/repository" + }) + }) + }) + } + + assert { + condition = ( + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-small"].value)).githubConfigUrl == "https://github.com/example" && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["ec2/linux-large"].value)).githubConfigUrl == "https://github.example.test/example" && + jsondecode(nonsensitive(aws_ssm_parameter.reconciler_config["microvm/microvm"].value)).githubConfigUrl == "https://github.com/example/repository" + ) + error_message = "The reconciler config URL must combine the GitHub server with the configured organization or repository owner." + } +} + +run "rejects_duplicate_scale_set_ownership_across_groups" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + linux-small = merge(var.runner_configs.linux-small, { + github = merge(var.runner_configs.linux-small.github, { + enterprise_server = { url = "https://mygithub.com" } + }) + }) + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + enterprise_server = { url = "https://mygithub.com:443/" } + runner_registration_level = "organization" + runner_owner = "example" + }) + scale_set = merge(var.runner_configs.microvm.scale_set, { + name = "linux-small" + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_leading_zero_default_port_spelling" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + enterprise_server = { url = "https://github.com:0443/" } + }) + scale_set = merge(var.runner_configs.microvm.scale_set, { + name = "linux-small" + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_port_above_url_maximum" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + enterprise_server = { url = "https://github.com:65536/" } + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_non_ascii_scale_set_name" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + scale_set = merge(var.runner_configs.microvm.scale_set, { + name = "microvm-☃" + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_invalid_compute_provider_type_identifier" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + compute_provider = merge(var.runner_configs.microvm.compute_provider, { + type = "AWS.MicroVM" + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_credential_arn_name_mismatch" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + app = merge(var.runner_configs.microvm.github.app, { + app_id = merge(var.runner_configs.microvm.github.app.app_id, { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/another/app-id" + }) + }) + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_cross_account_credential_parameter" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + app = merge(var.runner_configs.microvm.github.app, { + app_id = merge(var.runner_configs.microvm.github.app.app_id, { + arn = "arn:aws:ssm:eu-west-1:210987654321:parameter/github/microvm/app-id" + }) + }) + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "allows_same_scale_set_name_in_another_github_scope" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + enterprise_server = { url = "https://github.example.test" } + }) + scale_set = merge(var.runner_configs.microvm.scale_set, { + name = "linux-small" + }) + }) + }) + } + + assert { + condition = length([ + for runner_name, runner_config in var.runner_configs : format( + "%s#%s", + replace(trimsuffix(lower(local.github_config_urls[runner_name]), "/"), ":443", ""), + runner_config.scale_set.name, + ) + ]) == length(distinct([ + for runner_name, runner_config in var.runner_configs : format( + "%s#%s", + replace(trimsuffix(lower(local.github_config_urls[runner_name]), "/"), ":443", ""), + runner_config.scale_set.name, + ) + ])) + error_message = "Scale-set names are scoped to their normalized enterprise-server URL." + } +} + +run "bounds_default_session_owner_for_maximum_names" { + command = plan + + variables { + grouping = { + strategy = "runner_config" + } + runner_configs = { + (join("", [for index in range(128) : "a"])) = { + github = { + enterprise_server = {} + runner_owner = "example" + runner_registration_level = "organization" + user_agent = "scale-set-test" + app = { + app_id = { + name = "/github/max/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/max/app-id" + } + private_key = { + name = "/github/max/private-key" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/max/private-key" + } + installation_id = { + name = "/github/max/installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/max/installation-id" + } + } + } + scale_set = { + name = "maximum-name" + } + compute_provider = { + type = "ec2" + capabilities = { + scale_set = { + configuration_json = "{}" + } + } + } + } + } + } + + assert { + condition = ( + length(one(values(local.reconciler_configs)).value.sessionOwner) == 256 && + can(regex("^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$", one(values(local.reconciler_configs)).value.sessionOwner)) + ) + error_message = "A generated session owner must remain deterministic and within the runtime's 256-character limit." + } +} + +run "rejects_controller_group_policy_above_inline_quota" { + command = plan + + plan_options { + target = [terraform_data.validate_group_task_policy["ec2"]] + } + + override_data { + target = data.aws_iam_policy_document.task["ec2"] + values = { + json = <<-JSON + {"payload":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} + JSON + } + } + + expect_failures = [terraform_data.validate_group_task_policy["ec2"]] +} + +run "rejects_conflicting_group_environment_variables" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + linux-large = merge(var.runner_configs.linux-large, { + compute_provider = merge(var.runner_configs.linux-large.compute_provider, { + capabilities = { + scale_set = merge(var.runner_configs.linux-large.compute_provider.capabilities.scale_set, { + environment_variables = { + EC2_CONTROLLER_MODE = "isolated" + } + }) + } + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_controller_group_environment_above_task_definition_budget" { + command = plan + + plan_options { + target = [terraform_data.validate_grouping] + } + + variables { + runner_configs = merge(var.runner_configs, { + linux-small = merge(var.runner_configs.linux-small, { + compute_provider = merge(var.runner_configs.linux-small.compute_provider, { + capabilities = { + scale_set = merge(var.runner_configs.linux-small.compute_provider.capabilities.scale_set, { + environment_variables = merge( + var.runner_configs.linux-small.compute_provider.capabilities.scale_set.environment_variables, + { + for index in range(16) : format("EC2_QUOTA_%02d", index) => join("", [for part in range(1024) : "xxxx"]) + }, + ) + }) + } + }) + }) + }) + } + + expect_failures = [terraform_data.validate_grouping] +} + +run "rejects_reserved_provider_environment_variables" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + linux-small = merge(var.runner_configs.linux-small, { + compute_provider = merge(var.runner_configs.linux-small.compute_provider, { + capabilities = { + scale_set = merge(var.runner_configs.linux-small.compute_provider.capabilities.scale_set, { + environment_variables = { + SCALE_SET_OVERRIDE = "unsafe" + } + }) + } + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_invalid_boot_timeout" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + linux-small = merge(var.runner_configs.linux-small, { + scale_set = merge(var.runner_configs.linux-small.scale_set, { + runner = merge(var.runner_configs.linux-small.scale_set.runner, { + boot_time_in_minutes = 0 + }) + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_enterprise_runner_registration_level" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + linux-small = merge(var.runner_configs.linux-small, { + github = merge(var.runner_configs.linux-small.github, { + runner_registration_level = "enterprise" + runner_owner = null + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_invalid_runner_registration_level" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + linux-small = merge(var.runner_configs.linux-small, { + github = merge(var.runner_configs.linux-small.github, { + runner_registration_level = "invalid" + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} + +run "rejects_controller_group_above_runtime_reconciler_limit" { + command = plan + + plan_options { + target = [terraform_data.validate_grouping] + } + + variables { + runner_configs = { + for index in range(1001) : format("runner-%04d", index) => var.runner_configs.linux-small + } + grouping = { + strategy = "custom" + custom = { + groups = { + oversized = { + runner_configs = toset([for index in range(1001) : format("runner-%04d", index)]) + } + } + } + } + } + + expect_failures = [terraform_data.validate_grouping] +} + +run "rejects_controller_group_above_runtime_config_bytes" { + command = plan + + plan_options { + target = [terraform_data.validate_grouping] + } + + variables { + config_store = { + tier = "Advanced" + } + runner_configs = { + for index in range(900) : format("runner-%04d", index) => merge(var.runner_configs.linux-small, { + compute_provider = merge(var.runner_configs.linux-small.compute_provider, { + capabilities = { + scale_set = merge(var.runner_configs.linux-small.compute_provider.capabilities.scale_set, { + configuration_json = jsonencode({ + payload = join("", [for part in range(1000) : "xxxxx"]) + }) + }) + } + }) + }) + } + grouping = { + strategy = "custom" + custom = { + groups = { + oversized = { + runner_configs = toset([for index in range(900) : format("runner-%04d", index)]) + } + } + } + } + } + + expect_failures = [terraform_data.validate_grouping] +} + +run "rejects_runtime_invalid_credential_parameter_name" { + command = plan + + plan_options { + target = [terraform_data.validate_contract] + } + + variables { + runner_configs = merge(var.runner_configs, { + microvm = merge(var.runner_configs.microvm, { + github = merge(var.runner_configs.microvm.github, { + app = merge(var.runner_configs.microvm.github.app, { + app_id = { + name = "/github/microvm/bad app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github/microvm/bad app-id" + } + }) + }) + }) + }) + } + + expect_failures = [terraform_data.validate_contract] +} diff --git a/modules/orchestration-providers/scale-set/validations.tf b/modules/orchestration-providers/scale-set/validations.tf new file mode 100644 index 0000000000..9553581597 --- /dev/null +++ b/modules/orchestration-providers/scale-set/validations.tf @@ -0,0 +1,425 @@ +locals { + group_compute_environment_base64 = { + for group_name, environment_variables in local.group_compute_environment_variables : group_name => base64encode(jsonencode([ + for name in sort(keys(environment_variables)) : { + name = name + value = environment_variables[name] + } + ])) + } + group_compute_environment_bytes = { + for group_name, encoded in local.group_compute_environment_base64 : group_name => ( + floor(length(encoded) * 3 / 4) - + (endswith(encoded, "==") ? 2 : endswith(encoded, "=") ? 1 : 0) + ) + } +} + +resource "terraform_data" "validate_contract" { + lifecycle { + precondition { + condition = ( + length(var.prefix) >= 1 && + length(var.prefix) <= 20 && + can(regex("^[a-z0-9][a-z0-9-]*$", var.prefix)) + ) + error_message = "prefix must contain 1 to 20 lowercase ASCII letters, digits, or hyphens and start with a letter or digit." + } + + precondition { + condition = alltrue([ + for runner_name in keys(var.runner_configs) : ( + length(runner_name) >= 1 && + length(runner_name) <= 128 && + can(regex("^[A-Za-z0-9][A-Za-z0-9._-]*$", runner_name)) + ) + ]) + error_message = "runner-config keys must contain 1 to 128 ASCII letters, digits, dots, underscores, or hyphens and start with a letter or digit." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.runner_configs) : contains([ + "organization", + "repository", + ], runner_config.github.runner_registration_level) + ]) + error_message = "runner_registration_level must be organization or repository." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.runner_configs) : ( + runner_config.github.runner_owner != null && can(regex( + runner_config.github.runner_registration_level == "organization" + ? "^[A-Za-z0-9_.-]+$" + : "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + runner_config.github.runner_owner, + )) + ) + ]) + error_message = "runner_owner must be an organization or owner/repository path for organization and repository registration levels." + } + + precondition { + condition = alltrue([ + for runner_name, runner_config in var.runner_configs : ( + can(regex("^https://[A-Za-z0-9.-]+(:[1-9][0-9]{0,4})?(/[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?)?/?$", local.github_config_urls[runner_name])) && + try(tonumber(regex("^https://[A-Za-z0-9.-]+:([0-9]+)", local.github_config_urls[runner_name])[0]), 443) <= 65535 + ) + ]) + error_message = "Each assembled GitHub config URL must be an HTTPS GitHub Enterprise Server URL without credentials, query, fragment, or whitespace." + } + + precondition { + condition = length([ + for runner_name, runner_config in var.runner_configs : format( + "%s#%s", + replace(trimsuffix(lower(local.github_config_urls[runner_name]), "/"), ":443", ""), + runner_config.scale_set.name, + ) + ]) == length(distinct([ + for runner_name, runner_config in var.runner_configs : format( + "%s#%s", + replace(trimsuffix(lower(local.github_config_urls[runner_name]), "/"), ":443", ""), + runner_config.scale_set.name, + ) + ])) + error_message = "Each normalized githubConfigUrl and scale_set.name tuple must be unique across runner_configs so two controller services cannot own the same message session. URL matching ignores case, one trailing slash, and the default HTTPS port." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.runner_configs) : ( + alltrue([ + for parameter in [ + runner_config.github.app.app_id, + runner_config.github.app.private_key, + runner_config.github.app.installation_id, + ] : ( + length(parameter.name) <= 2048 && + can(regex("^/[A-Za-z0-9_./-]+$", parameter.name)) && + !endswith(parameter.name, "/") && + !strcontains(parameter.name, "//") && + parameter.arn == format( + "arn:%s:ssm:%s:%s:parameter%s", + data.aws_partition.current.partition, + data.aws_region.current.region, + data.aws_caller_identity.current.account_id, + parameter.name, + ) && + (parameter.kms_key_arn == null ? true : can(regex("^arn:[^:]+:kms:[^:]+:[0-9]{12}:key/.+$", parameter.kms_key_arn))) + ) + ]) + ) + ]) + error_message = "GitHub App credentials must use valid absolute SSM parameter names and exact same-account, same-region parameter ARNs; optional KMS references must be key ARNs." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.runner_configs) : ( + can(regex("^[ -~]{1,128}$", runner_config.scale_set.name)) && + runner_config.scale_set.runner.min_runners >= 0 && + floor(runner_config.scale_set.runner.min_runners) == runner_config.scale_set.runner.min_runners && + runner_config.scale_set.runner.max_runners >= 1 && + runner_config.scale_set.runner.max_runners <= 10000 && + floor(runner_config.scale_set.runner.max_runners) == runner_config.scale_set.runner.max_runners && + runner_config.scale_set.runner.min_runners <= runner_config.scale_set.runner.max_runners && + runner_config.scale_set.runner.boot_time_in_minutes >= 1 && + runner_config.scale_set.runner.boot_time_in_minutes <= 120 && + floor(runner_config.scale_set.runner.boot_time_in_minutes) == runner_config.scale_set.runner.boot_time_in_minutes && + length(runner_config.github.user_agent) <= 256 && + can(regex("^[ -~]+$", runner_config.github.user_agent)) + ) + ]) + error_message = "Scale-set names must be valid, boot_time_in_minutes must be an integer from 1 through 120, and min_runners must be between zero and max_runners (maximum 10000)." + } + + precondition { + condition = alltrue([ + for runner_config in values(var.runner_configs) : ( + can(regex("^[a-z][a-z0-9_-]{0,63}$", runner_config.compute_provider.type)) && + can(keys(jsondecode(runner_config.compute_provider.capabilities.scale_set.configuration_json))) && + length(runner_config.compute_provider.capabilities.scale_set.environment_variables) <= 64 && + alltrue([ + for name, value in runner_config.compute_provider.capabilities.scale_set.environment_variables : ( + can(regex("^[A-Z][A-Z0-9_]{0,127}$", name)) && + !contains(["PATH", "HOME", "HOSTNAME", "PWD", "SHLVL"], name) && + alltrue([ + for prefix in ["AWS_", "ECS_", "GITHUB_", "SCALE_SET_", "NODE_"] : + !startswith(name, prefix) + ]) && + length(regexall("[\\x00-\\x1F\\x7F]", value)) == 0 && + ( + floor(length(base64encode(value)) * 3 / 4) - + (endswith(base64encode(value), "==") ? 2 : endswith(base64encode(value), "=") ? 1 : 0) + ) <= 4096 + ) + ]) && + alltrue([ + for statement_name, statement in runner_config.compute_provider.capabilities.scale_set.iam_statements : ( + can(regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", statement_name)) && + length(statement.actions) > 0 && + length(statement.resources) > 0 && + alltrue([for action in statement.actions : !strcontains(action, "*")]) && + alltrue([ + for condition in statement.conditions : ( + length(condition.test) > 0 && + length(condition.variable) > 0 && + length(condition.values) > 0 + ) + ]) + ) + ]) + ) + ]) + error_message = "Each compute-provider scale-set capability must have safe identifiers, object-shaped configuration JSON, non-secret environment variables with safe unreserved names and bounded values, and non-empty least-privilege IAM statements without wildcard actions." + } + + precondition { + condition = alltrue([ + for group_name, entries in local.group_compute_environment_entries : alltrue([ + for name in distinct([for entry in entries : entry.name]) : + length(distinct([for entry in entries : entry.value if entry.name == name])) <= 1 + ]) + ]) + error_message = "Compute-provider environment variables grouped into the same controller task must use identical values for duplicate names. Use a different grouping strategy when providers require conflicting process settings." + } + } +} + +resource "terraform_data" "validate_grouping" { + lifecycle { + precondition { + condition = contains(["compute_provider", "runner_config", "custom"], var.grouping.strategy) + error_message = "grouping.strategy must be compute_provider, runner_config, or custom." + } + + precondition { + condition = ( + var.grouping.strategy == "custom" + ? var.grouping.custom != null && length(var.grouping.custom.groups) > 0 + : var.grouping.custom == null + ) + error_message = "grouping.custom must be non-null and non-empty only when grouping.strategy is custom." + } + + precondition { + condition = var.grouping.strategy != "custom" ? true : alltrue([ + for group_name, group in var.grouping.custom.groups : ( + can(regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", group_name)) && + length(group.runner_configs) > 0 + ) + ]) + error_message = "Custom group names must be stable, safe identifiers of at most 64 characters, and every group must contain at least one runner config." + } + + precondition { + condition = var.grouping.strategy != "custom" ? true : ( + length(flatten(values(local.declared_custom_groups))) == length(distinct(flatten(values(local.declared_custom_groups)))) && + length(setsubtract(toset(flatten(values(local.declared_custom_groups))), toset(keys(var.runner_configs)))) == 0 && + length(setsubtract(toset(keys(var.runner_configs)), toset(flatten(values(local.declared_custom_groups))))) == 0 + ) + error_message = "Custom groups must contain every runner config exactly once and cannot contain unknown runner configs." + } + + precondition { + condition = alltrue([ + for group_name, runner_names in local.controller_groups : ( + can(regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", group_name)) && + length(runner_names) > 0 && + length(runner_names) <= 1000 + ) + ]) + error_message = "Resolved controller groups must have safe, non-empty, plan-known names and contain at most 1000 runner configs." + } + + precondition { + condition = alltrue([ + for group_name, runner_names in local.controller_groups : sum([ + for runner_name in runner_names : local.reconciler_config_bytes["${group_name}/${runner_name}"] + ]) <= 4 * 1024 * 1024 + ]) + error_message = "A controller group's decoded reconciler configuration must not exceed the runtime's 4 MiB aggregate limit. Split the group or reduce provider configuration size." + } + + precondition { + condition = alltrue([ + for group_name, environment_bytes in local.group_compute_environment_bytes : environment_bytes <= 48 * 1024 + ]) + error_message = "A controller group's compute-provider environment JSON must not exceed 49152 bytes. This reserves 16 KiB of AWS's 64 KiB ECS task-definition quota for the fixed task definition; split the group or reduce provider environment settings." + } + + precondition { + condition = alltrue([ + for group_name, manifest_bytes in local.group_controller_manifests : ( + length(manifest_bytes) + local.group_compute_environment_bytes[group_name] <= 48 * 1024 + ) + ]) + error_message = "A controller group's manifest and compute-provider environment JSON must fit the ECS task-definition budget; split the group or reduce provider configuration." + } + } +} + +resource "terraform_data" "validate_runtime" { + lifecycle { + precondition { + condition = ( + var.container.image == null ? true : ( + length(trimspace(var.container.image)) > 0 && + length(regexall("[[:space:]]", var.container.image)) == 0 + )) + error_message = "Container image references must be non-empty and cannot contain whitespace." + } + + precondition { + condition = ( + can(regex("^[1-9][0-9]{0,9}(:[1-9][0-9]{0,9})?$", var.container.user)) && + var.container.health_port >= 1 && var.container.health_port <= 65535 && + (var.container.health_check_command == null ? true : ( + length(var.container.health_check_command) >= 2 && + contains(["CMD", "CMD-SHELL"], var.container.health_check_command[0]) + )) + ) + error_message = "The container must use a numeric non-root UID (and optional GID), a valid health port, and a valid ECS health-check command." + } + + precondition { + condition = var.container.health_path == "/healthz" + error_message = "container.health_path must be /healthz, the scale-set service liveness endpoint." + } + + precondition { + condition = ( + var.container.health_check_interval >= 5 && var.container.health_check_interval <= 300 && + var.container.health_check_timeout >= 2 && var.container.health_check_timeout <= 60 && + var.container.health_check_timeout < var.container.health_check_interval && + var.container.health_check_retries >= 1 && var.container.health_check_retries <= 10 && + var.container.health_check_start_period >= 0 && var.container.health_check_start_period <= 300 && + var.container.health_stale_after_seconds >= 30 && var.container.health_stale_after_seconds <= 3600 && + var.container.shutdown_timeout_seconds >= 1 && var.container.shutdown_timeout_seconds <= 119 && + var.container.session_close_timeout_seconds >= 1 && var.container.session_close_timeout_seconds <= 60 && + var.container.reconnect_initial_backoff_seconds >= 1 && var.container.reconnect_initial_backoff_seconds <= 300 && + var.container.reconnect_max_backoff_seconds >= 1 && var.container.reconnect_max_backoff_seconds <= 3600 && + var.container.reconnect_initial_backoff_seconds <= var.container.reconnect_max_backoff_seconds && + var.container.stop_timeout_seconds >= 2 && var.container.stop_timeout_seconds <= 120 && + var.container.shutdown_timeout_seconds < var.container.stop_timeout_seconds + ) + error_message = "Container health and shutdown timings must be within ECS limits, with health timeout below interval and application shutdown below task stop timeout." + } + + precondition { + condition = ( + contains(keys(local.fargate_memory_by_cpu), tostring(var.ecs.task.cpu)) && + contains(lookup(local.fargate_memory_by_cpu, tostring(var.ecs.task.cpu), []), var.ecs.task.memory) + ) + error_message = "ecs.task.cpu and ecs.task.memory must be a supported Fargate CPU/memory combination." + } + + precondition { + condition = ( + contains(["X86_64", "ARM64"], var.ecs.task.cpu_architecture) && + (var.ecs.task.ephemeral_storage == null ? true : ( + var.ecs.task.ephemeral_storage.size_in_gib >= 21 && var.ecs.task.ephemeral_storage.size_in_gib <= 200 + )) + ) + error_message = "ecs.task.cpu_architecture must be X86_64 or ARM64, and optional ephemeral storage must be between 21 and 200 GiB." + } + + precondition { + condition = ( + contains(["managed", "external"], var.ecs.cluster.mode) && + (var.ecs.cluster.mode == "external" ? ( + var.ecs.cluster.arn != null && can(regex("^arn:[^:]+:ecs:[^:]+:[0-9]{12}:cluster/.+$", var.ecs.cluster.arn)) + ) : ( + var.ecs.cluster.arn == null && + (var.ecs.cluster.name == null ? true : can(regex("^[A-Za-z0-9_-]{1,255}$", var.ecs.cluster.name))) + )) + ) + error_message = "Use a valid external ECS cluster ARN only with cluster.mode external; managed cluster names may contain letters, digits, underscores, and hyphens." + } + + precondition { + condition = ( + startswith(var.ecs.iam.path, "/") && + endswith(var.ecs.iam.path, "/") && + length(var.ecs.iam.path) <= 512 + ) + error_message = "ecs.iam.path must start and end with a slash and be at most 512 characters." + } + + precondition { + condition = ( + length(var.network.vpc_id) > 0 && + length(var.network.subnet_ids) > 0 && + length(var.network.https_egress.ipv4_cidrs) + length(var.network.https_egress.ipv6_cidrs) > 0 && + alltrue([for cidr in var.network.https_egress.ipv4_cidrs : can(cidrnetmask(cidr))]) && + alltrue([for cidr in var.network.https_egress.ipv6_cidrs : can(cidrhost(cidr, 0)) && strcontains(cidr, ":")]) + ) + error_message = "network must select a VPC and at least one subnet, and HTTPS egress must contain valid IPv4 or IPv6 CIDRs." + } + + precondition { + condition = ( + contains(["STANDARD", "INFREQUENT_ACCESS"], var.logging.log_group_class) && + contains([1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.logging.retention_in_days) && + (var.logging.kms_key_arn == null ? true : can(regex("^arn:[^:]+:kms:[^:]+:[0-9]{12}:key/.+$", var.logging.kms_key_arn))) + ) + error_message = "logging must use a supported class and retention period; kms_key_arn must be a KMS key ARN when set." + } + } +} + +resource "terraform_data" "validate_config_store" { + lifecycle { + precondition { + condition = ( + contains(["Standard", "Advanced"], var.config_store.tier) && + startswith(local.config_store_path_prefix, "/") && + !endswith(local.config_store_path_prefix, "/") && + length(local.config_store_path_prefix) >= 2 && + can(regex("^/[A-Za-z0-9_.\\/-]+$", local.config_store_path_prefix)) + ) + error_message = "config_store must use Standard or Advanced tier and a valid absolute SSM path prefix without a trailing slash." + } + + precondition { + condition = alltrue([ + for config_key, config in local.reconciler_configs : ( + length("${local.config_store_path_prefix}/${config.group_name}/${config.runner_name}") <= 1011 && + local.reconciler_config_bytes[config_key] <= local.config_store_max_bytes + ) + ]) + error_message = "Each reconciler SSM parameter name and encoded JSON value must fit the selected Parameter Store tier. Split large controller groups or reduce provider configuration when necessary." + } + } +} + +resource "terraform_data" "validate_group_task_policy" { + for_each = local.controller_groups + + lifecycle { + precondition { + condition = ( + floor(length(base64encode(data.aws_iam_policy_document.task[each.key].json)) * 3 / 4) - + (endswith(base64encode(data.aws_iam_policy_document.task[each.key].json), "==") ? 2 : endswith(base64encode(data.aws_iam_policy_document.task[each.key].json), "=") ? 1 : 0) + ) <= 10240 + error_message = "Controller group ${each.key} produces a task-role policy exceeding AWS's 10240-byte inline role-policy quota. Split the group or reduce controller permissions." + } + } +} + +resource "terraform_data" "validate_compute_role_policy" { + for_each = local.compute_role_configs + + lifecycle { + precondition { + condition = ( + floor(length(base64encode(data.aws_iam_policy_document.compute[each.key].json)) * 3 / 4) - + (endswith(base64encode(data.aws_iam_policy_document.compute[each.key].json), "==") ? 2 : endswith(base64encode(data.aws_iam_policy_document.compute[each.key].json), "=") ? 1 : 0) + ) <= 10240 + error_message = "Compute role ${each.key} produces an inline policy exceeding AWS's 10240-byte role-policy quota. Split the group or reduce provider IAM statements." + } + } +} diff --git a/modules/orchestration-providers/scale-set/variables.tf b/modules/orchestration-providers/scale-set/variables.tf new file mode 100644 index 0000000000..236dfc8213 --- /dev/null +++ b/modules/orchestration-providers/scale-set/variables.tf @@ -0,0 +1,200 @@ +variable "prefix" { + description = "Stable prefix used for scale-set controller resources." + type = string + default = "github-actions" + nullable = false +} + +variable "log_level" { + description = "Logging level for the scale-set controller container." + type = string + default = "info" + nullable = false +} + +variable "runner_configs" { + description = <<-EOT + Normalized scale-set runner configurations keyed by stable runner-config name. + + Map keys must be known during planning. Credential values are never accepted: `github.app` contains only the exact GitHub App Parameter Store references used by the runtime. `github.enterprise_server` and `github.user_agent` carry the global GitHub settings needed to render each reconciler configuration. `scale_set.runner.group_name` selects the GitHub runner group. `runner_registration_level` selects organization or repository registration, and `runner_owner` supplies the corresponding organization or owner/repository path. Enterprise-level registration is not supported by this module. `compute_provider` carries the provider-neutral scale-set capability contract for this runner configuration. Parameter and optional KMS ARNs, scale-set names, and other inner values may remain unknown until apply. + EOT + type = map(object({ + github = object({ + enterprise_server = object({ + url = optional(string, null) + ssl_verify = optional(bool, true) + }) + app = object({ + app_id = object({ + name = string + arn = string + kms_key_arn = optional(string, null) + }) + private_key = object({ + name = string + arn = string + kms_key_arn = optional(string, null) + }) + installation_id = object({ + name = string + arn = string + kms_key_arn = optional(string, null) + }) + }) + runner_owner = string + runner_registration_level = string + user_agent = string + }) + scale_set = object({ + name = string + runner = optional(object({ + labels = optional(list(string), []) + group_name = optional(string, "Default") + min_runners = optional(number, 0) + max_runners = optional(number, 10) + boot_time_in_minutes = optional(number, 10) + }), {}) + }) + compute_provider = object({ + type = string + capabilities = object({ + scale_set = object({ + role_arn = optional(string, null) + configuration_json = optional(string, "{}") + environment_variables = optional(map(string), {}) + iam_statements = optional(map(object({ + actions = set(string) + resources = set(string) + conditions = optional(list(object({ + test = string + variable = string + values = set(string) + })), []) + })), {}) + }) + }) + }) + })) + nullable = false +} + +variable "grouping" { + description = <<-EOT + Packing strategy for scale-set reconcilers. `compute_provider` creates one controller group per compute-provider type and is the default. `runner_config` creates one group per runner config. `custom` uses `custom.groups`; custom membership must cover every runner config exactly once. + + The strategy, custom group keys, and memberships select Terraform `for_each` instances and must be known during planning. + EOT + type = object({ + strategy = optional(string, "compute_provider") + custom = optional(object({ + groups = map(object({ + runner_configs = set(string) + })) + }), null) + }) + default = {} + nullable = false +} + +variable "container" { + description = "Scale-set controller image and runtime settings. A null image uses the internal official convenience image; production callers should use the release digest. Filesystem and Linux capability hardening are enforced by the module; health_path is fixed at /healthz, the ECS liveness endpoint." + type = object({ + image = optional(string, null) + user = optional(string, "10001:10001") + health_port = optional(number, 8080) + health_path = optional(string, "/healthz") + health_check_command = optional(list(string), null) + health_check_interval = optional(number, 30) + health_check_timeout = optional(number, 5) + health_check_retries = optional(number, 3) + health_check_start_period = optional(number, 30) + health_stale_after_seconds = optional(number, 180) + shutdown_timeout_seconds = optional(number, 110) + session_close_timeout_seconds = optional(number, 10) + reconnect_initial_backoff_seconds = optional(number, 1) + reconnect_max_backoff_seconds = optional(number, 30) + stop_timeout_seconds = optional(number, 120) + }) + default = {} + nullable = false +} + +variable "config_store" { + description = <<-EOT + Non-secret controller configuration storage. The module writes one SSM String parameter per reconciler below `path_prefix//`. The task receives only its group path and a SHA-256 revision, then loads the group with `GetParametersByPath`. + + Standard parameters are limited to 4096 encoded bytes and Advanced parameters to 8192 encoded bytes. Null `path_prefix` resolves to `//scale-set-controller`. + EOT + type = object({ + path_prefix = optional(string, null) + tier = optional(string, "Standard") + tags = optional(map(string), {}) + }) + default = {} + nullable = false +} + +variable "ecs" { + description = <<-EOT + ECS substrate configuration. A managed cluster is created by default. For an external cluster, set `cluster.mode = "external"` and pass its ARN; the mode must be plan-known while the ARN may be computed. + EOT + type = object({ + cluster = optional(object({ + mode = optional(string, "managed") + arn = optional(string, null) + name = optional(string, null) + container_insights = optional(bool, true) + }), {}) + task = optional(object({ + cpu = optional(number, 512) + memory = optional(number, 1024) + cpu_architecture = optional(string, "X86_64") + ephemeral_storage = optional(object({ + size_in_gib = number + }), null) + }), {}) + service = optional(object({ + platform_version = optional(string, "LATEST") + }), {}) + iam = optional(object({ + path = optional(string, "/") + permissions_boundary = optional(string, null) + }), {}) + }) + default = {} + nullable = false +} + +variable "network" { + description = <<-EOT + Private Fargate networking. Tasks never receive public IP addresses and the managed security groups have no ingress. HTTPS egress defaults to IPv4 Internet access because GitHub endpoints cannot be represented as security-group destinations; route it through controlled NAT, firewall, or proxy infrastructure when required. + EOT + type = object({ + vpc_id = string + subnet_ids = set(string) + https_egress = optional(object({ + ipv4_cidrs = optional(set(string), ["0.0.0.0/0"]) + ipv6_cidrs = optional(set(string), []) + }), {}) + }) + nullable = false +} + +variable "logging" { + description = "CloudWatch Logs configuration. CloudWatch encrypts logs at rest with an AWS-owned key by default; set `kms_key_arn` to use a customer-managed key." + type = object({ + retention_in_days = optional(number, 30) + kms_key_arn = optional(string, null) + log_group_class = optional(string, "STANDARD") + tags = optional(map(string), {}) + }) + default = {} + nullable = false +} + +variable "tags" { + description = "Tags applied to scale-set orchestration resources." + type = map(string) + default = {} + nullable = false +} diff --git a/modules/orchestration-providers/scale-set/versions.tf b/modules/orchestration-providers/scale-set/versions.tf new file mode 100644 index 0000000000..0bedc91fd5 --- /dev/null +++ b/modules/orchestration-providers/scale-set/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md index 798f22ec8e..53d0115ebb 100644 --- a/modules/orchestration-providers/webhook/README.md +++ b/modules/orchestration-providers/webhook/README.md @@ -11,7 +11,7 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers @@ -39,8 +39,8 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | -| [config](#input\_config) | Provider-owned webhook values supplied from `orchestration_provider.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks.

- `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `runner.ephemeral`.
- `runner.maximum_count`: Maximum number of runners managed for this runner configuration.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `queue.build.arn`: ARN of the runner configuration's build queue.
- `queue.build.url`: URL of the runner configuration's build queue.
- `queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. This is independent from the Parameter Store KMS key.
- `queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides.
- `lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. At most one of `zip` or `s3` may be selected; no selection uses the packaged runner archive.
- `lambda.artifact.zip`: Optional local path to the runner-control Lambda archive.
- `lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning and selecting it requires a non-null common bucket.
- `lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive.
- `lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. Use `-1` for unreserved concurrency.
- `lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. Null follows the resolved runner mode.
- `lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation.
- `lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags.
- `lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `lambda.scale.down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. A value of `0` preserves the single-reading behavior.
- `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags.
- `lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired number of runners for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners.
- `lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags.
- `job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, 0)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | -| [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. |
object({
app_parameters = object({
key_base64 = map(string)
id = map(string)
additional_apps_manifest = optional(object({
name = string
arn = string
}), null)
additional_app_parameter_arns = optional(list(string), [])
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
})
| n/a | yes | +| [config](#input\_config) | Provider-owned webhook values supplied from `orchestration_provider.webhook`. The parent resolves inherited input values before calling this module; this provider still resolves the documented JIT, artifact, and tag-precedence fallbacks.

- `runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. Null follows `runner.ephemeral`.
- `runner.maximum_count`: Maximum number of runners managed for this runner configuration.
- `github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `queue.build.arn`: ARN of the runner configuration's build queue.
- `queue.build.url`: URL of the runner configuration's build queue.
- `queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. This is independent from the Parameter Store KMS key.
- `queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides.
- `lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. At most one of `zip` or `s3` may be selected; no selection uses the packaged runner archive.
- `lambda.artifact.zip`: Optional local path to the runner-control Lambda archive.
- `lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning and selecting it requires a non-null common bucket.
- `lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive.
- `lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB.
- `lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds.
- `lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. Use `-1` for unreserved concurrency.
- `lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. Null follows the resolved runner mode.
- `lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation.
- `lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records.
- `lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags.
- `lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB.
- `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds.
- `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down.
- `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default.
- `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations.
- `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed.
- `lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags.
- `lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.pool.timeout`: Pool Lambda timeout in seconds.
- `lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use `-1` for unreserved concurrency.
- `lambda.pool.config`: Scheduled target pool sizes. An empty list disables the pool component.
- `lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `lambda.pool.config[].size`: Desired number of runners for the schedule.
- `lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity.
- `lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners.
- `lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags.
- `job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources.
- `job_retry.delay_in_seconds`: Initial delay before a queued-job retry check.
- `job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check.
- `job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished.
- `job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags.
- `job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB.
- `job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. Use `-1` for unreserved concurrency.
- `job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. |
object({
runner = object({
boot_time_in_minutes = number
ephemeral = bool
jit_config_enabled = optional(bool, null)
maximum_count = number
})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = object({
artifact = object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
})
scale = object({
up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = optional(bool, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
tags = optional(map(string), {})
})
down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = optional(map(string), {})
})
})
pool = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
config = list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
}))
include_busy_runners = bool
runner_owner = optional(string, null)
tags = optional(map(string), {})
})
})
job_retry = object({
enabled = bool
delay_in_seconds = number
delay_backoff = number
max_attempts = number
tags = optional(map(string), {})
lambda = object({
memory_size = number
reserved_concurrent_executions = number
timeout = number
})
})
})
| n/a | yes | +| [github](#input\_github) | Common GitHub API client and GitHub App Parameter Store references. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
})
| n/a | yes | | [lambda](#input\_lambda) | Common Lambda substrate. Only the shared artifact bucket crosses this boundary; the webhook provider owns its archive key, version, and local zip selection. |
object({
artifact = object({
s3 = object({
bucket = optional(string, null)
})
})
runtime = string
architecture = string
subnet_ids = list(string)
security_group_ids = list(string)
tags = optional(map(string), {})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
| n/a | yes | | [observability](#input\_observability) | Common logging, tracing, and metrics configuration consumed by webhook controls. |
object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
tags = optional(map(string), {})
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
job_retry = object({
enabled = bool
})
})
})
})
| n/a | yes | | [prefix](#input\_prefix) | Prefix used to identify resources created for this webhook orchestration provider. | `string` | n/a | yes | diff --git a/modules/orchestration-providers/webhook/job-retry/README.md b/modules/orchestration-providers/webhook/job-retry/README.md index 999535a4e2..9c6e4e0f52 100644 --- a/modules/orchestration-providers/webhook/job-retry/README.md +++ b/modules/orchestration-providers/webhook/job-retry/README.md @@ -12,7 +12,7 @@ The module is an inner module used by the webhook orchestration provider when th | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers @@ -52,7 +52,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-config.

- `prefix`: Prefix used to name job-retry resources.
- `aws_partition`: AWS partition used to construct the Lambda VPC managed-policy ARN.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the job-retry Lambda.
- `lambda.architecture`: Instruction-set architecture used by the job-retry Lambda.
- `lambda.memory_size`: Memory allocated to the job-retry Lambda.
- `lambda.timeout`: Lambda timeout and retry-queue visibility timeout in seconds.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the Lambda. Use `-1` for unreserved concurrency.
- `lambda.environment_variables`: Additional Lambda environment variables. Required job-retry variables override matching keys.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the job-retry Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the Lambda role.
- `lambda.role.principals`: Extra principals allowed to assume the Lambda role, for example during local testing.
- `runner.name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `github.organization_runners`: Enables organization runners.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `github.app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `github.app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `github.app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per job-retry invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `queue.encryption`: Server-side encryption configuration for the retry queue.
- `ssm.kms_key_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and job-retry metric configuration.
- `tags.resources`: Tags for the job-retry Lambda role and component resources.
- `tags.lambda`: Tags for the job-retry Lambda function.
- `tags.log_group`: Tags for the job-retry log group.
- `tags.queue`: Tags for the retry queue.
- `tags.event_source_mapping`: Tags for the retry-queue event-source mapping. |
object({
prefix = string
aws_partition = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
reserved_concurrent_executions = number
environment_variables = map(string)
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = list(object({
type = string
identifiers = list(string)
}))
})
})
runner = object({
name_prefix = string
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = map(string)
id = map(string)
additional_apps_manifest = optional(object({
name = string
arn = string
}), null)
additional_app_parameter_arns = optional(list(string), [])
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
encryption = object({
sqs_managed_sse_enabled = bool
kms_master_key_id = optional(string, null)
kms_data_key_reuse_period_seconds = optional(number, null)
})
})
ssm = object({
kms_key_id = optional(string, null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
job_retry = object({
enabled = bool
})
})
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
queue = map(string)
event_source_mapping = map(string)
})
})
| n/a | yes | +| [config](#input\_config) | Provider-neutral job-retry configuration assembled by runner-config.

- `prefix`: Prefix used to name job-retry resources.
- `aws_partition`: AWS partition used to construct the Lambda VPC managed-policy ARN.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by the job-retry Lambda.
- `lambda.architecture`: Instruction-set architecture used by the job-retry Lambda.
- `lambda.memory_size`: Memory allocated to the job-retry Lambda.
- `lambda.timeout`: Lambda timeout and retry-queue visibility timeout in seconds.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the Lambda. Use `-1` for unreserved concurrency.
- `lambda.environment_variables`: Additional Lambda environment variables. Required job-retry variables override matching keys.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the job-retry Lambda role.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the Lambda role.
- `lambda.role.principals`: Extra principals allowed to assume the Lambda role, for example during local testing.
- `runner.name_prefix`: Prefix used to identify runners belonging to this runner configuration.
- `github.organization_runners`: Enables organization runners.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build`: URL and ARN of the build queue to which retry messages are published.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per job-retry invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `queue.encryption`: Server-side encryption configuration for the retry queue.
- `ssm.kms_key_id`: Optional KMS key ARN used by the job-retry IAM policy. Its value may be unknown until apply.
- `observability.logs`: Logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and job-retry metric configuration.
- `tags.resources`: Tags for the job-retry Lambda role and component resources.
- `tags.lambda`: Tags for the job-retry Lambda function.
- `tags.log_group`: Tags for the job-retry log group.
- `tags.queue`: Tags for the retry queue.
- `tags.event_source_mapping`: Tags for the retry-queue event-source mapping. |
object({
prefix = string
aws_partition = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
memory_size = number
timeout = number
reserved_concurrent_executions = number
environment_variables = map(string)
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = list(object({
type = string
identifiers = list(string)
}))
})
})
runner = object({
name_prefix = string
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = optional(bool, true)
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
url = string
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
encryption = object({
sqs_managed_sse_enabled = bool
kms_master_key_id = optional(string, null)
kms_data_key_reuse_period_seconds = optional(number, null)
})
})
ssm = object({
kms_key_id = optional(string, null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
job_retry = object({
enabled = bool
})
})
})
})
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
queue = map(string)
event_source_mapping = map(string)
})
})
| n/a | yes | ## Outputs diff --git a/modules/orchestration-providers/webhook/job-retry/iam-policies.tf b/modules/orchestration-providers/webhook/job-retry/iam-policies.tf index dc9cecf44f..0e79e8a265 100644 --- a/modules/orchestration-providers/webhook/job-retry/iam-policies.tf +++ b/modules/orchestration-providers/webhook/job-retry/iam-policies.tf @@ -62,12 +62,9 @@ data "aws_iam_policy_document" "job_retry" { ] resources = concat( - [ - var.config.github.app_parameters.id.arn, - var.config.github.app_parameters.key_base64.arn, - ], - var.config.github.app_parameters.additional_app_parameter_arns, - var.config.github.app_parameters.additional_apps_manifest != null ? [var.config.github.app_parameters.additional_apps_manifest.arn] : [], + [for p in var.config.github.app_parameters.id : p.arn], + [for p in var.config.github.app_parameters.key_base64 : p.arn], + [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], ) } diff --git a/modules/orchestration-providers/webhook/job-retry/job-retry.tf b/modules/orchestration-providers/webhook/job-retry/job-retry.tf index 248f5c35ad..a536cfe196 100644 --- a/modules/orchestration-providers/webhook/job-retry/job-retry.tf +++ b/modules/orchestration-providers/webhook/job-retry/job-retry.tf @@ -19,17 +19,17 @@ locals { } job_retry_environment_variables = { - ENABLE_ORGANIZATION_RUNNERS = var.config.github.organization_runners - ENABLE_METRIC_JOB_RETRY = var.config.observability.metrics.enabled && var.config.observability.metrics.metric.job_retry.enabled - ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.observability.metrics.enabled && var.config.observability.metrics.metric.github_app_rate_limit.enabled - GHES_URL = var.config.github.enterprise_server.url - NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 - USER_AGENT = var.config.github.user_agent - JOB_QUEUE_SCALE_UP_URL = var.config.queue.build.url - PARAMETER_GITHUB_APP_ID_NAME = var.config.github.app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github.app_parameters.key_base64.name - PARAMETER_GITHUB_APPS_MANIFEST_NAME = var.config.github.app_parameters.additional_apps_manifest != null ? var.config.github.app_parameters.additional_apps_manifest.name : "" - RUNNER_NAME_PREFIX = var.config.runner.name_prefix + ENABLE_ORGANIZATION_RUNNERS = var.config.github.organization_runners + ENABLE_METRIC_JOB_RETRY = var.config.observability.metrics.enabled && var.config.observability.metrics.metric.job_retry.enabled + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.observability.metrics.enabled && var.config.observability.metrics.metric.github_app_rate_limit.enabled + GHES_URL = var.config.github.enterprise_server.url + NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 + USER_AGENT = var.config.github.user_agent + JOB_QUEUE_SCALE_UP_URL = var.config.queue.build.url + PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github.app_parameters.id : p.name]) + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github.app_parameters.key_base64 : p.name]) + PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github.app_parameters.installation_id : p != null ? p.name : ""]) + RUNNER_NAME_PREFIX = var.config.runner.name_prefix } environment_variables = merge( diff --git a/modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl b/modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl index 7d07bca573..25d1dfaafc 100644 --- a/modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl +++ b/modules/orchestration-providers/webhook/job-retry/tests/job-retry.tftest.hcl @@ -57,22 +57,32 @@ variables { } user_agent = "experimental-job-retry-user-agent" app_parameters = { - key_base64 = { - name = "/github-runner/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { - name = "/github-runner/app-id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } - additional_apps_manifest = { - name = "/github-runner/additional-apps-manifest" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-apps-manifest" - } - additional_app_parameter_arns = [ - "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2", - "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2", - "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2", + key_base64 = [ + { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + }, + { + name = "/github-runner/key-base64-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2" + }, + ] + id = [ + { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + }, + { + name = "/github-runner/app-id-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2" + }, + ] + installation_id = [ + null, + { + name = "/github-runner/installation-id-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2" + }, ] } } @@ -145,15 +155,14 @@ run "preserves_nested_job_retry_configuration" { output.lambda.function.environment[0].variables["GHES_URL"] == "https://experimental-job-retry.example.com" && output.lambda.function.environment[0].variables["NODE_TLS_REJECT_UNAUTHORIZED"] == "0" && output.lambda.function.environment[0].variables["USER_AGENT"] == "experimental-job-retry-user-agent" - && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id" - && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-runner/key-base64" - && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APPS_MANIFEST_NAME"] == "/github-runner/additional-apps-manifest" + && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id:/github-runner/app-id-2" + && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-runner/key-base64:/github-runner/key-base64-2" + && output.lambda.function.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == ":/github-runner/installation-id-2" && contains(data.aws_iam_policy_document.job_retry.statement[0].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2") && contains(data.aws_iam_policy_document.job_retry.statement[0].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2") && contains(data.aws_iam_policy_document.job_retry.statement[0].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2") - && contains(data.aws_iam_policy_document.job_retry.statement[0].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-apps-manifest") ) - error_message = "Job retry must receive the new GitHub App parameter format and grant access to every corresponding SSM ARN." + error_message = "Job retry must receive the nested GitHub connection settings, pass every app parameter, and grant access to every corresponding SSM ARN." } assert { @@ -258,14 +267,15 @@ run "does_not_enable_partial_vpc_configuration" { organization_runners = false enterprise_server = {} app_parameters = { - key_base64 = { + key_base64 = [{ name = "/github-runner/key-base64" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { + }] + id = [{ name = "/github-runner/app-id" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } + }] + installation_id = [null] } } queue = { diff --git a/modules/orchestration-providers/webhook/job-retry/variables.tf b/modules/orchestration-providers/webhook/job-retry/variables.tf index 9b2c2610ab..e8235265f8 100644 --- a/modules/orchestration-providers/webhook/job-retry/variables.tf +++ b/modules/orchestration-providers/webhook/job-retry/variables.tf @@ -24,10 +24,9 @@ variable "config" { - `github.enterprise_server.url`: Optional GitHub Enterprise Server URL. - `github.enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests. - `github.user_agent`: Optional User-Agent sent to GitHub. - - `github.app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key. - - `github.app_parameters.id`: Parameter Store reference for the primary GitHub App ID. - - `github.app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest. - - `github.app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters. + - `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. + - `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. + - `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - `queue.build`: URL and ARN of the build queue to which retry messages are published. - `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key. - `queue.event_source_mapping.batch_size`: Maximum records delivered per job-retry invocation. @@ -86,13 +85,9 @@ variable "config" { }) user_agent = optional(string, null) app_parameters = object({ - key_base64 = map(string) - id = map(string) - additional_apps_manifest = optional(object({ - name = string - arn = string - }), null) - additional_app_parameter_arns = optional(list(string), []) + key_base64 = list(map(string)) + id = list(map(string)) + installation_id = list(object({ name = string, arn = string })) }) }) queue = object({ diff --git a/modules/orchestration-providers/webhook/job-retry/versions.tf b/modules/orchestration-providers/webhook/job-retry/versions.tf index 1238b79cc3..fcec7c620d 100644 --- a/modules/orchestration-providers/webhook/job-retry/versions.tf +++ b/modules/orchestration-providers/webhook/job-retry/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md index 03a9c0d15d..877eec8039 100644 --- a/modules/orchestration-providers/webhook/pool/README.md +++ b/modules/orchestration-providers/webhook/pool/README.md @@ -10,7 +10,7 @@ The pool is an opt-in feature. To be able to use the count on a module level to | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers @@ -54,7 +54,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no | -| [config](#input\_config) | Configuration passed from the webhook orchestration provider to the pool Lambda and scheduler.

- `lambda`: Pool Lambda runtime and deployment configuration.
- `lambda.log_level`: Logging level used by the pool Lambda.
- `lambda.logging_retention_in_days`: Number of days to retain events in the pool Lambda log group.
- `lambda.logging_kms_key_id`: KMS key ID used to encrypt the pool Lambda log group.
- `lambda.log_class`: CloudWatch Logs class for the pool Lambda log group.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use -1 for no reservation.
- `lambda.s3_bucket`: S3 bucket containing the pool Lambda deployment package.
- `lambda.s3_key`: S3 key of the pool Lambda deployment package.
- `lambda.s3_object_version`: S3 object version of the pool Lambda deployment package.
- `lambda.security_group_ids`: Security group IDs associated with the pool Lambda.
- `lambda.runtime`: AWS Lambda runtime used by the pool Lambda.
- `lambda.architecture`: AWS Lambda architecture used by the pool Lambda.
- `lambda.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.timeout`: Pool Lambda timeout in seconds.
- `lambda.zip`: Local path to the pool Lambda deployment package when S3 is not used.
- `lambda.subnet_ids`: Subnet IDs in which the pool Lambda runs.
- `lambda.parameter_store_tags`: JSON-encoded tags supplied to the pool Lambda for SSM parameters it creates.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `tags`: Common tags added to pool resources.
- `ghes`: GitHub Enterprise Server connection configuration.
- `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub.
- `ghes.ssl_verify`: Whether the pool Lambda verifies the GitHub Enterprise Server TLS certificate.
- `github_app_parameters`: SSM parameter metadata for the primary and additional GitHub App credentials.
- `github_app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `github_app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `github_app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `github_app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `runner`: Runner registration configuration used by the pool Lambda.
- `runner.disable_runner_autoupdate`: Whether GitHub runner automatic updates are disabled.
- `runner.ephemeral`: Whether runners register as ephemeral runners.
- `runner.enable_jit_config`: Whether runners use just-in-time registration configuration.
- `runner.labels`: Labels assigned to runners created by the pool Lambda.
- `runner.group_name`: GitHub runner group assigned to runners created by the pool Lambda.
- `runner.name_prefix`: Prefix used for runner names.
- `runner.pool_owner`: GitHub organization or repository that owns the runner pool.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation.
- `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `prefix`: Prefix used to name pool resources.
- `pool`: Scheduled pool targets.
- `pool[*].schedule_expression`: EventBridge Scheduler expression for a pool target.
- `pool[*].schedule_expression_timezone`: Time zone used to evaluate the schedule expression.
- `pool[*].size`: Desired runner count for the scheduled pool target.
- `include_busy_runners`: Whether busy runners count toward the desired pool size.
- `role_permissions_boundary`: Permissions boundary applied to IAM roles created for the pool.
- `kms_key_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `ssm_config_path`: SSM path under which runner configuration is stored.
- `arn_ssm_parameters_path_config`: ARN matching the runner configuration SSM path.
- `lambda_tags`: Tags added specifically to the pool Lambda function, overriding common tags with the same key.
- `log_group_tags`: Tags added specifically to the pool Lambda log group, overriding common tags with the same key.
- `user_agent`: User-Agent header used for GitHub API requests. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
additional_apps_manifest = optional(object({
name = string
arn = string
}), null)
additional_app_parameter_arns = optional(list(string), [])
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
boot_time_in_minutes = number
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
log_group_tags = optional(map(string), {})
user_agent = string
})
| n/a | yes | +| [config](#input\_config) | Configuration passed from the webhook orchestration provider to the pool Lambda and scheduler.

- `lambda`: Pool Lambda runtime and deployment configuration.
- `lambda.log_level`: Logging level used by the pool Lambda.
- `lambda.logging_retention_in_days`: Number of days to retain events in the pool Lambda log group.
- `lambda.logging_kms_key_id`: KMS key ID used to encrypt the pool Lambda log group.
- `lambda.log_class`: CloudWatch Logs class for the pool Lambda log group.
- `lambda.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. Use -1 for no reservation.
- `lambda.s3_bucket`: S3 bucket containing the pool Lambda deployment package.
- `lambda.s3_key`: S3 key of the pool Lambda deployment package.
- `lambda.s3_object_version`: S3 object version of the pool Lambda deployment package.
- `lambda.security_group_ids`: Security group IDs associated with the pool Lambda.
- `lambda.runtime`: AWS Lambda runtime used by the pool Lambda.
- `lambda.architecture`: AWS Lambda architecture used by the pool Lambda.
- `lambda.memory_size`: Memory allocated to the pool Lambda in MB.
- `lambda.timeout`: Pool Lambda timeout in seconds.
- `lambda.zip`: Local path to the pool Lambda deployment package when S3 is not used.
- `lambda.subnet_ids`: Subnet IDs in which the pool Lambda runs.
- `lambda.parameter_store_tags`: JSON-encoded tags supplied to the pool Lambda for SSM parameters it creates.
- `lambda.principals`: Additional principals allowed to assume the pool Lambda role.
- `tags`: Common tags added to pool resources.
- `ghes`: GitHub Enterprise Server connection configuration.
- `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub.
- `ghes.ssl_verify`: Whether the pool Lambda verifies the GitHub Enterprise Server TLS certificate.
- `github_app_parameters`: Ordered SSM parameter metadata for GitHub App credentials.
- `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `runner`: Runner registration configuration used by the pool Lambda.
- `runner.disable_runner_autoupdate`: Whether GitHub runner automatic updates are disabled.
- `runner.ephemeral`: Whether runners register as ephemeral runners.
- `runner.enable_jit_config`: Whether runners use just-in-time registration configuration.
- `runner.labels`: Labels assigned to runners created by the pool Lambda.
- `runner.group_name`: GitHub runner group assigned to runners created by the pool Lambda.
- `runner.name_prefix`: Prefix used for runner names.
- `runner.pool_owner`: GitHub organization or repository that owns the runner pool.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by pool reconciliation.
- `runners_maximum_count`: Webhook-provider runner capacity limit enforced by the pool Lambda.
- `prefix`: Prefix used to name pool resources.
- `pool`: Scheduled pool targets.
- `pool[*].schedule_expression`: EventBridge Scheduler expression for a pool target.
- `pool[*].schedule_expression_timezone`: Time zone used to evaluate the schedule expression.
- `pool[*].size`: Desired runner count for the scheduled pool target.
- `include_busy_runners`: Whether busy runners count toward the desired pool size.
- `role_permissions_boundary`: Permissions boundary applied to IAM roles created for the pool.
- `kms_key_id`: Optional customer-managed KMS key ARN that the pool Lambda may use to decrypt encrypted parameters.
- `role_path`: IAM path applied to roles created for the pool.
- `ssm_token_path`: SSM path under which runner registration tokens are stored.
- `ssm_token_path_arn`: ARN matching the runner registration-token SSM path.
- `ssm_config_path`: SSM path under which runner configuration is stored.
- `arn_ssm_parameters_path_config`: ARN matching the runner configuration SSM path.
- `lambda_tags`: Tags added specifically to the pool Lambda function, overriding common tags with the same key.
- `log_group_tags`: Tags added specifically to the pool Lambda log group, overriding common tags with the same key.
- `user_agent`: User-Agent header used for GitHub API requests. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
labels = list(string)
group_name = string
name_prefix = string
pool_owner = string
boot_time_in_minutes = number
})
runners_maximum_count = number
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
include_busy_runners = bool
role_permissions_boundary = string
kms_key_id = optional(string, null)
role_path = string
ssm_token_path = string
ssm_token_path_arn = string
ssm_config_path = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
log_group_tags = optional(map(string), {})
user_agent = string
})
| n/a | yes | | [runner\_provider](#input\_runner\_provider) | Compute provider integration used by the pool Lambda.

- `type`: Compute provider type passed to scheduled pool invocations.
- `environment_variables`: Provider-specific environment variables added to the pool Lambda.
- `iam_policy_json`: Provider-specific IAM policy document merged into the pool Lambda policy.
- `managed_policy_enabled`: Whether to attach a provider-specific managed IAM policy to the pool Lambda role.
- `managed_policy_arn`: ARN of the provider-specific managed IAM policy to attach when enabled. |
object({
type = string
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
| n/a | yes | | [tracing\_config](#input\_tracing\_config) | Tracing configuration for the pool Lambda.

- `mode`: AWS X-Ray tracing mode. A null value disables tracing.
- `capture_http_requests`: Whether Powertools tracing captures outgoing HTTP requests.
- `capture_error`: Whether Powertools tracing captures errors as tracing metadata. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | diff --git a/modules/orchestration-providers/webhook/pool/iam-policies.tf b/modules/orchestration-providers/webhook/pool/iam-policies.tf index bd5c15a25d..f5a9285bce 100644 --- a/modules/orchestration-providers/webhook/pool/iam-policies.tf +++ b/modules/orchestration-providers/webhook/pool/iam-policies.tf @@ -43,12 +43,9 @@ data "aws_iam_policy_document" "pool_common" { ] resources = concat( - [ - var.config.github_app_parameters.id.arn, - var.config.github_app_parameters.key_base64.arn, - ], - var.config.github_app_parameters.additional_app_parameter_arns, - var.config.github_app_parameters.additional_apps_manifest != null ? [var.config.github_app_parameters.additional_apps_manifest.arn] : [], + [for p in var.config.github_app_parameters.id : p.arn], + [for p in var.config.github_app_parameters.key_base64 : p.arn], + [for p in var.config.github_app_parameters.installation_id : p.arn if p != null], ) } diff --git a/modules/orchestration-providers/webhook/pool/pool.tf b/modules/orchestration-providers/webhook/pool/pool.tf index 00e12dd433..cff2776e90 100644 --- a/modules/orchestration-providers/webhook/pool/pool.tf +++ b/modules/orchestration-providers/webhook/pool/pool.tf @@ -7,32 +7,32 @@ locals { ) common_environment_variables = { - DISABLE_RUNNER_AUTOUPDATE = var.config.runner.disable_runner_autoupdate - ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral - ENABLE_JIT_CONFIG = var.config.runner.enable_jit_config - ENVIRONMENT = var.config.prefix - GHES_URL = var.config.ghes.url - USER_AGENT = var.config.user_agent - LOG_LEVEL = upper(var.config.lambda.log_level) - NODE_TLS_REJECT_UNAUTHORIZED = var.config.ghes.url != null && !var.config.ghes.ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name - PARAMETER_GITHUB_APPS_MANIFEST_NAME = var.config.github_app_parameters.additional_apps_manifest != null ? var.config.github_app_parameters.additional_apps_manifest.name : "" - POWERTOOLS_LOGGER_LOG_EVENT = var.config.lambda.log_level == "debug" ? "true" : "false" - RUNNER_LABELS = lower(join(",", var.config.runner.labels)) - RUNNER_GROUP_NAME = var.config.runner.group_name - RUNNER_NAME_PREFIX = var.config.runner.name_prefix - RUNNER_OWNER = var.config.runner.pool_owner - RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes - RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count - SSM_TOKEN_PATH = var.config.ssm_token_path - SSM_CONFIG_PATH = var.config.ssm_config_path - POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-pool" - POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error - SSM_PARAMETER_STORE_TAGS = var.config.lambda.parameter_store_tags - INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners + DISABLE_RUNNER_AUTOUPDATE = var.config.runner.disable_runner_autoupdate + ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral + ENABLE_JIT_CONFIG = var.config.runner.enable_jit_config + ENVIRONMENT = var.config.prefix + GHES_URL = var.config.ghes.url + USER_AGENT = var.config.user_agent + LOG_LEVEL = upper(var.config.lambda.log_level) + NODE_TLS_REJECT_UNAUTHORIZED = var.config.ghes.url != null && !var.config.ghes.ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github_app_parameters.id : p.name]) + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github_app_parameters.key_base64 : p.name]) + PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github_app_parameters.installation_id : p != null ? p.name : ""]) + POWERTOOLS_LOGGER_LOG_EVENT = var.config.lambda.log_level == "debug" ? "true" : "false" + RUNNER_LABELS = lower(join(",", var.config.runner.labels)) + RUNNER_GROUP_NAME = var.config.runner.group_name + RUNNER_NAME_PREFIX = var.config.runner.name_prefix + RUNNER_OWNER = var.config.runner.pool_owner + RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes + RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count + SSM_TOKEN_PATH = var.config.ssm_token_path + SSM_CONFIG_PATH = var.config.ssm_config_path + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-pool" + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + SSM_PARAMETER_STORE_TAGS = var.config.lambda.parameter_store_tags + INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners } } diff --git a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl index 4b5035a58f..c04f435024 100644 --- a/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl +++ b/modules/orchestration-providers/webhook/pool/tests/provider.tftest.hcl @@ -38,22 +38,32 @@ variables { ssl_verify = true } github_app_parameters = { - key_base64 = { - name = "/github-runner/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { - name = "/github-runner/app-id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } - additional_apps_manifest = { - name = "/github-runner/additional-apps-manifest" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-apps-manifest" - } - additional_app_parameter_arns = [ - "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2", - "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2", - "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2", + key_base64 = [ + { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + }, + { + name = "/github-runner/key-base64-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2" + }, + ] + id = [ + { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + }, + { + name = "/github-runner/app-id-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2" + }, + ] + installation_id = [ + null, + { + name = "/github-runner/installation-id-2" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2" + }, ] } runner = { @@ -136,15 +146,14 @@ run "provider_supplies_only_compute_specific_pool_configuration" { assert { condition = ( - aws_lambda_function.pool.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id" - && aws_lambda_function.pool.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-runner/key-base64" - && aws_lambda_function.pool.environment[0].variables["PARAMETER_GITHUB_APPS_MANIFEST_NAME"] == "/github-runner/additional-apps-manifest" + aws_lambda_function.pool.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id:/github-runner/app-id-2" + && aws_lambda_function.pool.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-runner/key-base64:/github-runner/key-base64-2" + && aws_lambda_function.pool.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == ":/github-runner/installation-id-2" && contains(data.aws_iam_policy_document.pool_common.statement[2].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id-2") && contains(data.aws_iam_policy_document.pool_common.statement[2].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64-2") && contains(data.aws_iam_policy_document.pool_common.statement[2].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/installation-id-2") - && contains(data.aws_iam_policy_document.pool_common.statement[2].resources, "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-apps-manifest") ) - error_message = "Pool must receive the new GitHub App parameter format and grant access to every corresponding SSM ARN." + error_message = "Pool must pass every GitHub App parameter and grant access to every corresponding SSM ARN." } assert { diff --git a/modules/orchestration-providers/webhook/pool/variables.tf b/modules/orchestration-providers/webhook/pool/variables.tf index d4c07fee63..e1f516c8ad 100644 --- a/modules/orchestration-providers/webhook/pool/variables.tf +++ b/modules/orchestration-providers/webhook/pool/variables.tf @@ -24,11 +24,10 @@ variable "config" { - `ghes`: GitHub Enterprise Server connection configuration. - `ghes.url`: GitHub Enterprise Server URL; null when using public GitHub. - `ghes.ssl_verify`: Whether the pool Lambda verifies the GitHub Enterprise Server TLS certificate. - - `github_app_parameters`: SSM parameter metadata for the primary and additional GitHub App credentials. - - `github_app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key. - - `github_app_parameters.id`: Parameter Store reference for the primary GitHub App ID. - - `github_app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest. - - `github_app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters. + - `github_app_parameters`: Ordered SSM parameter metadata for GitHub App credentials. + - `github_app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. + - `github_app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. + - `github_app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - `runner`: Runner registration configuration used by the pool Lambda. - `runner.disable_runner_autoupdate`: Whether GitHub runner automatic updates are disabled. - `runner.ephemeral`: Whether runners register as ephemeral runners. @@ -85,13 +84,9 @@ variable "config" { ssl_verify = string }) github_app_parameters = object({ - key_base64 = map(string) - id = map(string) - additional_apps_manifest = optional(object({ - name = string - arn = string - }), null) - additional_app_parameter_arns = optional(list(string), []) + key_base64 = list(map(string)) + id = list(map(string)) + installation_id = list(object({ name = string, arn = string })) }) runner = object({ disable_runner_autoupdate = bool diff --git a/modules/orchestration-providers/webhook/pool/versions.tf b/modules/orchestration-providers/webhook/pool/versions.tf index 1238b79cc3..fcec7c620d 100644 --- a/modules/orchestration-providers/webhook/pool/versions.tf +++ b/modules/orchestration-providers/webhook/pool/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/orchestration-providers/webhook/scale-runners.tf b/modules/orchestration-providers/webhook/scale-runners.tf index 4863436b7c..caf79eefb5 100644 --- a/modules/orchestration-providers/webhook/scale-runners.tf +++ b/modules/orchestration-providers/webhook/scale-runners.tf @@ -38,7 +38,6 @@ module "scale_runners" { } }) scale_down = merge(local.resolved_config.scale_down, { - idle_confirmation_seconds = local.resolved_config.scale_down.idle_confirmation_seconds tags = { resources = local.scale_down_tags lambda = local.scale_down_lambda_tags diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md index eee03f94da..3b096f9b85 100644 --- a/modules/orchestration-providers/webhook/scale-runners/README.md +++ b/modules/orchestration-providers/webhook/scale-runners/README.md @@ -11,7 +11,7 @@ The module is an implementation detail of the experimental runner configuration. | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers @@ -67,7 +67,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM policy ARNs. | `string` | `"aws"` | no | -| [config](#input\_config) | Provider-neutral scale-up and scale-down configuration assembled by runner-config.

- `prefix`: Prefix used to name scaling resources.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by both scaling Lambdas.
- `lambda.architecture`: Instruction-set architecture used by both scaling Lambdas.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the scaling Lambda roles.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the scaling Lambda roles.
- `lambda.role.principals`: Additional principals allowed to assume the scaling Lambda roles.
- `runner.os`: Runner operating system used for the minimum-runtime default.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Enables or disables just-in-time runner configuration.
- `runner.labels`: Labels supplied when a runner is registered.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by scale-down.
- `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration.
- `github.organization_runners`: Registers organization runners when true.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS verification for GitHub Enterprise Server.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `github.app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `github.app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `github.app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per scale-up invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `ssm.token_path`: Parameter Store path used for registration tokens.
- `ssm.token_path_arn`: ARN of the Parameter Store path used for registration tokens.
- `ssm.config_path`: Parameter Store path used for persistent runner configuration.
- `ssm.config_path_arn`: ARN of the persistent runner configuration path.
- `ssm.kms_key_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `ssm.parameter_store_tags`: JSON-encoded tags applied to parameters created at runtime.
- `observability.logs`: Shared logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and GitHub rate-limit metric configuration.
- `scale_up`: Scale-up Lambda sizing, concurrency, queued-job behavior, and resolved resource tag maps.
- `scale_up.tags.resources`: Tags for the scale-up IAM role and other component resources.
- `scale_up.tags.lambda`: Tags for the scale-up Lambda function.
- `scale_up.tags.log_group`: Tags for the scale-up log group.
- `scale_up.tags.event_source_mapping`: Tags for the build-queue event-source mapping.
- `scale_down`: Scale-down Lambda sizing, schedule, idle configuration, minimum runtime, and resolved resource tag maps.
- `scale_down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale (it can read false for a runner that is actively executing a job), so a single not-busy reading is not sufficient evidence a runner is idle. Set to at least one scale-down schedule interval to require two consecutive not-busy evaluations; a busy reading resets the window. 0 keeps the previous single-reading behaviour.
- `scale_down.tags.resources`: Tags for the scale-down IAM role and EventBridge rule.
- `scale_down.tags.lambda`: Tags for the scale-down Lambda function.
- `scale_down.tags.log_group`: Tags for the scale-down log group.
- `job_retry.enabled`: Enables publishing retry checks from scale-up.
- `job_retry.queue`: Retry queue ARN and URL. Required when job retry is enabled.
- `job_retry.max_attempts`: Maximum queued-job retry attempts.
- `job_retry.delay_in_seconds`: Initial delay before checking the queued job.
- `job_retry.delay_backoff`: Multiplier applied to subsequent delays. |
object({
prefix = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
runner = object({
os = string
auto_update_disabled = bool
ephemeral = bool
jit_config_enabled = optional(bool, null)
labels = list(string)
group_name = string
name_prefix = string
boot_time_in_minutes = number
maximum_count = number
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = map(string)
id = map(string)
additional_apps_manifest = optional(object({
name = string
arn = string
}), null)
additional_app_parameter_arns = optional(list(string), [])
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = optional(string, null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
})
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = bool
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
event_source_mapping = map(string)
})
})
scale_down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, 0)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
job_retry = object({
enabled = bool
max_attempts = number
delay_in_seconds = number
delay_backoff = number
queue = optional(object({
arn = string
url = string
}), null)
})
})
| n/a | yes | +| [config](#input\_config) | Provider-neutral scale-up and scale-down configuration assembled by runner-config.

- `prefix`: Prefix used to name scaling resources.
- `lambda.artifact.zip`: Resolved local control-plane archive.
- `lambda.artifact.s3.bucket`: Optional S3 bucket containing the Lambda archive.
- `lambda.artifact.s3.key`: Object key of the Lambda archive.
- `lambda.artifact.s3.object_version`: Optional object version of the Lambda archive.
- `lambda.runtime`: Runtime used by both scaling Lambdas.
- `lambda.architecture`: Instruction-set architecture used by both scaling Lambdas.
- `lambda.vpc.subnet_ids`: Subnets used for Lambda VPC configuration.
- `lambda.vpc.security_group_ids`: Security groups used for Lambda VPC configuration.
- `lambda.role.path`: IAM path used for the scaling Lambda roles.
- `lambda.role.permissions_boundary`: Optional permissions boundary for the scaling Lambda roles.
- `lambda.role.principals`: Additional principals allowed to assume the scaling Lambda roles.
- `runner.os`: Runner operating system used for the minimum-runtime default.
- `runner.auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `runner.ephemeral`: Registers runners in ephemeral mode.
- `runner.jit_config_enabled`: Enables or disables just-in-time runner configuration.
- `runner.labels`: Labels supplied when a runner is registered.
- `runner.group_name`: GitHub runner group used during registration.
- `runner.name_prefix`: Prefix added to registered runner names.
- `runner.boot_time_in_minutes`: Webhook-provider runner boot timeout used by scale-down.
- `runner.maximum_count`: Webhook-provider runner capacity limit for this runner configuration.
- `github.organization_runners`: Registers organization runners when true.
- `github.enterprise_server.url`: Optional GitHub Enterprise Server URL.
- `github.enterprise_server.ssl_verify`: Enables TLS verification for GitHub Enterprise Server.
- `github.user_agent`: Optional User-Agent sent to GitHub.
- `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `queue.build.arn`: ARN of the build queue consumed by scale-up.
- `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key.
- `queue.event_source_mapping.batch_size`: Maximum records delivered per scale-up invocation.
- `queue.event_source_mapping.maximum_batching_window_in_seconds`: Maximum event batching window.
- `ssm.token_path`: Parameter Store path used for registration tokens.
- `ssm.token_path_arn`: ARN of the Parameter Store path used for registration tokens.
- `ssm.config_path`: Parameter Store path used for persistent runner configuration.
- `ssm.config_path_arn`: ARN of the persistent runner configuration path.
- `ssm.kms_key_id`: Optional KMS key ARN used to decrypt shared parameters. Its value may be unknown until apply.
- `ssm.parameter_store_tags`: JSON-encoded tags applied to parameters created at runtime.
- `observability.logs`: Shared logging level, retention, encryption, and log-class configuration.
- `observability.tracing`: Lambda X-Ray and tracing-helper configuration.
- `observability.metrics`: Metrics enablement, namespace, and GitHub rate-limit metric configuration.
- `scale_up`: Scale-up Lambda sizing, concurrency, queued-job behavior, and resolved resource tag maps.
- `scale_up.tags.resources`: Tags for the scale-up IAM role and other component resources.
- `scale_up.tags.lambda`: Tags for the scale-up Lambda function.
- `scale_up.tags.log_group`: Tags for the scale-up log group.
- `scale_up.tags.event_source_mapping`: Tags for the build-queue event-source mapping.
- `scale_down`: Scale-down Lambda sizing, schedule, idle configuration, minimum runtime, and resolved resource tag maps.
- `scale_down.tags.resources`: Tags for the scale-down IAM role and EventBridge rule.
- `scale_down.tags.lambda`: Tags for the scale-down Lambda function.
- `scale_down.tags.log_group`: Tags for the scale-down log group.
- `job_retry.enabled`: Enables publishing retry checks from scale-up.
- `job_retry.queue`: Retry queue ARN and URL. Required when job retry is enabled.
- `job_retry.max_attempts`: Maximum queued-job retry attempts.
- `job_retry.delay_in_seconds`: Initial delay before checking the queued job.
- `job_retry.delay_backoff`: Multiplier applied to subsequent delays. |
object({
prefix = string
lambda = object({
artifact = object({
zip = string
s3 = object({
bucket = optional(string, null)
key = optional(string, null)
object_version = optional(string, null)
})
})
runtime = string
architecture = string
vpc = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
role = object({
path = string
permissions_boundary = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
})
})
runner = object({
os = string
auto_update_disabled = bool
ephemeral = bool
jit_config_enabled = optional(bool, null)
labels = list(string)
group_name = string
name_prefix = string
boot_time_in_minutes = number
maximum_count = number
})
github = object({
organization_runners = bool
enterprise_server = object({
url = optional(string, null)
ssl_verify = bool
})
user_agent = optional(string, null)
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
})
queue = object({
build = object({
arn = string
})
kms_key_id = optional(string, null)
event_source_mapping = object({
batch_size = number
maximum_batching_window_in_seconds = number
})
})
ssm = object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
parameter_store_tags = string
kms_key_id = optional(string, null)
})
observability = object({
logs = object({
level = string
retention_in_days = number
kms_key_id = optional(string, null)
class = string
})
tracing = object({
mode = optional(string, null)
capture_http_requests = bool
capture_error = bool
})
metrics = object({
enabled = bool
namespace = string
metric = object({
github_app_rate_limit = object({
enabled = bool
})
})
})
})
scale_up = object({
memory_size = number
timeout = number
reserved_concurrent_executions = number
job_queued_check_enabled = bool
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
event_source_mapping = map(string)
})
})
scale_down = object({
memory_size = number
timeout = number
schedule_expression = string
minimum_running_time_in_minutes = optional(number, null)
idle_config = list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = string
}))
tags = object({
resources = map(string)
lambda = map(string)
log_group = map(string)
})
})
job_retry = object({
enabled = bool
max_attempts = number
delay_in_seconds = number
delay_backoff = number
queue = optional(object({
arn = string
url = string
}), null)
})
})
| n/a | yes | | [runner\_provider](#input\_runner\_provider) | Selected compute-provider integration for the scaling control plane.

- `type`: Compute-provider discriminator supplied to both Lambdas.
- `scale_up.environment_variables`: Provider-specific scale-up environment variables.
- `scale_up.iam_policy_json`: Provider-specific IAM policy merged into the common scale-up policy.
- `scale_up.additional_iam_policy_json`: Optional additional provider policy attached separately to the scale-up role.
- `scale_up.managed_policy`: Optional provider-managed policy attachment. Object presence controls attachment creation.
- `scale_up.managed_policy.arn`: ARN of the provider-managed policy. The ARN may remain unknown until apply.
- `scale_down.environment_variables`: Provider-specific scale-down environment variables.
- `scale_down.iam_policy_json`: Provider-specific IAM policy merged into the common scale-down policy. |
object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = string
additional_iam_policy_json = optional(string, null)
managed_policy = optional(object({
arn = string
}), null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = string
})
})
| n/a | yes | ## Outputs diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf b/modules/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf index 46a79df258..b95cb9e686 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-down-iam-policies.tf @@ -7,12 +7,9 @@ data "aws_iam_policy_document" "scale_down_common" { "ssm:GetParameters", ] resources = concat( - [ - var.config.github.app_parameters.id.arn, - var.config.github.app_parameters.key_base64.arn, - ], - var.config.github.app_parameters.additional_app_parameter_arns, - var.config.github.app_parameters.additional_apps_manifest != null ? [var.config.github.app_parameters.additional_apps_manifest.arn] : [], + [for p in var.config.github.app_parameters.id : p.arn], + [for p in var.config.github.app_parameters.key_base64 : p.arn], + [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], ) } diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf index b00c592929..44e651d78c 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf @@ -15,26 +15,25 @@ resource "aws_lambda_function" "scale_down" { environment { variables = merge(var.runner_provider.scale_down.environment_variables, { - ENVIRONMENT = var.config.prefix - ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.observability.metrics.enabled && var.config.observability.metrics.metric.github_app_rate_limit.enabled - GHES_URL = var.config.github.enterprise_server.url - USER_AGENT = var.config.github.user_agent - LOG_LEVEL = upper(var.config.observability.logs.level) - MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.config.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.config.runner.os]) - SCALE_DOWN_IDLE_CONFIRMATION_SECONDS = var.config.scale_down.idle_confirmation_seconds - NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = var.config.github.app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github.app_parameters.key_base64.name - PARAMETER_GITHUB_APPS_MANIFEST_NAME = var.config.github.app_parameters.additional_apps_manifest != null ? var.config.github.app_parameters.additional_apps_manifest.name : "" - POWERTOOLS_LOGGER_LOG_EVENT = var.config.observability.logs.level == "debug" ? "true" : "false" - SCALE_DOWN_CONFIG = jsonencode(var.config.scale_down.idle_config) - POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-scale-down" - POWERTOOLS_METRICS_NAMESPACE = var.config.observability.metrics.namespace - POWERTOOLS_TRACE_ENABLED = var.config.observability.tracing.mode != null - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.observability.tracing.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error - COMPUTE_PROVIDER_TYPE = var.runner_provider.type - RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes + ENVIRONMENT = var.config.prefix + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.observability.metrics.enabled && var.config.observability.metrics.metric.github_app_rate_limit.enabled + GHES_URL = var.config.github.enterprise_server.url + USER_AGENT = var.config.github.user_agent + LOG_LEVEL = upper(var.config.observability.logs.level) + MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.config.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.config.runner.os]) + NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github.app_parameters.id : p.name]) + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github.app_parameters.key_base64 : p.name]) + PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github.app_parameters.installation_id : p != null ? p.name : ""]) + POWERTOOLS_LOGGER_LOG_EVENT = var.config.observability.logs.level == "debug" ? "true" : "false" + SCALE_DOWN_CONFIG = jsonencode(var.config.scale_down.idle_config) + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-scale-down" + POWERTOOLS_METRICS_NAMESPACE = var.config.observability.metrics.namespace + POWERTOOLS_TRACE_ENABLED = var.config.observability.tracing.mode != null + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.observability.tracing.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error + COMPUTE_PROVIDER_TYPE = var.runner_provider.type + RUNNER_BOOT_TIME_IN_MINUTES = var.config.runner.boot_time_in_minutes }) } diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf b/modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf index b44d01c756..b3c87b8ad7 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-up-iam-policies.tf @@ -22,12 +22,9 @@ data "aws_iam_policy_document" "scale_up_common" { "ssm:GetParameters", ] resources = concat( - [ - var.config.github.app_parameters.id.arn, - var.config.github.app_parameters.key_base64.arn, - ], - var.config.github.app_parameters.additional_app_parameter_arns, - var.config.github.app_parameters.additional_apps_manifest != null ? [var.config.github.app_parameters.additional_apps_manifest.arn] : [], + [for p in var.config.github.app_parameters.id : p.arn], + [for p in var.config.github.app_parameters.key_base64 : p.arn], + [for p in var.config.github.app_parameters.installation_id : p.arn if p != null], [ var.config.ssm.config_path_arn, "${var.config.ssm.config_path_arn}/*", diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-up.tf b/modules/orchestration-providers/webhook/scale-runners/scale-up.tf index 1f0ddcdc5b..2997aeac21 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-up.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-up.tf @@ -16,36 +16,36 @@ resource "aws_lambda_function" "scale_up" { environment { variables = merge(var.runner_provider.scale_up.environment_variables, { - DISABLE_RUNNER_AUTOUPDATE = var.config.runner.auto_update_disabled - ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral - ENABLE_JIT_CONFIG = var.config.runner.jit_config_enabled - ENABLE_JOB_QUEUED_CHECK = var.config.scale_up.job_queued_check_enabled - ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.observability.metrics.enabled && var.config.observability.metrics.metric.github_app_rate_limit.enabled - ENABLE_ORGANIZATION_RUNNERS = var.config.github.organization_runners - ENVIRONMENT = var.config.prefix - GHES_URL = var.config.github.enterprise_server.url - USER_AGENT = var.config.github.user_agent - LOG_LEVEL = upper(var.config.observability.logs.level) - MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.config.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.config.runner.os]) - NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 - PARAMETER_GITHUB_APP_ID_NAME = var.config.github.app_parameters.id.name - PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github.app_parameters.key_base64.name - PARAMETER_GITHUB_APPS_MANIFEST_NAME = var.config.github.app_parameters.additional_apps_manifest != null ? var.config.github.app_parameters.additional_apps_manifest.name : "" - POWERTOOLS_LOGGER_LOG_EVENT = var.config.observability.logs.level == "debug" ? "true" : "false" - POWERTOOLS_METRICS_NAMESPACE = var.config.observability.metrics.namespace - POWERTOOLS_TRACE_ENABLED = var.config.observability.tracing.mode != null - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.observability.tracing.capture_http_requests - POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error - RUNNER_LABELS = lower(join(",", var.config.runner.labels)) - RUNNER_GROUP_NAME = var.config.runner.group_name - RUNNER_NAME_PREFIX = var.config.runner.name_prefix - COMPUTE_PROVIDER_TYPE = var.runner_provider.type - RUNNERS_MAXIMUM_COUNT = var.config.runner.maximum_count - POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-scale-up" - SSM_TOKEN_PATH = var.config.ssm.token_path - SSM_CONFIG_PATH = var.config.ssm.config_path - SSM_PARAMETER_STORE_TAGS = var.config.ssm.parameter_store_tags - JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) + DISABLE_RUNNER_AUTOUPDATE = var.config.runner.auto_update_disabled + ENABLE_EPHEMERAL_RUNNERS = var.config.runner.ephemeral + ENABLE_JIT_CONFIG = var.config.runner.jit_config_enabled + ENABLE_JOB_QUEUED_CHECK = var.config.scale_up.job_queued_check_enabled + ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.observability.metrics.enabled && var.config.observability.metrics.metric.github_app_rate_limit.enabled + ENABLE_ORGANIZATION_RUNNERS = var.config.github.organization_runners + ENVIRONMENT = var.config.prefix + GHES_URL = var.config.github.enterprise_server.url + USER_AGENT = var.config.github.user_agent + LOG_LEVEL = upper(var.config.observability.logs.level) + MINIMUM_RUNNING_TIME_IN_MINUTES = coalesce(var.config.scale_down.minimum_running_time_in_minutes, local.min_runtime_defaults[var.config.runner.os]) + NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 + PARAMETER_GITHUB_APP_ID_NAME = join(":", [for p in var.config.github.app_parameters.id : p.name]) + PARAMETER_GITHUB_APP_KEY_BASE64_NAME = join(":", [for p in var.config.github.app_parameters.key_base64 : p.name]) + PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = join(":", [for p in var.config.github.app_parameters.installation_id : p != null ? p.name : ""]) + POWERTOOLS_LOGGER_LOG_EVENT = var.config.observability.logs.level == "debug" ? "true" : "false" + POWERTOOLS_METRICS_NAMESPACE = var.config.observability.metrics.namespace + POWERTOOLS_TRACE_ENABLED = var.config.observability.tracing.mode != null + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.observability.tracing.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.observability.tracing.capture_error + RUNNER_LABELS = lower(join(",", var.config.runner.labels)) + RUNNER_GROUP_NAME = var.config.runner.group_name + RUNNER_NAME_PREFIX = var.config.runner.name_prefix + COMPUTE_PROVIDER_TYPE = var.runner_provider.type + RUNNERS_MAXIMUM_COUNT = var.config.runner.maximum_count + POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-scale-up" + SSM_TOKEN_PATH = var.config.ssm.token_path + SSM_CONFIG_PATH = var.config.ssm.config_path + SSM_PARAMETER_STORE_TAGS = var.config.ssm.parameter_store_tags + JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) }) } diff --git a/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl b/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl index 9d45a441d0..acca05980b 100644 --- a/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl +++ b/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl @@ -60,22 +60,32 @@ variables { } user_agent = "scale-runners-test" app_parameters = { - key_base64 = { - name = "/github-runner/key-base64" - arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { - name = "/github-runner/app-id" - arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/app-id" - } - additional_apps_manifest = { - name = "/github-runner/additional-apps-manifest" - arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/additional-apps-manifest" - } - additional_app_parameter_arns = [ - "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/app-id-2", - "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/key-base64-2", - "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/installation-id-2", + key_base64 = [ + { + name = "/github-runner/key-base64" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/key-base64" + }, + { + name = "/github-runner/key-base64-2" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/key-base64-2" + }, + ] + id = [ + { + name = "/github-runner/app-id" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/app-id" + }, + { + name = "/github-runner/app-id-2" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/app-id-2" + }, + ] + installation_id = [ + null, + { + name = "/github-runner/installation-id-2" + arn = "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/installation-id-2" + }, ] } } @@ -251,15 +261,14 @@ run "assembles_provider_neutral_scaling_control_plane" { assert { condition = ( - aws_lambda_function.scale_up.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id" - && aws_lambda_function.scale_down.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-runner/key-base64" - && aws_lambda_function.scale_up.environment[0].variables["PARAMETER_GITHUB_APPS_MANIFEST_NAME"] == "/github-runner/additional-apps-manifest" + aws_lambda_function.scale_up.environment[0].variables["PARAMETER_GITHUB_APP_ID_NAME"] == "/github-runner/app-id:/github-runner/app-id-2" + && aws_lambda_function.scale_down.environment[0].variables["PARAMETER_GITHUB_APP_KEY_BASE64_NAME"] == "/github-runner/key-base64:/github-runner/key-base64-2" + && aws_lambda_function.scale_up.environment[0].variables["PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME"] == ":/github-runner/installation-id-2" && contains(data.aws_iam_policy_document.scale_up_common.statement[1].resources, "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/app-id-2") && contains(data.aws_iam_policy_document.scale_down_common.statement[0].resources, "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/key-base64-2") && contains(data.aws_iam_policy_document.scale_down_common.statement[0].resources, "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/installation-id-2") - && contains(data.aws_iam_policy_document.scale_up_common.statement[1].resources, "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/github-runner/additional-apps-manifest") ) - error_message = "Scale-up and scale-down must receive the new GitHub App parameter format and grant access to every corresponding SSM ARN." + error_message = "Scale-up and scale-down must pass every GitHub App parameter and grant access to every corresponding SSM ARN." } assert { diff --git a/modules/orchestration-providers/webhook/scale-runners/variables.tf b/modules/orchestration-providers/webhook/scale-runners/variables.tf index 4134b2e1b7..e191e303d1 100644 --- a/modules/orchestration-providers/webhook/scale-runners/variables.tf +++ b/modules/orchestration-providers/webhook/scale-runners/variables.tf @@ -33,10 +33,9 @@ variable "config" { - `github.enterprise_server.url`: Optional GitHub Enterprise Server URL. - `github.enterprise_server.ssl_verify`: Enables TLS verification for GitHub Enterprise Server. - `github.user_agent`: Optional User-Agent sent to GitHub. - - `github.app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key. - - `github.app_parameters.id`: Parameter Store reference for the primary GitHub App ID. - - `github.app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest. - - `github.app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters. + - `github.app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. + - `github.app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. + - `github.app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - `queue.build.arn`: ARN of the build queue consumed by scale-up. - `queue.kms_key_id`: Optional KMS key ARN used to encrypt the build queue. This is distinct from the Parameter Store key. - `queue.event_source_mapping.batch_size`: Maximum records delivered per scale-up invocation. @@ -56,7 +55,6 @@ variable "config" { - `scale_up.tags.log_group`: Tags for the scale-up log group. - `scale_up.tags.event_source_mapping`: Tags for the build-queue event-source mapping. - `scale_down`: Scale-down Lambda sizing, schedule, idle configuration, minimum runtime, and resolved resource tag maps. - - `scale_down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. GitHub's busy flag can be stale (it can read false for a runner that is actively executing a job), so a single not-busy reading is not sufficient evidence a runner is idle. Set to at least one scale-down schedule interval to require two consecutive not-busy evaluations; a busy reading resets the window. 0 keeps the previous single-reading behaviour. - `scale_down.tags.resources`: Tags for the scale-down IAM role and EventBridge rule. - `scale_down.tags.lambda`: Tags for the scale-down Lambda function. - `scale_down.tags.log_group`: Tags for the scale-down log group. @@ -112,13 +110,9 @@ variable "config" { }) user_agent = optional(string, null) app_parameters = object({ - key_base64 = map(string) - id = map(string) - additional_apps_manifest = optional(object({ - name = string - arn = string - }), null) - additional_app_parameter_arns = optional(list(string), []) + key_base64 = list(map(string)) + id = list(map(string)) + installation_id = list(object({ name = string, arn = string })) }) }) queue = object({ @@ -178,7 +172,6 @@ variable "config" { timeout = number schedule_expression = string minimum_running_time_in_minutes = optional(number, null) - idle_confirmation_seconds = optional(number, 0) idle_config = list(object({ cron = string timeZone = string diff --git a/modules/orchestration-providers/webhook/scale-runners/versions.tf b/modules/orchestration-providers/webhook/scale-runners/versions.tf index 0bedc91fd5..3ef011ea0a 100644 --- a/modules/orchestration-providers/webhook/scale-runners/versions.tf +++ b/modules/orchestration-providers/webhook/scale-runners/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl b/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl index b4df7b2df6..ac92fb907b 100644 --- a/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl +++ b/modules/orchestration-providers/webhook/tests/webhook.tftest.hcl @@ -30,14 +30,15 @@ variables { github = { app_parameters = { - key_base64 = { + key_base64 = [{ name = "/github-runner/key-base64" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { + }] + id = [{ name = "/github-runner/app-id" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } + }] + installation_id = [null] } enterprise_server = { url = null diff --git a/modules/orchestration-providers/webhook/variables.tf b/modules/orchestration-providers/webhook/variables.tf index 0e7ecd37c8..5dfecdbd6c 100644 --- a/modules/orchestration-providers/webhook/variables.tf +++ b/modules/orchestration-providers/webhook/variables.tf @@ -45,7 +45,6 @@ variable "config" { - `lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. - `lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. - `lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. Null selects the operating-system default. - - `lambda.scale.down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. A value of `0` preserves the single-reading behavior. - `lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. - `lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. - `lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. @@ -114,7 +113,6 @@ variable "config" { timeout = number schedule_expression = string minimum_running_time_in_minutes = optional(number, null) - idle_confirmation_seconds = optional(number, 0) idle_config = list(object({ cron = string timeZone = string @@ -169,13 +167,9 @@ variable "github" { description = "Common GitHub API client and GitHub App Parameter Store references." type = object({ app_parameters = object({ - key_base64 = map(string) - id = map(string) - additional_apps_manifest = optional(object({ - name = string - arn = string - }), null) - additional_app_parameter_arns = optional(list(string), []) + key_base64 = list(map(string)) + id = list(map(string)) + installation_id = list(object({ name = string, arn = string })) }) enterprise_server = object({ url = optional(string, null) diff --git a/modules/orchestration-providers/webhook/versions.tf b/modules/orchestration-providers/webhook/versions.tf index 0bedc91fd5..3ef011ea0a 100644 --- a/modules/orchestration-providers/webhook/versions.tf +++ b/modules/orchestration-providers/webhook/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/runner-binaries-syncer/README.md b/modules/runner-binaries-syncer/README.md index 3355a5d4ff..41b2bdd1e5 100644 --- a/modules/runner-binaries-syncer/README.md +++ b/modules/runner-binaries-syncer/README.md @@ -36,7 +36,7 @@ yarn run dist | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers diff --git a/modules/runner-binaries-syncer/versions.tf b/modules/runner-binaries-syncer/versions.tf index 1238b79cc3..42a40b33fd 100644 --- a/modules/runner-binaries-syncer/versions.tf +++ b/modules/runner-binaries-syncer/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index 7c1585dca2..eace9fe184 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -70,7 +70,7 @@ yarn run dist | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers @@ -111,10 +111,10 @@ yarn run dist | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | | [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block.

Exactly one compute-provider block must be non-null. The populated block selects the provider, and its presence must be known during planning. Values inside the selected block may remain unknown until apply.

- `aws`: AWS compute-provider configurations.
- `aws.ec2`: EC2 compute-provider configuration.
- `aws.ec2.ami`: Optional AMI discovery or external AMI-parameter configuration. Null uses the operating-system and architecture defaults.
- `aws.ec2.ami.filter`: EC2 AMI filters combined with the provider's default AMI-name filter.
- `aws.ec2.ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `aws.ec2.ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Null creates a provider-managed AMI-ID parameter. The wrapper's presence is the plan-time ownership discriminator, so keep the object literal even when its ARN comes from another resource.
- `aws.ec2.ami.id_ssm_parameter.arn`: ARN of the externally managed SSM parameter. The ARN may be unknown until apply.
- `aws.ec2.ami.kms_key`: Optional KMS key required to launch encrypted AMIs or snapshots. The wrapper's presence is the plan-time policy discriminator.
- `aws.ec2.ami.kms_key.arn`: ARN of the KMS key. The ARN may be unknown until apply.
- `aws.ec2.vpc_id`: VPC in which runner networking resources are created.
- `aws.ec2.subnet_ids`: Subnets from which scale-up may launch runner instances.
- `aws.ec2.overrides`: Optional resource-name overrides.
- `aws.ec2.overrides.name_runner`: Name tag used for runner compute resources. An empty value uses the generated provider name.
- `aws.ec2.overrides.name_sg`: Name tag used for the managed runner security group. An empty value uses the generated provider name.
- `aws.ec2.instance_profile`: Optional externally managed instance profile used by the launch template.
- `aws.ec2.instance_profile.name`: Name of the externally managed instance profile.
- `aws.ec2.instance_profile_path`: IAM path for the provider-managed instance profile. Null uses a path derived from the runner-configuration prefix.
- `aws.ec2.binaries_syncer`: Runner-distribution synchronization configuration.
- `aws.ec2.binaries_syncer.enabled`: Enables use of a synchronized runner distribution from S3.
- `aws.ec2.binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `aws.ec2.binaries_syncer.s3.arn`: ARN of the runner-distribution bucket, used by IAM policies.
- `aws.ec2.binaries_syncer.s3.id`: Bucket name used to construct the runner-distribution S3 URI.
- `aws.ec2.binaries_syncer.s3.key`: Object key of the runner distribution.
- `aws.ec2.block_device_mappings`: EBS mappings added to the runner launch template.
- `aws.ec2.block_device_mappings[].delete_on_termination`: Deletes the volume when its runner instance terminates.
- `aws.ec2.block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `aws.ec2.block_device_mappings[].encrypted`: Enables EBS encryption.
- `aws.ec2.block_device_mappings[].iops`: Provisioned IOPS for volume types that support it.
- `aws.ec2.block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `aws.ec2.block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `aws.ec2.block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `aws.ec2.block_device_mappings[].volume_initialization_rate`: Fixed initialization rate in MiB/s for supported snapshot-backed volumes.
- `aws.ec2.block_device_mappings[].volume_size`: Volume size in GiB.
- `aws.ec2.block_device_mappings[].volume_type`: EBS volume type.
- `aws.ec2.ebs_optimized`: Requests EBS-optimized runner instances.
- `aws.ec2.instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `aws.ec2.instance_allocation_strategy`: EC2 Fleet allocation strategy used to select instance capacity.
- `aws.ec2.instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `aws.ec2.instance_max_spot_price`: Optional maximum hourly Spot price.
- `aws.ec2.instance_types`: EC2 instance types available to the scale-up and pool functions.
- `aws.ec2.user_data`: Runner bootstrap user-data configuration.
- `aws.ec2.user_data.enabled`: Enables launch-template user data.
- `aws.ec2.user_data.template`: Optional path to a custom user-data template.
- `aws.ec2.user_data.content`: Optional complete user-data content. When set, it is used instead of rendering a template.
- `aws.ec2.user_data.pre_install`: Script content inserted before runner installation in the default template.
- `aws.ec2.user_data.post_install`: Script content inserted after runner installation in the default template.
- `aws.ec2.user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets in logs.
- `aws.ec2.ssm_enabled`: Attaches runner permissions and policies required for AWS Systems Manager access.
- `aws.ec2.create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `aws.ec2.cloudwatch_agent`: CloudWatch agent configuration for runner instances.
- `aws.ec2.cloudwatch_agent.enabled`: Installs and configures the CloudWatch agent through the default bootstrap flow.
- `aws.ec2.cloudwatch_agent.config`: Optional complete CloudWatch agent configuration. Null renders the provider default from `log_files`.
- `aws.ec2.managed_security_group_enabled`: Creates and attaches the provider-managed runner security group.
- `aws.ec2.log_files`: Optional log files collected by the CloudWatch agent. Null uses the provider defaults.
- `aws.ec2.log_files[].log_group_name`: CloudWatch log-group name, before optional prefixing.
- `aws.ec2.log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path when true.
- `aws.ec2.log_files[].file_path`: File or glob read by the CloudWatch agent.
- `aws.ec2.log_files[].log_stream_name`: CloudWatch log-stream name template.
- `aws.ec2.log_files[].log_class`: CloudWatch log-group class for the collected file.
- `aws.ec2.key_name`: Optional EC2 key-pair name added to the launch template.
- `aws.ec2.additional_security_group_ids`: Existing security groups attached in addition to the managed security group.
- `aws.ec2.detailed_monitoring_enabled`: Enables detailed EC2 monitoring for runner instances.
- `aws.ec2.egress_rules`: Egress rules created on the managed runner security group.
- `aws.ec2.egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `aws.ec2.egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `aws.ec2.egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `aws.ec2.egress_rules[].from_port`: First destination port in the permitted range.
- `aws.ec2.egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `aws.ec2.egress_rules[].security_groups`: Destination security-group IDs.
- `aws.ec2.egress_rules[].self`: Allows traffic to the managed security group itself when true.
- `aws.ec2.egress_rules[].to_port`: Last destination port in the permitted range.
- `aws.ec2.egress_rules[].description`: Optional rule description.
- `aws.ec2.tags`: Additional tags for runner instances, EBS volumes, network interfaces, and eligible Spot instance requests created from the launch template. They override module-level tags and the generated runner `Name`; the provider-managed `ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` bootstrap tags take final precedence. These tags do not apply to static provider resources such as the launch template, security group, IAM resources, SSM parameters, or log groups.
- `aws.ec2.metadata_options`: Instance Metadata Service configuration in the launch template.
- `aws.ec2.metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when `enabled`.
- `aws.ec2.metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `aws.ec2.metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `aws.ec2.metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `aws.ec2.credit_specification`: CPU credit mode for burstable instance types, either `standard` or `unlimited`.
- `aws.ec2.cpu_options`: CPU topology and processor-feature configuration.
- `aws.ec2.cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `aws.ec2.cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `aws.ec2.cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `aws.ec2.cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `aws.ec2.placement`: EC2 placement configuration for runner instances.
- `aws.ec2.placement.affinity`: Host affinity setting.
- `aws.ec2.placement.availability_zone`: Availability Zone in which the instance is placed.
- `aws.ec2.placement.group_id`: Placement-group ID.
- `aws.ec2.placement.group_name`: Placement-group name.
- `aws.ec2.placement.host_id`: Dedicated Host ID.
- `aws.ec2.placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `aws.ec2.placement.spread_domain`: Spread-domain placement value.
- `aws.ec2.placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `aws.ec2.placement.partition_number`: Placement-group partition number.
- `aws.ec2.license_specifications`: License Manager configurations added to the launch template.
- `aws.ec2.license_specifications[].license_configuration_arn`: ARN of a License Manager license configuration.
- `aws.ec2.associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `aws.ec2.on_demand_failover_for_errors`: EC2 error codes that trigger an on-demand fallback after a Spot launch failure.
- `aws.ec2.scale_errors`: EC2 error codes treated as retryable scale-up failures.
- `aws.ec2.use_dedicated_host`: Enables the dedicated-host launch path, required for macOS runners. |
object({
aws = optional(object({
ec2 = optional(object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
vpc_id = string
subnet_ids = list(string)
overrides = optional(object({
name_runner = optional(string, "")
name_sg = optional(string, "")
}), {})
instance_profile = optional(object({
name = string
}), null)
instance_profile_path = optional(string, null)
binaries_syncer = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
arn = string
id = string
key = string
}), null)
}), {})
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
ebs_optimized = optional(bool, false)
instance_target_capacity_type = optional(string, "spot")
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_types = list(string)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
ssm_enabled = optional(bool, false)
create_service_linked_role_spot = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
managed_security_group_enabled = optional(bool, true)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
key_name = optional(string, null)
additional_security_group_ids = optional(list(string), [])
detailed_monitoring_enabled = optional(bool, false)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
tags = optional(map(string), {})
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
credit_specification = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
associate_public_ipv4_address = optional(bool, false)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
use_dedicated_host = optional(bool, false)
}), null)
}), {})
})
| n/a | yes | | [compute\_provider\_key](#input\_compute\_provider\_key) | Optional plan-known compute-provider dispatch key. Null discovers the key from the exactly one populated compute\_provider block. | `string` | `null` | no | -| [github](#input\_github) | GitHub API and runner-registration configuration.

- `app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key.
- `app_parameters.id`: Parameter Store reference for the primary GitHub App ID.
- `app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest.
- `app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters.
- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `user_agent`: Optional User-Agent value added to GitHub API requests. |
object({
app_parameters = object({
key_base64 = map(string)
id = map(string)
additional_apps_manifest = optional(object({
name = string
arn = string
}), null)
additional_app_parameter_arns = optional(list(string), [])
})
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | +| [github](#input\_github) | GitHub API and runner-registration configuration.

- `app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys.
- `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs.
- `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs.
- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests.
- `user_agent`: Optional User-Agent value added to GitHub API requests. |
object({
app_parameters = object({
key_base64 = list(map(string))
id = list(map(string))
installation_id = list(object({ name = string, arn = string }))
})
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
user_agent = optional(string, null)
})
| n/a | yes | | [lambda](#input\_lambda) | Common Lambda substrate independent of the selected runner orchestration provider.

- `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact.
- `runtime`: Runtime used by the control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by the control-plane Lambda functions. Supported values are `arm64` and `x86_64`.
- `subnet_ids`: Subnets used for Lambda VPC configuration.
- `security_group_ids`: Security groups used for Lambda VPC configuration.
- `tags`: Shared tags applied to Lambda function resources only. These override module-level `tags`; component `tags` override this map when keys conflict.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `role.path`: IAM path for module-managed Lambda execution roles. Defaults to a path derived from `prefix`.
- `role.permissions_boundary`: Permissions-boundary ARN applied to module-managed Lambda execution roles. |
object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| `{}` | no | | [observability](#input\_observability) | Logging, tracing, and metrics configuration for control-plane and provider resources.

- `logs.level`: Application log level supplied to the control-plane functions.
- `logs.retention_in_days`: CloudWatch Logs retention period.
- `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt CloudWatch log groups.
- `logs.class`: CloudWatch log-group class. Supported values are `STANDARD` and `INFREQUENT_ACCESS`.
- `logs.tags`: Shared tags for CloudWatch log groups. These override module-level `tags`; component `tags` override this map when keys conflict.
- `tracing.mode`: Optional Lambda active-tracing mode. Null disables X-Ray tracing configuration.
- `tracing.capture_http_requests`: Enables HTTP request capture in the tracing helper.
- `tracing.capture_error`: Enables error capture in the tracing helper.
- `metrics.enabled`: Enables module-emitted metrics.
- `metrics.namespace`: CloudWatch namespace used for emitted metrics.
- `metrics.metric.github_app_rate_limit.enabled`: Emits GitHub App rate-limit metrics.
- `metrics.metric.job_retry.enabled`: Emits job-retry metrics.
- `metrics.metric.spot_termination_warning.enabled`: Emits spot-termination warning metrics where supported. |
object({
logs = optional(object({
level = optional(string, "info")
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
class = optional(string, "STANDARD")
tags = optional(map(string), {})
}), {})
tracing = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
metrics = optional(object({
enabled = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
github_app_rate_limit = optional(object({
enabled = optional(bool, true)
}), {})
job_retry = optional(object({
enabled = optional(bool, true)
}), {})
spot_termination_warning = optional(object({
enabled = optional(bool, true)
}), {})
}), {})
}), {})
})
| `{}` | no | -| [orchestration\_provider](#input\_orchestration\_provider) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply.

- `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. Future providers can be added as sibling blocks without moving this contract.
- `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`.
- `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`.
- `webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. The default is null, which follows `runner.ephemeral`.
- `webhook.runner.maximum_count`: Maximum number of runners managed for this runner configuration. The default is `3`.
- `webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `webhook.queue.build.arn`: ARN of the runner configuration's build queue.
- `webhook.queue.build.url`: URL of the runner configuration's build queue.
- `webhook.queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. The default is null and is independent from the Parameter Store KMS key.
- `webhook.queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides. The default is `{}`.
- `webhook.lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. Set at most one of `zip` or `s3`; no selection uses the packaged runner archive.
- `webhook.lambda.artifact.zip`: Optional local path to the runner-control Lambda archive. The default is null.
- `webhook.lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning, selecting it requires a non-null common bucket, and the default is null.
- `webhook.lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `webhook.lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive. The default is null.
- `webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. The default is `512`.
- `webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. The default is `0`.
- `webhook.lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. The default is `512`.
- `webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. The default is `cron(*/5 * * * ? *)`.
- `webhook.lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `webhook.lambda.scale.down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. The default is `0`, which preserves the single-reading behavior.
- `webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. The default is `[]`.
- `webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `webhook.lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. The default is `512`.
- `webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.pool.config`: Scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `webhook.lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. The default is `false`.
- `webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. The default is `300`.
- `webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. The default is `2`.
- `webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. The default is `1`.
- `webhook.job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. The default is `256`.
- `webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. The default is `30`. |
object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_confirmation_seconds = optional(number, 0)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
})
| n/a | yes | +| [orchestration\_provider](#input\_orchestration\_provider) | Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply.

- `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls.
- `scale_set`: Selects scale-set orchestration for this runner config. The multi-runner topology owns the shared controller service and passes this plan-known selection marker to runner-config. Scale-set runners always use ephemeral JIT registration.
- `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration.
- `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`.
- `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`.
- `webhook.runner.jit_config_enabled`: Explicitly enables or disables just-in-time configuration. The default is null, which follows `runner.ephemeral`.
- `webhook.runner.maximum_count`: Maximum number of runners managed for this runner configuration. The default is `3`.
- `webhook.github.organization_runners`: Registers runners at organization scope when true; otherwise registration is repository-scoped.
- `webhook.queue.build.arn`: ARN of the runner configuration's build queue.
- `webhook.queue.build.url`: URL of the runner configuration's build queue.
- `webhook.queue.kms_key_id`: Optional KMS key ARN encrypting the build queue. The default is null and is independent from the Parameter Store KMS key.
- `webhook.queue.tags`: Tags inherited by queue-related provider resources before component-specific overrides. The default is `{}`.
- `webhook.lambda.artifact`: Runner-control artifact shared by scale, pool, and job-retry components. Set at most one of `zip` or `s3`; no selection uses the packaged runner archive.
- `webhook.lambda.artifact.zip`: Optional local path to the runner-control Lambda archive. The default is null.
- `webhook.lambda.artifact.s3`: Optional S3 object selector in the common `lambda.artifact.s3.bucket`. Wrapper presence must be known during planning, selecting it requires a non-null common bucket, and the default is null.
- `webhook.lambda.artifact.s3.key`: Object key of the runner-control Lambda archive.
- `webhook.lambda.artifact.s3.object_version`: Optional object version of the runner-control Lambda archive. The default is null.
- `webhook.lambda.scale.up.memory_size`: Memory allocated to the scale-up Lambda in MB. The default is `512`.
- `webhook.lambda.scale.up.timeout`: Scale-up Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.up.reserved_concurrent_executions`: Reserved concurrency for scale-up. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.scale.up.job_queued_check_enabled`: Enables queued-job verification before scaling. The default is null, which follows the resolved runner mode.
- `webhook.lambda.scale.up.event_source_mapping.batch_size`: Maximum build-queue records delivered per scale-up invocation. The default is `10`.
- `webhook.lambda.scale.up.event_source_mapping.maximum_batching_window_in_seconds`: Maximum batching window for build-queue records. The default is `0`.
- `webhook.lambda.scale.up.tags`: Tags applied within scale-up resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.scale.down.memory_size`: Memory allocated to the scale-down Lambda in MB. The default is `512`.
- `webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. The default is `cron(*/5 * * * ? *)`.
- `webhook.lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default.
- `webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. The default is `[]`.
- `webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies.
- `webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression.
- `webhook.lambda.scale.down.idle_config[].idleCount`: Number of idle runners retained during the matching period.
- `webhook.lambda.scale.down.idle_config[].evictionStrategy`: Selection strategy used when excess idle runners are removed. The default is `oldest_first`.
- `webhook.lambda.scale.down.tags`: Tags applied within scale-down resource scopes after common provider tags. The default is `{}`.
- `webhook.lambda.pool.memory_size`: Memory allocated to the pool Lambda in MB. The default is `512`.
- `webhook.lambda.pool.timeout`: Pool Lambda timeout in seconds. The default is `60`.
- `webhook.lambda.pool.reserved_concurrent_executions`: Reserved concurrency for the pool Lambda. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.lambda.pool.config`: Scheduled target pool sizes. The default is `[]`, which disables the pool component.
- `webhook.lambda.pool.config[].schedule_expression`: Scheduler expression that activates the target size.
- `webhook.lambda.pool.config[].schedule_expression_timezone`: Optional IANA time zone used to evaluate the schedule.
- `webhook.lambda.pool.config[].size`: Desired number of runners for the schedule.
- `webhook.lambda.pool.include_busy_runners`: Includes busy runners when reconciling scheduled pool capacity. The default is `false`.
- `webhook.lambda.pool.runner_owner`: Optional GitHub organization or repository owner used for pooled runners. The default is null.
- `webhook.lambda.pool.tags`: Tags applied within pool resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.enabled`: Creates the retry queue, Lambda function, event-source mapping, and related IAM resources. The default is `false`.
- `webhook.job_retry.delay_in_seconds`: Initial delay before a queued-job retry check. The default is `300`.
- `webhook.job_retry.delay_backoff`: Multiplier applied to the delay after each unsuccessful check. The default is `2`.
- `webhook.job_retry.max_attempts`: Maximum retry-check attempts before the message is no longer republished. The default is `1`.
- `webhook.job_retry.tags`: Tags applied within job-retry resource scopes after common provider tags. The default is `{}`.
- `webhook.job_retry.lambda.memory_size`: Memory allocated to the job-retry Lambda in MB. The default is `256`.
- `webhook.job_retry.lambda.reserved_concurrent_executions`: Reserved concurrency for job retry. The default is `1`; use `-1` for unreserved concurrency.
- `webhook.job_retry.lambda.timeout`: Job-retry Lambda timeout in seconds and visibility timeout for its retry queue. The default is `30`. |
object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, 5)
ephemeral = optional(bool, false)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, 3)
}), {})
github = object({
organization_runners = bool
})
queue = object({
build = object({
arn = string
url = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
scale = optional(object({
up = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, 10)
maximum_batching_window_in_seconds = optional(number, 0)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
schedule_expression = optional(string, "cron(*/5 * * * ? *)")
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, 512)
timeout = optional(number, 60)
reserved_concurrent_executions = optional(number, 1)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
include_busy_runners = optional(bool, false)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})
job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})
}), null)
scale_set = optional(object({
name = string
runner = optional(object({
min_runners = optional(number, 0)
max_runners = optional(number, 10)
boot_time_in_minutes = optional(number, 10)
}), {})
}), null)
})
| n/a | yes | | [prefix](#input\_prefix) | The prefix used for naming resources. | `string` | `"github-actions"` | no | | [runner](#input\_runner) | Provider-neutral GitHub runner configuration.

- `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`.
- `architecture`: Runner distribution architecture, such as `x64` or `arm64`.
- `disable_default_labels`: Prevents GitHub's default self-hosted, operating-system, and architecture labels from being registered.
- `labels`: Complete set of labels supplied to the control-plane functions.
- `group_name`: GitHub runner group used during registration.
- `name_prefix`: Prefix added to registered runner names.
- `run_as_root`: Runs the runner service as root when supported by the compute provider.
- `run_as`: Operating-system user used when `run_as_root` is false.
- `auto_update_disabled`: Disables the GitHub runner application's built-in updater.
- `tags`: Additional tags for common runner resources, currently the managed runner IAM role. These override module-level `tags` with the same key.
- `hooks.job_started`: Script content installed as the runner job-started hook.
- `hooks.job_completed`: Script content installed as the runner job-completed hook.
- `iam.role.arn`: ARN of an externally managed runner role. When set, this module does not create or modify that role.
- `iam.managed_policy_arns`: Named managed-policy ARNs attached to the module-managed runner role.
- `iam.additional_trust_policy_json`: Optional IAM policy document merged with the selected compute provider's default runner-role trust policy.
- `iam.path`: IAM path for the module-managed runner role. Defaults to a path derived from `prefix`.
- `iam.permissions_boundary`: Permissions-boundary ARN for the module-managed runner role. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
disable_default_labels = optional(bool, false)
labels = list(string)
group_name = optional(string, "Default")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
auto_update_disabled = optional(bool, false)
tags = optional(map(string), {})
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = optional(object({
role = optional(object({
arn = string
}), null)
managed_policy_arns = optional(map(string), {})
additional_trust_policy_json = optional(string, null)
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| n/a | yes | | [ssm](#input\_ssm) | Parameter Store paths, encryption, tag scopes, and housekeeper configuration.

- `paths.root`: Root Parameter Store path for this runner configuration.
- `paths.tokens`: Path segment under `paths.root` used for registration tokens and just-in-time configuration.
- `paths.config`: Path segment under `paths.root` used for persistent runner configuration.
- `kms_key_id`: Optional customer-managed KMS key ARN used by control-plane IAM policies to decrypt shared GitHub App parameters. The ARN may be unknown until apply; null omits the provider-owned KMS statements. It does not select encryption for runtime-created runner parameters.
- `tags`: Shared tags for SSM-related resources. These override module-level `tags` and are inherited by parameter and housekeeper resources.
- `parameters.tags`: Tags for Terraform-managed runner configuration parameters and temporary parameters created by the scale-up and pool Lambdas. These override module-level and `ssm.tags` values with the same key.
- `housekeeper.schedule_expression`: EventBridge schedule expression that invokes the SSM housekeeper.
- `housekeeper.state`: EventBridge rule state, such as `ENABLED` or `DISABLED`.
- `housekeeper.tags`: Tags for housekeeper resources, including the Lambda function, log group, EventBridge rule, and IAM role. These override module-level, `ssm.tags`, shared Lambda, and shared log tags when keys conflict.
- `housekeeper.lambda.artifact`: Component-owned SSM-housekeeper artifact selection. Set at most one of `zip` or `s3`; when neither is selected, the module uses its packaged runner control-plane archive. This selector does not inherit an orchestration-provider artifact.
- `housekeeper.lambda.artifact.zip`: Optional local path to the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3`: Optional object key and version in the shared `lambda.artifact.s3.bucket`. Selecting S3 requires that common bucket.
- `housekeeper.lambda.artifact.s3.key`: Object key of the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.artifact.s3.object_version`: Optional object version of the SSM-housekeeper Lambda archive.
- `housekeeper.lambda.memory_size`: Memory allocated to the SSM housekeeper Lambda in MB.
- `housekeeper.lambda.timeout`: SSM housekeeper Lambda timeout in seconds.
- `housekeeper.config.tokenPath`: Parameter Store token path cleaned by the housekeeper. When omitted, the configured runner token path is used.
- `housekeeper.config.minimumDaysOld`: Minimum parameter age in days before deletion is allowed.
- `housekeeper.config.dryRun`: Reports eligible parameters without deleting them when true. |
object({
paths = object({
root = string
tokens = string
config = string
})
kms_key_id = optional(string, null)
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
housekeeper = optional(object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
tags = optional(map(string), {})
lambda = optional(object({
artifact = optional(object({
zip = optional(string, null)
s3 = optional(object({
key = string
object_version = optional(string, null)
}), null)
}), {})
memory_size = optional(number, 512)
timeout = optional(number, 60)
}), {})
config = optional(object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
}), {})
}), {})
})
| n/a | yes | @@ -124,6 +124,7 @@ yarn run dist | Name | Description | |------|-------------| +| [compute\_provider\_contract](#output\_compute\_provider\_contract) | Provider-neutral compute-provider capabilities consumed by topology-level orchestration. | | [orchestration\_provider](#output\_orchestration\_provider) | Resources grouped under the selected runner orchestration provider. | | [pool](#output\_pool) | Scheduled pool resources. Null when no pool configuration is supplied. | | [provider](#output\_provider) | Provider-specific resources grouped under the selected provider namespace and type. | diff --git a/modules/runner-config/common-config.tf b/modules/runner-config/common-config.tf index fe12790d9a..660fcc60ab 100644 --- a/modules/runner-config/common-config.tf +++ b/modules/runner-config/common-config.tf @@ -1,15 +1,6 @@ # Shared control-plane configuration: naming, paths, tags, and normalized values. locals { - common_tags = merge( - { - "Name" = format("%s-action-runner", var.prefix) - }, - { - "ghr:ssm_config_path" = "${var.ssm.paths.root}/${var.ssm.paths.config}" - }, - var.tags, - ) - + common_tags = var.tags runner_tags = merge(local.common_tags, var.runner.tags) lambda_tags = merge(local.common_tags, var.lambda.tags) observability_log_tags = merge(local.common_tags, var.observability.logs.tags) diff --git a/modules/runner-config/compute-provider.aws.ec2.tf b/modules/runner-config/compute-provider.aws.ec2.tf index e289b70217..5d053a73dc 100644 --- a/modules/runner-config/compute-provider.aws.ec2.tf +++ b/modules/runner-config/compute-provider.aws.ec2.tf @@ -12,7 +12,7 @@ module "compute_aws_ec2" { aws_partition = var.aws_partition aws_region = var.aws_region prefix = var.prefix - tags = local.common_tags + tags = var.tags config = var.compute_provider.aws.ec2 runner = merge(var.runner, { diff --git a/modules/runner-config/orchestration-provider.tf b/modules/runner-config/orchestration-provider.tf index 16238fa6c6..4d6c4c4444 100644 --- a/modules/runner-config/orchestration-provider.tf +++ b/modules/runner-config/orchestration-provider.tf @@ -7,11 +7,16 @@ locals { orchestration_provider_type = one(keys(local.orchestration_providers)) orchestration_provider_enabled = { - webhook = local.orchestration_provider_type == "webhook" + webhook = local.orchestration_provider_type == "webhook" + scale_set = local.orchestration_provider_type == "scale_set" } orchestration_provider_runner_lifecycle = { webhook = one(module.orchestration_webhook[*].runner_lifecycle) + scale_set = { + ephemeral = true + jit_config_enabled = true + } }[local.orchestration_provider_type] } @@ -21,7 +26,7 @@ module "orchestration_webhook" { aws_partition = var.aws_partition prefix = var.prefix - tags = local.common_tags + tags = var.tags config = var.orchestration_provider.webhook runner = var.runner @@ -32,7 +37,7 @@ module "orchestration_webhook" { architecture = var.lambda.architecture subnet_ids = var.lambda.subnet_ids security_group_ids = var.lambda.security_group_ids - tags = local.lambda_tags + tags = var.lambda.tags role = { path = local.lambda_role_path permissions_boundary = var.lambda.role.permissions_boundary diff --git a/modules/runner-config/outputs.tf b/modules/runner-config/outputs.tf index 486e3261eb..d52230ba5f 100644 --- a/modules/runner-config/outputs.tf +++ b/modules/runner-config/outputs.tf @@ -22,13 +22,26 @@ output "pool" { output "orchestration_provider" { description = "Resources grouped under the selected runner orchestration provider." + value = merge( + { + webhook = local.orchestration_provider_enabled.webhook ? { + scale_up = one(module.orchestration_webhook[*].scale_up) + scale_down = one(module.orchestration_webhook[*].scale_down) + pool = one(module.orchestration_webhook[*].pool) + job_retry = one(module.orchestration_webhook[*].job_retry) + } : null + }, + local.orchestration_provider_enabled.scale_set ? { + scale_set = {} + } : {}, + ) +} + +output "compute_provider_contract" { + description = "Provider-neutral compute-provider capabilities consumed by topology-level orchestration." value = { - webhook = local.orchestration_provider_enabled.webhook ? { - scale_up = one(module.orchestration_webhook[*].scale_up) - scale_down = one(module.orchestration_webhook[*].scale_down) - pool = one(module.orchestration_webhook[*].pool) - job_retry = one(module.orchestration_webhook[*].job_retry) - } : null + type = local.provider_contract.type + capabilities = local.provider_contract.capabilities } } diff --git a/modules/runner-config/ssm-housekeeper/README.md b/modules/runner-config/ssm-housekeeper/README.md index b8899e3f43..5f5d1ad166 100644 --- a/modules/runner-config/ssm-housekeeper/README.md +++ b/modules/runner-config/ssm-housekeeper/README.md @@ -11,7 +11,7 @@ The module is an implementation detail of the experimental runner configuration. | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/runner-config/ssm-housekeeper/versions.tf b/modules/runner-config/ssm-housekeeper/versions.tf index 0bedc91fd5..da9769f550 100644 --- a/modules/runner-config/ssm-housekeeper/versions.tf +++ b/modules/runner-config/ssm-housekeeper/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md index cf16de0bb5..3bbf0f9027 100644 --- a/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/README.md @@ -3,7 +3,7 @@ | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3 | | [random](#requirement\_random) | ~> 3.0 | ## Providers diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf index b442e56166..e765015997 100644 --- a/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/computed-iam-inputs.tf @@ -62,22 +62,32 @@ module "external_iam" { github = { app_parameters = { - key_base64 = { - name = "/github-runner/key-base64" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { - name = "/github-runner/app-id" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } - additional_apps_manifest = { - name = "/github-runner/additional-apps-manifest" - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-apps-manifest" - } - additional_app_parameter_arns = [ - "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-app-id", - "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-app-key-base64", - "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-app-installation-id", + key_base64 = [ + { + name = "/github-runner/key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" + }, + { + name = "/github-runner/additional-app-key-base64" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-app-key-base64" + }, + ] + id = [ + { + name = "/github-runner/app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" + }, + { + name = "/github-runner/additional-app-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-app-id" + }, + ] + installation_id = [ + null, + { + name = "/github-runner/additional-app-installation-id" + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/additional-app-installation-id" + }, ] } } @@ -168,14 +178,15 @@ module "generated_policy" { github = { app_parameters = { - key_base64 = { + key_base64 = [{ name = "/github-runner/key-base64" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { + }] + id = [{ name = "/github-runner/app-id" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } + }] + installation_id = [null] } } diff --git a/modules/runner-config/tests/fixtures/computed-iam-inputs/versions.tf b/modules/runner-config/tests/fixtures/computed-iam-inputs/versions.tf index 688b2a4e03..9fd85fad8f 100644 --- a/modules/runner-config/tests/fixtures/computed-iam-inputs/versions.tf +++ b/modules/runner-config/tests/fixtures/computed-iam-inputs/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3" required_providers { aws = { diff --git a/modules/runner-config/tests/pool.tftest.hcl b/modules/runner-config/tests/pool.tftest.hcl index 5639c0d217..12a81854c3 100644 --- a/modules/runner-config/tests/pool.tftest.hcl +++ b/modules/runner-config/tests/pool.tftest.hcl @@ -81,8 +81,9 @@ variables { github = { app_parameters = { - key_base64 = { name = "/github-runner/key-base64", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" } - id = { name = "/github-runner/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" } + key_base64 = [{ name = "/github-runner/key-base64", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" }] + id = [{ name = "/github-runner/app-id", arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" }] + installation_id = [null] } } @@ -219,14 +220,8 @@ run "plan_with_pool_enabled" { } assert { - condition = tomap({ - for tag in jsondecode(module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : - tag.Key => tag.Value - }) == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - }) - error_message = "Runtime Parameter Store tags must include common generated tags without leaking EC2 bootstrap tags." + condition = length(jsondecode(module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"])) == 0 + error_message = "Runtime Parameter Store tags must remain empty when no module or SSM tags are configured; EC2 bootstrap tags must not leak into them." } assert { diff --git a/modules/runner-config/tests/tags.tftest.hcl b/modules/runner-config/tests/tags.tftest.hcl index 509abfebfa..6c004879ba 100644 --- a/modules/runner-config/tests/tags.tftest.hcl +++ b/modules/runner-config/tests/tags.tftest.hcl @@ -79,14 +79,15 @@ variables { github = { app_parameters = { - key_base64 = { + key_base64 = [{ name = "/github-runner/key-base64" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/key-base64" - } - id = { + }] + id = [{ name = "/github-runner/app-id" arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - } + }] + installation_id = [null] } } @@ -191,178 +192,130 @@ run "layered_component_tags" { assert { condition = module.orchestration_webhook[0].scale_up.lambda.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "scale-up" - module = "yes" - lambda = "yes" - scale_up = "yes" + precedence = "scale-up" + module = "yes" + lambda = "yes" + scale_up = "yes" }) && module.orchestration_webhook[0].scale_up.log_group.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "scale-up" - module = "yes" - log = "yes" - scale_up = "yes" + precedence = "scale-up" + module = "yes" + log = "yes" + scale_up = "yes" }) && module.orchestration_webhook[0].scale_up.role.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "scale-up" - module = "yes" - scale_up = "yes" + precedence = "scale-up" + module = "yes" + scale_up = "yes" }) error_message = "Scale-up tags must layer module, shared resource, and component tags with the component taking precedence." } assert { condition = module.orchestration_webhook[0].scale_down.lambda.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "scale-down" - module = "yes" - lambda = "yes" - scale_down = "yes" + precedence = "scale-down" + module = "yes" + lambda = "yes" + scale_down = "yes" }) && module.orchestration_webhook[0].scale_down.log_group.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "scale-down" - module = "yes" - log = "yes" - scale_down = "yes" + precedence = "scale-down" + module = "yes" + log = "yes" + scale_down = "yes" }) && module.orchestration_webhook[0].scale_down.role.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "scale-down" - module = "yes" - scale_down = "yes" + precedence = "scale-down" + module = "yes" + scale_down = "yes" }) error_message = "Scale-down tags must layer module, shared resource, and component tags with the component taking precedence." } assert { condition = aws_iam_role.runner[0].tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "runner" - module = "yes" - runner = "yes" + precedence = "runner" + module = "yes" + runner = "yes" }) error_message = "Runner tags must override module tags on the common runner role." } assert { condition = aws_ssm_parameter.runner_agent_mode.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "ssm-parameter" - module = "yes" - ssm = "yes" - parameter = "yes" + precedence = "ssm-parameter" + module = "yes" + ssm = "yes" + parameter = "yes" }) && tomap({ for tag in jsondecode(module.orchestration_webhook[0].scale_up.lambda.environment[0].variables["SSM_PARAMETER_STORE_TAGS"]) : tag.Key => tag.Value }) == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "ssm-parameter" - module = "yes" - ssm = "yes" - parameter = "yes" + precedence = "ssm-parameter" + module = "yes" + ssm = "yes" + parameter = "yes" }) error_message = "Terraform-managed and runtime-created SSM parameters must use the same layered parameter tags." } assert { - condition = tomap(local.ssm_housekeeper_lambda_tags) == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "ssm-housekeeper" - module = "yes" - lambda = "yes" - ssm = "yes" - housekeeper = "yes" - }) - error_message = "SSM housekeeper Lambda tags must include generated and layered tags." - } - - assert { - condition = tomap(local.ssm_housekeeper_log_tags) == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "ssm-housekeeper" - module = "yes" - log = "yes" - ssm = "yes" - housekeeper = "yes" + condition = local.ssm_housekeeper_lambda_tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + lambda = "yes" + ssm = "yes" + housekeeper = "yes" + }) && local.ssm_housekeeper_log_tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + log = "yes" + ssm = "yes" + housekeeper = "yes" + }) && local.ssm_housekeeper_tags == tomap({ + precedence = "ssm-housekeeper" + module = "yes" + ssm = "yes" + housekeeper = "yes" }) - error_message = "SSM housekeeper log tags must include generated and layered tags." - } - - assert { - condition = tomap(local.ssm_housekeeper_tags) == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "ssm-housekeeper" - module = "yes" - ssm = "yes" - housekeeper = "yes" - }) - error_message = "SSM housekeeper resource tags must include generated and layered tags." + error_message = "SSM housekeeper tags must layer module, SSM, shared resource, and housekeeper tags." } assert { condition = module.orchestration_webhook[0].pool.lambda.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "pool" - module = "yes" - lambda = "yes" - pool = "yes" + precedence = "pool" + module = "yes" + lambda = "yes" + pool = "yes" }) && module.orchestration_webhook[0].pool.log_group.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "pool" - module = "yes" - log = "yes" - pool = "yes" + precedence = "pool" + module = "yes" + log = "yes" + pool = "yes" }) && module.orchestration_webhook[0].pool.role.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "pool" - module = "yes" - pool = "yes" + precedence = "pool" + module = "yes" + pool = "yes" }) error_message = "Pool tags must layer module, shared resource, and component tags with the component taking precedence." } assert { condition = module.orchestration_webhook[0].job_retry.lambda.function.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "job-retry" - module = "yes" - lambda = "yes" - job_retry = "yes" + precedence = "job-retry" + module = "yes" + lambda = "yes" + job_retry = "yes" }) && module.orchestration_webhook[0].job_retry.lambda.log_group.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "job-retry" - module = "yes" - log = "yes" - job_retry = "yes" + precedence = "job-retry" + module = "yes" + log = "yes" + job_retry = "yes" }) && module.orchestration_webhook[0].job_retry.lambda.role.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "job-retry" - module = "yes" - job_retry = "yes" + precedence = "job-retry" + module = "yes" + job_retry = "yes" }) && module.orchestration_webhook[0].job_retry.queue.tags == tomap({ - Name = "github-actions-action-runner" - "ghr:ssm_config_path" = "/github-runner/config" - precedence = "job-retry" - module = "yes" - queue = "yes" - job_retry = "yes" + precedence = "job-retry" + module = "yes" + queue = "yes" + job_retry = "yes" }) error_message = "Job-retry tags must layer module, shared resource, and component tags with the component taking precedence." } diff --git a/modules/runner-config/validations.tf b/modules/runner-config/validations.tf index f510bf0422..0c4ff5bcf0 100644 --- a/modules/runner-config/validations.tf +++ b/modules/runner-config/validations.tf @@ -90,7 +90,12 @@ resource "terraform_data" "validate_config" { for provider_name, provider_config in var.orchestration_provider : provider_name if provider_config != null ]) == 1 - error_message = "Exactly one orchestration provider must be configured. Supported providers: webhook." + error_message = "Exactly one orchestration provider must be configured. Supported providers: webhook and scale_set." + } + + precondition { + condition = var.orchestration_provider.scale_set == null ? true : local.provider_contract.capabilities.scale_set != null + error_message = "The selected compute provider must expose a scale_set capability when scale_set orchestration is selected." } precondition { diff --git a/modules/runner-config/variables.orchestration-provider.tf b/modules/runner-config/variables.orchestration-provider.tf index 44fa3525fd..4077dc29f7 100644 --- a/modules/runner-config/variables.orchestration-provider.tf +++ b/modules/runner-config/variables.orchestration-provider.tf @@ -3,7 +3,8 @@ variable "orchestration_provider" { description = <<-EOT Runner demand-orchestration provider configuration. Exactly one provider block must be non-null. Wrapper presence selects the provider and must therefore be known during planning; values inside the selected provider may remain unknown until apply. - - `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. Future providers can be added as sibling blocks without moving this contract. + - `webhook`: Selects the workflow-job webhook control plane. It owns runner lifecycle and capacity, the build queue reference, the runner-control artifact, scale-up, scale-down, scheduled pool, and optional job-retry controls. + - `scale_set`: Selects scale-set orchestration for this runner config. The multi-runner topology owns the shared controller service and passes this plan-known selection marker to runner-config. Scale-set runners always use ephemeral JIT registration. - `webhook.runner`: Runner lifecycle, boot timeout, and capacity settings owned by webhook orchestration. - `webhook.runner.boot_time_in_minutes`: Expected runner boot duration used by scale-down and pool controls. The default is `5`. - `webhook.runner.ephemeral`: Registers runners in ephemeral mode. The default is `false`. @@ -30,7 +31,6 @@ variable "orchestration_provider" { - `webhook.lambda.scale.down.timeout`: Scale-down Lambda timeout in seconds. The default is `60`. - `webhook.lambda.scale.down.schedule_expression`: EventBridge schedule expression that invokes scale-down. The default is `cron(*/5 * * * ? *)`. - `webhook.lambda.scale.down.minimum_running_time_in_minutes`: Optional minimum runner age before scale-down may terminate it. The default is null, which selects the operating-system default. - - `webhook.lambda.scale.down.idle_confirmation_seconds`: Number of seconds a runner must consistently report not-busy before scale-down terminates it. The default is `0`, which preserves the single-reading behavior. - `webhook.lambda.scale.down.idle_config`: Time-based desired idle-runner configurations. The default is `[]`. - `webhook.lambda.scale.down.idle_config[].cron`: Cron expression identifying when the idle configuration applies. - `webhook.lambda.scale.down.idle_config[].timeZone`: IANA time zone used to evaluate the cron expression. @@ -100,7 +100,6 @@ variable "orchestration_provider" { timeout = optional(number, 60) schedule_expression = optional(string, "cron(*/5 * * * ? *)") minimum_running_time_in_minutes = optional(number, null) - idle_confirmation_seconds = optional(number, 0) idle_config = optional(list(object({ cron = string timeZone = string @@ -137,6 +136,14 @@ variable "orchestration_provider" { }), {}) }), {}) }), null) + scale_set = optional(object({ + name = string + runner = optional(object({ + min_runners = optional(number, 0) + max_runners = optional(number, 10) + boot_time_in_minutes = optional(number, 10) + }), {}) + }), null) }) nullable = false diff --git a/modules/runner-config/variables.tf b/modules/runner-config/variables.tf index 2273dc4769..5928693ade 100644 --- a/modules/runner-config/variables.tf +++ b/modules/runner-config/variables.tf @@ -75,23 +75,18 @@ variable "github" { description = <<-EOT GitHub API and runner-registration configuration. - - `app_parameters.key_base64`: Parameter Store reference for the primary GitHub App private key. - - `app_parameters.id`: Parameter Store reference for the primary GitHub App ID. - - `app_parameters.additional_apps_manifest`: Optional Parameter Store reference containing the additional GitHub App manifest. - - `app_parameters.additional_app_parameter_arns`: ARNs of the additional GitHub App credential parameters. + - `app_parameters.key_base64`: Ordered Parameter Store references for GitHub App private keys. + - `app_parameters.id`: Ordered Parameter Store references for GitHub App IDs. + - `app_parameters.installation_id`: Ordered optional Parameter Store references for GitHub App installation IDs. - `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com. - `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server requests. - `user_agent`: Optional User-Agent value added to GitHub API requests. EOT type = object({ app_parameters = object({ - key_base64 = map(string) - id = map(string) - additional_apps_manifest = optional(object({ - name = string - arn = string - }), null) - additional_app_parameter_arns = optional(list(string), []) + key_base64 = list(map(string)) + id = list(map(string)) + installation_id = list(object({ name = string, arn = string })) }) enterprise_server = optional(object({ url = optional(string, null) diff --git a/modules/runner-config/versions.tf b/modules/runner-config/versions.tf index 0bedc91fd5..3ef011ea0a 100644 --- a/modules/runner-config/versions.tf +++ b/modules/runner-config/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.4.0" required_providers { aws = { diff --git a/modules/runners/README.md b/modules/runners/README.md index 4e4f650bc4..627b7b62c9 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -52,7 +52,7 @@ yarn run dist | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/runners/job-retry/README.md b/modules/runners/job-retry/README.md index 88ec3bdde0..57c6d9dc91 100644 --- a/modules/runners/job-retry/README.md +++ b/modules/runners/job-retry/README.md @@ -12,7 +12,7 @@ The module is an inner module and used by the runner module when the opt-in feat | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers diff --git a/modules/runners/job-retry/versions.tf b/modules/runners/job-retry/versions.tf index 1238b79cc3..42a40b33fd 100644 --- a/modules/runners/job-retry/versions.tf +++ b/modules/runners/job-retry/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/runners/pool/README.md b/modules/runners/pool/README.md index 7bb73a5a58..54b85d968e 100644 --- a/modules/runners/pool/README.md +++ b/modules/runners/pool/README.md @@ -10,7 +10,7 @@ The pool is an opt-in feature. To be able to use the count on a module level to | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 0.14.1 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers diff --git a/modules/runners/pool/versions.tf b/modules/runners/pool/versions.tf index 1238b79cc3..bceee0424e 100644 --- a/modules/runners/pool/versions.tf +++ b/modules/runners/pool/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 0.14.1" required_providers { aws = { diff --git a/modules/runners/templates/start-runner.sh b/modules/runners/templates/start-runner.sh index b2c6efed1a..7f2c0f82c5 100644 --- a/modules/runners/templates/start-runner.sh +++ b/modules/runners/templates/start-runner.sh @@ -95,7 +95,7 @@ cleanup() { if [ "$exit_code" -ne 0 ]; then echo "ERROR: runner-start-failed with exit code $exit_code occurred on $error_location" - create_xray_error_segment "$${SEGMENT:-}" "runner-start-failed with exit code $exit_code occurred on $error_location - $error_lineno" + create_xray_error_segment "$SEGMENT" "runner-start-failed with exit code $exit_code occurred on $error_location - $error_lineno" fi # allows to flush the cloud watch logs and traces sleep 10 @@ -260,7 +260,7 @@ if [[ "$enable_jit_config" == "false" || $agent_mode != "ephemeral" ]]; then tag_instance_with_runner_id fi -create_xray_success_segment "$${SEGMENT:-}" +create_xray_success_segment "$SEGMENT" if [[ $agent_mode = "ephemeral" ]]; then echo "Starting the runner in ephemeral mode" diff --git a/modules/runners/versions.tf b/modules/runners/versions.tf index 0bedc91fd5..da9769f550 100644 --- a/modules/runners/versions.tf +++ b/modules/runners/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/setup-iam-permissions/README.md b/modules/setup-iam-permissions/README.md index f5a8831cb6..f2401278c0 100644 --- a/modules/setup-iam-permissions/README.md +++ b/modules/setup-iam-permissions/README.md @@ -41,7 +41,7 @@ Next execute the created Terraform code via `terraform init && terraform apply`. | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers diff --git a/modules/setup-iam-permissions/versions.tf b/modules/setup-iam-permissions/versions.tf index 1238b79cc3..42a40b33fd 100644 --- a/modules/setup-iam-permissions/versions.tf +++ b/modules/setup-iam-permissions/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/ssm/README.md b/modules/ssm/README.md index e35c340071..2750e04f0e 100644 --- a/modules/ssm/README.md +++ b/modules/ssm/README.md @@ -9,7 +9,7 @@ This module is used for storing configuration of runners, registration tokens an | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers @@ -31,6 +31,7 @@ No modules. | [aws_ssm_parameter.additional_github_app_key_base64](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.additional_github_apps_manifest](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.github_app_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.github_app_installation_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.github_app_key_base64](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.github_app_webhook_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | @@ -39,7 +40,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for distributing API rate limit usage. |
list(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({ arn = string, name = string }))
id = optional(string)
id_ssm = optional(object({ arn = string, name = string }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | -| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | +| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
installation_id = optional(string)
installation_id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | | [kms\_key\_arn](#input\_kms\_key\_arn) | Optional CMK Key ARN to be used for Parameter Store. | `string` | `null` | no | | [path\_prefix](#input\_path\_prefix) | The path prefix used for naming resources | `string` | n/a | yes | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no | diff --git a/modules/ssm/outputs.tf b/modules/ssm/outputs.tf index e1afaf990e..f37b28d939 100644 --- a/modules/ssm/outputs.tf +++ b/modules/ssm/outputs.tf @@ -8,6 +8,10 @@ output "parameters" { name = var.github_app.key_base64_ssm != null ? var.github_app.key_base64_ssm.name : aws_ssm_parameter.github_app_key_base64[0].name arn = var.github_app.key_base64_ssm != null ? var.github_app.key_base64_ssm.arn : aws_ssm_parameter.github_app_key_base64[0].arn } + github_app_installation_id = var.github_app.installation_id_ssm != null || var.github_app.installation_id != null ? { + name = var.github_app.installation_id_ssm != null ? var.github_app.installation_id_ssm.name : aws_ssm_parameter.github_app_installation_id[0].name + arn = var.github_app.installation_id_ssm != null ? var.github_app.installation_id_ssm.arn : aws_ssm_parameter.github_app_installation_id[0].arn + } : null github_app_webhook_secret = { name = var.github_app.webhook_secret_ssm != null ? var.github_app.webhook_secret_ssm.name : aws_ssm_parameter.github_app_webhook_secret[0].name arn = var.github_app.webhook_secret_ssm != null ? var.github_app.webhook_secret_ssm.arn : aws_ssm_parameter.github_app_webhook_secret[0].arn diff --git a/modules/ssm/ssm.tf b/modules/ssm/ssm.tf index 9467a136e5..ab8a406f2d 100644 --- a/modules/ssm/ssm.tf +++ b/modules/ssm/ssm.tf @@ -16,6 +16,15 @@ resource "aws_ssm_parameter" "github_app_key_base64" { tags = var.tags } +resource "aws_ssm_parameter" "github_app_installation_id" { + count = var.github_app.installation_id_ssm != null || var.github_app.installation_id == null ? 0 : 1 + name = "${var.path_prefix}/github_app_installation_id" + type = "SecureString" + value = var.github_app.installation_id + key_id = local.kms_key_arn + tags = var.tags +} + resource "aws_ssm_parameter" "github_app_webhook_secret" { count = var.github_app.webhook_secret_ssm != null ? 0 : 1 name = "${var.path_prefix}/github_app_webhook_secret" diff --git a/modules/ssm/variables.tf b/modules/ssm/variables.tf index d7387ecc30..d1c0f41f36 100644 --- a/modules/ssm/variables.tf +++ b/modules/ssm/variables.tf @@ -17,6 +17,11 @@ variable "github_app" { arn = string name = string })) + installation_id = optional(string) + installation_id_ssm = optional(object({ + arn = string + name = string + })) webhook_secret = optional(string) webhook_secret_ssm = optional(object({ arn = string diff --git a/modules/ssm/versions.tf b/modules/ssm/versions.tf index 1238b79cc3..42a40b33fd 100644 --- a/modules/ssm/versions.tf +++ b/modules/ssm/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/termination-watcher/README.md b/modules/termination-watcher/README.md index 89a488658a..4cdf37f13b 100644 --- a/modules/termination-watcher/README.md +++ b/modules/termination-watcher/README.md @@ -60,7 +60,7 @@ yarn run dist | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers diff --git a/modules/termination-watcher/notification/README.md b/modules/termination-watcher/notification/README.md index c6feff297f..31df74586f 100644 --- a/modules/termination-watcher/notification/README.md +++ b/modules/termination-watcher/notification/README.md @@ -3,7 +3,7 @@ | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers @@ -42,4 +42,4 @@ | Name | Description | |------|-------------| | [lambda](#output\_lambda) | n/a | - + \ No newline at end of file diff --git a/modules/termination-watcher/notification/versions.tf b/modules/termination-watcher/notification/versions.tf index 1238b79cc3..42a40b33fd 100644 --- a/modules/termination-watcher/notification/versions.tf +++ b/modules/termination-watcher/notification/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/termination-watcher/termination/README.md b/modules/termination-watcher/termination/README.md index 77d37d91aa..32b32aa54e 100644 --- a/modules/termination-watcher/termination/README.md +++ b/modules/termination-watcher/termination/README.md @@ -3,7 +3,7 @@ | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | ## Providers @@ -39,4 +39,4 @@ | Name | Description | |------|-------------| | [lambda](#output\_lambda) | n/a | - + \ No newline at end of file diff --git a/modules/termination-watcher/termination/versions.tf b/modules/termination-watcher/termination/versions.tf index 1238b79cc3..42a40b33fd 100644 --- a/modules/termination-watcher/termination/versions.tf +++ b/modules/termination-watcher/termination/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/termination-watcher/versions.tf b/modules/termination-watcher/versions.tf index 1238b79cc3..42a40b33fd 100644 --- a/modules/termination-watcher/versions.tf +++ b/modules/termination-watcher/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/webhook-github-app/README.md b/modules/webhook-github-app/README.md index 66505f2ecb..6de85ee30d 100644 --- a/modules/webhook-github-app/README.md +++ b/modules/webhook-github-app/README.md @@ -11,7 +11,7 @@ This module updates the GitHub App webhook with the endpoint and secret and can | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [null](#requirement\_null) | ~> 3 | ## Providers diff --git a/modules/webhook-github-app/versions.tf b/modules/webhook-github-app/versions.tf index ad5dce45d4..e0632ba7df 100644 --- a/modules/webhook-github-app/versions.tf +++ b/modules/webhook-github-app/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { null = { diff --git a/modules/webhook/README.md b/modules/webhook/README.md index f6a752da2c..70121458a7 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -35,7 +35,7 @@ yarn run dist | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [null](#requirement\_null) | ~> 3 | diff --git a/modules/webhook/direct/README.md b/modules/webhook/direct/README.md index e4735db39d..d639ed6398 100644 --- a/modules/webhook/direct/README.md +++ b/modules/webhook/direct/README.md @@ -3,7 +3,7 @@ | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [null](#requirement\_null) | ~> 3.2 | @@ -48,4 +48,4 @@ No modules. |------|-------------| | [webhook](#output\_webhook) | n/a | | [webhook\_lambda\_function](#output\_webhook\_lambda\_function) | n/a | - + \ No newline at end of file diff --git a/modules/webhook/direct/versions.tf b/modules/webhook/direct/versions.tf index 98ffc58edc..82776fc618 100644 --- a/modules/webhook/direct/versions.tf +++ b/modules/webhook/direct/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/webhook/eventbridge/README.md b/modules/webhook/eventbridge/README.md index 1159745b4f..07aa0bdd61 100644 --- a/modules/webhook/eventbridge/README.md +++ b/modules/webhook/eventbridge/README.md @@ -3,7 +3,7 @@ | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.21 | | [null](#requirement\_null) | ~> 3.2 | @@ -63,4 +63,4 @@ No modules. | [dispatcher](#output\_dispatcher) | n/a | | [eventbridge](#output\_eventbridge) | n/a | | [webhook](#output\_webhook) | n/a | - + \ No newline at end of file diff --git a/modules/webhook/eventbridge/versions.tf b/modules/webhook/eventbridge/versions.tf index 98ffc58edc..82776fc618 100644 --- a/modules/webhook/eventbridge/versions.tf +++ b/modules/webhook/eventbridge/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/modules/webhook/versions.tf b/modules/webhook/versions.tf index 651075f9ce..e864c4f9ed 100644 --- a/modules/webhook/versions.tf +++ b/modules/webhook/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/scripts/migrate_multi_runner_state.py b/scripts/migrate_multi_runner_state.py deleted file mode 100644 index 065bf0c72c..0000000000 --- a/scripts/migrate_multi_runner_state.py +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env python3 -"""Move v1 multi-runner state into the v2 runner-config topology. - -The migration mapping table contains relative, unkeyed mappings. The actual -state contains one module instance per dynamic multi-runner key, for example: - - module.runners["large"].aws_iam_role.runner[0] - -This script expands every mapping for every key found in the current state and -then optionally runs state mv. It is deliberately a dry run unless --apply -is supplied. -""" - -from __future__ import annotations - -import argparse -import os -import re -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Iterable - - -MODULE_KEY_RE = re.compile(r'module\.runners(\["(?:\\.|[^"])*"\])') -INSTANCE_SUFFIX_RE = r'(?P(?:\[[^]]+\])*)$' -MULTI_RUNNER_MODULE_PREFIX = "module.runners." - -# These are the relative addresses from the v1 module call to the v2 module -# call. The runner key is inserted after module.runners/module.runner_configs -# at runtime because it is a user-controlled for_each key. -MIGRATION_MAPPINGS = ( - ('module.runners.aws_iam_role.runner', 'module.runner_configs.aws_iam_role.runner'), - ('module.runners.aws_ssm_parameter.runner_agent_mode', 'module.runner_configs.aws_ssm_parameter.runner_agent_mode'), - ('module.runners.aws_ssm_parameter.disable_default_labels', 'module.runner_configs.aws_ssm_parameter.disable_default_labels'), - ('module.runners.aws_ssm_parameter.jit_config_enabled', 'module.runner_configs.aws_ssm_parameter.jit_config_enabled'), - ('module.runners.aws_ssm_parameter.token_path', 'module.runner_configs.aws_ssm_parameter.token_path'), - ('module.runners.aws_iam_policy.ami_id_ssm_parameter_read', 'module.runner_configs.module.compute_aws_ec2[0].aws_iam_policy.ami_id_ssm_parameter_read'), - ('module.runners.aws_iam_instance_profile.runner', 'module.runner_configs.module.compute_aws_ec2[0].aws_iam_instance_profile.runner'), - ('module.runners.aws_ssm_parameter.cloudwatch_agent_config_runner', 'module.runner_configs.module.compute_aws_ec2[0].aws_ssm_parameter.cloudwatch_agent_config_runner'), - ('module.runners.aws_cloudwatch_log_group.gh_runners', 'module.runner_configs.module.compute_aws_ec2[0].aws_cloudwatch_log_group.gh_runners'), - ('module.runners.aws_iam_role_policy.cloudwatch[0]', 'module.runner_configs.aws_iam_role_policy.runner_provider["cloudwatch"]'), - ('module.runners.aws_ssm_parameter.runner_ami_id', 'module.runner_configs.module.compute_aws_ec2[0].aws_ssm_parameter.runner_ami_id'), - ('module.runners.aws_launch_template.runner', 'module.runner_configs.module.compute_aws_ec2[0].aws_launch_template.runner'), - ('module.runners.aws_security_group.runner_sg', 'module.runner_configs.module.compute_aws_ec2[0].aws_security_group.runner_sg'), - ('module.runners.aws_ssm_parameter.runner_config_run_as', 'module.runner_configs.module.compute_aws_ec2[0].aws_ssm_parameter.runner_config_run_as'), - ('module.runners.aws_ssm_parameter.runner_enable_cloudwatch', 'module.runner_configs.module.compute_aws_ec2[0].aws_ssm_parameter.runner_enable_cloudwatch'), - ('module.runners.aws_iam_role_policy.runner_session_manager_aws_managed[0]', 'module.runner_configs.aws_iam_role_policy.runner_provider["session_manager"]'), - ('module.runners.aws_iam_role_policy.ssm_parameters[0]', 'module.runner_configs.aws_iam_role_policy.runner_provider["ssm_parameters"]'), - ('module.runners.aws_iam_role_policy.dist_bucket[0]', 'module.runner_configs.aws_iam_role_policy.runner_provider["distribution_bucket"]'), - ('module.runners.aws_iam_role_policy.describe_tags[0]', 'module.runner_configs.aws_iam_role_policy.runner_provider["describe_tags"]'), - ('module.runners.aws_iam_role_policy.create_tag[0]', 'module.runner_configs.aws_iam_role_policy.runner_provider["create_tags"]'), - ('module.runners.aws_iam_role_policy.ec2[0]', 'module.runner_configs.aws_iam_role_policy.runner_provider["terminate_self"]'), - ('module.runners.aws_iam_role_policy_attachment.xray_tracing[0]', 'module.runner_configs.aws_iam_role_policy_attachment.runner["xray"]'), - ('module.runners.module.pool[0].aws_lambda_function.pool', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_lambda_function.pool'), - ('module.runners.module.pool[0].aws_cloudwatch_log_group.pool', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_cloudwatch_log_group.pool'), - ('module.runners.module.pool[0].aws_iam_role.pool', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_iam_role.pool'), - ('module.runners.module.pool[0].aws_iam_role_policy.pool', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_iam_role_policy.pool'), - ('module.runners.module.pool[0].aws_iam_role_policy.pool_logging', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_iam_role_policy.pool_logging'), - ('module.runners.module.pool[0].aws_iam_role_policy_attachment.pool_vpc_execution_role', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_iam_role_policy_attachment.pool_vpc_execution_role'), - ('module.runners.module.pool[0].aws_iam_role_policy_attachment.ami_id_ssm_parameter_read', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_iam_role_policy_attachment.provider'), - ('module.runners.module.pool[0].aws_iam_role_policy.pool_xray', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_iam_role_policy.pool_xray'), - ('module.runners.module.pool[0].aws_scheduler_schedule_group.pool', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_scheduler_schedule_group.pool'), - ('module.runners.module.pool[0].aws_iam_role.scheduler', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_iam_role.scheduler'), - ('module.runners.module.pool[0].aws_iam_role_policy.scheduler', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_iam_role_policy.scheduler'), - ('module.runners.module.pool[0].aws_scheduler_schedule.pool', 'module.runner_configs.module.orchestration_webhook[0].module.pool[0].aws_scheduler_schedule.pool'), - ('module.runners.aws_lambda_function.scale_up', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_lambda_function.scale_up'), - ('module.runners.aws_cloudwatch_log_group.scale_up', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_cloudwatch_log_group.scale_up'), - ('module.runners.aws_lambda_event_source_mapping.scale_up', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_lambda_event_source_mapping.scale_up'), - ('module.runners.aws_lambda_permission.scale_runners_lambda', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_lambda_permission.scale_runners_lambda'), - ('module.runners.aws_iam_role.scale_up', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role.scale_up'), - ('module.runners.aws_iam_role_policy.scale_up', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy.scale_up'), - ('module.runners.aws_iam_role_policy.scale_up_logging', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy.scale_up_logging'), - ('module.runners.aws_iam_role_policy.service_linked_role', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy.service_linked_role'), - ('module.runners.aws_iam_role_policy_attachment.scale_up_vpc_execution_role', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy_attachment.scale_up_vpc_execution_role'), - ('module.runners.aws_iam_role_policy_attachment.ami_id_ssm_parameter_read', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy_attachment.provider'), - ('module.runners.aws_iam_role_policy.scale_up_xray', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy.scale_up_xray'), - ('module.runners.aws_iam_role_policy.job_retry_sqs_publish', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy.job_retry_sqs_publish'), - ('module.runners.aws_lambda_function.scale_down', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_lambda_function.scale_down'), - ('module.runners.aws_cloudwatch_log_group.scale_down', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_cloudwatch_log_group.scale_down'), - ('module.runners.aws_cloudwatch_event_rule.scale_down', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_cloudwatch_event_rule.scale_down'), - ('module.runners.aws_cloudwatch_event_target.scale_down', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_cloudwatch_event_target.scale_down'), - ('module.runners.aws_lambda_permission.scale_down', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_lambda_permission.scale_down'), - ('module.runners.aws_iam_role.scale_down', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role.scale_down'), - ('module.runners.aws_iam_role_policy.scale_down', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy.scale_down'), - ('module.runners.aws_iam_role_policy.scale_down_logging', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy.scale_down_logging'), - ('module.runners.aws_iam_role_policy_attachment.scale_down_vpc_execution_role', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy_attachment.scale_down_vpc_execution_role'), - ('module.runners.aws_iam_role_policy.scale_down_xray', 'module.runner_configs.module.orchestration_webhook[0].module.scale_runners.aws_iam_role_policy.scale_down_xray'), - ('module.runners.module.job_retry[0].aws_sqs_queue_policy.job_retry_check_queue_policy', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_sqs_queue_policy.job_retry_check_queue_policy'), - ('module.runners.module.job_retry[0].aws_sqs_queue.job_retry_check_queue', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_sqs_queue.job_retry_check_queue'), - ('module.runners.module.job_retry[0].module.job_retry.aws_lambda_function.main', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_lambda_function.job_retry'), - ('module.runners.module.job_retry[0].module.job_retry.aws_cloudwatch_log_group.main', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_cloudwatch_log_group.job_retry'), - ('module.runners.module.job_retry[0].module.job_retry.aws_iam_role.main', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_iam_role.job_retry'), - ('module.runners.module.job_retry[0].module.job_retry.aws_iam_role_policy.lambda_logging', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_iam_role_policy.job_retry_logging'), - ('module.runners.module.job_retry[0].module.job_retry.aws_iam_role_policy_attachment.vpc_execution_role', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_iam_role_policy_attachment.job_retry_vpc_execution_role'), - ('module.runners.module.job_retry[0].module.job_retry.aws_iam_role_policy.xray', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_iam_role_policy.job_retry_xray'), - ('module.runners.module.job_retry[0].aws_lambda_event_source_mapping.job_retry', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_lambda_event_source_mapping.job_retry'), - ('module.runners.module.job_retry[0].aws_lambda_permission.job_retry', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_lambda_permission.job_retry'), - ('module.runners.module.job_retry[0].aws_iam_role_policy.job_retry', 'module.runner_configs.module.orchestration_webhook[0].module.job_retry[0].aws_iam_role_policy.job_retry'), - ('module.runners.aws_lambda_function.ssm_housekeeper', 'module.runner_configs.module.ssm_housekeeper.aws_lambda_function.ssm_housekeeper'), - ('module.runners.aws_cloudwatch_log_group.ssm_housekeeper', 'module.runner_configs.module.ssm_housekeeper.aws_cloudwatch_log_group.ssm_housekeeper'), - ('module.runners.aws_cloudwatch_event_rule.ssm_housekeeper', 'module.runner_configs.module.ssm_housekeeper.aws_cloudwatch_event_rule.ssm_housekeeper'), - ('module.runners.aws_cloudwatch_event_target.ssm_housekeeper', 'module.runner_configs.module.ssm_housekeeper.aws_cloudwatch_event_target.ssm_housekeeper'), - ('module.runners.aws_lambda_permission.ssm_housekeeper', 'module.runner_configs.module.ssm_housekeeper.aws_lambda_permission.ssm_housekeeper'), - ('module.runners.aws_iam_role.ssm_housekeeper', 'module.runner_configs.module.ssm_housekeeper.aws_iam_role.ssm_housekeeper'), - ('module.runners.aws_iam_role_policy.ssm_housekeeper', 'module.runner_configs.module.ssm_housekeeper.aws_iam_role_policy.ssm_housekeeper'), - ('module.runners.aws_iam_role_policy.ssm_housekeeper_logging', 'module.runner_configs.module.ssm_housekeeper.aws_iam_role_policy.ssm_housekeeper_logging'), - ('module.runners.aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role', 'module.runner_configs.module.ssm_housekeeper.aws_iam_role_policy_attachment.ssm_housekeeper_vpc_execution_role'), - ('module.runners.aws_iam_role_policy.ssm_housekeeper_xray', 'module.runner_configs.module.ssm_housekeeper.aws_iam_role_policy.ssm_housekeeper_xray'), -) - -# These resources are outside the dynamic runner-key modules and therefore -# must be moved once, without inserting a runner key into their addresses. -STATIC_MIGRATION_MAPPINGS = ( - ( - 'module.runners.terraform_data.validate_v1[0]', - 'module.runners.terraform_data.validate_v2[0]', - ), -) - - -@dataclass(frozen=True) -class Mapping: - source: str - target: str - - -@dataclass(frozen=True) -class Move: - source: str - target: str - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Expand multi-runner migration mappings for dynamic state keys " - "and optionally run terraform/terragrunt state mv." - ) - ) - parser.add_argument( - "--working-directory", - type=Path, - default=Path.cwd(), - help="Terraform/Terragrunt working directory (default: current directory).", - ) - parser.add_argument( - "--tool", - default="terragrunt", - help="State command to run: terragrunt, terraform, or tofu (default: terragrunt).", - ) - parser.add_argument( - "--apply", - action="store_true", - help="Execute the generated state mv commands. Without this, only a plan is printed.", - ) - parser.add_argument( - "--yes", - action="store_true", - help="Skip the confirmation prompt when --apply is supplied.", - ) - parser.add_argument( - "--backup", - type=Path, - help="Optional path for a state pull backup before any moves are executed.", - ) - return parser.parse_args() - - -def command(tool: str, args: list[str], working_directory: Path) -> str: - completed = subprocess.run( - [tool, *args], - cwd=working_directory, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - if completed.returncode != 0: - details = completed.stderr.strip() or completed.stdout.strip() - raise RuntimeError( - f"{tool} {' '.join(args)} failed with exit status " - f"{completed.returncode}: {details}" - ) - return completed.stdout - - -def state_addresses(tool: str, working_directory: Path) -> list[str]: - output = command(tool, ["state", "list"], working_directory) - return [line.strip() for line in output.splitlines() if line.strip()] - - -def key_refs(addresses: Iterable[str]) -> list[str]: - refs = { - match.group(1) - for address in addresses - for match in MODULE_KEY_RE.finditer(address) - } - return sorted(refs) - - -def keyed_mapping(mapping: Mapping, key_ref: str) -> Mapping: - source = MULTI_RUNNER_MODULE_PREFIX + mapping.source.replace( - "module.runners", f"module.runners{key_ref}", 1 - ) - target = MULTI_RUNNER_MODULE_PREFIX + mapping.target.replace( - "module.runner_configs", f"module.runner_configs{key_ref}", 1 - ) - return Mapping(source, target) - - -def expand_moves(mappings: Iterable[Mapping], addresses: Iterable[str]) -> list[Move]: - addresses = list(addresses) - moves: list[Move] = [] - seen: set[tuple[str, str]] = set() - - for key_ref in key_refs(addresses): - for mapping in mappings: - expanded = keyed_mapping(mapping, key_ref) - pattern = re.compile(re.escape(expanded.source) + INSTANCE_SUFFIX_RE) - for address in addresses: - match = pattern.search(address) - if not match: - continue - prefix = address[: match.start()] - target = f"{prefix}{expanded.target}{match.group('instances')}" - pair = (address, target) - if pair not in seen: - moves.append(Move(address, target)) - seen.add(pair) - return moves - - -def expand_static_moves(mappings: Iterable[Mapping], addresses: Iterable[str]) -> list[Move]: - address_set = set(addresses) - return [ - Move(mapping.source, mapping.target) - for mapping in mappings - if mapping.source in address_set - ] - - -def pull_backup(tool: str, working_directory: Path, path: Path) -> None: - if path.exists(): - raise RuntimeError(f"refusing to overwrite existing backup: {path}") - path.parent.mkdir(parents=True, exist_ok=True) - completed = subprocess.run( - [tool, "state", "pull"], - cwd=working_directory, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - if completed.returncode != 0: - details = completed.stderr.strip() or completed.stdout.strip() - raise RuntimeError(f"{tool} state pull failed: {details}") - path.write_text(completed.stdout, encoding="utf-8") - os.chmod(path, 0o600) - print(f"State backup written to {path}") - - -def main() -> int: - args = parse_args() - working_directory = args.working_directory.resolve() - - if not working_directory.is_dir(): - print(f"working directory not found: {working_directory}", file=sys.stderr) - return 2 - - try: - mappings = [Mapping(source, target) for source, target in MIGRATION_MAPPINGS] - static_mappings = [ - Mapping(source, target) for source, target in STATIC_MIGRATION_MAPPINGS - ] - addresses = state_addresses(args.tool, working_directory) - except (OSError, RuntimeError, ValueError) as error: - print(str(error), file=sys.stderr) - return 2 - - moves = expand_moves(mappings, addresses) - moves.extend(expand_static_moves(static_mappings, addresses)) - address_set = set(addresses) - conflicts = [move for move in moves if move.target in address_set] - - print(f"Found {len(key_refs(addresses))} runner key(s).") - print(f"Found {len(mappings) + len(static_mappings)} migration mapping(s).") - print(f"Generated {len(moves)} state move(s).") - if not moves: - print("No old keyed addresses matched the current state.") - return 1 - - if conflicts: - print("Refusing to continue because target addresses already exist:", file=sys.stderr) - for move in conflicts: - print(f" {move.source} -> {move.target}", file=sys.stderr) - return 2 - - for move in moves: - print(f" {move.source} -> {move.target}") - - if not args.apply: - print("Dry run only. Re-run with --apply after reviewing the mappings.") - return 0 - - if not args.yes: - answer = input("Execute these state moves? Type 'move' to continue: ") - if answer != "move": - print("Aborted.") - return 1 - - try: - if args.backup: - pull_backup(args.tool, working_directory, args.backup.resolve()) - for move in moves: - command(args.tool, ["state", "mv", move.source, move.target], working_directory) - print(f"Moved {move.source} -> {move.target}") - except (OSError, RuntimeError) as error: - print(str(error), file=sys.stderr) - print("Migration stopped. Review state before retrying.", file=sys.stderr) - return 2 - - print("State migration completed. Run the Terraform plan again.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/ministack/README.md b/tests/ministack/README.md index 3e6cdc82fa..706b5157a2 100644 --- a/tests/ministack/README.md +++ b/tests/ministack/README.md @@ -1,7 +1,8 @@ # MiniStack example tests The MiniStack workflow runs the `base`, `prebuilt`, `default`, `ephemeral`, -`multi-runner`, `multi-runner-v2`, and `termination-watcher` examples directly +`multi-runner`, `multi-runner-v2`, `multi-runner-scale-set`, and +`termination-watcher` examples directly with Terraform 1.5.6 and the latest Terraform release, and with OpenTofu 1.11 and the latest OpenTofu release. The examples with input variables get their inputs from their own tfvars files @@ -10,8 +11,8 @@ and uses the configuration checked into the example itself. No override files, setup module, or Terraform fixture configuration is checked in. The helper creates and removes a temporary AMI override for `default` and `ephemeral`, temporary SSM parameters for `multi-runner`, and temporary AMI -fixtures for `multi-runner-v2`. The migration test uses its dedicated -`run-migration-test.sh` lifecycle script. +fixtures for `multi-runner-v2` and `multi-runner-scale-set`. The migration test +uses its dedicated `run-migration-test.sh` lifecycle script. Start MiniStack, set the AWS endpoint and test credentials, then run: @@ -28,6 +29,8 @@ tests/ministack/run-example.sh apply multi-runner # or tests/ministack/run-example.sh apply multi-runner-v2 # or +tests/ministack/run-example.sh apply multi-runner-scale-set +# or tests/ministack/run-example.sh apply termination-watcher ``` @@ -36,8 +39,8 @@ ZIP fixtures in the paths expected by the modules when they are absent, and removes only the files it created. For `prebuilt`, it seeds AMI metadata through MiniStack's AWS-compatible EC2 API, then removes only the resources it created during cleanup. MiniStack v1.5.11 provides the EC2 image behavior needed by the -`default`, `ephemeral`, and `multi-runner` examples, so they are included in -the same lifecycle matrix. +`default`, `ephemeral`, `multi-runner`, and `multi-runner-scale-set` examples, +so they are included in the same lifecycle matrix. ## Webhook and runner lifecycle smoke test diff --git a/tests/ministack/multi-runner-scale-set.tfvars b/tests/ministack/multi-runner-scale-set.tfvars new file mode 100644 index 0000000000..67bc39ea7a --- /dev/null +++ b/tests/ministack/multi-runner-scale-set.tfvars @@ -0,0 +1,50 @@ +environment = "ministack-scale-set" +aws_region = "eu-west-1" + +github_app = { + id = "0" + key_base64 = "ministack-invalid-key" + installation_id = "1" +} + +runner_binaries_enabled = false + +ami = { + "linux-arm64" = { + filter = { + name = ["ministack-scale-set-linux-arm64"] + state = ["available"] + } + owners = ["self"] + } + "linux-x64" = { + filter = { + name = ["ministack-scale-set-linux-x64"] + state = ["available"] + } + owners = ["self"] + } + "linux-scale-set" = { + filter = { + name = ["ministack-scale-set-linux-x64"] + state = ["available"] + } + owners = ["self"] + } + "windows-x64" = { + filter = { + name = ["ministack-scale-set-windows-x64"] + state = ["available"] + } + owners = ["self"] + } +} + +scale_set = { + config_url = "https://github.com/example" + name = "ministack-scale-set" + id = 1 + runner_group_id = 1 + runner_owner = "example" + runner_registration_level = "organization" +} diff --git a/tests/ministack/multi-runner-v2.tfvars b/tests/ministack/multi-runner-v2.tfvars index 0f9c6073fc..de54e291cf 100644 --- a/tests/ministack/multi-runner-v2.tfvars +++ b/tests/ministack/multi-runner-v2.tfvars @@ -6,6 +6,8 @@ github_app = { key_base64 = "ministack-invalid-key" } +runner_binaries_enabled = false + ami = { "linux-arm64" = { filter = { diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index 961b3a8cab..ab16343d95 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -23,7 +23,7 @@ case "$iac_binary" in esac case "$example" in - base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2) + base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | multi-runner-scale-set) use_tfvars=true ;; migration-test) @@ -33,7 +33,7 @@ case "$example" in use_tfvars=false ;; *) - echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, migration-test, termination-watcher" >&2 + echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, multi-runner-scale-set, migration-test, termination-watcher" >&2 exit 64 ;; esac @@ -41,7 +41,7 @@ esac case "$action" in init | plan | apply | destroy) ;; *) - echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|migration-test|termination-watcher} [TFVARS_FILE]" >&2 + echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|multi-runner-scale-set|migration-test|termination-watcher} [TFVARS_FILE]" >&2 exit 64 ;; esac @@ -325,6 +325,11 @@ $lambda_zip" create_ami_fixture "ministack-v2-linux-x64" x86_64 >/dev/null create_ami_fixture "ministack-v2-windows-x64" x86_64 >/dev/null ;; + multi-runner-scale-set) + create_ami_fixture "ministack-scale-set-linux-x64" x86_64 >/dev/null + create_ami_fixture "ministack-scale-set-linux-arm64" arm64 >/dev/null + create_ami_fixture "ministack-scale-set-windows-x64" x86_64 >/dev/null + ;; esac } diff --git a/tests/ministack/run-migration-test.sh b/tests/ministack/run-migration-test.sh deleted file mode 100755 index b5ab9c0786..0000000000 --- a/tests/ministack/run-migration-test.sh +++ /dev/null @@ -1,295 +0,0 @@ -#!/bin/sh - -set -eu - -export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-000000000000}" -export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-test-only}" -export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-eu-west-1}" -export AWS_REGION="${AWS_REGION:-eu-west-1}" -export AWS_ENDPOINT_URL="${AWS_ENDPOINT_URL:-http://localhost:4566}" -export AWS_EC2_METADATA_DISABLED="${AWS_EC2_METADATA_DISABLED:-true}" - -action="${1:-}" -iac_binary="${IAC_BINARY:-terraform}" - -case "$iac_binary" in - terraform | tofu) ;; - *) - echo "Supported IaC binaries are: terraform, tofu" >&2 - exit 64 - ;; -esac - -case "$action" in - init | plan | apply | destroy) ;; - *) - echo "Usage: $0 {init|plan|apply|destroy}" >&2 - exit 64 - ;; -esac - -script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) -source_root=$(CDPATH='' cd -- "$script_dir/../.." && pwd) -example_root="$source_root/examples/migration-test" -expected_lockfile=".terraform.lock.hcl" -if [ "$iac_binary" = tofu ]; then - expected_lockfile="$expected_lockfile.tofu" -fi -lockfile_name="${IAC_LOCK_FILE:-$expected_lockfile}" -if [ "$lockfile_name" != "$expected_lockfile" ]; then - echo "Lock file does not match IaC binary: $lockfile_name (expected $expected_lockfile)" >&2 - exit 64 -fi - -case "$lockfile_name" in - .terraform.lock.hcl | .terraform.lock.hcl.tofu) ;; - *) - echo "Supported lock files are: .terraform.lock.hcl, .terraform.lock.hcl.tofu" >&2 - exit 64 - ;; -esac - -lockfile_backup_dir="" -lambda_fixture_dir="" -lambda_created_paths="" -ami_created_ids="" -migration_state_backup="" -migration_iam_policy_v1_snapshot="" -migration_iam_policy_v2_snapshot="" - -lambda_zip_paths=" -$source_root/lambdas/functions/ami-housekeeper/ami-housekeeper.zip -$source_root/lambdas/functions/control-plane/runners.zip -$source_root/lambdas/functions/gh-agent-syncer/runner-binaries-syncer.zip -$source_root/lambdas/functions/webhook/webhook.zip -$source_root/lambdas/functions/termination-watcher/termination-watcher.zip -" - -ministack_aws() { - aws --endpoint-url "$AWS_ENDPOINT_URL" --region "$AWS_DEFAULT_REGION" "$@" -} - -wait_for_ministack() { - attempts=60 - while ! curl -fsS --max-time 2 "$AWS_ENDPOINT_URL/_ministack/health" >/dev/null 2>&1; do - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "MiniStack did not become ready at $AWS_ENDPOINT_URL." >&2 - exit 70 - fi - sleep 1 - done -} - -prepare_lockfiles() { - lockfile_backup_dir=$(mktemp -d "${TMPDIR:-/tmp}/terraform-aws-github-runner-migration-lock.XXXXXX") - for phase in v1 v2; do - target="$example_root/$phase/.terraform.lock.hcl" - source="$example_root/$phase/$lockfile_name" - if [ "$source" = "$target" ]; then - continue - fi - if [ -f "$target" ]; then - cp "$target" "$lockfile_backup_dir/$phase.lock" - : > "$lockfile_backup_dir/$phase.exists" - fi - cp "$source" "$target" - done -} - -restore_lockfiles() { - if [ -z "$lockfile_backup_dir" ]; then - return - fi - for phase in v1 v2; do - target="$example_root/$phase/.terraform.lock.hcl" - source="$example_root/$phase/$lockfile_name" - if [ "$source" = "$target" ]; then - continue - fi - if [ -f "$lockfile_backup_dir/$phase.exists" ]; then - cp "$lockfile_backup_dir/$phase.lock" "$target" - else - rm -f "$target" - fi - done - rm -rf "$lockfile_backup_dir" - lockfile_backup_dir="" -} - -create_ami_fixture() { - ami_name="$1" - architecture="$2" - ami_id=$(ministack_aws ec2 describe-images \ - --owners self \ - --filters "Name=name,Values=$ami_name" "Name=state,Values=available" \ - --query 'Images[0].ImageId' \ - --output text) - - if [ "$ami_id" = "None" ]; then - ami_id=$(ministack_aws ec2 register-image \ - --name "$ami_name" \ - --description "MiniStack test-only AMI" \ - --architecture "$architecture" \ - --root-device-name /dev/xvda \ - --virtualization-type hvm \ - --image-location alpine:3.20 \ - --query 'ImageId' \ - --output text) - ami_created_ids="$ami_created_ids -$ami_id" - fi -} - -create_ministack_fixtures() { - if ! command -v aws >/dev/null 2>&1; then - echo "AWS CLI is required to seed MiniStack API fixtures." >&2 - exit 69 - fi - if ! command -v zip >/dev/null 2>&1; then - echo "zip is required to create Lambda fixture packages." >&2 - exit 69 - fi - if ! command -v curl >/dev/null 2>&1; then - echo "curl is required to check MiniStack readiness." >&2 - exit 69 - fi - - wait_for_ministack - lambda_fixture_dir=$(mktemp -d "${TMPDIR:-/tmp}/terraform-aws-github-runner-ministack-lambda.XXXXXX") - printf '%s\n' 'exports.handler = async () => ({ statusCode: 200, body: "ministack" });' > "$lambda_fixture_dir/index.js" - (CDPATH='' cd -- "$lambda_fixture_dir" && zip -q ministack-lambda.zip index.js) - - for lambda_zip in $lambda_zip_paths; do - if [ -e "$lambda_zip" ]; then - continue - fi - mkdir -p "$(dirname "$lambda_zip")" - cp "$lambda_fixture_dir/ministack-lambda.zip" "$lambda_zip" - lambda_created_paths="$lambda_created_paths -$lambda_zip" - done - - create_ami_fixture migration-test-linux x86_64 >/dev/null -} - -cleanup() { - restore_lockfiles - for image_id in $ami_created_ids; do - ministack_aws ec2 deregister-image --image-id "$image_id" >/dev/null 2>&1 || true - done - for lambda_zip in $lambda_created_paths; do - rm -f "$lambda_zip" - done - if [ -n "$lambda_fixture_dir" ]; then - rm -rf "$lambda_fixture_dir" - fi - if [ -n "$migration_state_backup" ]; then - rm -f "$migration_state_backup" - fi - if [ -n "$migration_iam_policy_v1_snapshot" ]; then - rm -f "$migration_iam_policy_v1_snapshot" - fi - if [ -n "$migration_iam_policy_v2_snapshot" ]; then - rm -f "$migration_iam_policy_v2_snapshot" - fi -} - -iac_migration_init() { - prepare_lockfiles - "$iac_binary" -chdir="$example_root/v1" init -reconfigure -input=false - "$iac_binary" -chdir="$example_root/v2" init -reconfigure -input=false -} - -iac_migration_example() { - phase="$1" - shift - phase_root="$example_root/$phase" - "$iac_binary" -chdir="$phase_root" "$@" -var-file="$phase_root/$phase.tfvars" -} - -snapshot_migration_iam_policies() { - python3 "$example_root/compare_iam_role_policies.py" snapshot "$1" -} - -compare_migration_iam_policies() { - python3 "$example_root/compare_iam_role_policies.py" compare "$1" "$2" -} - -assert_migration_plan_has_no_infrastructure_changes() { - phase="$1" - phase_root="$example_root/$phase" - plan_file=$(mktemp "${TMPDIR:-/tmp}/migration-test-plan.XXXXXX") - plan_status=0 - if iac_migration_example "$phase" plan -input=false -out="$plan_file"; then - plan_status=0 - else - plan_status=$? - fi - if [ "$plan_status" -ne 0 ] && [ "$plan_status" -ne 2 ]; then - rm -f "$plan_file" - return "$plan_status" - fi - - if "$iac_binary" -chdir="$phase_root" show -json "$plan_file" | - python3 "$example_root/filter_migration_plan.py"; then - rm -f "$plan_file" - return 0 - else - plan_status=$? - rm -f "$plan_file" - return "$plan_status" - fi -} - -assert_migration_plan_is_empty() { - assert_migration_plan_has_no_infrastructure_changes "$1" -} - -run_migration_test() { - iac_migration_init - iac_migration_example v1 apply -auto-approve -input=false - - migration_iam_policy_v1_snapshot=$(mktemp "${TMPDIR:-/tmp}/migration-test-iam-v1.XXXXXX") - snapshot_migration_iam_policies "$migration_iam_policy_v1_snapshot" - - migration_state_backup=$(mktemp "${TMPDIR:-/tmp}/migration-test-state.XXXXXX") - rm -f "$migration_state_backup" - python3 "$source_root/scripts/migrate_multi_runner_state.py" \ - --working-directory "$example_root/v1" \ - --tool "$iac_binary" \ - --backup "$migration_state_backup" \ - --apply \ - --yes - - assert_migration_plan_has_no_infrastructure_changes v2 - iac_migration_example v2 apply -auto-approve -input=false - - migration_iam_policy_v2_snapshot=$(mktemp "${TMPDIR:-/tmp}/migration-test-iam-v2.XXXXXX") - snapshot_migration_iam_policies "$migration_iam_policy_v2_snapshot" - compare_migration_iam_policies "$migration_iam_policy_v1_snapshot" "$migration_iam_policy_v2_snapshot" - - assert_migration_plan_is_empty v2 -} - -trap cleanup EXIT INT TERM - -case "$action" in - init) - iac_migration_init - ;; - plan) - create_ministack_fixtures - iac_migration_init - iac_migration_example v1 plan -input=false - ;; - apply) - create_ministack_fixtures - run_migration_test - ;; - destroy) - create_ministack_fixtures - iac_migration_init - iac_migration_example v2 destroy -auto-approve -input=false - ;; -esac diff --git a/versions.tf b/versions.tf index 224f3e7c76..d60d1772ff 100644 --- a/versions.tf +++ b/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.5.6" + required_version = ">= 1.3.0" required_providers { aws = {