diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 6d91e10c66..e222a3c6ee 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -86,6 +86,13 @@ jobs: "lambda", "multi-runner", "runner-binaries-syncer", + "storage-providers/aws/dynamodb", + "orchestration-providers/webhook", + "orchestration-providers/webhook/job-retry", + "orchestration-providers/webhook/pool", + "orchestration-providers/webhook/scale-runners", + "compute-providers/aws/ec2", + "compute-providers/aws/ec2/trust-policy", "runners", "setup-iam-permissions", "ssm", @@ -215,6 +222,13 @@ jobs: module: - modules/runners - modules/multi-runner + - modules/orchestration-providers/webhook + - modules/orchestration-providers/webhook/job-retry + - modules/orchestration-providers/webhook/pool + - modules/orchestration-providers/webhook/scale-runners + - modules/storage-providers/aws/dynamodb + - modules/compute-providers/aws/ec2 + - modules/compute-providers/aws/ec2/trust-policy defaults: run: working-directory: ${{ matrix.module }} diff --git a/docs/adr/0002-runner-storage-provider-boundary.md b/docs/adr/0002-runner-storage-provider-boundary.md new file mode 100644 index 0000000000..4864388307 --- /dev/null +++ b/docs/adr/0002-runner-storage-provider-boundary.md @@ -0,0 +1,196 @@ +# ADR-0002: Runner Storage Provider Boundary + +## Status + +Proposed + +## Date + +2026-09-08 + +## Context + +Runner operation depends on several kinds of stored data: GitHub App +credentials, webhook secrets, matcher configuration, runner-group mappings, +short-lived runner bootstrap configuration, and runner lifecycle state. The +original implementation stored these values in AWS Systems Manager Parameter +Store (SSM), with parameter names, SecureString handling, cleanup, and IAM +permissions spread across the Lambda and Terraform modules. + +That coupling makes it difficult to provide durable runner inventory and to +change the storage implementation without adding provider-specific branches to +each consumer. It also makes the control plane rely on provider discovery for +runner counts, which is insufficient while a runner is being provisioned or +when a launch succeeds before the rest of its registration flow completes. + +The repository needs a replaceable storage boundary that preserves existing +SSM deployments while allowing an opt-in DynamoDB implementation for the +provider-boundary multi-runner configuration. + +## Decision + +Define provider-neutral storage interfaces for the data used by runner +orchestration and select one storage provider for a deployment. The supported +providers are: + +- `aws_ssm`: the existing default and compatibility path. +- `aws_dynamodb`: the durable, opt-in path for provider-boundary + multi-runner configurations. + +Provider selection is represented by the canonical values `aws_ssm` and +`aws_dynamodb`. An omitted selection resolves to `aws_ssm`. A deployment must +select at most one provider; storage consumers do not silently fall back from +one provider to the other when a credential, permission, or data lookup fails. + +### Provider-neutral contract + +The storage library owns interfaces and provider factories for: + +- GitHub App credentials; +- webhook secrets; +- runner matcher configuration; +- runner configuration creation and one-time consumption; +- runner-group ID caching; and +- runner lifecycle state. + +Control-plane and bootstrap code depends on these interfaces. It does not +construct SSM parameter names or DynamoDB keys. Provider-specific factories are +selected once per Lambda process from the environment and are safe to reuse +within that process. + +Runner bootstrap configuration remains separate from lifecycle state. Bootstrap +configuration contains short-lived or sensitive values and is consumed once; +runner state is durable inventory keyed by the compute resource and records +states such as `provisioning`, `active`, `orphan`, and `terminating`. + +### SSM provider + +The SSM provider retains the established behavior for existing deployments: + +- parameters remain the storage boundary for credentials, secrets, matcher + configuration, runner groups, and runner bootstrap configuration; +- sensitive values use SecureString parameters and existing parameter-store + tagging conventions; +- runner configuration cleanup remains an explicit housekeeper operation; and +- existing stable Terraform inputs continue to translate to the SSM provider. + +SSM does not provide the durable runner-state implementation in this phase. +When state inventory is unavailable, the control plane uses compute-provider +discovery, preserving the existing behavior. + +### DynamoDB provider + +The DynamoDB provider uses two shared tables: + +1. a configuration table for global records and per-runner-entry records; and +2. a runner-state table for durable lifecycle inventory with TTL-based cleanup. + +Records use explicit logical scopes and an `id` so that global data, entry +configuration, runner-group mappings, bootstrap values, and runner state cannot +collide. The provider exposes table names, scopes, TTL settings, and IAM policy +fragments as Terraform capabilities rather than making callers know the table +layout. + +The DynamoDB implementation must enforce the storage contract at the data +operation boundary: + +- one-time bootstrap consumption is conditional and removes the consumed + record; +- lifecycle transitions are conditional so stale workers cannot overwrite a + newer state; +- runner-state records identify the compute provider, compute resource, GitHub + identity when known, owner, runner type, and lifecycle state; and +- IAM policies restrict access with table ARNs and DynamoDB leading-key + conditions. Runner bootstrap access is restricted to the matching compute + resource identity. + +### Terraform capability boundary + +Terraform resolves the selected provider once and passes opaque capabilities to +the webhook orchestration and compute-provider modules. Capabilities include +provider-specific environment variables and IAM policy documents for each +consumer, including the runner bootstrap path. + +The `global_config_storage_provider` input selects the provider for the +provider-boundary configuration. Stable v1 configuration is translated to an +SSM selection, so existing users retain the current backend unless they opt in +to DynamoDB through the provider-boundary configuration. + +The compute provider owns the runner-side capability needed to read bootstrap +configuration. The orchestration provider owns its Lambda resources and +receives only the capabilities it needs. This keeps storage ownership separate +from both compute implementation and orchestration scheduling. + +## Alternatives considered + +### Keep SSM as the only backend + +This preserves the smallest implementation, but does not provide durable +runner inventory or a suitable shared store for the provider-boundary design. + +### Add storage conditionals to every consumer + +This would avoid a factory layer initially, but it would duplicate key +construction, error handling, security rules, and migration behavior across +Lambdas and runner bootstrap code. It would make each new provider more +expensive and easier to implement inconsistently. + +### Use one DynamoDB table for all data + +One table could reduce resource count, but separating configuration from +ephemeral runner state gives the two lifecycles independent TTL, protection, +and access policies. The two-table design also makes accidental access to +runner state from configuration consumers less likely. + +### Migrate existing SSM data automatically + +Automatic migration would require dual writes or a cutover protocol and could +duplicate or lose short-lived bootstrap configuration. Migration is therefore +an explicit operational decision outside provider selection; the default +remains backward compatible with SSM. + +## Consequences + +### Positive + +- Existing stable deployments continue to use SSM without configuration + changes. +- Storage consumers share one provider-neutral contract and do not duplicate + backend logic. +- DynamoDB can provide durable runner inventory and conservative recovery from + launch-before-registration failures. +- Provider-specific IAM conditions and runner bootstrap capabilities can be + reviewed at the Terraform module boundary. +- A future storage provider can implement the same interfaces without changing + orchestration or compute-provider callers. + +### Negative + +- The DynamoDB path adds two tables, TTL behavior, conditional-write logic, + provider-specific IAM, and additional operational cost. +- SSM and DynamoDB have different consistency, cleanup, and failure behavior; + both implementations require provider-specific contract tests. +- Switching an existing deployment does not migrate stored values or active + runner inventory automatically. +- The control plane must retain compute discovery as a recovery source even + when DynamoDB inventory is enabled. + +## Migration and operational rules + +1. Keep `aws_ssm` as the default until a deployment explicitly selects + `aws_dynamodb`. +2. Treat a provider switch as an operational migration with a planned cutover; + do not assume existing SSM records are present in DynamoDB. +3. Keep provider-specific secrets, table names, scopes, and IAM details inside + provider capabilities and environment configuration, not in shared + orchestration code. +4. Add contract tests for every new provider covering reads, writes, + one-time consumption, conditional lifecycle transitions, and authorization + boundaries. + +## References + +- [Storage-provider interfaces and factories](../../lambdas/libs/storage-providers/) +- [DynamoDB storage-provider module](../../modules/storage-providers/aws/dynamodb/) +- [Multi-runner storage-provider composition](../../modules/multi-runner/storage-provider.tf) +- [Compute-provider storage capability contract](../../modules/compute-providers/aws/ec2/variables.tf) diff --git a/mkdocs.yaml b/mkdocs.yaml index 849b9a53dc..e3916290b0 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -60,6 +60,7 @@ nav: - Security: security.md - Architecture decisions: - MiniStack for integration tests: adr/0001-use-ministack-for-terraform-integration-tests.md + - Runner storage provider boundary: adr/0002-runner-storage-provider-boundary.md - Modules: - Runners (main): modules/runners.md - Submodules (public): diff --git a/modules/compute-providers/aws/ec2/README.md b/modules/compute-providers/aws/ec2/README.md index aa2ad83651..7567bd1d2f 100644 --- a/modules/compute-providers/aws/ec2/README.md +++ b/modules/compute-providers/aws/ec2/README.md @@ -67,6 +67,7 @@ No modules. | [prefix](#input\_prefix) | Prefix used to identify resources created for the runner configuration. | `string` | `"github-actions"` | no | | [runner](#input\_runner) | Provider-neutral runner settings consumed by compute providers.

- `os`: Runner operating system. Supported values are `linux`, `osx`, and `windows`.
- `architecture`: Runner distribution architecture.
- `name_prefix`: Prefix added to registered runner names.
- `run_as_root`: Runs the runner service as root.
- `run_as`: Operating-system user used when `run_as_root` is false.
- `hooks.job_started`: Script installed as the runner job-started hook.
- `hooks.job_completed`: Script installed as the runner job-completed hook.
- `iam.role.arn`: Resolved runner-role ARN referenced by provider policies and resources.
- `iam.role.name`: Resolved runner-role name used by provider resources.
- `iam.role.managed`: Whether runner-config manages the resolved runner role.
- `iam.managed_policy_arns`: Common managed-policy ARNs returned with the provider-specific runner policies for attachment by runner-config.
- `iam.path`: IAM path available to provider-managed IAM resources. Null derives the path from `prefix`. |
object({
os = optional(string, "linux")
architecture = optional(string, "x64")
name_prefix = optional(string, "")
run_as_root = optional(bool, false)
run_as = optional(string, "ec2-user")
hooks = optional(object({
job_started = optional(string, "")
job_completed = optional(string, "")
}), {})
iam = object({
role = object({
arn = string
name = string
managed = optional(bool, true)
})
managed_policy_arns = optional(map(string), {})
path = optional(string, null)
})
})
| n/a | yes | | [ssm](#input\_ssm) | Parameter Store paths and tag scopes available to compute-provider bootstrap resources.

- `paths.root`: Root Parameter Store path for the runner configuration.
- `paths.tokens`: Path segment used for registration tokens and just-in-time configuration.
- `paths.config`: Path segment used for persistent runner and provider configuration.
- `tags`: Shared SSM tags that override module-level `tags`.
- `parameters.tags`: Parameter-specific tags that override module-level and shared SSM tags. |
object({
paths = object({
root = string
tokens = string
config = string
})
tags = optional(map(string), {})
parameters = optional(object({
tags = optional(map(string), {})
}), {})
})
| n/a | yes | +| [storage\_provider](#input\_storage\_provider) | Runner-side storage locator and opaque IAM policy supplied by runner-config. The default preserves the existing SSM bootstrap path. |
object({
type = string
runner = object({
config_table_name = optional(string, null)
runner_state_table_name = optional(string, null)
scope = optional(string, null)
iam_policy_json = optional(string, null)
})
})
|
{
"runner": {
"config_table_name": null,
"iam_policy_json": null,
"runner_state_table_name": null,
"scope": null
},
"type": "aws_ssm"
}
| no | | [tags](#input\_tags) | Base tags available to taggable compute-provider resources. Provider-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | ## Outputs diff --git a/modules/compute-providers/aws/ec2/control-plane.tf b/modules/compute-providers/aws/ec2/control-plane.tf index 1b25442032..70caee4225 100644 --- a/modules/compute-providers/aws/ec2/control-plane.tf +++ b/modules/compute-providers/aws/ec2/control-plane.tf @@ -191,7 +191,7 @@ data "aws_iam_policy_document" "service_linked_role" { } locals { - scale_up_environment_variables = { + scale_up_environment_variables = merge({ AMI_ID_SSM_PARAMETER_NAME = local.ami_id_ssm_parameter_name INSTANCE_ALLOCATION_STRATEGY = var.config.instance_allocation_strategy INSTANCE_MAX_SPOT_PRICE = var.config.instance_max_spot_price @@ -203,7 +203,9 @@ locals { ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS = jsonencode(var.config.on_demand_failover_for_errors) SCALE_ERRORS = jsonencode(var.config.scale_errors) USE_DEDICATED_HOST = var.config.use_dedicated_host - } + }, var.storage_provider.type == "aws_dynamodb" ? { + EC2_INSTANCE_ARN_PREFIX = local.ec2_instance_arn_prefix + } : {}) scale_down_environment_variables = {} diff --git a/modules/compute-providers/aws/ec2/policies-runner.tf b/modules/compute-providers/aws/ec2/policies-runner.tf index c16077debc..c4f387846b 100644 --- a/modules/compute-providers/aws/ec2/policies-runner.tf +++ b/modules/compute-providers/aws/ec2/policies-runner.tf @@ -4,6 +4,7 @@ data "aws_caller_identity" "current" {} locals { ssm_parameter_arn_prefix = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter" + ec2_instance_arn_prefix = "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/" ssm_config_arn = "${local.ssm_parameter_arn_prefix}${var.ssm.paths.root}/${var.ssm.paths.config}" cloudwatch_config_arn = "${local.ssm_config_arn}/cloudwatch_agent_config_runner" } @@ -167,10 +168,6 @@ data "aws_iam_policy_document" "cloudwatch" { locals { runner_inline_policies = merge( { - ssm_parameters = { - name = "runner-ssm-parameters" - policy_json = data.aws_iam_policy_document.ssm_parameters.json - } describe_tags = { name = "runner-describe-tags" policy_json = data.aws_iam_policy_document.describe_tags.json @@ -184,6 +181,17 @@ locals { policy_json = data.aws_iam_policy_document.terminate_self.json } }, + var.storage_provider.type == "aws_ssm" ? { + ssm_parameters = { + name = "runner-ssm-parameters" + policy_json = data.aws_iam_policy_document.ssm_parameters.json + } + } : { + runner_config_storage = { + name = "runner-config-storage" + policy_json = var.storage_provider.runner.iam_policy_json + } + }, var.config.ssm_enabled ? { session_manager = { name = "runner-ssm-session" diff --git a/modules/compute-providers/aws/ec2/runner-config.tf b/modules/compute-providers/aws/ec2/runner-config.tf index f1d859581c..f562047a7f 100644 --- a/modules/compute-providers/aws/ec2/runner-config.tf +++ b/modules/compute-providers/aws/ec2/runner-config.tf @@ -1,4 +1,5 @@ resource "aws_ssm_parameter" "runner_config_run_as" { + count = var.storage_provider.type == "aws_ssm" ? 1 : 0 name = "${var.ssm.paths.root}/${var.ssm.paths.config}/run_as" type = "String" value = var.runner.run_as_root ? "root" : var.runner.run_as @@ -6,8 +7,19 @@ resource "aws_ssm_parameter" "runner_config_run_as" { } resource "aws_ssm_parameter" "runner_enable_cloudwatch" { + count = var.storage_provider.type == "aws_ssm" ? 1 : 0 name = "${var.ssm.paths.root}/${var.ssm.paths.config}/enable_cloudwatch" type = "String" value = var.config.cloudwatch_agent.enabled tags = local.ssm_parameter_tags } + +moved { + from = aws_ssm_parameter.runner_config_run_as + to = aws_ssm_parameter.runner_config_run_as[0] +} + +moved { + from = aws_ssm_parameter.runner_enable_cloudwatch + to = aws_ssm_parameter.runner_enable_cloudwatch[0] +} diff --git a/modules/compute-providers/aws/ec2/runner-instances.tf b/modules/compute-providers/aws/ec2/runner-instances.tf index f33ab8f532..9734632c47 100644 --- a/modules/compute-providers/aws/ec2/runner-instances.tf +++ b/modules/compute-providers/aws/ec2/runner-instances.tf @@ -90,7 +90,12 @@ locals { hook_job_started = var.runner.hooks.job_started hook_job_completed = var.runner.hooks.job_completed start_runner = templatefile(local.userdata_start_runner[var.runner.os], { - metadata_tags = var.config.metadata_options != null ? var.config.metadata_options.instance_metadata_tags : "enabled" + metadata_tags = var.config.metadata_options != null ? var.config.metadata_options.instance_metadata_tags : "enabled" + storage_provider_type = var.storage_provider.type + dynamodb_config_table_name_base64 = var.storage_provider.type == "aws_dynamodb" ? base64encode(var.storage_provider.runner.config_table_name) : "" + dynamodb_scope_base64 = var.storage_provider.type == "aws_dynamodb" ? base64encode(var.storage_provider.runner.scope) : "" + ec2_instance_arn_prefix_base64 = var.storage_provider.type == "aws_dynamodb" ? base64encode(local.ec2_instance_arn_prefix) : "" + enable_cloudwatch_agent = var.config.cloudwatch_agent.enabled }) ghes_url = var.github.enterprise_server.url ghes_ssl_verify = var.github.enterprise_server.ssl_verify diff --git a/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh b/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh index a6da66116d..c0a274bb45 100644 --- a/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh +++ b/modules/compute-providers/aws/ec2/templates/start-runner-osx.sh @@ -88,6 +88,37 @@ echo "Retrieved ghr:environment tag - ($environment)" echo "Retrieved ghr:ssm_config_path tag - ($ssm_config_path)" echo "Retrieved ghr:runner_name_prefix tag - ($runner_name_prefix)" +%{ if storage_provider_type == "aws_dynamodb" } +dynamodb_config_table_name=$(printf '%s' "${dynamodb_config_table_name_base64}" | openssl base64 -d -A) +dynamodb_scope=$(printf '%s' "${dynamodb_scope_base64}" | openssl base64 -d -A) +ec2_instance_arn_prefix=$(printf '%s' "${ec2_instance_arn_prefix_base64}" | openssl base64 -d -A) + +echo "Retrieving runner bootstrap configuration from DynamoDB" +runner_config_key=$(jq -cn --arg scope "$dynamodb_scope" '{scope:{S:$scope},id:{S:"runner-config"}}') +runner_config_record=$(aws dynamodb get-item \ + --table-name "$dynamodb_config_table_name" \ + --key "$runner_config_key" \ + --consistent-read \ + --projection-expression "#value" \ + --expression-attribute-names '{"#value":"value"}' \ + --region "$region") +runner_config=$(printf '%s' "$runner_config_record" | jq -er '.Item.value.S | fromjson') +unset runner_config_record + +run_as=$(printf '%s' "$runner_config" | jq -r '.run_as') +agent_mode=$(printf '%s' "$runner_config" | jq -r '.agent_mode') +disable_default_labels=$(printf '%s' "$runner_config" | jq -r '.disable_default_labels') +enable_jit_config=$(printf '%s' "$runner_config" | jq -r '.enable_jit_config') +dynamodb_runner_state_table_name=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.table_name') +dynamodb_access_scope_type=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.access_scope') +dynamodb_config_id=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.id') +if [[ "$dynamodb_access_scope_type" != "compute-resource" ]]; then + echo "Unsupported runner configuration access scope" + exit 1 +fi +dynamodb_access_scope="$${ec2_instance_arn_prefix}$instance_id" +unset runner_config +%{ else } parameters=$(aws ssm get-parameters-by-path \ --path "$ssm_config_path" \ --region "$region" \ @@ -108,7 +139,38 @@ echo "Retrieved /$ssm_config_path/enable_jit_config parameter - ($enable_jit_con token_path=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/token_path") | .Value') echo "Retrieved /$ssm_config_path/token_path parameter - ($token_path)" +%{ endif } + +%{ if storage_provider_type == "aws_dynamodb" } +echo "Retrieving one-time runner configuration from DynamoDB" +runner_state_key=$(jq -cn --arg scope "$dynamodb_access_scope" --arg id "$dynamodb_config_id" '{scope:{S:$scope},id:{S:$id}}') +config="" +retrycount=0 +while [[ -z "$config" ]]; do + now_epoch=$(date +%s) + expression_values=$(jq -cn --arg now "$now_epoch" '{":now":{N:$now}}') + if config_record=$(aws dynamodb delete-item \ + --table-name "$dynamodb_runner_state_table_name" \ + --key "$runner_state_key" \ + --condition-expression "attribute_exists(#expires_at) AND #expires_at > :now" \ + --expression-attribute-names '{"#expires_at":"expires_at"}' \ + --expression-attribute-values "$expression_values" \ + --return-values ALL_OLD \ + --region "$region" 2>/dev/null); then + config=$(printf '%s' "$config_record" | jq -er '.Attributes.value.S') + unset config_record + break + fi + retrycount=$((retrycount + 1)) + if [[ $retrycount -gt 40 ]]; then + echo "Runner configuration was unavailable or expired" + exit 1 + fi + echo "Waiting for runner configuration to become available in DynamoDB" + sleep 1 +done +%{ else } echo "Get GH Runner config from AWS SSM" config=$(aws ssm get-parameter --name "$token_path"/"$instance_id" --with-decryption --region "$region" | jq -r ".Parameter | .Value") while [[ -z "$config" ]]; do @@ -119,6 +181,7 @@ done echo "Delete GH Runner token from AWS SSM" aws ssm delete-parameter --name "$token_path"/"$instance_id" --region "$region" +%{ endif } if [ -z "$run_as" ]; then echo "No user specified, using default ec2-user account" diff --git a/modules/compute-providers/aws/ec2/templates/start-runner.ps1 b/modules/compute-providers/aws/ec2/templates/start-runner.ps1 index ae2eeff3c9..8d88b37e8b 100644 --- a/modules/compute-providers/aws/ec2/templates/start-runner.ps1 +++ b/modules/compute-providers/aws/ec2/templates/start-runner.ps1 @@ -77,6 +77,43 @@ Write-Host "Retrieved ghr:runner_name_prefix tag - ($runner_name_prefix)" $ssm_config_path=$tags.Tags.where( {$_.Key -eq 'ghr:ssm_config_path'}).value Write-Host "Retrieved ghr:ssm_config_path tag - ($ssm_config_path)" +%{ if storage_provider_type == "aws_dynamodb" } +$DynamoDbConfigTableName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${dynamodb_config_table_name_base64}")) +$DynamoDbScope = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${dynamodb_scope_base64}")) +$Ec2InstanceArnPrefix = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${ec2_instance_arn_prefix_base64}")) + +Write-Host "Retrieving runner bootstrap configuration from DynamoDB" +$RunnerConfigKey = @{ + scope = @{ S = $DynamoDbScope } + id = @{ S = "runner-config" } +} | ConvertTo-Json -Compress +$RunnerConfigRecord = aws dynamodb get-item ` + --table-name $DynamoDbConfigTableName ` + --key $RunnerConfigKey ` + --consistent-read ` + --projection-expression "#value" ` + --expression-attribute-names '{"#value":"value"}' ` + --region $Region | ConvertFrom-Json +if ($LASTEXITCODE -ne 0 -or -not $RunnerConfigRecord.Item.value.S) { + throw "Runner bootstrap configuration is unavailable" +} +$RunnerConfig = $RunnerConfigRecord.Item.value.S | ConvertFrom-Json +$RunnerConfigRecord = $null + +$run_as = $RunnerConfig.run_as +$agent_mode = $RunnerConfig.agent_mode +$disable_default_labels = $RunnerConfig.disable_default_labels.ToString().ToLowerInvariant() +$enable_jit_config = $RunnerConfig.enable_jit_config.ToString().ToLowerInvariant() +$enable_cloudwatch_agent = "${enable_cloudwatch_agent}" +$DynamoDbRunnerStateTableName = $RunnerConfig.runner_config_storage.table_name +$DynamoDbAccessScopeType = $RunnerConfig.runner_config_storage.access_scope +$DynamoDbConfigId = $RunnerConfig.runner_config_storage.id +if ($DynamoDbAccessScopeType -ne "compute-resource") { + throw "Unsupported runner configuration access scope" +} +$DynamoDbAccessScope = "$Ec2InstanceArnPrefix$InstanceId" +$RunnerConfig = $null +%{ else } $parameters=$(aws ssm get-parameters-by-path --path "$ssm_config_path" --region "$Region" --query "Parameters[*].{Name:Name,Value:Value}") | ConvertFrom-Json Write-Host "Retrieved parameters from AWS SSM" @@ -97,6 +134,7 @@ Write-Host "Retrieved $ssm_config_path/enable_jit_config parameter - ($enable_j $token_path=$parameters.where( {$_.Name -eq "$ssm_config_path/token_path"}).value Write-Host "Retrieved $ssm_config_path/token_path parameter - ($token_path)" +%{ endif } if ($enable_cloudwatch_agent -eq "true") @@ -107,6 +145,40 @@ if ($enable_cloudwatch_agent -eq "true") ## Configure the runner +%{ if storage_provider_type == "aws_dynamodb" } +Write-Host "Retrieving one-time runner configuration from DynamoDB" +$RunnerStateKey = @{ + scope = @{ S = $DynamoDbAccessScope } + id = @{ S = $DynamoDbConfigId } +} | ConvertTo-Json -Compress +$config = $null +$i = 0 +do { + $NowEpoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString() + $ExpressionValues = @{ ":now" = @{ N = $NowEpoch } } | ConvertTo-Json -Compress + $ConfigRecordRaw = aws dynamodb delete-item ` + --table-name $DynamoDbRunnerStateTableName ` + --key $RunnerStateKey ` + --condition-expression "attribute_exists(#expires_at) AND #expires_at > :now" ` + --expression-attribute-names '{"#expires_at":"expires_at"}' ` + --expression-attribute-values $ExpressionValues ` + --return-values ALL_OLD ` + --region $Region 2>$null + if ($LASTEXITCODE -eq 0) { + $config = ($ConfigRecordRaw | ConvertFrom-Json).Attributes.value.S + $ConfigRecordRaw = $null + break + } + + Write-Host "Waiting for runner configuration to become available in DynamoDB ($i/40)" + Start-Sleep 1 + $i++ +} while (($null -eq $config) -and ($i -lt 40)) + +if ($null -eq $config) { + throw "Runner configuration was unavailable or expired" +} +%{ else } Write-Host "Get GH Runner config from AWS SSM" $config = $null $i = 0 @@ -119,6 +191,7 @@ do { Write-Host "Delete GH Runner token from AWS SSM" aws ssm delete-parameter --name "$token_path/$InstanceId" --region $Region +%{ endif } # Create or update user if (-not($run_as)) { diff --git a/modules/compute-providers/aws/ec2/templates/start-runner.sh b/modules/compute-providers/aws/ec2/templates/start-runner.sh index 7f2c0f82c5..b6533df1dc 100644 --- a/modules/compute-providers/aws/ec2/templates/start-runner.sh +++ b/modules/compute-providers/aws/ec2/templates/start-runner.sh @@ -159,6 +159,38 @@ echo "Retrieved ghr:environment tag - ($environment)" echo "Retrieved ghr:ssm_config_path tag - ($ssm_config_path)" echo "Retrieved ghr:runner_name_prefix tag - ($runner_name_prefix)" +%{ if storage_provider_type == "aws_dynamodb" } +dynamodb_config_table_name=$(printf '%s' "${dynamodb_config_table_name_base64}" | openssl base64 -d -A) +dynamodb_scope=$(printf '%s' "${dynamodb_scope_base64}" | openssl base64 -d -A) +ec2_instance_arn_prefix=$(printf '%s' "${ec2_instance_arn_prefix_base64}" | openssl base64 -d -A) + +echo "Retrieving runner bootstrap configuration from DynamoDB" +runner_config_key=$(jq -cn --arg scope "$dynamodb_scope" '{scope:{S:$scope},id:{S:"runner-config"}}') +runner_config_record=$(aws dynamodb get-item \ + --table-name "$dynamodb_config_table_name" \ + --key "$runner_config_key" \ + --consistent-read \ + --projection-expression "#value" \ + --expression-attribute-names '{"#value":"value"}' \ + --region "$region") +runner_config=$(printf '%s' "$runner_config_record" | jq -er '.Item.value.S | fromjson') +unset runner_config_record + +run_as=$(printf '%s' "$runner_config" | jq -r '.run_as') +agent_mode=$(printf '%s' "$runner_config" | jq -r '.agent_mode') +disable_default_labels=$(printf '%s' "$runner_config" | jq -r '.disable_default_labels') +enable_jit_config=$(printf '%s' "$runner_config" | jq -r '.enable_jit_config') +enable_cloudwatch_agent="${enable_cloudwatch_agent}" +dynamodb_runner_state_table_name=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.table_name') +dynamodb_access_scope_type=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.access_scope') +dynamodb_config_id=$(printf '%s' "$runner_config" | jq -er '.runner_config_storage.id') +if [[ "$dynamodb_access_scope_type" != "compute-resource" ]]; then + echo "Unsupported runner configuration access scope" + exit 1 +fi +dynamodb_access_scope="$${ec2_instance_arn_prefix}$instance_id" +unset runner_config +%{ else } parameters=$(aws ssm get-parameters-by-path --path "$ssm_config_path" --region "$region" --query "Parameters[*].{Name:Name,Value:Value}") echo "Retrieved parameters from AWS SSM ($parameters)" @@ -179,6 +211,7 @@ echo "Retrieved /$ssm_config_path/enable_jit_config parameter - ($enable_jit_con token_path=$(echo "$parameters" | jq --arg ssm_config_path "$ssm_config_path" -r '.[] | select(.Name == "'$ssm_config_path'/token_path") | .Value') echo "Retrieved /$ssm_config_path/token_path parameter - ($token_path)" +%{ endif } if [[ "$xray_trace_id" != "" ]]; then # run xray service @@ -199,6 +232,36 @@ fi ## Configure the runner +%{ if storage_provider_type == "aws_dynamodb" } +echo "Retrieving one-time runner configuration from DynamoDB" +runner_state_key=$(jq -cn --arg scope "$dynamodb_access_scope" --arg id "$dynamodb_config_id" '{scope:{S:$scope},id:{S:$id}}') +config="" +retrycount=0 +while [[ -z "$config" ]]; do + now_epoch=$(date +%s) + expression_values=$(jq -cn --arg now "$now_epoch" '{":now":{N:$now}}') + if config_record=$(aws dynamodb delete-item \ + --table-name "$dynamodb_runner_state_table_name" \ + --key "$runner_state_key" \ + --condition-expression "attribute_exists(#expires_at) AND #expires_at > :now" \ + --expression-attribute-names '{"#expires_at":"expires_at"}' \ + --expression-attribute-values "$expression_values" \ + --return-values ALL_OLD \ + --region "$region" 2>/dev/null); then + config=$(printf '%s' "$config_record" | jq -er '.Attributes.value.S') + unset config_record + break + fi + + retrycount=$((retrycount + 1)) + if [[ $retrycount -gt 40 ]]; then + echo "Runner configuration was unavailable or expired" + exit 1 + fi + echo "Waiting for runner configuration to become available in DynamoDB" + sleep 1 +done +%{ else } echo "Get GH Runner config from AWS SSM" config=$(aws ssm get-parameter --name "$token_path"/"$instance_id" --with-decryption --region "$region" | jq -r ".Parameter | .Value") while [[ -z "$config" ]]; do @@ -209,6 +272,7 @@ done echo "Delete GH Runner token from AWS SSM" aws ssm delete-parameter --name "$token_path"/"$instance_id" --region "$region" +%{ endif } if [ -z "$run_as" ]; then echo "No user specified, using default ec2-user account" diff --git a/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl b/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl index bc92537279..5de6ef2f1c 100644 --- a/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl +++ b/modules/compute-providers/aws/ec2/tests/provider.tftest.hcl @@ -367,11 +367,11 @@ run "separates_provider_runner_and_ssm_tags" { assert { condition = ( - aws_ssm_parameter.runner_config_run_as.tags["Name"] == "ssm-name" - && aws_ssm_parameter.runner_config_run_as.tags["Scope"] == "ssm" - && aws_ssm_parameter.runner_config_run_as.tags["SsmOnly"] == "ssm" - && !contains(keys(aws_ssm_parameter.runner_config_run_as.tags), "RunnerOnly") - && !contains(keys(aws_ssm_parameter.runner_config_run_as.tags), "ghr:environment") + aws_ssm_parameter.runner_config_run_as[0].tags["Name"] == "ssm-name" + && aws_ssm_parameter.runner_config_run_as[0].tags["Scope"] == "ssm" + && aws_ssm_parameter.runner_config_run_as[0].tags["SsmOnly"] == "ssm" + && !contains(keys(aws_ssm_parameter.runner_config_run_as[0].tags), "RunnerOnly") + && !contains(keys(aws_ssm_parameter.runner_config_run_as[0].tags), "ghr:environment") ) error_message = "EC2 SSM parameters must merge SSM component tags over provider tags." } @@ -449,3 +449,77 @@ run "requires_distribution_object_when_sync_is_enabled" { expect_failures = [terraform_data.validate_config] } + +run "dynamodb_bootstrap_is_opt_in_and_compute_scoped" { + command = plan + + variables { + config = { + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-12345678"] + instance_types = ["m5.large"] + ami = { + filter = { state = ["available"] } + owners = ["amazon"] + id_ssm_parameter = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/ami-id" + } + kms_key = null + } + binaries_syncer = { + enabled = false + } + cloudwatch_agent = { + enabled = false + } + managed_security_group_enabled = true + } + + runner = { + os = "windows" + architecture = "x64" + iam = { + role = { + arn = "arn:aws:iam::123456789012:role/provider-test-runner" + name = "provider-test-runner" + } + } + } + + storage_provider = { + type = "aws_dynamodb" + runner = { + config_table_name = "provider-test-config" + runner_state_table_name = "provider-test-runner-state" + scope = "entry#unsafe-$(value)#bootstrap" + iam_policy_json = jsonencode({ Version = "2012-10-17", Statement = [] }) + } + } + } + + assert { + condition = ( + length(aws_ssm_parameter.runner_config_run_as) == 0 + && length(aws_ssm_parameter.runner_enable_cloudwatch) == 0 + && contains(keys(output.provider.policies.runner.inline_policies), "runner_config_storage") + && !contains(keys(output.provider.policies.runner.inline_policies), "ssm_parameters") + && output.provider.environment_variables.scale_up["EC2_INSTANCE_ARN_PREFIX"] == "arn:aws:ec2:eu-west-1:123456789012:instance/" + && output.provider.environment_variables.pool["EC2_INSTANCE_ARN_PREFIX"] == "arn:aws:ec2:eu-west-1:123456789012:instance/" + ) + error_message = "DynamoDB-selected EC2 runners must replace SSM bootstrap resources and expose the matching compute-resource ARN prefix." + } + + assert { + condition = ( + strcontains(base64decode(aws_launch_template.runner.user_data), base64encode("provider-test-config")) + && strcontains(base64decode(aws_launch_template.runner.user_data), base64encode("entry#unsafe-$(value)#bootstrap")) + && strcontains(base64decode(aws_launch_template.runner.user_data), base64encode("arn:aws:ec2:eu-west-1:123456789012:instance/")) + && !strcontains(base64decode(aws_launch_template.runner.user_data), "entry#unsafe-$(value)#bootstrap") + && strcontains(base64decode(aws_launch_template.runner.user_data), "aws dynamodb delete-item") + && strcontains(base64decode(aws_launch_template.runner.user_data), "attribute_exists(#expires_at) AND #expires_at > :now") + && strcontains(base64decode(aws_launch_template.runner.user_data), "--return-values ALL_OLD") + && !strcontains(base64decode(aws_launch_template.runner.user_data), "aws ssm get-parameters --names") + ) + error_message = "DynamoDB bootstrap must base64-embed user-controlled locators and atomically consume only an unexpired per-instance record." + } +} diff --git a/modules/compute-providers/aws/ec2/variables.tf b/modules/compute-providers/aws/ec2/variables.tf index da04a3b37c..076c019876 100644 --- a/modules/compute-providers/aws/ec2/variables.tf +++ b/modules/compute-providers/aws/ec2/variables.tf @@ -345,6 +345,43 @@ variable "ssm" { nullable = false } +variable "storage_provider" { + description = "Runner-side storage locator and opaque IAM policy supplied by runner-config. The default preserves the existing SSM bootstrap path." + type = object({ + type = string + runner = object({ + config_table_name = optional(string, null) + runner_state_table_name = optional(string, null) + scope = optional(string, null) + iam_policy_json = optional(string, null) + }) + }) + default = { + type = "aws_ssm" + runner = { + config_table_name = null + runner_state_table_name = null + scope = null + iam_policy_json = null + } + } + + validation { + condition = contains(["aws_ssm", "aws_dynamodb"], var.storage_provider.type) + error_message = "storage_provider.type must be aws_ssm or aws_dynamodb." + } + + validation { + condition = var.storage_provider.type != "aws_dynamodb" || ( + var.storage_provider.runner.config_table_name != null && + var.storage_provider.runner.runner_state_table_name != null && + var.storage_provider.runner.scope != null && + var.storage_provider.runner.iam_policy_json != null + ) + error_message = "aws_dynamodb storage requires non-null runner config table, runner-state table, bootstrap scope, and IAM policy capabilities." + } +} + variable "observability" { description = <<-EOT CloudWatch Logs settings available to compute-provider runner log groups. diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 61a558389f..bbb95b67b5 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -157,6 +157,7 @@ multi_runner_config = { | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | | [runners](#module\_runners) | ../runners | n/a | | [ssm](#module\_ssm) | ../ssm | n/a | +| [storage\_aws\_dynamodb](#module\_storage\_aws\_dynamodb) | ../storage-providers/aws/dynamodb | n/a | | [webhook](#module\_webhook) | ../webhook | n/a | ## Resources @@ -168,6 +169,7 @@ multi_runner_config = { | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | | [aws_sqs_queue_policy.build_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | | [random_string.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | +| [aws_caller_identity.storage](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | | [aws_iam_policy_document.deny_insecure_transport](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs @@ -200,6 +202,7 @@ multi_runner_config = { | [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\_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_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\_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 | +| [global\_config\_storage\_provider](#input\_global\_config\_storage\_provider) | Global runner-configuration storage provider selection. Omit the DynamoDB block to retain the existing SSM backend. |
object({
aws = optional(object({
dynamodb = optional(object({
config = optional(object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, true)
deletion_protection_enabled = optional(bool, false)
tags = optional(map(string), {})
}), {})
runner_state = optional(object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, false)
deletion_protection_enabled = optional(bool, false)
runner_config_ttl_seconds = optional(number, 86400)
runner_state_ttl_seconds = optional(number, 604800)
tags = optional(map(string), {})
}), {})
}), null)
ssm = optional(object({}), null)
}), {})
})
| `{}` | 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 | | [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the spot termination watcher lambda function. This feature is Beta, changes will not trigger a major release as long in beta.

`enable`: Enable or disable the spot termination watcher.
`enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
`environment_variables`: Additional environment variables to merge into the Lambda configuration.
`memory_size`: Memory size limit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`timeout`: Time out of the lambda in seconds.
`zip`: File location of the lambda zip file. |
object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
})
| `{}` | no | diff --git a/modules/multi-runner/config.experimental.resolved.tf b/modules/multi-runner/config.experimental.resolved.tf index b7e2e26ab0..630de0b105 100644 --- a/modules/multi-runner/config.experimental.resolved.tf +++ b/modules/multi-runner/config.experimental.resolved.tf @@ -24,6 +24,7 @@ locals { runner = var.global_config.runner github = var.global_config_github lambda = var.global_config_lambda + storage_provider = var.global_config_storage_provider orchestration_provider = var.global_config_orchestration_provider ssm = var.global_config_ssm observability = var.global_config_observability @@ -37,6 +38,7 @@ locals { runner = local.stable_to_v2_runner github = local.stable_to_v2_github lambda = local.stable_to_v2_lambda + storage_provider = local.stable_to_v2_storage_provider orchestration_provider = local.stable_to_v2_orchestration_provider ssm = local.stable_to_v2_ssm observability = local.stable_to_v2_observability diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index 07df28e2e7..d1371e4cee 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -59,6 +59,13 @@ locals { } } + stable_to_v2_storage_provider = { + aws = { + dynamodb = null + ssm = {} + } + } + stable_to_v2_orchestration_provider = { webhook = { queue_selection_strategy = var.queue_selection_strategy diff --git a/modules/multi-runner/storage-provider.tf b/modules/multi-runner/storage-provider.tf new file mode 100644 index 0000000000..c680a5a305 --- /dev/null +++ b/modules/multi-runner/storage-provider.tf @@ -0,0 +1,160 @@ +locals { + requested_storage_provider_types = compact([ + try(local.normalized_config.storage_provider.aws.dynamodb, null) != null ? "aws_dynamodb" : "", + try(local.normalized_config.storage_provider.aws.ssm, null) != null ? "aws_ssm" : "", + ]) + + storage_provider_type = local.use_v2_config ? ( + length(local.requested_storage_provider_types) == 0 + ? "aws_ssm" + : one(local.requested_storage_provider_types) + ) : "aws_ssm" + + default_dynamodb_storage_provider = { + config = { + kms_key_arn = null + point_in_time_recovery_enabled = true + deletion_protection_enabled = false + tags = {} + } + runner_state = { + kms_key_arn = null + point_in_time_recovery_enabled = false + deletion_protection_enabled = false + runner_config_ttl_seconds = 86400 + runner_state_ttl_seconds = 604800 + tags = {} + } + } + + dynamodb_storage_provider = coalesce( + try(local.normalized_config.storage_provider.aws.dynamodb, null), + local.default_dynamodb_storage_provider, + ) + + storage_runner_matcher_config_by_key = { + for k, v in local.runner_matcher_config : format("%03d-%s", v.matcherConfig.priority, k) => merge(v, { + key = k + computeProvider = lower(trimspace(v.computeProvider)) + }) + } + storage_runner_matcher_config = [ + for k in sort(keys(local.storage_runner_matcher_config_by_key)) : local.storage_runner_matcher_config_by_key[k] + ] + + dynamodb_global_records = local.storage_provider_type == "aws_dynamodb" ? { + github_app_credentials = sensitive(jsonencode(concat( + [{ + appId = try(tonumber(local.normalized_config.github.app.id), 0) + privateKeyBase64 = local.normalized_config.github.app.key_base64 + }], + [for app in local.normalized_config.github.additional_apps : merge( + { + appId = try(tonumber(app.id), 0) + privateKeyBase64 = app.key_base64 + }, + app.installation_id == null ? {} : { + installationId = try(tonumber(app.installation_id), 0) + }, + )], + ))) + github_webhook_secret = sensitive(local.normalized_config.github.app.webhook_secret) + runner_matcher_config = jsonencode(local.storage_runner_matcher_config) + } : { + github_app_credentials = sensitive("") + github_webhook_secret = sensitive("") + runner_matcher_config = "" + } + + dynamodb_entry_records = { + for entry_id, entry in local.effective_config.multi_runner_config : entry_id => { + run_as = entry.runner.run_as_root ? "root" : entry.runner.run_as + agent_mode = entry.orchestration_provider.webhook.runner.ephemeral ? "ephemeral" : "persistent" + disable_default_labels = entry.runner.disable_default_labels + enable_jit_config = entry.orchestration_provider.webhook.runner.jit_config_enabled + } + if local.storage_provider_type == "aws_dynamodb" + } +} + +data "aws_caller_identity" "storage" { + count = local.storage_provider_type == "aws_dynamodb" ? 1 : 0 +} + +module "storage_aws_dynamodb" { + source = "../storage-providers/aws/dynamodb" + count = local.storage_provider_type == "aws_dynamodb" ? 1 : 0 + + prefix = var.prefix + tags = merge( + local.effective_config.tags, + { "ghr:environment" = var.prefix }, + ) + config = { + config = local.dynamodb_storage_provider.config + runner_state = { + kms_key_arn = local.dynamodb_storage_provider.runner_state.kms_key_arn + point_in_time_recovery_enabled = local.dynamodb_storage_provider.runner_state.point_in_time_recovery_enabled + deletion_protection_enabled = local.dynamodb_storage_provider.runner_state.deletion_protection_enabled + tags = local.dynamodb_storage_provider.runner_state.tags + } + } + entry_ids = keys(local.effective_config.multi_runner_config) + runner_config_access_scope_prefixes = { + for entry_id in keys(local.effective_config.multi_runner_config) : + entry_id => "arn:${var.aws_partition}:ec2:${var.aws_region}:${data.aws_caller_identity.storage[0].account_id}:instance/" + } + runner_config_ttl_seconds = local.dynamodb_storage_provider.runner_state.runner_config_ttl_seconds + runner_state_ttl_seconds = local.dynamodb_storage_provider.runner_state.runner_state_ttl_seconds + global_records = local.dynamodb_global_records + entry_records = local.dynamodb_entry_records +} + +locals { + dynamodb_storage_capabilities = one(module.storage_aws_dynamodb[*].capabilities) + + storage_provider_capabilities = local.storage_provider_type == "aws_dynamodb" ? local.dynamodb_storage_capabilities : { + webhook = { + direct = { + environment_variables = tomap({}) + iam_policy_json = null + } + eventbridge = { + webhook = { + environment_variables = tomap({}) + iam_policy_json = null + } + dispatcher = { + environment_variables = tomap({}) + iam_policy_json = null + } + } + } + entries = { + for entry_id in keys(local.effective_config.multi_runner_config) : entry_id => { + scale_up = { + environment_variables = tomap({}) + iam_policy_json = null + } + scale_down = { + environment_variables = tomap({}) + iam_policy_json = null + } + pool = { + environment_variables = tomap({}) + iam_policy_json = null + } + job_retry = { + environment_variables = tomap({}) + iam_policy_json = null + } + runner = { + config_table_name = null + runner_state_table_name = null + scope = null + iam_policy_json = null + } + } + } + } +} diff --git a/modules/multi-runner/variables.experimental.global.tf b/modules/multi-runner/variables.experimental.global.tf index de1def09cf..35c0820593 100644 --- a/modules/multi-runner/variables.experimental.global.tf +++ b/modules/multi-runner/variables.experimental.global.tf @@ -70,3 +70,37 @@ variable "global_config" { }) default = {} } + +variable "global_config_storage_provider" { + description = "Global runner-configuration storage provider selection. Omit the DynamoDB block to retain the existing SSM backend." + type = object({ + aws = optional(object({ + dynamodb = optional(object({ + config = optional(object({ + kms_key_arn = optional(string, null) + point_in_time_recovery_enabled = optional(bool, true) + deletion_protection_enabled = optional(bool, false) + tags = optional(map(string), {}) + }), {}) + runner_state = optional(object({ + kms_key_arn = optional(string, null) + point_in_time_recovery_enabled = optional(bool, false) + deletion_protection_enabled = optional(bool, false) + runner_config_ttl_seconds = optional(number, 86400) + runner_state_ttl_seconds = optional(number, 604800) + tags = optional(map(string), {}) + }), {}) + }), null) + ssm = optional(object({}), null) + }), {}) + }) + default = {} + + validation { + condition = ( + (try(var.global_config_storage_provider.aws.dynamodb, null) != null ? 1 : 0) + + (try(var.global_config_storage_provider.aws.ssm, null) != null ? 1 : 0) + ) <= 1 + error_message = "global_config_storage_provider must select at most one provider: aws.dynamodb or aws.ssm." + } +} diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index f8d16406fe..52ea215f0a 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -19,19 +19,24 @@ locals { } } } + + webhook_storage_kms_key_arn = local.storage_provider_type == "aws_ssm" ? local.effective_config.ssm.kms_key_id : null } module "webhook" { source = "../webhook" prefix = var.prefix tags = local.tags - kms_key_arn = local.effective_config.ssm.kms_key_id + kms_key_arn = local.webhook_storage_kms_key_arn eventbridge = { enable = local.effective_config.orchestration_provider.webhook.eventbridge.enabled accept_events = local.effective_config.orchestration_provider.webhook.eventbridge.accept_events } runner_matcher_config = local.runner_matcher_config matcher_config_parameter_store_tier = local.effective_config.orchestration_provider.webhook.matcher_config_parameter_store_tier + storage_provider = merge(local.storage_provider_capabilities.webhook, { + type = local.storage_provider_type + }) ssm_paths = { root = local.ssm_root_path diff --git a/modules/orchestration-providers/webhook/README.md b/modules/orchestration-providers/webhook/README.md index 53d0115ebb..d0af49f902 100644 --- a/modules/orchestration-providers/webhook/README.md +++ b/modules/orchestration-providers/webhook/README.md @@ -47,6 +47,7 @@ The scale-down lifecycle is documented in the [scale-down state diagram](./scale | [runner](#input\_runner) | Common runner registration values consumed by webhook demand controls. Lifecycle, boot timeout, and capacity remain provider-owned under config.runner. |
object({
os = string
auto_update_disabled = bool
labels = list(string)
group_name = string
name_prefix = string
})
| n/a | yes | | [runner\_provider](#input\_runner\_provider) | Selected compute-provider capabilities consumed by webhook scale-up, scale-down, and pool controls. |
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
})
pool = object({
environment_variables = map(string)
iam_policy_json = string
managed_policy_enabled = bool
managed_policy_arn = optional(string, null)
})
})
| n/a | yes | | [ssm](#input\_ssm) | Resolved Parameter Store paths, optional decrypt key, and runtime parameter tags. |
object({
token_path = string
token_path_arn = string
config_path = string
config_path_arn = string
kms_key_id = optional(string, null)
parameter_store_tags = string
})
| n/a | yes | +| [storage\_provider](#input\_storage\_provider) | Opaque storage-provider environment and IAM capabilities for webhook control-plane functions. |
object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
pool = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
job_retry = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
})
|
{
"job_retry": {
"environment_variables": {},
"iam_policy_json": null
},
"pool": {
"environment_variables": {},
"iam_policy_json": null
},
"scale_down": {
"environment_variables": {},
"iam_policy_json": null
},
"scale_up": {
"environment_variables": {},
"iam_policy_json": null
},
"type": "aws_ssm"
}
| no | | [tags](#input\_tags) | Base tags available to webhook-provider resources. Component-specific tags override this map within their documented scopes. | `map(string)` | `{}` | no | ## Outputs diff --git a/modules/orchestration-providers/webhook/job-retry.tf b/modules/orchestration-providers/webhook/job-retry.tf index 651e12c9ea..fe05707ed1 100644 --- a/modules/orchestration-providers/webhook/job-retry.tf +++ b/modules/orchestration-providers/webhook/job-retry.tf @@ -45,4 +45,9 @@ module "job_retry" { event_source_mapping = local.job_retry_queue_tags } } + + storage_provider = merge( + { type = local.resolved_config.storage_provider.type }, + local.resolved_config.storage_provider.job_retry, + ) } diff --git a/modules/orchestration-providers/webhook/job-retry/README.md b/modules/orchestration-providers/webhook/job-retry/README.md index 9c6e4e0f52..a0dd72e90a 100644 --- a/modules/orchestration-providers/webhook/job-retry/README.md +++ b/modules/orchestration-providers/webhook/job-retry/README.md @@ -53,6 +53,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`: 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 | +| [storage\_provider](#input\_storage\_provider) | Opaque runner-configuration storage capability used by the job-retry Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
|
{
"environment_variables": {},
"iam_policy_json": null,
"type": "aws_ssm"
}
| no | ## Outputs diff --git a/modules/orchestration-providers/webhook/job-retry/iam-policies.tf b/modules/orchestration-providers/webhook/job-retry/iam-policies.tf index 0e79e8a265..68310119fc 100644 --- a/modules/orchestration-providers/webhook/job-retry/iam-policies.tf +++ b/modules/orchestration-providers/webhook/job-retry/iam-policies.tf @@ -52,20 +52,26 @@ data "aws_iam_policy_document" "lambda_xray" { } data "aws_iam_policy_document" "job_retry" { - statement { - sid = "WebhookJobRetryReadGitHubAppParameters" - effect = "Allow" + source_policy_documents = compact([var.storage_provider.iam_policy_json]) - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - ] + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] + + content { + sid = "WebhookJobRetryReadGitHubAppParameters" + effect = "Allow" + + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] - resources = concat( - [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], - ) + resources = concat( + [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], + ) + } } statement { @@ -94,7 +100,7 @@ data "aws_iam_policy_document" "job_retry" { } dynamic "statement" { - for_each = var.config.ssm.kms_key_id == null ? [] : [var.config.ssm.kms_key_id] + for_each = var.storage_provider.type == "aws_ssm" && var.config.ssm.kms_key_id != null ? [var.config.ssm.kms_key_id] : [] iterator = kms_key content { diff --git a/modules/orchestration-providers/webhook/job-retry/job-retry.tf b/modules/orchestration-providers/webhook/job-retry/job-retry.tf index a536cfe196..c33a5aa08c 100644 --- a/modules/orchestration-providers/webhook/job-retry/job-retry.tf +++ b/modules/orchestration-providers/webhook/job-retry/job-retry.tf @@ -19,23 +19,28 @@ 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 + 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 + RUNNER_NAME_PREFIX = var.config.runner.name_prefix + } + + ssm_environment_variables = { 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( local.lambda_environment_variables, var.config.lambda.environment_variables, local.job_retry_environment_variables, + var.storage_provider.type == "aws_ssm" ? local.ssm_environment_variables : {}, + var.storage_provider.environment_variables, ) } diff --git a/modules/orchestration-providers/webhook/job-retry/variables.tf b/modules/orchestration-providers/webhook/job-retry/variables.tf index e8235265f8..e7ebc76e37 100644 --- a/modules/orchestration-providers/webhook/job-retry/variables.tf +++ b/modules/orchestration-providers/webhook/job-retry/variables.tf @@ -145,3 +145,17 @@ variable "config" { nullable = false } + +variable "storage_provider" { + description = "Opaque runner-configuration storage capability used by the job-retry Lambda." + type = object({ + type = string + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + default = { + type = "aws_ssm" + environment_variables = {} + iam_policy_json = null + } +} diff --git a/modules/orchestration-providers/webhook/main.tf b/modules/orchestration-providers/webhook/main.tf index d9b1722d98..8dd566ec18 100644 --- a/modules/orchestration-providers/webhook/main.tf +++ b/modules/orchestration-providers/webhook/main.tf @@ -30,12 +30,13 @@ locals { queue = merge(var.config.queue, { event_source_mapping = var.config.lambda.scale.up.event_source_mapping }) - scale_up = var.config.lambda.scale.up - scale_down = var.config.lambda.scale.down - pool = var.config.lambda.pool - job_retry = var.config.job_retry - ssm = var.ssm - observability = var.observability + scale_up = var.config.lambda.scale.up + scale_down = var.config.lambda.scale.down + pool = var.config.lambda.pool + job_retry = var.config.job_retry + ssm = var.ssm + storage_provider = var.storage_provider + observability = var.observability } common_tags = local.resolved_config.tags diff --git a/modules/orchestration-providers/webhook/pool.tf b/modules/orchestration-providers/webhook/pool.tf index 6fb9ad3d34..4ed40fb919 100644 --- a/modules/orchestration-providers/webhook/pool.tf +++ b/modules/orchestration-providers/webhook/pool.tf @@ -56,6 +56,10 @@ module "pool" { aws_partition = var.aws_partition tracing_config = local.resolved_config.observability.tracing + storage_provider = merge( + { type = local.resolved_config.storage_provider.type }, + local.resolved_config.storage_provider.pool, + ) runner_provider = { type = var.runner_provider.type environment_variables = var.runner_provider.pool.environment_variables diff --git a/modules/orchestration-providers/webhook/pool/README.md b/modules/orchestration-providers/webhook/pool/README.md index 877eec8039..8491aae222 100644 --- a/modules/orchestration-providers/webhook/pool/README.md +++ b/modules/orchestration-providers/webhook/pool/README.md @@ -56,6 +56,7 @@ No modules. | [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`: 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 | +| [storage\_provider](#input\_storage\_provider) | Opaque runner-configuration storage capability used by the pool Lambda. |
object({
type = string
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
|
{
"environment_variables": {},
"iam_policy_json": null,
"type": "aws_ssm"
}
| no | | [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 | ## Outputs diff --git a/modules/orchestration-providers/webhook/pool/iam-policies.tf b/modules/orchestration-providers/webhook/pool/iam-policies.tf index f5a9285bce..334aa2845c 100644 --- a/modules/orchestration-providers/webhook/pool/iam-policies.tf +++ b/modules/orchestration-providers/webhook/pool/iam-policies.tf @@ -1,56 +1,68 @@ # IAM policies attached to the pool Lambda role. data "aws_iam_policy_document" "pool_common" { - statement { - sid = "WebhookPoolWriteRuntimeParameters" - effect = "Allow" + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] - actions = [ - "ssm:AddTagsToResource", - "ssm:PutParameter", - ] + content { + sid = "WebhookPoolWriteRuntimeParameters" + effect = "Allow" - resources = [ - var.config.ssm_token_path_arn, - "${var.config.ssm_token_path_arn}/*", - var.config.arn_ssm_parameters_path_config, - "${var.config.arn_ssm_parameters_path_config}/*", - ] + actions = [ + "ssm:AddTagsToResource", + "ssm:PutParameter", + ] + + resources = [ + var.config.ssm_token_path_arn, + "${var.config.ssm_token_path_arn}/*", + var.config.arn_ssm_parameters_path_config, + "${var.config.arn_ssm_parameters_path_config}/*", + ] + } } - statement { - sid = "WebhookPoolReadRunnerConfigParameters" - effect = "Allow" + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - "ssm:GetParametersByPath", - ] + content { + sid = "WebhookPoolReadRunnerConfigParameters" + effect = "Allow" - resources = [ - var.config.arn_ssm_parameters_path_config, - "${var.config.arn_ssm_parameters_path_config}/*", - ] + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + ] + + resources = [ + var.config.arn_ssm_parameters_path_config, + "${var.config.arn_ssm_parameters_path_config}/*", + ] + } } - statement { - sid = "WebhookPoolReadGitHubAppParameters" - effect = "Allow" + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - ] + content { + sid = "WebhookPoolReadGitHubAppParameters" + effect = "Allow" + + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] - resources = concat( - [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], - ) + resources = concat( + [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], + ) + } } dynamic "statement" { - for_each = var.config.kms_key_id == null ? [] : [var.config.kms_key_id] + for_each = var.storage_provider.type == "aws_ssm" && var.config.kms_key_id != null ? [var.config.kms_key_id] : [] iterator = kms_key content { diff --git a/modules/orchestration-providers/webhook/pool/pool.tf b/modules/orchestration-providers/webhook/pool/pool.tf index cff2776e90..ce3319c8c8 100644 --- a/modules/orchestration-providers/webhook/pool/pool.tf +++ b/modules/orchestration-providers/webhook/pool/pool.tf @@ -7,32 +7,35 @@ 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 + 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 + 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 + 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 + INCLUDE_BUSY_RUNNERS = var.config.include_busy_runners + } + + ssm_environment_variables = { 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 } } @@ -54,7 +57,12 @@ resource "aws_lambda_function" "pool" { tags = merge(var.config.tags, var.config.lambda_tags) environment { - variables = merge(var.runner_provider.environment_variables, local.common_environment_variables) + variables = merge( + var.runner_provider.environment_variables, + local.common_environment_variables, + var.storage_provider.type == "aws_ssm" ? local.ssm_environment_variables : {}, + var.storage_provider.environment_variables, + ) } dynamic "vpc_config" { @@ -96,10 +104,11 @@ resource "aws_iam_role_policy" "pool" { } data "aws_iam_policy_document" "pool" { - source_policy_documents = [ + source_policy_documents = compact([ data.aws_iam_policy_document.pool_common.json, var.runner_provider.iam_policy_json, - ] + var.storage_provider.iam_policy_json, + ]) } resource "aws_iam_role_policy" "pool_logging" { diff --git a/modules/orchestration-providers/webhook/pool/variables.tf b/modules/orchestration-providers/webhook/pool/variables.tf index e1f516c8ad..2b3b9cfb0b 100644 --- a/modules/orchestration-providers/webhook/pool/variables.tf +++ b/modules/orchestration-providers/webhook/pool/variables.tf @@ -138,6 +138,20 @@ variable "runner_provider" { }) } +variable "storage_provider" { + description = "Opaque runner-configuration storage capability used by the pool Lambda." + type = object({ + type = string + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + default = { + type = "aws_ssm" + environment_variables = {} + iam_policy_json = null + } +} + variable "aws_partition" { description = "(optional) partition for the arn if not 'aws'" type = string diff --git a/modules/orchestration-providers/webhook/scale-runners.tf b/modules/orchestration-providers/webhook/scale-runners.tf index caf79eefb5..cdcbc9350a 100644 --- a/modules/orchestration-providers/webhook/scale-runners.tf +++ b/modules/orchestration-providers/webhook/scale-runners.tf @@ -53,6 +53,12 @@ module "scale_runners" { } } + storage_provider = { + type = local.resolved_config.storage_provider.type + scale_up = local.resolved_config.storage_provider.scale_up + scale_down = local.resolved_config.storage_provider.scale_down + } + runner_provider = { type = var.runner_provider.type scale_up = var.runner_provider.scale_up diff --git a/modules/orchestration-providers/webhook/scale-runners/README.md b/modules/orchestration-providers/webhook/scale-runners/README.md index 3b096f9b85..36f92f12dd 100644 --- a/modules/orchestration-providers/webhook/scale-runners/README.md +++ b/modules/orchestration-providers/webhook/scale-runners/README.md @@ -69,6 +69,7 @@ No modules. | [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`: 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 | +| [storage\_provider](#input\_storage\_provider) | Opaque storage-provider capabilities for scale-up and scale-down. |
object({
type = string
scale_up = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
scale_down = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
})
|
{
"scale_down": {
"environment_variables": {},
"iam_policy_json": null
},
"scale_up": {
"environment_variables": {},
"iam_policy_json": null
},
"type": "aws_ssm"
}
| no | ## 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 b95cb9e686..79571c2747 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 @@ -1,20 +1,24 @@ data "aws_iam_policy_document" "scale_down_common" { - statement { - sid = "WebhookScaleDownReadGitHubAppParameters" - effect = "Allow" - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - ] - resources = concat( - [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], - ) + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] + + content { + sid = "WebhookScaleDownReadGitHubAppParameters" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + resources = concat( + [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], + ) + } } dynamic "statement" { - for_each = var.config.ssm.kms_key_id == null ? [] : [var.config.ssm.kms_key_id] + for_each = var.storage_provider.type == "aws_ssm" && var.config.ssm.kms_key_id != null ? [var.config.ssm.kms_key_id] : [] iterator = kms_key content { @@ -27,10 +31,11 @@ data "aws_iam_policy_document" "scale_down_common" { } data "aws_iam_policy_document" "scale_down" { - source_policy_documents = [ + source_policy_documents = compact([ data.aws_iam_policy_document.scale_down_common.json, var.runner_provider.scale_down.iam_policy_json, - ] + var.storage_provider.scale_down.iam_policy_json, + ]) } data "aws_iam_policy_document" "scale_down_logging" { diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf index 44e651d78c..7c5c19f4fe 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf @@ -15,26 +15,27 @@ 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]) - NODE_TLS_REJECT_UNAUTHORIZED = var.config.github.enterprise_server.url != null && !var.config.github.enterprise_server.ssl_verify ? 0 : 1 + 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 + 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 + }, var.storage_provider.type == "aws_ssm" ? { 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 - }) + } : {}, var.storage_provider.scale_down.environment_variables) } dynamic "vpc_config" { 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 b3c87b8ad7..3fddefbe99 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 @@ -1,35 +1,43 @@ data "aws_iam_policy_document" "scale_up_common" { - statement { - sid = "WebhookScaleUpWriteRuntimeParameters" - effect = "Allow" - actions = [ - "ssm:PutParameter", - "ssm:AddTagsToResource", - ] - resources = [ - var.config.ssm.token_path_arn, - "${var.config.ssm.token_path_arn}/*", - var.config.ssm.config_path_arn, - "${var.config.ssm.config_path_arn}/*", - ] - } + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] - statement { - sid = "WebhookScaleUpReadGitHubAppAndRunnerConfigParameters" - effect = "Allow" - actions = [ - "ssm:GetParameter", - "ssm:GetParameters", - ] - resources = concat( - [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], - [ + content { + sid = "WebhookScaleUpWriteRuntimeParameters" + effect = "Allow" + actions = [ + "ssm:PutParameter", + "ssm:AddTagsToResource", + ] + resources = [ + var.config.ssm.token_path_arn, + "${var.config.ssm.token_path_arn}/*", var.config.ssm.config_path_arn, "${var.config.ssm.config_path_arn}/*", - ], - ) + ] + } + } + + dynamic "statement" { + for_each = var.storage_provider.type == "aws_ssm" ? [true] : [] + + content { + sid = "WebhookScaleUpReadGitHubAppAndRunnerConfigParameters" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + resources = concat( + [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}/*", + ], + ) + } } statement { @@ -44,7 +52,7 @@ data "aws_iam_policy_document" "scale_up_common" { } dynamic "statement" { - for_each = var.config.ssm.kms_key_id == null ? [] : [var.config.ssm.kms_key_id] + for_each = var.storage_provider.type == "aws_ssm" && var.config.ssm.kms_key_id != null ? [var.config.ssm.kms_key_id] : [] iterator = kms_key content { @@ -69,10 +77,11 @@ data "aws_iam_policy_document" "scale_up_common" { } data "aws_iam_policy_document" "scale_up" { - source_policy_documents = [ + source_policy_documents = compact([ data.aws_iam_policy_document.scale_up_common.json, var.runner_provider.scale_up.iam_policy_json, - ] + var.storage_provider.scale_up.iam_policy_json, + ]) } data "aws_iam_policy_document" "scale_up_logging" { diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-up.tf b/modules/orchestration-providers/webhook/scale-runners/scale-up.tf index 2997aeac21..43ac0aff76 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-up.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-up.tf @@ -16,37 +16,38 @@ 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 + 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 + 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" + JOB_RETRY_CONFIG = jsonencode(local.job_retry_config) + }, var.storage_provider.type == "aws_ssm" ? { 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) - }) + } : {}, var.storage_provider.scale_up.environment_variables) } dynamic "vpc_config" { diff --git a/modules/orchestration-providers/webhook/scale-runners/variables.tf b/modules/orchestration-providers/webhook/scale-runners/variables.tf index e191e303d1..82baab3f0a 100644 --- a/modules/orchestration-providers/webhook/scale-runners/variables.tf +++ b/modules/orchestration-providers/webhook/scale-runners/variables.tf @@ -231,3 +231,29 @@ variable "runner_provider" { nullable = false } + +variable "storage_provider" { + description = "Opaque storage-provider capabilities for scale-up and scale-down." + type = object({ + type = string + scale_up = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + scale_down = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + }) + default = { + type = "aws_ssm" + scale_up = { + environment_variables = {} + iam_policy_json = null + } + scale_down = { + environment_variables = {} + iam_policy_json = null + } + } +} diff --git a/modules/orchestration-providers/webhook/variables.tf b/modules/orchestration-providers/webhook/variables.tf index 5dfecdbd6c..78cfb41bf6 100644 --- a/modules/orchestration-providers/webhook/variables.tf +++ b/modules/orchestration-providers/webhook/variables.tf @@ -215,6 +215,48 @@ variable "ssm" { }) } +variable "storage_provider" { + description = "Opaque storage-provider environment and IAM capabilities for webhook control-plane functions." + type = object({ + type = string + scale_up = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + scale_down = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + pool = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + job_retry = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + }) + default = { + type = "aws_ssm" + scale_up = { + environment_variables = {} + iam_policy_json = null + } + scale_down = { + environment_variables = {} + iam_policy_json = null + } + pool = { + environment_variables = {} + iam_policy_json = null + } + job_retry = { + environment_variables = {} + iam_policy_json = null + } + } +} + variable "observability" { description = "Common logging, tracing, and metrics configuration consumed by webhook controls." type = object({ diff --git a/modules/storage-providers/aws/dynamodb/README.md b/modules/storage-providers/aws/dynamodb/README.md new file mode 100644 index 0000000000..9b25e0f927 --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/README.md @@ -0,0 +1,59 @@ +# DynamoDB runner-config storage provider + +This internal module creates the two shared DynamoDB tables used by an opt-in multi-runner v2 deployment: one durable configuration table and one TTL-enabled runner-state table. It stores global and per-entry configuration under capability-specific partition-key scopes and returns opaque Lambda and runner capabilities with matching least-privilege IAM policies. + +The durable table isolates GitHub App credentials, webhook secrets, matcher configuration, runner-group cache entries, and bootstrap records by `scope`. The runner-state table keeps lifecycle inventory under entry-specific scopes and one-time registration configuration under the compute resource's access scope. For EC2, `compute-resource` means the full source-instance ARN; the runner can atomically read and delete only its own unexpired record. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [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_dynamodb_table.config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table) | resource | +| [aws_dynamodb_table.runner_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table) | resource | +| [aws_dynamodb_table_item.github_app_credentials](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [aws_dynamodb_table_item.github_webhook_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [aws_dynamodb_table_item.runner_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [aws_dynamodb_table_item.runner_matcher_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [terraform_data.config_version](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [config](#input\_config) | Settings for the shared durable configuration table and ephemeral runner-state table.

- `config.kms_key_arn`: Optional customer-managed KMS key ARN for durable configuration encryption. Null uses the AWS-owned DynamoDB key.
- `config.point_in_time_recovery_enabled`: Enables point-in-time recovery for durable configuration.
- `config.deletion_protection_enabled`: Enables deletion protection for the durable table.
- `config.tags`: Tags applied after the shared tag map.
- `runner_state.kms_key_arn`: Optional customer-managed KMS key ARN for runner-state encryption. Null uses the AWS-owned DynamoDB key.
- `runner_state.point_in_time_recovery_enabled`: Enables point-in-time recovery for ephemeral runner state.
- `runner_state.deletion_protection_enabled`: Enables deletion protection for the runner-state table.
- `runner_state.tags`: Tags applied after the shared tag map. |
object({
config = object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, true)
deletion_protection_enabled = optional(bool, false)
tags = optional(map(string), {})
})
runner_state = object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, false)
deletion_protection_enabled = optional(bool, false)
tags = optional(map(string), {})
})
})
| n/a | yes | +| [entry\_ids](#input\_entry\_ids) | Runner-entry identifiers used to build entry-scoped Lambda capabilities. | `set(string)` | n/a | yes | +| [entry\_records](#input\_entry\_records) | Resolved durable runner bootstrap configuration keyed by runner-entry identifier. |
map(object({
run_as = string
agent_mode = string
disable_default_labels = bool
enable_jit_config = bool
}))
| n/a | yes | +| [global\_records](#input\_global\_records) | Terraform-managed values stored under the shared global scope. |
object({
github_app_credentials = string
github_webhook_secret = string
runner_matcher_config = string
})
| n/a | yes | +| [prefix](#input\_prefix) | Multi-runner prefix used to name the two shared DynamoDB tables. | `string` | n/a | yes | +| [runner\_config\_access\_scope\_prefixes](#input\_runner\_config\_access\_scope\_prefixes) | Per-entry compute-resource scope prefixes used to constrain one-time runner-config writes. | `map(string)` | n/a | yes | +| [runner\_config\_ttl\_seconds](#input\_runner\_config\_ttl\_seconds) | TTL in seconds for one-time registration and JIT configuration records. | `number` | n/a | yes | +| [runner\_state\_ttl\_seconds](#input\_runner\_state\_ttl\_seconds) | Safety TTL in seconds applied only while lifecycle records are provisioning or terminating; active and orphan inventory has no expiry. | `number` | n/a | yes | +| [tags](#input\_tags) | Base tags added to both shared DynamoDB tables. Table-specific tags override matching keys. | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [capabilities](#output\_capabilities) | Opaque environment and least-privilege IAM additions consumed by the shared webhook and each runner entry's control-plane functions. | +| [config\_table](#output\_config\_table) | Shared durable configuration table. Global and runner-entry records are separated by the `scope` partition key. | +| [runner\_state\_table](#output\_runner\_state\_table) | Shared TTL-backed table containing ephemeral runner configuration and provider-neutral runner lifecycle records. | + diff --git a/modules/storage-providers/aws/dynamodb/capabilities.tf b/modules/storage-providers/aws/dynamodb/capabilities.tf new file mode 100644 index 0000000000..b5f47d36ea --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/capabilities.tf @@ -0,0 +1,243 @@ +locals { + config_environment_variables = { + RUNNER_CONFIG_STORAGE_PROVIDER = "aws_dynamodb" + RUNNER_CONFIG_STORAGE_VERSION = terraform_data.config_version.id + RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = aws_dynamodb_table.config.name + } + + matcher_environment_variables = merge(local.config_environment_variables, { + RUNNER_MATCHER_CONFIG_VERSION = nonsensitive(sha256(var.global_records.runner_matcher_config)) + }) + + runner_state_environment_variables = { + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME = aws_dynamodb_table.runner_state.name + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS = tostring(var.runner_state_ttl_seconds) + } + + runner_config_environment_variables = { + RUNNER_CONFIG_DYNAMODB_TTL_SECONDS = tostring(var.runner_config_ttl_seconds) + } + + global_scopes = { + github_app = "global#github-app" + webhook = "global#webhook" + matcher = "global#matcher" + } + + entry_scopes = { + for entry_id in var.entry_ids : entry_id => { + bootstrap = "entry#${entry_id}#bootstrap" + runner_group = "entry#${entry_id}#runner-group" + runner_state = "entry#${entry_id}#runner-state" + } + } + + entry_environment_variables = { + for entry_id, scopes in local.entry_scopes : entry_id => merge(local.config_environment_variables, { + RUNNER_CONFIG_DYNAMODB_ENTRY_ID = entry_id + }) + } + + scale_up_environment_variables = { + for entry_id in var.entry_ids : entry_id => merge( + local.entry_environment_variables[entry_id], + local.runner_state_environment_variables, + local.runner_config_environment_variables, + ) + } + + scale_down_environment_variables = { + for entry_id in var.entry_ids : entry_id => merge( + local.entry_environment_variables[entry_id], + local.runner_state_environment_variables, + ) + } + + github_app_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.github_app] + } + } + } + + direct_webhook_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.webhook, local.global_scopes.matcher] + } + } + } + + eventbridge_webhook_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.webhook] + } + } + } + + dispatcher_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.matcher] + } + } + } + + direct_webhook_iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [local.direct_webhook_read_statement] + }) + + eventbridge_webhook_iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [local.eventbridge_webhook_read_statement] + }) + + dispatcher_iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = [local.dispatcher_read_statement] + }) + + entry_runner_group_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = ["dynamodb:GetItem", "dynamodb:PutItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.runner_group] + } + } + } + } + + runner_config_write_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = ["dynamodb:PutItem"] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringLike" = { + "dynamodb:LeadingKeys" = ["${lookup(var.runner_config_access_scope_prefixes, entry_id, "__missing_runner_config_access_scope__")}*"] + } + } + } + } + + runner_state_write_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = [ + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:UpdateItem", + ] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.runner_state] + } + } + } + } + + runner_state_reconcile_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = [ + "dynamodb:DeleteItem", + "dynamodb:Query", + "dynamodb:UpdateItem", + ] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.runner_state] + } + } + } + } + + scale_up_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = [ + local.github_app_read_statement, + local.entry_runner_group_statements[entry_id], + local.runner_config_write_statements[entry_id], + local.runner_state_write_statements[entry_id], + ] + }) + } + + scale_down_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = [local.github_app_read_statement, local.runner_state_reconcile_statements[entry_id]] + }) + } + + pool_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = [ + local.github_app_read_statement, + local.entry_runner_group_statements[entry_id], + local.runner_config_write_statements[entry_id], + local.runner_state_write_statements[entry_id], + ] + }) + } + + job_retry_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = [local.github_app_read_statement] + }) + } + + runner_iam_policy_json = { + for entry_id, scopes in local.entry_scopes : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.bootstrap] + } + } + }, + { + Effect = "Allow" + Action = [ + "dynamodb:DeleteItem", + "dynamodb:GetItem", + ] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = ["$${ec2:SourceInstanceARN}"] + } + } + }, + ] + }) + } +} diff --git a/modules/storage-providers/aws/dynamodb/config-version.tf b/modules/storage-providers/aws/dynamodb/config-version.tf new file mode 100644 index 0000000000..a0bd45605c --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/config-version.tf @@ -0,0 +1,23 @@ +resource "terraform_data" "config_version" { + triggers_replace = sensitive({ + global_records = sha256(jsonencode(var.global_records)) + entry_records = sha256(jsonencode(var.entry_records)) + }) + + lifecycle { + precondition { + condition = toset(keys(var.runner_config_access_scope_prefixes)) == var.entry_ids && alltrue([for prefix in values(var.runner_config_access_scope_prefixes) : trimspace(prefix) != ""]) + error_message = "runner_config_access_scope_prefixes must contain one non-empty prefix for every entry_id." + } + + precondition { + condition = var.runner_state_ttl_seconds > var.runner_config_ttl_seconds && floor(var.runner_state_ttl_seconds) == var.runner_state_ttl_seconds + error_message = "runner_state_ttl_seconds must be an integer greater than runner_config_ttl_seconds." + } + + precondition { + condition = toset(keys(var.entry_records)) == var.entry_ids + error_message = "entry_records must contain exactly one durable bootstrap record for every entry_id." + } + } +} diff --git a/modules/storage-providers/aws/dynamodb/items.tf b/modules/storage-providers/aws/dynamodb/items.tf new file mode 100644 index 0000000000..bc34f70e19 --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/items.tf @@ -0,0 +1,56 @@ +resource "aws_dynamodb_table_item" "github_app_credentials" { + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.global_scopes.github_app } + id = { S = "github-app-credentials" } + value = { S = var.global_records.github_app_credentials } + }) +} + +resource "aws_dynamodb_table_item" "github_webhook_secret" { + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.global_scopes.webhook } + id = { S = "github-webhook-secret" } + value = { S = var.global_records.github_webhook_secret } + }) +} + +resource "aws_dynamodb_table_item" "runner_matcher_config" { + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.global_scopes.matcher } + id = { S = "runner-matcher-config" } + value = { S = var.global_records.runner_matcher_config } + }) +} + +resource "aws_dynamodb_table_item" "runner_config" { + for_each = var.entry_records + + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.entry_scopes[each.key].bootstrap } + id = { S = "runner-config" } + value = { S = jsonencode(merge(each.value, { + runner_config_storage = { + provider = "aws_dynamodb" + table_name = aws_dynamodb_table.runner_state.name + access_scope = "compute-resource" + id = "config" + } + })) } + }) +} diff --git a/modules/storage-providers/aws/dynamodb/outputs.tf b/modules/storage-providers/aws/dynamodb/outputs.tf new file mode 100644 index 0000000000..4c337b212d --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/outputs.tf @@ -0,0 +1,71 @@ +output "config_table" { + description = "Shared durable configuration table. Global and runner-entry records are separated by the `scope` partition key." + value = { + arn = aws_dynamodb_table.config.arn + name = aws_dynamodb_table.config.name + } +} + +output "runner_state_table" { + description = "Shared TTL-backed table containing ephemeral runner configuration and provider-neutral runner lifecycle records." + value = { + arn = aws_dynamodb_table.runner_state.arn + name = aws_dynamodb_table.runner_state.name + ttl_attribute_name = "expires_at" + } +} + +output "capabilities" { + description = "Opaque environment and least-privilege IAM additions consumed by the shared webhook and each runner entry's control-plane functions." + depends_on = [ + aws_dynamodb_table_item.github_app_credentials, + aws_dynamodb_table_item.github_webhook_secret, + aws_dynamodb_table_item.runner_matcher_config, + aws_dynamodb_table_item.runner_config, + terraform_data.config_version, + ] + value = { + webhook = { + direct = { + environment_variables = tomap(local.matcher_environment_variables) + iam_policy_json = local.direct_webhook_iam_policy_json + } + eventbridge = { + webhook = { + environment_variables = tomap(local.config_environment_variables) + iam_policy_json = local.eventbridge_webhook_iam_policy_json + } + dispatcher = { + environment_variables = tomap(local.matcher_environment_variables) + iam_policy_json = local.dispatcher_iam_policy_json + } + } + } + entries = { + for entry_id in var.entry_ids : entry_id => { + scale_up = { + environment_variables = tomap(local.scale_up_environment_variables[entry_id]) + iam_policy_json = local.scale_up_iam_policy_json[entry_id] + } + scale_down = { + environment_variables = tomap(local.scale_down_environment_variables[entry_id]) + iam_policy_json = local.scale_down_iam_policy_json[entry_id] + } + pool = { + environment_variables = tomap(local.scale_up_environment_variables[entry_id]) + iam_policy_json = local.pool_iam_policy_json[entry_id] + } + job_retry = { + environment_variables = tomap(local.config_environment_variables) + iam_policy_json = local.job_retry_iam_policy_json[entry_id] + } + runner = { + config_table_name = aws_dynamodb_table.config.name + runner_state_table_name = aws_dynamodb_table.runner_state.name + scope = local.entry_scopes[entry_id].bootstrap + iam_policy_json = local.runner_iam_policy_json[entry_id] + } + } + } + } +} diff --git a/modules/storage-providers/aws/dynamodb/tables.tf b/modules/storage-providers/aws/dynamodb/tables.tf new file mode 100644 index 0000000000..12cd94867c --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/tables.tf @@ -0,0 +1,62 @@ +resource "aws_dynamodb_table" "config" { + name = "${var.prefix}-config" + billing_mode = "PAY_PER_REQUEST" + hash_key = "scope" + range_key = "id" + + attribute { + name = "scope" + type = "S" + } + + attribute { + name = "id" + type = "S" + } + + point_in_time_recovery { + enabled = var.config.config.point_in_time_recovery_enabled + } + + server_side_encryption { + enabled = true + kms_key_arn = var.config.config.kms_key_arn + } + + deletion_protection_enabled = var.config.config.deletion_protection_enabled + tags = merge(var.tags, var.config.config.tags) +} + +resource "aws_dynamodb_table" "runner_state" { + name = "${var.prefix}-runner-state" + billing_mode = "PAY_PER_REQUEST" + hash_key = "scope" + range_key = "id" + + attribute { + name = "scope" + type = "S" + } + + attribute { + name = "id" + type = "S" + } + + ttl { + attribute_name = "expires_at" + enabled = true + } + + point_in_time_recovery { + enabled = var.config.runner_state.point_in_time_recovery_enabled + } + + server_side_encryption { + enabled = true + kms_key_arn = var.config.runner_state.kms_key_arn + } + + deletion_protection_enabled = var.config.runner_state.deletion_protection_enabled + tags = merge(var.tags, var.config.runner_state.tags) +} diff --git a/modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl b/modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl new file mode 100644 index 0000000000..2aac0c62b8 --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/tests/provider.tftest.hcl @@ -0,0 +1,284 @@ +mock_provider "aws" { + mock_resource "aws_dynamodb_table" { + defaults = { + arn = "arn:aws:dynamodb:eu-west-1:123456789012:table/test" + } + } +} + +variables { + prefix = "github-actions" + entry_ids = ["linux", "microvm"] + runner_config_access_scope_prefixes = { + linux = "arn:aws:ec2:eu-west-1:123456789012:instance/" + microvm = "arn:aws:ec2:eu-west-1:123456789012:instance/" + } + runner_config_ttl_seconds = 3600 + runner_state_ttl_seconds = 604800 + global_records = { + github_app_credentials = jsonencode([{ appId = 123456, privateKeyBase64 = "dGVzdA==" }]) + github_webhook_secret = "test-secret" + runner_matcher_config = jsonencode([{ key = "linux" }]) + } + entry_records = { + linux = { + run_as = "runner" + agent_mode = "ephemeral" + disable_default_labels = false + enable_jit_config = true + } + microvm = { + run_as = "root" + agent_mode = "ephemeral" + disable_default_labels = true + enable_jit_config = true + } + } + tags = { + Environment = "test" + Shared = "base" + } + config = { + config = { + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/config" + point_in_time_recovery_enabled = true + deletion_protection_enabled = true + tags = { + Shared = "config" + } + } + runner_state = { + kms_key_arn = "arn:aws:kms:eu-west-1:123456789012:key/runner-state" + point_in_time_recovery_enabled = false + deletion_protection_enabled = false + tags = { + Shared = "runner-state" + } + } + } +} + +run "creates_two_shared_scoped_tables" { + command = apply + + assert { + condition = ( + aws_dynamodb_table.config.name == "github-actions-config" + && aws_dynamodb_table.config.billing_mode == "PAY_PER_REQUEST" + && aws_dynamodb_table.config.hash_key == "scope" + && aws_dynamodb_table.config.range_key == "id" + && aws_dynamodb_table.config.point_in_time_recovery[0].enabled + && aws_dynamodb_table.config.deletion_protection_enabled + && aws_dynamodb_table.config.server_side_encryption[0].kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/config" + && aws_dynamodb_table.config.tags["Shared"] == "config" + ) + error_message = "The durable provider table must be one encrypted, scoped, on-demand table for the whole multi-runner deployment." + } + + assert { + condition = ( + aws_dynamodb_table.runner_state.name == "github-actions-runner-state" + && aws_dynamodb_table.runner_state.billing_mode == "PAY_PER_REQUEST" + && aws_dynamodb_table.runner_state.hash_key == "scope" + && aws_dynamodb_table.runner_state.range_key == "id" + && aws_dynamodb_table.runner_state.ttl[0].enabled + && aws_dynamodb_table.runner_state.ttl[0].attribute_name == "expires_at" + && !aws_dynamodb_table.runner_state.point_in_time_recovery[0].enabled + && !aws_dynamodb_table.runner_state.deletion_protection_enabled + && aws_dynamodb_table.runner_state.server_side_encryption[0].kms_key_arn == "arn:aws:kms:eu-west-1:123456789012:key/runner-state" + && aws_dynamodb_table.runner_state.tags["Shared"] == "runner-state" + ) + error_message = "The runner-state provider table must be one encrypted, TTL-backed, scoped, on-demand table for the whole multi-runner deployment." + } + + assert { + condition = ( + output.config_table.name == "github-actions-config" + && output.runner_state_table.name == "github-actions-runner-state" + && output.runner_state_table.ttl_attribute_name == "expires_at" + ) + error_message = "The provider outputs must expose the two shared table contracts." + } + + assert { + condition = ( + output.capabilities.webhook.direct.environment_variables["RUNNER_CONFIG_STORAGE_PROVIDER"] == "aws_dynamodb" + && output.capabilities.webhook.direct.environment_variables["RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME"] == "github-actions-config" + && output.capabilities.webhook.direct.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.webhook.eventbridge.webhook.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.webhook.eventbridge.dispatcher.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.webhook.direct.environment_variables["RUNNER_MATCHER_CONFIG_VERSION"] == sha256(jsonencode([{ key = "linux" }])) + && !contains(keys(output.capabilities.webhook.direct.environment_variables), "RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME") + && !contains(keys(output.capabilities.webhook.direct.environment_variables), "RUNNER_CONFIG_DYNAMODB_TTL_SECONDS") + && !contains(keys(output.capabilities.webhook.eventbridge.webhook.environment_variables), "RUNNER_MATCHER_CONFIG_VERSION") + && output.capabilities.webhook.eventbridge.dispatcher.environment_variables["RUNNER_MATCHER_CONFIG_VERSION"] == sha256(jsonencode([{ key = "linux" }])) + && output.capabilities.entries["linux"].scale_up.environment_variables["RUNNER_CONFIG_DYNAMODB_ENTRY_ID"] == "linux" + && output.capabilities.entries["linux"].scale_up.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && !contains(keys(output.capabilities.entries["linux"].scale_up.environment_variables), "RUNNER_MATCHER_CONFIG_VERSION") + && output.capabilities.entries["microvm"].scale_down.environment_variables["RUNNER_CONFIG_DYNAMODB_ENTRY_ID"] == "microvm" + && output.capabilities.entries["microvm"].scale_down.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.entries["linux"].pool.environment_variables["RUNNER_CONFIG_DYNAMODB_TTL_SECONDS"] == "3600" + && output.capabilities.entries["linux"].scale_down.environment_variables["RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS"] == "604800" + && !contains(keys(output.capabilities.entries["linux"].scale_down.environment_variables), "RUNNER_CONFIG_DYNAMODB_TTL_SECONDS") + && !contains(keys(output.capabilities.entries["linux"].job_retry.environment_variables), "RUNNER_CONFIG_DYNAMODB_ENTRY_ID") + && !contains(keys(output.capabilities.entries["linux"].job_retry.environment_variables), "RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME") + && output.capabilities.entries["linux"].job_retry.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.entries["linux"].runner.config_table_name == "github-actions-config" + && output.capabilities.entries["linux"].runner.runner_state_table_name == "github-actions-runner-state" + && output.capabilities.entries["linux"].runner.scope == "entry#linux#bootstrap" + ) + error_message = "The provider must expose one global and entry-scoped Lambda environment contract over the same two tables." + } + + assert { + condition = ( + jsondecode(output.capabilities.webhook.direct.iam_policy_json).Statement[0].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["global#webhook", "global#matcher"] + && jsondecode(output.capabilities.webhook.eventbridge.webhook.iam_policy_json).Statement[0].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["global#webhook"] + && jsondecode(output.capabilities.webhook.eventbridge.dispatcher.iam_policy_json).Statement[0].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["global#matcher"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[0].Action == ["dynamodb:GetItem"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[0].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["global#github-app"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[1].Action == ["dynamodb:GetItem", "dynamodb:PutItem"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[1].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["entry#linux#runner-group"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[2].Action == ["dynamodb:PutItem"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[2].Condition["ForAllValues:StringLike"]["dynamodb:LeadingKeys"] == ["arn:aws:ec2:eu-west-1:123456789012:instance/*"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[3].Action == ["dynamodb:PutItem", "dynamodb:Query", "dynamodb:UpdateItem"] + && jsondecode(output.capabilities.entries["linux"].scale_up.iam_policy_json).Statement[3].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["entry#linux#runner-state"] + && jsondecode(output.capabilities.entries["microvm"].scale_down.iam_policy_json).Statement[1].Action == ["dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:UpdateItem"] + && jsondecode(output.capabilities.entries["microvm"].scale_down.iam_policy_json).Statement[1].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["entry#microvm#runner-state"] + && !contains(jsondecode(output.capabilities.entries["linux"].pool.iam_policy_json).Statement[3].Action, "dynamodb:DeleteItem") + && jsondecode(output.capabilities.entries["linux"].runner.iam_policy_json).Statement[0].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["entry#linux#bootstrap"] + && jsondecode(output.capabilities.entries["linux"].runner.iam_policy_json).Statement[1].Condition["ForAllValues:StringEquals"]["dynamodb:LeadingKeys"] == ["$${ec2:SourceInstanceARN}"] + ) + error_message = "Provider IAM capabilities must restrict global and entry operations with DynamoDB leading-key conditions." + } + + + assert { + condition = ( + jsondecode(aws_dynamodb_table_item.github_app_credentials.item).scope.S == "global#github-app" + && jsondecode(aws_dynamodb_table_item.github_webhook_secret.item).scope.S == "global#webhook" + && jsondecode(aws_dynamodb_table_item.runner_matcher_config.item).scope.S == "global#matcher" + && jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).scope.S == "entry#linux#bootstrap" + && jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).id.S == "runner-config" + && jsondecode(jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).value.S).run_as == "runner" + && jsondecode(jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).value.S).runner_config_storage.table_name == "github-actions-runner-state" + && jsondecode(jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).value.S).runner_config_storage.access_scope == "compute-resource" + && jsondecode(jsondecode(aws_dynamodb_table_item.runner_config["linux"].item).value.S).runner_config_storage.id == "config" + ) + error_message = "Each entry must receive one durable bootstrap record that points at its scope in the shared runner-state table." + } +} + +run "storage_version_tracks_global_record_changes" { + command = apply + + variables { + global_records = { + github_app_credentials = jsonencode([{ appId = 123456, privateKeyBase64 = "dGVzdA==" }]) + github_webhook_secret = "rotated-test-secret" + runner_matcher_config = jsonencode([{ key = "linux" }]) + } + } + + assert { + condition = ( + output.capabilities.webhook.direct.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.entries["linux"].job_retry.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + ) + error_message = "A durable global-record update must publish the replacement storage resource ID to every Lambda capability." + } +} + +run "matcher_version_tracks_matcher_content" { + command = apply + + variables { + global_records = { + github_app_credentials = jsonencode([{ appId = 123456, privateKeyBase64 = "dGVzdA==" }]) + github_webhook_secret = "rotated-test-secret" + runner_matcher_config = jsonencode([{ key = "microvm" }]) + } + } + + assert { + condition = ( + output.capabilities.webhook.direct.environment_variables["RUNNER_MATCHER_CONFIG_VERSION"] == sha256(jsonencode([{ key = "microvm" }])) + && output.capabilities.webhook.direct.environment_variables["RUNNER_MATCHER_CONFIG_VERSION"] != sha256(jsonencode([{ key = "linux" }])) + && output.capabilities.webhook.direct.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + ) + error_message = "The matcher and opaque storage versions must change without exposing the matcher payload whenever the durable matcher record changes." + } +} + +run "storage_version_tracks_entry_record_changes" { + command = apply + + variables { + global_records = { + github_app_credentials = jsonencode([{ appId = 123456, privateKeyBase64 = "dGVzdA==" }]) + github_webhook_secret = "rotated-test-secret" + runner_matcher_config = jsonencode([{ key = "microvm" }]) + } + entry_records = { + linux = { + run_as = "root" + agent_mode = "ephemeral" + disable_default_labels = false + enable_jit_config = true + } + microvm = { + run_as = "root" + agent_mode = "ephemeral" + disable_default_labels = true + enable_jit_config = true + } + } + } + + assert { + condition = ( + output.capabilities.entries["linux"].scale_up.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + && output.capabilities.webhook.eventbridge.webhook.environment_variables["RUNNER_CONFIG_STORAGE_VERSION"] == terraform_data.config_version.id + ) + error_message = "A durable entry-record update must publish the replacement storage resource ID to every Lambda capability." + } +} + +run "rejects_missing_runner_config_access_scope_prefix" { + command = plan + + variables { + runner_config_access_scope_prefixes = { + linux = "arn:aws:ec2:eu-west-1:123456789012:instance/" + } + } + + expect_failures = [terraform_data.config_version] +} + +run "rejects_runner_state_ttl_not_greater_than_runner_config_ttl" { + command = plan + + variables { + runner_state_ttl_seconds = 3600 + } + + expect_failures = [terraform_data.config_version] +} + +run "rejects_missing_entry_record" { + command = plan + + variables { + entry_records = { + linux = { + run_as = "runner" + agent_mode = "ephemeral" + disable_default_labels = false + enable_jit_config = true + } + } + } + + expect_failures = [terraform_data.config_version] +} diff --git a/modules/storage-providers/aws/dynamodb/variables.tf b/modules/storage-providers/aws/dynamodb/variables.tf new file mode 100644 index 0000000000..dc4e90badc --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/variables.tf @@ -0,0 +1,84 @@ +variable "prefix" { + description = "Multi-runner prefix used to name the two shared DynamoDB tables." + type = string +} + +variable "tags" { + description = "Base tags added to both shared DynamoDB tables. Table-specific tags override matching keys." + type = map(string) + default = {} +} + +variable "entry_ids" { + description = "Runner-entry identifiers used to build entry-scoped Lambda capabilities." + type = set(string) +} + +variable "runner_config_access_scope_prefixes" { + description = "Per-entry compute-resource scope prefixes used to constrain one-time runner-config writes." + type = map(string) +} + +variable "runner_config_ttl_seconds" { + description = "TTL in seconds for one-time registration and JIT configuration records." + type = number + + validation { + condition = var.runner_config_ttl_seconds > 0 && floor(var.runner_config_ttl_seconds) == var.runner_config_ttl_seconds + error_message = "runner_config_ttl_seconds must be a positive integer." + } +} + +variable "runner_state_ttl_seconds" { + description = "Safety TTL in seconds applied only while lifecycle records are provisioning or terminating; active and orphan inventory has no expiry." + type = number +} + +variable "global_records" { + description = "Terraform-managed values stored under the shared global scope." + type = object({ + github_app_credentials = string + github_webhook_secret = string + runner_matcher_config = string + }) + sensitive = true +} + +variable "entry_records" { + description = "Resolved durable runner bootstrap configuration keyed by runner-entry identifier." + type = map(object({ + run_as = string + agent_mode = string + disable_default_labels = bool + enable_jit_config = bool + })) +} + +variable "config" { + description = <<-EOT + Settings for the shared durable configuration table and ephemeral runner-state table. + + - `config.kms_key_arn`: Optional customer-managed KMS key ARN for durable configuration encryption. Null uses the AWS-owned DynamoDB key. + - `config.point_in_time_recovery_enabled`: Enables point-in-time recovery for durable configuration. + - `config.deletion_protection_enabled`: Enables deletion protection for the durable table. + - `config.tags`: Tags applied after the shared tag map. + - `runner_state.kms_key_arn`: Optional customer-managed KMS key ARN for runner-state encryption. Null uses the AWS-owned DynamoDB key. + - `runner_state.point_in_time_recovery_enabled`: Enables point-in-time recovery for ephemeral runner state. + - `runner_state.deletion_protection_enabled`: Enables deletion protection for the runner-state table. + - `runner_state.tags`: Tags applied after the shared tag map. + EOT + type = object({ + config = object({ + kms_key_arn = optional(string, null) + point_in_time_recovery_enabled = optional(bool, true) + deletion_protection_enabled = optional(bool, false) + tags = optional(map(string), {}) + }) + runner_state = object({ + kms_key_arn = optional(string, null) + point_in_time_recovery_enabled = optional(bool, false) + deletion_protection_enabled = optional(bool, false) + tags = optional(map(string), {}) + }) + }) +} diff --git a/modules/storage-providers/aws/dynamodb/versions.tf b/modules/storage-providers/aws/dynamodb/versions.tf new file mode 100644 index 0000000000..3ef011ea0a --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.4.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +} diff --git a/modules/webhook/README.md b/modules/webhook/README.md index 70121458a7..7c787729bb 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -91,6 +91,7 @@ yarn run dist | [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | | [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`. |
map(object({
arn = string
id = string
computeProvider = optional(string, "ec2")
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| n/a | yes | | [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
webhook = string
})
| n/a | yes | +| [storage\_provider](#input\_storage\_provider) | Selected storage-provider type and opaque capabilities used by the webhook and optional dispatcher Lambdas. |
object({
type = optional(string, "aws_ssm")
direct = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
eventbridge = object({
webhook = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
dispatcher = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
})
})
|
{
"direct": {
"environment_variables": {},
"iam_policy_json": null
},
"eventbridge": {
"dispatcher": {
"environment_variables": {},
"iam_policy_json": null
},
"webhook": {
"environment_variables": {},
"iam_policy_json": null
}
},
"type": "aws_ssm"
}
| no | | [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 | | [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | | [webhook\_lambda\_apigateway\_access\_log\_settings](#input\_webhook\_lambda\_apigateway\_access\_log\_settings) | Access log settings for webhook API gateway. |
object({
destination_arn = string
format = string
})
| `null` | no | diff --git a/modules/webhook/direct/README.md b/modules/webhook/direct/README.md index d639ed6398..0dd69652aa 100644 --- a/modules/webhook/direct/README.md +++ b/modules/webhook/direct/README.md @@ -40,7 +40,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
})
| n/a | yes | +| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
storage_provider = optional(object({
type = optional(string, "aws_ssm")
environment_variables = map(string)
iam_policy_json = optional(string, null)
}), {
type = "aws_ssm"
environment_variables = {}
iam_policy_json = null
})
})
| n/a | yes | ## Outputs diff --git a/modules/webhook/direct/variables.tf b/modules/webhook/direct/variables.tf index 402ac514b4..1729f97b23 100644 --- a/modules/webhook/direct/variables.tf +++ b/modules/webhook/direct/variables.tf @@ -48,5 +48,14 @@ variable "config" { arn = string version = string })) + storage_provider = optional(object({ + type = optional(string, "aws_ssm") + environment_variables = map(string) + iam_policy_json = optional(string, null) + }), { + type = "aws_ssm" + environment_variables = {} + iam_policy_json = null + }) }) } diff --git a/modules/webhook/direct/webhook.tf b/modules/webhook/direct/webhook.tf index 5ef2e1ebfb..a322c3e432 100644 --- a/modules/webhook/direct/webhook.tf +++ b/modules/webhook/direct/webhook.tf @@ -19,20 +19,20 @@ resource "aws_lambda_function" "webhook" { depends_on = [aws_cloudwatch_log_group.webhook] environment { - variables = { + variables = merge({ for k, v in { LOG_LEVEL = upper(var.config.log_level) POWERTOOLS_LOGGER_LOG_EVENT = var.config.log_level == "debug" ? "true" : "false" POWERTOOLS_TRACE_ENABLED = var.config.tracing_config.mode != null ? true : false POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error - PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name + PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.storage_provider.type == "aws_ssm" ? var.config.github_app_parameters.webhook_secret.name : null REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list) QUEUE_SELECTION_STRATEGY = var.config.queue_selection_strategy - PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) - PARAMETER_RUNNER_MATCHER_VERSION = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) # enforce cold start after Changes in SSM parameter + PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.storage_provider.type == "aws_ssm" ? join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) : null + PARAMETER_RUNNER_MATCHER_VERSION = var.config.storage_provider.type == "aws_ssm" ? join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) : null # enforce cold start after Changes in SSM parameter } : k => v if v != null - } + }, var.config.storage_provider.environment_variables) } dynamic "vpc_config" { @@ -125,6 +125,8 @@ resource "aws_iam_role_policy" "webhook_sqs" { } resource "aws_iam_role_policy" "webhook_kms" { + count = var.config.storage_provider.type == "aws_ssm" ? 1 : 0 + name = "kms-policy" role = aws_iam_role.webhook_lambda.name @@ -133,18 +135,23 @@ resource "aws_iam_role_policy" "webhook_kms" { }) } +moved { + from = aws_iam_role_policy.webhook_kms + to = aws_iam_role_policy.webhook_kms[0] +} + resource "aws_iam_role_policy" "webhook_ssm" { name = "publish-ssm-policy" role = aws_iam_role.webhook_lambda.name - policy = templatefile("${path.module}/../policies/lambda-ssm.json", { + policy = var.config.storage_provider.type == "aws_ssm" ? templatefile("${path.module}/../policies/lambda-ssm.json", { resource_arns = jsonencode( concat( [var.config.github_app_parameters.webhook_secret.arn], [for p in var.config.ssm_parameter_runner_matcher_config : p.arn] ) ) - }) + }) : var.config.storage_provider.iam_policy_json } resource "aws_iam_role_policy" "xray" { diff --git a/modules/webhook/eventbridge/README.md b/modules/webhook/eventbridge/README.md index 07aa0bdd61..0cf90dda3f 100644 --- a/modules/webhook/eventbridge/README.md +++ b/modules/webhook/eventbridge/README.md @@ -54,7 +54,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
accept_events = optional(list(string), null)
})
| n/a | yes | +| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
storage_provider = optional(object({
type = optional(string, "aws_ssm")
webhook = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
dispatcher = object({
environment_variables = map(string)
iam_policy_json = optional(string, null)
})
}), {
type = "aws_ssm"
webhook = {
environment_variables = {}
iam_policy_json = null
}
dispatcher = {
environment_variables = {}
iam_policy_json = null
}
})
accept_events = optional(list(string), null)
})
| n/a | yes | ## Outputs diff --git a/modules/webhook/eventbridge/dispatcher.tf b/modules/webhook/eventbridge/dispatcher.tf index 39b65a6d48..3e50f9bafb 100644 --- a/modules/webhook/eventbridge/dispatcher.tf +++ b/modules/webhook/eventbridge/dispatcher.tf @@ -40,7 +40,7 @@ resource "aws_lambda_function" "dispatcher" { depends_on = [aws_cloudwatch_log_group.dispatcher] environment { - variables = { + variables = merge({ for k, v in { LOG_LEVEL = upper(var.config.log_level) POWERTOOLS_LOGGER_LOG_EVENT = var.config.log_level == "debug" ? "true" : "false" @@ -49,12 +49,12 @@ resource "aws_lambda_function" "dispatcher" { POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error # Parameters required for lambda configuration - PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) - PARAMETER_RUNNER_MATCHER_VERSION = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) # enforce cold start after Changes in SSM parameter + PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.storage_provider.type == "aws_ssm" ? join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) : null + PARAMETER_RUNNER_MATCHER_VERSION = var.config.storage_provider.type == "aws_ssm" ? join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) : null # enforce cold start after Changes in SSM parameter REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list) QUEUE_SELECTION_STRATEGY = var.config.queue_selection_strategy } : k => v if v != null - } + }, var.config.storage_provider.dispatcher.environment_variables) } dynamic "vpc_config" { @@ -123,6 +123,8 @@ resource "aws_iam_role_policy" "dispatcher_sqs" { } resource "aws_iam_role_policy" "dispatcher_kms" { + count = var.config.storage_provider.type == "aws_ssm" ? 1 : 0 + name = "kms-policy" role = aws_iam_role.dispatcher_lambda.name @@ -131,17 +133,22 @@ resource "aws_iam_role_policy" "dispatcher_kms" { }) } +moved { + from = aws_iam_role_policy.dispatcher_kms + to = aws_iam_role_policy.dispatcher_kms[0] +} + resource "aws_iam_role_policy" "dispatcher_ssm" { name = "publish-ssm-policy" role = aws_iam_role.dispatcher_lambda.name - policy = templatefile("${path.module}/../policies/lambda-ssm.json", { + policy = var.config.storage_provider.type == "aws_ssm" ? templatefile("${path.module}/../policies/lambda-ssm.json", { resource_arns = jsonencode( concat( [for p in var.config.ssm_parameter_runner_matcher_config : p.arn] ) ) - }) + }) : var.config.storage_provider.dispatcher.iam_policy_json } resource "aws_iam_role_policy" "dispatcher_xray" { diff --git a/modules/webhook/eventbridge/variables.tf b/modules/webhook/eventbridge/variables.tf index c6d35d82d3..91bbd39482 100644 --- a/modules/webhook/eventbridge/variables.tf +++ b/modules/webhook/eventbridge/variables.tf @@ -48,6 +48,27 @@ variable "config" { arn = string version = string })) + storage_provider = optional(object({ + type = optional(string, "aws_ssm") + webhook = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + dispatcher = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + }), { + type = "aws_ssm" + webhook = { + environment_variables = {} + iam_policy_json = null + } + dispatcher = { + environment_variables = {} + iam_policy_json = null + } + }) accept_events = optional(list(string), null) }) } diff --git a/modules/webhook/eventbridge/webhook.tf b/modules/webhook/eventbridge/webhook.tf index 9af03279a2..9d6a01561f 100644 --- a/modules/webhook/eventbridge/webhook.tf +++ b/modules/webhook/eventbridge/webhook.tf @@ -24,7 +24,7 @@ resource "aws_lambda_function" "webhook" { depends_on = [aws_cloudwatch_log_group.webhook] environment { - variables = { + variables = merge({ for k, v in { LOG_LEVEL = upper(var.config.log_level) POWERTOOLS_LOGGER_LOG_EVENT = var.config.log_level == "debug" ? "true" : "false" @@ -35,10 +35,10 @@ resource "aws_lambda_function" "webhook" { # Parameters required for lambda configuration ACCEPT_EVENTS = jsonencode(var.config.accept_events) EVENT_BUS_NAME = aws_cloudwatch_event_bus.main.name - PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name - PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) + PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.storage_provider.type == "aws_ssm" ? var.config.github_app_parameters.webhook_secret.name : null + PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.storage_provider.type == "aws_ssm" ? join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) : null } : k => v if v != null - } + }, var.config.storage_provider.webhook.environment_variables) } dynamic "vpc_config" { @@ -129,12 +129,14 @@ resource "aws_iam_role_policy" "webhook_ssm" { name = "publish-ssm-policy" role = aws_iam_role.webhook_lambda.name - policy = templatefile("${path.module}/../policies/lambda-ssm.json", { + policy = var.config.storage_provider.type == "aws_ssm" ? templatefile("${path.module}/../policies/lambda-ssm.json", { resource_arns = jsonencode([var.config.github_app_parameters.webhook_secret.arn]) - }) + }) : var.config.storage_provider.webhook.iam_policy_json } resource "aws_iam_role_policy" "webhook_kms" { + count = var.config.storage_provider.type == "aws_ssm" ? 1 : 0 + name = "kms-policy" role = aws_iam_role.webhook_lambda.name @@ -143,6 +145,11 @@ resource "aws_iam_role_policy" "webhook_kms" { }) } +moved { + from = aws_iam_role_policy.webhook_kms + to = aws_iam_role_policy.webhook_kms[0] +} + resource "aws_iam_role_policy" "xray" { count = var.config.tracing_config.mode != null ? 1 : 0 name = "xray-policy" diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf index 2e5fafd205..d97e9db88b 100644 --- a/modules/webhook/variables.tf +++ b/modules/webhook/variables.tf @@ -234,6 +234,49 @@ variable "matcher_config_parameter_store_tier" { } } +variable "storage_provider" { + description = "Selected storage-provider type and opaque capabilities used by the webhook and optional dispatcher Lambdas." + type = object({ + type = optional(string, "aws_ssm") + direct = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + eventbridge = object({ + webhook = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + dispatcher = object({ + environment_variables = map(string) + iam_policy_json = optional(string, null) + }) + }) + }) + default = { + type = "aws_ssm" + direct = { + environment_variables = {} + iam_policy_json = null + } + eventbridge = { + webhook = { + environment_variables = {} + iam_policy_json = null + } + dispatcher = { + environment_variables = {} + iam_policy_json = null + } + } + } + + validation { + condition = contains(["aws_ssm", "aws_dynamodb"], var.storage_provider.type) + error_message = "storage_provider.type must be aws_ssm or aws_dynamodb." + } +} + variable "eventbridge" { description = <