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.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.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.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.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.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.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.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.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.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.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.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.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 = [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 = <