From d8bc2a726670cedb139d9478bf1c568ca4a2e4be Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 3 Sep 2026 23:32:05 +0200 Subject: [PATCH 01/38] feat(multi-runner): integrate Lambda MicroVM provider --- .../config.experimental.translation.tf | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index 915b2d9276..8257907442 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -578,30 +578,7 @@ locals { log_files = v.runner_config.runner_log_files tags = v.runner_config.runner_ec2_tags } - microvm = { - image_arn = null - image_version = null - ingress_network_connectors = [] - egress_network_connectors = [] - cloudwatch_agent = { - enabled = true - config = null - } - log_files = null - environment_variables = {} - iam = { - resource_arns = { - images = null - } - additional_policy_json = { - scale_up = null - } - managed_policies = { - scale_up = null - pool = null - } - } - } + microvm = null } } } From 4110ffa6f794d39cd0eb42b1d1359e7c7f4ba379 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:25:22 +0000 Subject: [PATCH 02/38] docs: auto update terraform docs --- modules/multi-runner/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 809b915290..b632ee52aa 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -102,7 +102,7 @@ module "multi-runner" { ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.4 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -110,7 +110,7 @@ module "multi-runner" { ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [aws](#provider\_aws) | 6.63.0 | | [random](#provider\_random) | 3.9.0 | | [terraform](#provider\_terraform) | n/a | @@ -118,7 +118,7 @@ module "multi-runner" { ## Modules | Name | Source | Version | -|------|--------|---------| +| ---- | ------ | ------- | | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -130,7 +130,7 @@ module "multi-runner" { ## Resources | Name | Type | -|------|------| +| ---- | ---- | | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -143,7 +143,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({ arn = string, name = string }))
id = optional(string)
id_ssm = optional(object({ arn = string, name = string }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | @@ -168,7 +168,7 @@ module "multi-runner" { | [global\_config\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
}), {})
}))
| `{}` | no | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | @@ -235,7 +235,7 @@ module "multi-runner" { ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | From 1747ea57ebac0a951f5a53dd373566ebb31085fa Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 23:05:45 +0200 Subject: [PATCH 03/38] fix(multi-runner): align v2 configuration tests --- modules/multi-runner/validations.tf | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/multi-runner/validations.tf b/modules/multi-runner/validations.tf index 5de521c1bb..4be542eb59 100644 --- a/modules/multi-runner/validations.tf +++ b/modules/multi-runner/validations.tf @@ -68,11 +68,10 @@ resource "terraform_data" "validate_v2" { precondition { condition = alltrue([ for config in local.resolved_config.multi_runner_config : ( - try(config.orchestration_provider.webhook != null, false) && - try(length(config.orchestration_provider.webhook.matcherConfig.labelMatchers) > 0, false) + try(config.orchestration_provider.webhook != null, false) ) ]) - error_message = "Each experimental v2 runner lane requires a webhook matcher." + error_message = "Each experimental v2 runner lane requires a webhook provider." } precondition { From e41e27d58ad2482c216c576810738e838d237a7e Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 3 Sep 2026 23:40:52 +0200 Subject: [PATCH 04/38] feat(microvm): add lifecycle hook service --- .../microvm-lifecycle-hooks/README.md | 126 ++++++++ .../microvm-lifecycle-hooks/package.json | 53 ++++ .../runtime-package.json | 3 + .../microvm-lifecycle-hooks/src/contracts.ts | 39 +++ .../microvm-lifecycle-hooks/src/index.ts | 7 + .../src/lifecycle.test.ts | 214 ++++++++++++++ .../microvm-lifecycle-hooks/src/lifecycle.ts | 161 ++++++++++ .../src/payload.test.ts | 116 ++++++++ .../microvm-lifecycle-hooks/src/payload.ts | 101 +++++++ .../src/processes.test.ts | 107 +++++++ .../microvm-lifecycle-hooks/src/processes.ts | 187 ++++++++++++ .../microvm-lifecycle-hooks/src/public.ts | 26 ++ .../src/server.test.ts | 210 +++++++++++++ .../microvm-lifecycle-hooks/src/server.ts | 275 ++++++++++++++++++ .../src/storage.test.ts | 88 ++++++ .../microvm-lifecycle-hooks/src/storage.ts | 49 ++++ .../src/timing.test.ts | 32 ++ .../microvm-lifecycle-hooks/src/timing.ts | 66 +++++ .../microvm-lifecycle-hooks/tsconfig.json | 8 + .../microvm-lifecycle-hooks/vitest.config.ts | 12 + 20 files changed, 1880 insertions(+) create mode 100644 lambdas/services/microvm-lifecycle-hooks/README.md create mode 100644 lambdas/services/microvm-lifecycle-hooks/package.json create mode 100644 lambdas/services/microvm-lifecycle-hooks/runtime-package.json create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/contracts.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/index.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/lifecycle.test.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/lifecycle.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/payload.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/processes.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/public.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/server.test.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/server.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/storage.test.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/storage.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/timing.test.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/src/timing.ts create mode 100644 lambdas/services/microvm-lifecycle-hooks/tsconfig.json create mode 100644 lambdas/services/microvm-lifecycle-hooks/vitest.config.ts diff --git a/lambdas/services/microvm-lifecycle-hooks/README.md b/lambdas/services/microvm-lifecycle-hooks/README.md new file mode 100644 index 0000000000..bcd890f100 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/README.md @@ -0,0 +1,126 @@ +# Lambda MicroVM lifecycle hooks + +This service implements the lifecycle-hook HTTP server used to start one ephemeral GitHub Actions runner inside an AWS Lambda MicroVM. Storage-specific reads and one-time consumption are delegated to `@aws-github-runner/storage-providers`; this package owns only payload validation, lifecycle state, and the runner process boundary. + +## Build and run + +From `lambdas/`: + +```bash +yarn nx test @aws-github-runner/microvm-lifecycle-hooks +yarn workspace @aws-github-runner/microvm-lifecycle-hooks build +yarn workspace @aws-github-runner/microvm-lifecycle-hooks start +``` + +`build` uses NCC to create a self-contained `dist/`. It also writes `dist/package.json` with `type: module`, so the artifact runs after it is copied outside the Yarn workspace. Copy the **entire** directory; do not copy only `index.js`. + +To build before invoking Docker, run the workspace build above. In the existing MicroVM runner Dockerfile, which already installs s6-overlay and the GitHub runner's Node 24 runtime, copy the complete artifact and replace the old hook command with: + +```dockerfile +COPY lambdas/services/microvm-lifecycle-hooks/dist/ /opt/microvm-lifecycle-hooks/ +ENV RUNNER_ENTRYPOINT=/opt/microvm/entrypoint.sh +ENTRYPOINT ["/init"] +CMD ["/command/with-contenv", "/opt/actions-runner/externals/node24/bin/node", "/opt/microvm-lifecycle-hooks/index.js"] +``` + +Alternatively, build the service inside Docker with the repository root as the build context. Add this pinned builder stage: + +```dockerfile +ARG NODE_BUILDER_IMAGE=node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 +FROM ${NODE_BUILDER_IMAGE} AS lifecycle-build +WORKDIR /source +COPY lambdas/ ./lambdas/ +RUN corepack enable \ + && cd lambdas \ + && yarn install --immutable \ + && yarn workspace @aws-github-runner/microvm-lifecycle-hooks build +``` + +Use a clean checkout for that build context, or exclude local `node_modules/`, `coverage/`, and `dist/` directories with `.dockerignore`, so host-built dependencies are not copied into the Linux builder. + +Then copy the builder output into the existing final runner stage and use its supervisor and Node 24 runtime: + +```dockerfile +COPY --from=lifecycle-build \ + /source/lambdas/services/microvm-lifecycle-hooks/dist/ \ + /opt/microvm-lifecycle-hooks/ +ENV RUNNER_ENTRYPOINT=/opt/microvm/entrypoint.sh +ENTRYPOINT ["/init"] +CMD ["/command/with-contenv", "/opt/actions-runner/externals/node24/bin/node", "/opt/microvm-lifecycle-hooks/index.js"] +``` + +For an image without s6-overlay, start the artifact with `node /opt/microvm-lifecycle-hooks/index.js` under that image's process supervisor. The hook binds to `0.0.0.0:8080` by default. Restrict the port to the Lambda MicroVM lifecycle network; the protocol does not add a separate application authentication layer. + +## Run payloads + +AWS sends an outer JSON object whose `runHookPayload` is itself a JSON string. Version 1 remains strict and SSM-specific for backwards compatibility: + +```json +{ + "microvmId": "microvm-bdd2d536-3d87-35e4-8b40-18664608ebc1", + "runHookPayload": "{\"version\":1,\"runnerConfigSsmPath\":\"/github-action-runners/example/token\"}" +} +``` + +Version 1 is translated to the shared allowlisted SSM environment. Version 2 carries the exact environment-variable map under `context.storage`. SSM example: + +```json +{ + "version": 2, + "context": { + "storage": { + "RUNNER_CONFIG_STORAGE_PROVIDER": "aws_ssm", + "SSM_TOKEN_PATH": "/github-action-runners/example/token" + } + } +} +``` + +Both versions reject missing, unknown, or provider-incompatible fields. The SSM storage context accepts only its two keys; AWS credentials, timeout overrides, and arbitrary environment names are rejected. The validated storage map is exported once before the consumer is resolved. A retry may reuse the identical map, but it cannot change storage configuration after initialization. + +`microvmId` is an opaque path-safe `[A-Za-z0-9_.-]{1,256}` value. The resolved storage provider uses it to consume the one-time JIT configuration. Storage context variables are removed from the runner child environment. + +For a rolling upgrade, keep emitting version 1 SSM payloads until every deployed image contains this service. Old images do not understand version 2. + +## Entrypoint contract + +On `/run`, the hook starts `${RUNNER_ENTRYPOINT:-/opt/microvm/entrypoint.sh} run` without a shell. It writes this versioned document to stdin: + +```json +{ + "jitConfig": "", + "microvmId": "", + "version": 1 +} +``` + +The entrypoint must write exactly `ready\n` to file descriptor 3 after the runner is ready. The JIT configuration, storage context, and AWS credential environment variables are not passed to the child process. `/terminate` sends `SIGTERM` to the detached process group and escalates to `SIGKILL` after the grace period. + +After the runner entrypoint exits on its own, the hook closes its HTTP server and exits with status `0` only when the runner exited cleanly. In the documented s6-overlay image layout above, that makes the foreground container command exit so s6 can stop the remaining image services and shut down the application container's PID 1. This path does not require `lambda:TerminateMicrovm` in the runner role. AWS documents only explicit termination and maximum duration as MicroVM termination triggers, so retain trusted control-plane cleanup and the maximum duration as failure backstops, and verify the container-exit behavior against a restored MicroVM before relying on it operationally. + +Useful environment variables are: + +| Variable | Default | Purpose | +| --------------------------------- | ---------------------------- | --------------------------------------------- | +| `HOOK_PORT` | `8080` | Lifecycle-hook HTTP port | +| `RUNNER_ENTRYPOINT` | `/opt/microvm/entrypoint.sh` | Image-specific runner supervisor | +| `RUN_HOOK_TIMEOUT_SECONDS` | `55` | Total `/run` budget, bounded to 40–55 seconds | +| `HOOK_HEADERS_TIMEOUT_SECONDS` | `5` | HTTP header receive timeout | +| `HOOK_REQUEST_TIMEOUT_SECONDS` | `10` | HTTP request receive timeout | +| `HOOK_KEEP_ALIVE_TIMEOUT_SECONDS` | `5` | Idle keep-alive timeout | +| `AWS_SDK_CALL_TIMEOUT_SECONDS` | `5` | Individual storage-provider call timeout | +| `RUNNER_CONFIG_TIMEOUT_SECONDS` | `20` | Total runner-configuration polling timeout | +| `RUNNER_CONFIG_POLL_SECONDS` | `2` | Delay between provider polling attempts | +| `RUNNER_CONFIG_DELETE_ATTEMPTS` | `3` | SSM one-time configuration delete attempts | + +The request body is capped at 20 KiB and HTTP headers at 16 KiB. Internal errors are returned generically and secret-bearing provider errors are never logged. + +## Runtime security + +Removing AWS credential and storage variables from the runner child prevents accidental environment inheritance; it is not an IAM boundary. A job can still obtain credentials made available to the runtime role, so scope that role to each lane and treat job code as untrusted. + +- For SSM, grant only `ssm:GetParameter` and `ssm:DeleteParameter` on the lane's token path. Add `kms:Decrypt` only for the customer-managed key that encrypts those parameters. + +## TypeScript API + +The workspace service root is import-safe; importing it does not start the server. It exports the parser, lifecycle, process launcher, storage adapter, and server factories for composition and testing. `src/index.ts` is the executable-only NCC entrypoint. A producer can call `loadRunnerConfigStorageContextFromEnvironment` from `@aws-github-runner/storage-providers/runner-config-consumer` to copy only the selected provider and locator into `context.storage`. diff --git a/lambdas/services/microvm-lifecycle-hooks/package.json b/lambdas/services/microvm-lifecycle-hooks/package.json new file mode 100644 index 0000000000..4add4eb6ae --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/package.json @@ -0,0 +1,53 @@ +{ + "name": "@aws-github-runner/microvm-lifecycle-hooks", + "version": "1.0.0", + "private": true, + "description": "AWS Lambda MicroVM lifecycle hook server for ephemeral GitHub Actions runners", + "main": "src/public.ts", + "exports": { + ".": "./src/public.ts" + }, + "type": "module", + "license": "MIT", + "engines": { + "node": ">=24" + }, + "scripts": { + "start": "node dist/index.js", + "test": "NODE_ENV=test nx test", + "test:watch": "NODE_ENV=test nx test --watch", + "lint": "eslint src", + "build": "ncc build src/index.ts -o dist && cp runtime-package.json dist/package.json", + "format": "prettier --write \"**/*.{ts,json,md}\"", + "format-check": "prettier --check \"**/*.{ts,json,md}\"", + "all": "yarn build && yarn format && yarn lint && yarn test" + }, + "devDependencies": { + "@types/node": "^22.19.3", + "@vercel/ncc": "^0.38.4" + }, + "dependencies": { + "@aws-github-runner/storage-providers": "*" + }, + "nx": { + "targets": { + "build": { + "inputs": [ + "default", + "^default" + ], + "outputs": [ + "{projectRoot}/dist/**/*" + ] + } + }, + "includedScripts": [ + "build", + "format", + "format-check", + "lint", + "start", + "all" + ] + } +} diff --git a/lambdas/services/microvm-lifecycle-hooks/runtime-package.json b/lambdas/services/microvm-lifecycle-hooks/runtime-package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/runtime-package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/lambdas/services/microvm-lifecycle-hooks/src/contracts.ts b/lambdas/services/microvm-lifecycle-hooks/src/contracts.ts new file mode 100644 index 0000000000..9d49daeeae --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/contracts.ts @@ -0,0 +1,39 @@ +import type { RunnerConfigStorageContext } from '@aws-github-runner/storage-providers/runner-config-consumer'; + +export interface RunContext { + microvmId: string; + storage: RunnerConfigStorageContext; +} + +export interface ConsumeOptions { + deadlineMs: number; + signal: AbortSignal; +} + +export interface RunnerBootstrap { + jitConfig: string; +} + +/** Resolves and consumes a one-time runner configuration without exposing provider details. */ +export interface JitConfigSource { + consume(context: RunContext, options: ConsumeOptions): Promise; +} + +export interface ManagedProcess { + readonly ready: Promise; + readonly exit: Promise; + readonly exited: boolean; + stop(graceMs?: number): Promise; +} + +export interface RunnerLauncher { + launch(bootstrap: RunnerBootstrap, microvmId: string): ManagedProcess; +} + +export interface Logger { + info(message: string, ...values: unknown[]): void; + warn(message: string, ...values: unknown[]): void; + error(message: string, ...values: unknown[]): void; +} + +export const consoleLogger: Logger = console; diff --git a/lambdas/services/microvm-lifecycle-hooks/src/index.ts b/lambdas/services/microvm-lifecycle-hooks/src/index.ts new file mode 100644 index 0000000000..30cde16d7b --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/index.ts @@ -0,0 +1,7 @@ +import { consoleLogger } from './contracts'; +import { main } from './server'; + +void main().catch(() => { + consoleLogger.error('Lambda MicroVM lifecycle hook server failed to start'); + process.exitCode = 1; +}); diff --git a/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.test.ts new file mode 100644 index 0000000000..ea7bc02e73 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.test.ts @@ -0,0 +1,214 @@ +import type { JitConfigSource, Logger, ManagedProcess, RunContext, RunnerBootstrap, RunnerLauncher } from './contracts'; +import { RunnerLifecycle } from './lifecycle'; + +const quietLogger: Logger = { + error: () => undefined, + info: () => undefined, + warn: () => undefined, +}; + +const MICROVM_ID = 'microvm-bdd2d536-3d87-35e4-8b40-18664608ebc1'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function runRequest(): string { + return JSON.stringify({ + microvmId: MICROVM_ID, + runHookPayload: JSON.stringify({ + runnerConfigSsmPath: '/runner/token', + version: 1, + }), + }); +} + +class DeferredProcess implements ManagedProcess { + public readonly ready = Promise.resolve(); + public readonly exit: Promise; + public exited = false; + private resolveExit!: (code: number | null) => void; + + public constructor() { + this.exit = new Promise((resolve) => { + this.resolveExit = resolve; + }); + } + + public finish(code: number | null): void { + this.exited = true; + this.resolveExit(code); + } + + public async stop(): Promise { + if (!this.exited) { + this.finish(null); + } + } +} + +describe('RunnerLifecycle', () => { + it('starts only once and waits for terminate cleanup after the runner exits', async () => { + const events: string[] = []; + const processHandle = new DeferredProcess(); + const source: JitConfigSource = { + async consume(context: RunContext): Promise { + events.push(`consume:${context.storage.RUNNER_CONFIG_STORAGE_PROVIDER}:${context.microvmId}`); + return { jitConfig: 'encoded-jit' }; + }, + }; + const launcher: RunnerLauncher = { + launch(bootstrap, id): ManagedProcess { + events.push(`launch:${id}:${bootstrap.jitConfig}`); + return processHandle; + }, + }; + const lifecycle = new RunnerLifecycle(source, launcher, quietLogger); + + await expect(lifecycle.start(runRequest())).resolves.toBe(true); + await expect(lifecycle.start(runRequest())).resolves.toBe(false); + expect(events).toEqual([`consume:aws_ssm:${MICROVM_ID}`, `launch:${MICROVM_ID}:encoded-jit`]); + + processHandle.finish(0); + await expect(lifecycle.completion).resolves.toBe(0); + await lifecycle.stop(); + expect(processHandle.exited).toBe(true); + }); + + it('does not report an externally requested stop as runner self-completion', async () => { + const processHandle = new DeferredProcess(); + const lifecycle = new RunnerLifecycle( + { consume: async () => ({ jitConfig: 'encoded-jit' }) }, + { launch: () => processHandle }, + quietLogger, + ); + + await lifecycle.start(runRequest()); + await lifecycle.stop(); + + await expect( + Promise.race([ + lifecycle.completion.then(() => 'completed'), + new Promise((resolve) => setImmediate(() => resolve('pending'))), + ]), + ).resolves.toBe('pending'); + }); + + it('reserves the runner startup budget before consuming configuration', async () => { + let consumeDeadline = 0; + const processHandle = new DeferredProcess(); + const lifecycle = new RunnerLifecycle( + { + async consume(_context, options): Promise { + consumeDeadline = options.deadlineMs; + return { jitConfig: 'encoded-jit' }; + }, + }, + { launch: () => processHandle }, + quietLogger, + ); + vi.spyOn(Date, 'now').mockReturnValue(1_000); + + await lifecycle.start(runRequest()); + + expect(consumeDeadline).toBe(21_000); + await lifecycle.stop(); + }); + + it('returns to idle if the configured entrypoint cannot launch', async () => { + const consume = vi.fn().mockResolvedValue({ jitConfig: 'encoded-jit' }); + const lifecycle = new RunnerLifecycle( + { consume }, + { + launch(): ManagedProcess { + throw new Error('spawn failed'); + }, + }, + quietLogger, + ); + + await expect(lifecycle.start(runRequest())).rejects.toThrow('spawn failed'); + await expect(lifecycle.start(runRequest())).rejects.toThrow('spawn failed'); + expect(consume).toHaveBeenCalledTimes(2); + }); + + it('aborts in-flight consumption when the run-hook deadline elapses', async () => { + let consumedSignal: AbortSignal | undefined; + let launched = false; + let releaseConsume = (): void => undefined; + const consumption = new Promise((resolve) => { + releaseConsume = resolve; + }); + const lifecycle = new RunnerLifecycle( + { + async consume(_context, options): Promise { + consumedSignal = options.signal; + await consumption; + return { jitConfig: 'encoded-jit' }; + }, + }, + { + launch(): ManagedProcess { + launched = true; + return new DeferredProcess(); + }, + }, + quietLogger, + ); + let calls = 0; + vi.spyOn(Date, 'now').mockImplementation(() => (calls++ === 0 ? 1_000 : 61_000)); + + await expect(lifecycle.start(runRequest())).rejects.toThrow('run-hook deadline elapsed'); + releaseConsume(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(consumedSignal?.aborted).toBe(true); + expect(launched).toBe(false); + }); + + it('waits for cleanup when terminate races with entrypoint readiness', async () => { + let finishCleanup = (): void => undefined; + let reportLaunched = (): void => undefined; + let stopCalled = false; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + const launched = new Promise((resolve) => { + reportLaunched = resolve; + }); + const processHandle: ManagedProcess = { + ready: new Promise(() => undefined), + exit: new Promise(() => undefined), + exited: false, + async stop(): Promise { + stopCalled = true; + await cleanup; + }, + }; + const lifecycle = new RunnerLifecycle( + { consume: async () => ({ jitConfig: 'encoded-jit' }) }, + { + launch(): ManagedProcess { + reportLaunched(); + return processHandle; + }, + }, + quietLogger, + ); + + const rejectedStart = expect(lifecycle.start(runRequest())).rejects.toThrow('runner start was cancelled'); + await launched; + let terminateSettled = false; + const terminate = lifecycle.stop().then(() => { + terminateSettled = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(stopCalled).toBe(true); + expect(terminateSettled).toBe(false); + + finishCleanup(); + await terminate; + await rejectedStart; + expect(terminateSettled).toBe(true); + }); +}); diff --git a/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.ts b/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.ts new file mode 100644 index 0000000000..4eae1aa091 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.ts @@ -0,0 +1,161 @@ +import type { JitConfigSource, Logger, ManagedProcess, RunnerBootstrap, RunnerLauncher } from './contracts'; +import { consoleLogger } from './contracts'; +import { parseRunRequest } from './payload'; +import { beforeDeadline, beforeDeadlineOrAbort } from './timing'; + +type LifecycleState = 'idle' | 'starting' | 'running' | 'stopping' | 'stopped'; + +function boundedNumber(value: string | undefined, fallback: number, minimum: number, maximum: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(minimum, Math.min(maximum, parsed)) : fallback; +} + +export class RunnerLifecycle { + private readonly runHookBudgetMs = boundedNumber(process.env.RUN_HOOK_TIMEOUT_SECONDS, 55, 40, 55) * 1_000; + // Reserve Lambda's 30-second service readiness window plus five seconds of local margin. + private readonly launchReserveMs = 35_000; + private state: LifecycleState = 'idle'; + private microvmId?: string; + private startAbort?: AbortController; + private startPromise?: Promise; + private runner?: ManagedProcess; + private resolveCompletion!: (exitCode: number | null) => void; + public readonly completion = new Promise((resolve) => { + this.resolveCompletion = resolve; + }); + + public constructor( + private readonly jitConfigSource: JitConfigSource, + private readonly launcher: RunnerLauncher, + private readonly logger: Logger = consoleLogger, + ) {} + + private currentState(): LifecycleState { + return this.state; + } + + public async start(body: string): Promise { + const context = parseRunRequest(body); + const deadlineMs = Date.now() + this.runHookBudgetMs; + + if (this.microvmId === context.microvmId && this.state === 'running') { + return false; + } + if (this.microvmId === context.microvmId && this.state === 'starting') { + if (this.startPromise === undefined) { + throw new Error('runner start state is inconsistent'); + } + await beforeDeadline(this.startPromise, deadlineMs); + if (this.currentState() === 'running') { + return false; + } + throw new Error('the preceding runner start did not succeed'); + } + if (this.state !== 'idle') { + throw new Error('another runner lifecycle is already active in this MicroVM'); + } + + const abort = new AbortController(); + this.state = 'starting'; + this.microvmId = context.microvmId; + this.startAbort = abort; + const startOperation = this.startRunner(context, deadlineMs, abort); + this.startPromise = startOperation; + const clearStartPromise = (): void => { + if (this.startPromise === startOperation) { + this.startPromise = undefined; + } + }; + void startOperation.then(clearStartPromise, clearStartPromise); + try { + await beforeDeadline(startOperation, deadlineMs); + return true; + } catch (error) { + // Cancel the underlying work so a timed-out hook cannot register a runner later. + abort.abort(); + throw error; + } + } + + private async startRunner( + context: ReturnType, + deadlineMs: number, + abort: AbortController, + ): Promise { + let bootstrap: RunnerBootstrap | undefined; + let processHandle: ManagedProcess | undefined; + try { + bootstrap = await this.jitConfigSource.consume(context, { + deadlineMs: deadlineMs - this.launchReserveMs, + signal: abort.signal, + }); + if (abort.signal.aborted) { + throw new Error('runner start was cancelled'); + } + + processHandle = this.launcher.launch(bootstrap, context.microvmId); + await beforeDeadlineOrAbort(processHandle.ready, deadlineMs, abort.signal); + if (abort.signal.aborted || this.state !== 'starting') { + throw new Error('runner start was cancelled'); + } + + this.runner = processHandle; + this.startAbort = undefined; + this.state = 'running'; + this.logger.info('GitHub Actions runner started for MicroVM %s', context.microvmId); + void this.monitorRunner(processHandle); + } catch (error) { + if (processHandle !== undefined) { + await processHandle.stop(); + } + if (this.state === 'stopping') { + this.state = 'stopped'; + } else { + this.state = 'idle'; + this.microvmId = undefined; + } + this.startAbort = undefined; + throw error; + } finally { + // JavaScript strings cannot be zeroized, but release the retained credential promptly. + if (bootstrap !== undefined) { + bootstrap.jitConfig = ''; + } + } + } + + private async monitorRunner(processHandle: ManagedProcess): Promise { + const exitCode = await processHandle.exit; + if (this.runner === processHandle) { + this.runner = undefined; + this.state = 'stopped'; + this.resolveCompletion(exitCode); + } + } + + public async stop(): Promise { + if (this.state === 'idle') { + this.state = 'stopped'; + } else if (this.state === 'starting' || this.state === 'running') { + this.state = 'stopping'; + } + this.startAbort?.abort(); + const starting = this.startPromise; + if (starting !== undefined) { + try { + await starting; + } catch { + // Cancellation is expected when terminate races with /run. + } + } + const running = this.runner; + this.runner = undefined; + await (running?.stop() ?? Promise.resolve()); + this.state = 'stopped'; + } + + public async resume(): Promise { + // Never re-consume a one-time runner configuration on resume. + return true; + } +} diff --git a/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts new file mode 100644 index 0000000000..9af6b7b6a0 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts @@ -0,0 +1,116 @@ +import { HookRequestError, parseRunRequest } from './payload'; + +const MICROVM_ID = 'microvm-bdd2d536-3d87-35e4-8b40-18664608ebc1'; +const SSM_STORAGE = { + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + SSM_TOKEN_PATH: '/github-action-runners/tenant/token', +} as const; +function request( + payload: object = { + runnerConfigSsmPath: '/github-action-runners/tenant/token', + version: 1, + }, + microvmId = MICROVM_ID, +): string { + return JSON.stringify({ + microvmId, + runHookPayload: JSON.stringify(payload), + }); +} + +describe('parseRunRequest', () => { + it('maps the strict version 1 payload to the allowlisted SSM storage environment', () => { + expect(parseRunRequest(request())).toEqual({ + microvmId: MICROVM_ID, + storage: SSM_STORAGE, + }); + }); + + it('preserves version 1 trailing-slash normalization', () => { + expect( + parseRunRequest( + request({ + runnerConfigSsmPath: '/github-action-runners/tenant/token/', + version: 1, + }), + ).storage, + ).toEqual({ + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + SSM_TOKEN_PATH: '/github-action-runners/tenant/token', + }); + }); + + it.each([SSM_STORAGE])('accepts a strict version 2 $RUNNER_CONFIG_STORAGE_PROVIDER storage context', (storage) => { + expect( + parseRunRequest( + request({ + context: { storage }, + version: 2, + }), + ), + ).toEqual({ microvmId: MICROVM_ID, storage }); + }); + + it('accepts opaque path-safe MicroVM identifiers up to 256 characters', () => { + expect(parseRunRequest(request(undefined, 'a'.repeat(256))).microvmId).toHaveLength(256); + expect(parseRunRequest(request(undefined, 'future_id.example-01')).microvmId).toBe('future_id.example-01'); + }); + + it.each([ + ['invalid outer JSON', '{'], + ['an invalid MicroVM identifier', request(undefined, '../vm')], + ['an overlong MicroVM identifier', request(undefined, 'a'.repeat(257))], + ['an unversioned payload', request({ runnerConfigSsmPath: '/runner/token' })], + ['a relative legacy SSM path', request({ runnerConfigSsmPath: 'runner/token', version: 1 })], + ['a root legacy SSM path', request({ runnerConfigSsmPath: '/', version: 1 })], + ['repeated legacy SSM slashes', request({ runnerConfigSsmPath: '/runner//token', version: 1 })], + ['legacy SSM traversal', request({ runnerConfigSsmPath: '/runner/../token', version: 1 })], + [ + 'extra version 1 fields', + request({ encodedJitConfig: 'not-a-real-secret', runnerConfigSsmPath: '/runner/token', version: 1 }), + ], + [ + 'missing version 1 fields', + request({ context: { storage: SSM_STORAGE }, runnerConfigSsmPath: '/runner/token', version: 1 }), + ], + ['missing version 2 context', request({ version: 2 })], + ['missing version 2 storage', request({ context: {}, version: 2 })], + ['extra version 2 fields', request({ context: { storage: SSM_STORAGE }, unexpected: true, version: 2 })], + ['extra version 2 context fields', request({ context: { storage: SSM_STORAGE, unexpected: true }, version: 2 })], + [ + 'an unknown storage provider', + request({ + context: { + storage: { RUNNER_CONFIG_STORAGE_PROVIDER: 'unknown', SSM_TOKEN_PATH: '/runner/token' }, + }, + version: 2, + }), + ], + [ + 'provider-incompatible storage fields', + request({ + context: { + storage: { + ...SSM_STORAGE, + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME: 'runner-config', + }, + }, + version: 2, + }), + ], + [ + 'typed provider fields in the environment map', + request({ context: { storage: { provider: 'aws_ssm', tokenPath: '/runner/token' } }, version: 2 }), + ], + [ + 'AWS credential injection', + request({ context: { storage: { ...SSM_STORAGE, AWS_ACCESS_KEY_ID: 'not-a-real-key' } }, version: 2 }), + ], + [ + 'timeout override injection', + request({ context: { storage: { ...SSM_STORAGE, RUNNER_CONFIG_TIMEOUT_SECONDS: '60' } }, version: 2 }), + ], + ])('rejects %s', (_name, body) => { + expect(() => parseRunRequest(body)).toThrow(HookRequestError); + }); +}); diff --git a/lambdas/services/microvm-lifecycle-hooks/src/payload.ts b/lambdas/services/microvm-lifecycle-hooks/src/payload.ts new file mode 100644 index 0000000000..5dcc60daf9 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/payload.ts @@ -0,0 +1,101 @@ +import { + parseRunnerConfigStorageContext, + type RunnerConfigStorageContext, +} from '@aws-github-runner/storage-providers/runner-config-consumer'; + +import type { RunContext } from './contracts'; + +const MICROVM_ID_PATTERN = /^[A-Za-z0-9_.-]{1,256}$/; + +export const MAX_REQUEST_BYTES = 20 * 1024; + +export class HookRequestError extends Error { + public constructor(message: string) { + super(message); + this.name = 'HookRequestError'; + } +} + +interface LambdaRunRequest { + microvmId?: unknown; + runHookPayload?: unknown; +} + +interface VersionedRunPayload { + version?: unknown; + runnerConfigSsmPath?: unknown; + context?: unknown; +} + +interface VersionTwoContext { + storage?: unknown; +} + +function parseObject(value: string, errorMessage: string): T { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new HookRequestError(errorMessage); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new HookRequestError(errorMessage); + } + return parsed as T; +} + +function hasExactKeys(value: object, expected: readonly string[]): boolean { + const keys = Object.keys(value); + return keys.length === expected.length && keys.every((key) => expected.includes(key)); +} + +function isObject(value: unknown): value is object { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function parseStorageContext(value: unknown): RunnerConfigStorageContext { + try { + return parseRunnerConfigStorageContext(value); + } catch { + // Storage validation details are deliberately not reflected to the hook caller. + throw new HookRequestError('runner configuration storage context is missing or invalid'); + } +} + +export function parseRunRequest(body: string): RunContext { + const request = parseObject(body, 'request body must be a JSON object'); + if (typeof request.microvmId !== 'string' || !MICROVM_ID_PATTERN.test(request.microvmId)) { + throw new HookRequestError('microvmId is missing or invalid'); + } + if (typeof request.runHookPayload !== 'string') { + throw new HookRequestError('runHookPayload must be a JSON string'); + } + + const payload = parseObject(request.runHookPayload, 'runHookPayload must contain valid JSON'); + if (payload.version === 1) { + if (!hasExactKeys(payload, ['version', 'runnerConfigSsmPath'])) { + throw new HookRequestError('version 1 runHookPayload contains unsupported or missing fields'); + } + return { + microvmId: request.microvmId, + storage: parseStorageContext({ + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + SSM_TOKEN_PATH: payload.runnerConfigSsmPath, + }), + }; + } + if (payload.version === 2) { + if (!hasExactKeys(payload, ['version', 'context'])) { + throw new HookRequestError('version 2 runHookPayload contains unsupported or missing fields'); + } + if (!isObject(payload.context) || !hasExactKeys(payload.context, ['storage'])) { + throw new HookRequestError('version 2 context contains unsupported or missing fields'); + } + const context = payload.context as VersionTwoContext; + return { + microvmId: request.microvmId, + storage: parseStorageContext(context.storage), + }; + } + throw new HookRequestError('runHookPayload version must be 1 or 2'); +} diff --git a/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts new file mode 100644 index 0000000000..e56027cf96 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts @@ -0,0 +1,107 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { RunnerEntrypointLauncher } from './processes'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('RunnerEntrypointLauncher', () => { + it('passes the MicroVM id and one-time JIT only through stdin', async () => { + const directory = await mkdtemp(join(tmpdir(), 'microvm-entrypoint-')); + const entrypoint = join(directory, 'entrypoint.sh'); + const output = join(directory, 'output'); + const environmentOutput = join(directory, 'environment-output'); + + await writeFile( + entrypoint, + `#!/bin/sh +set -eu +case "$1" in + run) + cat > "$TEST_ENTRYPOINT_OUTPUT" + printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' \ + "\${ENCODED_JIT_CONFIG-unset}" \ + "\${AWS_ACCESS_KEY_ID-unset}" \ + "\${AWS_SESSION_TOKEN-unset}" \ + "\${AWS_CONTAINER_CREDENTIALS_FULL_URI-unset}" \ + "\${AWS_PROFILE-unset}" \ + "\${AWS_DEFAULT_PROFILE-unset}" \ + "\${AWS_CONFIG_FILE-unset}" \ + "\${AWS_SHARED_CREDENTIALS_FILE-unset}" \ + "\${AWS_CREDENTIAL_EXPIRATION-unset}" \ + "\${RUNNER_CONFIG_STORAGE_PROVIDER-unset}" \ + "\${SSM_TOKEN_PATH-unset}" \ + "\${RUNNER_ALLOW_RUNASROOT-unset}" > "$TEST_ENTRYPOINT_ENV_OUTPUT" + printf 'ready\n' >&3 + ;; + *) exit 2 ;; +esac +`, + { mode: 0o700 }, + ); + + vi.stubEnv('RUNNER_ENTRYPOINT', entrypoint); + vi.stubEnv('TEST_ENTRYPOINT_OUTPUT', output); + vi.stubEnv('TEST_ENTRYPOINT_ENV_OUTPUT', environmentOutput); + vi.stubEnv('ENCODED_JIT_CONFIG', 'test-value'); + vi.stubEnv('AWS_ACCESS_KEY_ID', 'test-value'); + vi.stubEnv('AWS_SESSION_TOKEN', 'test-value'); + vi.stubEnv('AWS_CONTAINER_CREDENTIALS_FULL_URI', 'http://127.0.0.1/credentials'); + vi.stubEnv('AWS_PROFILE', 'test-profile'); + vi.stubEnv('AWS_DEFAULT_PROFILE', 'test-profile'); + vi.stubEnv('AWS_CONFIG_FILE', '/tmp/test-config'); + vi.stubEnv('AWS_SHARED_CREDENTIALS_FILE', '/tmp/test-credentials'); + vi.stubEnv('AWS_CREDENTIAL_EXPIRATION', '2099-01-01T00:00:00Z'); + vi.stubEnv('RUNNER_CONFIG_STORAGE_PROVIDER', 'aws_ssm'); + vi.stubEnv('SSM_TOKEN_PATH', '/runner/token'); + vi.stubEnv('RUNNER_ALLOW_RUNASROOT', '1'); + try { + const processHandle = new RunnerEntrypointLauncher().launch({ jitConfig: 'encoded-jit' }, 'mvm-1234'); + + await processHandle.ready; + await expect(processHandle.exit).resolves.toBe(0); + expect(JSON.parse(await readFile(output, 'utf8'))).toEqual({ + jitConfig: 'encoded-jit', + microvmId: 'mvm-1234', + version: 1, + }); + expect(await readFile(environmentOutput, 'utf8')).toBe( + 'unset|unset|unset|unset|unset|unset|unset|unset|unset|unset|unset|unset', + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); + + it('requires the entrypoint to signal readiness before it exits', async () => { + const directory = await mkdtemp(join(tmpdir(), 'microvm-entrypoint-')); + const entrypoint = join(directory, 'entrypoint.sh'); + await writeFile( + entrypoint, + `#!/bin/sh +set -eu +case "$1" in + run) + cat >/dev/null + exit 7 + ;; + *) exit 2 ;; +esac +`, + { mode: 0o700 }, + ); + + vi.stubEnv('RUNNER_ENTRYPOINT', entrypoint); + try { + const processHandle = new RunnerEntrypointLauncher().launch({ jitConfig: 'encoded-jit' }, 'mvm-1234'); + + await expect(processHandle.ready).rejects.toThrow('exited before signaling readiness'); + await expect(processHandle.exit).resolves.toBe(7); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/lambdas/services/microvm-lifecycle-hooks/src/processes.ts b/lambdas/services/microvm-lifecycle-hooks/src/processes.ts new file mode 100644 index 0000000000..c3670a3c71 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/processes.ts @@ -0,0 +1,187 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { Readable } from 'node:stream'; + +import type { ManagedProcess, RunnerBootstrap, RunnerLauncher } from './contracts'; +import { delay } from './timing'; + +const CREDENTIAL_ENVIRONMENT_VARIABLES = [ + 'AWS_ACCESS_KEY_ID', + 'AWS_CONFIG_FILE', + 'AWS_CONTAINER_AUTHORIZATION_TOKEN', + 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE', + 'AWS_CONTAINER_CREDENTIALS_FULL_URI', + 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', + 'AWS_CREDENTIAL_EXPIRATION', + 'AWS_DEFAULT_PROFILE', + 'AWS_PROFILE', + 'AWS_ROLE_ARN', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SECURITY_TOKEN', + 'AWS_SHARED_CREDENTIALS_FILE', + 'AWS_SESSION_TOKEN', + 'AWS_WEB_IDENTITY_TOKEN_FILE', + 'ENCODED_JIT_CONFIG', + 'RUNNER_CONFIG_STORAGE_PROVIDER', + 'RUNNER_ALLOW_RUNASROOT', + 'SSM_TOKEN_PATH', +] as const; + +function signalProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void { + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) { + return; + } + try { + process.kill(-child.pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + child.kill(signal); + } + } +} + +export class NodeManagedProcess implements ManagedProcess { + public readonly ready: Promise; + public readonly exit: Promise; + + public constructor( + private readonly child: ChildProcess, + readiness: Promise, + private readonly defaultStopGraceMs: number, + ) { + this.ready = readiness; + this.exit = new Promise((resolve) => { + child.once('exit', (code) => resolve(code)); + child.once('error', () => resolve(null)); + }); + } + + public get exited(): boolean { + return this.child.exitCode !== null || this.child.signalCode !== null; + } + + public async stop(graceMs = this.defaultStopGraceMs): Promise { + if (this.exited) { + return; + } + signalProcessGroup(this.child, 'SIGTERM'); + const exitedGracefully = await Promise.race([this.exit.then(() => true), delay(graceMs).then(() => false)]); + if (!exitedGracefully && !this.exited) { + signalProcessGroup(this.child, 'SIGKILL'); + await Promise.race([this.exit, delay(5_000)]); + } + } +} + +function entrypointEnvironment(microvmId: string): NodeJS.ProcessEnv { + const environment = { ...process.env }; + for (const variable of CREDENTIAL_ENVIRONMENT_VARIABLES) { + delete environment[variable]; + } + return { + ...environment, + MICROVM_ID: microvmId, + }; +} + +function waitForEntrypointReady(child: ChildProcess): Promise { + const candidate = child.stdio[3]; + if (!(candidate instanceof Readable)) { + return Promise.reject(new Error('runner entrypoint readiness pipe is unavailable')); + } + const readinessStream: Readable = candidate; + readinessStream.setEncoding('utf8'); + + return new Promise((resolve, reject) => { + let buffer = ''; + let settled = false; + + function cleanup(): void { + readinessStream.off('data', onData); + readinessStream.off('end', onEnd); + readinessStream.off('error', onError); + child.off('error', onError); + } + + function succeed(): void { + if (!settled) { + settled = true; + cleanup(); + resolve(); + } + } + + function fail(error: Error): void { + if (!settled) { + settled = true; + cleanup(); + reject(error); + } + } + + function onData(chunk: string | Buffer): void { + buffer += chunk.toString(); + if (buffer === 'ready\n') { + succeed(); + } else if (buffer.includes('\n') || buffer.length > 64) { + fail(new Error('runner entrypoint emitted an invalid readiness signal')); + } + } + + function onEnd(): void { + fail(new Error('runner entrypoint exited before signaling readiness')); + } + + function onError(error: Error): void { + fail(error); + } + + readinessStream.on('data', onData); + readinessStream.once('end', onEnd); + readinessStream.once('error', onError); + child.once('error', onError); + }); +} + +/** + * Sends the one-time JIT document through stdin to an image-specific supervisor. + * Neither the JIT document nor storage-provider credentials are exported to the runner. + */ +export class RunnerEntrypointLauncher implements RunnerLauncher { + private readonly entrypoint = process.env.RUNNER_ENTRYPOINT ?? '/opt/microvm/entrypoint.sh'; + + public constructor(private readonly stopGraceMs = 30_000) {} + + public launch(bootstrap: RunnerBootstrap, microvmId: string): ManagedProcess { + const child = spawn(this.entrypoint, ['run'], { + detached: true, + env: entrypointEnvironment(microvmId), + stdio: ['pipe', 'inherit', 'inherit', 'pipe'], + }); + const entrypointReady = waitForEntrypointReady(child); + const inputWritten = new Promise((resolve, reject) => { + const fail = (error: Error): void => reject(error); + child.once('error', fail); + child.once('spawn', () => { + if (child.stdin === null) { + reject(new Error('runner entrypoint stdin is unavailable')); + return; + } + child.stdin.once('error', fail); + child.stdin.end( + JSON.stringify({ + jitConfig: bootstrap.jitConfig, + microvmId, + version: 1, + }), + () => { + child.removeListener('error', fail); + child.stdin?.removeListener('error', fail); + resolve(); + }, + ); + }); + }); + const ready = Promise.all([inputWritten, entrypointReady]).then(() => undefined); + return new NodeManagedProcess(child, ready, this.stopGraceMs); + } +} diff --git a/lambdas/services/microvm-lifecycle-hooks/src/public.ts b/lambdas/services/microvm-lifecycle-hooks/src/public.ts new file mode 100644 index 0000000000..32b385b15c --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/public.ts @@ -0,0 +1,26 @@ +export type { + ConsumeOptions, + JitConfigSource, + Logger, + ManagedProcess, + RunContext, + RunnerBootstrap, + RunnerLauncher, +} from './contracts'; +export { consoleLogger } from './contracts'; +export { RunnerLifecycle } from './lifecycle'; +export { HookRequestError, MAX_REQUEST_BYTES, parseRunRequest } from './payload'; +export { NodeManagedProcess, RunnerEntrypointLauncher } from './processes'; +export { + createHookExitRequester, + createDefaultLifecycle, + createHookServer, + HOOK_PREFIX, + main, + parsePositiveInteger, + shutdownHookServer, + watchRunnerCompletion, +} from './server'; +export type { HookLifecycle, HookServerOptions } from './server'; +export { StorageJitConfigSource } from './storage'; +export type { StorageJitConfigSourceOptions } from './storage'; diff --git a/lambdas/services/microvm-lifecycle-hooks/src/server.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/server.test.ts new file mode 100644 index 0000000000..823955d755 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/server.test.ts @@ -0,0 +1,210 @@ +import type { AddressInfo } from 'node:net'; + +import type { Logger } from './contracts'; +import { + createHookExitRequester, + createHookServer, + type HookLifecycle, + HOOK_PREFIX, + parsePositiveInteger, + shutdownHookServer, + watchRunnerCompletion, +} from './server'; + +const quietLogger: Logger = { + error: () => undefined, + info: () => undefined, + warn: () => undefined, +}; + +const idleLifecycle: HookLifecycle = { + resume: async () => true, + start: async () => true, + stop: async () => undefined, +}; + +async function listen(server: ReturnType): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; +} + +async function close(server: ReturnType): Promise { + await new Promise((resolve, reject) => { + server.close((error) => (error === undefined ? resolve() : reject(error))); + server.closeAllConnections(); + }); +} + +describe('hook server', () => { + it('rejects invalid and out-of-range positive integer values', () => { + expect(parsePositiveInteger(undefined, 8080, 65_535)).toBe(8080); + expect(parsePositiveInteger('', 8080, 65_535)).toBe(8080); + expect(parsePositiveInteger('0', 8080, 65_535)).toBe(8080); + expect(parsePositiveInteger('-1', 8080, 65_535)).toBe(8080); + expect(parsePositiveInteger('1.5', 8080, 65_535)).toBe(8080); + expect(parsePositiveInteger('8080http', 8080, 65_535)).toBe(8080); + expect(parsePositiveInteger('65536', 8080, 65_535)).toBe(8080); + expect(parsePositiveInteger('9007199254740992', 8080)).toBe(8080); + expect(parsePositiveInteger('9090', 8080, 65_535)).toBe(9090); + }); + + it('configures bounded request, header, connection, and socket limits', () => { + const server = createHookServer(idleLifecycle, quietLogger, { + headersTimeoutMs: 2_000, + keepAliveTimeoutMs: 3_000, + requestTimeoutMs: 4_000, + }); + + expect(server.headersTimeout).toBe(2_000); + expect(server.keepAliveTimeout).toBe(3_000); + expect(server.requestTimeout).toBe(4_000); + expect(server.maxConnections).toBe(128); + expect(server.maxHeadersCount).toBe(64); + expect(server.maxRequestsPerSocket).toBe(100); + }); + + it('acknowledges build hooks without starting a runner', async () => { + const lifecycle: HookLifecycle = { + resume: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + const server = createHookServer(lifecycle, quietLogger); + const baseUrl = await listen(server); + try { + const ready = await fetch(`${baseUrl}${HOOK_PREFIX}/ready`, { method: 'POST' }); + const validate = await fetch(`${baseUrl}${HOOK_PREFIX}/validate`, { method: 'POST' }); + + expect(ready.status).toBe(200); + await expect(ready.json()).resolves.toEqual({ status: 'ready' }); + expect(validate.status).toBe(200); + await expect(validate.json()).resolves.toEqual({ status: 'validated' }); + expect(lifecycle.start).not.toHaveBeenCalled(); + expect(lifecycle.stop).not.toHaveBeenCalled(); + } finally { + await close(server); + } + }); + + it('rejects oversized request bodies before invoking the lifecycle', async () => { + const lifecycle: HookLifecycle = { + ...idleLifecycle, + start: vi.fn(), + }; + const server = createHookServer(lifecycle, quietLogger); + const baseUrl = await listen(server); + try { + const response = await fetch(`${baseUrl}${HOOK_PREFIX}/run`, { + body: 'x'.repeat(20 * 1024 + 1), + method: 'POST', + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'request body is too large' }); + expect(lifecycle.start).not.toHaveBeenCalled(); + } finally { + await close(server); + } + }); + + it('does not reflect or log secret-bearing internal errors', async () => { + const messages: unknown[] = []; + const logger: Logger = { + error: (...values) => messages.push(...values), + info: () => undefined, + warn: () => undefined, + }; + const lifecycle: HookLifecycle = { + ...idleLifecycle, + start: async () => { + const error = new Error('encoded-jit-secret'); + error.name = 'encoded-jit-secret'; + throw error; + }, + }; + const server = createHookServer(lifecycle, logger); + const baseUrl = await listen(server); + try { + const response = await fetch(`${baseUrl}${HOOK_PREFIX}/run`, { + body: '{}', + method: 'POST', + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ error: 'lifecycle hook failed' }); + expect(JSON.stringify(messages)).not.toContain('encoded-jit-secret'); + } finally { + await close(server); + } + }); + + it('waits for lifecycle cleanup before closing active connections', async () => { + const events: string[] = []; + let finishCleanup = (): void => undefined; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + const server = { + close: () => events.push('stop-accepting'), + closeAllConnections: () => events.push('close-connections'), + }; + const lifecycle = { + async stop(): Promise { + events.push('cleanup-started'); + await cleanup; + events.push('cleanup-finished'); + }, + }; + + const shutdown = shutdownHookServer(server, lifecycle); + await new Promise((resolve) => setImmediate(resolve)); + expect(events).toEqual(['stop-accepting', 'cleanup-started']); + + finishCleanup(); + await shutdown; + expect(events).toEqual(['stop-accepting', 'cleanup-started', 'cleanup-finished', 'close-connections']); + }); + + it.each([ + { expectedExitCode: 0, runnerExitCode: 0 }, + { expectedExitCode: 1, runnerExitCode: 7 }, + { expectedExitCode: 1, runnerExitCode: null }, + ])('requests hook exit $expectedExitCode after runner status $runnerExitCode', async (testCase) => { + const requestExit = vi.fn(); + + watchRunnerCompletion({ completion: Promise.resolve(testCase.runnerExitCode) }, quietLogger, requestExit); + + await Promise.resolve(); + expect(requestExit).not.toHaveBeenCalled(); + await new Promise((resolve) => setImmediate(resolve)); + expect(requestExit).toHaveBeenCalledOnce(); + expect(requestExit).toHaveBeenCalledWith(testCase.expectedExitCode); + }); + + it('closes the hook exactly once before publishing its process exit code', async () => { + const events: string[] = []; + const requestExit = createHookExitRequester( + { + close: () => events.push('stop-accepting'), + closeAllConnections: () => events.push('close-connections'), + }, + { + async stop(): Promise { + events.push('stop-runner'); + }, + }, + quietLogger, + (exitCode) => events.push(`exit:${exitCode}`), + ); + + requestExit(0); + requestExit(1); + await new Promise((resolve) => setImmediate(resolve)); + + expect(events).toEqual(['stop-accepting', 'stop-runner', 'close-connections', 'exit:0']); + }); +}); diff --git a/lambdas/services/microvm-lifecycle-hooks/src/server.ts b/lambdas/services/microvm-lifecycle-hooks/src/server.ts new file mode 100644 index 0000000000..fe735653e7 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/server.ts @@ -0,0 +1,275 @@ +import http, { type IncomingMessage, type ServerResponse } from 'node:http'; + +import type { Logger } from './contracts'; +import { consoleLogger } from './contracts'; +import { RunnerLifecycle } from './lifecycle'; +import { HookRequestError, MAX_REQUEST_BYTES } from './payload'; +import { RunnerEntrypointLauncher } from './processes'; +import { StorageJitConfigSource } from './storage'; + +export const HOOK_PREFIX = '/aws/lambda-microvms/runtime/v1'; + +const MAX_TIMER_SECONDS = 2_147_483; + +export interface HookLifecycle { + start(body: string): Promise; + stop(): Promise; + resume(): Promise; +} + +export interface HookServerOptions { + headersTimeoutMs?: number; + keepAliveTimeoutMs?: number; + requestTimeoutMs?: number; +} + +export function parsePositiveInteger( + value: string | undefined, + fallback: number, + maximum = Number.MAX_SAFE_INTEGER, +): number { + if (value === undefined || !/^\d+$/.test(value)) { + return fallback; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= maximum ? parsed : fallback; +} + +function timeoutMilliseconds(variable: string, fallbackSeconds: number, maximumSeconds = 60): number { + return ( + parsePositiveInteger(process.env[variable], fallbackSeconds, Math.min(maximumSeconds, MAX_TIMER_SECONDS)) * 1_000 + ); +} + +function respond(response: ServerResponse, status: number, payload: object): void { + const body = Buffer.from(JSON.stringify(payload)); + response.writeHead(status, { + 'Cache-Control': 'no-store', + 'Content-Length': body.length, + 'Content-Type': 'application/json', + }); + response.end(body); +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const contentLength = request.headers['content-length']; + let declaredLength: number | undefined; + if (contentLength !== undefined) { + declaredLength = Number(contentLength); + if (!Number.isInteger(declaredLength) || declaredLength < 0) { + reject(new HookRequestError('Content-Length is invalid')); + request.resume(); + return; + } + if (declaredLength > MAX_REQUEST_BYTES) { + reject(new HookRequestError('request body is too large')); + request.resume(); + return; + } + } + + const chunks: Buffer[] = []; + let size = 0; + let settled = false; + + const fail = (error: Error): void => { + if (settled) { + return; + } + settled = true; + reject(error); + }; + request.on('data', (chunk: Buffer) => { + if (settled) { + return; + } + size += chunk.length; + if (size > MAX_REQUEST_BYTES) { + fail(new HookRequestError('request body is too large')); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.once('end', () => { + if (settled) { + return; + } + if (declaredLength !== undefined && declaredLength !== size) { + fail(new HookRequestError('Content-Length does not match the request body')); + return; + } + settled = true; + resolve(Buffer.concat(chunks).toString('utf8')); + }); + request.once('aborted', () => fail(new HookRequestError('request body was interrupted'))); + request.once('error', (error) => fail(error)); + }); +} + +export function createHookServer( + lifecycle: HookLifecycle, + logger: Logger = consoleLogger, + options: HookServerOptions = {}, +): http.Server { + const requestTimeout = options.requestTimeoutMs ?? timeoutMilliseconds('HOOK_REQUEST_TIMEOUT_SECONDS', 10); + const headersTimeout = Math.min( + options.headersTimeoutMs ?? timeoutMilliseconds('HOOK_HEADERS_TIMEOUT_SECONDS', 5), + requestTimeout, + ); + const keepAliveTimeout = options.keepAliveTimeoutMs ?? timeoutMilliseconds('HOOK_KEEP_ALIVE_TIMEOUT_SECONDS', 5); + + const server = http.createServer( + { + headersTimeout, + keepAliveTimeout, + maxHeaderSize: 16 * 1024, + requestTimeout, + }, + async (request, response) => { + const path = request.url ?? ''; + if (request.method !== 'POST') { + request.resume(); + respond(response, 405, { error: 'method not allowed' }); + return; + } + + try { + // Consume every POST body so all lifecycle endpoints share the same bounded request handling. + const body = await readBody(request); + if (path === `${HOOK_PREFIX}/ready`) { + respond(response, 200, { status: 'ready' }); + return; + } + if (path === `${HOOK_PREFIX}/validate`) { + respond(response, 200, { status: 'validated' }); + return; + } + if (path === `${HOOK_PREFIX}/run`) { + const started = await lifecycle.start(body); + respond(response, 200, { status: started ? 'started' : 'already-started' }); + return; + } + if (path === `${HOOK_PREFIX}/terminate`) { + await lifecycle.stop(); + respond(response, 200, { status: 'stopped' }); + return; + } + if (path === `${HOOK_PREFIX}/resume`) { + const ready = await lifecycle.resume(); + respond(response, ready ? 200 : 503, { status: ready ? 'ready' : 'not-ready' }); + return; + } + if (path === `${HOOK_PREFIX}/suspend`) { + respond(response, 200, { status: 'ok' }); + return; + } + respond(response, 404, { error: 'unknown lifecycle hook' }); + } catch (error) { + if (error instanceof HookRequestError) { + logger.warn('Rejected invalid lifecycle hook request'); + respond(response, 400, { error: error.message }); + return; + } + // Parse and provider errors can contain credentials in both message and name. + logger.error('Lifecycle hook failed'); + respond(response, 500, { error: 'lifecycle hook failed' }); + } + }, + ); + server.maxConnections = 128; + server.maxHeadersCount = 64; + server.maxRequestsPerSocket = 100; + return server; +} + +export function createDefaultLifecycle(logger: Logger = consoleLogger): RunnerLifecycle { + return new RunnerLifecycle(new StorageJitConfigSource(), new RunnerEntrypointLauncher(), logger); +} + +interface ClosableServer { + close(): unknown; + closeAllConnections(): void; +} + +interface StoppableLifecycle { + stop(): Promise; +} + +export async function shutdownHookServer(server: ClosableServer, lifecycle: StoppableLifecycle): Promise { + server.close(); + try { + await lifecycle.stop(); + } finally { + server.closeAllConnections(); + } +} + +function hookExitCode(runnerExitCode: number | null): number { + return runnerExitCode === 0 ? 0 : 1; +} + +export function watchRunnerCompletion( + lifecycle: Pick, + logger: Logger, + requestExit: (exitCode: number) => void, +): void { + void lifecycle.completion.then((runnerExitCode) => { + const exitCode = hookExitCode(runnerExitCode); + if (exitCode === 0) { + logger.info('GitHub Actions runner exited with status %s', runnerExitCode); + } else { + logger.error('GitHub Actions runner exited unexpectedly with status %s', runnerExitCode ?? 'signal'); + } + // Let the /run handler flush its acknowledgement if the runner exits immediately after readiness. + setImmediate(() => requestExit(exitCode)); + }); +} + +export function createHookExitRequester( + server: ClosableServer, + lifecycle: StoppableLifecycle, + logger: Logger, + setExitCode: (exitCode: number) => void = (exitCode) => { + // Let Node exit naturally after lifecycle cleanup and log streams have drained. + process.exitCode = exitCode; + }, +): (exitCode: number) => void { + let exiting = false; + return (exitCode: number): void => { + if (exiting) { + return; + } + exiting = true; + void shutdownHookServer(server, lifecycle).then( + () => setExitCode(exitCode), + () => { + logger.error('Lifecycle hook shutdown failed'); + setExitCode(1); + }, + ); + }; +} + +export async function main(): Promise { + const logger = consoleLogger; + const lifecycle = createDefaultLifecycle(logger); + const server = createHookServer(lifecycle, logger); + const port = parsePositiveInteger(process.env.HOOK_PORT, 8080, 65_535); + + const requestExit = createHookExitRequester(server, lifecycle, logger); + process.once('SIGINT', () => requestExit(0)); + process.once('SIGTERM', () => requestExit(0)); + watchRunnerCompletion(lifecycle, logger, requestExit); + + await new Promise((resolve, reject) => { + const onError = (): void => reject(new Error('lifecycle hook server could not listen')); + server.once('error', onError); + server.listen(port, '0.0.0.0', () => { + server.off('error', onError); + logger.info('Lambda MicroVM lifecycle hooks listening on port %d', port); + resolve(); + }); + }); +} diff --git a/lambdas/services/microvm-lifecycle-hooks/src/storage.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/storage.test.ts new file mode 100644 index 0000000000..b174be6e50 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/storage.test.ts @@ -0,0 +1,88 @@ +import type { + RunnerConfigConsumer, + RunnerConfigStorageContext, +} from '@aws-github-runner/storage-providers/runner-config-consumer'; + +import { StorageJitConfigSource } from './storage'; + +const SSM_STORAGE: RunnerConfigStorageContext = { + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + SSM_TOKEN_PATH: '/github-action-runners/tenant/token', +}; + +describe('StorageJitConfigSource', () => { + it('exports the allowlisted context once before resolving and consuming from the environment', async () => { + const events: string[] = []; + const environment: NodeJS.ProcessEnv = {}; + const consumer: RunnerConfigConsumer = { + consume: vi.fn(async () => { + events.push('consume'); + return 'encoded-jit'; + }), + }; + const exportEnvironment = vi.fn((context: RunnerConfigStorageContext, target: NodeJS.ProcessEnv) => { + events.push('export'); + Object.assign(target, context); + }); + const createConsumer = vi.fn((target: NodeJS.ProcessEnv) => { + events.push('create'); + expect(target).toBe(environment); + expect(target).toMatchObject(SSM_STORAGE); + return consumer; + }); + const source = new StorageJitConfigSource({ createConsumer, environment, exportEnvironment }); + const signal = new AbortController().signal; + + await expect( + source.consume({ microvmId: 'microvm-1234', storage: SSM_STORAGE }, { deadlineMs: 123_456, signal }), + ).resolves.toEqual({ jitConfig: 'encoded-jit' }); + await expect( + source.consume( + { + microvmId: 'microvm-1234', + storage: { + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + SSM_TOKEN_PATH: '/github-action-runners/tenant/token', + }, + }, + { deadlineMs: 123_457, signal }, + ), + ).resolves.toEqual({ jitConfig: 'encoded-jit' }); + + expect(events).toEqual(['export', 'create', 'consume', 'create', 'consume']); + expect(exportEnvironment).toHaveBeenCalledOnce(); + expect(createConsumer).toHaveBeenCalledTimes(2); + expect(consumer.consume).toHaveBeenNthCalledWith(1, 'microvm-1234', { + deadlineMs: 123_456, + signal, + }); + }); + + it('rejects storage context changes after the one-time environment export', async () => { + const environment: NodeJS.ProcessEnv = {}; + const consumer: RunnerConfigConsumer = { consume: vi.fn().mockResolvedValue('encoded-jit') }; + const exportEnvironment = vi.fn((context: RunnerConfigStorageContext, target: NodeJS.ProcessEnv) => { + Object.assign(target, context); + }); + const createConsumer = vi.fn().mockReturnValue(consumer); + const source = new StorageJitConfigSource({ createConsumer, environment, exportEnvironment }); + const options = { deadlineMs: 123_456, signal: new AbortController().signal }; + + await source.consume({ microvmId: 'microvm-1234', storage: SSM_STORAGE }, options); + await expect( + source.consume( + { + microvmId: 'microvm-1234', + storage: { + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + SSM_TOKEN_PATH: '/github-action-runners/other/token', + }, + }, + options, + ), + ).rejects.toThrow('storage context cannot change'); + + expect(exportEnvironment).toHaveBeenCalledOnce(); + expect(createConsumer).toHaveBeenCalledOnce(); + }); +}); diff --git a/lambdas/services/microvm-lifecycle-hooks/src/storage.ts b/lambdas/services/microvm-lifecycle-hooks/src/storage.ts new file mode 100644 index 0000000000..4e50a4c9b0 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/storage.ts @@ -0,0 +1,49 @@ +import { + createRunnerConfigConsumerFromEnvironment, + exportRunnerConfigStorageEnvironment, + type RunnerConfigConsumer, + type RunnerConfigStorageContext, +} from '@aws-github-runner/storage-providers/runner-config-consumer'; + +import type { ConsumeOptions, JitConfigSource, RunContext, RunnerBootstrap } from './contracts'; + +type RunnerConfigConsumerFactory = typeof createRunnerConfigConsumerFromEnvironment; +type RunnerConfigStorageExporter = typeof exportRunnerConfigStorageEnvironment; + +export interface StorageJitConfigSourceOptions { + createConsumer?: RunnerConfigConsumerFactory; + environment?: NodeJS.ProcessEnv; + exportEnvironment?: RunnerConfigStorageExporter; +} + +function storageContextFingerprint(context: RunnerConfigStorageContext): string { + return JSON.stringify(Object.entries(context).sort(([left], [right]) => left.localeCompare(right))); +} + +/** Adapts the shared provider registry to the lifecycle's one-time bootstrap contract. */ +export class StorageJitConfigSource implements JitConfigSource { + private readonly createConsumer: RunnerConfigConsumerFactory; + private readonly environment: NodeJS.ProcessEnv; + private readonly exportEnvironment: RunnerConfigStorageExporter; + private exportedStorageFingerprint?: string; + + public constructor(options: StorageJitConfigSourceOptions = {}) { + this.createConsumer = options.createConsumer ?? createRunnerConfigConsumerFromEnvironment; + this.environment = options.environment ?? process.env; + this.exportEnvironment = options.exportEnvironment ?? exportRunnerConfigStorageEnvironment; + } + + public async consume(context: RunContext, options: ConsumeOptions): Promise { + const fingerprint = storageContextFingerprint(context.storage); + if (this.exportedStorageFingerprint === undefined) { + this.exportEnvironment(context.storage, this.environment); + this.exportedStorageFingerprint = fingerprint; + } else if (this.exportedStorageFingerprint !== fingerprint) { + throw new Error('runner configuration storage context cannot change after initialization'); + } + + const consumer: RunnerConfigConsumer = this.createConsumer(this.environment); + const jitConfig = await consumer.consume(context.microvmId, options); + return { jitConfig }; + } +} diff --git a/lambdas/services/microvm-lifecycle-hooks/src/timing.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/timing.test.ts new file mode 100644 index 0000000000..b62d8af038 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/timing.test.ts @@ -0,0 +1,32 @@ +import { beforeDeadlineOrAbort, delay } from './timing'; + +describe('timing helpers', () => { + it('removes the delay abort listener after resolving', async () => { + const signal = new AbortController().signal; + const remove = vi.spyOn(signal, 'removeEventListener'); + + await delay(1, signal); + + expect(remove).toHaveBeenCalledOnce(); + }); + + it('removes the delay abort listener after cancellation', async () => { + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, 'removeEventListener'); + const pending = delay(1_000, controller.signal); + + controller.abort(); + + await expect(pending).rejects.toThrow('operation was cancelled'); + expect(remove).toHaveBeenCalledOnce(); + }); + + it('rejects immediately when an operation is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + beforeDeadlineOrAbort(Promise.resolve('unused'), Date.now() + 1_000, controller.signal), + ).rejects.toThrow('runner start was cancelled'); + }); +}); diff --git a/lambdas/services/microvm-lifecycle-hooks/src/timing.ts b/lambdas/services/microvm-lifecycle-hooks/src/timing.ts new file mode 100644 index 0000000000..fc791d2e29 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/src/timing.ts @@ -0,0 +1,66 @@ +export function delay(milliseconds: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = (): void => signal?.removeEventListener('abort', cancel); + const finish = (): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(); + }; + const timer = setTimeout(finish, milliseconds); + const cancel = (): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + cleanup(); + reject(new Error('operation was cancelled')); + }; + signal?.addEventListener('abort', cancel, { once: true }); + if (signal?.aborted) { + cancel(); + } + }); +} + +export async function beforeDeadline(promise: Promise, deadlineMs: number): Promise { + const remaining = deadlineMs - Date.now(); + if (remaining <= 0) { + throw new Error('run-hook deadline elapsed'); + } + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('run-hook deadline elapsed')), remaining); + }); + try { + return await Promise.race([promise, deadline]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +export async function beforeDeadlineOrAbort( + promise: Promise, + deadlineMs: number, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + throw new Error('runner start was cancelled'); + } + let cancel = (): void => undefined; + const cancelled = new Promise((_resolve, reject) => { + cancel = (): void => reject(new Error('runner start was cancelled')); + signal.addEventListener('abort', cancel, { once: true }); + }); + try { + return await beforeDeadline(Promise.race([promise, cancelled]), deadlineMs); + } finally { + signal.removeEventListener('abort', cancel); + } +} diff --git a/lambdas/services/microvm-lifecycle-hooks/tsconfig.json b/lambdas/services/microvm-lifecycle-hooks/tsconfig.json new file mode 100644 index 0000000000..714aa27b6b --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/lambdas/services/microvm-lifecycle-hooks/vitest.config.ts b/lambdas/services/microvm-lifecycle-hooks/vitest.config.ts new file mode 100644 index 0000000000..e3c59146ee --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/vitest.config.ts @@ -0,0 +1,12 @@ +import { mergeConfig } from 'vitest/config'; + +import defaultConfig from '../../vitest.base.config'; + +export default mergeConfig(defaultConfig, { + test: { + coverage: { + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts'], + }, + }, +}); From 360cdf9c2445d24b3f606572f8a61d58aa8cc79a Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 01:18:15 +0200 Subject: [PATCH 05/38] test(microvm): remove legacy DynamoDB payload case --- .../microvm-lifecycle-hooks/src/payload.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts index 9af6b7b6a0..170be297c0 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts @@ -86,18 +86,6 @@ describe('parseRunRequest', () => { version: 2, }), ], - [ - 'provider-incompatible storage fields', - request({ - context: { - storage: { - ...SSM_STORAGE, - RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME: 'runner-config', - }, - }, - version: 2, - }), - ], [ 'typed provider fields in the environment map', request({ context: { storage: { provider: 'aws_ssm', tokenPath: '/runner/token' } }, version: 2 }), From 6f1ead89c5b6219a89d85401a466a0547b8e8e6c Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 8 Sep 2026 22:18:09 +0200 Subject: [PATCH 06/38] fix(microvm): align storage environment export --- .../services/microvm-lifecycle-hooks/src/storage.test.ts | 9 ++++----- lambdas/services/microvm-lifecycle-hooks/src/storage.ts | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/lambdas/services/microvm-lifecycle-hooks/src/storage.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/storage.test.ts index b174be6e50..d1f2ad3400 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/storage.test.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/storage.test.ts @@ -20,9 +20,9 @@ describe('StorageJitConfigSource', () => { return 'encoded-jit'; }), }; - const exportEnvironment = vi.fn((context: RunnerConfigStorageContext, target: NodeJS.ProcessEnv) => { + const exportEnvironment = vi.fn((context: RunnerConfigStorageContext) => { events.push('export'); - Object.assign(target, context); + return context; }); const createConsumer = vi.fn((target: NodeJS.ProcessEnv) => { events.push('create'); @@ -51,6 +51,7 @@ describe('StorageJitConfigSource', () => { expect(events).toEqual(['export', 'create', 'consume', 'create', 'consume']); expect(exportEnvironment).toHaveBeenCalledOnce(); + expect(exportEnvironment).toHaveBeenCalledWith(SSM_STORAGE); expect(createConsumer).toHaveBeenCalledTimes(2); expect(consumer.consume).toHaveBeenNthCalledWith(1, 'microvm-1234', { deadlineMs: 123_456, @@ -61,9 +62,7 @@ describe('StorageJitConfigSource', () => { it('rejects storage context changes after the one-time environment export', async () => { const environment: NodeJS.ProcessEnv = {}; const consumer: RunnerConfigConsumer = { consume: vi.fn().mockResolvedValue('encoded-jit') }; - const exportEnvironment = vi.fn((context: RunnerConfigStorageContext, target: NodeJS.ProcessEnv) => { - Object.assign(target, context); - }); + const exportEnvironment = vi.fn((context: RunnerConfigStorageContext) => context); const createConsumer = vi.fn().mockReturnValue(consumer); const source = new StorageJitConfigSource({ createConsumer, environment, exportEnvironment }); const options = { deadlineMs: 123_456, signal: new AbortController().signal }; diff --git a/lambdas/services/microvm-lifecycle-hooks/src/storage.ts b/lambdas/services/microvm-lifecycle-hooks/src/storage.ts index 4e50a4c9b0..528683f59e 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/storage.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/storage.ts @@ -36,7 +36,7 @@ export class StorageJitConfigSource implements JitConfigSource { public async consume(context: RunContext, options: ConsumeOptions): Promise { const fingerprint = storageContextFingerprint(context.storage); if (this.exportedStorageFingerprint === undefined) { - this.exportEnvironment(context.storage, this.environment); + Object.assign(this.environment, this.exportEnvironment(context.storage)); this.exportedStorageFingerprint = fingerprint; } else if (this.exportedStorageFingerprint !== fingerprint) { throw new Error('runner configuration storage context cannot change after initialization'); From 8c1c06a6aca48d6354be7fd258e20e7ed9788b1f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:40:33 +0000 Subject: [PATCH 07/38] docs: auto update terraform docs --- modules/compute-providers/aws/microvm/README.md | 2 +- modules/compute-providers/aws/microvm/trust-policy/README.md | 2 +- modules/multi-runner/README.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/compute-providers/aws/microvm/README.md b/modules/compute-providers/aws/microvm/README.md index 07ecf5d50f..a80d04a9d2 100644 --- a/modules/compute-providers/aws/microvm/README.md +++ b/modules/compute-providers/aws/microvm/README.md @@ -13,7 +13,7 @@ The resolved provider-neutral `runner.iam.role` is passed to Lambda as the Micro | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [terraform](#requirement\_terraform) | >= 1.5.6 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/compute-providers/aws/microvm/trust-policy/README.md b/modules/compute-providers/aws/microvm/trust-policy/README.md index 43302d1055..857ad8013d 100644 --- a/modules/compute-providers/aws/microvm/trust-policy/README.md +++ b/modules/compute-providers/aws/microvm/trust-policy/README.md @@ -7,7 +7,7 @@ This internal submodule builds the MicroVM runner-role trust policy independentl | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.4.0 | +| [terraform](#requirement\_terraform) | >= 1.5.6 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index b632ee52aa..deafdf3deb 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -160,6 +160,7 @@ module "multi-runner" { | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
})
| `{}` | no | | [experimental\_features](#input\_experimental\_features) | Explicit acknowledgement for opt-in features whose schemas may change
while experimental. Set to ["multi-runner-v2"] when using the v2
provider-boundary configuration. This flag will become a deprecated no-op
for one release when the feature graduates. | `set(string)` | `[]` | no | +| [experimental\_multi\_runner\_config](#input\_experimental\_multi\_runner\_config) | Experimental per-runner and per-lane overrides. |
map(object({
tags = optional(map(string), {})

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

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

orchestration_provider = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

}), null)

})

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

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

compute_provider = object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
})

}))
| `{}` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. .However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | | [github\_app](#input\_github\_app) | GitHub app parameters for the stable v1 interface, see your github app.
Omit this value when using the experimental v2 interface and provide the
app through `global_config_github` instead.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| `{}` | no | From 702d2e9af2ffab5ca7195a1718428c4ea39c9abd Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 17 Sep 2026 15:17:49 +0200 Subject: [PATCH 08/38] chore(pr): scope lifecycle hook change to service --- .../compute-providers/aws/microvm/README.md | 2 +- .../aws/microvm/trust-policy/README.md | 2 +- modules/multi-runner/README.md | 17 ++++++------- .../config.experimental.translation.tf | 25 ++++++++++++++++++- modules/multi-runner/validations.tf | 5 ++-- 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/modules/compute-providers/aws/microvm/README.md b/modules/compute-providers/aws/microvm/README.md index a80d04a9d2..07ecf5d50f 100644 --- a/modules/compute-providers/aws/microvm/README.md +++ b/modules/compute-providers/aws/microvm/README.md @@ -13,7 +13,7 @@ The resolved provider-neutral `runner.iam.role` is passed to Lambda as the Micro | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/compute-providers/aws/microvm/trust-policy/README.md b/modules/compute-providers/aws/microvm/trust-policy/README.md index 857ad8013d..43302d1055 100644 --- a/modules/compute-providers/aws/microvm/trust-policy/README.md +++ b/modules/compute-providers/aws/microvm/trust-policy/README.md @@ -7,7 +7,7 @@ This internal submodule builds the MicroVM runner-role trust policy independentl | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [terraform](#requirement\_terraform) | >= 1.4.0 | | [aws](#requirement\_aws) | >= 6.33 | ## Providers diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index deafdf3deb..809b915290 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -102,7 +102,7 @@ module "multi-runner" { ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.4 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -110,7 +110,7 @@ module "multi-runner" { ## Providers | Name | Version | -| ---- | ------- | +|------|---------| | [aws](#provider\_aws) | 6.63.0 | | [random](#provider\_random) | 3.9.0 | | [terraform](#provider\_terraform) | n/a | @@ -118,7 +118,7 @@ module "multi-runner" { ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ../ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ../termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ../runner-binaries-syncer | n/a | @@ -130,7 +130,7 @@ module "multi-runner" { ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -143,7 +143,7 @@ module "multi-runner" { ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for random API rate limit distribution.

The primary app (var.github\_app) is always included and is the one whose
webhook secret is used for incoming webhook signature validation. Only the
primary app needs a webhook configured in GitHub.

Additional apps listed here are used exclusively by the control-plane
lambdas (scale-up, scale-down, pool, job-retry) which randomly select an
app for each GitHub API call. Each additional app must be installed on the
same repositories/organizations as the primary app. |
list(object({
key_base64 = optional(string)
key_base64_ssm = optional(object({ arn = string, name = string }))
id = optional(string)
id_ssm = optional(object({ arn = string, name = string }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
}))
| `[]` | no | | [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. |
object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
})
| `{}` | no | | [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no | @@ -160,7 +160,6 @@ module "multi-runner" { | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
})
| `{}` | no | | [experimental\_features](#input\_experimental\_features) | Explicit acknowledgement for opt-in features whose schemas may change
while experimental. Set to ["multi-runner-v2"] when using the v2
provider-boundary configuration. This flag will become a deprecated no-op
for one release when the feature graduates. | `set(string)` | `[]` | no | -| [experimental\_multi\_runner\_config](#input\_experimental\_multi\_runner\_config) | Experimental per-runner and per-lane overrides. |
map(object({
tags = optional(map(string), {})

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

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

orchestration_provider = object({
webhook = optional(object({
runner = optional(object({
boot_time_in_minutes = optional(number, null)
ephemeral = optional(bool, null)
jit_config_enabled = optional(bool, null)
maximum_count = optional(number, null)
}), {})

github = optional(object({
organization_runners = optional(bool, false)
}), {})

matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
dynamic_labels_enabled = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})

queue = optional(object({
delay_webhook_event = optional(number, null)
job_queue_retention_in_seconds = optional(number, null)
visibility_timeout_seconds = optional(number, null)
redrive_build_queue = optional(object({
enabled = optional(bool, null)
maxReceiveCount = optional(number, null)
}), null)
tags = optional(map(string), {})
}), {})

lambda = optional(object({
scale = optional(object({
up = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
job_queued_check_enabled = optional(bool, null)
event_source_mapping = optional(object({
batch_size = optional(number, null)
maximum_batching_window_in_seconds = optional(number, null)
}), {})
tags = optional(map(string), {})
}), {})
down = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
schedule_expression = optional(string, null)
minimum_running_time_in_minutes = optional(number, null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), null)
tags = optional(map(string), {})
}), {})
}), {})
pool = optional(object({
memory_size = optional(number, null)
timeout = optional(number, null)
reserved_concurrent_executions = optional(number, null)
config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), null)
include_busy_runners = optional(bool, null)
runner_owner = optional(string, null)
tags = optional(map(string), {})
}), {})
}), {})

job_retry = optional(object({
enabled = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
max_attempts = optional(number, 1)
tags = optional(map(string), {})
lambda = optional(object({
memory_size = optional(number, 256)
reserved_concurrent_executions = optional(number, 1)
timeout = optional(number, 30)
}), {})
}), {})

}), null)

})

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

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

compute_provider = object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = list(string)
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
})

}))
| `{}` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. .However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | | [github\_app](#input\_github\_app) | GitHub app parameters for the stable v1 interface, see your github app.
Omit this value when using the experimental v2 interface and provide the
app through `global_config_github` instead.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| `{}` | no | @@ -169,7 +168,7 @@ module "multi-runner" { | [global\_config\_github](#input\_global\_config\_github) | Global GitHub configuration shared by all runner lanes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
}), {})
}))
| `{}` | no | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | @@ -236,7 +235,7 @@ module "multi-runner" { ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a | | [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a | | [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a | diff --git a/modules/multi-runner/config.experimental.translation.tf b/modules/multi-runner/config.experimental.translation.tf index 8257907442..915b2d9276 100644 --- a/modules/multi-runner/config.experimental.translation.tf +++ b/modules/multi-runner/config.experimental.translation.tf @@ -578,7 +578,30 @@ locals { log_files = v.runner_config.runner_log_files tags = v.runner_config.runner_ec2_tags } - microvm = null + microvm = { + image_arn = null + image_version = null + ingress_network_connectors = [] + egress_network_connectors = [] + cloudwatch_agent = { + enabled = true + config = null + } + log_files = null + environment_variables = {} + iam = { + resource_arns = { + images = null + } + additional_policy_json = { + scale_up = null + } + managed_policies = { + scale_up = null + pool = null + } + } + } } } } diff --git a/modules/multi-runner/validations.tf b/modules/multi-runner/validations.tf index 4be542eb59..5de521c1bb 100644 --- a/modules/multi-runner/validations.tf +++ b/modules/multi-runner/validations.tf @@ -68,10 +68,11 @@ resource "terraform_data" "validate_v2" { precondition { condition = alltrue([ for config in local.resolved_config.multi_runner_config : ( - try(config.orchestration_provider.webhook != null, false) + try(config.orchestration_provider.webhook != null, false) && + try(length(config.orchestration_provider.webhook.matcherConfig.labelMatchers) > 0, false) ) ]) - error_message = "Each experimental v2 runner lane requires a webhook provider." + error_message = "Each experimental v2 runner lane requires a webhook matcher." } precondition { From 4afb4c7bded2526b8245b4df16517bdd23fb6ca4 Mon Sep 17 00:00:00 2001 From: Ederson Brilhante Date: Fri, 18 Sep 2026 19:40:47 +0200 Subject: [PATCH 09/38] chore(microvm): build and publish lifecycle hook (#5380) ## Description Add CI and release automation for the MicroVM lifecycle hook introduced by #5280. The hook is registered as a Yarn workspace, built and tested in the Lambda workflow, packaged as a release asset, and attested alongside the Lambda artifacts. The release workflow uploads `microvm-lifecycle-hooks.zip` and its attestation assets. The storage adapter is aligned with the current storage-provider export API so the new build succeeds on the #5280 base. ## Test Plan - `yarn install --immutable --mode=skip-build` - `yarn prettier --check "services/microvm-lifecycle-hooks/**/*.{ts,json,md}"` - `yarn eslint services/microvm-lifecycle-hooks/src` - `NX_DAEMON=false yarn nx test @aws-github-runner/microvm-lifecycle-hooks` (45 tests passed) - `yarn workspace @aws-github-runner/microvm-lifecycle-hooks build` - Packaged and inspected `microvm-lifecycle-hooks.zip` - YAML parsing and `git diff --check` ## Related Issues Stacked on #5280. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/lambda.yml | 34 ++ .github/workflows/ministack.yml | 61 +++ .github/workflows/release.yml | 72 ++- tests/ministack/README.md | 8 + tests/ministack/run-microvm-smoke.sh | 729 +++++++++++++++++++++++++++ 5 files changed, 885 insertions(+), 19 deletions(-) create mode 100644 tests/ministack/run-microvm-smoke.sh diff --git a/.github/workflows/lambda.yml b/.github/workflows/lambda.yml index 09d96892a1..dbc91358c7 100644 --- a/.github/workflows/lambda.yml +++ b/.github/workflows/lambda.yml @@ -50,3 +50,37 @@ jobs: name: coverage-reports path: ./**/coverage retention-days: 5 + + microvm-lifecycle-hooks: + name: Build MicroVM lifecycle hook + runs-on: ubuntu-latest + container: + image: node:24@sha256:aa648b387728c25f81ff811799bbf8de39df66d7e2d9b3ab55cc6300cb9175d9 + defaults: + run: + working-directory: ./lambdas + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run prettier + run: yarn prettier --check "services/microvm-lifecycle-hooks/**/*.{ts,json,md}" + + - name: Run linter + run: yarn eslint services/microvm-lifecycle-hooks/src + + - name: Run tests + run: yarn nx test @aws-github-runner/microvm-lifecycle-hooks + + - name: Build distribution + run: yarn workspace @aws-github-runner/microvm-lifecycle-hooks build diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 2e7c69d235..0032ecc8ec 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -197,3 +197,64 @@ jobs: MINISTACK_GITHUB_MOCK_PORT: "1080" MINISTACK_GITHUB_MOCK_URL: ${{ steps.mockserver.outputs.url }} run: sh tests/ministack/run-smoke.sh + + integration_smoke_microvm: + name: Run webhook and pool lifecycle smoke test against MiniStack (MicroVM) + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + ministack: + image: ghcr.io/ministackorg/ministack:1.5.10@sha256:706b2b83c6be7e4f4dbb6a0dc28ffdebb500c6c80b64cf7938f45040fb2158e8 + ports: + - 4566:4566 + options: --add-host=host.docker.internal:host-gateway + env: + MINISTACK_ACCOUNT_ID: "000000000000" + MINISTACK_REGION: eu-west-1 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: lambdas/.nvmrc + package-manager-cache: false + + - name: Setup Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: latest + terraform_wrapper: false + + - name: Install Lambda dependencies + working-directory: lambdas + run: yarn install --frozen-lockfile + + - name: Build smoke-test Lambda distributions + working-directory: lambdas + run: | + yarn workspace @aws-github-runner/webhook dist + yarn workspace @aws-github-runner/control-plane dist + + - name: Start MockServer + id: mockserver + uses: mock-server/setup-mockserver@24612c2ccef1f83d587f331ed77cc5cef441e0b1 # v1.0.0 + with: + version: '7.6.0@sha256:80b3b1a26f3553d0c81a3f3896b5b7274c17b2a2e52f0fd2b28e246bc9efa290' + port: '1080' + startup-timeout: '60' + + - name: Run MicroVM webhook and pool lifecycle smoke test + env: + MINISTACK_GITHUB_MOCK_HOST: host.docker.internal + MINISTACK_GITHUB_MOCK_PORT: "1080" + MINISTACK_GITHUB_MOCK_URL: ${{ steps.mockserver.outputs.url }} + run: sh tests/ministack/run-microvm-smoke.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68f5a38341..63d2a006aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,14 @@ jobs: - name: Build dist working-directory: lambdas run: yarn install --frozen-lockfile && yarn run test && yarn dist + + - name: Build MicroVM lifecycle hook + working-directory: lambdas + run: yarn workspace @aws-github-runner/microvm-lifecycle-hooks build + + - name: Package MicroVM lifecycle hook + working-directory: lambdas/services/microvm-lifecycle-hooks + run: (cd dist && zip -r ../microvm-lifecycle-hooks.zip .) - name: Get installation token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: token @@ -61,24 +69,34 @@ jobs: uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: '${{ github.workspace }}/lambdas/functions/**/*.zip' + + - name: Attest MicroVM lifecycle hook + if: ${{ steps.release.outputs.releases_created == 'true' }} + id: lifecycle-hook-attest + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: '${{ github.workspace }}/lambdas/services/microvm-lifecycle-hooks/microvm-lifecycle-hooks.zip' + - name: Update release notes with attestation if: ${{ steps.release.outputs.releases_created == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ github.event.inputs.version }} TAG_NAME: ${{ steps.release.outputs.tag_name }} ATTESTATION_URL: ${{ steps.attest.outputs.attestation-url }} + LIFECYCLE_HOOK_ATTESTATION_URL: ${{ steps.lifecycle-hook-attest.outputs.attestation-url }} REPOSITORY: ${{ github.repository }} run: | - version="${VERSION}" tag_name="${TAG_NAME}" attestation_url="${ATTESTATION_URL}" + lifecycle_hook_attestation_url="${LIFECYCLE_HOOK_ATTESTATION_URL}" repository="${REPOSITORY}" - gh release view $version --json body -q '.body' > new-release-notes.md + gh release view "$tag_name" --json body -q '.body' > new-release-notes.md echo "## Attestation" >> new-release-notes.md - echo "Attestation url: $attestation_url" >> new-release-notes.md + echo "Lambda attestation url: $attestation_url" >> new-release-notes.md + echo "MicroVM lifecycle hook attestation url: $lifecycle_hook_attestation_url" >> new-release-notes.md echo "Verify the artifacts by running \`gh attestation verify --repo ${repository}\`" >> new-release-notes.md - gh release edit $tag_name -F new-release-notes.md -t $tag_name + gh release edit "$tag_name" -F new-release-notes.md -t "$tag_name" + - name: Upload release assets if: ${{ steps.release.outputs.releases_created == 'true' }} env: @@ -86,10 +104,13 @@ jobs: TAG_NAME: ${{ steps.release.outputs.tag_name }} run: | tag_name="${TAG_NAME}" - for f in $(find . -name '*.zip'); do - gh release upload $tag_name $f - done - - name: Attach attestation + while IFS= read -r -d '' f; do + gh release upload "$tag_name" "$f" + done < <(find lambdas/functions -name '*.zip' -print0) + gh release upload "$tag_name" \ + "lambdas/services/microvm-lifecycle-hooks/microvm-lifecycle-hooks.zip" + + - name: Attach Lambda attestation if: ${{ steps.release.outputs.releases_created == 'true' }} env: ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} @@ -99,13 +120,26 @@ jobs: run: | # rename attest bundle to github-aws-runners-terraform-aws-github-runner-attestation-$attestation-id.sigstore # OpenSSF expects the attestation bundle to be named in this format (*.sigstore) - SIGSTORE_BUNDLE=$RUNNER_TEMP/github-aws-runners-terraform-aws-github-runner-attestation-${ATTESTATION_ID}.sigstore - INTOTO_BUNDLE=$RUNNER_TEMP/github-aws-runners-terraform-aws-github-runner-attestation-${ATTESTATION_ID}.intoto.jsonl - mv ${ATTESTATION_BUNDLE} $SIGSTORE_BUNDLE - if [ -z "$SIGSTORE_BUNDLE" ]; then - echo "No attestation bundle found, skipping attachment." - exit 0 - fi - gh release upload $TAG_NAME "$SIGSTORE_BUNDLE" - cat ${SIGSTORE_BUNDLE} | jq -r '.dsseEnvelope | select(.payloadType == "application/vnd.in-toto+json").payload' | base64 -d | jq .> ${INTOTO_BUNDLE} - gh release upload $TAG_NAME "${INTOTO_BUNDLE}" + sigstore_bundle="$RUNNER_TEMP/github-aws-runners-terraform-aws-github-runner-attestation-${ATTESTATION_ID}.sigstore" + intoto_bundle="$RUNNER_TEMP/github-aws-runners-terraform-aws-github-runner-attestation-${ATTESTATION_ID}.intoto.jsonl" + cp "$ATTESTATION_BUNDLE" "$sigstore_bundle" + gh release upload "$TAG_NAME" "$sigstore_bundle" + jq -r '.dsseEnvelope | select(.payloadType == "application/vnd.in-toto+json").payload' "$sigstore_bundle" \ + | base64 --decode > "$intoto_bundle" + gh release upload "$TAG_NAME" "$intoto_bundle" + + - name: Attach MicroVM lifecycle hook attestation + if: ${{ steps.release.outputs.releases_created == 'true' }} + env: + ATTESTATION_BUNDLE: ${{ steps.lifecycle-hook-attest.outputs.bundle-path }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ steps.release.outputs.tag_name }} + ATTESTATION_ID: ${{ steps.lifecycle-hook-attest.outputs.attestation-id }} + run: | + sigstore_bundle="$RUNNER_TEMP/github-aws-runners-microvm-lifecycle-hooks-attestation-${ATTESTATION_ID}.sigstore" + intoto_bundle="$RUNNER_TEMP/github-aws-runners-microvm-lifecycle-hooks-attestation-${ATTESTATION_ID}.intoto.jsonl" + cp "$ATTESTATION_BUNDLE" "$sigstore_bundle" + gh release upload "$TAG_NAME" "$sigstore_bundle" + jq -r '.dsseEnvelope | select(.payloadType == "application/vnd.in-toto+json").payload' "$sigstore_bundle" \ + | base64 --decode > "$intoto_bundle" + gh release upload "$TAG_NAME" "$intoto_bundle" diff --git a/tests/ministack/README.md b/tests/ministack/README.md index 3e6cdc82fa..e3d81b8250 100644 --- a/tests/ministack/README.md +++ b/tests/ministack/README.md @@ -67,6 +67,12 @@ verifies the GitHub API calls and EC2 termination. The pool schedule is configured for a far-future date because the test invokes the Lambda directly. +The MicroVM smoke job runs the same webhook and pool lifecycle chains against +the `microvm` example. It sends the exact `self-hosted`, `linux`, `arm64`, and +`microvm` matcher labels, verifies `RunMicrovm` resources and durable SSM +ownership metadata, and confirms scale-down reaches `TerminateMicrovm` for +both webhook- and pool-created runners. + Build the two real Lambda distributions, start MiniStack, and run: ```sh @@ -74,6 +80,8 @@ Build the two real Lambda distributions, start MiniStack, and run: (cd lambdas && yarn workspace @aws-github-runner/webhook dist) (cd lambdas && yarn workspace @aws-github-runner/control-plane dist) sh tests/ministack/run-smoke.sh +# In CI, the MicroVM-specific job runs: +sh tests/ministack/run-microvm-smoke.sh ``` The smoke script generates a temporary RSA key and Terraform variables file, diff --git a/tests/ministack/run-microvm-smoke.sh b/tests/ministack/run-microvm-smoke.sh new file mode 100644 index 0000000000..6484ad20a0 --- /dev/null +++ b/tests/ministack/run-microvm-smoke.sh @@ -0,0 +1,729 @@ +#!/bin/sh + +set -eu + +export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-000000000000}" +export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-test-only}" +export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-eu-west-1}" +export AWS_REGION="${AWS_REGION:-eu-west-1}" +export AWS_ENDPOINT_URL="${AWS_ENDPOINT_URL:-http://127.0.0.1:4566}" +export AWS_EC2_METADATA_DISABLED="${AWS_EC2_METADATA_DISABLED:-true}" + +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +source_root=$(CDPATH='' cd -- "$script_dir/../.." && pwd) +example_root="$source_root/examples/microvm" +mock_expectations="$script_dir/github-api-expectations.json" +fixture=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-workflow-job.XXXXXX") +mock_host="${MINISTACK_GITHUB_MOCK_HOST:-host.docker.internal}" +mock_port="${MINISTACK_GITHUB_MOCK_PORT:-}" +mock_service_url="${MINISTACK_GITHUB_MOCK_URL:-}" +mock_image="${MINISTACK_GITHUB_MOCK_IMAGE:-mockserver/mockserver:7.6.0@sha256:80b3b1a26f3553d0c81a3f3896b5b7274c17b2a2e52f0fd2b28e246bc9efa290}" +mock_container="" +tfvars_source="$script_dir/microvm.tfvars" +tfvars_file=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-smoke.XXXXXX") +response_file=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-smoke-response.XXXXXX") +lambda_response_file=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-lambda-response.XXXXXX") +terraform_initialized=false +before_microvm_ids="" +discovered_microvm_ids="" +override_file="$example_root/zz_ministack_microvm_smoke_override.tf" +metadata_ssm_path="/github-action-runners/microvm-ministack/microvm/runners/config/microvm-metadata" +runner_token_ssm_path="/github-action-runners/microvm-ministack/microvm/runners/tokens" + +cleanup() { + set +e + for microvm_id in $discovered_microvm_ids; do + aws --endpoint-url "$AWS_ENDPOINT_URL" lambda-microvms terminate-microvm \ + --microvm-identifier "$microvm_id" >/dev/null 2>&1 + done + if [ "$terraform_initialized" = true ]; then + "$source_root/tests/ministack/run-example.sh" destroy microvm "$tfvars_file" >/dev/null 2>&1 + fi + if [ -n "$mock_container" ]; then + docker rm -f "$mock_container" >/dev/null 2>&1 + fi + rm -f "$fixture" "$response_file" "$lambda_response_file" "$override_file" "$tfvars_file" +} +trap cleanup EXIT INT TERM + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "$1 is required to run the MiniStack MicroVM smoke test." >&2 + exit 69 + fi +} + +for command in aws curl grep openssl python3 terraform; do + require_command "$command" +done +if [ -z "$mock_service_url" ]; then + require_command docker +fi + +for lambda_zip in \ + "$source_root/lambdas/functions/webhook/webhook.zip" \ + "$source_root/lambdas/functions/control-plane/runners.zip"; do + if [ ! -f "$lambda_zip" ]; then + echo "Missing $lambda_zip. Build the webhook and control-plane distributions first." >&2 + exit 66 + fi +done + +if [ -z "$mock_port" ]; then + if [ -n "$mock_service_url" ]; then + mock_port=1080 + else + mock_port=$(python3 -c 'import socket; s = socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()') + fi +fi + +if [ -z "$mock_service_url" ]; then + mock_container="terraform-aws-github-runner-microvm-github-api-mock-$$" + mock_service_url="http://127.0.0.1:${mock_port}" + docker run --detach --name "$mock_container" --publish "${mock_port}:1080" \ + --volume "$mock_expectations:/config/github-api-expectations.json:ro" \ + --env MOCKSERVER_INITIALIZATION_JSON_PATH=/config/github-api-expectations.json \ + "$mock_image" >/dev/null +fi + +attempts=30 +while ! curl -fsS --max-time 2 -X PUT "${mock_service_url}/mockserver/status" >/dev/null 2>&1; do + attempts=$((attempts - 1)) + if [ "$attempts" -le 0 ]; then + echo "MockServer did not become ready." >&2 + if [ -n "$mock_container" ]; then + docker logs "$mock_container" >&2 + fi + exit 70 + fi + sleep 1 +done + +if [ -z "$mock_container" ]; then + MOCKSERVER_URL="$mock_service_url" python3 - "$mock_expectations" <<'PY' +import json +import os +import sys +import urllib.request + +with open(sys.argv[1], encoding="utf-8") as expectations_file: + expectations = json.load(expectations_file) + +for expectation in expectations: + request = urllib.request.Request( + f'{os.environ["MOCKSERVER_URL"]}/mockserver/expectation', + data=json.dumps(expectation).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="PUT", + ) + with urllib.request.urlopen(request, timeout=10) as response: + if response.status not in (200, 201): + raise RuntimeError(f"MockServer expectation rejected with HTTP {response.status}") +PY +fi + +MOCKSERVER_URL="$mock_service_url" python3 - <<'PY' +import json +import os +import urllib.request + +path = "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig" +expectation = { + "httpRequest": {"method": "POST", "path": path}, + "httpResponse": { + "statusCode": 200, + "headers": {"Content-Type": ["application/json"]}, + "body": json.dumps( + { + "runner": { + "id": 987654321, + "labels": [ + {"name": "self-hosted"}, + {"name": "linux"}, + {"name": "arm64"}, + {"name": "microvm"}, + ], + }, + "encoded_jit_config": "ministack-microvm-jit-config", + } + ), + }, +} +request = urllib.request.Request( + f'{os.environ["MOCKSERVER_URL"]}/mockserver/expectation', + data=json.dumps(expectation).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="PUT", +) +with urllib.request.urlopen(request, timeout=10) as response: + if response.status not in (200, 201): + raise RuntimeError(f"MockServer JIT expectation rejected with HTTP {response.status}") +PY + +MOCKSERVER_URL="$mock_service_url" python3 - <<'PY' +import json +import os +import urllib.request + +path = "/api/v3/orgs/test-owner/actions/runner-groups" +expectation = { + "httpRequest": {"method": "GET", "path": path}, + "httpResponse": { + "statusCode": 200, + "headers": {"Content-Type": ["application/json"]}, + "body": '[{"id":1,"name":"Default"}]', + }, +} +request = urllib.request.Request( + f'{os.environ["MOCKSERVER_URL"]}/mockserver/expectation', + data=json.dumps(expectation).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="PUT", +) +with urllib.request.urlopen(request, timeout=10) as response: + if response.status not in (200, 201): + raise RuntimeError(f"MockServer runner-group expectation rejected with HTTP {response.status}") +PY + +python3 - "$script_dir/workflow_job_event.json" "$fixture" <<'PY' +import json +import sys + +source, destination = sys.argv[1:] +with open(source, encoding="utf-8") as source_file: + event = json.load(source_file) + +job = event["workflow_job"] +job["labels"] = ["self-hosted", "linux", "arm64", "microvm"] +job["name"] = "ministack-microvm-smoke" + +with open(destination, "w", encoding="utf-8") as destination_file: + json.dump(event, destination_file) +PY + +list_microvm_ids() { + aws --endpoint-url "$AWS_ENDPOINT_URL" lambda-microvms list-microvms \ + --query 'items[].microvmId' --output text 2>/dev/null || true +} + +id_in_list() { + case " $1 " in + *" $2 "*) return 0 ;; + esac + return 1 +} + +metadata_value() { + parameter_name="$1" + aws --endpoint-url "$AWS_ENDPOINT_URL" ssm get-parameter \ + --name "$parameter_name" --query 'Parameter.Value' --output text 2>/dev/null || true +} + +json_field() { + field="$1" + python3 -c 'import json, sys; print(json.load(sys.stdin).get(sys.argv[1], ""))' "$field" +} + +assert_metadata() { + microvm_id="$1" + source="$2" + metadata=$(metadata_value "$metadata_ssm_path/$microvm_id") + if [ -z "$metadata" ] || [ "$metadata" = "None" ]; then + echo "Missing MicroVM ownership metadata for $microvm_id." >&2 + exit 1 + fi + + actual_id=$(printf '%s' "$metadata" | json_field microvmId) + actual_environment=$(printf '%s' "$metadata" | json_field environment) + actual_owner=$(printf '%s' "$metadata" | json_field runnerOwner) + actual_type=$(printf '%s' "$metadata" | json_field runnerType) + actual_source=$(printf '%s' "$metadata" | json_field source) + if [ "$actual_id" != "$microvm_id" ] || [ "$actual_environment" != "microvm-ministack" ] \ + || [ "$actual_owner" != "test-owner" ] || [ "$actual_type" != "Org" ] || [ "$actual_source" != "$source" ]; then + echo "Unexpected MicroVM ownership metadata for $microvm_id: $metadata" >&2 + exit 1 + fi + + tags=$(metadata_value "$metadata_ssm_path/$microvm_id.tags") + if [ -z "$tags" ] || [ "$tags" = "None" ]; then + echo "Missing MicroVM runner tag metadata for $microvm_id." >&2 + exit 1 + fi + TAGS="$tags" EXPECTED_SOURCE="$source" python3 - <<'PY' +import json +import os + +tags = json.loads(os.environ["TAGS"]) +required = { + "ghr:Application": "github-action-runner", + "ghr:created_by": os.environ["EXPECTED_SOURCE"], + "ghr:environment": "microvm-ministack", + "ghr:Owner": "test-owner", + "ghr:Type": "Org", +} +missing = [key for key, value in required.items() if tags.get(key) != value] +if missing: + raise SystemExit(f"Missing or incorrect MicroVM runner tags: {missing}; got {tags}") +if not tags.get("ghr:microvm_id") or not tags.get("ghr:github_runner_id"): + raise SystemExit(f"MicroVM runner tags do not contain runtime ownership fields: {tags}") +PY +} + +assert_parameter_absent() { + parameter_name="$1" + description="$2" + if aws --endpoint-url "$AWS_ENDPOINT_URL" ssm get-parameter \ + --name "$parameter_name" >/dev/null 2>&1; then + echo "Expected SSM parameter to be deleted: $parameter_name" >&2 + exit 1 + fi + printf ' [PASS] %s (%s is absent)\n' "$description" "$parameter_name" +} + +wait_for_microvm() { + expected_source="$1" + description="$2" + attempts=60 + while :; do + for microvm_id in $(list_microvm_ids); do + if [ "$microvm_id" = "None" ] || id_in_list "$before_microvm_ids" "$microvm_id"; then + continue + fi + metadata=$(metadata_value "$metadata_ssm_path/$microvm_id") + if [ -z "$metadata" ] || [ "$metadata" = "None" ]; then + continue + fi + actual_source=$(printf '%s' "$metadata" | json_field source) + if [ "$actual_source" != "$expected_source" ]; then + continue + fi + discovered_microvm_ids="$discovered_microvm_ids $microvm_id" + printf ' [PASS] MiniStack Lambda MicroVM API reports %s: %s\n' "$description" "$microvm_id" + return + done + + attempts=$((attempts - 1)) + if [ "$attempts" -le 0 ]; then + echo "Timed out waiting for $description in the MiniStack Lambda MicroVM API." >&2 + aws --endpoint-url "$AWS_ENDPOINT_URL" lambda-microvms list-microvms --output json >&2 || true + exit 1 + fi + sleep 2 + done +} + +assert_microvm_running() { + microvm_id="$1" + details=$(aws --endpoint-url "$AWS_ENDPOINT_URL" lambda-microvms get-microvm \ + --microvm-identifier "$microvm_id" --output json) + state=$(printf '%s' "$details" | json_field state) + image_arn=$(printf '%s' "$details" | json_field imageArn) + case "$state" in + PENDING | RUNNING | SUSPENDING | SUSPENDED) ;; + *) + echo "MicroVM $microvm_id did not reach an active state: $details" >&2 + exit 1 + ;; + esac + if [ "$image_arn" != "arn:aws:lambda:eu-west-1:000000000000:microvm-image:ministack" ]; then + echo "MicroVM $microvm_id used an unexpected image: $details" >&2 + exit 1 + fi + printf ' [PASS] MicroVM %s is active with the configured MicroVM image (%s)\n' "$microvm_id" "$state" +} + +webhook_endpoint="" +send_webhook() { + fixture_file="$1" + delivery_id="$2" + signature=$(openssl dgst -sha256 -hmac "$webhook_secret" "$fixture_file" | awk '{print $NF}') + status_code=$(curl -sS --max-time 15 -o "$response_file" -w '%{http_code}' \ + --connect-to "${api_host}:4566:127.0.0.1:${endpoint_port}" \ + -X POST "$webhook_endpoint" \ + -H 'Content-Type: application/json' \ + -H 'X-GitHub-Event: workflow_job' \ + -H "X-GitHub-Delivery: ${delivery_id}" \ + -H 'X-GitHub-Hook-Installation-Target-ID: 123' \ + -H "X-Hub-Signature-256: sha256=${signature}" \ + --data-binary "@${fixture_file}") + + if [ "$status_code" != 201 ]; then + echo "Webhook smoke request failed with HTTP $status_code." >&2 + sed -n '1,80p' "$response_file" >&2 + exit 1 + fi + echo " [PASS] API Gateway accepted the signed MicroVM workflow_job webhook ${delivery_id} (HTTP 201)" +} + +wait_for_log_event() { + log_group="$1" + marker="$2" + description="$3" + attempts=60 + while ! aws --endpoint-url "$AWS_ENDPOINT_URL" logs filter-log-events \ + --log-group-name "$log_group" --filter-pattern "$marker" --limit 1 --output text 2>/dev/null | grep -Fq "$marker"; do + attempts=$((attempts - 1)) + if [ "$attempts" -le 0 ]; then + echo "Timed out waiting for MiniStack log marker '$marker' in $log_group." >&2 + exit 1 + fi + sleep 2 + done + printf ' [PASS] %s (log group %s contains %s)\n' "$description" "$log_group" "$marker" +} + +wait_for_mock_route() { + method="$1" + route="$2" + description="$3" + verification_body=$(printf '{"httpRequest":{"method":"%s","path":"%s"},"times":{"atLeast":1}}' "$method" "$route") + attempts=60 + while ! curl -fsS --max-time 5 -X PUT "${mock_service_url}/mockserver/verify" \ + -H 'Content-Type: application/json' \ + --data-binary "$verification_body" >/dev/null 2>&1; do + attempts=$((attempts - 1)) + if [ "$attempts" -le 0 ]; then + echo "Timed out waiting for MockServer route: $method $route" >&2 + curl -sS --max-time 5 -X PUT \ + "${mock_service_url}/mockserver/retrieve?type=REQUEST_RESPONSES&format=JSON" >&2 || true + exit 1 + fi + sleep 2 + done + printf ' [PASS] %s (MockServer verified %s %s)\n' "$description" "$method" "$route" +} + +clear_mock_request_log() { + if ! curl -fsS --max-time 5 -X PUT \ + "${mock_service_url}/mockserver/clear?type=log" >/dev/null 2>&1; then + echo "Failed to clear MockServer request history before the next lifecycle phase." >&2 + exit 1 + fi +} + +assert_scale_up_github_routes() { + wait_for_mock_route POST "/api/v3/app/installations/123/access_tokens" \ + "Scale-up requested a GitHub App installation token" + wait_for_mock_route GET "/api/v3/repos/test-owner/test-repo/actions/jobs/123456" \ + "Scale-up checked the queued GitHub job 123456" + wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runner-groups" \ + "Scale-up resolved the Default GitHub runner group" + wait_for_mock_route POST "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig" \ + "Scale-up generated the MicroVM runner JIT configuration" +} + +assert_pool_github_routes() { + wait_for_mock_route GET "/api/v3/orgs/test-owner/installation" \ + "Pool looked up the GitHub App installation" + wait_for_mock_route POST "/api/v3/app/installations/123/access_tokens" \ + "Pool requested a GitHub App installation token" + wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runners" \ + "Pool listed organization runners" + wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runner-groups" \ + "Pool resolved the Default GitHub runner group" + wait_for_mock_route POST "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig" \ + "Pool generated the MicroVM runner JIT configuration" +} + +assert_scale_down_github_routes() { + runner_id="$1" + wait_for_mock_route POST "/api/v3/app/installations/123/access_tokens" \ + "Scale-down requested a GitHub App installation token" + wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runners" \ + "Scale-down listed organization runners" + wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runners/${runner_id}" \ + "Scale-down checked the runner busy state" + wait_for_mock_route DELETE "/api/v3/orgs/test-owner/actions/runners/${runner_id}" \ + "Scale-down deleted the runner from GitHub" +} + +configure_mock_runner_state() { + microvm_id="$1" + runner_id="$2" + MOCKSERVER_URL="$mock_service_url" python3 - "$microvm_id" "$runner_id" <<'PY' +import json +import os +import sys +import urllib.request + +microvm_id, runner_id = sys.argv[1:] +runner_id = int(runner_id) +base = "/api/v3/orgs/test-owner/actions/runners" + +def control(path, method, payload): + request = urllib.request.Request( + f'{os.environ["MOCKSERVER_URL"]}{path}', + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method=method, + ) + with urllib.request.urlopen(request, timeout=10) as response: + if response.status not in (200, 201, 202): + raise RuntimeError(f'MockServer API rejected {method} {path} with HTTP {response.status}') + +def clear(method, path): + control("/mockserver/clear", "PUT", {"httpRequest": {"method": method, "path": path}}) + +def expect(method, path, status, body=None): + response = {"statusCode": status} + if body is not None: + response["headers"] = {"Content-Type": ["application/json"]} + response["body"] = json.dumps(body) + control( + "/mockserver/expectation", + "PUT", + {"httpRequest": {"method": method, "path": path}, "httpResponse": response}, + ) + +state_path = f"{base}/{runner_id}" +clear("GET", base) +clear("GET", state_path) +clear("DELETE", state_path) +expect( + "GET", + base, + 200, + { + "total_count": 1, + "runners": [ + { + "id": runner_id, + "name": f"microvm-{microvm_id}", + "os": "linux", + "status": "offline", + "busy": False, + "labels": [], + } + ], + }, +) +expect( + "GET", + state_path, + 200, + { + "id": runner_id, + "name": f"microvm-{microvm_id}", + "os": "linux", + "status": "offline", + "busy": False, + "labels": [], + }, +) +expect("DELETE", state_path, 204) +PY +} + +configure_mock_runner_removed() { + runner_id="$1" + MOCKSERVER_URL="$mock_service_url" python3 - "$runner_id" <<'PY' +import json +import os +import sys +import urllib.request + +runner_id = sys.argv[1] +path = f"/api/v3/orgs/test-owner/actions/runners/{runner_id}" + +def control(path, method, payload): + request = urllib.request.Request( + f'{os.environ["MOCKSERVER_URL"]}{path}', + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method=method, + ) + with urllib.request.urlopen(request, timeout=10) as response: + if response.status not in (200, 201, 202): + raise RuntimeError(f'MockServer API rejected {method} {path} with HTTP {response.status}') + +control("/mockserver/clear", "PUT", {"httpRequest": {"method": "GET", "path": path}}) +control( + "/mockserver/expectation", + "PUT", + { + "httpRequest": {"method": "GET", "path": path}, + "httpResponse": { + "statusCode": 404, + "headers": {"Content-Type": ["application/json"]}, + "body": '{"message":"Not Found"}', + }, + }, +) +PY +} + +configure_empty_mock_runner_list() { + MOCKSERVER_URL="$mock_service_url" python3 - <<'PY' +import json +import os +import urllib.request + +path = "/api/v3/orgs/test-owner/actions/runners" +clear_request = urllib.request.Request( + f'{os.environ["MOCKSERVER_URL"]}/mockserver/clear', + data=json.dumps({"httpRequest": {"method": "GET", "path": path}}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="PUT", +) +with urllib.request.urlopen(clear_request, timeout=10) as response: + if response.status not in (200, 201): + raise RuntimeError(f"MockServer runner-list expectation clear rejected with HTTP {response.status}") + +request = urllib.request.Request( + f'{os.environ["MOCKSERVER_URL"]}/mockserver/expectation', + data=json.dumps( + { + "httpRequest": {"method": "GET", "path": path}, + "httpResponse": { + "statusCode": 200, + "headers": {"Content-Type": ["application/json"]}, + "body": '{"total_count":0,"runners":[]}', + }, + } + ).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="PUT", +) +with urllib.request.urlopen(request, timeout=10) as response: + if response.status not in (200, 201): + raise RuntimeError(f"MockServer API rejected pool runner expectation with HTTP {response.status}") +PY +} + +assert_mock_runner_removed() { + runner_id="$1" + status_code=$(curl -sS --max-time 5 -o "$response_file" -w '%{http_code}' \ + "${mock_service_url}/api/v3/orgs/test-owner/actions/runners/${runner_id}") + if [ "$status_code" != 404 ]; then + echo "Expected GitHub API mock to return 404 for removed runner $runner_id, got HTTP $status_code." >&2 + sed -n '1,80p' "$response_file" >&2 + exit 1 + fi + printf ' [PASS] GitHub API mock reports runner %s removed (HTTP 404)\n' "$runner_id" +} + +wait_for_microvm_termination() { + microvm_id="$1" + description="$2" + attempts=60 + while :; do + if details=$(aws --endpoint-url "$AWS_ENDPOINT_URL" lambda-microvms get-microvm \ + --microvm-identifier "$microvm_id" --output json 2>/dev/null); then + state=$(printf '%s' "$details" | json_field state) + if [ "$state" = "TERMINATED" ]; then + printf ' [PASS] MiniStack Lambda MicroVM API reports %s terminated\n' "$description" + return + fi + else + state="not found" + fi + attempts=$((attempts - 1)) + if [ "$attempts" -le 0 ]; then + echo "Timed out waiting for $description to terminate; current state: $state." >&2 + exit 1 + fi + sleep 2 + done +} + +invoke_lambda() { + function_name="$1" + payload="$2" + description="$3" + invocation_result=$(aws --endpoint-url "$AWS_ENDPOINT_URL" lambda invoke \ + --cli-binary-format raw-in-base64-out \ + --invocation-type RequestResponse \ + --function-name "$function_name" \ + --payload "$payload" \ + "$lambda_response_file" --output json) + if printf '%s' "$invocation_result" | grep -Fq '"FunctionError"'; then + echo "Lambda invocation returned FunctionError for $function_name." >&2 + exit 1 + fi + printf ' [PASS] %s (Lambda API accepted the request)\n' "$description" +} + +before_microvm_ids=$(list_microvm_ids) +terraform_initialized=true +cp "$tfvars_source" "$tfvars_file" +printf '\norganization_runners = true\n' >> "$tfvars_file" +printf '%s\n' \ + 'module "runners" {' \ + ' global_config_github = {' \ + ' app = var.github_app' \ + ' enterprise_server = {' \ + " url = \"http://${mock_host}:${mock_port}\"" \ + ' ssl_verify = false' \ + ' }' \ + ' }' \ + '}' > "$override_file" +"$source_root/tests/ministack/run-example.sh" apply microvm "$tfvars_file" + +webhook_endpoint=$(terraform -chdir="$example_root" output -raw webhook_endpoint) +endpoint_host_port=${AWS_ENDPOINT_URL#*://} +endpoint_port=${endpoint_host_port##*:} +api_host_port=${webhook_endpoint#*://} +api_host_port=${api_host_port%%/*} +api_host=${api_host_port%:*} +webhook_secret=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ssm get-parameter \ + --name /ministack/microvm/webhook-secret --query 'Parameter.Value' --output text) + +send_webhook "$fixture" "ministack-microvm-smoke-123456" +wait_for_log_event "/aws/lambda/microvm-ministack-microvm-webhook" "123456" \ + "Webhook Lambda received MicroVM workflow job 123456" +wait_for_log_event "/aws/lambda/microvm-ministack-microvm-dispatch-to-runner" "123456" \ + "EventBridge invoked the MicroVM dispatcher Lambda" +wait_for_log_event "/aws/lambda/microvm-ministack-microvm-scale-up" "123456" \ + "Dispatcher delivered workflow job 123456 through SQS to the MicroVM scale-up Lambda" +assert_scale_up_github_routes +wait_for_microvm "scale-up-lambda" "a scale-up MicroVM" +scale_up_microvm_id=$(printf '%s' "$discovered_microvm_ids" | awk '{print $1}') +assert_microvm_running "$scale_up_microvm_id" +EXPECTED_SOURCE="scale-up-lambda" assert_metadata "$scale_up_microvm_id" "scale-up-lambda" + +scale_up_runner_id=987654321 +configure_mock_runner_state "$scale_up_microvm_id" "$scale_up_runner_id" +clear_mock_request_log +invoke_lambda "microvm-ministack-microvm-scale-down" \ + '{"smokeMarker":"ministack-microvm-scale-up-scale-down","type":"microvm"}' \ + "MicroVM scale-down Lambda invoked for the webhook runner" +wait_for_log_event "/aws/lambda/microvm-ministack-microvm-scale-down" "ministack-microvm-scale-up-scale-down" \ + "MicroVM scale-down Lambda started processing the webhook runner" +assert_scale_down_github_routes "$scale_up_runner_id" +configure_mock_runner_removed "$scale_up_runner_id" +assert_mock_runner_removed "$scale_up_runner_id" +wait_for_microvm_termination "$scale_up_microvm_id" "the webhook MicroVM" +assert_parameter_absent "$runner_token_ssm_path/$scale_up_microvm_id" \ + "the webhook MicroVM JIT configuration was cleaned up" + +echo "MiniStack MicroVM smoke chain 1 passed: API Gateway -> webhook -> EventBridge -> dispatcher -> SQS -> RunMicrovm -> SSM ownership metadata -> GitHub API mock -> TerminateMicrovm." + +configure_empty_mock_runner_list +clear_mock_request_log +invoke_lambda "microvm-ministack-microvm-pool" '{"poolSize":1,"type":"microvm"}' \ + "MicroVM pool Lambda invoked to maintain one runner" +assert_pool_github_routes +wait_for_log_event "/aws/lambda/microvm-ministack-microvm-pool" "topped up with 1 runners" \ + "MicroVM pool Lambda requested one runner" +wait_for_microvm "pool-lambda" "a pool MicroVM" +pool_microvm_id=$(printf '%s' "$discovered_microvm_ids" | awk '{print $2}') +assert_microvm_running "$pool_microvm_id" +EXPECTED_SOURCE="pool-lambda" assert_metadata "$pool_microvm_id" "pool-lambda" + +pool_runner_id=987654321 +configure_mock_runner_state "$pool_microvm_id" "$pool_runner_id" +clear_mock_request_log +invoke_lambda "microvm-ministack-microvm-scale-down" \ + '{"smokeMarker":"ministack-microvm-pool-scale-down","type":"microvm"}' \ + "MicroVM scale-down Lambda invoked for the pool runner" +wait_for_log_event "/aws/lambda/microvm-ministack-microvm-scale-down" "ministack-microvm-pool-scale-down" \ + "MicroVM scale-down Lambda started processing the pool runner" +assert_scale_down_github_routes "$pool_runner_id" +configure_mock_runner_removed "$pool_runner_id" +assert_mock_runner_removed "$pool_runner_id" +wait_for_microvm_termination "$pool_microvm_id" "the pool MicroVM" +assert_parameter_absent "$runner_token_ssm_path/$pool_microvm_id" \ + "the pool MicroVM JIT configuration was cleaned up" + +echo "MiniStack MicroVM smoke chain 2 passed: MicroVM pool -> GitHub API mock -> RunMicrovm -> scale-down -> GitHub API mock -> TerminateMicrovm." +echo "MiniStack MicroVM smoke tests passed: webhook and pool lifecycle chains completed." From c99ffef4307226d7873afa6972651cfa3d0af520 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 18 Sep 2026 20:48:06 +0200 Subject: [PATCH 10/38] chore(microvm): build lifecycle hook server --- .github/workflows/lambda.yml | 9 ++++++-- .github/workflows/release.yml | 8 ++++++- lambdas/package.json | 3 ++- .../microvm-lifecycle-hooks/README.md | 8 +++---- .../microvm-lifecycle-hooks/build.mjs | 21 +++++++++++++++++++ .../microvm-lifecycle-hooks/package.json | 6 +++--- .../runtime-package.json | 3 --- lambdas/yarn.lock | 10 +++++++++ 8 files changed, 54 insertions(+), 14 deletions(-) create mode 100644 lambdas/services/microvm-lifecycle-hooks/build.mjs delete mode 100644 lambdas/services/microvm-lifecycle-hooks/runtime-package.json diff --git a/.github/workflows/lambda.yml b/.github/workflows/lambda.yml index dbc91358c7..2ff6ff1bf8 100644 --- a/.github/workflows/lambda.yml +++ b/.github/workflows/lambda.yml @@ -33,7 +33,7 @@ jobs: with: persist-credentials: false - name: Install dependencies - run: yarn install --frozen-lockfile + run: yarn install --immutable --mode=skip-build - name: Run prettier run: yarn format-check - name: Run linter @@ -71,7 +71,7 @@ jobs: persist-credentials: false - name: Install dependencies - run: yarn install --frozen-lockfile + run: yarn install --immutable --mode=skip-build - name: Run prettier run: yarn prettier --check "services/microvm-lifecycle-hooks/**/*.{ts,json,md}" @@ -84,3 +84,8 @@ jobs: - name: Build distribution run: yarn workspace @aws-github-runner/microvm-lifecycle-hooks build + + - name: Verify server distribution + run: | + test -s services/microvm-lifecycle-hooks/dist/server.js + test -f services/microvm-lifecycle-hooks/dist/package.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63d2a006aa..b5789d50dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,12 +37,18 @@ jobs: persist-credentials: false - name: Build dist working-directory: lambdas - run: yarn install --frozen-lockfile && yarn run test && yarn dist + run: yarn install --immutable --mode=skip-build && yarn run test && yarn dist - name: Build MicroVM lifecycle hook working-directory: lambdas run: yarn workspace @aws-github-runner/microvm-lifecycle-hooks build + - name: Verify MicroVM lifecycle hook distribution + working-directory: lambdas + run: | + test -s services/microvm-lifecycle-hooks/dist/server.js + test -f services/microvm-lifecycle-hooks/dist/package.json + - name: Package MicroVM lifecycle hook working-directory: lambdas/services/microvm-lifecycle-hooks run: (cd dist && zip -r ../microvm-lifecycle-hooks.zip .) diff --git a/lambdas/package.json b/lambdas/package.json index b223239520..e070302b3a 100644 --- a/lambdas/package.json +++ b/lambdas/package.json @@ -3,7 +3,8 @@ "private": true, "workspaces": [ "functions/*", - "libs/*" + "libs/*", + "services/*" ], "scripts": { "build": "nx run-many --target=build --all", diff --git a/lambdas/services/microvm-lifecycle-hooks/README.md b/lambdas/services/microvm-lifecycle-hooks/README.md index bcd890f100..5f1481c370 100644 --- a/lambdas/services/microvm-lifecycle-hooks/README.md +++ b/lambdas/services/microvm-lifecycle-hooks/README.md @@ -12,7 +12,7 @@ yarn workspace @aws-github-runner/microvm-lifecycle-hooks build yarn workspace @aws-github-runner/microvm-lifecycle-hooks start ``` -`build` uses NCC to create a self-contained `dist/`. It also writes `dist/package.json` with `type: module`, so the artifact runs after it is copied outside the Yarn workspace. Copy the **entire** directory; do not copy only `index.js`. +`build` uses esbuild to create the self-contained CommonJS server bundle `dist/server.js`. It also writes `dist/package.json` with `type: commonjs` so the bundle remains executable after it is copied outside the Yarn workspace. To build before invoking Docker, run the workspace build above. In the existing MicroVM runner Dockerfile, which already installs s6-overlay and the GitHub runner's Node 24 runtime, copy the complete artifact and replace the old hook command with: @@ -20,7 +20,7 @@ To build before invoking Docker, run the workspace build above. In the existing COPY lambdas/services/microvm-lifecycle-hooks/dist/ /opt/microvm-lifecycle-hooks/ ENV RUNNER_ENTRYPOINT=/opt/microvm/entrypoint.sh ENTRYPOINT ["/init"] -CMD ["/command/with-contenv", "/opt/actions-runner/externals/node24/bin/node", "/opt/microvm-lifecycle-hooks/index.js"] +CMD ["/command/with-contenv", "/opt/actions-runner/externals/node24/bin/node", "/opt/microvm-lifecycle-hooks/server.js"] ``` Alternatively, build the service inside Docker with the repository root as the build context. Add this pinned builder stage: @@ -46,10 +46,10 @@ COPY --from=lifecycle-build \ /opt/microvm-lifecycle-hooks/ ENV RUNNER_ENTRYPOINT=/opt/microvm/entrypoint.sh ENTRYPOINT ["/init"] -CMD ["/command/with-contenv", "/opt/actions-runner/externals/node24/bin/node", "/opt/microvm-lifecycle-hooks/index.js"] +CMD ["/command/with-contenv", "/opt/actions-runner/externals/node24/bin/node", "/opt/microvm-lifecycle-hooks/server.js"] ``` -For an image without s6-overlay, start the artifact with `node /opt/microvm-lifecycle-hooks/index.js` under that image's process supervisor. The hook binds to `0.0.0.0:8080` by default. Restrict the port to the Lambda MicroVM lifecycle network; the protocol does not add a separate application authentication layer. +For an image without s6-overlay, start the artifact with `node /opt/microvm-lifecycle-hooks/server.js` under that image's process supervisor. The hook binds to `0.0.0.0:8080` by default. Restrict the port to the Lambda MicroVM lifecycle network; the protocol does not add a separate application authentication layer. ## Run payloads diff --git a/lambdas/services/microvm-lifecycle-hooks/build.mjs b/lambdas/services/microvm-lifecycle-hooks/build.mjs new file mode 100644 index 0000000000..4f26c0dcb8 --- /dev/null +++ b/lambdas/services/microvm-lifecycle-hooks/build.mjs @@ -0,0 +1,21 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; + +import { build } from 'esbuild'; + +await rm('dist', { force: true, recursive: true }); +await mkdir('dist', { recursive: true }); + +await build({ + bundle: true, + entryPoints: ['src/index.ts'], + format: 'cjs', + legalComments: 'eof', + minify: false, + packages: 'bundle', + platform: 'node', + sourcemap: false, + target: 'node24', + outfile: 'dist/server.js', +}); + +await writeFile('dist/package.json', '{\n "type": "commonjs"\n}\n'); diff --git a/lambdas/services/microvm-lifecycle-hooks/package.json b/lambdas/services/microvm-lifecycle-hooks/package.json index 4add4eb6ae..388cad6613 100644 --- a/lambdas/services/microvm-lifecycle-hooks/package.json +++ b/lambdas/services/microvm-lifecycle-hooks/package.json @@ -13,18 +13,18 @@ "node": ">=24" }, "scripts": { - "start": "node dist/index.js", + "start": "node dist/server.js", "test": "NODE_ENV=test nx test", "test:watch": "NODE_ENV=test nx test --watch", "lint": "eslint src", - "build": "ncc build src/index.ts -o dist && cp runtime-package.json dist/package.json", + "build": "node build.mjs", "format": "prettier --write \"**/*.{ts,json,md}\"", "format-check": "prettier --check \"**/*.{ts,json,md}\"", "all": "yarn build && yarn format && yarn lint && yarn test" }, "devDependencies": { "@types/node": "^22.19.3", - "@vercel/ncc": "^0.38.4" + "esbuild": "^0.27.0" }, "dependencies": { "@aws-github-runner/storage-providers": "*" diff --git a/lambdas/services/microvm-lifecycle-hooks/runtime-package.json b/lambdas/services/microvm-lifecycle-hooks/runtime-package.json deleted file mode 100644 index 3dbc1ca591..0000000000 --- a/lambdas/services/microvm-lifecycle-hooks/runtime-package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index 4eb03fcab0..5b298b2028 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -202,6 +202,16 @@ __metadata: languageName: unknown linkType: soft +"@aws-github-runner/microvm-lifecycle-hooks@workspace:services/microvm-lifecycle-hooks": + version: 0.0.0-use.local + resolution: "@aws-github-runner/microvm-lifecycle-hooks@workspace:services/microvm-lifecycle-hooks" + dependencies: + "@aws-github-runner/storage-providers": "npm:*" + "@types/node": "npm:^22.19.3" + esbuild: "npm:^0.27.0" + languageName: unknown + linkType: soft + "@aws-github-runner/storage-providers@npm:*, @aws-github-runner/storage-providers@workspace:libs/storage-providers": version: 0.0.0-use.local resolution: "@aws-github-runner/storage-providers@workspace:libs/storage-providers" From 0cc81cfed63fe790fa5c56e05f19adcf638f51e8 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 18 Sep 2026 22:09:23 +0200 Subject: [PATCH 11/38] test: fix microvm test with ministack --- examples/microvm/main.tf | 6 +----- examples/microvm/variables.tf | 5 +++-- tests/ministack/multi-runner.tfvars | 5 +++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/examples/microvm/main.tf b/examples/microvm/main.tf index 4c51dd52e1..f668e03bf6 100644 --- a/examples/microvm/main.tf +++ b/examples/microvm/main.tf @@ -10,10 +10,6 @@ module "base" { aws_region = local.aws_region } -resource "random_id" "random" { - byte_length = 20 -} - module "runners" { source = "../../modules/multi-runner" @@ -26,7 +22,7 @@ module "runners" { app = { key_base64 = var.github_app.key_base64 id = var.github_app.id - webhook_secret = random_id.random.hex + webhook_secret = var.github_app.webhook_secret } } diff --git a/examples/microvm/variables.tf b/examples/microvm/variables.tf index 4f8eaa3bc9..fb8060a54b 100644 --- a/examples/microvm/variables.tf +++ b/examples/microvm/variables.tf @@ -14,8 +14,9 @@ variable "github_app" { description = "GitHub for API usages." type = object({ - id = string - key_base64 = string + id = string + key_base64 = string + webhook_secret = string }) } diff --git a/tests/ministack/multi-runner.tfvars b/tests/ministack/multi-runner.tfvars index 7321053e10..346efc5378 100644 --- a/tests/ministack/multi-runner.tfvars +++ b/tests/ministack/multi-runner.tfvars @@ -2,6 +2,7 @@ environment = "ms-multi" aws_region = "eu-west-1" github_app = { - id = "0" - key_base64 = "ministack-invalid-key" + id = "0" + key_base64 = "ministack-invalid-key" + webhook_secret = "ministack-invalid-webhook-secret" } From c31c050d6339287c7f44db8a5f8eeb996732ef0d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:13:34 +0000 Subject: [PATCH 12/38] docs: auto update terraform docs --- examples/microvm/README.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/examples/microvm/README.md b/examples/microvm/README.md index a124ba8da5..0ac689a594 100644 --- a/examples/microvm/README.md +++ b/examples/microvm/README.md @@ -60,12 +60,11 @@ tables and network ACLs configured by the helper module. |------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | +| [random](#requirement\_random) | ~> 3.0 | ## Providers -| Name | Version | -|------|---------| -| [random](#provider\_random) | 3.9.0 | +No providers. ## Modules @@ -76,9 +75,7 @@ tables and network ACLs configured by the helper module. ## Resources -| Name | Type | -|------|------| -| [random_id.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | +No resources. ## Inputs @@ -87,7 +84,7 @@ tables and network ACLs configured by the helper module. | [aws\_region](#input\_aws\_region) | AWS Region where the runner control plane and MicroVM resources are deployed. | `string` | `"eu-west-1"` | no | | [egress\_network\_connector\_arn](#input\_egress\_network\_connector\_arn) | Regional Lambda Network Connector ARN used by MicroVMs and the image build. | `string` | n/a | yes | | [environment](#input\_environment) | Name prefix for the example resources. | `string` | `null` | no | -| [github\_app](#input\_github\_app) | GitHub for API usages. |
object({
id = string
key_base64 = string
})
| n/a | yes | +| [github\_app](#input\_github\_app) | GitHub for API usages. |
object({
id = string
key_base64 = string
webhook_secret = string
})
| n/a | yes | | [ingress\_network\_connector\_arns](#input\_ingress\_network\_connector\_arns) | Optional regional Lambda Network Connector ARNs exposed to MicroVMs. | `list(string)` | `[]` | no | | [lambda\_artifact\_bucket](#input\_lambda\_artifact\_bucket) | S3 bucket containing the runner-control Lambda artifacts. | `string` | n/a | yes | | [microvm\_image\_arn](#input\_microvm\_image\_arn) | Lambda MicroVM image ARN produced by the MicroVM image build. | `string` | n/a | yes | From 99dc6e738e4aad15716b5c1fc402796048925cdb Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 18 Sep 2026 23:39:57 +0200 Subject: [PATCH 13/38] test: fix microvm variables --- tests/ministack/microvm.tfvars | 5 +++-- tests/ministack/multi-runner.tfvars | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/ministack/microvm.tfvars b/tests/ministack/microvm.tfvars index c777b7228a..516ace3e4a 100644 --- a/tests/ministack/microvm.tfvars +++ b/tests/ministack/microvm.tfvars @@ -3,8 +3,9 @@ aws_region = "eu-west-1" environment = "microvm-ministack" github_app = { - id = "your-github-app-id" - key_base64 = "your-github-app-key-base64" + id = "2" + key_base64 = "ministack-invalid-key" + webhook_secret = "ministack-invalid-webhook-secret" } lambda_artifact_bucket = "github-actions-runner-microvm-ministack" diff --git a/tests/ministack/multi-runner.tfvars b/tests/ministack/multi-runner.tfvars index 346efc5378..7321053e10 100644 --- a/tests/ministack/multi-runner.tfvars +++ b/tests/ministack/multi-runner.tfvars @@ -2,7 +2,6 @@ environment = "ms-multi" aws_region = "eu-west-1" github_app = { - id = "0" - key_base64 = "ministack-invalid-key" - webhook_secret = "ministack-invalid-webhook-secret" + id = "0" + key_base64 = "ministack-invalid-key" } From e3e57b1fac7e58f28c08867d1e9dae7a8d221e5d Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Mon, 21 Sep 2026 18:37:46 +0200 Subject: [PATCH 14/38] feat(ministack): add multi-provider webhook smoke test --- .github/workflows/ministack.yml | 65 +- .github/workflows/terraform.yml | 2 - docs/examples/index.md | 3 +- docs/examples/microvm.md | 3 - docs/examples/multi-runner-v2.md | 1 - docs/examples/multi-runner-webhook.md | 1 + examples/microvm/.terraform.lock.hcl | 71 -- examples/microvm/.terraform.lock.hcl.tofu | 113 --- examples/microvm/README.md | 103 --- examples/microvm/main.tf | 105 --- examples/microvm/outputs.tf | 9 - examples/microvm/providers.tf | 9 - examples/microvm/variables.tf | 73 -- examples/microvm/versions.tf | 13 - examples/multi-runner-v2/README.md | 77 -- examples/multi-runner-v2/main.tf | 180 ----- examples/multi-runner-v2/providers.tf | 9 - examples/multi-runner-v2/variables.tf | 39 - .../.terraform.lock.hcl | 22 - .../.terraform.lock.hcl.tofu | 37 - examples/multi-runner-webhook/README.md | 19 + examples/multi-runner-webhook/main.tf | 179 +++++ .../outputs.tf | 3 +- examples/multi-runner-webhook/providers.tf | 9 + examples/multi-runner-webhook/variables.tf | 68 ++ .../versions.tf | 1 + mkdocs.yaml | 2 +- .../tests/config-resolution.tftest.hcl | 3 +- modules/multi-runner/webhook.tf | 9 +- modules/webhook/README.md | 2 +- modules/webhook/variables.tf | 11 +- tests/ministack/README.md | 79 +- tests/ministack/microvm.tfvars | 13 - tests/ministack/multi-runner-v2.tfvars | 31 - tests/ministack/multi-runner-webhook.tfvars | 32 + tests/ministack/run-example.sh | 54 +- tests/ministack/run-microvm-smoke.sh | 729 ------------------ tests/ministack/run-smoke.sh | 721 ----------------- tests/ministack/run-webhook-smoke.py | 53 ++ tests/ministack/smoke/__init__.py | 1 + tests/ministack/smoke/common.py | 430 +++++++++++ tests/ministack/smoke/ec2.py | 137 ++++ tests/ministack/smoke/lifecycle.py | 95 +++ tests/ministack/smoke/microvm.py | 146 ++++ tests/ministack/smoke/provider.py | 54 ++ 45 files changed, 1324 insertions(+), 2492 deletions(-) delete mode 100644 docs/examples/microvm.md delete mode 100644 docs/examples/multi-runner-v2.md create mode 100644 docs/examples/multi-runner-webhook.md delete mode 100644 examples/microvm/.terraform.lock.hcl delete mode 100644 examples/microvm/.terraform.lock.hcl.tofu delete mode 100644 examples/microvm/README.md delete mode 100644 examples/microvm/main.tf delete mode 100644 examples/microvm/outputs.tf delete mode 100644 examples/microvm/providers.tf delete mode 100644 examples/microvm/variables.tf delete mode 100644 examples/microvm/versions.tf delete mode 100644 examples/multi-runner-v2/README.md delete mode 100644 examples/multi-runner-v2/main.tf delete mode 100644 examples/multi-runner-v2/providers.tf delete mode 100644 examples/multi-runner-v2/variables.tf rename examples/{multi-runner-v2 => multi-runner-webhook}/.terraform.lock.hcl (76%) rename examples/{multi-runner-v2 => multi-runner-webhook}/.terraform.lock.hcl.tofu (75%) create mode 100644 examples/multi-runner-webhook/README.md create mode 100644 examples/multi-runner-webhook/main.tf rename examples/{multi-runner-v2 => multi-runner-webhook}/outputs.tf (73%) create mode 100644 examples/multi-runner-webhook/providers.tf create mode 100644 examples/multi-runner-webhook/variables.tf rename examples/{multi-runner-v2 => multi-runner-webhook}/versions.tf (99%) delete mode 100644 tests/ministack/microvm.tfvars delete mode 100644 tests/ministack/multi-runner-v2.tfvars create mode 100644 tests/ministack/multi-runner-webhook.tfvars delete mode 100644 tests/ministack/run-microvm-smoke.sh delete mode 100644 tests/ministack/run-smoke.sh create mode 100644 tests/ministack/run-webhook-smoke.py create mode 100644 tests/ministack/smoke/__init__.py create mode 100644 tests/ministack/smoke/common.py create mode 100644 tests/ministack/smoke/ec2.py create mode 100644 tests/ministack/smoke/lifecycle.py create mode 100644 tests/ministack/smoke/microvm.py create mode 100644 tests/ministack/smoke/provider.py diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 0032ecc8ec..5c39b90e11 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -70,11 +70,9 @@ jobs: - prebuilt - default - ephemeral - - microvm - microvm-foundation - migration-test - multi-runner - - multi-runner-v2 - termination-watcher services: ministack: @@ -196,65 +194,4 @@ jobs: MINISTACK_GITHUB_MOCK_HOST: host.docker.internal MINISTACK_GITHUB_MOCK_PORT: "1080" MINISTACK_GITHUB_MOCK_URL: ${{ steps.mockserver.outputs.url }} - run: sh tests/ministack/run-smoke.sh - - integration_smoke_microvm: - name: Run webhook and pool lifecycle smoke test against MiniStack (MicroVM) - runs-on: ubuntu-latest - timeout-minutes: 30 - services: - ministack: - image: ghcr.io/ministackorg/ministack:1.5.10@sha256:706b2b83c6be7e4f4dbb6a0dc28ffdebb500c6c80b64cf7938f45040fb2158e8 - ports: - - 4566:4566 - options: --add-host=host.docker.internal:host-gateway - env: - MINISTACK_ACCOUNT_ID: "000000000000" - MINISTACK_REGION: eu-west-1 - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 - with: - egress-policy: audit - - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version-file: lambdas/.nvmrc - package-manager-cache: false - - - name: Setup Terraform - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - with: - terraform_version: latest - terraform_wrapper: false - - - name: Install Lambda dependencies - working-directory: lambdas - run: yarn install --frozen-lockfile - - - name: Build smoke-test Lambda distributions - working-directory: lambdas - run: | - yarn workspace @aws-github-runner/webhook dist - yarn workspace @aws-github-runner/control-plane dist - - - name: Start MockServer - id: mockserver - uses: mock-server/setup-mockserver@24612c2ccef1f83d587f331ed77cc5cef441e0b1 # v1.0.0 - with: - version: '7.6.0@sha256:80b3b1a26f3553d0c81a3f3896b5b7274c17b2a2e52f0fd2b28e246bc9efa290' - port: '1080' - startup-timeout: '60' - - - name: Run MicroVM webhook and pool lifecycle smoke test - env: - MINISTACK_GITHUB_MOCK_HOST: host.docker.internal - MINISTACK_GITHUB_MOCK_PORT: "1080" - MINISTACK_GITHUB_MOCK_URL: ${{ steps.mockserver.outputs.url }} - run: sh tests/ministack/run-microvm-smoke.sh + run: python3 tests/ministack/run-webhook-smoke.py diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index cb36c9bbcf..08cae2eadc 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -50,10 +50,8 @@ env: prebuilt ephemeral termination-watcher - microvm microvm-foundation multi-runner - multi-runner-v2 external-managed-ssm-secrets TEST_MODULES: | modules/runners diff --git a/docs/examples/index.md b/docs/examples/index.md index b7bdf60811..526eb6a112 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -5,11 +5,10 @@ Examples are located in the [examples](https://github.com/github-aws-runners/ter - _[Default](default.md)_: The default example of the module - _[Ephemeral](ephemeral.md)_: Example usages of ephemeral runners based on the default example. - _[Multi Runner](multi-runner.md)_ : Example usage of creating a multi runner which creates multiple runners/ configurations with a single deployment. The examples including: "arm64", "windows", and "ubuntu" runners. -- _[Multi Runner v2](multi-runner-v2.md)_ : Example usage of the experimental v2 multi-runner configuration interface with shared defaults and per-lane overrides. +- _[Multi Runner Webhook](multi-runner-webhook.md)_: Example usage of one webhook deployment serving EC2 and Lambda MicroVM runner lanes. - _[Permissions boundary](permissions-boundary.md)_: Example usages of permissions boundaries. - _[Prebuilt Images](prebuilt.md)_: Example usages of deploying runners with a custom prebuilt image. - _[Termination watcher](termination-watcher.md)_: Example usages of termination watcher. - _[Dedicated Mac Hosts](dedicated-mac-hosts.md)_: Example usage of setting up dedicated hosts for macOS runners. - _[Externally managed SSM secrets](external-managed-ssm-secrets.md)_: Example usage of externally managed SSM secrets for the GitHub App credentials. - _[MicroVM foundation](microvm-foundation.md)_: Example usage of the regional Lambda MicroVM image-build and Network Connector prerequisites. -- _[Lambda MicroVM](microvm.md)_: Example usage of Linux ARM64 ephemeral runners backed by Lambda MicroVM images. diff --git a/docs/examples/microvm.md b/docs/examples/microvm.md deleted file mode 100644 index 4014781114..0000000000 --- a/docs/examples/microvm.md +++ /dev/null @@ -1,3 +0,0 @@ -# Lambda MicroVM - ---8<-- "examples/microvm/README.md" diff --git a/docs/examples/multi-runner-v2.md b/docs/examples/multi-runner-v2.md deleted file mode 100644 index 565b601ecb..0000000000 --- a/docs/examples/multi-runner-v2.md +++ /dev/null @@ -1 +0,0 @@ ---8<-- "examples/multi-runner-v2/README.md" diff --git a/docs/examples/multi-runner-webhook.md b/docs/examples/multi-runner-webhook.md new file mode 100644 index 0000000000..19d24d5994 --- /dev/null +++ b/docs/examples/multi-runner-webhook.md @@ -0,0 +1 @@ +--8<-- "examples/multi-runner-webhook/README.md" diff --git a/examples/microvm/.terraform.lock.hcl b/examples/microvm/.terraform.lock.hcl deleted file mode 100644 index e46d40b514..0000000000 --- a/examples/microvm/.terraform.lock.hcl +++ /dev/null @@ -1,71 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/aws" { - version = "6.63.0" - constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" - hashes = [ - "h1:9cre7jh1lSs/9igpgAcENMUAUlYW3HCtkav3up4oit0=", - "h1:dRlYHkc+r6fgzF57WC7Zjcmb6sF/6TTGDEgwGK+LAZY=", - "zh:005d56736afd17d963998c405cee6f434dbc23a415109f9435ff1542879ae611", - "zh:026ef126321a86ad7080b5d858e2527f96f5289678cbcd8856296e229c43339d", - "zh:06e0b58b2d1eddb5137fc86bee7ad2d07953c0bc3f57cccfc5ae0d2456068a3a", - "zh:07221735d61ababed84734e5ffcfc5bd59d01f29f029166ba5f2175895dceed1", - "zh:1a72db00583112bdb8c19b213a78a3f5de754fffc08f07e061f4e326289fab7d", - "zh:32968e74a53b03e97a084dc7050c22ef661fb5b3ea8a44f5a63e47bc45ad0e7c", - "zh:4b357dfe4b820e3e4acd2881cff8288b2186491e63416751f0d12692ba478ceb", - "zh:81e30884d7de686265e7d87bb92527e802878c65a378470ede2a1e9f4e40ccc9", - "zh:82e137297f6a5a08b9ce2138f7aabea245ad99495d9d9eff502f752d6ca90dbd", - "zh:8eb83b67099f0ea9df238a979dff933ff50ce06a2e3ff05a48556a10f10dd204", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:d0ba30886cbe41850fee689f51ef9088578f323cfd21817bb409951d43c465eb", - "zh:dd48e7089784454bc03d713e9057f5ca0ea1613bd402125054a51894957b7925", - "zh:f250fa81e54cf60fcb0e9c0fc4ac043f1ecc2ac24967f628b3609364fcab3d04", - "zh:f38fc09fc25a8d2cf89a4d4cd6a5ef7cb1aad72798dbdcad58b8876b6a551a54", - "zh:f7c7380fdf126e1901f2084588dbfd724c76cb131ccfa795a541219111103c06", - ] -} - -provider "registry.terraform.io/hashicorp/null" { - version = "3.3.1" - constraints = "~> 3.0, ~> 3.2" - hashes = [ - "h1:TuxJq10DVnRP7c5HBZPyyvQGcckNVfijyU1eXEu5e4M=", - "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=", - "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44", - "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451", - "zh:14b3fa4372754b54844b41d5dbd4671a292d8d6828b90169061feb4d7b15dd05", - "zh:56a4daaa3212f57b764bf3d1f333141c6610c5f21abb240e0111221f7c7fa4d4", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7e888a026dbacd2474a42264227ae35f639780f0f0c613529d10a95cd61988b3", - "zh:85a53646267e87d600df7124e4767ffde9bba3b6356d45d961618bdd68131cc7", - "zh:8ffa0e9c7c39b2ab0905b472465d6e35ef0b776b3f6273bb34c150340b61bff1", - "zh:9846510a1841530d4403f4818e233f91e3b3bade7441047599fbf800742f65be", - "zh:afa98d44860875f037c6def0a7e6ff208e042712ba771f620482b143cd336891", - "zh:bdca130d9ef27488ae0b13bc8fd8019e8bbdd4f2ceff29da066bd333165d68c5", - "zh:cb3b94cbca88210dd0d1f11e2b8a89333f48c3857faf8f70f589072ce7c28610", - "zh:f0c0ba87925fe32f84b80f7513b1efb1b0866f51f899ba825e95ad59ff09b018", - ] -} - -provider "registry.terraform.io/hashicorp/random" { - version = "3.9.0" - constraints = "~> 3.0" - hashes = [ - "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", - "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", - "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", - "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", - "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", - "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", - "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", - "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", - "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", - "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", - "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", - "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", - "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", - ] -} diff --git a/examples/microvm/.terraform.lock.hcl.tofu b/examples/microvm/.terraform.lock.hcl.tofu deleted file mode 100644 index 7aa235531d..0000000000 --- a/examples/microvm/.terraform.lock.hcl.tofu +++ /dev/null @@ -1,113 +0,0 @@ -# This file is maintained automatically by "tofu init". -# Manual edits may be lost in future updates. - -provider "registry.opentofu.org/hashicorp/aws" { - version = "6.63.0" - constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" - hashes = [ - "h1:1jhQJPHOPu2mzDG/ke3tK8PNcEqQHA4vhF05WWlM/yg=", - "h1:3+pvT0KN/bkJ6TBuExj+gxptEozhnpo80Ztblwq85eo=", - "h1:5aTequ87wZS7Mh4dEIayDGKcFdaFgHtw74NtqY5Idi0=", - "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", - "h1:OTjECFWTDxsjcUfOKCNBp75Z5lGrW/KplRDsjTZYT2g=", - "h1:b8LORLOKMOOl+nK1M2UhCjELSjjziClJuAv6hYuySHs=", - "h1:bUfTX1giRLOyfDbBvsDbwR3tJmsTFRWcOTQdj2npDWA=", - "h1:dzs4kwx+itVGAH7yEOyeoWcE3LNRMnWtlt4ROgyAa0M=", - "h1:lnjou+SiwpYJ+j9PXWozXPHSPlhxIZb0RqpsSEBzfGw=", - "h1:pqzUeHAQj9NctgkwaynaF2aB+3QiZXcoslzMGjT743w=", - "h1:qTXEWOWxA6sfUpC29UXrsbHnNzWH7+j1RTUVG4YCm+U=", - "h1:qdHKOKt/ISn9RLjUe22OZBpN3F7H2DFeHJL/CSc2x8E=", - "h1:tpNzIZBzzUW7/kLU3BhYf3jhdO5uNwYfNmgC9B8kvMM=", - "h1:uVVlFgjg6GyxJLbCsTO1+R5fTNbZ73mLpVpSd0mMrFk=", - "h1:xGJsV5IFf7c11cXzJrsY40hiJCghp4odT0eJyTyAUYY=", - "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", - "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", - "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", - "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", - "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", - "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", - "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", - "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", - "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", - "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", - "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", - "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", - "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", - "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", - "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", - ] -} - -provider "registry.opentofu.org/hashicorp/null" { - version = "3.3.1" - constraints = "~> 3.0, ~> 3.2" - hashes = [ - "h1:2wld81FnmHW0WVgy081sIfokCr2+NuatS8yjeLEet7Y=", - "h1:AClQjJ6X22V4qcRgcYSxiXCMmp2pz0G8WVQC7wAx66o=", - "h1:AY3XQbuviNd2X5VhHYEbhNta1m/CG3JD2BKFKhCt1Y4=", - "h1:CUOZUd7H11lsU+4tISlnYIiP5BqnX8IDwFCVqfLJyAg=", - "h1:JIfV0nA/pLWnIFGscvTfuavQCn2NeHxJBeb6UUg/joA=", - "h1:RejAh+nyCwqDGExGln2Kb4Ro5LyHak0eJe0P9g8CHPc=", - "h1:SHOuTZjYymsmy4asuRq6NC3yW+zdVZOOt4f5nrb+EPM=", - "h1:WwPat/gT4gO8GvvKNdSkkXWVD65JppLJfqKOt9HhOqQ=", - "h1:Z3hXVLrOyaRiiLmmL5UCOdcRMguwjN1x5TYNdmBDgls=", - "h1:dd78Ad5HdfPzPts7A9qIxfitXhAriV/qza38fr2ukjk=", - "h1:dyVb++KwDdybzLTE6bf7GZiVQ31iWsgKPWmhTQ8G42k=", - "h1:gD8ZH6WWe+5gg5+y8SpLWGPUDzSxcQ3HKP8IDM/wW3I=", - "h1:juXCww0zRQKFTDZoKqYR0+Sn1lu99oeL6pr0Jh6LWx0=", - "h1:kFAySmtsshyNV7IhIrEdASzVcvwy68eeZCVC66P7yNk=", - "h1:nS5azDopRisB2NInwDx3Hrfg2FdVt8Gw0gTQzC0rd70=", - "zh:164eb061d84e01759f391265865fb31828083d0a06b25f7af7e094cbdb18c799", - "zh:1bb9b669a82b52c0cba2860c71e9ee6699ef302f28cb8ed06f572d39bc6c7c4f", - "zh:1ea9b31a8f29302122c1e8d673693f3ac270336dae560af803cd1117265a469a", - "zh:238bd463cb0154fb935dc331da40c0a9cbe5db9cee615ae5f35ccad5eed7dc41", - "zh:30ef2b7384cf7e20f33fe75754b54cf669d59816f3ad4fc73bfb2b26fb6735e9", - "zh:35b5cded16e4b57c207d03ee0979b14baf486fa520e6edb7a2eecf18f1b85471", - "zh:3dc840d13a50cd215c7540573f27e2b61f739ba90aee5b7c3846079aa0ab5534", - "zh:3f9309a18db608f975d5691fcb47a6e14d77199156a52e9c39dcafe3737f2b07", - "zh:44263a219f7dbd1848b545d080110b4f7d0495e77b71cd3c7a0b5ec52a09accb", - "zh:4dec54aa5f445eeea035bbd4839bcded5e47ecd07cba0e70c5a09e9272cb592f", - "zh:5e8fb319d7c6d6c4566a18b9d0c91580b4901a96acd7fdc476bfc79f074368e2", - "zh:b0e8b6d41834b57fcfbb5ca00da52ccb757e1a95b6a2d546c0dae8bfbeca1cdf", - "zh:bbde4c3a1dcc1718027a61a4cdf661619d17af1b58df1038fe27bcf43c3dc29b", - "zh:c4140fff9f692baf29236557f706f9515f93229413438527d764023a82301da3", - "zh:f8e9d83184e4bbeb97c6f0d569833007c48ba5a7ff334def201df4991d03a962", - ] -} - -provider "registry.opentofu.org/hashicorp/random" { - version = "3.9.0" - constraints = "~> 3.0" - hashes = [ - "h1:8EQU5KSxezcjo/phRSe69rDOI0lk4pSaggj7FsskYp8=", - "h1:Lw9im2VBBJQ3RyAbHPQ0rcvcmmcZWm3x+kIOpN+Tv9s=", - "h1:U8KXqGCoNI9/guYbTvzgdtVk3fRthoG0UXwm1JoEpIs=", - "h1:YXaVd4p6qXPPVaxIBaIDNXmBwT02ZqDn0qD+tYpw8sA=", - "h1:cOpc03fphEt/G9Rfc4jLL/fW0D7tgvlXqiDKPF4vuww=", - "h1:g09RR7T1xWkeGrZwWvWMT9ncJrFGr1k3CBD585UmO7w=", - "h1:gGDdPPibmw2EWROx+sh1RGLjR5+nPwZyrf6/N9jXfeM=", - "h1:haE7/nXCOhXKP4oXeEnER3t5CaVQWqujz4nBnpeTUv4=", - "h1:ieSVpfZS2lKuMr05ph0QsOVpCzg7uk3cgKBaXR+Ikug=", - "h1:ig2s1IS9IzehorRjvVAnKIsUUj8fkgyxct1L/kswcc4=", - "h1:j3lS+ZEERFnoab8t1ppDrScGVP/cgWbzlCrEYKTCXYw=", - "h1:lxezrKmOiQIySHAM+os8qLVq7hqufDr8h3Hpzvsk+78=", - "h1:lzRqBJAG+NETxHbEZUJ/YP3RMEjZBinTX7VmgH3lw60=", - "h1:tdSNWK5ApqUsgbdYieyeYLTu6nIZUV3hR1oFqUfAuGo=", - "h1:xedet8yH/zI2CfdxsGlK0nlFWc/Bp61yrWsEa3fHB8g=", - "zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc", - "zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a", - "zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2", - "zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1", - "zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9", - "zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d", - "zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae", - "zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a", - "zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261", - "zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c", - "zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627", - "zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e", - "zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1", - "zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5", - "zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64", - ] -} diff --git a/examples/microvm/README.md b/examples/microvm/README.md deleted file mode 100644 index 0ac689a594..0000000000 --- a/examples/microvm/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# Lambda MicroVM runner example - -This example creates the VPC and GitHub Actions runner control plane for one -Linux ARM64 Lambda MicroVM lane. The lane uses ephemeral runners and -just-in-time configuration, which are required by the MicroVM provider. - -The regional MicroVM foundation is provisioned separately by the -[`microvm-foundation`](../microvm-foundation) example. Apply that example -first and provide its artifact bucket, build role, and egress Network Connector -outputs to the image build script. The image ARN produced by that build is then -supplied to this example. - -The GitHub App credentials must already exist in SSM Parameter Store. The -example outputs the webhook endpoint; configure that endpoint on the GitHub -App with the same secret stored in the referenced SSM parameter. - -## Usage - -Build or download the Lambda archives into an S3 bucket, then create a -`terraform.tfvars` file. The parameter references below are examples only: - -```hcl -aws_region = "eu-west-1" -lambda_artifact_bucket = "my-runner-lambda-artifacts" -microvm_image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:github-runner-arm64" -egress_network_connector_arn = "arn:aws:lambda:eu-west-1:123456789012:network-connector:example" - -github_app = { - key_base64_ssm = { - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-key" - name = "/github-runner/app-key" - } - id_ssm = { - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/app-id" - name = "/github-runner/app-id" - } - webhook_secret_ssm = { - arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/github-runner/webhook-secret" - name = "/github-runner/webhook-secret" - } -} -``` - -Run Terraform from this directory: - -```bash -terraform init -terraform apply -terraform output -raw webhook_endpoint -``` - -The MicroVM image must be built for Linux ARM64 and should use a versioned image -ARN in production. Network connector egress remains bounded by the VPC route -tables and network ACLs configured by the helper module. - - -## Requirements - -| Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.3.0 | -| [aws](#requirement\_aws) | >= 6.33 | -| [random](#requirement\_random) | ~> 3.0 | - -## Providers - -No providers. - -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [base](#module\_base) | ../base | n/a | -| [runners](#module\_runners) | ../../modules/multi-runner | n/a | - -## Resources - -No resources. - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [aws\_region](#input\_aws\_region) | AWS Region where the runner control plane and MicroVM resources are deployed. | `string` | `"eu-west-1"` | no | -| [egress\_network\_connector\_arn](#input\_egress\_network\_connector\_arn) | Regional Lambda Network Connector ARN used by MicroVMs and the image build. | `string` | n/a | yes | -| [environment](#input\_environment) | Name prefix for the example resources. | `string` | `null` | no | -| [github\_app](#input\_github\_app) | GitHub for API usages. |
object({
id = string
key_base64 = string
webhook_secret = string
})
| n/a | yes | -| [ingress\_network\_connector\_arns](#input\_ingress\_network\_connector\_arns) | Optional regional Lambda Network Connector ARNs exposed to MicroVMs. | `list(string)` | `[]` | no | -| [lambda\_artifact\_bucket](#input\_lambda\_artifact\_bucket) | S3 bucket containing the runner-control Lambda artifacts. | `string` | n/a | yes | -| [microvm\_image\_arn](#input\_microvm\_image\_arn) | Lambda MicroVM image ARN produced by the MicroVM image build. | `string` | n/a | yes | -| [microvm\_image\_version](#input\_microvm\_image\_version) | Optional immutable version of the Lambda MicroVM image. | `string` | `null` | no | -| [organization\_runners](#input\_organization\_runners) | Register the MicroVM runners at organization scope when true. | `bool` | `false` | no | -| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for the runners Lambda archive. | `string` | `"runners.zip"` | no | -| [runners\_maximum\_count](#input\_runners\_maximum\_count) | Maximum number of concurrent MicroVM runners. | `number` | `10` | no | -| [webhook\_lambda\_s3\_key](#input\_webhook\_lambda\_s3\_key) | S3 key for the webhook Lambda archive. | `string` | `"webhook.zip"` | no | - -## Outputs - -| Name | Description | -|------|-------------| -| [microvm\_image\_arn](#output\_microvm\_image\_arn) | The MicroVM image ARN consumed by this runner configuration. | -| [webhook\_endpoint](#output\_webhook\_endpoint) | Webhook endpoint to configure on the GitHub App. | - diff --git a/examples/microvm/main.tf b/examples/microvm/main.tf deleted file mode 100644 index f668e03bf6..0000000000 --- a/examples/microvm/main.tf +++ /dev/null @@ -1,105 +0,0 @@ -locals { - environment = coalesce(var.environment, "microvm") - aws_region = var.aws_region -} - -module "base" { - source = "../base" - - prefix = local.environment - aws_region = local.aws_region -} - -module "runners" { - source = "../../modules/multi-runner" - - aws_region = local.aws_region - prefix = local.environment - - experimental_features = ["multi-runner-v2"] - - global_config_github = { - app = { - key_base64 = var.github_app.key_base64 - id = var.github_app.id - webhook_secret = var.github_app.webhook_secret - } - } - - global_config_lambda = { - artifact = { - s3 = { - bucket = var.lambda_artifact_bucket - } - } - } - - global_config_orchestration_provider = { - webhook = { - runner = { - ephemeral = true - jit_config_enabled = true - maximum_count = var.runners_maximum_count - boot_time_in_minutes = 5 - } - github = { - organization_runners = var.organization_runners - } - lambda = { - artifact = { - s3 = { - key = var.runners_lambda_s3_key - } - } - webhook = { - artifact = { - s3 = { - key = var.webhook_lambda_s3_key - } - } - } - } - } - } - - global_config_ssm = { - paths = { - root = "/github-action-runners/${local.environment}" - } - } - - global_config_compute_provider = { - aws = { - microvm = { - image_arn = var.microvm_image_arn - image_version = var.microvm_image_version - ingress_network_connectors = var.ingress_network_connector_arns - egress_network_connectors = [var.egress_network_connector_arn] - } - } - } - - multi_runner_config = { - microvm = { - runner = { - os = "linux" - architecture = "arm64" - name_prefix = "microvm-" - extra_labels = ["microvm"] - } - orchestration_provider = { - webhook = { - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "arm64", "microvm"]] - bidirectionalLabelMatch = true - } - } - } - compute_provider = { - aws = { - microvm = {} - } - } - } - } -} diff --git a/examples/microvm/outputs.tf b/examples/microvm/outputs.tf deleted file mode 100644 index 87ad4c924c..0000000000 --- a/examples/microvm/outputs.tf +++ /dev/null @@ -1,9 +0,0 @@ -output "webhook_endpoint" { - description = "Webhook endpoint to configure on the GitHub App." - value = module.runners.webhook.endpoint -} - -output "microvm_image_arn" { - description = "The MicroVM image ARN consumed by this runner configuration." - value = var.microvm_image_arn -} diff --git a/examples/microvm/providers.tf b/examples/microvm/providers.tf deleted file mode 100644 index eca2fe96a7..0000000000 --- a/examples/microvm/providers.tf +++ /dev/null @@ -1,9 +0,0 @@ -provider "aws" { - region = local.aws_region - - default_tags { - tags = { - Example = local.environment - } - } -} diff --git a/examples/microvm/variables.tf b/examples/microvm/variables.tf deleted file mode 100644 index fb8060a54b..0000000000 --- a/examples/microvm/variables.tf +++ /dev/null @@ -1,73 +0,0 @@ -variable "aws_region" { - description = "AWS Region where the runner control plane and MicroVM resources are deployed." - type = string - default = "eu-west-1" -} - -variable "environment" { - description = "Name prefix for the example resources." - type = string - default = null -} - -variable "github_app" { - description = "GitHub for API usages." - - type = object({ - id = string - key_base64 = string - webhook_secret = string - }) -} - - -variable "lambda_artifact_bucket" { - description = "S3 bucket containing the runner-control Lambda artifacts." - type = string -} - -variable "runners_lambda_s3_key" { - description = "S3 key for the runners Lambda archive." - type = string - default = "runners.zip" -} - -variable "webhook_lambda_s3_key" { - description = "S3 key for the webhook Lambda archive." - type = string - default = "webhook.zip" -} - -variable "microvm_image_arn" { - description = "Lambda MicroVM image ARN produced by the MicroVM image build." - type = string -} - -variable "microvm_image_version" { - description = "Optional immutable version of the Lambda MicroVM image." - type = string - default = null -} - -variable "egress_network_connector_arn" { - description = "Regional Lambda Network Connector ARN used by MicroVMs and the image build." - type = string -} - -variable "ingress_network_connector_arns" { - description = "Optional regional Lambda Network Connector ARNs exposed to MicroVMs." - type = list(string) - default = [] -} - -variable "organization_runners" { - description = "Register the MicroVM runners at organization scope when true." - type = bool - default = false -} - -variable "runners_maximum_count" { - description = "Maximum number of concurrent MicroVM runners." - type = number - default = 10 -} diff --git a/examples/microvm/versions.tf b/examples/microvm/versions.tf deleted file mode 100644 index 8ace4cfd44..0000000000 --- a/examples/microvm/versions.tf +++ /dev/null @@ -1,13 +0,0 @@ -terraform { - required_providers { - aws = { - source = "hashicorp/aws" - version = ">= 6.33" - } - random = { - source = "hashicorp/random" - version = "~> 3.0" - } - } - required_version = ">= 1.3.0" -} diff --git a/examples/multi-runner-v2/README.md b/examples/multi-runner-v2/README.md deleted file mode 100644 index 2755c8fcf5..0000000000 --- a/examples/multi-runner-v2/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Multi-runner v2 example - -This example demonstrates the experimental multi-runner v2 interface. Shared -defaults are configured with `global_config*` variables, while -each runner lane uses `multi_runner_config` for its matcher, -runner lifecycle, and compute-provider settings. - -The example creates three lanes from one deployment: - -- Linux ARM64 Amazon Linux runners. -- Ephemeral Linux x64 Amazon Linux runners with job retry enabled. -- Windows x64 Server Core 2022 runners. - -The v2 interface keeps provider-owned settings inside the selected provider -configuration. For example, VPC and subnet settings are under -`global_config_compute_provider.aws.ec2`, while the per-lane -instance types and AMI configuration are under each lane's compute provider -block. The optional `ami` variable can provide per-lane AMI filters and owners, -which is useful for test environments with locally registered images. - -Configure the GitHub App variables before applying: - -```bash -terraform init -terraform apply \ - -var='github_app={id="123456",key_base64="..."}' -``` - -The `github_app` value is sensitive and should be supplied through a secure -variable source in real deployments rather than committed to configuration. - - -## Requirements - -| Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.5.6 | -| [aws](#requirement\_aws) | >= 6.33 | -| [local](#requirement\_local) | ~> 2.0 | -| [random](#requirement\_random) | ~> 3.0 | - -## Providers - -| Name | Version | -|------|---------| -| [random](#provider\_random) | 3.9.0 | - -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [base](#module\_base) | ../base | n/a | -| [runners](#module\_runners) | ../../modules/multi-runner | n/a | -| [webhook\_github\_app](#module\_webhook\_github\_app) | ../../modules/webhook-github-app | n/a | - -## Resources - -| Name | Type | -|------|------| -| [random_id.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [ami](#input\_ami) | Optional AMI configuration keyed by runner lane. |
map(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}))
| `{}` | no | -| [aws\_region](#input\_aws\_region) | AWS region to deploy to. | `string` | `"eu-west-1"` | no | -| [environment](#input\_environment) | Environment name, used as prefix. | `string` | `null` | no | -| [github\_app](#input\_github\_app) | GitHub App ID and base64-encoded private key. |
object({
id = string
key_base64 = string
})
| n/a | yes | - -## Outputs - -| Name | Description | -|------|-------------| -| [webhook\_endpoint](#output\_webhook\_endpoint) | n/a | -| [webhook\_secret](#output\_webhook\_secret) | n/a | - diff --git a/examples/multi-runner-v2/main.tf b/examples/multi-runner-v2/main.tf deleted file mode 100644 index 4f63974f11..0000000000 --- a/examples/multi-runner-v2/main.tf +++ /dev/null @@ -1,180 +0,0 @@ -locals { - environment = var.environment != null ? var.environment : "multi-runner-v2" - aws_region = var.aws_region -} - -resource "random_id" "random" { - byte_length = 20 -} - -module "base" { - source = "../base" - - prefix = local.environment - aws_region = local.aws_region -} - -module "runners" { - source = "../../modules/multi-runner" - - prefix = local.environment - aws_region = local.aws_region - - experimental_features = ["multi-runner-v2"] - - global_config = { - tags = { - Example = local.environment - Project = "ProjectX" - } - runner = { - os = "linux" - architecture = "x64" - extra_labels = ["v2"] - } - } - - global_config_github = { - app = { - key_base64 = var.github_app.key_base64 - id = var.github_app.id - webhook_secret = random_id.random.hex - } - } - - global_config_lambda = { - architecture = "arm64" - } - - global_config_orchestration_provider = { - webhook = { - eventbridge = { - enabled = true - accept_events = ["workflow_job"] - } - } - } - - global_config_compute_provider = { - aws = { - ec2 = { - vpc_id = module.base.vpc.vpc_id - subnet_ids = module.base.vpc.private_subnets - ssm_enabled = true - runner_binaries = { - enabled = true - } - } - } - } - - multi_runner_config = { - linux-arm64 = { - runner = { - architecture = "arm64" - name_prefix = "amazon-arm64-" - extra_labels = ["amazon"] - } - orchestration_provider = { - webhook = { - runner = { - maximum_count = 1 - } - matcherConfig = { - exactMatch = true - labelMatchers = [["self-hosted", "linux", "arm64", "amazon"]] - } - } - } - compute_provider = { - aws = { - ec2 = { - instance_types = ["t4g.large", "c6g.large"] - ami = lookup(var.ami, "linux-arm64", null) - } - } - } - } - - linux-x64 = { - runner = { - name_prefix = "amazon-x64-" - extra_labels = ["amazon"] - } - orchestration_provider = { - webhook = { - runner = { - ephemeral = true - maximum_count = 1 - } - matcherConfig = { - labelMatchers = [["self-hosted", "linux", "x64", "amazon"]] - exactMatch = false - priority = 1 - } - queue = { - delay_webhook_event = 0 - } - job_retry = { - enabled = true - } - } - } - compute_provider = { - aws = { - ec2 = { - instance_types = ["m5a.large", "m5ad.large"] - ami = lookup(var.ami, "linux-x64", null) - } - } - } - } - - windows-x64 = { - runner = { - os = "windows" - name_prefix = "windows-x64-" - } - orchestration_provider = { - webhook = { - runner = { - boot_time_in_minutes = 20 - maximum_count = 1 - } - matcherConfig = { - exactMatch = true - labelMatchers = [["self-hosted", "windows", "x64", "servercore-2022"]] - } - } - } - compute_provider = { - aws = { - ec2 = { - instance_types = ["m5.large", "c5.large"] - ami = lookup(var.ami, "windows-x64", { - filter = { - name = ["Windows_Server-2022-English-Full-ECS_Optimized-*"] - state = ["available"] - } - owners = ["amazon"] - id_ssm_parameter = null - kms_key = null - }) - } - } - } - } - } -} - -module "webhook_github_app" { - source = "../../modules/webhook-github-app" - depends_on = [module.runners] - - github_app = { - key_base64 = var.github_app.key_base64 - id = var.github_app.id - webhook_secret = random_id.random.hex - } - webhook_endpoint = module.runners.webhook.endpoint -} diff --git a/examples/multi-runner-v2/providers.tf b/examples/multi-runner-v2/providers.tf deleted file mode 100644 index eca2fe96a7..0000000000 --- a/examples/multi-runner-v2/providers.tf +++ /dev/null @@ -1,9 +0,0 @@ -provider "aws" { - region = local.aws_region - - default_tags { - tags = { - Example = local.environment - } - } -} diff --git a/examples/multi-runner-v2/variables.tf b/examples/multi-runner-v2/variables.tf deleted file mode 100644 index fe104758b9..0000000000 --- a/examples/multi-runner-v2/variables.tf +++ /dev/null @@ -1,39 +0,0 @@ -variable "github_app" { - description = "GitHub App ID and base64-encoded private key." - - type = object({ - id = string - key_base64 = string - }) - sensitive = true -} - -variable "environment" { - description = "Environment name, used as prefix." - - type = string - default = null -} - -variable "aws_region" { - description = "AWS region to deploy to." - - type = string - default = "eu-west-1" -} - -variable "ami" { - description = "Optional AMI configuration keyed by runner lane." - - type = map(object({ - filter = optional(map(list(string)), { state = ["available"] }) - owners = optional(list(string), ["amazon"]) - id_ssm_parameter = optional(object({ - arn = string - }), null) - kms_key = optional(object({ - arn = string - }), null) - })) - default = {} -} diff --git a/examples/multi-runner-v2/.terraform.lock.hcl b/examples/multi-runner-webhook/.terraform.lock.hcl similarity index 76% rename from examples/multi-runner-v2/.terraform.lock.hcl rename to examples/multi-runner-webhook/.terraform.lock.hcl index 62c535d49e..8c974d9a4b 100644 --- a/examples/multi-runner-v2/.terraform.lock.hcl +++ b/examples/multi-runner-webhook/.terraform.lock.hcl @@ -26,28 +26,6 @@ provider "registry.terraform.io/hashicorp/aws" { ] } -provider "registry.terraform.io/hashicorp/local" { - version = "2.9.0" - constraints = "~> 2.0" - hashes = [ - "h1:9rBZCMNpxKwMlRbWH2QpwD3kqUCAejdOZQ/aiiDObXQ=", - "h1:m24fjcInWvTVZ1XSo2MaNuKPe+X/gfG8SIi09rA7a7M=", - "zh:0baa4566cf77f1ff52f4293d1c8536202dd23edc197c3196413a28343c3ac3a0", - "zh:16b5559c3c07088ddad11a9bb9e9c0799999363c2958e9a5be2bcbbf2cd9ca64", - "zh:197c79015a10d1cce904a8ea722cbc750c42aeae2da53f44a6a0751d9fd1aa90", - "zh:29d0b03e5343a80677ebfeb2e2c31cbe4b1f65e736e53417454a4277fec2544c", - "zh:4896bfa6cf1d2fd562b47ef2e87f47862ae92a04f8ad5d764380f0c6653473b8", - "zh:531f8529cbca49f681883e57761a05a8398afaef6d1ab0d205d26bf12f4428e8", - "zh:6aaf5011d83161c86d2bfb80c0923ec934e578288758da2f37acb7aec129004b", - "zh:7430275253d3d3c40aa6179e0ec0d63212874dbbc06c5a51b9d07ec590f9756c", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:be17dc611e95e26cdf6cad79dfccf1064f0e32032a2efeb939a9bbe7fb1cbfe9", - "zh:f0e3b0aa644202e1d79d2000dca91f6019425da71e9800fa23f27e51c034f195", - "zh:f62bae4519e4ead49182ddc8afe8cf61e2a4c3ba3973b0fbba967736a2696aa3", - "zh:fcafa360a5b0b96244f26f4e3a6d642b716a376557142c2442ff2fb12d11da18", - ] -} - provider "registry.terraform.io/hashicorp/null" { version = "3.3.1" constraints = "~> 3.0, ~> 3.2" diff --git a/examples/multi-runner-v2/.terraform.lock.hcl.tofu b/examples/multi-runner-webhook/.terraform.lock.hcl.tofu similarity index 75% rename from examples/multi-runner-v2/.terraform.lock.hcl.tofu rename to examples/multi-runner-webhook/.terraform.lock.hcl.tofu index e8fb9a5cf8..72e73d566d 100644 --- a/examples/multi-runner-v2/.terraform.lock.hcl.tofu +++ b/examples/multi-runner-webhook/.terraform.lock.hcl.tofu @@ -38,43 +38,6 @@ provider "registry.opentofu.org/hashicorp/aws" { ] } -provider "registry.opentofu.org/hashicorp/local" { - version = "2.9.0" - constraints = "~> 2.0" - hashes = [ - "h1:1dtKYW/5a1qob3yneL6WzOlnSGfYtJ6a2XeejCk9yb4=", - "h1:5NseXq5wU8O20ersTtV4ocrLYFFtgFr7n0pRLO1W2Rw=", - "h1:5d22ZPPK4iiygPbwRz/PJF5Es/0axVpMlPRpCR0Padw=", - "h1:AnwyolirmIlBMjH6+tV8bKkvT+5axJNYxi2y2IguiX4=", - "h1:PBp+HeseY021Fw3sLznCG27idgwPoff4cBuNmKgPL2w=", - "h1:VDxIhe4GbzdOCdmt7mQaqdwERQW6GSI7Roonts42Gr0=", - "h1:ZO6eWWnf8LjjV1q/JNeL9WLtZ6fwIttOnyN5LjCNSEo=", - "h1:dPIAf8oUAz+vW2E0iZunMvpuPddRZIztRsPSY1u+VnY=", - "h1:fwTDVG9AhFVKQZIb1EXkHv4FqzsZNlLWgkyPGDmZZEE=", - "h1:kDc465XPC7/6XFCjrMC4mTqhA9ef0FHKuJ3ZgfGNfeg=", - "h1:kGbjxrI2P8MHeyVtE1U3Q1TbyF71ExnHxtkrE+Aj6UU=", - "h1:kcoK6Afbsj54u9zaEqpecWAFKytqjBijtguCNwV3d4M=", - "h1:rxomJjDwOo+YZ+WIPc25FqEgsz9orh/2MCyUcZmFjvw=", - "h1:t0CMn/Rkwquw8l2yQ+O4ApzbMZfY2UazbsDnZygzACA=", - "h1:tJwgm2BS4xCGlElCDQEFXQoefY9Y4t0JdSKTtsPBbBo=", - "zh:13ef7ecd1e397ec5b20ea588508dd3e3b8d6c50d809ae76b079abf9dd8d02e4b", - "zh:2190c9325980076489ce02b0f5dd2c0b91fc8711cefa99e714d8619a32827ad1", - "zh:2a0cfc5600730093705071707e4a4e4e953e7d9091859e0f66b46daa1060dd5d", - "zh:2ff53eac1af43ab9a2248a0e53c963d46e19cf04bc4c3f323591cfcebb218252", - "zh:4ebc3dee700f60af9da29970052fd02fa947813162b224716862dc9d7f1f7542", - "zh:5fe6dab84ceeaa8eb3f1567c5f05578333370c472240ca5c5bfc25e92d4d5586", - "zh:66bbec16367bbf440045502c9779b11f4ac5b022c8d8d17afe12d431950838b5", - "zh:7641e5c2e4b529e869cde29ab5b1de2fd1091489eb745b19ac2709bd7f4dfd84", - "zh:855bfba0756d17ce07595ff57d7cf664443d1495127cb88fb063362734b8b22a", - "zh:aaec10f237921d60c581d1b7a66f0a8a8019d9802dc04af11b5b981f6682e01d", - "zh:e460835a38ffa1e74f6929904bfd14ef473d217fd537b7ce834abe5ce5e2ce07", - "zh:ecc4295215db0e4aea3c9329611c31e09a853e1ae207d56742403bd4f5516703", - "zh:ee6d9fae63a612072e00402894e14826af7a3351c235b9c5b423b7629a77ca29", - "zh:f2b5c8db74aa7ebcf7cd423672358437d42401675069ef67b01ff910054e49d5", - "zh:f5aff74d3eb96d4592c7bca5cd3ea89b469e84efbf382944bd0f844a57059c09", - ] -} - provider "registry.opentofu.org/hashicorp/null" { version = "3.3.2" constraints = "~> 3.0, ~> 3.2" diff --git a/examples/multi-runner-webhook/README.md b/examples/multi-runner-webhook/README.md new file mode 100644 index 0000000000..4312cd452a --- /dev/null +++ b/examples/multi-runner-webhook/README.md @@ -0,0 +1,19 @@ +# Multi-runner webhook example + +This example exercises the shared experimental multi-runner v2 webhook path +with EC2 and Lambda MicroVM compute. The runner lanes, webhook orchestration, +Lambda artifacts, and GitHub configuration are common; provider-owned inputs +are grouped under `compute_provider`. + +The example creates both an EC2 lane and a Lambda MicroVM lane behind the same +webhook endpoint. The MiniStack smoke test sends matching jobs to each lane in +sequence, so adding another provider means adding another lane and provider +specific lifecycle assertions to the same deployment. + +The runner-control and webhook Lambda archives are explicit inputs: + +```sh +terraform apply \ + -var='runners_lambda_zip=/path/to/runners.zip' \ + -var='webhook_lambda_zip=/path/to/webhook.zip' +``` diff --git a/examples/multi-runner-webhook/main.tf b/examples/multi-runner-webhook/main.tf new file mode 100644 index 0000000000..09ecab8674 --- /dev/null +++ b/examples/multi-runner-webhook/main.tf @@ -0,0 +1,179 @@ +module "base" { + source = "../base" + + prefix = var.environment + aws_region = var.aws_region +} + +module "runners" { + source = "../../modules/multi-runner" + + prefix = var.environment + aws_region = var.aws_region + + experimental_features = ["multi-runner-v2"] + + global_config = { + tags = { + Example = var.environment + Project = "MiniStack" + } + runner = { + os = "linux" + architecture = "x64" + } + } + + global_config_github = { + app = { + key_base64 = var.github_app.key_base64 + id = var.github_app.id + webhook_secret = var.github_app.webhook_secret + } + enterprise_server = var.github_enterprise_server + } + + global_config_lambda = { + architecture = "x86_64" + } + + global_config_observability = { + logs = { + level = "debug" + } + } + + global_config_orchestration_provider = { + webhook = { + runner = { + ephemeral = true + jit_config_enabled = true + # The smoke test keeps the ephemeral resources alive until each provider's + # scale-down phase, so it needs capacity for standard, dynamic, and pool runners. + maximum_count = 3 + boot_time_in_minutes = 0 + } + lambda = { + artifact = { + zip = var.runners_lambda_zip + } + scale = { + up = { + job_queued_check_enabled = true + } + down = { + # Smoke scenarios create and remove runners immediately; do not + # wait for the Linux five-minute minimum runtime before checking + # the GitHub runner state. + minimum_running_time_in_minutes = 0 + } + } + pool = { + config = [{ + schedule_expression = "cron(0 0 1 1 ? 2099)" + schedule_expression_timezone = "UTC" + size = 1 + }] + runner_owner = "test-owner" + } + webhook = { + artifact = { + zip = var.webhook_lambda_zip + } + } + } + } + } + + global_config_ssm = { + paths = { + root = "/github-action-runners/${var.environment}" + } + } + + global_config_compute_provider = { + aws = { + ec2 = { + vpc_id = module.base.vpc.vpc_id + subnet_ids = module.base.vpc.private_subnets + ssm_enabled = true + binaries_syncer = { + enabled = false + } + } + microvm = { + image_arn = var.compute_provider.aws.microvm.image_arn + image_version = var.compute_provider.aws.microvm.image_version + ingress_network_connectors = var.compute_provider.aws.microvm.ingress_network_connectors + egress_network_connectors = var.compute_provider.aws.microvm.egress_network_connectors + } + } + } + + multi_runner_config = { + ec2 = { + runner = { + os = "linux" + architecture = "x64" + name_prefix = "ec2-" + extra_labels = ["ec2"] + } + orchestration_provider = { + webhook = { + github = { + organization_runners = true + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "x64", "ec2"]] + bidirectionalLabelMatch = true + dynamic_labels_enabled = true + awsDynamicLabelsPolicy = { + restricted_keys = { + "instance-type" = { allowed = ["m5.*"] } + } + } + } + } + } + compute_provider = { + aws = { + ec2 = { + instance_types = var.compute_provider.aws.ec2.instance_types + ami = var.compute_provider.aws.ec2.ami + } + } + } + } + + microvm = { + runner = { + os = "linux" + architecture = "arm64" + name_prefix = "microvm-" + extra_labels = ["microvm"] + } + orchestration_provider = { + webhook = { + github = { + organization_runners = true + } + matcherConfig = { + labelMatchers = [["self-hosted", "linux", "arm64", "microvm"]] + bidirectionalLabelMatch = true + dynamic_labels_enabled = true + awsDynamicLabelsPolicy = { + restricted_keys = { + "image-version" = { allowed = ["3.0"] } + } + } + } + } + } + compute_provider = { + aws = { + microvm = {} + } + } + } + } +} diff --git a/examples/multi-runner-v2/outputs.tf b/examples/multi-runner-webhook/outputs.tf similarity index 73% rename from examples/multi-runner-v2/outputs.tf rename to examples/multi-runner-webhook/outputs.tf index 1feaf2e671..f1e5f7cdb5 100644 --- a/examples/multi-runner-v2/outputs.tf +++ b/examples/multi-runner-webhook/outputs.tf @@ -1,8 +1,7 @@ output "webhook_endpoint" { value = module.runners.webhook.endpoint } - output "webhook_secret" { sensitive = true - value = random_id.random.hex + value = var.github_app.webhook_secret } diff --git a/examples/multi-runner-webhook/providers.tf b/examples/multi-runner-webhook/providers.tf new file mode 100644 index 0000000000..f24f950b27 --- /dev/null +++ b/examples/multi-runner-webhook/providers.tf @@ -0,0 +1,9 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Example = var.environment + } + } +} diff --git a/examples/multi-runner-webhook/variables.tf b/examples/multi-runner-webhook/variables.tf new file mode 100644 index 0000000000..7e19d64c1a --- /dev/null +++ b/examples/multi-runner-webhook/variables.tf @@ -0,0 +1,68 @@ +variable "aws_region" { + description = "AWS Region where the runner control plane and compute provider resources are deployed." + type = string + default = "eu-west-1" +} + +variable "environment" { + description = "Name prefix for the example resources." + type = string +} + +variable "github_app" { + description = "GitHub App credentials used by the webhook orchestration provider." + sensitive = true + + type = object({ + id = string + key_base64 = string + webhook_secret = string + }) +} + +variable "github_enterprise_server" { + description = "Optional GitHub Enterprise Server endpoint used by the smoke-test API mock." + type = object({ + url = string + ssl_verify = bool + }) + default = null +} + +variable "runners_lambda_zip" { + description = "Local ZIP file for the runner-control Lambda." + type = string +} + +variable "webhook_lambda_zip" { + description = "Local ZIP file for the webhook Lambda." + type = string +} + +variable "compute_provider" { + description = "Provider-specific settings for the EC2 and MicroVM runner lanes." + + type = object({ + aws = object({ + ec2 = object({ + instance_types = list(string) + ami = object({ + filter = optional(map(list(string)), { state = ["available"] }) + owners = optional(list(string), ["amazon"]) + id_ssm_parameter = optional(object({ + arn = string + }), null) + kms_key = optional(object({ + arn = string + }), null) + }) + }) + microvm = object({ + image_arn = string + image_version = optional(string, null) + ingress_network_connectors = optional(list(string), []) + egress_network_connectors = list(string) + }) + }) + }) +} diff --git a/examples/multi-runner-v2/versions.tf b/examples/multi-runner-webhook/versions.tf similarity index 99% rename from examples/multi-runner-v2/versions.tf rename to examples/multi-runner-webhook/versions.tf index 6af69ab915..e4008ada42 100644 --- a/examples/multi-runner-v2/versions.tf +++ b/examples/multi-runner-webhook/versions.tf @@ -13,5 +13,6 @@ terraform { version = "~> 3.0" } } + required_version = ">= 1.5.6" } diff --git a/mkdocs.yaml b/mkdocs.yaml index 81b4992a9e..d29d6ee12f 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -80,7 +80,7 @@ nav: - Overview: examples/index.md - Default: examples/default.md - Multi Runner: examples/multi-runner.md - - Multi Runner v2: examples/multi-runner-v2.md + - Multi Runner Webhook: examples/multi-runner-webhook.md - Ephemeral: examples/ephemeral.md - External managed secrets: examples/external-managed-ssm-secrets.md - Custom AMI: examples/prebuilt.md diff --git a/modules/multi-runner/tests/config-resolution.tftest.hcl b/modules/multi-runner/tests/config-resolution.tftest.hcl index 7d0c05701e..26387d9b96 100644 --- a/modules/multi-runner/tests/config-resolution.tftest.hcl +++ b/modules/multi-runner/tests/config-resolution.tftest.hcl @@ -623,6 +623,7 @@ run "v2_microvm_inputs_route_to_microvm_provider" { && local.resolved_config.multi_runner_config["microvm"].compute_provider.aws.ec2 == null && local.resolved_config.multi_runner_config["microvm"].compute_provider.aws.microvm.image_arn == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:global" && local.resolved_config.multi_runner_config["microvm"].compute_provider.aws.microvm.image_version == "8" + && local.runner_matcher_config["microvm"].computeProvider == "microvm" && local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.s3.key == "global-webhook.zip" ) error_message = "Experimental MicroVM lanes must resolve Linux ARM64 settings, inherit global provider values, and place the webhook artifact key under lambda.webhook.artifact." @@ -638,4 +639,4 @@ run "v2_microvm_inputs_route_to_microvm_provider" { ) error_message = "Experimental MicroVM lanes must route through module.runner_configs and expose the MicroVM provider contract without an EC2 provider." } -} \ No newline at end of file +} diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index f8d16406fe..dc1e0505d4 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -6,9 +6,12 @@ locals { runner_matcher_config = { for k, v in local.webhook_runner_config : k => { - id = aws_sqs_queue.queued_builds[k].id - arn = aws_sqs_queue.queued_builds[k].arn - computeProvider = "ec2" + id = aws_sqs_queue.queued_builds[k].id + arn = aws_sqs_queue.queued_builds[k].arn + computeProvider = try(one([ + for provider_type, provider_config in module.runner_configs[k].provider.aws : provider_type + if provider_config != null + ]), "ec2") matcherConfig = { labelMatchers = v.orchestration_provider.webhook.matcherConfig.labelMatchers exactMatch = v.orchestration_provider.webhook.matcherConfig.exactMatch diff --git a/modules/webhook/README.md b/modules/webhook/README.md index f6a752da2c..dd992dbf90 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -89,7 +89,7 @@ yarn run dist | [repository\_white\_list](#input\_repository\_white\_list) | List of github repository full names (owner/repo\_name) that will be allowed to use the github app. Leave empty for no filtering. | `list(string)` | `[]` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the environment name will be used. | `string` | `null` | no | | [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | -| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`. |
map(object({
arn = string
id = string
computeProvider = optional(string, "ec2")
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| n/a | yes | +| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2` and identifies the compute provider that owns the queue. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type` or `image-version` for `ghr-microvm-image-version`. |
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 | | [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 | diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf index 2e5fafd205..0857f5eb7c 100644 --- a/modules/webhook/variables.tf +++ b/modules/webhook/variables.tf @@ -23,7 +23,7 @@ variable "tags" { } variable "runner_matcher_config" { - description = "SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`." + description = "SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2` and identifies the compute provider that owns the queue. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type` or `image-version` for `ghr-microvm-image-version`." type = map(object({ arn = string id = string @@ -38,15 +38,18 @@ variable "runner_matcher_config" { }) })) validation { - condition = try(var.runner_matcher_config.matcherConfig.priority, 999) >= 0 && try(var.runner_matcher_config.matcherConfig.priority, 999) < 1000 + condition = alltrue([ + for config in values(var.runner_matcher_config) : + config.matcherConfig.priority >= 0 && config.matcherConfig.priority < 1000 + ]) error_message = "The priority of the matcher must be between 0 and 999." } validation { condition = alltrue([ for config in values(var.runner_matcher_config) : - lower(trimspace(config.computeProvider)) == "ec2" + contains(["ec2", "microvm"], lower(trimspace(config.computeProvider))) ]) - error_message = "computeProvider must be ec2." + error_message = "computeProvider must be one of: ec2, microvm." } } diff --git a/tests/ministack/README.md b/tests/ministack/README.md index 0bd05893ab..5af424a1ea 100644 --- a/tests/ministack/README.md +++ b/tests/ministack/README.md @@ -1,7 +1,7 @@ # MiniStack example tests The MiniStack workflow runs the `base`, `prebuilt`, `default`, `ephemeral`, -`multi-runner`, `multi-runner-v2`, and `termination-watcher` examples directly +`multi-runner`, and `termination-watcher` examples directly with Terraform 1.5.6 and the latest Terraform release, and with OpenTofu 1.11 and the latest OpenTofu release. The examples with input variables get their inputs from their own tfvars files @@ -11,8 +11,8 @@ into the MicroVM Network Connector. The `termination-watcher` example has no inp and uses the configuration checked into the example itself. No override files, setup module, or Terraform fixture configuration is checked in. The helper creates and removes a temporary AMI override for `default` and -`ephemeral`, temporary SSM parameters for `multi-runner`, and temporary AMI -fixtures for `multi-runner-v2`. The migration test uses its dedicated +`ephemeral`, and temporary SSM parameters for `multi-runner`. The migration +test uses its dedicated `run-migration-test.sh` lifecycle script. Start MiniStack, set the AWS endpoint and test credentials, then run: @@ -28,8 +28,6 @@ tests/ministack/run-example.sh apply ephemeral # or tests/ministack/run-example.sh apply multi-runner # or -tests/ministack/run-example.sh apply multi-runner-v2 -# or tests/ministack/run-example.sh apply termination-watcher ``` @@ -46,19 +44,19 @@ the same lifecycle matrix. The smoke test covers two independent lifecycle chains. The webhook chain sends signed `workflow_job` webhooks through the API Gateway endpoint and verifies the asynchronous path through EventBridge, the dispatcher Lambda, SQS, -and the scale-up Lambda. It runs scale-up once without a dynamic label and once -with `ghr-ec2-instance-type:m5.large`, checking that the first launch uses a -configured default instance type and the second launch uses exactly `m5.large`. -The scale-up Lambda calls a pinned `mockserver/mockserver` container initialized -from `github-api-expectations.json`; the test uses MockServer's verification API -to confirm the expected GitHub API calls for both jobs. It also checks the +and the scale-up Lambda. Each provider runs scale-up once without a dynamic +label and once with a provider-specific dynamic label, checking the provider's +resolved resource configuration. +The smoke runner connects to an already-running MockServer initialized from +`github-api-expectations.json`; it uses MockServer's verification API to confirm +the expected GitHub API calls for both jobs. It also checks the webhook, dispatcher, and scale-up Lambda log groups for each smoke job ID, then -confirms that both MiniStack EC2 runner instances are removed and terminated. +confirms that each provider resource is removed and terminated. The second, pool chain then invokes the pool Lambda with a pool size of one and verifies every expected GitHub API route for pool reconciliation, including the installation, token, runner-list, and registration-token calls, before confirming that it -creates a second EC2 runner. Installation lookup is mocked for configurations +creates a second provider runner. Installation lookup is mocked for configurations that do not provide a stored installation ID, but is conditional and is not a required assertion. The test also verifies the `ghr:Application`, `ghr:created_by`, `ghr:Type`, and `ghr:Owner` tags used to discover managed @@ -69,30 +67,59 @@ verifies the GitHub API calls and EC2 termination. The pool schedule is configured for a far-future date because the test invokes the Lambda directly. -The MicroVM smoke job runs the same webhook and pool lifecycle chains against -the `microvm` example. It sends the exact `self-hosted`, `linux`, `arm64`, and -`microvm` matcher labels, verifies `RunMicrovm` resources and durable SSM -ownership metadata, and confirms scale-down reaches `TerminateMicrovm` for -both webhook- and pool-created runners. +The smoke deployment uses the `multi-runner-webhook` example, which creates +both EC2 and MicroVM lanes behind one webhook endpoint. For each provider, the +shared lifecycle runs scale-up without a dynamic label, scale-up with a dynamic +label, one pool scale-up, and scale-down for all three resources. The provider +implementation supplies the event labels, resource discovery, provider-specific +route checks, and compute-resource assertions. The shared example accepts the +built runner-control and webhook Lambda ZIP files as `runners_lambda_zip` and +`webhook_lambda_zip`. -Build the two real Lambda distributions, start MiniStack, and run: +Build the two real Lambda distributions, start MockServer and MiniStack, and run: ```sh (cd lambdas && yarn install --frozen-lockfile) (cd lambdas && yarn workspace @aws-github-runner/webhook dist) (cd lambdas && yarn workspace @aws-github-runner/control-plane dist) -sh tests/ministack/run-smoke.sh -# In CI, the MicroVM-specific job runs: -sh tests/ministack/run-microvm-smoke.sh +# Run both provider lanes in one deployment. MockServer must already be running +# and MINISTACK_GITHUB_MOCK_URL must point to it: +MINISTACK_GITHUB_MOCK_URL=http://localhost:1080 \ + python3 tests/ministack/run-webhook-smoke.py +# Preserve the deployment and temporary tfvars file for debugging: +MINISTACK_GITHUB_MOCK_URL=http://localhost:1080 \ + python3 tests/ministack/run-webhook-smoke.py --keep-deployment +# Run one provider explicitly when debugging: +MINISTACK_GITHUB_MOCK_URL=http://localhost:1080 \ + python3 tests/ministack/run-webhook-smoke.py ec2 +MINISTACK_GITHUB_MOCK_URL=http://localhost:1080 \ + python3 tests/ministack/run-webhook-smoke.py microvm ``` +The runner writes `ministack-smoke-checklist.txt` in the current directory and +updates it throughout the run. Override the destination with +`MINISTACK_SMOKE_CHECKLIST_FILE` when the file should be retained as a CI +artifact. + +The Python `smoke/lifecycle.py` module owns the provider-neutral scenarios, and +`smoke/provider.py` defines the provider interface. Shared webhook delivery, log +polling, MockServer route verification, GitHub runner-state fixtures, and Lambda +invocation helpers live in `smoke/common.py`. To add a provider, implement the +interface under `smoke/`, register the provider in +`run-webhook-smoke.py`, and add its provider-specific assertions. + The smoke script generates a temporary RSA key and Terraform variables file, -starts the MockServer container on a temporary port, and removes all temporary -state during cleanup. In CI, the pinned MockServer setup action starts the -server and waits for readiness; the expectations are loaded after checkout. +expects an already-running MockServer at `MINISTACK_GITHUB_MOCK_URL`, loads the +expectations into it, and destroys the Terraform deployment during cleanup. +Pass `--keep-deployment` (or set `MINISTACK_SMOKE_KEEP_DEPLOYMENT=1`) to retain +the deployment for debugging; it prints the generated tfvars path so the +deployment can be destroyed separately with `tests/ministack/run-example.sh destroy`. +The MockServer lifecycle is an external test dependency; the Python smoke runner +does not start or stop Docker containers. In CI, the MockServer setup action +starts the server and waits for readiness before the Python smoke runner executes. MiniStack must be able to reach `host.docker.internal`; override the hostname with `MINISTACK_GITHUB_MOCK_HOST` when using a different container runtime. When MiniStack is exposed on a non-default local port, use a host address reachable from its container for `AWS_ENDPOINT_URL`, for example -`AWS_ENDPOINT_URL=http://:14568`, instead of `127.0.0.1`. +`AWS_ENDPOINT_URL=http://:14568`, instead of `localhost`. diff --git a/tests/ministack/microvm.tfvars b/tests/ministack/microvm.tfvars deleted file mode 100644 index 516ace3e4a..0000000000 --- a/tests/ministack/microvm.tfvars +++ /dev/null @@ -1,13 +0,0 @@ - -aws_region = "eu-west-1" -environment = "microvm-ministack" - -github_app = { - id = "2" - key_base64 = "ministack-invalid-key" - webhook_secret = "ministack-invalid-webhook-secret" -} - -lambda_artifact_bucket = "github-actions-runner-microvm-ministack" -microvm_image_arn = "arn:aws:lambda:eu-west-1:000000000000:microvm-image:ministack" -egress_network_connector_arn = "arn:aws:lambda:eu-west-1:000000000000:network-connector:ministack" diff --git a/tests/ministack/multi-runner-v2.tfvars b/tests/ministack/multi-runner-v2.tfvars deleted file mode 100644 index 0f9c6073fc..0000000000 --- a/tests/ministack/multi-runner-v2.tfvars +++ /dev/null @@ -1,31 +0,0 @@ -environment = "ministack-v2" -aws_region = "eu-west-1" - -github_app = { - id = "0" - key_base64 = "ministack-invalid-key" -} - -ami = { - "linux-arm64" = { - filter = { - name = ["ministack-v2-linux-arm64"] - state = ["available"] - } - owners = ["self"] - } - "linux-x64" = { - filter = { - name = ["ministack-v2-linux-x64"] - state = ["available"] - } - owners = ["self"] - } - "windows-x64" = { - filter = { - name = ["ministack-v2-windows-x64"] - state = ["available"] - } - owners = ["self"] - } -} diff --git a/tests/ministack/multi-runner-webhook.tfvars b/tests/ministack/multi-runner-webhook.tfvars new file mode 100644 index 0000000000..aaab6d1da1 --- /dev/null +++ b/tests/ministack/multi-runner-webhook.tfvars @@ -0,0 +1,32 @@ +aws_region = "eu-west-1" +environment = "multi-runner-webhook" + +runners_lambda_zip = "../../lambda_output/runners.zip" +webhook_lambda_zip = "../../lambda_output/webhook.zip" + +github_app = { + id = "123" + key_base64 = "ministack-invalid-key" + webhook_secret = "ministack-webhook-secret" +} + +compute_provider = { + aws = { + ec2 = { + instance_types = ["m7a.large", "m5.large"] + ami = { + filter = { + name = ["ministack-webhook-linux-x64"] + state = ["available"] + } + owners = ["self"] + } + } + microvm = { + image_arn = "arn:aws:lambda:eu-west-1:000000000000:microvm-image:ministack" + image_version = "3.0" + egress_network_connectors = ["arn:aws:lambda:eu-west-1:000000000000:network-connector:ministack"] + ingress_network_connectors = [] + } + } +} diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index bb52c7689a..ff9b66b858 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -22,7 +22,7 @@ case "$iac_binary" in ;; esac case "$example" in - base | prebuilt | default | ephemeral | multi-runner | multi-runner-v2 | microvm-foundation | microvm) + base | prebuilt | default | ephemeral | multi-runner | multi-runner-webhook | microvm-foundation) use_tfvars=true ;; migration-test) @@ -32,7 +32,7 @@ case "$example" in use_tfvars=false ;; *) - echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-v2, microvm-foundation, microvm, migration-test, termination-watcher" >&2 + echo "Supported examples for the runner are: base, prebuilt, default, ephemeral, multi-runner, multi-runner-webhook, microvm-foundation, migration-test, termination-watcher" >&2 exit 64 ;; esac @@ -40,7 +40,7 @@ esac case "$action" in init | plan | apply | destroy) ;; *) - echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-v2|microvm-foundation|microvm|migration-test|termination-watcher} [TFVARS_FILE]" >&2 + echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-webhook|microvm-foundation|migration-test|termination-watcher} [TFVARS_FILE]" >&2 exit 64 ;; esac @@ -196,11 +196,11 @@ create_ami_fixture() { architecture="$2" ami_id=$(ministack_aws ec2 describe-images \ --owners self \ - --filters "Name=name,Values=$ami_name" "Name=state,Values=available" \ + --filters "Name=name,Values=$ami_name" \ --query 'Images[0].ImageId' \ --output text) - if [ "$ami_id" = "None" ]; then + if [ "$ami_id" = "None" ] || [ -z "$ami_id" ]; then ami_id=$(ministack_aws ec2 register-image \ --name "$ami_name" \ --description "MiniStack test-only AMI" \ @@ -214,6 +214,25 @@ create_ami_fixture() { $ami_id" fi + attempts=60 + while [ "$attempts" -gt 0 ]; do + ami_state=$(ministack_aws ec2 describe-images \ + --owners self \ + --image-ids "$ami_id" \ + --query 'Images[0].State' \ + --output text) + if [ "$ami_state" = "available" ]; then + return + fi + + attempts=$((attempts - 1)) + if [ "$attempts" -eq 0 ]; then + echo "AMI $ami_id ($ami_name) did not become available; last state: $ami_state" >&2 + exit 70 + fi + sleep 1 + done + } create_ssm_fixture() { @@ -347,29 +366,8 @@ $lambda_zip" "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64" \ "ami-0abcdef1234567890" ;; - multi-runner-v2) - create_ami_fixture "ministack-v2-linux-arm64" arm64 >/dev/null - create_ami_fixture "ministack-v2-linux-x64" x86_64 >/dev/null - create_ami_fixture "ministack-v2-windows-x64" x86_64 >/dev/null - ;; - microvm) - create_ssm_fixture \ - "/ministack/microvm/github-app-key" \ - "test-only" - create_ssm_fixture \ - "/ministack/microvm/github-app-id" \ - "123456" - create_ssm_fixture \ - "/ministack/microvm/webhook-secret" \ - "test-only" - create_s3_fixture \ - "github-actions-runner-microvm-ministack" \ - "runners.zip" \ - "$lambda_fixture_dir/ministack-lambda.zip" - create_s3_fixture \ - "github-actions-runner-microvm-ministack" \ - "webhook.zip" \ - "$lambda_fixture_dir/ministack-lambda.zip" + multi-runner-webhook) + create_ami_fixture "ministack-webhook-linux-x64" x86_64 >/dev/null ;; esac } diff --git a/tests/ministack/run-microvm-smoke.sh b/tests/ministack/run-microvm-smoke.sh deleted file mode 100644 index 6484ad20a0..0000000000 --- a/tests/ministack/run-microvm-smoke.sh +++ /dev/null @@ -1,729 +0,0 @@ -#!/bin/sh - -set -eu - -export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-000000000000}" -export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-test-only}" -export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-eu-west-1}" -export AWS_REGION="${AWS_REGION:-eu-west-1}" -export AWS_ENDPOINT_URL="${AWS_ENDPOINT_URL:-http://127.0.0.1:4566}" -export AWS_EC2_METADATA_DISABLED="${AWS_EC2_METADATA_DISABLED:-true}" - -script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) -source_root=$(CDPATH='' cd -- "$script_dir/../.." && pwd) -example_root="$source_root/examples/microvm" -mock_expectations="$script_dir/github-api-expectations.json" -fixture=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-workflow-job.XXXXXX") -mock_host="${MINISTACK_GITHUB_MOCK_HOST:-host.docker.internal}" -mock_port="${MINISTACK_GITHUB_MOCK_PORT:-}" -mock_service_url="${MINISTACK_GITHUB_MOCK_URL:-}" -mock_image="${MINISTACK_GITHUB_MOCK_IMAGE:-mockserver/mockserver:7.6.0@sha256:80b3b1a26f3553d0c81a3f3896b5b7274c17b2a2e52f0fd2b28e246bc9efa290}" -mock_container="" -tfvars_source="$script_dir/microvm.tfvars" -tfvars_file=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-smoke.XXXXXX") -response_file=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-smoke-response.XXXXXX") -lambda_response_file=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-microvm-lambda-response.XXXXXX") -terraform_initialized=false -before_microvm_ids="" -discovered_microvm_ids="" -override_file="$example_root/zz_ministack_microvm_smoke_override.tf" -metadata_ssm_path="/github-action-runners/microvm-ministack/microvm/runners/config/microvm-metadata" -runner_token_ssm_path="/github-action-runners/microvm-ministack/microvm/runners/tokens" - -cleanup() { - set +e - for microvm_id in $discovered_microvm_ids; do - aws --endpoint-url "$AWS_ENDPOINT_URL" lambda-microvms terminate-microvm \ - --microvm-identifier "$microvm_id" >/dev/null 2>&1 - done - if [ "$terraform_initialized" = true ]; then - "$source_root/tests/ministack/run-example.sh" destroy microvm "$tfvars_file" >/dev/null 2>&1 - fi - if [ -n "$mock_container" ]; then - docker rm -f "$mock_container" >/dev/null 2>&1 - fi - rm -f "$fixture" "$response_file" "$lambda_response_file" "$override_file" "$tfvars_file" -} -trap cleanup EXIT INT TERM - -require_command() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "$1 is required to run the MiniStack MicroVM smoke test." >&2 - exit 69 - fi -} - -for command in aws curl grep openssl python3 terraform; do - require_command "$command" -done -if [ -z "$mock_service_url" ]; then - require_command docker -fi - -for lambda_zip in \ - "$source_root/lambdas/functions/webhook/webhook.zip" \ - "$source_root/lambdas/functions/control-plane/runners.zip"; do - if [ ! -f "$lambda_zip" ]; then - echo "Missing $lambda_zip. Build the webhook and control-plane distributions first." >&2 - exit 66 - fi -done - -if [ -z "$mock_port" ]; then - if [ -n "$mock_service_url" ]; then - mock_port=1080 - else - mock_port=$(python3 -c 'import socket; s = socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()') - fi -fi - -if [ -z "$mock_service_url" ]; then - mock_container="terraform-aws-github-runner-microvm-github-api-mock-$$" - mock_service_url="http://127.0.0.1:${mock_port}" - docker run --detach --name "$mock_container" --publish "${mock_port}:1080" \ - --volume "$mock_expectations:/config/github-api-expectations.json:ro" \ - --env MOCKSERVER_INITIALIZATION_JSON_PATH=/config/github-api-expectations.json \ - "$mock_image" >/dev/null -fi - -attempts=30 -while ! curl -fsS --max-time 2 -X PUT "${mock_service_url}/mockserver/status" >/dev/null 2>&1; do - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "MockServer did not become ready." >&2 - if [ -n "$mock_container" ]; then - docker logs "$mock_container" >&2 - fi - exit 70 - fi - sleep 1 -done - -if [ -z "$mock_container" ]; then - MOCKSERVER_URL="$mock_service_url" python3 - "$mock_expectations" <<'PY' -import json -import os -import sys -import urllib.request - -with open(sys.argv[1], encoding="utf-8") as expectations_file: - expectations = json.load(expectations_file) - -for expectation in expectations: - request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}/mockserver/expectation', - data=json.dumps(expectation).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="PUT", - ) - with urllib.request.urlopen(request, timeout=10) as response: - if response.status not in (200, 201): - raise RuntimeError(f"MockServer expectation rejected with HTTP {response.status}") -PY -fi - -MOCKSERVER_URL="$mock_service_url" python3 - <<'PY' -import json -import os -import urllib.request - -path = "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig" -expectation = { - "httpRequest": {"method": "POST", "path": path}, - "httpResponse": { - "statusCode": 200, - "headers": {"Content-Type": ["application/json"]}, - "body": json.dumps( - { - "runner": { - "id": 987654321, - "labels": [ - {"name": "self-hosted"}, - {"name": "linux"}, - {"name": "arm64"}, - {"name": "microvm"}, - ], - }, - "encoded_jit_config": "ministack-microvm-jit-config", - } - ), - }, -} -request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}/mockserver/expectation', - data=json.dumps(expectation).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="PUT", -) -with urllib.request.urlopen(request, timeout=10) as response: - if response.status not in (200, 201): - raise RuntimeError(f"MockServer JIT expectation rejected with HTTP {response.status}") -PY - -MOCKSERVER_URL="$mock_service_url" python3 - <<'PY' -import json -import os -import urllib.request - -path = "/api/v3/orgs/test-owner/actions/runner-groups" -expectation = { - "httpRequest": {"method": "GET", "path": path}, - "httpResponse": { - "statusCode": 200, - "headers": {"Content-Type": ["application/json"]}, - "body": '[{"id":1,"name":"Default"}]', - }, -} -request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}/mockserver/expectation', - data=json.dumps(expectation).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="PUT", -) -with urllib.request.urlopen(request, timeout=10) as response: - if response.status not in (200, 201): - raise RuntimeError(f"MockServer runner-group expectation rejected with HTTP {response.status}") -PY - -python3 - "$script_dir/workflow_job_event.json" "$fixture" <<'PY' -import json -import sys - -source, destination = sys.argv[1:] -with open(source, encoding="utf-8") as source_file: - event = json.load(source_file) - -job = event["workflow_job"] -job["labels"] = ["self-hosted", "linux", "arm64", "microvm"] -job["name"] = "ministack-microvm-smoke" - -with open(destination, "w", encoding="utf-8") as destination_file: - json.dump(event, destination_file) -PY - -list_microvm_ids() { - aws --endpoint-url "$AWS_ENDPOINT_URL" lambda-microvms list-microvms \ - --query 'items[].microvmId' --output text 2>/dev/null || true -} - -id_in_list() { - case " $1 " in - *" $2 "*) return 0 ;; - esac - return 1 -} - -metadata_value() { - parameter_name="$1" - aws --endpoint-url "$AWS_ENDPOINT_URL" ssm get-parameter \ - --name "$parameter_name" --query 'Parameter.Value' --output text 2>/dev/null || true -} - -json_field() { - field="$1" - python3 -c 'import json, sys; print(json.load(sys.stdin).get(sys.argv[1], ""))' "$field" -} - -assert_metadata() { - microvm_id="$1" - source="$2" - metadata=$(metadata_value "$metadata_ssm_path/$microvm_id") - if [ -z "$metadata" ] || [ "$metadata" = "None" ]; then - echo "Missing MicroVM ownership metadata for $microvm_id." >&2 - exit 1 - fi - - actual_id=$(printf '%s' "$metadata" | json_field microvmId) - actual_environment=$(printf '%s' "$metadata" | json_field environment) - actual_owner=$(printf '%s' "$metadata" | json_field runnerOwner) - actual_type=$(printf '%s' "$metadata" | json_field runnerType) - actual_source=$(printf '%s' "$metadata" | json_field source) - if [ "$actual_id" != "$microvm_id" ] || [ "$actual_environment" != "microvm-ministack" ] \ - || [ "$actual_owner" != "test-owner" ] || [ "$actual_type" != "Org" ] || [ "$actual_source" != "$source" ]; then - echo "Unexpected MicroVM ownership metadata for $microvm_id: $metadata" >&2 - exit 1 - fi - - tags=$(metadata_value "$metadata_ssm_path/$microvm_id.tags") - if [ -z "$tags" ] || [ "$tags" = "None" ]; then - echo "Missing MicroVM runner tag metadata for $microvm_id." >&2 - exit 1 - fi - TAGS="$tags" EXPECTED_SOURCE="$source" python3 - <<'PY' -import json -import os - -tags = json.loads(os.environ["TAGS"]) -required = { - "ghr:Application": "github-action-runner", - "ghr:created_by": os.environ["EXPECTED_SOURCE"], - "ghr:environment": "microvm-ministack", - "ghr:Owner": "test-owner", - "ghr:Type": "Org", -} -missing = [key for key, value in required.items() if tags.get(key) != value] -if missing: - raise SystemExit(f"Missing or incorrect MicroVM runner tags: {missing}; got {tags}") -if not tags.get("ghr:microvm_id") or not tags.get("ghr:github_runner_id"): - raise SystemExit(f"MicroVM runner tags do not contain runtime ownership fields: {tags}") -PY -} - -assert_parameter_absent() { - parameter_name="$1" - description="$2" - if aws --endpoint-url "$AWS_ENDPOINT_URL" ssm get-parameter \ - --name "$parameter_name" >/dev/null 2>&1; then - echo "Expected SSM parameter to be deleted: $parameter_name" >&2 - exit 1 - fi - printf ' [PASS] %s (%s is absent)\n' "$description" "$parameter_name" -} - -wait_for_microvm() { - expected_source="$1" - description="$2" - attempts=60 - while :; do - for microvm_id in $(list_microvm_ids); do - if [ "$microvm_id" = "None" ] || id_in_list "$before_microvm_ids" "$microvm_id"; then - continue - fi - metadata=$(metadata_value "$metadata_ssm_path/$microvm_id") - if [ -z "$metadata" ] || [ "$metadata" = "None" ]; then - continue - fi - actual_source=$(printf '%s' "$metadata" | json_field source) - if [ "$actual_source" != "$expected_source" ]; then - continue - fi - discovered_microvm_ids="$discovered_microvm_ids $microvm_id" - printf ' [PASS] MiniStack Lambda MicroVM API reports %s: %s\n' "$description" "$microvm_id" - return - done - - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "Timed out waiting for $description in the MiniStack Lambda MicroVM API." >&2 - aws --endpoint-url "$AWS_ENDPOINT_URL" lambda-microvms list-microvms --output json >&2 || true - exit 1 - fi - sleep 2 - done -} - -assert_microvm_running() { - microvm_id="$1" - details=$(aws --endpoint-url "$AWS_ENDPOINT_URL" lambda-microvms get-microvm \ - --microvm-identifier "$microvm_id" --output json) - state=$(printf '%s' "$details" | json_field state) - image_arn=$(printf '%s' "$details" | json_field imageArn) - case "$state" in - PENDING | RUNNING | SUSPENDING | SUSPENDED) ;; - *) - echo "MicroVM $microvm_id did not reach an active state: $details" >&2 - exit 1 - ;; - esac - if [ "$image_arn" != "arn:aws:lambda:eu-west-1:000000000000:microvm-image:ministack" ]; then - echo "MicroVM $microvm_id used an unexpected image: $details" >&2 - exit 1 - fi - printf ' [PASS] MicroVM %s is active with the configured MicroVM image (%s)\n' "$microvm_id" "$state" -} - -webhook_endpoint="" -send_webhook() { - fixture_file="$1" - delivery_id="$2" - signature=$(openssl dgst -sha256 -hmac "$webhook_secret" "$fixture_file" | awk '{print $NF}') - status_code=$(curl -sS --max-time 15 -o "$response_file" -w '%{http_code}' \ - --connect-to "${api_host}:4566:127.0.0.1:${endpoint_port}" \ - -X POST "$webhook_endpoint" \ - -H 'Content-Type: application/json' \ - -H 'X-GitHub-Event: workflow_job' \ - -H "X-GitHub-Delivery: ${delivery_id}" \ - -H 'X-GitHub-Hook-Installation-Target-ID: 123' \ - -H "X-Hub-Signature-256: sha256=${signature}" \ - --data-binary "@${fixture_file}") - - if [ "$status_code" != 201 ]; then - echo "Webhook smoke request failed with HTTP $status_code." >&2 - sed -n '1,80p' "$response_file" >&2 - exit 1 - fi - echo " [PASS] API Gateway accepted the signed MicroVM workflow_job webhook ${delivery_id} (HTTP 201)" -} - -wait_for_log_event() { - log_group="$1" - marker="$2" - description="$3" - attempts=60 - while ! aws --endpoint-url "$AWS_ENDPOINT_URL" logs filter-log-events \ - --log-group-name "$log_group" --filter-pattern "$marker" --limit 1 --output text 2>/dev/null | grep -Fq "$marker"; do - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "Timed out waiting for MiniStack log marker '$marker' in $log_group." >&2 - exit 1 - fi - sleep 2 - done - printf ' [PASS] %s (log group %s contains %s)\n' "$description" "$log_group" "$marker" -} - -wait_for_mock_route() { - method="$1" - route="$2" - description="$3" - verification_body=$(printf '{"httpRequest":{"method":"%s","path":"%s"},"times":{"atLeast":1}}' "$method" "$route") - attempts=60 - while ! curl -fsS --max-time 5 -X PUT "${mock_service_url}/mockserver/verify" \ - -H 'Content-Type: application/json' \ - --data-binary "$verification_body" >/dev/null 2>&1; do - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "Timed out waiting for MockServer route: $method $route" >&2 - curl -sS --max-time 5 -X PUT \ - "${mock_service_url}/mockserver/retrieve?type=REQUEST_RESPONSES&format=JSON" >&2 || true - exit 1 - fi - sleep 2 - done - printf ' [PASS] %s (MockServer verified %s %s)\n' "$description" "$method" "$route" -} - -clear_mock_request_log() { - if ! curl -fsS --max-time 5 -X PUT \ - "${mock_service_url}/mockserver/clear?type=log" >/dev/null 2>&1; then - echo "Failed to clear MockServer request history before the next lifecycle phase." >&2 - exit 1 - fi -} - -assert_scale_up_github_routes() { - wait_for_mock_route POST "/api/v3/app/installations/123/access_tokens" \ - "Scale-up requested a GitHub App installation token" - wait_for_mock_route GET "/api/v3/repos/test-owner/test-repo/actions/jobs/123456" \ - "Scale-up checked the queued GitHub job 123456" - wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runner-groups" \ - "Scale-up resolved the Default GitHub runner group" - wait_for_mock_route POST "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig" \ - "Scale-up generated the MicroVM runner JIT configuration" -} - -assert_pool_github_routes() { - wait_for_mock_route GET "/api/v3/orgs/test-owner/installation" \ - "Pool looked up the GitHub App installation" - wait_for_mock_route POST "/api/v3/app/installations/123/access_tokens" \ - "Pool requested a GitHub App installation token" - wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runners" \ - "Pool listed organization runners" - wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runner-groups" \ - "Pool resolved the Default GitHub runner group" - wait_for_mock_route POST "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig" \ - "Pool generated the MicroVM runner JIT configuration" -} - -assert_scale_down_github_routes() { - runner_id="$1" - wait_for_mock_route POST "/api/v3/app/installations/123/access_tokens" \ - "Scale-down requested a GitHub App installation token" - wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runners" \ - "Scale-down listed organization runners" - wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runners/${runner_id}" \ - "Scale-down checked the runner busy state" - wait_for_mock_route DELETE "/api/v3/orgs/test-owner/actions/runners/${runner_id}" \ - "Scale-down deleted the runner from GitHub" -} - -configure_mock_runner_state() { - microvm_id="$1" - runner_id="$2" - MOCKSERVER_URL="$mock_service_url" python3 - "$microvm_id" "$runner_id" <<'PY' -import json -import os -import sys -import urllib.request - -microvm_id, runner_id = sys.argv[1:] -runner_id = int(runner_id) -base = "/api/v3/orgs/test-owner/actions/runners" - -def control(path, method, payload): - request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}{path}', - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method=method, - ) - with urllib.request.urlopen(request, timeout=10) as response: - if response.status not in (200, 201, 202): - raise RuntimeError(f'MockServer API rejected {method} {path} with HTTP {response.status}') - -def clear(method, path): - control("/mockserver/clear", "PUT", {"httpRequest": {"method": method, "path": path}}) - -def expect(method, path, status, body=None): - response = {"statusCode": status} - if body is not None: - response["headers"] = {"Content-Type": ["application/json"]} - response["body"] = json.dumps(body) - control( - "/mockserver/expectation", - "PUT", - {"httpRequest": {"method": method, "path": path}, "httpResponse": response}, - ) - -state_path = f"{base}/{runner_id}" -clear("GET", base) -clear("GET", state_path) -clear("DELETE", state_path) -expect( - "GET", - base, - 200, - { - "total_count": 1, - "runners": [ - { - "id": runner_id, - "name": f"microvm-{microvm_id}", - "os": "linux", - "status": "offline", - "busy": False, - "labels": [], - } - ], - }, -) -expect( - "GET", - state_path, - 200, - { - "id": runner_id, - "name": f"microvm-{microvm_id}", - "os": "linux", - "status": "offline", - "busy": False, - "labels": [], - }, -) -expect("DELETE", state_path, 204) -PY -} - -configure_mock_runner_removed() { - runner_id="$1" - MOCKSERVER_URL="$mock_service_url" python3 - "$runner_id" <<'PY' -import json -import os -import sys -import urllib.request - -runner_id = sys.argv[1] -path = f"/api/v3/orgs/test-owner/actions/runners/{runner_id}" - -def control(path, method, payload): - request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}{path}', - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method=method, - ) - with urllib.request.urlopen(request, timeout=10) as response: - if response.status not in (200, 201, 202): - raise RuntimeError(f'MockServer API rejected {method} {path} with HTTP {response.status}') - -control("/mockserver/clear", "PUT", {"httpRequest": {"method": "GET", "path": path}}) -control( - "/mockserver/expectation", - "PUT", - { - "httpRequest": {"method": "GET", "path": path}, - "httpResponse": { - "statusCode": 404, - "headers": {"Content-Type": ["application/json"]}, - "body": '{"message":"Not Found"}', - }, - }, -) -PY -} - -configure_empty_mock_runner_list() { - MOCKSERVER_URL="$mock_service_url" python3 - <<'PY' -import json -import os -import urllib.request - -path = "/api/v3/orgs/test-owner/actions/runners" -clear_request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}/mockserver/clear', - data=json.dumps({"httpRequest": {"method": "GET", "path": path}}).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="PUT", -) -with urllib.request.urlopen(clear_request, timeout=10) as response: - if response.status not in (200, 201): - raise RuntimeError(f"MockServer runner-list expectation clear rejected with HTTP {response.status}") - -request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}/mockserver/expectation', - data=json.dumps( - { - "httpRequest": {"method": "GET", "path": path}, - "httpResponse": { - "statusCode": 200, - "headers": {"Content-Type": ["application/json"]}, - "body": '{"total_count":0,"runners":[]}', - }, - } - ).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="PUT", -) -with urllib.request.urlopen(request, timeout=10) as response: - if response.status not in (200, 201): - raise RuntimeError(f"MockServer API rejected pool runner expectation with HTTP {response.status}") -PY -} - -assert_mock_runner_removed() { - runner_id="$1" - status_code=$(curl -sS --max-time 5 -o "$response_file" -w '%{http_code}' \ - "${mock_service_url}/api/v3/orgs/test-owner/actions/runners/${runner_id}") - if [ "$status_code" != 404 ]; then - echo "Expected GitHub API mock to return 404 for removed runner $runner_id, got HTTP $status_code." >&2 - sed -n '1,80p' "$response_file" >&2 - exit 1 - fi - printf ' [PASS] GitHub API mock reports runner %s removed (HTTP 404)\n' "$runner_id" -} - -wait_for_microvm_termination() { - microvm_id="$1" - description="$2" - attempts=60 - while :; do - if details=$(aws --endpoint-url "$AWS_ENDPOINT_URL" lambda-microvms get-microvm \ - --microvm-identifier "$microvm_id" --output json 2>/dev/null); then - state=$(printf '%s' "$details" | json_field state) - if [ "$state" = "TERMINATED" ]; then - printf ' [PASS] MiniStack Lambda MicroVM API reports %s terminated\n' "$description" - return - fi - else - state="not found" - fi - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "Timed out waiting for $description to terminate; current state: $state." >&2 - exit 1 - fi - sleep 2 - done -} - -invoke_lambda() { - function_name="$1" - payload="$2" - description="$3" - invocation_result=$(aws --endpoint-url "$AWS_ENDPOINT_URL" lambda invoke \ - --cli-binary-format raw-in-base64-out \ - --invocation-type RequestResponse \ - --function-name "$function_name" \ - --payload "$payload" \ - "$lambda_response_file" --output json) - if printf '%s' "$invocation_result" | grep -Fq '"FunctionError"'; then - echo "Lambda invocation returned FunctionError for $function_name." >&2 - exit 1 - fi - printf ' [PASS] %s (Lambda API accepted the request)\n' "$description" -} - -before_microvm_ids=$(list_microvm_ids) -terraform_initialized=true -cp "$tfvars_source" "$tfvars_file" -printf '\norganization_runners = true\n' >> "$tfvars_file" -printf '%s\n' \ - 'module "runners" {' \ - ' global_config_github = {' \ - ' app = var.github_app' \ - ' enterprise_server = {' \ - " url = \"http://${mock_host}:${mock_port}\"" \ - ' ssl_verify = false' \ - ' }' \ - ' }' \ - '}' > "$override_file" -"$source_root/tests/ministack/run-example.sh" apply microvm "$tfvars_file" - -webhook_endpoint=$(terraform -chdir="$example_root" output -raw webhook_endpoint) -endpoint_host_port=${AWS_ENDPOINT_URL#*://} -endpoint_port=${endpoint_host_port##*:} -api_host_port=${webhook_endpoint#*://} -api_host_port=${api_host_port%%/*} -api_host=${api_host_port%:*} -webhook_secret=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ssm get-parameter \ - --name /ministack/microvm/webhook-secret --query 'Parameter.Value' --output text) - -send_webhook "$fixture" "ministack-microvm-smoke-123456" -wait_for_log_event "/aws/lambda/microvm-ministack-microvm-webhook" "123456" \ - "Webhook Lambda received MicroVM workflow job 123456" -wait_for_log_event "/aws/lambda/microvm-ministack-microvm-dispatch-to-runner" "123456" \ - "EventBridge invoked the MicroVM dispatcher Lambda" -wait_for_log_event "/aws/lambda/microvm-ministack-microvm-scale-up" "123456" \ - "Dispatcher delivered workflow job 123456 through SQS to the MicroVM scale-up Lambda" -assert_scale_up_github_routes -wait_for_microvm "scale-up-lambda" "a scale-up MicroVM" -scale_up_microvm_id=$(printf '%s' "$discovered_microvm_ids" | awk '{print $1}') -assert_microvm_running "$scale_up_microvm_id" -EXPECTED_SOURCE="scale-up-lambda" assert_metadata "$scale_up_microvm_id" "scale-up-lambda" - -scale_up_runner_id=987654321 -configure_mock_runner_state "$scale_up_microvm_id" "$scale_up_runner_id" -clear_mock_request_log -invoke_lambda "microvm-ministack-microvm-scale-down" \ - '{"smokeMarker":"ministack-microvm-scale-up-scale-down","type":"microvm"}' \ - "MicroVM scale-down Lambda invoked for the webhook runner" -wait_for_log_event "/aws/lambda/microvm-ministack-microvm-scale-down" "ministack-microvm-scale-up-scale-down" \ - "MicroVM scale-down Lambda started processing the webhook runner" -assert_scale_down_github_routes "$scale_up_runner_id" -configure_mock_runner_removed "$scale_up_runner_id" -assert_mock_runner_removed "$scale_up_runner_id" -wait_for_microvm_termination "$scale_up_microvm_id" "the webhook MicroVM" -assert_parameter_absent "$runner_token_ssm_path/$scale_up_microvm_id" \ - "the webhook MicroVM JIT configuration was cleaned up" - -echo "MiniStack MicroVM smoke chain 1 passed: API Gateway -> webhook -> EventBridge -> dispatcher -> SQS -> RunMicrovm -> SSM ownership metadata -> GitHub API mock -> TerminateMicrovm." - -configure_empty_mock_runner_list -clear_mock_request_log -invoke_lambda "microvm-ministack-microvm-pool" '{"poolSize":1,"type":"microvm"}' \ - "MicroVM pool Lambda invoked to maintain one runner" -assert_pool_github_routes -wait_for_log_event "/aws/lambda/microvm-ministack-microvm-pool" "topped up with 1 runners" \ - "MicroVM pool Lambda requested one runner" -wait_for_microvm "pool-lambda" "a pool MicroVM" -pool_microvm_id=$(printf '%s' "$discovered_microvm_ids" | awk '{print $2}') -assert_microvm_running "$pool_microvm_id" -EXPECTED_SOURCE="pool-lambda" assert_metadata "$pool_microvm_id" "pool-lambda" - -pool_runner_id=987654321 -configure_mock_runner_state "$pool_microvm_id" "$pool_runner_id" -clear_mock_request_log -invoke_lambda "microvm-ministack-microvm-scale-down" \ - '{"smokeMarker":"ministack-microvm-pool-scale-down","type":"microvm"}' \ - "MicroVM scale-down Lambda invoked for the pool runner" -wait_for_log_event "/aws/lambda/microvm-ministack-microvm-scale-down" "ministack-microvm-pool-scale-down" \ - "MicroVM scale-down Lambda started processing the pool runner" -assert_scale_down_github_routes "$pool_runner_id" -configure_mock_runner_removed "$pool_runner_id" -assert_mock_runner_removed "$pool_runner_id" -wait_for_microvm_termination "$pool_microvm_id" "the pool MicroVM" -assert_parameter_absent "$runner_token_ssm_path/$pool_microvm_id" \ - "the pool MicroVM JIT configuration was cleaned up" - -echo "MiniStack MicroVM smoke chain 2 passed: MicroVM pool -> GitHub API mock -> RunMicrovm -> scale-down -> GitHub API mock -> TerminateMicrovm." -echo "MiniStack MicroVM smoke tests passed: webhook and pool lifecycle chains completed." diff --git a/tests/ministack/run-smoke.sh b/tests/ministack/run-smoke.sh deleted file mode 100644 index d4d9c7ab3e..0000000000 --- a/tests/ministack/run-smoke.sh +++ /dev/null @@ -1,721 +0,0 @@ -#!/bin/sh - -set -eu - -export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-000000000000}" -export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-test-only}" -export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-eu-west-1}" -export AWS_REGION="${AWS_REGION:-eu-west-1}" -export AWS_ENDPOINT_URL="${AWS_ENDPOINT_URL:-http://127.0.0.1:4566}" -export AWS_EC2_METADATA_DISABLED="${AWS_EC2_METADATA_DISABLED:-true}" - -script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) -source_root=$(CDPATH='' cd -- "$script_dir/../.." && pwd) -example_root="$source_root/examples/default" -mock_expectations="$script_dir/github-api-expectations.json" -fixture="$script_dir/workflow_job_event.json" -dynamic_fixture=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-dynamic-workflow-job.XXXXXX") -mock_host="${MINISTACK_GITHUB_MOCK_HOST:-host.docker.internal}" -mock_port="${MINISTACK_GITHUB_MOCK_PORT:-}" -mock_service_url="${MINISTACK_GITHUB_MOCK_URL:-}" -mock_image="${MINISTACK_GITHUB_MOCK_IMAGE:-mockserver/mockserver:7.6.0@sha256:80b3b1a26f3553d0c81a3f3896b5b7274c17b2a2e52f0fd2b28e246bc9efa290}" -mock_container="" -tfvars_file=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-smoke.XXXXXX") -app_key_file=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-github-app.XXXXXX") -response_file=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-smoke-response.XXXXXX") -lambda_response_file=$(mktemp "${TMPDIR:-/tmp}/terraform-aws-github-runner-lambda-response.XXXXXX") -override_file="$example_root/zz_ministack_smoke_override.tf" -terraform_initialized=false -discovered_instance_ids="" - -cleanup() { - set +e - for instance_id in $discovered_instance_ids; do - aws --endpoint-url "$AWS_ENDPOINT_URL" ec2 terminate-instances \ - --instance-ids "$instance_id" >/dev/null 2>&1 - done - if [ "$terraform_initialized" = true ]; then - "$source_root/tests/ministack/run-example.sh" destroy default "$tfvars_file" >/dev/null 2>&1 - fi - if [ -n "$mock_container" ]; then - docker rm -f "$mock_container" >/dev/null 2>&1 - fi - rm -f "$override_file" "$tfvars_file" "$app_key_file" "$response_file" "$lambda_response_file" "$dynamic_fixture" -} -trap cleanup EXIT INT TERM - -require_command() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "$1 is required to run the MiniStack smoke test." >&2 - exit 69 - fi -} - -for command in aws curl openssl python3 terraform; do - require_command "$command" -done -if [ -z "$mock_service_url" ]; then - require_command docker -fi - -for lambda_zip in \ - "$source_root/lambdas/functions/webhook/webhook.zip" \ - "$source_root/lambdas/functions/control-plane/runners.zip"; do - if [ ! -f "$lambda_zip" ]; then - echo "Missing $lambda_zip. Build the webhook and control-plane distributions first." >&2 - exit 66 - fi -done - -if [ -z "$mock_port" ]; then - if [ -n "$mock_service_url" ]; then - mock_port=1080 - else - mock_port=$(python3 -c 'import socket; s = socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()') - fi -fi - -if [ -z "$mock_service_url" ]; then - mock_container="terraform-aws-github-runner-github-api-mock-$$" - mock_service_url="http://127.0.0.1:${mock_port}" -fi - -openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$app_key_file" 2>/dev/null -app_key_base64=$(base64 < "$app_key_file" | tr -d '\n') -APP_KEY_BASE64="$app_key_base64" python3 - "$script_dir/default.tfvars" "$tfvars_file" <<'PY' -import os -import sys - -source, destination = sys.argv[1:] -replacement = os.environ["APP_KEY_BASE64"] -with open(source, encoding="utf-8") as source_file: - lines = source_file.readlines() -with open(destination, "w", encoding="utf-8") as destination_file: - for line in lines: - if line.lstrip().startswith("key_base64 ="): - destination_file.write(f' key_base64 = "{replacement}"\n') - elif line.lstrip().startswith('id') and '=' in line: - destination_file.write(' id = "123"\n') - else: - destination_file.write(line) -PY -unset app_key_base64 APP_KEY_BASE64 - -printf '%s\n' \ - 'module "runners" {' \ - " ghes_url = \"http://${mock_host}:${mock_port}\"" \ - ' ghes_ssl_verify = false' \ - ' eventbridge = {' \ - ' enable = true' \ - ' accept_events = ["workflow_job"]' \ - ' }' \ - ' delay_webhook_event = 0' \ - ' runners_maximum_count = 1' \ - ' instance_types = ["m7a.large"]' \ - ' enable_dynamic_labels = true' \ - ' minimum_running_time_in_minutes = 0' \ - ' pool_runner_owner = "test-owner"' \ - ' pool_config = [{ schedule_expression = "cron(0 0 1 1 ? 2099)", size = 1 }]' \ - ' scale_down_schedule_expression = "cron(0 0 1 1 ? 2099)"' \ - ' enable_job_queued_check = true' \ - ' enable_jit_config = false' \ - ' enable_runner_binaries_syncer = false' \ - ' log_level = "debug"' \ - '}' \ - '' \ - 'module "webhook_github_app" {' \ - ' count = 0' \ - '}' > "$override_file" - -if [ -n "$mock_container" ]; then - docker run --detach --name "$mock_container" --publish "${mock_port}:1080" \ - --volume "$mock_expectations:/config/github-api-expectations.json:ro" \ - --env MOCKSERVER_INITIALIZATION_JSON_PATH=/config/github-api-expectations.json \ - "$mock_image" >/dev/null -fi - -attempts=30 -while ! curl -fsS --max-time 2 -X PUT "${mock_service_url}/mockserver/status" >/dev/null 2>&1; do - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "MockServer did not become ready." >&2 - if [ -n "$mock_container" ]; then - docker logs "$mock_container" >&2 - fi - exit 70 - fi - sleep 1 -done - -if [ -z "$mock_container" ]; then - MOCKSERVER_URL="$mock_service_url" python3 - "$mock_expectations" <<'PY' -import json -import os -import sys -import urllib.request - -with open(sys.argv[1], encoding="utf-8") as expectations_file: - expectations = json.load(expectations_file) - -for expectation in expectations: - request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}/mockserver/expectation', - data=json.dumps(expectation).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="PUT", - ) - with urllib.request.urlopen(request, timeout=10) as response: - if response.status not in (200, 201): - raise RuntimeError(f"MockServer expectation rejected with HTTP {response.status}") -PY -fi - -python3 - "$fixture" "$dynamic_fixture" <<'PY' -import json -import sys - -source, destination = sys.argv[1:] -with open(source, encoding="utf-8") as source_file: - event = json.load(source_file) - -job = event["workflow_job"] -job["id"] = 123457 -job["run_id"] = 654322 -job["run_url"] = job["run_url"].replace("654321", "654322") -job["url"] = job["url"].replace("123456", "123457") -job["html_url"] = job["html_url"].replace("123456", "123457") -job["name"] = "ministack-smoke-dynamic" -job["labels"].append("ghr-ec2-instance-type:m5.large") - -with open(destination, "w", encoding="utf-8") as destination_file: - json.dump(event, destination_file) -PY - -terraform_initialized=true -"$source_root/tests/ministack/run-example.sh" apply default "$tfvars_file" - -printf '%s\n' \ - 'MiniStack smoke chain evidence checklist:' \ - ' [ ] API Gateway accepted the signed workflow_job webhook (HTTP 201)' \ - ' [ ] Webhook Lambda log contains workflow job 123456' \ - ' [ ] EventBridge invoked the dispatcher Lambda (dispatcher log contains 123456)' \ - ' [ ] Dispatcher delivered the job through SQS (scale-up log contains 123456)' \ - ' [ ] Scale-up without a dynamic label called each expected GitHub API route in MockServer' \ - ' [ ] MiniStack EC2 API reports a standard scale-up instance with default EC2 configuration' \ - ' [ ] Scale-up with ghr-ec2-instance-type:m5.large called each expected GitHub API route in MockServer' \ - ' [ ] Dynamic label selected EC2 instance type m5.large' \ - ' [ ] Scale-up EC2 instance has the expected runner discovery tags' \ - ' [ ] Pool called every expected GitHub API route in MockServer' \ - ' [ ] Pool Lambda created a runner instance' \ - ' [ ] Pool EC2 instance has the expected runner discovery tags' \ - ' [ ] Scale-down removed each runner from GitHub and terminated its EC2 instance' - -webhook_endpoint=$(terraform -chdir="$example_root" output -raw webhook_endpoint) -endpoint_host_port=${AWS_ENDPOINT_URL#*://} -endpoint_port=${endpoint_host_port##*:} -api_host_port=${webhook_endpoint#*://} -api_host_port=${api_host_port%%/*} -api_host=${api_host_port%:*} -webhook_secret=$(terraform -chdir="$example_root" output -raw webhook_secret) - -send_webhook() { - fixture_file="$1" - delivery_id="$2" - signature=$(openssl dgst -sha256 -hmac "$webhook_secret" "$fixture_file" | awk '{print $NF}') - status_code=$(curl -sS --max-time 15 -o "$response_file" -w '%{http_code}' \ - --connect-to "${api_host}:4566:127.0.0.1:${endpoint_port}" \ - -X POST "$webhook_endpoint" \ - -H 'Content-Type: application/json' \ - -H 'X-GitHub-Event: workflow_job' \ - -H "X-GitHub-Delivery: ${delivery_id}" \ - -H 'X-GitHub-Hook-Installation-Target-ID: 123' \ - -H "X-Hub-Signature-256: sha256=${signature}" \ - --data-binary "@${fixture_file}") - - if [ "$status_code" != 201 ]; then - echo "Webhook smoke request failed with HTTP $status_code." >&2 - sed -n '1,80p' "$response_file" >&2 - exit 1 - fi - echo " [PASS] API Gateway accepted the signed workflow_job webhook ${delivery_id} (HTTP 201)" -} - -send_webhook "$fixture" "ministack-smoke-123456" - -wait_for_log_event() { - log_group="$1" - marker="$2" - description="$3" - attempts=60 - while ! aws --endpoint-url "$AWS_ENDPOINT_URL" logs filter-log-events \ - --log-group-name "$log_group" --filter-pattern "$marker" --limit 1 --output text 2>/dev/null | grep -Fq "$marker"; do - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "Timed out waiting for MiniStack log marker '$marker' in $log_group." >&2 - exit 1 - fi - sleep 2 - done - printf ' [PASS] %s (log group %s contains %s)\n' "$description" "$log_group" "$marker" -} - -wait_for_optional_log_event() { - log_group="$1" - marker="$2" - description="$3" - attempts=60 - while ! aws --endpoint-url "$AWS_ENDPOINT_URL" logs filter-log-events \ - --log-group-name "$log_group" --filter-pattern "$marker" --limit 1 --output text 2>/dev/null | grep -Fq "$marker"; do - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - printf ' [WARN] %s (log marker %s was not observed in %s)\n' \ - "$description" "$marker" "$log_group" - return 0 - fi - sleep 2 - done - printf ' [PASS] %s (log group %s contains %s)\n' "$description" "$log_group" "$marker" -} - -wait_for_log_event "/aws/lambda/ministack-default-webhook" "123456" \ - "Webhook Lambda received workflow job 123456" -wait_for_log_event "/aws/lambda/ministack-default-dispatch-to-runner" "123456" \ - "EventBridge invoked the dispatcher Lambda" -wait_for_log_event "/aws/lambda/ministack-default-scale-up" "123456" \ - "Dispatcher delivered workflow job 123456 through SQS to scale-up" - -wait_for_mock_route() { - method="$1" - route="$2" - description="$3" - verification_body=$(printf '{"httpRequest":{"method":"%s","path":"%s"},"times":{"atLeast":1}}' "$method" "$route") - attempts=60 - while ! curl -fsS --max-time 5 -X PUT "${mock_service_url}/mockserver/verify" \ - -H 'Content-Type: application/json' \ - --data-binary "$verification_body" >/dev/null 2>&1; do - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "Timed out waiting for MockServer route: $method $route" >&2 - curl -sS --max-time 5 -X PUT \ - "${mock_service_url}/mockserver/retrieve?type=REQUEST_RESPONSES&format=JSON" >&2 || true - exit 1 - fi - sleep 2 - done - printf ' [PASS] %s (MockServer verified %s %s)\n' "$description" "$method" "$route" -} - -clear_mock_request_log() { - if ! curl -fsS --max-time 5 -X PUT \ - "${mock_service_url}/mockserver/clear?type=log" >/dev/null 2>&1; then - echo "Failed to clear MockServer request history before the next lifecycle phase." >&2 - exit 1 - fi -} - -assert_scale_down_github_routes() { - wait_for_mock_route POST "/api/v3/app/installations/123/access_tokens" \ - "Scale-down requested a GitHub App installation token" - wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runners" \ - "Scale-down listed organization runners" - wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runners/${1}" \ - "Scale-down checked the runner busy state" - wait_for_mock_route DELETE "/api/v3/orgs/test-owner/actions/runners/${1}" \ - "Scale-down deleted the runner from GitHub" -} - -assert_pool_github_routes() { - wait_for_mock_route GET "/api/v3/orgs/test-owner/installation" \ - "Pool looked up the GitHub App installation" - wait_for_mock_route POST "/api/v3/app/installations/123/access_tokens" \ - "Pool requested a GitHub App installation token" - wait_for_mock_route GET "/api/v3/orgs/test-owner/actions/runners" \ - "Pool listed organization runners" - wait_for_mock_route POST "/api/v3/orgs/test-owner/actions/runners/registration-token" \ - "Pool requested a GitHub runner registration token" -} - -assert_scale_up_github_routes() { - job_id="$1" - wait_for_mock_route POST "/api/v3/app/installations/123/access_tokens" \ - "Scale-up requested a GitHub App installation token for job ${job_id}" - wait_for_mock_route GET "/api/v3/repos/test-owner/test-repo/actions/jobs/${job_id}" \ - "Scale-up checked the queued GitHub job ${job_id}" - wait_for_mock_route POST "/api/v3/orgs/test-owner/actions/runners/registration-token" \ - "Scale-up requested a GitHub runner registration token for job ${job_id}" -} - -assert_scale_up_github_routes 123456 - -wait_for_ec2_instance() { - source="$1" - description="$2" - attempts=60 - while :; do - found_instance_id=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ec2 describe-instances \ - --filters \ - "Name=instance-state-name,Values=running,pending" \ - "Name=tag:ghr:Application,Values=github-action-runner" \ - "Name=tag:ghr:created_by,Values=$source" \ - --query 'Reservations[].Instances[].InstanceId | [0]' \ - --output text 2>/dev/null || true) - if [ -n "$found_instance_id" ] && [ "$found_instance_id" != "None" ]; then - case " $discovered_instance_ids " in - *" $found_instance_id "*) ;; - *) discovered_instance_ids="$discovered_instance_ids $found_instance_id" ;; - esac - printf ' [PASS] MiniStack EC2 API reports %s: %s\n' "$description" "$found_instance_id" - return - fi - - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "Timed out waiting for $description in the MiniStack EC2 API." >&2 - aws --endpoint-url "$AWS_ENDPOINT_URL" ec2 describe-instances \ - --filters \ - "Name=instance-state-name,Values=running,pending" \ - "Name=tag:ghr:Application,Values=github-action-runner" \ - "Name=tag:ghr:created_by,Values=$source" \ - --output json >&2 || true - exit 1 - fi - sleep 2 - done -} - -wait_for_ec2_instance "scale-up-lambda" "a scale-up instance" -scale_up_instance_id="$found_instance_id" - -assert_ec2_tag() { - instance_id="$1" - key="$2" - expected_value="$3" - description="$4" - actual_value=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ec2 describe-instances \ - --instance-ids "$instance_id" \ - --query "Reservations[].Instances[].Tags[?Key=='${key}'].Value | [0]" \ - --output text 2>/dev/null || true) - if [ "$actual_value" != "$expected_value" ]; then - echo "Expected $description tag $key=$expected_value on $instance_id, got $actual_value." >&2 - exit 1 - fi -} - -assert_ec2_runner_tags() { - instance_id="$1" - source="$2" - description="$3" - assert_ec2_tag "$instance_id" "ghr:Application" "github-action-runner" "$description" - assert_ec2_tag "$instance_id" "ghr:created_by" "$source" "$description" - assert_ec2_tag "$instance_id" "ghr:Type" "Org" "$description" - assert_ec2_tag "$instance_id" "ghr:Owner" "test-owner" "$description" - printf ' [PASS] MiniStack EC2 API reports correct runner tags on %s\n' "$instance_id" -} - -assert_ec2_runner_tags "$scale_up_instance_id" "scale-up-lambda" "the scale-up runner" - -assert_ec2_default_instance_type() { - instance_id="$1" - actual_type=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ec2 describe-instances \ - --instance-ids "$instance_id" \ - --query 'Reservations[0].Instances[0].InstanceType' \ - --output text 2>/dev/null || true) - if [ "$actual_type" != "m7a.large" ]; then - echo "Expected standard scale-up to use the configured default m7a.large, got $actual_type." >&2 - exit 1 - fi - printf ' [PASS] Standard scale-up used the configured default EC2 instance type: %s\n' "$actual_type" -} - -assert_ec2_instance_type() { - instance_id="$1" - expected_type="$2" - actual_type=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ec2 describe-instances \ - --instance-ids "$instance_id" \ - --query 'Reservations[0].Instances[0].InstanceType' \ - --output text 2>/dev/null || true) - if [ "$actual_type" != "$expected_type" ]; then - echo "Expected $instance_id to use EC2 instance type $expected_type, got $actual_type." >&2 - exit 1 - fi - printf ' [PASS] EC2 dynamic label selected instance type %s on %s\n' "$expected_type" "$instance_id" -} - -assert_ec2_default_instance_type "$scale_up_instance_id" - -configure_mock_runner_state() { - instance_id="$1" - runner_id="$2" - MOCKSERVER_URL="$mock_service_url" python3 - "$instance_id" "$runner_id" <<'PY' -import json -import os -import sys -import urllib.request - -instance_id, runner_id = sys.argv[1:] -runner_id = int(runner_id) -base = "/api/v3/orgs/test-owner/actions/runners" - -def control(path, method, payload): - request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}{path}', - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method=method, - ) - with urllib.request.urlopen(request, timeout=10) as response: - if response.status not in (200, 201, 202): - raise RuntimeError(f'MockServer API rejected {method} {path} with HTTP {response.status}') - -def clear(method, path): - control("/mockserver/clear", "PUT", {"httpRequest": {"method": method, "path": path}}) - -def expect(method, path, status, body=None): - response = {"statusCode": status} - if body is not None: - response["headers"] = {"Content-Type": ["application/json"]} - response["body"] = json.dumps(body) - control( - "/mockserver/expectation", - "PUT", - {"httpRequest": {"method": method, "path": path}, "httpResponse": response}, - ) - -state_path = f"{base}/{runner_id}" -clear("GET", base) -clear("GET", state_path) -clear("DELETE", state_path) -expect( - "GET", - base, - 200, - { - "total_count": 1, - "runners": [ - { - "id": runner_id, - "name": f"ministack-smoke-{instance_id}", - "os": "linux", - "status": "offline", - "busy": False, - "labels": [], - } - ], - }, -) -expect( - "GET", - state_path, - 200, - { - "id": runner_id, - "name": f"ministack-smoke-{instance_id}", - "os": "linux", - "status": "offline", - "busy": False, - "labels": [], - }, -) -expect("DELETE", state_path, 204) -PY -} - -configure_mock_runner_removed() { - runner_id="$1" - MOCKSERVER_URL="$mock_service_url" python3 - "$runner_id" <<'PY' -import json -import os -import sys -import urllib.request - -runner_id = sys.argv[1] -path = f"/api/v3/orgs/test-owner/actions/runners/{runner_id}" - -def control(path, method, payload): - request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}{path}', - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method=method, - ) - with urllib.request.urlopen(request, timeout=10) as response: - if response.status not in (200, 201, 202): - raise RuntimeError(f'MockServer API rejected {method} {path} with HTTP {response.status}') - -control("/mockserver/clear", "PUT", {"httpRequest": {"method": "GET", "path": path}}) -control( - "/mockserver/expectation", - "PUT", - { - "httpRequest": {"method": "GET", "path": path}, - "httpResponse": { - "statusCode": 404, - "headers": {"Content-Type": ["application/json"]}, - "body": '{"message":"Not Found"}', - }, - }, -) -PY -} - -configure_empty_mock_runner_list() { - MOCKSERVER_URL="$mock_service_url" python3 - <<'PY' -import json -import os -import urllib.request - -path = "/api/v3/orgs/test-owner/actions/runners" - -def control(path, method, payload): - request = urllib.request.Request( - f'{os.environ["MOCKSERVER_URL"]}{path}', - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method=method, - ) - with urllib.request.urlopen(request, timeout=10) as response: - if response.status not in (200, 201, 202): - raise RuntimeError(f'MockServer API rejected {method} {path} with HTTP {response.status}') - -control("/mockserver/clear", "PUT", {"httpRequest": {"method": "GET", "path": path}}) -control( - "/mockserver/expectation", - "PUT", - { - "httpRequest": {"method": "GET", "path": path}, - "httpResponse": { - "statusCode": 200, - "headers": {"Content-Type": ["application/json"]}, - "body": '{"total_count":0,"runners":[]}', - }, - }, -) -PY -} - -assert_mock_runner_removed() { - runner_id="$1" - status_code=$(curl -sS --max-time 5 -o "$response_file" -w '%{http_code}' \ - "${mock_service_url}/api/v3/orgs/test-owner/actions/runners/${runner_id}") - if [ "$status_code" != 404 ]; then - echo "Expected GitHub API mock to return 404 for removed runner $runner_id, got HTTP $status_code." >&2 - sed -n '1,80p' "$response_file" >&2 - exit 1 - fi - printf ' [PASS] GitHub API mock reports runner %s removed (HTTP 404)\n' "$runner_id" -} - -wait_for_ec2_termination() { - instance_id="$1" - description="$2" - attempts=60 - while :; do - if state=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ec2 describe-instances \ - --instance-ids "$instance_id" \ - --query 'Reservations[].Instances[].State.Name | [0]' \ - --output text 2>/dev/null); then - if [ -z "$state" ] || [ "$state" = "None" ] || [ "$state" = "terminated" ]; then - printf ' [PASS] MiniStack EC2 API reports %s terminated\n' "$description" - return - fi - else - state="describe-instances failed" - fi - attempts=$((attempts - 1)) - if [ "$attempts" -le 0 ]; then - echo "Timed out waiting for $description to terminate; current state: $state." >&2 - exit 1 - fi - sleep 2 - done -} - -invoke_lambda() { - function_name="$1" - payload="$2" - description="$3" - invocation_result=$(aws --endpoint-url "$AWS_ENDPOINT_URL" lambda invoke \ - --cli-binary-format raw-in-base64-out \ - --invocation-type RequestResponse \ - --function-name "$function_name" \ - --payload "$payload" \ - "$lambda_response_file" --output json) - if printf '%s' "$invocation_result" | grep -Fq '"FunctionError"'; then - echo "Lambda invocation returned FunctionError for $function_name." >&2 - exit 1 - fi - printf ' [PASS] %s (Lambda API accepted the request)\n' "$description" -} - -scale_up_runner_id=987654321 -configure_mock_runner_state "$scale_up_instance_id" "$scale_up_runner_id" -clear_mock_request_log -invoke_lambda "ministack-default-scale-down" '{"smokeMarker":"ministack-scale-up-scale-down"}' \ - "Scale-down Lambda invoked for the scale-up runner" -wait_for_log_event "/aws/lambda/ministack-default-scale-down" "ministack-scale-up-scale-down" \ - "Scale-down Lambda started processing the scale-up runner" -assert_scale_down_github_routes "$scale_up_runner_id" -configure_mock_runner_removed "$scale_up_runner_id" -assert_mock_runner_removed "$scale_up_runner_id" -wait_for_ec2_termination "$scale_up_instance_id" "the scale-up instance" -wait_for_optional_log_event "/aws/lambda/ministack-default-scale-down" "$scale_up_instance_id" \ - "Scale-down log recorded termination of the scale-up EC2 runner" - -clear_mock_request_log -send_webhook "$dynamic_fixture" "ministack-smoke-123457" -wait_for_log_event "/aws/lambda/ministack-default-webhook" "123457" \ - "Webhook Lambda received dynamic-label workflow job 123457" -wait_for_log_event "/aws/lambda/ministack-default-dispatch-to-runner" "123457" \ - "EventBridge invoked the dispatcher for dynamic-label workflow job 123457" -wait_for_log_event "/aws/lambda/ministack-default-scale-up" "123457" \ - "Dispatcher delivered dynamic-label workflow job 123457 through SQS to scale-up" -assert_scale_up_github_routes 123457 -wait_for_ec2_instance "scale-up-lambda" "a dynamic-label scale-up instance" -dynamic_scale_up_instance_id="$found_instance_id" -assert_ec2_runner_tags "$dynamic_scale_up_instance_id" "scale-up-lambda" \ - "the dynamic-label scale-up runner" -assert_ec2_instance_type "$dynamic_scale_up_instance_id" "m5.large" - -dynamic_scale_up_runner_id=987654323 -configure_mock_runner_state "$dynamic_scale_up_instance_id" "$dynamic_scale_up_runner_id" -clear_mock_request_log -invoke_lambda "ministack-default-scale-down" '{"smokeMarker":"ministack-dynamic-scale-up-scale-down"}' \ - "Scale-down Lambda invoked for the dynamic-label scale-up runner" -wait_for_log_event "/aws/lambda/ministack-default-scale-down" "ministack-dynamic-scale-up-scale-down" \ - "Scale-down Lambda started processing the dynamic-label scale-up runner" -assert_scale_down_github_routes "$dynamic_scale_up_runner_id" -configure_mock_runner_removed "$dynamic_scale_up_runner_id" -assert_mock_runner_removed "$dynamic_scale_up_runner_id" -wait_for_ec2_termination "$dynamic_scale_up_instance_id" "the dynamic-label scale-up instance" -wait_for_optional_log_event "/aws/lambda/ministack-default-scale-down" "$dynamic_scale_up_instance_id" \ - "Scale-down log recorded termination of the dynamic-label scale-up EC2 runner" - -echo "MiniStack smoke chain 1 passed: API Gateway -> webhook -> EventBridge -> dispatcher -> SQS -> scale-up without and with EC2 dynamic label -> GitHub API mock." - -configure_empty_mock_runner_list -clear_mock_request_log -invoke_lambda "ministack-default-pool" '{"poolSize":1,"type":"ec2"}' \ - "Pool Lambda invoked to maintain one runner" -assert_pool_github_routes -wait_for_log_event "/aws/lambda/ministack-default-pool" "topped up with 1 runners" \ - "Pool Lambda requested one runner" -wait_for_ec2_instance "pool-lambda" "a pool instance" -pool_instance_id="$found_instance_id" -assert_ec2_runner_tags "$pool_instance_id" "pool-lambda" "the pool runner" - -pool_runner_id=987654322 -configure_mock_runner_state "$pool_instance_id" "$pool_runner_id" -clear_mock_request_log -invoke_lambda "ministack-default-scale-down" '{"smokeMarker":"ministack-pool-scale-down"}' \ - "Scale-down Lambda invoked for the pool runner" -wait_for_log_event "/aws/lambda/ministack-default-scale-down" "ministack-pool-scale-down" \ - "Scale-down Lambda started processing the pool runner" -assert_scale_down_github_routes "$pool_runner_id" -configure_mock_runner_removed "$pool_runner_id" -assert_mock_runner_removed "$pool_runner_id" -wait_for_ec2_termination "$pool_instance_id" "the pool instance" -wait_for_optional_log_event "/aws/lambda/ministack-default-scale-down" "$pool_instance_id" \ - "Scale-down log recorded termination of the pool EC2 runner" - -echo "MiniStack smoke chain 2 passed: pool -> GitHub API mock -> EC2 runner creation -> scale-down -> GitHub API mock -> EC2 termination." -echo "MiniStack smoke tests passed: both lifecycle chains completed." diff --git a/tests/ministack/run-webhook-smoke.py b/tests/ministack/run-webhook-smoke.py new file mode 100644 index 0000000000..0d8d9ed514 --- /dev/null +++ b/tests/ministack/run-webhook-smoke.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Run the shared multi-runner webhook smoke test once for both providers.""" + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from smoke.common import SmokeContext # noqa: E402 +from smoke.lifecycle import run # noqa: E402 +from smoke import ec2, microvm # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Run the multi-runner webhook smoke test for one or all compute providers." + ) + parser.add_argument("provider", nargs="?", choices=("all", "ec2", "microvm"), default="all") + parser.add_argument( + "--keep-deployment", + action="store_true", + default=False, + help="Keep the Terraform deployment and temporary tfvars file for debugging", + ) + args = parser.parse_args() + + providers = {item.slug: item for item in (ec2.provider, microvm.provider)} + + context = SmokeContext(Path(__file__).parent, keep_deployment=args.keep_deployment) + selected = tuple(providers.values()) if args.provider == "all" else (providers[args.provider],) + context.initialize_checklist([item.slug for item in selected]) + succeeded = False + try: + context.prepare() + for selected_provider in selected: + run(context, selected_provider) + tested = "EC2 and MicroVM" if args.provider == "all" else providers[args.provider].display_name + print(f"MiniStack multi-runner-webhook smoke tests passed for {tested}.", flush=True) + succeeded = True + return 0 + except BaseException as error: + context.record_checklist_failure(error) + raise + finally: + try: + context.cleanup() + finally: + context.finish_checklist(succeeded) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/ministack/smoke/__init__.py b/tests/ministack/smoke/__init__.py new file mode 100644 index 0000000000..e3cfc2fae6 --- /dev/null +++ b/tests/ministack/smoke/__init__.py @@ -0,0 +1 @@ +"""Provider-specific MiniStack smoke checks.""" diff --git a/tests/ministack/smoke/common.py b/tests/ministack/smoke/common.py new file mode 100644 index 0000000000..fddb654d97 --- /dev/null +++ b/tests/ministack/smoke/common.py @@ -0,0 +1,430 @@ +"""Shared MiniStack smoke-test plumbing.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +class SmokeContext: + def __init__(self, script_dir: Path, *, keep_deployment: bool = False) -> None: + self.script_dir = script_dir + self.source_root = script_dir.parent.parent + self.example_root = self.source_root / "examples" / "multi-runner-webhook" + self.aws_endpoint = os.environ.get("AWS_ENDPOINT_URL", "http://localhost:4566") + self.region = os.environ.get("AWS_DEFAULT_REGION", "eu-west-1") + self.environment = os.environ.copy() + self.environment.setdefault("AWS_ACCESS_KEY_ID", "000000000000") + self.environment.setdefault("AWS_SECRET_ACCESS_KEY", "test-only") + self.environment.setdefault("AWS_DEFAULT_REGION", self.region) + self.environment.setdefault("AWS_REGION", self.region) + self.environment.setdefault("AWS_ENDPOINT_URL", self.aws_endpoint) + self.environment.setdefault("AWS_EC2_METADATA_DISABLED", "true") + self.mock_host = os.environ.get("MINISTACK_GITHUB_MOCK_HOST", "host.docker.internal") + self.mock_url = os.environ.get("MINISTACK_GITHUB_MOCK_URL") + self.mock_port = int(os.environ.get("MINISTACK_GITHUB_MOCK_PORT", "0") or 0) + self.tfvars_path: Path | None = None + self.webhook_endpoint = "" + self.webhook_secret = "" + self.discovered_instance_ids: list[str] = [] + self.discovered_microvm_ids: list[str] = [] + self.before_microvm_ids: set[str] = set() + self.response_path = Path(tempfile.mkstemp(prefix="ministack-smoke-response.")[1]) + self.checklist_path = Path(os.environ.get("MINISTACK_SMOKE_CHECKLIST_FILE", "ministack-smoke-checklist.txt")) + self.checklist: dict[str, list[dict[str, str | bool]]] = {} + self.checklist_failure = "" + self.keep_deployment = keep_deployment or os.environ.get("MINISTACK_SMOKE_KEEP_DEPLOYMENT") == "1" + self.jit_expectations_configured = False + + def command(self, name: str) -> None: + if not shutil_which(name): + raise RuntimeError(f"{name} is required to run the MiniStack smoke test") + + def run(self, command: list[str], *, check: bool = True, stream: bool = False) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + check=check, + text=True, + capture_output=not stream, + env=self.environment, + ) + + def aws(self, *args: str, check: bool = True) -> Any: + result = self.run( + ["aws", "--endpoint-url", self.aws_endpoint, "--region", self.region, *args, "--output", "json"], + check=check, + ) + if not result.stdout.strip(): + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return result.stdout.strip() + + def terraform(self, *args: str, check: bool = True) -> str: + result = self.run(["terraform", f"-chdir={self.example_root}", *args], check=check) + return result.stdout.strip() + + def http(self, method: str, url: str, body: Any = None) -> tuple[int, str]: + data = None if body is None else json.dumps(body).encode() + request = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"} if data else {}, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return response.status, response.read().decode() + except urllib.error.HTTPError as error: + return error.code, error.read().decode() + except urllib.error.URLError as error: + return 0, str(error) + + def wait_for(self, predicate, description: str, attempts: int = 60) -> Any: + for attempt in range(1, attempts + 1): + result = predicate() + if result: + return result + if attempt == 1 or attempt % 10 == 0: + print(f" Still waiting for {description} ({attempt}/{attempts})", flush=True) + time.sleep(2) + raise RuntimeError(f"Timed out waiting for {description}") + + def initialize_checklist(self, providers: list[str]) -> None: + checks = ( + ("webhook", "API Gateway accepted the signed workflow_job webhook (HTTP 201)"), + ("chain", "Webhook, EventBridge, dispatcher, SQS, and scale-up logs contain the workflow job"), + ("scale_up_standard_routes", "Standard scale-up called every expected GitHub API route"), + ("scale_up_standard_resource", "Standard scale-up created the expected compute resource"), + ("scale_up_dynamic_routes", "Dynamic-label scale-up called every expected GitHub API route"), + ("scale_up_dynamic_resource", "Dynamic-label scale-up created the expected compute resource"), + ("pool_routes", "Pool called every expected GitHub API route"), + ("pool_resource", "Pool created the expected compute resource"), + ("scale_down_standard", "Scale-down removed the standard runner and compute resource"), + ("scale_down_dynamic", "Scale-down removed the dynamic-label runner and compute resource"), + ("scale_down_pool", "Scale-down removed the pool runner and compute resource"), + ) + self.checklist = { + provider: [{"key": key, "label": label, "passed": False} for key, label in checks] + for provider in providers + } + self._write_checklist("running") + print(f"Writing smoke checklist to {self.checklist_path}", flush=True) + + def mark_check(self, provider: str, key: str) -> None: + for check in self.checklist.get(provider, []): + if check["key"] == key: + check["passed"] = True + break + self._write_checklist("running") + + def record_checklist_failure(self, error: BaseException) -> None: + self.checklist_failure = f"{type(error).__name__}: {error}" + + def finish_checklist(self, passed: bool) -> None: + self._write_checklist("passed" if passed else "failed") + + def update_checklist_status(self, status: str) -> None: + self._write_checklist(status) + + def _write_checklist(self, status: str) -> None: + lines = [f"MiniStack multi-runner-webhook smoke checklist", f"Status: {status}"] + if self.checklist_failure: + lines.append(f"Failure: {self.checklist_failure}") + for provider, checks in self.checklist.items(): + lines.append("") + lines.append(f"[{provider}]") + lines.extend(f" [{'x' if check['passed'] else ' '}] {check['label']}" for check in checks) + self.checklist_path.parent.mkdir(parents=True, exist_ok=True) + self.checklist_path.write_text("\n".join(lines) + "\n") + + def configure_mockserver(self) -> None: + if not self.mock_url: + raise RuntimeError("MINISTACK_GITHUB_MOCK_URL must point to an already-running MockServer") + print(f"Using external MockServer at {self.mock_url}", flush=True) + if not self.mock_port: + self.mock_port = urlsplit(self.mock_url).port or 1080 + self.wait_for(lambda: self.http("PUT", f"{self.mock_url}/mockserver/status")[0] < 300, "MockServer") + expectations = json.loads((self.script_dir / "github-api-expectations.json").read_text()) + for expectation in expectations: + status, body = self.http("PUT", f"{self.mock_url}/mockserver/expectation", expectation) + if status >= 300: + raise RuntimeError(f"MockServer rejected an expectation: {status} {body}") + + def add_expectation(self, method: str, path: str, status: int, body: Any = None) -> None: + response: dict[str, Any] = {"statusCode": status} + if body is not None: + response.update(headers={"Content-Type": ["application/json"]}, body=json.dumps(body)) + code, text = self.http( + "PUT", + f"{self.mock_url}/mockserver/expectation", + {"httpRequest": {"method": method, "path": path}, "httpResponse": response}, + ) + if code >= 300: + raise RuntimeError(f"MockServer expectation failed: {code} {text}") + + def configure_jit_expectations(self) -> None: + if self.jit_expectations_configured: + return + self.add_expectation("GET", "/api/v3/orgs/test-owner/actions/runner-groups", 200, [{"id": 1, "name": "Default"}]) + self.add_expectation( + "POST", "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig", 200, + {"runner": {"id": 987654321, "labels": [{"name": "self-hosted"}, {"name": "linux"}]}, "encoded_jit_config": "ministack-jit-config"}, + ) + self.jit_expectations_configured = True + + def clear_runner_group_cache(self, provider: str) -> None: + parameter_name = ( + f"/github-action-runners/multi-runner-webhook/{provider}/runners/config/runner-group/Default" + ) + print(f" Clearing {provider} runner-group cache", flush=True) + self.aws("ssm", "delete-parameter", "--name", parameter_name, check=False) + + def clear_requests(self) -> None: + print(" Clearing MockServer request history", flush=True) + code, _ = self.http("PUT", f"{self.mock_url}/mockserver/clear?type=log") + if code >= 300: + raise RuntimeError("Failed to clear MockServer request history") + print(" MockServer request history cleared", flush=True) + + def verify_route(self, method: str, path: str, description: str) -> None: + def verify() -> bool: + code, _ = self.http( + "PUT", + f"{self.mock_url}/mockserver/verify", + {"httpRequest": {"method": method, "path": path}, "times": {"atLeast": 1}}, + ) + return code < 300 + + self.wait_for(verify, description) + + def send_webhook(self, event: dict[str, Any], delivery_id: str) -> None: + payload = json.dumps(event).encode() + signature = hmac.new(self.webhook_secret.encode(), payload, hashlib.sha256).hexdigest() + parsed_endpoint = urlsplit(self.webhook_endpoint) + endpoint = urlunsplit((parsed_endpoint.scheme, f"localhost:{parsed_endpoint.port or 4566}", parsed_endpoint.path, parsed_endpoint.query, parsed_endpoint.fragment)) + print(f" Sending webhook {delivery_id} to {endpoint} (Host: {parsed_endpoint.netloc})", flush=True) + request = urllib.request.Request( + endpoint, + data=payload, + headers={ + "Content-Type": "application/json", + "Host": parsed_endpoint.netloc, + "X-GitHub-Event": "workflow_job", + "X-GitHub-Delivery": delivery_id, + "X-GitHub-Hook-Installation-Target-ID": "123", + "X-Hub-Signature-256": f"sha256={signature}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + status = response.status + except urllib.error.HTTPError as error: + status = error.code + body = error.read().decode() + raise RuntimeError( + f"Webhook smoke request failed with HTTP {status} at {endpoint} " + f"(Host: {parsed_endpoint.netloc}): {body}" + ) from error + if status != 201: + raise RuntimeError(f"Webhook smoke request failed with HTTP {status}") + print(f" Webhook {delivery_id} accepted with HTTP 201", flush=True) + + def wait_for_log(self, group: str, marker: str, description: str) -> None: + print(f" Waiting for {description} ({group})", flush=True) + def found() -> bool: + events = self.aws("logs", "filter-log-events", "--log-group-name", group, "--filter-pattern", marker, "--limit", "1", check=False) + return bool(events and events.get("events")) + + try: + self.wait_for(found, description) + except RuntimeError as error: + groups = self.aws( + "logs", "describe-log-groups", + "--log-group-name-prefix", "/aws/lambda/multi-runner-webhook", + check=False, + ) or {} + available = [item.get("logGroupName") for item in groups.get("logGroups", [])] + recent = self.aws( + "logs", "filter-log-events", "--log-group-name", group, "--limit", "10", check=False, + ) or {} + messages = [item.get("message", "") for item in recent.get("events", [])] + raise RuntimeError( + f"{error}. Available smoke log groups: {available}. " + f"Recent messages in {group}: {messages}" + ) from error + print(f" Found {description}", flush=True) + + def invoke(self, function_name: str, payload: dict[str, Any], description: str) -> None: + payload_path = Path(tempfile.mkstemp(prefix="ministack-smoke-payload.")[1]) + output_path = Path(tempfile.mkstemp(prefix="ministack-smoke-lambda.")[1]) + try: + payload_path.write_text(json.dumps(payload)) + result = self.run([ + "aws", "--endpoint-url", self.aws_endpoint, "--region", self.region, + "lambda", "invoke", "--function-name", function_name, + "--payload", f"fileb://{payload_path}", str(output_path), "--output", "json", + ], check=False) + if result.returncode != 0: + raise RuntimeError( + f"{description} failed with exit code {result.returncode}: " + f"stdout={result.stdout.strip()} stderr={result.stderr.strip()}" + ) + metadata = json.loads(result.stdout) + if metadata.get("FunctionError"): + raise RuntimeError(output_path.read_text()) + finally: + payload_path.unlink(missing_ok=True) + output_path.unlink(missing_ok=True) + + def configure_runner_fixtures( + self, + provider: str, + runners: list[tuple[int, str]], + target_runner_id: int, + ) -> None: + """Expose all active GitHub runners while selecting one for removal. + + The scale-down Lambda evaluates every active provider resource. If the + GitHub list only contains the target runner, the remaining provider + resources are incorrectly marked as orphans and later invocations skip + the normal list-runners path. Keep the other runners visible and busy + so the fixture models a real multi-runner environment and only the + selected runner is eligible for termination. + """ + base = "/api/v3/orgs/test-owner/actions/runners" + if not any(runner_id == target_runner_id for runner_id, _ in runners): + raise RuntimeError(f"Target GitHub runner {target_runner_id} is not in the active runner fixtures") + + github_runners = [ + { + "id": runner_id, + "name": f"{provider}-{resource_id}", + "os": "linux", + "status": "offline", + "busy": runner_id != target_runner_id, + "labels": [], + } + for runner_id, resource_id in runners + ] + paths = [("GET", base)] + paths.extend(("GET", f"{base}/{runner_id}") for runner_id, _ in runners) + paths.append(("DELETE", f"{base}/{target_runner_id}")) + for method, path in paths: + self.http("PUT", f"{self.mock_url}/mockserver/clear", {"httpRequest": {"method": method, "path": path}}) + self.add_expectation("GET", base, 200, {"total_count": len(github_runners), "runners": github_runners}) + for runner in github_runners: + self.add_expectation("GET", f"{base}/{runner['id']}", 200, runner) + self.add_expectation("DELETE", f"{base}/{target_runner_id}", 204) + + def configure_runner_removed(self, runner_id: int) -> None: + path = f"/api/v3/orgs/test-owner/actions/runners/{runner_id}" + self.http("PUT", f"{self.mock_url}/mockserver/clear", {"httpRequest": {"method": "GET", "path": path}}) + self.add_expectation("GET", path, 404, {"message": "Not Found"}) + + def configure_empty_runner_list(self) -> None: + path = "/api/v3/orgs/test-owner/actions/runners" + self.http("PUT", f"{self.mock_url}/mockserver/clear", {"httpRequest": {"method": "GET", "path": path}}) + self.add_expectation("GET", path, 200, {"total_count": 0, "runners": []}) + + def assert_runner_removed(self, runner_id: int) -> None: + code, _ = self.http("GET", f"{self.mock_url}/api/v3/orgs/test-owner/actions/runners/{runner_id}") + if code != 404: + raise RuntimeError(f"Expected runner {runner_id} to be removed, got HTTP {code}") + + def scale_down_routes(self, runner_id: int) -> None: + self.verify_route("POST", "/api/v3/app/installations/123/access_tokens", "Scale-down requested a GitHub App token") + self.verify_route("GET", "/api/v3/orgs/test-owner/actions/runners", "Scale-down listed organization runners") + self.verify_route("GET", f"/api/v3/orgs/test-owner/actions/runners/{runner_id}", "Scale-down checked runner state") + self.verify_route("DELETE", f"/api/v3/orgs/test-owner/actions/runners/{runner_id}", "Scale-down deleted the GitHub runner") + + def scale_up_routes(self, job_id: int, provider: str) -> None: + self.verify_route("POST", "/api/v3/app/installations/123/access_tokens", f"{provider} scale-up requested a GitHub token for {job_id}") + self.verify_route("GET", f"/api/v3/repos/test-owner/test-repo/actions/jobs/{job_id}", f"{provider} scale-up checked queued job {job_id}") + + def pool_routes(self, provider: str) -> None: + self.verify_route("GET", "/api/v3/orgs/test-owner/installation", f"{provider} pool looked up the GitHub App installation") + self.verify_route("POST", "/api/v3/app/installations/123/access_tokens", f"{provider} pool requested a GitHub token") + self.verify_route("GET", "/api/v3/orgs/test-owner/actions/runners", f"{provider} pool listed organization runners") + + def prepare(self) -> None: + print("Preparing multi-runner-webhook smoke deployment", flush=True) + for command in ("aws", "openssl", "terraform"): + self.command(command) + self.configure_mockserver() + key_path = Path(tempfile.mkstemp(prefix="ministack-smoke-key.")[1]) + try: + self.run(["openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", str(key_path)], check=True) + key = base64.b64encode(key_path.read_bytes()).decode() + finally: + key_path.unlink(missing_ok=True) + source = self.script_dir / "multi-runner-webhook.tfvars" + text = source.read_text().replace('key_base64 = "ministack-invalid-key"', f'key_base64 = "{key}"') + self.tfvars_path = Path(tempfile.mkstemp(prefix="terraform-aws-github-runner-smoke.")[1]) + additions = [ + "github_enterprise_server = {", + f' url = "http://{self.mock_host}:{self.mock_port}"', + " ssl_verify = false", + "}", + ] + if "runners_lambda_zip" not in text: + additions.append(f'runners_lambda_zip = "{self.source_root / "lambdas/functions/control-plane/runners.zip"}"') + if "webhook_lambda_zip" not in text: + additions.append(f'webhook_lambda_zip = "{self.source_root / "lambdas/functions/webhook/webhook.zip"}"') + self.tfvars_path.write_text(text + "\n" + "\n".join(additions) + "\n") + print("Applying multi-runner-webhook Terraform example", flush=True) + self.run( + [str(self.source_root / "tests/ministack/run-example.sh"), "apply", "multi-runner-webhook", str(self.tfvars_path)], + stream=True, + ) + self.webhook_endpoint = self.terraform("output", "-raw", "webhook_endpoint") + self.webhook_secret = self.terraform("output", "-raw", "webhook_secret") + print("Deployment ready; starting provider lifecycle checks", flush=True) + + def cleanup(self) -> None: + self.update_checklist_status("cleanup") + for instance_id in self.discovered_instance_ids: + self.aws("ec2", "terminate-instances", "--instance-ids", instance_id, check=False) + for microvm_id in self.discovered_microvm_ids: + self.aws( + "lambda-microvms", + "terminate-microvm", + "--microvm-identifier", + microvm_id, + check=False, + ) + if self.tfvars_path and self.keep_deployment: + print(f"Terraform deployment retained; tfvars file: {self.tfvars_path}", flush=True) + elif self.tfvars_path: + print("Destroying multi-runner-webhook Terraform deployment", flush=True) + self.run( + [ + str(self.source_root / "tests/ministack/run-example.sh"), + "destroy", + "multi-runner-webhook", + str(self.tfvars_path), + ], + stream=True, + ) + self.tfvars_path.unlink(missing_ok=True) + self.response_path.unlink(missing_ok=True) + + +def shutil_which(name: str) -> str | None: + for directory in os.environ.get("PATH", "").split(os.pathsep): + candidate = Path(directory) / name + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + return None diff --git a/tests/ministack/smoke/ec2.py b/tests/ministack/smoke/ec2.py new file mode 100644 index 0000000000..6e087ce561 --- /dev/null +++ b/tests/ministack/smoke/ec2.py @@ -0,0 +1,137 @@ +"""EC2 implementation of the provider smoke-test interface.""" + +import json +from typing import Any + +from .common import SmokeContext +from .provider import RunnerResource + + +class Ec2Provider: + slug = "ec2" + display_name = "EC2" + + def configure(self, context: SmokeContext) -> None: + context.configure_jit_expectations() + + def event(self, context: SmokeContext, job_id: int, dynamic: bool) -> dict[str, Any]: + value = json.loads((context.script_dir / "workflow_job_event.json").read_text()) + job = value["workflow_job"] + job["id"] = job_id + job["name"] = f"multi-runner-webhook-ec2-{job_id}" + job["labels"] = ["self-hosted", "linux", "x64", "ec2"] + if dynamic: + job["labels"].append("ghr-ec2-instance-type:m5.large") + return value + + def verify_scale_up_routes(self, context: SmokeContext, job_id: int) -> None: + context.verify_route( + "GET", + "/api/v3/orgs/test-owner/actions/runner-groups", + f"EC2 scale-up resolved the runner group for {job_id}", + ) + context.verify_route( + "POST", + "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig", + f"EC2 scale-up generated JIT configuration for {job_id}", + ) + + def verify_pool_routes(self, context: SmokeContext) -> None: + context.verify_route( + "GET", + "/api/v3/orgs/test-owner/actions/runner-groups", + "EC2 pool resolved the runner group", + ) + context.verify_route( + "POST", + "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig", + "EC2 pool generated JIT configuration", + ) + + def _wait_for_instance(self, context: SmokeContext, source: str, description: str) -> RunnerResource: + def find() -> str | None: + result = context.aws( + "ec2", "describe-instances", "--filters", + "Name=instance-state-name,Values=running,pending", + "Name=tag:ghr:Application,Values=github-action-runner", + f"Name=tag:ghr:created_by,Values={source}", check=False, + ) or {} + for reservation in result.get("Reservations", []): + for instance in reservation.get("Instances", []): + instance_id = instance.get("InstanceId") + if instance_id and instance_id not in context.discovered_instance_ids: + context.discovered_instance_ids.append(instance_id) + return instance_id + return None + + return RunnerResource(context.wait_for(find, description)) + + def _instance(self, context: SmokeContext, resource: RunnerResource) -> dict[str, Any]: + result = context.aws("ec2", "describe-instances", "--instance-ids", resource.identifier) + return result["Reservations"][0]["Instances"][0] + + def _assert_tags(self, context: SmokeContext, resource: RunnerResource, source: str) -> None: + tags = {tag["Key"]: tag["Value"] for tag in self._instance(context, resource).get("Tags", [])} + expected = { + "ghr:Application": "github-action-runner", + "ghr:created_by": source, + "ghr:Type": "Org", + "ghr:Owner": "test-owner", + } + for key, value in expected.items(): + if tags.get(key) != value: + raise RuntimeError(f"Unexpected EC2 runner tag {key}: expected {value}, got {tags.get(key)}") + + def wait_for_scale_up(self, context: SmokeContext, source: str) -> RunnerResource: + return self._wait_for_instance(context, source, "an EC2 scale-up instance") + + def assert_scale_up(self, context: SmokeContext, resource: RunnerResource, dynamic: bool) -> None: + expected_type = "m5.large" if dynamic else "m7a.large" + actual_type = self._instance(context, resource).get("InstanceType") + if actual_type != expected_type: + raise RuntimeError(f"EC2 scale-up used {actual_type}, expected {expected_type}") + self._assert_tags(context, resource, "scale-up-lambda") + + def wait_for_pool(self, context: SmokeContext, source: str) -> RunnerResource: + return self._wait_for_instance(context, source, "an EC2 pool instance") + + def assert_pool(self, context: SmokeContext, resource: RunnerResource) -> None: + self._assert_tags(context, resource, "pool-lambda") + + def _wait_for_termination(self, context: SmokeContext, resource: RunnerResource) -> None: + def terminated() -> bool: + result = context.aws("ec2", "describe-instances", "--instance-ids", resource.identifier, check=False) + if not result: + return True + state = result.get("Reservations", [{}])[0].get("Instances", [{}])[0].get("State", {}).get("Name") + return state in (None, "terminated") + + context.wait_for(terminated, f"EC2 instance {resource.identifier} termination") + + def scale_down( + self, + context: SmokeContext, + resource: RunnerResource, + runner_id: int, + marker: str, + active_runners: list[tuple[int, RunnerResource]], + ) -> None: + context.configure_runner_fixtures( + self.slug, + [(active_runner_id, active_resource.identifier) for active_runner_id, active_resource in active_runners], + runner_id, + ) + context.clear_requests() + context.invoke( + "multi-runner-webhook-ec2-scale-down", + {"smokeMarker": marker, "type": "ec2"}, + "EC2 scale-down Lambda invoked", + ) + context.wait_for_log("/aws/lambda/multi-runner-webhook-ec2-scale-down", marker, "EC2 scale-down Lambda started") + context.scale_down_routes(runner_id) + context.configure_runner_removed(runner_id) + context.assert_runner_removed(runner_id) + self._wait_for_termination(context, resource) + + +provider = Ec2Provider() diff --git a/tests/ministack/smoke/lifecycle.py b/tests/ministack/smoke/lifecycle.py new file mode 100644 index 0000000000..67af4148ef --- /dev/null +++ b/tests/ministack/smoke/lifecycle.py @@ -0,0 +1,95 @@ +"""Provider-neutral webhook and runner lifecycle scenarios.""" + +from __future__ import annotations + +from .common import SmokeContext +from .provider import RunnerResource, SmokeProvider + + +def _log_group(provider: SmokeProvider, stage: str) -> str: + # The webhook and EventBridge dispatcher are shared by all compute + # providers. Only the scale-up Lambda is provider-specific. + if stage in ("webhook", "dispatch-to-runner"): + return f"/aws/lambda/multi-runner-webhook-{stage}" + return f"/aws/lambda/multi-runner-webhook-{provider.slug}-{stage}" + + +def _wait_for_webhook_chain(context: SmokeContext, provider: SmokeProvider, job_id: int) -> None: + context.wait_for_log(_log_group(provider, "webhook"), str(job_id), f"{provider.display_name} webhook received job {job_id}") + context.wait_for_log(_log_group(provider, "dispatch-to-runner"), str(job_id), f"{provider.display_name} dispatcher received job {job_id}") + context.wait_for_log(_log_group(provider, "scale-up"), str(job_id), f"{provider.display_name} scale-up received job {job_id}") + + +def _scale_up( + context: SmokeContext, + provider: SmokeProvider, + job_id: int, + dynamic: bool, + source: str, +) -> RunnerResource: + label_mode = "with dynamic label" if dynamic else "without dynamic label" + print(f" {provider.display_name}: scale-up {label_mode} (job {job_id})", flush=True) + context.clear_runner_group_cache(provider.slug) + context.clear_requests() + context.send_webhook(provider.event(context, job_id, dynamic), f"multi-runner-webhook-{provider.slug}-{job_id}") + context.mark_check(provider.slug, "webhook") + _wait_for_webhook_chain(context, provider, job_id) + context.mark_check(provider.slug, "chain") + context.scale_up_routes(job_id, provider.display_name) + provider.verify_scale_up_routes(context, job_id) + context.mark_check(provider.slug, "scale_up_dynamic_routes" if dynamic else "scale_up_standard_routes") + resource = provider.wait_for_scale_up(context, source) + provider.assert_scale_up(context, resource, dynamic) + context.mark_check(provider.slug, "scale_up_dynamic_resource" if dynamic else "scale_up_standard_resource") + return resource + + +def _pool(context: SmokeContext, provider: SmokeProvider, pool_size: int) -> RunnerResource: + print(f" {provider.display_name}: pool scale-up (target size {pool_size})", flush=True) + context.configure_empty_runner_list() + context.clear_runner_group_cache(provider.slug) + context.clear_requests() + context.invoke( + f"multi-runner-webhook-{provider.slug}-pool", + {"poolSize": pool_size, "type": provider.slug}, + f"{provider.display_name} pool Lambda invoked", + ) + context.pool_routes(provider.display_name) + provider.verify_pool_routes(context) + context.mark_check(provider.slug, "pool_routes") + resource = provider.wait_for_pool(context, "pool-lambda") + print(f" {provider.display_name}: pool created resource {resource.identifier}", flush=True) + provider.assert_pool(context, resource) + context.mark_check(provider.slug, "pool_resource") + return resource + + +def run(context: SmokeContext, provider: SmokeProvider) -> None: + print(f"Running {provider.display_name} webhook lifecycle scenarios", flush=True) + provider.configure(context) + + scale_up = _scale_up(context, provider, 123456, False, "scale-up-lambda") + dynamic_scale_up = _scale_up(context, provider, 123457, True, "scale-up-lambda") + # The two webhook scale-ups above already create two managed runners. Ask + # the pool to reach three so it has to create the third runner and exercise + # its runner-group/JIT path as well. + pool = _pool(context, provider, pool_size=3) + + scale_down_runners = [ + (987654321, scale_up), + (987654323, dynamic_scale_up), + (987654322, pool), + ] + for resource, runner_id, marker, check in ( + (scale_up, 987654321, f"multi-runner-webhook-{provider.slug}-scale-up-scale-down", "scale_down_standard"), + (dynamic_scale_up, 987654323, f"multi-runner-webhook-{provider.slug}-dynamic-scale-down", "scale_down_dynamic"), + (pool, 987654322, f"multi-runner-webhook-{provider.slug}-pool-scale-down", "scale_down_pool"), + ): + print(f" {provider.display_name}: scale-down resource {resource.identifier}", flush=True) + provider.scale_down(context, resource, runner_id, marker, scale_down_runners) + context.mark_check(provider.slug, check) + scale_down_runners = [ + (active_runner_id, active_resource) + for active_runner_id, active_resource in scale_down_runners + if active_runner_id != runner_id + ] diff --git a/tests/ministack/smoke/microvm.py b/tests/ministack/smoke/microvm.py new file mode 100644 index 0000000000..eb75d5be51 --- /dev/null +++ b/tests/ministack/smoke/microvm.py @@ -0,0 +1,146 @@ +"""MicroVM implementation of the provider smoke-test interface.""" + +import json +from typing import Any + +from .common import SmokeContext +from .provider import RunnerResource + + +class MicrovmProvider: + slug = "microvm" + display_name = "MicroVM" + image_arn = "arn:aws:lambda:eu-west-1:000000000000:microvm-image:ministack" + image_version = "3.0" + + def configure(self, context: SmokeContext) -> None: + context.configure_jit_expectations() + microvms = context.aws("lambda-microvms", "list-microvms", check=False) or {} + context.before_microvm_ids = { + item["microvmId"] + for item in microvms.get("items", []) + if item.get("microvmId") + } + + def event(self, context: SmokeContext, job_id: int, dynamic: bool) -> dict[str, Any]: + value = json.loads((context.script_dir / "workflow_job_event.json").read_text()) + job = value["workflow_job"] + job["id"] = job_id + job["name"] = f"multi-runner-webhook-microvm-{job_id}" + job["labels"] = ["self-hosted", "linux", "arm64", "microvm"] + if dynamic: + job["labels"].append(f"ghr-microvm-image-version:{self.image_version}") + return value + + def verify_scale_up_routes(self, context: SmokeContext, job_id: int) -> None: + context.verify_route("GET", "/api/v3/orgs/test-owner/actions/runner-groups", "MicroVM scale-up resolved the runner group") + context.verify_route("POST", "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig", "MicroVM scale-up generated JIT configuration") + + def verify_pool_routes(self, context: SmokeContext) -> None: + context.verify_route("GET", "/api/v3/orgs/test-owner/actions/runner-groups", "MicroVM pool resolved the runner group") + context.verify_route("POST", "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig", "MicroVM pool generated JIT configuration") + + def _metadata(self, context: SmokeContext, microvm_id: str) -> dict[str, Any]: + value = context.aws( + "ssm", "get-parameter", + "--name", f"/github-action-runners/multi-runner-webhook/microvm/runners/config/microvm-metadata/{microvm_id}", + check=False, + ) + if not value or value.get("Parameter", {}).get("Value") in (None, "None"): + raise RuntimeError(f"Missing MicroVM ownership metadata for {microvm_id}") + return json.loads(value["Parameter"]["Value"]) + + def _wait_for_microvm(self, context: SmokeContext, source: str, description: str) -> RunnerResource: + def find() -> str | None: + result = context.aws("lambda-microvms", "list-microvms", check=False) or {} + for item in result.get("items", []): + microvm_id = item.get("microvmId") + if not microvm_id or microvm_id in context.before_microvm_ids: + continue + if self._metadata(context, microvm_id).get("source") == source: + if microvm_id not in context.discovered_microvm_ids: + context.discovered_microvm_ids.append(microvm_id) + return microvm_id + return None + + return RunnerResource(context.wait_for(find, description)) + + def _details(self, context: SmokeContext, resource: RunnerResource) -> dict[str, Any]: + return context.aws( + "lambda-microvms", + "get-microvm", + "--microvm-identifier", + resource.identifier, + check=False, + ) or {} + + def _assert_resource(self, context: SmokeContext, resource: RunnerResource, source: str, dynamic: bool) -> None: + details = self._details(context, resource) + if details.get("state") not in ("PENDING", "RUNNING", "SUSPENDING", "SUSPENDED"): + raise RuntimeError(f"MicroVM {resource.identifier} is not active: {details}") + if details.get("imageArn") != self.image_arn: + raise RuntimeError(f"MicroVM {resource.identifier} used {details.get('imageArn')}, expected {self.image_arn}") + if dynamic and details.get("imageVersion") != self.image_version: + raise RuntimeError(f"MicroVM {resource.identifier} used image version {details.get('imageVersion')}, expected {self.image_version}") + metadata = self._metadata(context, resource.identifier) + expected = { + "environment": "multi-runner-webhook-microvm", + "source": source, + "runnerOwner": "test-owner", + "runnerType": "Org", + } + if any(metadata.get(key) != value for key, value in expected.items()): + raise RuntimeError(f"Unexpected MicroVM metadata: {metadata}") + + def wait_for_scale_up(self, context: SmokeContext, source: str) -> RunnerResource: + return self._wait_for_microvm(context, source, "a MicroVM scale-up resource") + + def assert_scale_up(self, context: SmokeContext, resource: RunnerResource, dynamic: bool) -> None: + self._assert_resource(context, resource, "scale-up-lambda", dynamic) + + def wait_for_pool(self, context: SmokeContext, source: str) -> RunnerResource: + return self._wait_for_microvm(context, source, "a MicroVM pool resource") + + def assert_pool(self, context: SmokeContext, resource: RunnerResource) -> None: + self._assert_resource(context, resource, "pool-lambda", False) + + def _wait_for_termination(self, context: SmokeContext, resource: RunnerResource) -> None: + def terminated() -> bool: + details = context.aws( + "lambda-microvms", + "get-microvm", + "--microvm-identifier", + resource.identifier, + check=False, + ) + return not details or details.get("state") == "TERMINATED" + + context.wait_for(terminated, f"MicroVM {resource.identifier} termination") + + def scale_down( + self, + context: SmokeContext, + resource: RunnerResource, + runner_id: int, + marker: str, + active_runners: list[tuple[int, RunnerResource]], + ) -> None: + context.configure_runner_fixtures( + self.slug, + [(active_runner_id, active_resource.identifier) for active_runner_id, active_resource in active_runners], + runner_id, + ) + context.clear_requests() + context.invoke( + "multi-runner-webhook-microvm-scale-down", + {"smokeMarker": marker, "type": "microvm"}, + "MicroVM scale-down Lambda invoked", + ) + context.wait_for_log("/aws/lambda/multi-runner-webhook-microvm-scale-down", marker, "MicroVM scale-down Lambda started") + context.scale_down_routes(runner_id) + context.configure_runner_removed(runner_id) + context.assert_runner_removed(runner_id) + self._wait_for_termination(context, resource) + + +provider = MicrovmProvider() diff --git a/tests/ministack/smoke/provider.py b/tests/ministack/smoke/provider.py new file mode 100644 index 0000000000..81383fa52d --- /dev/null +++ b/tests/ministack/smoke/provider.py @@ -0,0 +1,54 @@ +"""Interface implemented by each compute provider smoke test.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol + +from .common import SmokeContext + + +@dataclass(frozen=True) +class RunnerResource: + """Provider resource created by one smoke scenario.""" + + identifier: str + + +class SmokeProvider(Protocol): + slug: str + display_name: str + + def configure(self, context: SmokeContext) -> None: + """Add provider-specific MockServer expectations.""" + + def event(self, context: SmokeContext, job_id: int, dynamic: bool) -> dict[str, Any]: + """Build a workflow_job event that selects this provider.""" + + def verify_scale_up_routes(self, context: SmokeContext, job_id: int) -> None: + """Verify provider-specific GitHub API calls made during scale-up.""" + + def verify_pool_routes(self, context: SmokeContext) -> None: + """Verify provider-specific GitHub API calls made during pool scale-up.""" + + def wait_for_scale_up(self, context: SmokeContext, source: str) -> RunnerResource: + """Find the resource created by a scale-up Lambda.""" + + def assert_scale_up(self, context: SmokeContext, resource: RunnerResource, dynamic: bool) -> None: + """Check provider-specific scale-up state and ownership.""" + + def wait_for_pool(self, context: SmokeContext, source: str) -> RunnerResource: + """Find the resource created by a pool Lambda.""" + + def assert_pool(self, context: SmokeContext, resource: RunnerResource) -> None: + """Check provider-specific pool state and ownership.""" + + def scale_down( + self, + context: SmokeContext, + resource: RunnerResource, + runner_id: int, + marker: str, + active_runners: list[tuple[int, RunnerResource]], + ) -> None: + """Run provider-specific scale-down checks for one resource.""" From ebfdc93d5ff80280c1c1923f2600b7091da46d81 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:39:07 +0000 Subject: [PATCH 15/38] docs: auto update terraform docs --- examples/multi-runner-webhook/README.md | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/examples/multi-runner-webhook/README.md b/examples/multi-runner-webhook/README.md index 4312cd452a..2f3a3c2989 100644 --- a/examples/multi-runner-webhook/README.md +++ b/examples/multi-runner-webhook/README.md @@ -17,3 +17,48 @@ terraform apply \ -var='runners_lambda_zip=/path/to/runners.zip' \ -var='webhook_lambda_zip=/path/to/webhook.zip' ``` + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [aws](#requirement\_aws) | >= 6.33 | +| [local](#requirement\_local) | ~> 2.0 | +| [random](#requirement\_random) | ~> 3.0 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [base](#module\_base) | ../base | n/a | +| [runners](#module\_runners) | ../../modules/multi-runner | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_region](#input\_aws\_region) | AWS Region where the runner control plane and compute provider resources are deployed. | `string` | `"eu-west-1"` | no | +| [compute\_provider](#input\_compute\_provider) | Provider-specific settings for the EC2 and MicroVM runner lanes. |
object({
aws = object({
ec2 = object({
instance_types = list(string)
ami = object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
})
})
microvm = object({
image_arn = string
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), [])
egress_network_connectors = list(string)
})
})
})
| n/a | yes | +| [environment](#input\_environment) | Name prefix for the example resources. | `string` | n/a | yes | +| [github\_app](#input\_github\_app) | GitHub App credentials used by the webhook orchestration provider. |
object({
id = string
key_base64 = string
webhook_secret = string
})
| n/a | yes | +| [github\_enterprise\_server](#input\_github\_enterprise\_server) | Optional GitHub Enterprise Server endpoint used by the smoke-test API mock. |
object({
url = string
ssl_verify = bool
})
| `null` | no | +| [runners\_lambda\_zip](#input\_runners\_lambda\_zip) | Local ZIP file for the runner-control Lambda. | `string` | n/a | yes | +| [webhook\_lambda\_zip](#input\_webhook\_lambda\_zip) | Local ZIP file for the webhook Lambda. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [webhook\_endpoint](#output\_webhook\_endpoint) | n/a | +| [webhook\_secret](#output\_webhook\_secret) | n/a | + \ No newline at end of file From b27dce29d34376f954173b245f8ff591800c91d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:52:13 +0000 Subject: [PATCH 16/38] docs: auto update terraform docs --- README.md | 3 ++- modules/compute-providers/aws/ec2/README.md | 2 +- modules/multi-runner/README.md | 2 +- modules/runner-config/README.md | 2 +- modules/runners/README.md | 1 + modules/webhook/README.md | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9f5c9041b1..8ba80adff0 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [ami\_housekeeper\_lambda\_timeout](#input\_ami\_housekeeper\_lambda\_timeout) | Time out of the lambda in seconds. | `number` | `300` | no | | [ami\_housekeeper\_lambda\_zip](#input\_ami\_housekeeper\_lambda\_zip) | File location of the lambda zip file. | `string` | `null` | no | | [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no | -| [aws\_dynamic\_labels\_policy](#input\_aws\_dynamic\_labels\_policy) | Experimental! Can be removed / changed without trigger a major release.
Optional AWS dynamic label policy evaluated by the webhook dispatcher.
Only effective when `enable_dynamic_labels = true`.

Jobs whose provider-specific dynamic labels violate the policy are rejected
with a 202 and a warning is logged. Currently this policy applies to EC2
override labels using the `ghr-ec2-*` prefix.

Evaluation:
1. Keys in `blocked_keys` are always rejected.
2. Keys in `restricted_keys` are allowed only when their value passes the rule.
3. Keys not listed in `blocked_keys` or `restricted_keys` are allowed.

Schema:
- `blocked_keys`: keys to reject outright.
- `restricted_keys`: map of key to value rule:
`{ allowed = [globs], denied = [globs], max = number|string }`.

Keys use the provider dynamic label suffix, not the full label. For example,
use `instance-type` for `ghr-ec2-instance-type`. | `any` | `null` | no | +| [aws\_dynamic\_labels\_policy](#input\_aws\_dynamic\_labels\_policy) | Experimental! Can be removed / changed without trigger a major release.
Optional AWS dynamic label policy evaluated by the webhook dispatcher.
Only effective when `enable_dynamic_labels = true`.

Jobs whose provider-specific dynamic labels violate the policy are rejected
with a 202 and a warning is logged. Currently this policy applies to EC2
override labels using the `ghr-ec2-*` prefix.

Evaluation:
1. If `allowed_keys` is set (non-empty), any key not listed in it is rejected;
everything else in the policy still applies to the keys it does allow.
2. Keys in `blocked_keys` are always rejected. Cannot be used together with
`allowed_keys` — see `docs/configuration.md` for why.
3. Keys in `restricted_keys` are allowed only when their value passes the rule.
4. A key not listed anywhere above is allowed.

Schema:
- `allowed_keys`: only these keys are accepted; every other key is rejected.
- `blocked_keys`: keys to reject outright.
- `restricted_keys`: map of key to value rule:
`{ allowed = [globs], denied = [globs], max = number|string }`.

Keys use the provider dynamic label suffix, not the full label. For example,
use `instance-type` for `ghr-ec2-instance-type`. | `any` | `null` | no | | [aws\_partition](#input\_aws\_partition) | (optiona) partition in the arn namespace to use if not 'aws' | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | | [block\_device\_mappings](#input\_block\_device\_mappings) | The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`. |
list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
}))
|
[
{
"volume_size": 30
}
]
| no | @@ -210,6 +210,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [runner\_log\_files](#input\_runner\_log\_files) | (optional) List of logfiles to send to CloudWatch, will only be used if `enable_cloudwatch_agent` is set to true. Object description: `log_group_name`: Name of the log group, `prefix_log_group`: If true, the log group name will be prefixed with `/github-self-hosted-runners/`, `file_path`: path to the log file, `log_stream_name`: name of the log stream, `log_class`: The log class of the log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. Defaults to `STANDARD`. |
list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
}))
| `null` | no | | [runner\_metadata\_options](#input\_runner\_metadata\_options) | Metadata options for the ec2 runner instances. By default, the module uses metadata tags for bootstrapping the runner, only disable `instance_metadata_tags` when using custom scripts for starting the runner. | `map(any)` |
{
"http_endpoint": "enabled",
"http_put_response_hop_limit": 1,
"http_tokens": "required",
"instance_metadata_tags": "enabled"
}
| no | | [runner\_name\_prefix](#input\_runner\_name\_prefix) | The prefix used for the GitHub runner name. The prefix will be used in the default start script to prefix the instance name when register the runner in GitHub. The value is available via an EC2 tag 'ghr:runner\_name\_prefix'. | `string` | `""` | no | +| [runner\_network\_interfaces](#input\_runner\_network\_interfaces) | Advanced network interface configuration for the runner launch template. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#network-interfaces for details. Leave unset (default) to keep using associate\_public\_ipv4\_address for a simple single-interface setup; set this to fully control one or more interfaces. |
list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
}))
| `[]` | no | | [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux, osx, windows). | `string` | `"linux"` | no | | [runner\_placement](#input\_runner\_placement) | The placement options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#placement for details. |
object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
})
| `null` | no | | [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | diff --git a/modules/compute-providers/aws/ec2/README.md b/modules/compute-providers/aws/ec2/README.md index ec44a82fa8..6c692445c9 100644 --- a/modules/compute-providers/aws/ec2/README.md +++ b/modules/compute-providers/aws/ec2/README.md @@ -61,7 +61,7 @@ No modules. |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct IAM ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region used by compute-provider resources and policy documents. | `string` | n/a | yes | -| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.aws.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `on_demand_failover_for_errors`: EC2 errors that trigger on-demand fallback after a Spot failure.
- `scale_errors`: EC2 errors treated as retryable scale-up failures.
- `use_dedicated_host`: Enables the dedicated-host launch path. |
object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
vpc_id = string
subnet_ids = list(string)
overrides = optional(object({
name_runner = optional(string, "")
name_sg = optional(string, "")
}), {})
instance_profile = optional(object({
name = string
}), null)
instance_profile_path = optional(string, null)
binaries_syncer = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
arn = string
id = string
key = string
}), null)
}), {})
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
ebs_optimized = optional(bool, false)
instance_target_capacity_type = optional(string, "spot")
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_types = list(string)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
ssm_enabled = optional(bool, false)
create_service_linked_role_spot = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
managed_security_group_enabled = optional(bool, true)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
key_name = optional(string, null)
additional_security_group_ids = optional(list(string), [])
detailed_monitoring_enabled = optional(bool, false)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
tags = optional(map(string), {})
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
credit_specification = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
associate_public_ipv4_address = optional(bool, false)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
use_dedicated_host = optional(bool, false)
})
| n/a | yes | +| [config](#input\_config) | EC2 compute-provider configuration. Paths match `compute_provider.aws.ec2` in the runner configuration.

- `ami`: Optional AMI discovery and encryption configuration. Null selects defaults for `runner.os` and `runner.architecture`.
- `ami.filter`: AMI filter names mapped to accepted values and merged over the provider defaults.
- `ami.owners`: AWS account IDs or aliases allowed to own the selected AMI.
- `ami.id_ssm_parameter`: Optional externally managed SSM parameter containing the AMI ID. Its object presence is the plan-time ownership discriminator.
- `ami.id_ssm_parameter.arn`: ARN of the external AMI-ID parameter. The ARN may remain unknown until apply.
- `ami.kms_key`: Optional customer-managed KMS key required for encrypted AMIs or snapshots. Its object presence is the plan-time policy discriminator.
- `ami.kms_key.arn`: ARN of the AMI KMS key. The ARN may remain unknown until apply.
- `vpc_id`: VPC in which runner networking resources are created.
- `subnet_ids`: Subnets from which the control plane may launch runners.
- `overrides.name_runner`: Optional Name tag override for runner compute resources.
- `overrides.name_sg`: Optional Name tag override for the managed security group.
- `instance_profile`: Optional externally managed instance profile. Its object presence is the plan-time ownership discriminator.
- `instance_profile.name`: Name of the external instance profile. The name may remain unknown until apply.
- `instance_profile_path`: IAM path for the provider-managed instance profile. Null derives the path from `prefix`.
- `binaries_syncer.enabled`: Uses the synchronized runner distribution from S3 during bootstrap.
- `binaries_syncer.s3`: S3 object containing the synchronized runner distribution. Required when synchronization is enabled.
- `binaries_syncer.s3.arn`: Runner-distribution bucket ARN used by IAM policies.
- `binaries_syncer.s3.id`: Runner-distribution bucket name used in the bootstrap URI.
- `binaries_syncer.s3.key`: Runner-distribution object key.
- `block_device_mappings`: EBS mappings added to the launch template.
- `block_device_mappings[].delete_on_termination`: Deletes the volume when its runner terminates.
- `block_device_mappings[].device_name`: Device name exposed to the runner instance.
- `block_device_mappings[].encrypted`: Enables EBS encryption.
- `block_device_mappings[].iops`: Provisioned IOPS for volume types that support configurable IOPS.
- `block_device_mappings[].kms_key_id`: KMS key ID or ARN used to encrypt the volume.
- `block_device_mappings[].snapshot_id`: Snapshot used to initialize the volume.
- `block_device_mappings[].throughput`: Provisioned throughput for volume types that support it.
- `block_device_mappings[].volume_initialization_rate`: Fixed initialization rate for supported snapshot-backed volumes.
- `block_device_mappings[].volume_size`: EBS volume size in GiB.
- `block_device_mappings[].volume_type`: EBS volume type.
- `ebs_optimized`: Requests EBS-optimized instances.
- `instance_target_capacity_type`: Primary capacity type, either `spot` or `on-demand`.
- `instance_allocation_strategy`: EC2 Fleet allocation strategy.
- `instance_type_priorities`: Optional numeric priorities keyed by instance type.
- `instance_max_spot_price`: Optional maximum hourly Spot price.
- `instance_types`: EC2 instance types available to the control plane.
- `user_data`: Runner bootstrap user-data configuration.
- `user_data.enabled`: Enables launch-template user data.
- `user_data.template`: Optional path to a custom user-data template.
- `user_data.content`: Optional complete user-data content used instead of a template.
- `user_data.pre_install`: Script inserted before runner installation.
- `user_data.post_install`: Script inserted after runner installation.
- `user_data.debug_logging_enabled`: Enables verbose user-data tracing, which can expose secrets.
- `ssm_enabled`: Includes Session Manager permissions in the provider's runner policy group.
- `create_service_linked_role_spot`: Allows scale-up to create the EC2 Spot service-linked role.
- `cloudwatch_agent.enabled`: Enables CloudWatch agent configuration for runner instances.
- `cloudwatch_agent.config`: Optional complete CloudWatch agent configuration.
- `managed_security_group_enabled`: Creates and attaches the provider-managed security group.
- `log_files`: Optional files collected by the CloudWatch agent. Null uses provider defaults.
- `log_files[].log_group_name`: CloudWatch log-group name before optional prefixing.
- `log_files[].prefix_log_group`: Prefixes the log-group name with the runner configuration path.
- `log_files[].file_path`: File or glob read by the CloudWatch agent.
- `log_files[].log_stream_name`: CloudWatch log-stream name template.
- `log_files[].log_class`: CloudWatch log-group class for the collected file.
- `key_name`: Optional EC2 key-pair name.
- `additional_security_group_ids`: Existing security groups attached to runners.
- `detailed_monitoring_enabled`: Enables detailed EC2 monitoring.
- `egress_rules`: Rules created on the managed security group.
- `egress_rules[].cidr_blocks`: IPv4 CIDR destinations.
- `egress_rules[].ipv6_cidr_blocks`: IPv6 CIDR destinations.
- `egress_rules[].prefix_list_ids`: AWS prefix-list destinations.
- `egress_rules[].from_port`: First destination port in the permitted range.
- `egress_rules[].protocol`: IP protocol name or number. Use `-1` for all protocols.
- `egress_rules[].security_groups`: Destination security-group IDs.
- `egress_rules[].self`: Allows traffic to the managed security group itself.
- `egress_rules[].to_port`: Last destination port in the permitted range.
- `egress_rules[].description`: Optional rule description.
- `tags`: Runner instance, volume, network-interface, and eligible Spot-request tags. Provider-required bootstrap tags take final precedence.
- `metadata_options`: Instance Metadata Service configuration.
- `metadata_options.instance_metadata_tags`: Exposes instance tags through Instance Metadata Service when enabled.
- `metadata_options.http_endpoint`: Enables or disables the Instance Metadata Service endpoint.
- `metadata_options.http_tokens`: Controls whether IMDSv2 session tokens are optional or required.
- `metadata_options.http_put_response_hop_limit`: Network hop limit for Instance Metadata Service token responses.
- `credit_specification`: CPU credit mode for burstable instance types.
- `cpu_options`: CPU topology and processor-feature configuration.
- `cpu_options.core_count`: Number of CPU cores exposed to the runner instance.
- `cpu_options.threads_per_core`: Number of hardware threads exposed per CPU core.
- `cpu_options.amd_sev_snp`: Enables or disables AMD SEV-SNP on supported instance types.
- `cpu_options.nested_virtualization`: Enables or disables nested virtualization on supported instance types.
- `placement`: EC2 placement configuration.
- `placement.affinity`: Dedicated Host affinity setting.
- `placement.availability_zone`: Availability Zone in which runner instances are placed.
- `placement.group_id`: Placement-group ID.
- `placement.group_name`: Placement-group name.
- `placement.host_id`: Dedicated Host ID.
- `placement.host_resource_group_arn`: ARN of the host resource group used for placement.
- `placement.spread_domain`: Spread-domain placement value.
- `placement.tenancy`: Instance tenancy, such as `default`, `dedicated`, or `host`.
- `placement.partition_number`: Placement-group partition number.
- `license_specifications`: License Manager configurations added to the launch template.
- `license_specifications[].license_configuration_arn`: ARN of an AWS License Manager license configuration.
- `associate_public_ipv4_address`: Associates a public IPv4 address with runner network interfaces.
- `network_interfaces`: Advanced network interface configuration for the launch template. Leave empty to keep using `associate_public_ipv4_address` for a simple single-interface setup.
- `on_demand_failover_for_errors`: EC2 errors that trigger on-demand fallback after a Spot failure.
- `scale_errors`: EC2 errors treated as retryable scale-up failures.
- `use_dedicated_host`: Enables the dedicated-host launch path. |
object({
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
vpc_id = string
subnet_ids = list(string)
overrides = optional(object({
name_runner = optional(string, "")
name_sg = optional(string, "")
}), {})
instance_profile = optional(object({
name = string
}), null)
instance_profile_path = optional(string, null)
binaries_syncer = optional(object({
enabled = optional(bool, true)
s3 = optional(object({
arn = string
id = string
key = string
}), null)
}), {})
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
ebs_optimized = optional(bool, false)
instance_target_capacity_type = optional(string, "spot")
instance_allocation_strategy = optional(string, "lowest-price")
instance_type_priorities = optional(map(number), null)
instance_max_spot_price = optional(string, null)
instance_types = list(string)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
ssm_enabled = optional(bool, false)
create_service_linked_role_spot = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
managed_security_group_enabled = optional(bool, true)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
key_name = optional(string, null)
additional_security_group_ids = optional(list(string), [])
detailed_monitoring_enabled = optional(bool, false)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), [{
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
prefix_list_ids = null
from_port = 0
protocol = "-1"
security_groups = null
self = null
to_port = 0
description = null
}])
tags = optional(map(string), {})
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
credit_specification = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
associate_public_ipv4_address = optional(bool, false)
network_interfaces = optional(list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
})), [])
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
use_dedicated_host = optional(bool, false)
})
| n/a | yes | | [github](#input\_github) | GitHub Enterprise Server settings available to compute-provider bootstrap data.

- `enterprise_server.url`: Optional GitHub Enterprise Server base URL. Null selects GitHub.com.
- `enterprise_server.ssl_verify`: Enables TLS certificate verification for GitHub Enterprise Server. |
object({
enterprise_server = optional(object({
url = optional(string, null)
ssl_verify = optional(bool, true)
}), {})
})
| `{}` | no | | [observability](#input\_observability) | CloudWatch Logs settings available to compute-provider runner log groups.

- `logs.retention_in_days`: Retention period for provider-owned runner log groups.
- `logs.kms_key_id`: Optional KMS key ID or ARN used to encrypt runner log groups.
- `logs.tags`: Shared log-group tags that override module-level `tags`. |
object({
logs = optional(object({
retention_in_days = optional(number, 180)
kms_key_id = optional(string, null)
tags = optional(map(string), {})
}), {})
})
| `{}` | no | | [prefix](#input\_prefix) | Prefix used to identify resources created for the runner configuration. | `string` | `"github-actions"` | no | diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index e1abf62ace..faa3676fcc 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -190,7 +190,7 @@ module "multi-runner" { | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no | | [matcher\_config\_parameter\_store\_tier](#input\_matcher\_config\_parameter\_store\_tier) | The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`. | `string` | `"Standard"` | no | | [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. |
object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
})
| `{}` | no | -| [multi\_runner\_config](#input\_multi\_runner\_config) | Accepts either the stable v1 runner configuration shape or the provider-boundary v2 shape. Entries with `runner_config` use the v1 shape; entries without `runner_config` use the v2 shape. A v2 entry does not need matcher configuration. A v2 entry must be acknowledged with `experimental_features = ["multi-runner-v2"]`; the v2 shape is experimental and may change before graduation.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

compute_provider = optional(object({
aws = optional(object({
ec2 = optional(object({
metadata_options = optional(object({
instance_metadata_tags = optional(string, "enabled")
http_endpoint = optional(string, "enabled")
http_tokens = optional(string, "required")
http_put_response_hop_limit = optional(number, 1)
}), {})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter = optional(object({
arn = string
}), null)
kms_key = optional(object({
arn = string
}), null)
}), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{ volume_size = 30 }])
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
ebs_optimized = optional(bool, false)
cloudwatch_agent = optional(object({
enabled = optional(bool, true)
config = optional(string, null)
}), {})
binaries_syncer = optional(object({
enabled = optional(bool, null)
}), {})
detailed_monitoring_enabled = optional(bool, false)
ssm_enabled = optional(bool, false)
user_data = optional(object({
enabled = optional(bool, true)
template = optional(string, null)
content = optional(string, null)
pre_install = optional(string, "")
post_install = optional(string, "")
debug_logging_enabled = optional(bool, false)
}), {})
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_type_priorities = optional(map(number), null)
instance_types = optional(list(string), [])
additional_security_group_ids = optional(list(string), null)
managed_security_group_enabled = optional(bool, null)
egress_rules = optional(list(object({
cidr_blocks = list(string)
ipv6_cidr_blocks = list(string)
prefix_list_ids = list(string)
from_port = number
protocol = string
security_groups = list(string)
self = bool
to_port = number
description = string
})), null)
instance_profile_path = optional(string, null)
key_name = optional(string, null)
associate_public_ipv4_address = optional(bool, null)
instance_profile = optional(object({
name = string
}), null)
on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
subnet_ids = optional(list(string), null)
vpc_id = optional(string, null)
cpu_options = optional(object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), null)
network_interfaces = optional(list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
})), [])
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
tags = optional(map(string), {})
}), null)
microvm = optional(object({
image_arn = optional(string, null)
image_version = optional(string, null)
ingress_network_connectors = optional(list(string), null)
egress_network_connectors = optional(list(string), null)
cloudwatch_agent = optional(object({
enabled = optional(bool, null)
config = optional(string, null)
}), {})
log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
environment_variables = optional(map(string), {})
iam = optional(object({
resource_arns = optional(object({
images = optional(list(string), null)
}), {})
additional_policy_json = optional(object({
scale_up = optional(string, null)
}), {})
managed_policies = optional(object({
scale_up = optional(object({
arn = string
}), null)
pool = optional(object({
arn = string
}), null)
}), {})
}), {})
}), null)
}), {})
}), {})
}))
| `{}` | no | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | diff --git a/modules/runner-config/README.md b/modules/runner-config/README.md index ed2e25ea47..70ece73562 100644 --- a/modules/runner-config/README.md +++ b/modules/runner-config/README.md @@ -119,7 +119,7 @@ yarn run dist |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | AWS partition used to construct ARNs. | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | -| [compute\_provider](#input\_compute\_provider) | Typed compute-provider configuration. Provider-owned settings remain inside the selected compute-provider block.

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

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

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

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

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

- `artifact.s3.bucket`: Optional shared S3 bucket containing component-owned Lambda artifacts. An orchestration provider selects its own object key and version; the bucket alone selects no artifact.
- `runtime`: Runtime used by the control-plane Lambda functions.
- `architecture`: Instruction-set architecture used by the control-plane Lambda functions. Supported values are `arm64` and `x86_64`.
- `subnet_ids`: Subnets used for Lambda VPC configuration.
- `security_group_ids`: Security groups used for Lambda VPC configuration.
- `tags`: Shared tags applied to Lambda function resources only. These override module-level `tags`; component `tags` override this map when keys conflict.
- `principals`: Additional principals allowed to assume the control-plane Lambda roles.
- `role.path`: IAM path for module-managed Lambda execution roles. Defaults to a path derived from `prefix`.
- `role.permissions_boundary`: Permissions-boundary ARN applied to module-managed Lambda execution roles. |
object({
artifact = optional(object({
s3 = optional(object({
bucket = optional(string, null)
}), {})
}), {})
runtime = optional(string, "nodejs24.x")
architecture = optional(string, "arm64")
subnet_ids = optional(list(string), [])
security_group_ids = optional(list(string), [])
tags = optional(map(string), {})
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role = optional(object({
path = optional(string, null)
permissions_boundary = optional(string, null)
}), {})
})
| `{}` | no | diff --git a/modules/runners/README.md b/modules/runners/README.md index 4e4f650bc4..048360449b 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -195,6 +195,7 @@ yarn run dist | [metadata\_options](#input\_metadata\_options) | Metadata options for the ec2 runner instances. By default, the module uses metadata tags for bootstrapping the runner, only disable `instance_metadata_tags` when using custom scripts for starting the runner. | `map(any)` |
{
"http_endpoint": "enabled",
"http_put_response_hop_limit": 1,
"http_tokens": "required",
"instance_metadata_tags": "enabled"
}
| no | | [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. |
object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
})
| `{}` | no | | [minimum\_running\_time\_in\_minutes](#input\_minimum\_running\_time\_in\_minutes) | The time an ec2 action runner should be running at minimum before terminated if non busy. If not set the default is calculated based on the OS. | `number` | `null` | no | +| [network\_interfaces](#input\_network\_interfaces) | Advanced network interface configuration for the runner launch template. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#network-interfaces for details. Leave unset (default) to keep using associate\_public\_ipv4\_address for a simple single-interface setup; set this to fully control one or more interfaces. |
list(object({
associate_carrier_ip_address = optional(bool)
associate_public_ip_address = optional(bool)
delete_on_termination = optional(bool)
description = optional(string)
device_index = optional(number)
interface_type = optional(string)
ipv4_address_count = optional(number)
ipv4_addresses = optional(list(string))
ipv4_prefix_count = optional(number)
ipv4_prefixes = optional(list(string))
ipv6_address_count = optional(number)
ipv6_addresses = optional(list(string))
ipv6_prefix_count = optional(number)
ipv6_prefixes = optional(list(string))
network_card_index = optional(number)
network_interface_id = optional(string)
primary_ipv6 = optional(bool)
private_ip_address = optional(string)
security_groups = optional(list(string))
subnet_id = optional(string)
connection_tracking_specification = optional(object({
tcp_established_timeout = optional(number)
udp_stream_timeout = optional(number)
udp_timeout = optional(number)
}))
ena_srd_specification = optional(object({
ena_srd_enabled = optional(bool)
ena_srd_udp_specification = optional(object({
ena_srd_udp_enabled = optional(bool)
}))
}))
}))
| `[]` | no | | [overrides](#input\_overrides) | This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent. | `map(string)` |
{
"name_runner": "",
"name_sg": ""
}
| no | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | | [placement](#input\_placement) | The placement options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#placement for details. |
object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
})
| `null` | no | diff --git a/modules/webhook/README.md b/modules/webhook/README.md index dd992dbf90..5737ce37ca 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -89,7 +89,7 @@ yarn run dist | [repository\_white\_list](#input\_repository\_white\_list) | List of github repository full names (owner/repo\_name) that will be allowed to use the github app. Leave empty for no filtering. | `list(string)` | `[]` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the environment name will be used. | `string` | `null` | no | | [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` and identifies the compute provider that owns the queue. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type` or `image-version` for `ghr-microvm-image-version`. |
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 | +| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2` and identifies the compute provider that owns the queue. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type` or `image-version` for `ghr-microvm-image-version`. |
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(object({
allowed_keys = optional(list(string), [])
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), 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 | | [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 | From a924d33527f83ac32f1269bd38de9ea30bc409be Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Mon, 21 Sep 2026 19:18:29 +0200 Subject: [PATCH 17/38] fix(ministack): align webhook example provider locks --- .../multi-runner-webhook/.terraform.lock.hcl | 93 +++++++------ .../.terraform.lock.hcl.tofu | 124 +++++++++--------- examples/multi-runner-webhook/versions.tf | 6 +- 3 files changed, 110 insertions(+), 113 deletions(-) diff --git a/examples/multi-runner-webhook/.terraform.lock.hcl b/examples/multi-runner-webhook/.terraform.lock.hcl index 8c974d9a4b..027d36e0d9 100644 --- a/examples/multi-runner-webhook/.terraform.lock.hcl +++ b/examples/multi-runner-webhook/.terraform.lock.hcl @@ -2,70 +2,67 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "6.64.0" + version = "6.66.0" constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" hashes = [ - "h1:2fTLxzUDmp/KVIHbIeLTB4bIzWHx8E6Dw+1ALLUi+Yw=", - "h1:wXARLY+IeQ7ufYxCLTPCwToWGMRvOpiOTfJS97iwUzI=", - "zh:07172315d67bc9781240272759cdfc7bd32b7e72384a56862c2c1da3cca99a81", - "zh:154ce7d2659de9a59ddfe96d7cab41a9ddc2cb267a7d4bcdf4e737ff2ffdec06", - "zh:17324d4335a7a7ac01cc23eded530775606680ff53b47cb74a3cb95d1121f836", - "zh:307ab92324ec5a61b124881ab8cac1d9e316f4527dfd0e1b59794c229407eb4e", - "zh:31e25f1903661332e36a95283042dd3ec50b47c186db00663fbd976a11e6a6b2", - "zh:3311d9f3bd12a24886027dbe73859dcd1e67bd0e3046227a338cf2c7ca04d18e", - "zh:37916156a3aac3b29be3acebd15d53145ea4ab5d4aaa825eaebe75481fa00500", - "zh:4158cb8c38b3ac6aa98eb15935ec6bd7c30838d85d2b00acc9812df8382ae908", - "zh:5bfb9499c66d9db5b34dc5c60f426a1ab1baa5457ce2aefebca826a9c3f92fb0", - "zh:6eb29ead5a4aca3b1f35812e7e8c75419180e1928e479b458f206861277736db", - "zh:7a82b6dd0c0cdef8045a4adfbddd36acb86b6b23fcbed8e189c2d71f7dc4a502", - "zh:9556bd792032c3f7e73ea4dd08cec88dc1327f5a4a57d79c30ba844ae2b9a3c0", + "h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=", + "zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523", + "zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9", + "zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f", + "zh:4070db1ee369ccb41cb610bfd887386bc0a9b9ecad60aeb4dbce58443d2519dd", + "zh:53da7d3c1840ef875c7d34e967732502a64fe677af0e78824773d4c15a8fe740", + "zh:576a93a28bf611a4de2a2e6ced697a41d5126b8fd31d30782b16797e410a9706", + "zh:58fed5fa9a033355b9d4f3092c817b70d934100e0d8678d6e4c93f3c9493d4e4", + "zh:6a9ca2f24e2ee9156dd785d159a850b35d190e9cf7eca21cb9582970c2db80cd", + "zh:729edd30f99cc16009deba5c013265b0c81eda261a3d0821cbd011d3287fd230", + "zh:7ae460049b75bd4aefee465ef7c53a01ac2df46d4d3e3ac00824afa8b5cb83fb", + "zh:9051fa85c8034ade8a57a5c6f232fd33da28f3800bb5aa40bc8625dbc5e27632", + "zh:906547e4319805e7acf7fbdf2bac28a4b1a7370790a2a430c7adb1b29bb934eb", + "zh:998f27410a66158a35ee5ed142c27e5b21fe8601941da55da2157f8042d6dcca", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:c5234180464cb800c83a41f57462742b802c150ad7d4417626fcd9cb511c01d2", - "zh:cd776b83b1f7b36635957350afe7ce28ba4e4ea3a5e2deb00d13dbd3b35d9d40", - "zh:fb583a7b791c6f915b86573d04f05ddbf7f1a5e4120c5d8a7450a3086c1225c4", + "zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f", + "zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283", ] } provider "registry.terraform.io/hashicorp/null" { - version = "3.3.1" + version = "3.3.2" constraints = "~> 3.0, ~> 3.2" hashes = [ - "h1:TuxJq10DVnRP7c5HBZPyyvQGcckNVfijyU1eXEu5e4M=", - "h1:m5FqidbIgh+E9OigiZh8/xbkvpUQFSj3hZo/jqNLCLQ=", - "zh:08c59776542ea16e5a8545752787b17ff412922182b4cfabe16139197be8ac44", - "zh:123109cc7e5ed6d515787fbc212f2a3fd5e75647bb24ab7c801ccd4d4ed42451", - "zh:14b3fa4372754b54844b41d5dbd4671a292d8d6828b90169061feb4d7b15dd05", - "zh:56a4daaa3212f57b764bf3d1f333141c6610c5f21abb240e0111221f7c7fa4d4", + "h1:/Wbz7DHFO92KAqyDBMxGA8nwOJYECM8iLGhR7+wVWhQ=", + "zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08", + "zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55", + "zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27", + "zh:64a8f9ce7852d9efc5b464c12306c946366d59f5e2757def97969c9fd64bd1d6", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7e888a026dbacd2474a42264227ae35f639780f0f0c613529d10a95cd61988b3", - "zh:85a53646267e87d600df7124e4767ffde9bba3b6356d45d961618bdd68131cc7", - "zh:8ffa0e9c7c39b2ab0905b472465d6e35ef0b776b3f6273bb34c150340b61bff1", - "zh:9846510a1841530d4403f4818e233f91e3b3bade7441047599fbf800742f65be", - "zh:afa98d44860875f037c6def0a7e6ff208e042712ba771f620482b143cd336891", - "zh:bdca130d9ef27488ae0b13bc8fd8019e8bbdd4f2ceff29da066bd333165d68c5", - "zh:cb3b94cbca88210dd0d1f11e2b8a89333f48c3857faf8f70f589072ce7c28610", - "zh:f0c0ba87925fe32f84b80f7513b1efb1b0866f51f899ba825e95ad59ff09b018", + "zh:92a374fb736a52f465283326d0a5bf4f495132eb99be209dfb4c75ec803fe8db", + "zh:98da9c42785d27a50f0604758bcb61a30f6278b9f2acd92bb3b2046e0e71916c", + "zh:b0f7896fae554729cdf4a24ac06359a050cff5817e6cd8597cba8a4ae01a7409", + "zh:bc8179ee35d67c72fb03012e7023b9f9816f033a7ec4109c001dd6d29752e812", + "zh:d23a598f713bfb6098bc003571d7de90b5a33b78f9be240488252fe5f3c2a60d", + "zh:d2855b922ea345dbd89ea287e4c6c4757e38bc0aaffeb2b79aa0b8004f9c53ff", + "zh:d3a60422bc6a2f9244d076c5222c07060c826ef91bdbaf4634cb752b86057473", + "zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba", ] } provider "registry.terraform.io/hashicorp/random" { - version = "3.9.0" + version = "3.9.1" constraints = "~> 3.0" hashes = [ - "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", - "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", - "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", - "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", - "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", - "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", - "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", - "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", - "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "h1:PlW+UZ4EElQF3NQwf41KQwavFujab3Czc51zu9dyVM8=", + "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", + "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", + "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", + "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", + "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", + "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", + "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", + "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", + "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", - "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", - "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", - "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", - "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", + "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", + "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", ] } diff --git a/examples/multi-runner-webhook/.terraform.lock.hcl.tofu b/examples/multi-runner-webhook/.terraform.lock.hcl.tofu index 72e73d566d..373b8d1c29 100644 --- a/examples/multi-runner-webhook/.terraform.lock.hcl.tofu +++ b/examples/multi-runner-webhook/.terraform.lock.hcl.tofu @@ -2,39 +2,39 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/aws" { - version = "6.64.0" + version = "6.65.0" constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" hashes = [ - "h1:/G38+XhC1mBVkmeWdtk/wk7lX2BxviJ2XZ70dpoaKKQ=", - "h1:7BzHdGCBG5usqOIhfBq89dkdUopnSo+qe9qRCKDSRHc=", - "h1:8AgY9Hc5/R5j97WgCcDSlbuKk0pjk3vkp7oz4mtGVY8=", - "h1:DKdOy/0RfYLxpzAXBPWTO5Eusvqx5UoGxiq2J0E6DY4=", - "h1:KSwetpR4S2eUsKmHftt73Cbx72lPWYET4V+Ej05rnkI=", - "h1:MEi5Ecge1Uwx/DRGfdVDzV5Q/soRxKh6dBHxUjGdaDQ=", - "h1:VqjWicgPZW32+YnSe0Lo78qq8/24I8XNV+E9d/lBz/4=", - "h1:WBgbFHdg/3ekWoAH6UeKiwfk6iqLr1f7TX9R/mJUK8M=", - "h1:YisB3zMV5Kh6p5/eVuPAAPEmudD/UqGN4C/V3zRtAq4=", - "h1:bG5dXqR4mSlcebUG+anerOWYDyeaScZJeLSJk0cYBfE=", - "h1:iosW/imG2pc4La7qdeM/rK6ldMXhcU6YVW7tjqwNXtI=", - "h1:nKE1gnLZxIoqukQ1YI9EUdmrQIUeAN4PWb5ecN8U9K8=", - "h1:x0hJO5+On8FaKExr4p2cNJhWsNWFZq1EiDD6CfVwy2E=", - "h1:yPH75sRH+f3aJlJAloOL/BikeZV6/0GP8VQvnJoMRKM=", - "h1:zCWB5ZD98/ZC0a50HTGoC/fTAseh189xxCFEL5Mt7r4=", - "zh:06e09ced9480ae12578122f7a25758a15d8fe684da0f6a0a61b9bc2f4a4918ad", - "zh:2035805f0ed8bf81d493e7a52f22965b3d5d402687a95d1caa8c4b1b348c1264", - "zh:25fe72a3d6a330eab6c8957f9e6bdf297ffdce95fa059fef30b80da764bee6b2", - "zh:49df644d19e39b9947e84609260028687057191ddd941783c0211386ade53040", - "zh:4d8438a5d25f18eb376c8375c70f81afb79d0fc1e63ebb6df1d0e02287964dde", - "zh:5cd9717e819506132126a896e959cd4cf1bb213c033c37777c9d01a593937e2c", - "zh:6955caa4f435373ae870de31bdda85e51c60b68c51a4206df5a21b853bcefe21", - "zh:82a413500c35241745e097797610d2bff57c26e29f34ca711182fdde5c265d13", - "zh:831f78acce42a759a977769b0409387ec13ff64b4f46c24eb7e7662e0f352525", - "zh:88648a159119a0435bf86c6cd1f7482dc43dfa2eb742f9b29053da1ee9fdabd8", - "zh:9fc745d71a2e36dbdae0ee69be70675509e5a9dec1a3c5a9be6007e568d78c07", - "zh:bf6d11d6ed1655f61f70eb2906e5d1f7ff5e78b6539dcfb3116ed6f8960c9e20", - "zh:ca17a6ca363afe930ad3474966d39cb549b7f1e5efdca909972dd26f90eefc89", - "zh:d7e9cc87ada1314e6d8ecc5849385a8f8f45757bd2c145b8657f015c65e5078d", - "zh:eddb4d6d86700788d132ba2a83d306646ccb2a3a0cec0a0ab3e215307c61d8f2", + "h1:/D/KChJMHHi6N2Ae8pDT2CoZ1ZVPgmO4DvT3TM1uYdA=", + "h1:0KBMNN4G86DISLGy8e7PdtgcjgLWmFM0Tu/+PlcI6Xc=", + "h1:0xke8tvUJrFES4mVvTaPvBQAad6XfV1kOE7p8i+xN+4=", + "h1:1Ra6ZrgNEnjkReeeNpOEml91kbhCHwoSzV/ZI3yVeNw=", + "h1:1jrxTTKLGfTEPR9BZ6hkGMYcK0ph5bx1dzhSSYtGgsQ=", + "h1:AYFFxtquEyqmRHuAEPx1mOnNzIEGPh5WvKIY+u4HSoA=", + "h1:CXFxyNowi2BGGcMxHPk6+X5wuo7ZAwuK2/OGX9wRve8=", + "h1:UTMijAbC6R9HYu8x6QoQjOKP5EhDB2885v+GjVbOfuk=", + "h1:UUQbMjGJufv6KVfAN1uMVoDkJJV7gL5+qXt8pA82W2w=", + "h1:XOuZUW+/aP5FeEMd25136uGu4Yd3scANUQ3khIePxpw=", + "h1:XkTODDfiyRIzHYCSQX/TxT5A28OJpimgbJvddYSVl0M=", + "h1:bJ3Hx/OsgpaMGLAyu7U3x+IJOOEpynsrp5SAa68/xdQ=", + "h1:e8xYPOcVYj/ke4NwRyOTdy2AH/g1n6I6STENon69LvM=", + "h1:pYM2NuBZ9Yml+TdLZfuTDSjpRR2ZDW2lgljMrSYhbS4=", + "h1:q041n/UFMg+1rQ8ydIoVaHSOYE7qzV/dnTii/eQl2HA=", + "zh:079c8d8adae825fcd81180979a7c87f66eb4c18825dde91790eac178ffa5b506", + "zh:30912d1497c8a5dfe2d26eeab68b1ceb05621a0cc5205a9623fc048c2a34987d", + "zh:33cbbc1fa2df3c80ca026b4073cff1013fd5e648b32ae40d2e2dd7a8892cd6f4", + "zh:6ba73e9aff1762c2ec9f3d4184b49ae7c219bc302fe38aa9c47a3267617c7c7d", + "zh:8581d0ae4ab4bb14b24fe3d4145900cbcbb373271fa327da10f107bff947ee62", + "zh:8d4528d906ebba03c857d2a30cfd75d3f847ff6c1781a57b535fd0edd659f7db", + "zh:9617db8d86e5ec3c4be76c163d3fd0de013cbd804b337368e21d7916aac686bf", + "zh:bbd497e2859a5a09962b1209529a07c8d921493758cd6c8d01200b2ec1f6eba7", + "zh:c446d97456cc9e8adf2a409e0f4ad8ab5c4a54d6b36e3d985a52f6ea7c2603e7", + "zh:c8b005e981e6e8fbb01e9ea2281df977401c236da24e676d98e8c46c2042463b", + "zh:d3c2ecb7647f865b17e93ae6f44fedd50e3d3aa1e7d67336731dc38d6b46a9de", + "zh:d4b0a5ab8625c787e9e706eaa3843a104ac4edaadd4cc46c3f13490c2003fd69", + "zh:da81af555be23b26b6f82352b29bdb904456429b3752b5a574fb7e636a62784b", + "zh:e4d7efff69897583bcf7451f631ae027da528f9355ff72339a5460837856e41b", + "zh:e76114d3e0b20893ce22bd50d7c813842f0747570101e4389b8c7e57cf2a1019", ] } @@ -76,38 +76,38 @@ provider "registry.opentofu.org/hashicorp/null" { } provider "registry.opentofu.org/hashicorp/random" { - version = "3.9.0" + version = "3.9.1" constraints = "~> 3.0" hashes = [ - "h1:8EQU5KSxezcjo/phRSe69rDOI0lk4pSaggj7FsskYp8=", - "h1:Lw9im2VBBJQ3RyAbHPQ0rcvcmmcZWm3x+kIOpN+Tv9s=", - "h1:U8KXqGCoNI9/guYbTvzgdtVk3fRthoG0UXwm1JoEpIs=", - "h1:YXaVd4p6qXPPVaxIBaIDNXmBwT02ZqDn0qD+tYpw8sA=", - "h1:cOpc03fphEt/G9Rfc4jLL/fW0D7tgvlXqiDKPF4vuww=", - "h1:g09RR7T1xWkeGrZwWvWMT9ncJrFGr1k3CBD585UmO7w=", - "h1:gGDdPPibmw2EWROx+sh1RGLjR5+nPwZyrf6/N9jXfeM=", - "h1:haE7/nXCOhXKP4oXeEnER3t5CaVQWqujz4nBnpeTUv4=", - "h1:ieSVpfZS2lKuMr05ph0QsOVpCzg7uk3cgKBaXR+Ikug=", - "h1:ig2s1IS9IzehorRjvVAnKIsUUj8fkgyxct1L/kswcc4=", - "h1:j3lS+ZEERFnoab8t1ppDrScGVP/cgWbzlCrEYKTCXYw=", - "h1:lxezrKmOiQIySHAM+os8qLVq7hqufDr8h3Hpzvsk+78=", - "h1:lzRqBJAG+NETxHbEZUJ/YP3RMEjZBinTX7VmgH3lw60=", - "h1:tdSNWK5ApqUsgbdYieyeYLTu6nIZUV3hR1oFqUfAuGo=", - "h1:xedet8yH/zI2CfdxsGlK0nlFWc/Bp61yrWsEa3fHB8g=", - "zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc", - "zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a", - "zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2", - "zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1", - "zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9", - "zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d", - "zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae", - "zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a", - "zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261", - "zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c", - "zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627", - "zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e", - "zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1", - "zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5", - "zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64", + "h1:38E2VQmQDhws/3AL3D/EzBGuCseepyZRIswAOx8CqoQ=", + "h1:7+qv9kpOpBC9EUPCubnPxh603tu3l9EIMMBkpbt1H1Y=", + "h1:CEQeHfnUDB3uqAkKoEWfWgbj+kpoQHgcuPbAjPzbh+U=", + "h1:HPYO9tf8KUSHqSdz1uOL97MLeaVHPaWPY1JW6tKU19E=", + "h1:KYXiC06Pr3WJcIUbDq9MgdAbInO5zcRyHFqV1x5UcJg=", + "h1:MygjbYH8CrPv8RUe75tZAFmrFNIzQLT45fiyYx7u2tI=", + "h1:RMSARNOw4qZx+VmHYnVMGljsFVfCV1P+nJjKrN2XIOI=", + "h1:U/71jbSbfsfVLxWpSlhyVHh/DnQXQhjoJCGkyongkBA=", + "h1:WwLLvRE1q95CTGxyTjKpctXJ0ooVs9d22JAQS9fC3uo=", + "h1:ZtRBSqoyfQAhngjUjM0NRPtj6NdSJ/JBENFwT8D276s=", + "h1:cfxedZLduhHD1UtqQDjAQZNAEhr/bWDAZ4nU9rSdySg=", + "h1:i45mo4de0QKOreStMqUQ7qyZL3ucFq42l178Fm5/hMU=", + "h1:v3SAJKN4D3dOM95xKwgKGIWELe5nUBbyXdb5HNz7icw=", + "h1:v3vTk/STekrzNc6NG3jL9/05zhvF1QVCgyRRbvSHPcI=", + "h1:zHgFWtRBgOycqhw8HdLSvMVNWGJtIjOAPYOcAdE7cL0=", + "zh:09aaf19b0d22726d2378e0e89fbbefc183494d7bd585759d6c4e69ba50951a2f", + "zh:31575ca9bc0db20337096d178ea73bce3ebca343ed071c67f78cf39f800c9ec6", + "zh:624fb6ed552abc34a5aaac41e76a373da65ac08e524b09b672f29c60e6ac896a", + "zh:6a4760d55132b9750ac1a04f6fc32e247034daa999f71452dba9cbca225a529a", + "zh:768a6047cfb8958e7b0b120c580aa3de6624a7fbb2c56ad6df85cd559ed26ec7", + "zh:8983c788ba660bcb587e64ff9c3e4323515caf78facbe0abe6432e7aff8df893", + "zh:8d570eb026a4f00b58a1d36be0ce3c13adf4d973efcd4162b05cb295bbc14257", + "zh:a2259540854d5f699c36b89244fb202ebb2c219b64669a51072687d04fb47152", + "zh:aaa51d905b0e80a28e02f9bee2cf6c91ffade7389d77ab9198aa12809ed04955", + "zh:afb60995e98573facddfb47baedf7e288408680eb00b5d3df570611758947c72", + "zh:b9a46d852ce53fa037f47537a7de53f37b759ccf211600b7ba44c66ba4b616b7", + "zh:bafcfeeefcd0dfefeff120b655b45edb0497c4717534ffe5201b3cb556d1ffe6", + "zh:c3ac24d397eae054aca2290e20943e0c767592cc661c890850c25ac01829308d", + "zh:eafba4127ebadcc5ed0e427935c66fb5e2da7cfdaae39a66d52f4a50d51faf1e", + "zh:f39d4bce213ed9bba3474bad468136af08ff6c4c33adaafcc10c1f78067adfe3", ] } diff --git a/examples/multi-runner-webhook/versions.tf b/examples/multi-runner-webhook/versions.tf index e4008ada42..6883c62423 100644 --- a/examples/multi-runner-webhook/versions.tf +++ b/examples/multi-runner-webhook/versions.tf @@ -4,9 +4,9 @@ terraform { source = "hashicorp/aws" version = ">= 6.33" } - local = { - source = "hashicorp/local" - version = "~> 2.0" + null = { + source = "hashicorp/null" + version = "~> 3.0" } random = { source = "hashicorp/random" From 820d1d5041ef0fbd1ff2bb65fcf04d70045997e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:23:33 +0000 Subject: [PATCH 18/38] docs: auto update terraform docs --- examples/multi-runner-webhook/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/multi-runner-webhook/README.md b/examples/multi-runner-webhook/README.md index 2f3a3c2989..c6b4cf5e5d 100644 --- a/examples/multi-runner-webhook/README.md +++ b/examples/multi-runner-webhook/README.md @@ -25,7 +25,7 @@ terraform apply \ |------|---------| | [terraform](#requirement\_terraform) | >= 1.5.6 | | [aws](#requirement\_aws) | >= 6.33 | -| [local](#requirement\_local) | ~> 2.0 | +| [null](#requirement\_null) | ~> 3.0 | | [random](#requirement\_random) | ~> 3.0 | ## Providers From 42565a272f54a7f325a187e486c19d6fa75ada5d Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Mon, 21 Sep 2026 21:14:54 +0200 Subject: [PATCH 19/38] fix: fix issue in tf 1.5.6 --- .github/workflows/ministack.yml | 18 +++--------------- .../multi-runner-webhook/.terraform.lock.hcl | 3 +++ mkdocs.yaml | 2 +- modules/multi-runner/webhook.tf | 6 +++--- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 5c39b90e11..6efd8b13e3 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -141,7 +141,7 @@ jobs: timeout-minutes: 30 services: ministack: - image: ghcr.io/ministackorg/ministack:1.5.10@sha256:706b2b83c6be7e4f4dbb6a0dc28ffdebb500c6c80b64cf7938f45040fb2158e8 + image: ministackorg/ministack-preview-build:pr-1790-172234b0 ports: - 4566:4566 options: --add-host=host.docker.internal:host-gateway @@ -159,27 +159,15 @@ jobs: with: persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version-file: lambdas/.nvmrc - package-manager-cache: false - - name: Setup Terraform uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 with: terraform_version: latest terraform_wrapper: false - - name: Install Lambda dependencies - working-directory: lambdas - run: yarn install --frozen-lockfile - - - name: Build smoke-test Lambda distributions - working-directory: lambdas + - name: Build Lambda distributions run: | - yarn workspace @aws-github-runner/webhook dist - yarn workspace @aws-github-runner/control-plane dist + ./.ci/build.sh - name: Start MockServer id: mockserver diff --git a/examples/multi-runner-webhook/.terraform.lock.hcl b/examples/multi-runner-webhook/.terraform.lock.hcl index 027d36e0d9..252cc1596b 100644 --- a/examples/multi-runner-webhook/.terraform.lock.hcl +++ b/examples/multi-runner-webhook/.terraform.lock.hcl @@ -6,6 +6,7 @@ provider "registry.terraform.io/hashicorp/aws" { constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" hashes = [ "h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=", + "h1:hBEaeBm9nm7A/u1nnD0nfolTPP55/BoKRFWk8zG8/fk=", "zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523", "zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9", "zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f", @@ -30,6 +31,7 @@ provider "registry.terraform.io/hashicorp/null" { constraints = "~> 3.0, ~> 3.2" hashes = [ "h1:/Wbz7DHFO92KAqyDBMxGA8nwOJYECM8iLGhR7+wVWhQ=", + "h1:IQ1qrkht1sC1nibUR+AJ3ulryyhVDHfCHZhoJi0sg2Y=", "zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08", "zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55", "zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27", @@ -51,6 +53,7 @@ provider "registry.terraform.io/hashicorp/random" { constraints = "~> 3.0" hashes = [ "h1:PlW+UZ4EElQF3NQwf41KQwavFujab3Czc51zu9dyVM8=", + "h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=", "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", diff --git a/mkdocs.yaml b/mkdocs.yaml index d29d6ee12f..ed4f2246a5 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -80,7 +80,7 @@ nav: - Overview: examples/index.md - Default: examples/default.md - Multi Runner: examples/multi-runner.md - - Multi Runner Webhook: examples/multi-runner-webhook.md + - Multi Runner Webhook: examples/multi-runner-webhook.md - Ephemeral: examples/ephemeral.md - External managed secrets: examples/external-managed-ssm-secrets.md - Custom AMI: examples/prebuilt.md diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index dc1e0505d4..a817f00f35 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -8,10 +8,10 @@ locals { for k, v in local.webhook_runner_config : k => { id = aws_sqs_queue.queued_builds[k].id arn = aws_sqs_queue.queued_builds[k].arn - computeProvider = try(one([ - for provider_type, provider_config in module.runner_configs[k].provider.aws : provider_type + computeProvider = one([ + for provider_type, provider_config in v.compute_provider.aws : provider_type if provider_config != null - ]), "ec2") + ]) matcherConfig = { labelMatchers = v.orchestration_provider.webhook.matcherConfig.labelMatchers exactMatch = v.orchestration_provider.webhook.matcherConfig.exactMatch From d3e6903e7a872636df113a6a04924716829c4f1e Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 22 Sep 2026 10:13:11 +0200 Subject: [PATCH 20/38] fix(storage): recognize wrapped SSM parameter errors --- lambdas/libs/storage-providers/aws/ssm/logger.test.ts | 7 +++++++ lambdas/libs/storage-providers/aws/ssm/logger.ts | 3 +++ .../aws/ssm/runner-group-cache-store.test.ts | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lambdas/libs/storage-providers/aws/ssm/logger.test.ts b/lambdas/libs/storage-providers/aws/ssm/logger.test.ts index 9fcd42af98..e954406b46 100644 --- a/lambdas/libs/storage-providers/aws/ssm/logger.test.ts +++ b/lambdas/libs/storage-providers/aws/ssm/logger.test.ts @@ -28,4 +28,11 @@ describe('AWS SSM storage logger', () => { expect(getErrorNames(error)).toEqual(['GetParameterError', 'ParameterNotFound']); }); + + it('includes the AWS service error type from a wrapped cause', () => { + const cause = Object.assign(new Error('ParameterNotFound'), { __type: 'ParameterNotFound' }); + const error = Object.assign(new Error('wrapped'), { name: 'GetParameterError', cause }); + + expect(getErrorNames(error)).toEqual(['GetParameterError', 'Error', 'ParameterNotFound']); + }); }); diff --git a/lambdas/libs/storage-providers/aws/ssm/logger.ts b/lambdas/libs/storage-providers/aws/ssm/logger.ts index 0e3633eb71..309a5c38ff 100644 --- a/lambdas/libs/storage-providers/aws/ssm/logger.ts +++ b/lambdas/libs/storage-providers/aws/ssm/logger.ts @@ -20,6 +20,9 @@ export function getErrorNames(error: unknown): string[] { if ('name' in current && typeof current.name === 'string') { names.push(current.name); } + if ('__type' in current && typeof current.__type === 'string' && !names.includes(current.__type)) { + names.push(current.__type); + } current = 'cause' in current ? current.cause : undefined; } diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts b/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts index 4fc13926d9..20f4373619 100644 --- a/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts +++ b/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.test.ts @@ -53,7 +53,7 @@ describe('aws_ssm runner group cache store', () => { }); it('returns undefined when ParameterNotFound is wrapped by the SSM provider', async () => { - const cause = Object.assign(new Error('missing'), { name: 'ParameterNotFound' }); + const cause = Object.assign(new Error('ParameterNotFound'), { __type: 'ParameterNotFound' }); getParameterMock.mockRejectedValue( Object.assign(new Error('failed to get parameter'), { name: 'GetParameterError', cause }), ); @@ -64,7 +64,7 @@ describe('aws_ssm runner group cache store', () => { expect.objectContaining({ runnerGroupName: 'Default', parameterName: '/runner/config/runner-group/Default', - errorNames: ['GetParameterError', 'ParameterNotFound'], + errorNames: ['GetParameterError', 'Error', 'ParameterNotFound'], }), ); }); From 2d6ee5c590559d317a4e0d8a16cd2f688b79791f Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 22 Sep 2026 10:19:57 +0200 Subject: [PATCH 21/38] ci: use latest ministack version --- .github/workflows/ministack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 6efd8b13e3..707f3a4c38 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -141,7 +141,7 @@ jobs: timeout-minutes: 30 services: ministack: - image: ministackorg/ministack-preview-build:pr-1790-172234b0 + image: ghcr.io/ministackorg/ministack:1.5.10@sha256:3ab60a21fc04df74522d853141b7a839ada576ce640909b20f5c65475526f25c ports: - 4566:4566 options: --add-host=host.docker.internal:host-gateway From a42e542481ae6b82e17f3bf0fb21c26d203856f8 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 22 Sep 2026 12:15:08 +0200 Subject: [PATCH 22/38] ci: fix ministack job --- .github/workflows/ministack.yml | 4 +- tests/ministack/README.md | 4 +- tests/ministack/smoke/lifecycle.py | 65 +++++++++++++++++++----------- tests/ministack/smoke/microvm.py | 50 ++++++++++++++++------- 4 files changed, 81 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index 707f3a4c38..b4d332af85 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -76,7 +76,7 @@ jobs: - termination-watcher services: ministack: - image: ghcr.io/ministackorg/ministack:1.5.11@sha256:ce3c906f2866ff953ce4c56f06b1fa3e453bc32e41c00de17b5f5a8672c5a42c + image: ghcr.io/ministackorg/ministack:1.5.15@sha256:3ab60a21fc04df74522d853141b7a839ada576ce640909b20f5c65475526f25c ports: - 4566:4566 env: @@ -141,7 +141,7 @@ jobs: timeout-minutes: 30 services: ministack: - image: ghcr.io/ministackorg/ministack:1.5.10@sha256:3ab60a21fc04df74522d853141b7a839ada576ce640909b20f5c65475526f25c + image: ghcr.io/ministackorg/ministack:1.5.15@sha256:3ab60a21fc04df74522d853141b7a839ada576ce640909b20f5c65475526f25c ports: - 4566:4566 options: --add-host=host.docker.internal:host-gateway diff --git a/tests/ministack/README.md b/tests/ministack/README.md index 5af424a1ea..e9f9838407 100644 --- a/tests/ministack/README.md +++ b/tests/ministack/README.md @@ -35,7 +35,7 @@ The script also supports `init`, `plan`, and `destroy`. It creates inert Lambda ZIP fixtures in the paths expected by the modules when they are absent, and removes only the files it created. For `prebuilt`, it seeds AMI metadata through MiniStack's AWS-compatible EC2 API, then removes only the resources it created -during cleanup. MiniStack v1.5.11 provides the EC2 image behavior needed by the +during cleanup. MiniStack v1.5.15 provides the EC2 image behavior needed by the `default`, `ephemeral`, and `multi-runner` examples, so they are included in the same lifecycle matrix. @@ -60,7 +60,7 @@ creates a second provider runner. Installation lookup is mocked for configuratio that do not provide a stored installation ID, but is conditional and is not a required assertion. The test also verifies the `ghr:Application`, `ghr:created_by`, `ghr:Type`, and `ghr:Owner` tags used to discover managed -instances. MiniStack v1.5.10 propagates the Terraform launch-template tags to +instances. MiniStack v1.5.15 propagates the Terraform launch-template tags to instances, allowing the scale-down Lambda to discover and remove each runner. The smoke test invokes scale-down for the webhook and pool-created runners and verifies the GitHub API calls and EC2 termination. diff --git a/tests/ministack/smoke/lifecycle.py b/tests/ministack/smoke/lifecycle.py index 67af4148ef..6d02387b7c 100644 --- a/tests/ministack/smoke/lifecycle.py +++ b/tests/ministack/smoke/lifecycle.py @@ -5,6 +5,8 @@ from .common import SmokeContext from .provider import RunnerResource, SmokeProvider +MOCK_JIT_RUNNER_ID = 987654321 + def _log_group(provider: SmokeProvider, stage: str) -> str: # The webhook and EventBridge dispatcher are shared by all compute @@ -64,32 +66,49 @@ def _pool(context: SmokeContext, provider: SmokeProvider, pool_size: int) -> Run return resource +def _scale_down( + context: SmokeContext, + provider: SmokeProvider, + resource: RunnerResource, + runner_id: int, + marker: str, + check: str, +) -> None: + print(f" {provider.display_name}: scale-down resource {resource.identifier}", flush=True) + provider.scale_down(context, resource, runner_id, marker, [(runner_id, resource)]) + context.mark_check(provider.slug, check) + + def run(context: SmokeContext, provider: SmokeProvider) -> None: print(f"Running {provider.display_name} webhook lifecycle scenarios", flush=True) provider.configure(context) scale_up = _scale_up(context, provider, 123456, False, "scale-up-lambda") + _scale_down( + context, + provider, + scale_up, + MOCK_JIT_RUNNER_ID, + f"multi-runner-webhook-{provider.slug}-scale-up-scale-down", + "scale_down_standard", + ) + dynamic_scale_up = _scale_up(context, provider, 123457, True, "scale-up-lambda") - # The two webhook scale-ups above already create two managed runners. Ask - # the pool to reach three so it has to create the third runner and exercise - # its runner-group/JIT path as well. - pool = _pool(context, provider, pool_size=3) - - scale_down_runners = [ - (987654321, scale_up), - (987654323, dynamic_scale_up), - (987654322, pool), - ] - for resource, runner_id, marker, check in ( - (scale_up, 987654321, f"multi-runner-webhook-{provider.slug}-scale-up-scale-down", "scale_down_standard"), - (dynamic_scale_up, 987654323, f"multi-runner-webhook-{provider.slug}-dynamic-scale-down", "scale_down_dynamic"), - (pool, 987654322, f"multi-runner-webhook-{provider.slug}-pool-scale-down", "scale_down_pool"), - ): - print(f" {provider.display_name}: scale-down resource {resource.identifier}", flush=True) - provider.scale_down(context, resource, runner_id, marker, scale_down_runners) - context.mark_check(provider.slug, check) - scale_down_runners = [ - (active_runner_id, active_resource) - for active_runner_id, active_resource in scale_down_runners - if active_runner_id != runner_id - ] + _scale_down( + context, + provider, + dynamic_scale_up, + MOCK_JIT_RUNNER_ID, + f"multi-runner-webhook-{provider.slug}-dynamic-scale-down", + "scale_down_dynamic", + ) + + pool = _pool(context, provider, pool_size=1) + _scale_down( + context, + provider, + pool, + MOCK_JIT_RUNNER_ID, + f"multi-runner-webhook-{provider.slug}-pool-scale-down", + "scale_down_pool", + ) diff --git a/tests/ministack/smoke/microvm.py b/tests/ministack/smoke/microvm.py index eb75d5be51..2db48e1ea5 100644 --- a/tests/ministack/smoke/microvm.py +++ b/tests/ministack/smoke/microvm.py @@ -12,15 +12,11 @@ class MicrovmProvider: display_name = "MicroVM" image_arn = "arn:aws:lambda:eu-west-1:000000000000:microvm-image:ministack" image_version = "3.0" + metadata_path = "/github-action-runners/multi-runner-webhook/microvm/runners/config/microvm-metadata" def configure(self, context: SmokeContext) -> None: context.configure_jit_expectations() - microvms = context.aws("lambda-microvms", "list-microvms", check=False) or {} - context.before_microvm_ids = { - item["microvmId"] - for item in microvms.get("items", []) - if item.get("microvmId") - } + context.before_microvm_ids = set(self._metadata_by_path(context)) def event(self, context: SmokeContext, job_id: int, dynamic: bool) -> dict[str, Any]: value = json.loads((context.script_dir / "workflow_job_event.json").read_text()) @@ -43,24 +39,48 @@ def verify_pool_routes(self, context: SmokeContext) -> None: def _metadata(self, context: SmokeContext, microvm_id: str) -> dict[str, Any]: value = context.aws( "ssm", "get-parameter", - "--name", f"/github-action-runners/multi-runner-webhook/microvm/runners/config/microvm-metadata/{microvm_id}", + "--name", f"{self.metadata_path}/{microvm_id}", check=False, ) if not value or value.get("Parameter", {}).get("Value") in (None, "None"): raise RuntimeError(f"Missing MicroVM ownership metadata for {microvm_id}") return json.loads(value["Parameter"]["Value"]) + def _metadata_by_path(self, context: SmokeContext) -> dict[str, dict[str, Any]]: + result = context.aws( + "ssm", + "get-parameters-by-path", + "--path", + self.metadata_path, + check=False, + ) or {} + prefix = f"{self.metadata_path}/" + metadata: dict[str, dict[str, Any]] = {} + for parameter in result.get("Parameters", []): + name = parameter.get("Name", "") + if not name.startswith(prefix): + continue + microvm_id = name[len(prefix):] + if "." in microvm_id: + continue + value = parameter.get("Value") + if value in (None, "None"): + continue + metadata[microvm_id] = json.loads(value) + return metadata + def _wait_for_microvm(self, context: SmokeContext, source: str, description: str) -> RunnerResource: def find() -> str | None: - result = context.aws("lambda-microvms", "list-microvms", check=False) or {} - for item in result.get("items", []): - microvm_id = item.get("microvmId") - if not microvm_id or microvm_id in context.before_microvm_ids: + for microvm_id, metadata in self._metadata_by_path(context).items(): + if microvm_id in context.before_microvm_ids or microvm_id in context.discovered_microvm_ids: + continue + if metadata.get("source") != source: + continue + details = self._details(context, RunnerResource(microvm_id)) + if details.get("state") not in ("PENDING", "RUNNING", "SUSPENDING", "SUSPENDED"): continue - if self._metadata(context, microvm_id).get("source") == source: - if microvm_id not in context.discovered_microvm_ids: - context.discovered_microvm_ids.append(microvm_id) - return microvm_id + context.discovered_microvm_ids.append(microvm_id) + return microvm_id return None return RunnerResource(context.wait_for(find, description)) From f0299db913c586c52e48a63d435df566242e187a Mon Sep 17 00:00:00 2001 From: Ederson Brilhante Date: Tue, 22 Sep 2026 16:55:09 +0200 Subject: [PATCH 23/38] docs(microvm): explain image and runtime dependencies (#5457) ## Description Documents the deployment dependencies for Lambda MicroVM runners after #5280. - Distinguishes the MicroVM image build role from the runner execution role. - Explains that the build role creates and publishes the image, while the execution role is passed by the control-plane TypeScript to `RunMicrovm` and runs the ephemeral job. - Documents the dependency order: apply the MicroVM foundation, build and release the lifecycle-hook server, build the image with Packer, and deploy the runner control plane. - Points to the repository examples for the foundation, image, lifecycle-hook, and combined EC2/MicroVM webhook deployment. - Documents that the lifecycle-hook server is built and released through the existing Lambda artifact workflow, then embedded in the MicroVM image. ## Test Plan - Ran `git diff --check`. - Reviewed the documented role ownership and commands against the foundation, MicroVM provider, image, lifecycle-hook, and multi-runner example implementations. - Confirmed the change is documentation-only; no infrastructure was deployed. ## Related Issues - Stacked on #5280. - Related foundation work: #5377. - Related MicroVM provider work: #5413 and #5255. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/microvm-runners.md | 129 ++++++++++++++++++ examples/microvm-foundation/README.md | 22 ++- examples/multi-runner-webhook/README.md | 28 +++- images/microvm-ubuntu/README.md | 21 ++- .../microvm-lifecycle-hooks/README.md | 14 ++ mkdocs.yaml | 1 + modules/microvm-foundation/README.md | 18 +++ 7 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 docs/microvm-runners.md diff --git a/docs/microvm-runners.md b/docs/microvm-runners.md new file mode 100644 index 0000000000..872dd52de0 --- /dev/null +++ b/docs/microvm-runners.md @@ -0,0 +1,129 @@ +# Lambda MicroVM Runners (Experimental) + +!!! warning + Lambda MicroVM runner support is experimental. The image build, lifecycle-hook server, control-plane integration, and AWS MicroVM APIs must be configured together. Validate the complete flow in a non-production environment before relying on it for workloads. + +## Overview + +Lambda MicroVM runners provide ephemeral GitHub Actions runners backed by +Lambda MicroVMs. The runner control plane receives demand, obtains the +one-time runner configuration, starts a MicroVM from a published image, and +passes the runtime execution role to the MicroVM. + +The repository includes a combined [multi-runner webhook example](examples/multi-runner-webhook.md) +that places EC2 and Lambda MicroVM lanes behind one webhook endpoint. The +provider-specific lifecycle checks are shared where possible, so the same +deployment can validate both providers. + +## Prerequisites + +Before deploying the MicroVM runner lane, prepare all of the following in the +target AWS Region: + +1. **MicroVM foundation.** Apply the + [MicroVM foundation example](examples/microvm-foundation.md). It creates the + regional artifact bucket, Lambda Network Connectors, the image-build role, + and the reusable MicroVM usage policy. +2. **Lifecycle-hook artifact.** Build and release + `lambdas/services/microvm-lifecycle-hooks` through the same workspace + artifact process used for the repository's Lambda services. The resulting + ZIP is embedded in the MicroVM image. +3. **Published MicroVM image.** Use the + [MicroVM Ubuntu image instructions](https://github.com/github-aws-runners/terraform-aws-github-runner/blob/main/images/microvm-ubuntu/README.md) + to build and publish an image with Packer. The image must contain the + compatible lifecycle-hook server and runner entrypoint. +4. **Runner execution role.** Configure the runner role through the runner + configuration. This is different from the foundation's build role. The + control-plane TypeScript passes the execution role to `RunMicrovm`, so the + Lambda that starts the MicroVM must have permission to pass it. +5. **Runner control plane and artifacts.** Deploy the runner control plane with + the published image ARN/version, Network Connector ARNs, GitHub App + configuration, and the runner-control and webhook Lambda ZIPs. + +The foundation does not create the image or the runner execution role. The +image build does not choose the runtime role. These are separate dependencies +owned by the image build and runner-control-plane stages respectively. + +## IAM roles + +MicroVM deployments use two roles for two different operations: + +| Role | Used by | Responsibility | +| --- | --- | --- | +| Build role (`build_role_arn`) | Packer/image publisher | Creates and publishes the MicroVM image and accesses the foundation build artifacts. | +| Execution role | Runner control plane and the MicroVM | Is passed to `RunMicrovm` and provides the permissions used by the ephemeral runner at runtime. | + +Do not use the build role as the runner execution role. The control-plane +Lambda needs `iam:PassRole` for the configured execution role, and the +execution role must contain the runtime permissions required by the selected +runner lane. + +## Deployment order + +The complete dependency chain is: + +```text +MicroVM foundation + | + v +Build/release lifecycle-hook server + | + v +Packer builds and publishes image + | + v +Runner control plane resolves execution role + | + v +RunMicrovm starts an ephemeral runner +``` + +The lifecycle-hook server is part of the image artifact. Updating the hook +server therefore requires building/releasing the artifact and publishing a +new compatible image before deploying that image version to the runner lane. + +## Combined EC2 and MicroVM deployment + +The [multi-runner webhook example](examples/multi-runner-webhook.md) accepts +explicit `runners_lambda_zip` and `webhook_lambda_zip` inputs and configures +both compute providers behind one webhook. Its MicroVM settings require a +published image: + +```hcl +compute_provider = { + aws = { + microvm = { + image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:gha-ubuntu-arm64" + image_version = null + ingress_network_connectors = [] + egress_network_connectors = ["arn:aws:lambda:eu-west-1:123456789012:network-connector:example"] + } + } +} +``` + +Use the example's complete Terraform configuration as the source of truth for +the current input shape. The example deploys the control plane; it does not +build the foundation, lifecycle-hook artifact, or MicroVM image for you. + +## Known limitations + +- This integration is experimental and depends on AWS Lambda MicroVM APIs and + the lifecycle-hook protocol. +- A compatible lifecycle-hook server must be present in every image used by + the MicroVM provider. +- Image publication and activation are separate from Terraform deployment; + wait for the image version to become active before starting jobs. +- The build role and execution role are intentionally separate. Changes to + either role can affect a different stage of the lifecycle. +- The combined webhook example is useful for integration testing, but a real + deployment still needs a real MicroVM image and the network/runtime IAM + configuration described above. + +## Repository examples + +- [MicroVM foundation](examples/microvm-foundation.md) +- [MicroVM image build README](https://github.com/github-aws-runners/terraform-aws-github-runner/blob/main/images/microvm-ubuntu/README.md) +- [Lifecycle-hook service README](https://github.com/github-aws-runners/terraform-aws-github-runner/blob/main/lambdas/services/microvm-lifecycle-hooks/README.md) +- [Multi-runner webhook](examples/multi-runner-webhook.md) +- [MicroVM foundation module](modules/public/microvm-foundation.md) diff --git a/examples/microvm-foundation/README.md b/examples/microvm-foundation/README.md index 7417f3c70e..c52c872d47 100644 --- a/examples/microvm-foundation/README.md +++ b/examples/microvm-foundation/README.md @@ -21,10 +21,26 @@ documented in `../../images/microvm-ubuntu/README.md`. Use the outputs as the bu - `connector_arns.ministack` -> `MICROVM_EGRESS_NETWORK_CONNECTOR_ARN` - `usage_policy_arn` -> attach to the control-plane role used by the runner example +The deployment order is: + +1. Apply this foundation to create the regional bucket, Network Connectors, + build role, and reusable runtime policy. +2. Build and release the lifecycle-hook service from + `lambdas/services/microvm-lifecycle-hooks` using the repository's normal + Lambda artifact process. +3. Build and publish the MicroVM image with Packer, passing the foundation + outputs and the released lifecycle-hook ZIP. The image builder uses the + **build role**. +4. Deploy the runner control plane, such as + `examples/multi-runner-webhook`, with the published image ARN/version. The + control plane resolves the **execution role** from the runner configuration + and passes it to `RunMicrovm` when it starts a job. + +The two roles must not be conflated: the build role creates the image, while +the execution role runs the ephemeral GitHub Actions runner inside that image. The foundation module owns regional storage, build IAM, Network Connectors, -and the reusable runtime policy. It does not publish an image or create the -runner control plane; those steps remain explicit and can be performed after -the foundation is available. +and the reusable runtime policy. It does not publish an image, create the +execution role, or create the runner control plane. ## Requirements diff --git a/examples/multi-runner-webhook/README.md b/examples/multi-runner-webhook/README.md index c6b4cf5e5d..fc653a2f70 100644 --- a/examples/multi-runner-webhook/README.md +++ b/examples/multi-runner-webhook/README.md @@ -18,6 +18,32 @@ terraform apply \ -var='webhook_lambda_zip=/path/to/webhook.zip' ``` +## MicroVM prerequisites + +The MicroVM lane expects an image that has already been built and published in +the target Region. The image is not created by this example. Prepare it in +this order: + +1. Apply `examples/microvm-foundation`. +2. Build and release the lifecycle-hook service from + `lambdas/services/microvm-lifecycle-hooks` using the same artifact process + used for the repository's Lambda services. +3. Build the image with Packer from `images/microvm-ubuntu`, passing the + foundation's bucket, connector, build-role, and lifecycle-hook artifact. +4. Set `compute_provider.aws.microvm.image_arn` (and, when applicable, + `image_version`) to the published image. + +The foundation's build role is used to create the image. It is different from +the execution role used by the runner job. The runner configuration owns that +execution role; the control-plane TypeScript passes it to `RunMicrovm` when it +starts an ephemeral runner. The control-plane Lambda therefore needs +permission to pass the configured execution role, and the role needs the +runtime permissions required by the selected runner lane. + +This example deploys both EC2 and MicroVM lanes behind one webhook endpoint, +but it does not replace the foundation, image build, lifecycle-hook release, +or execution-role setup steps. + ## Requirements @@ -61,4 +87,4 @@ No resources. |------|-------------| | [webhook\_endpoint](#output\_webhook\_endpoint) | n/a | | [webhook\_secret](#output\_webhook\_secret) | n/a | - \ No newline at end of file + diff --git a/images/microvm-ubuntu/README.md b/images/microvm-ubuntu/README.md index 84feeb24b4..218d79efab 100644 --- a/images/microvm-ubuntu/README.md +++ b/images/microvm-ubuntu/README.md @@ -7,8 +7,11 @@ Dockerfile, compiled lifecycle-hook ZIP contract, and image entrypoint. Before building the image: 1. Apply `examples/microvm-foundation` in the target AWS Region. -2. Install Packer and set the required AWS, S3, IAM, connector, and - lifecycle-hook variables. +2. Build and release the lifecycle-hook service from + `lambdas/services/microvm-lifecycle-hooks`, then set + `MICROVM_LIFECYCLE_HOOK_ZIP` to the released artifact. +3. Install Packer and set the required AWS, S3, IAM, connector, and image + variables. The image intentionally excludes the source repository's optional external telemetry and Teleport services. It contains only the Actions runner, @@ -40,7 +43,13 @@ packer build -color=false github_agent.microvm.ubuntu.pkr.hcl ``` The build role, artifact bucket, and network connector are created by the -foundation module. Keep the bucket private and versioned, use the module's -least-privilege policies, and do not put credentials in checked-in files. The -lifecycle-hook ZIP must contain the compiled `server.js` at its archive root; -any bundled dependencies must use safe relative paths. +foundation module. The build role is used only while Packer creates and +publishes the image; it is not baked into the image and is not the role used +by runner jobs. The execution role is selected by the runner control plane and +passed to `RunMicrovm` at launch time, so it is not configured by this image +build. + +Keep the bucket private and versioned, use the module's least-privilege +policies, and do not put credentials in checked-in files. The lifecycle-hook +ZIP must contain the compiled `server.js` at its archive root; any bundled +dependencies must use safe relative paths. diff --git a/lambdas/services/microvm-lifecycle-hooks/README.md b/lambdas/services/microvm-lifecycle-hooks/README.md index 5f1481c370..01a16e7732 100644 --- a/lambdas/services/microvm-lifecycle-hooks/README.md +++ b/lambdas/services/microvm-lifecycle-hooks/README.md @@ -14,6 +14,20 @@ yarn workspace @aws-github-runner/microvm-lifecycle-hooks start `build` uses esbuild to create the self-contained CommonJS server bundle `dist/server.js`. It also writes `dist/package.json` with `type: commonjs` so the bundle remains executable after it is copied outside the Yarn workspace. +## Build and release with the Lambda artifacts + +The lifecycle-hook server is a deployable image-build artifact, not a service +that is installed separately beside the runner control plane. Build and test it +through the normal Lambda workspace/release process, then provide the resulting +ZIP to the MicroVM image build as `MICROVM_LIFECYCLE_HOOK_ZIP`. Packer embeds +that artifact in the image; every published image used by the MicroVM provider +must contain a compatible hook server. + +The hook server's release lifecycle is therefore separate from the MicroVM +execution role. The image build uses the foundation's build role. When a job +starts, the runner control plane supplies the runtime execution role to +`RunMicrovm`. + To build before invoking Docker, run the workspace build above. In the existing MicroVM runner Dockerfile, which already installs s6-overlay and the GitHub runner's Node 24 runtime, copy the complete artifact and replace the old hook command with: ```dockerfile diff --git a/mkdocs.yaml b/mkdocs.yaml index ed4f2246a5..289ca9b955 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -60,6 +60,7 @@ nav: - Multi-runner v1 to v2 migration: multi-runner-v1-v2-migration.md - Getting started: getting-started.md - Security: security.md + - Lambda MicroVM runners (experimental): microvm-runners.md - Architecture decisions: - MiniStack for integration tests: adr/0001-use-ministack-for-terraform-integration-tests.md - Modules: diff --git a/modules/microvm-foundation/README.md b/modules/microvm-foundation/README.md index d87227c89f..9680f03622 100644 --- a/modules/microvm-foundation/README.md +++ b/modules/microvm-foundation/README.md @@ -12,6 +12,24 @@ It manages: - A Lambda-trusted Network Connector operator role and propagation barrier. - An unattached runtime usage policy for the reserved image namespace and connector inventory. +## Build role and execution role + +MicroVM deployments use two different IAM roles with different lifecycles: + +- The `build_role_arn` output is the **build role**. The image builder assumes + this role while it creates and publishes a MicroVM image. It grants the + image-build permissions for the foundation artifact bucket, logs, and any + configured ECR repositories. It is not the role used by a runner job. +- The **execution role** is attached to each MicroVM when the runner control + plane launches it. The control-plane TypeScript passes this role to + `RunMicrovm`; the Lambda that calls that API must be allowed to pass the + role. The MicroVM and the ephemeral runner use this role at runtime. + +The execution role is resolved by the runner configuration and is intentionally +not created by this foundation module. The foundation creates the regional +build resources and the reusable `usage_policy_arn`; the runner/control-plane +configuration owns the runtime role and its provider-specific permissions. + The module does not create MicroVM images, runner execution roles, or the runner control plane. Attach `usage_policy_arn` to the control-plane role that owns the runtime launch operations. The caller must also grant the Terraform From 3dd87d0b759ca16d891fed0582129979b399b81b Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 22 Sep 2026 18:01:33 +0200 Subject: [PATCH 24/38] ci: fix dist --- .../services/microvm-lifecycle-hooks/package.json | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/lambdas/services/microvm-lifecycle-hooks/package.json b/lambdas/services/microvm-lifecycle-hooks/package.json index 388cad6613..663194be7e 100644 --- a/lambdas/services/microvm-lifecycle-hooks/package.json +++ b/lambdas/services/microvm-lifecycle-hooks/package.json @@ -18,6 +18,7 @@ "test:watch": "NODE_ENV=test nx test --watch", "lint": "eslint src", "build": "node build.mjs", + "dist": "yarn build && cd dist && zip ../microvm-lifecycle-hooks.zip *", "format": "prettier --write \"**/*.{ts,json,md}\"", "format-check": "prettier --check \"**/*.{ts,json,md}\"", "all": "yarn build && yarn format && yarn lint && yarn test" @@ -30,23 +31,14 @@ "@aws-github-runner/storage-providers": "*" }, "nx": { - "targets": { - "build": { - "inputs": [ - "default", - "^default" - ], - "outputs": [ - "{projectRoot}/dist/**/*" - ] - } - }, "includedScripts": [ "build", + "dist", "format", "format-check", "lint", "start", + "watch", "all" ] } From c9aeb9b3f9faf25bc569cffa7ce975b6bb3fbea8 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Tue, 22 Sep 2026 21:48:19 +0200 Subject: [PATCH 25/38] feat(microvm): add lifecycle hook runner startup --- .../microvm/src/control-plane/config.test.ts | 5 +- .../aws/microvm/src/control-plane/config.ts | 4 +- .../aws/microvm/src/control-plane/microvms.ts | 2 +- .../src/control-plane/runner-config.test.ts | 18 +- .../src/control-plane/runner-config.ts | 26 +- .../src/lifecycle.test.ts | 37 ++- .../microvm-lifecycle-hooks/src/lifecycle.ts | 74 ++++- .../src/payload.test.ts | 14 +- .../microvm-lifecycle-hooks/src/payload.ts | 17 +- .../src/processes.test.ts | 88 +++--- .../microvm-lifecycle-hooks/src/processes.ts | 266 +++++++++++------- .../microvm-lifecycle-hooks/src/public.ts | 2 +- .../microvm-lifecycle-hooks/src/server.ts | 6 +- 13 files changed, 339 insertions(+), 220 deletions(-) diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts index e58d73093c..84a0c5e0fd 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts @@ -7,10 +7,10 @@ const cleanEnv = process.env; beforeEach(() => { process.env = { ...cleanEnv }; process.env.MICROVM_IMAGE_ARN = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner'; + process.env.MICROVM_IMAGE_VERSION = '2.0'; process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/microvm-runner'; process.env.MICROVM_METADATA_SSM_PATH = '/github-action-runners/unit-test/microvm-metadata/'; process.env.SSM_TOKEN_PATH = '/github-action-runners/unit-test/token/'; - delete process.env.MICROVM_IMAGE_VERSION; delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS; delete process.env.MICROVM_EGRESS_NETWORK_CONNECTORS; delete process.env.MICROVM_LOG_GROUP; @@ -20,7 +20,7 @@ describe('loadMicrovmProviderConfig', () => { it('loads required values and applies optional defaults', () => { expect(loadMicrovmProviderConfig()).toEqual({ imageIdentifier: process.env.MICROVM_IMAGE_ARN, - imageVersion: undefined, + imageVersion: '2.0', executionRoleArn: process.env.MICROVM_EXECUTION_ROLE_ARN, ingressNetworkConnectors: undefined, egressNetworkConnectors: undefined, @@ -46,6 +46,7 @@ describe('loadMicrovmProviderConfig', () => { it.each([ ['MICROVM_IMAGE_ARN', 'MICROVM_IMAGE_ARN'], + ['MICROVM_IMAGE_VERSION', 'MICROVM_IMAGE_VERSION'], ['MICROVM_EXECUTION_ROLE_ARN', 'MICROVM_EXECUTION_ROLE_ARN'], ['MICROVM_METADATA_SSM_PATH', 'MICROVM_METADATA_SSM_PATH'], ['SSM_TOKEN_PATH', 'SSM_TOKEN_PATH'], diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts index b86331967b..9baf16c1d7 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts @@ -4,7 +4,7 @@ export interface MicrovmProviderConfig { egressNetworkConnectors?: string[]; executionRoleArn: string; imageIdentifier: string; - imageVersion?: string; + imageVersion: string; ingressNetworkConnectors?: string[]; logging?: Logging; metadataSsmPath: string; @@ -61,7 +61,7 @@ export function loadMicrovmProviderConfig(): MicrovmProviderConfig { return { imageIdentifier: requiredEnvironmentValue('MICROVM_IMAGE_ARN', process.env.MICROVM_IMAGE_ARN), - imageVersion: optionalEnvironmentValue(process.env.MICROVM_IMAGE_VERSION), + imageVersion: requiredEnvironmentValue('MICROVM_IMAGE_VERSION', process.env.MICROVM_IMAGE_VERSION), executionRoleArn: requiredEnvironmentValue('MICROVM_EXECUTION_ROLE_ARN', process.env.MICROVM_EXECUTION_ROLE_ARN), ingressNetworkConnectors: parseNetworkConnectors( 'MICROVM_INGRESS_NETWORK_CONNECTORS', diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts index ace08450f3..ced9b18a4f 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/microvms.ts @@ -88,7 +88,7 @@ export async function runMicrovmRunner(input: RunMicrovmRunnerInput): Promise { }); }); - it('requires the image ARN and version to be provided together', () => { - expect(() => - createMicrovmRunHookPayload({ - imageArn, - runnerConfigSsmPath, - runnerTokenSsmPath, - }), - ).toThrow('MicroVM hook payload image ARN and version must be provided together'); - }); - - it('omits image metadata when no explicit image version is selected', () => { - expect(JSON.parse(createMicrovmRunHookPayload({ runnerConfigSsmPath, runnerTokenSsmPath }))).toEqual({ - version: 1, - runnerConfigSsmPath, - runnerTokenSsmPath, - }); - }); }); describe('createMicrovmRunners', () => { @@ -150,6 +133,7 @@ describe('createMicrovmRunners', () => { it('rejects a metadata path that overlaps the JIT token path', async () => { vi.mocked(loadMicrovmProviderConfig).mockReturnValue({ imageIdentifier: imageArn, + imageVersion: '2.0', executionRoleArn: 'arn:aws:iam::123456789012:role/microvm-runner', metadataSsmPath: '/github-action-runners/unit-test/token/metadata', runnerTokenSsmPath, diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts index 9d7154707b..76c1d9fe8f 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.ts @@ -27,28 +27,18 @@ const MICROVM_METADATA_CONTEXT_TAG_KEYS = new Set([ ]); export interface MicrovmRunHookPayloadV1 { - imageArn?: string; - imageVersion?: string; + imageArn: string; + imageVersion: string; runnerConfigSsmPath: string; runnerTokenSsmPath: string; version: 1; } export function createMicrovmRunHookPayload(payload: Omit): string { - const hasImageArn = payload.imageArn !== undefined; - const hasImageVersion = payload.imageVersion !== undefined; - if (hasImageArn !== hasImageVersion) { - throw new Error('MicroVM hook payload image ARN and version must be provided together'); - } - return JSON.stringify({ version: 1, - ...(hasImageArn - ? { - imageArn: payload.imageArn, - imageVersion: payload.imageVersion, - } - : {}), + imageArn: payload.imageArn, + imageVersion: payload.imageVersion, runnerConfigSsmPath: payload.runnerConfigSsmPath, runnerTokenSsmPath: payload.runnerTokenSsmPath, } satisfies MicrovmRunHookPayloadV1); @@ -140,12 +130,8 @@ export async function createMicrovmRunners( nonRetryableErrorCount: 0, }; const runHookPayload = createMicrovmRunHookPayload({ - ...(config.imageVersion !== undefined - ? { - imageArn: config.imageIdentifier, - imageVersion: config.imageVersion, - } - : {}), + imageArn: config.imageIdentifier, + imageVersion: config.imageVersion, runnerConfigSsmPath: normalizedRunnerConfigPath, runnerTokenSsmPath: normalizedRunnerTokenPath, }); diff --git a/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.test.ts index ea7bc02e73..5357716db0 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.test.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.test.ts @@ -17,7 +17,10 @@ function runRequest(): string { return JSON.stringify({ microvmId: MICROVM_ID, runHookPayload: JSON.stringify({ - runnerConfigSsmPath: '/runner/token', + imageArn: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', + imageVersion: '8.0', + runnerConfigSsmPath: '/runner/config', + runnerTokenSsmPath: '/runner/token', version: 1, }), }); @@ -115,7 +118,7 @@ describe('RunnerLifecycle', () => { await lifecycle.stop(); }); - it('returns to idle if the configured entrypoint cannot launch', async () => { + it('returns to idle if the GitHub Actions runner cannot launch', async () => { const consume = vi.fn().mockResolvedValue({ jitConfig: 'encoded-jit' }); const lifecycle = new RunnerLifecycle( { consume }, @@ -132,6 +135,34 @@ describe('RunnerLifecycle', () => { expect(consume).toHaveBeenCalledTimes(2); }); + it('logs the startup stage and safe error details without exposing internal messages', async () => { + const messages: unknown[] = []; + const logger: Logger = { + error: (...values) => messages.push(...values), + info: () => undefined, + warn: () => undefined, + }; + const error = new Error('encoded-jit-secret'); + error.name = 'encoded-jit-secret-name'; + Object.assign(error, { code: 'encoded-jit-secret-code' }); + const lifecycle = new RunnerLifecycle( + { + consume: async () => { + throw error; + }, + }, + { launch: () => new DeferredProcess() }, + logger, + ); + + await expect(lifecycle.start(runRequest())).rejects.toBe(error); + + const serializedMessages = JSON.stringify(messages); + expect(serializedMessages).toContain('consume runner configuration'); + expect(serializedMessages).toContain('unknown-error'); + expect(serializedMessages).not.toContain('encoded-jit-secret'); + }); + it('aborts in-flight consumption when the run-hook deadline elapses', async () => { let consumedSignal: AbortSignal | undefined; let launched = false; @@ -166,7 +197,7 @@ describe('RunnerLifecycle', () => { expect(launched).toBe(false); }); - it('waits for cleanup when terminate races with entrypoint readiness', async () => { + it('waits for cleanup when terminate races with the runner launch handoff', async () => { let finishCleanup = (): void => undefined; let reportLaunched = (): void => undefined; let stopCalled = false; diff --git a/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.ts b/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.ts index 4eae1aa091..201a5d5282 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/lifecycle.ts @@ -5,6 +5,66 @@ import { beforeDeadline, beforeDeadlineOrAbort } from './timing'; type LifecycleState = 'idle' | 'starting' | 'running' | 'stopping' | 'stopped'; +const SAFE_ERROR_NAMES = new Set([ + 'AccessDeniedException', + 'AbortError', + 'ExpiredTokenException', + 'InternalServerError', + 'InvalidKeyId', + 'KMSInvalidStateException', + 'ParameterNotFound', + 'ResourceNotFoundException', + 'TimeoutError', + 'ThrottlingException', +]); + +const SAFE_ERROR_CODES = new Set(['EACCES', 'EINVAL', 'ENOENT', 'EPERM', 'ETIMEDOUT']); + +const SAFE_ERROR_MESSAGES = new Set([ + 'operation was cancelled', + 'run-hook deadline elapsed', + 'GitHub Actions runner exited before the launch handoff', + 'runner launch was cancelled', + 'runner start was cancelled', +]); + +interface DiagnosticError extends Error { + code?: unknown; + $metadata?: unknown; +} + +function safeErrorDetails(error: unknown): Record { + if (!(error instanceof Error)) { + return { errorType: typeof error }; + } + + const diagnosticError = error as DiagnosticError; + const details: Record = { + errorName: SAFE_ERROR_NAMES.has(error.name) ? error.name : 'unknown-error', + }; + if (SAFE_ERROR_CODES.has(diagnosticError.code as string)) { + details.errorCode = diagnosticError.code; + } + if (SAFE_ERROR_MESSAGES.has(error.message)) { + details.errorMessage = error.message; + } + + if (diagnosticError.$metadata !== null && typeof diagnosticError.$metadata === 'object') { + const metadata = diagnosticError.$metadata as Record; + if (typeof metadata.httpStatusCode === 'number' && Number.isInteger(metadata.httpStatusCode)) { + details.httpStatusCode = metadata.httpStatusCode; + } + if (typeof metadata.attempts === 'number' && Number.isInteger(metadata.attempts)) { + details.awsAttempts = metadata.attempts; + } + if (typeof metadata.totalRetryDelay === 'number' && Number.isInteger(metadata.totalRetryDelay)) { + details.awsRetryDelayMs = metadata.totalRetryDelay; + } + } + + return details; +} + function boundedNumber(value: string | undefined, fallback: number, minimum: number, maximum: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? Math.max(minimum, Math.min(maximum, parsed)) : fallback; @@ -84,7 +144,9 @@ export class RunnerLifecycle { ): Promise { let bootstrap: RunnerBootstrap | undefined; let processHandle: ManagedProcess | undefined; + let stage = 'consume runner configuration'; try { + this.logger.info('Lifecycle hook consuming runner configuration for MicroVM %s', context.microvmId); bootstrap = await this.jitConfigSource.consume(context, { deadlineMs: deadlineMs - this.launchReserveMs, signal: abort.signal, @@ -93,7 +155,12 @@ export class RunnerLifecycle { throw new Error('runner start was cancelled'); } + stage = 'launch GitHub Actions runner'; + this.logger.info('Lifecycle hook launching GitHub Actions runner for MicroVM %s', context.microvmId); processHandle = this.launcher.launch(bootstrap, context.microvmId); + + stage = 'wait for runner launch handoff'; + this.logger.info('Lifecycle hook waiting for runner launch handoff for MicroVM %s', context.microvmId); await beforeDeadlineOrAbort(processHandle.ready, deadlineMs, abort.signal); if (abort.signal.aborted || this.state !== 'starting') { throw new Error('runner start was cancelled'); @@ -102,9 +169,14 @@ export class RunnerLifecycle { this.runner = processHandle; this.startAbort = undefined; this.state = 'running'; - this.logger.info('GitHub Actions runner started for MicroVM %s', context.microvmId); + this.logger.info('GitHub Actions runner launch handed off for MicroVM %s', context.microvmId); void this.monitorRunner(processHandle); } catch (error) { + this.logger.error('Lifecycle hook runner startup failed', { + microvmId: context.microvmId, + stage, + ...safeErrorDetails(error), + }); if (processHandle !== undefined) { await processHandle.stop(); } diff --git a/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts index 170be297c0..0f4af68f9f 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/payload.test.ts @@ -7,7 +7,10 @@ const SSM_STORAGE = { } as const; function request( payload: object = { - runnerConfigSsmPath: '/github-action-runners/tenant/token', + imageArn: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', + imageVersion: '8.0', + runnerConfigSsmPath: '/github-action-runners/tenant/config', + runnerTokenSsmPath: '/github-action-runners/tenant/token', version: 1, }, microvmId = MICROVM_ID, @@ -19,7 +22,7 @@ function request( } describe('parseRunRequest', () => { - it('maps the strict version 1 payload to the allowlisted SSM storage environment', () => { + it('maps the producer version 1 payload to the allowlisted SSM storage environment', () => { expect(parseRunRequest(request())).toEqual({ microvmId: MICROVM_ID, storage: SSM_STORAGE, @@ -30,7 +33,10 @@ describe('parseRunRequest', () => { expect( parseRunRequest( request({ - runnerConfigSsmPath: '/github-action-runners/tenant/token/', + imageArn: 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner', + imageVersion: '8.0', + runnerConfigSsmPath: '/github-action-runners/tenant/config/', + runnerTokenSsmPath: '/github-action-runners/tenant/token/', version: 1, }), ).storage, @@ -60,7 +66,7 @@ describe('parseRunRequest', () => { ['invalid outer JSON', '{'], ['an invalid MicroVM identifier', request(undefined, '../vm')], ['an overlong MicroVM identifier', request(undefined, 'a'.repeat(257))], - ['an unversioned payload', request({ runnerConfigSsmPath: '/runner/token' })], + ['an unversioned payload', request({ runnerConfigSsmPath: '/runner/config', runnerTokenSsmPath: '/runner/token' })], ['a relative legacy SSM path', request({ runnerConfigSsmPath: 'runner/token', version: 1 })], ['a root legacy SSM path', request({ runnerConfigSsmPath: '/', version: 1 })], ['repeated legacy SSM slashes', request({ runnerConfigSsmPath: '/runner//token', version: 1 })], diff --git a/lambdas/services/microvm-lifecycle-hooks/src/payload.ts b/lambdas/services/microvm-lifecycle-hooks/src/payload.ts index 5dcc60daf9..830a2b7eed 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/payload.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/payload.ts @@ -23,7 +23,10 @@ interface LambdaRunRequest { interface VersionedRunPayload { version?: unknown; + imageArn?: unknown; + imageVersion?: unknown; runnerConfigSsmPath?: unknown; + runnerTokenSsmPath?: unknown; context?: unknown; } @@ -49,6 +52,10 @@ function hasExactKeys(value: object, expected: readonly string[]): boolean { return keys.length === expected.length && keys.every((key) => expected.includes(key)); } +function hasOnlyKeys(value: object, allowed: readonly string[]): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + function isObject(value: unknown): value is object { return value !== null && typeof value === 'object' && !Array.isArray(value); } @@ -73,14 +80,20 @@ export function parseRunRequest(body: string): RunContext { const payload = parseObject(request.runHookPayload, 'runHookPayload must contain valid JSON'); if (payload.version === 1) { - if (!hasExactKeys(payload, ['version', 'runnerConfigSsmPath'])) { + if ( + !hasOnlyKeys(payload, ['version', 'imageArn', 'imageVersion', 'runnerConfigSsmPath', 'runnerTokenSsmPath']) || + typeof payload.imageArn !== 'string' || + typeof payload.imageVersion !== 'string' || + typeof payload.runnerConfigSsmPath !== 'string' || + typeof payload.runnerTokenSsmPath !== 'string' + ) { throw new HookRequestError('version 1 runHookPayload contains unsupported or missing fields'); } return { microvmId: request.microvmId, storage: parseStorageContext({ RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', - SSM_TOKEN_PATH: payload.runnerConfigSsmPath, + SSM_TOKEN_PATH: payload.runnerTokenSsmPath, }), }; } diff --git a/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts index e56027cf96..9a1da0717b 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts @@ -2,50 +2,45 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { RunnerEntrypointLauncher } from './processes'; +import { GitHubRunnerLauncher } from './processes'; afterEach(() => { vi.unstubAllEnvs(); }); -describe('RunnerEntrypointLauncher', () => { - it('passes the MicroVM id and one-time JIT only through stdin', async () => { - const directory = await mkdtemp(join(tmpdir(), 'microvm-entrypoint-')); - const entrypoint = join(directory, 'entrypoint.sh'); +describe('GitHubRunnerLauncher', () => { + it('launches run.sh directly with the JIT config and a sanitized environment', async () => { + const directory = await mkdtemp(join(tmpdir(), 'microvm-runner-')); const output = join(directory, 'output'); const environmentOutput = join(directory, 'environment-output'); + const runner = join(directory, 'run.sh'); await writeFile( - entrypoint, + runner, `#!/bin/sh set -eu -case "$1" in - run) - cat > "$TEST_ENTRYPOINT_OUTPUT" - printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' \ - "\${ENCODED_JIT_CONFIG-unset}" \ - "\${AWS_ACCESS_KEY_ID-unset}" \ - "\${AWS_SESSION_TOKEN-unset}" \ - "\${AWS_CONTAINER_CREDENTIALS_FULL_URI-unset}" \ - "\${AWS_PROFILE-unset}" \ - "\${AWS_DEFAULT_PROFILE-unset}" \ - "\${AWS_CONFIG_FILE-unset}" \ - "\${AWS_SHARED_CREDENTIALS_FILE-unset}" \ - "\${AWS_CREDENTIAL_EXPIRATION-unset}" \ - "\${RUNNER_CONFIG_STORAGE_PROVIDER-unset}" \ - "\${SSM_TOKEN_PATH-unset}" \ - "\${RUNNER_ALLOW_RUNASROOT-unset}" > "$TEST_ENTRYPOINT_ENV_OUTPUT" - printf 'ready\n' >&3 - ;; - *) exit 2 ;; -esac +printf '%s|%s|%s' "$1" "$2" "$MICROVM_ID" > "$TEST_RUNNER_OUTPUT" +printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' \ + "\${ENCODED_JIT_CONFIG-unset}" \ + "\${AWS_ACCESS_KEY_ID-unset}" \ + "\${AWS_SESSION_TOKEN-unset}" \ + "\${AWS_CONTAINER_CREDENTIALS_FULL_URI-unset}" \ + "\${AWS_PROFILE-unset}" \ + "\${AWS_DEFAULT_PROFILE-unset}" \ + "\${AWS_CONFIG_FILE-unset}" \ + "\${AWS_SHARED_CREDENTIALS_FILE-unset}" \ + "\${AWS_CREDENTIAL_EXPIRATION-unset}" \ + "\${RUNNER_CONFIG_STORAGE_PROVIDER-unset}" \ + "\${SSM_TOKEN_PATH-unset}" \ + "\${RUNNER_ALLOW_RUNASROOT-unset}" > "$TEST_RUNNER_ENV_OUTPUT" +sleep 0.2 `, { mode: 0o700 }, ); - vi.stubEnv('RUNNER_ENTRYPOINT', entrypoint); - vi.stubEnv('TEST_ENTRYPOINT_OUTPUT', output); - vi.stubEnv('TEST_ENTRYPOINT_ENV_OUTPUT', environmentOutput); + vi.stubEnv('RUNNER_ROOT', directory); + vi.stubEnv('TEST_RUNNER_OUTPUT', output); + vi.stubEnv('TEST_RUNNER_ENV_OUTPUT', environmentOutput); vi.stubEnv('ENCODED_JIT_CONFIG', 'test-value'); vi.stubEnv('AWS_ACCESS_KEY_ID', 'test-value'); vi.stubEnv('AWS_SESSION_TOKEN', 'test-value'); @@ -59,15 +54,11 @@ esac vi.stubEnv('SSM_TOKEN_PATH', '/runner/token'); vi.stubEnv('RUNNER_ALLOW_RUNASROOT', '1'); try { - const processHandle = new RunnerEntrypointLauncher().launch({ jitConfig: 'encoded-jit' }, 'mvm-1234'); + const processHandle = new GitHubRunnerLauncher(30_000, 10).launch({ jitConfig: 'encoded-jit' }, 'mvm-1234'); await processHandle.ready; await expect(processHandle.exit).resolves.toBe(0); - expect(JSON.parse(await readFile(output, 'utf8'))).toEqual({ - jitConfig: 'encoded-jit', - microvmId: 'mvm-1234', - version: 1, - }); + expect(await readFile(output, 'utf8')).toBe('--jitconfig|encoded-jit|mvm-1234'); expect(await readFile(environmentOutput, 'utf8')).toBe( 'unset|unset|unset|unset|unset|unset|unset|unset|unset|unset|unset|unset', ); @@ -76,29 +67,16 @@ esac } }); - it('requires the entrypoint to signal readiness before it exits', async () => { - const directory = await mkdtemp(join(tmpdir(), 'microvm-entrypoint-')); - const entrypoint = join(directory, 'entrypoint.sh'); - await writeFile( - entrypoint, - `#!/bin/sh -set -eu -case "$1" in - run) - cat >/dev/null - exit 7 - ;; - *) exit 2 ;; -esac -`, - { mode: 0o700 }, - ); + it('rejects readiness when run.sh exits before the launch handoff', async () => { + const directory = await mkdtemp(join(tmpdir(), 'microvm-runner-')); + const runner = join(directory, 'run.sh'); - vi.stubEnv('RUNNER_ENTRYPOINT', entrypoint); + await writeFile(runner, '#!/bin/sh\nexit 7\n', { mode: 0o700 }); + vi.stubEnv('RUNNER_ROOT', directory); try { - const processHandle = new RunnerEntrypointLauncher().launch({ jitConfig: 'encoded-jit' }, 'mvm-1234'); + const processHandle = new GitHubRunnerLauncher().launch({ jitConfig: 'encoded-jit' }, 'mvm-1234'); - await expect(processHandle.ready).rejects.toThrow('exited before signaling readiness'); + await expect(processHandle.ready).rejects.toThrow('exited before the launch handoff'); await expect(processHandle.exit).resolves.toBe(7); } finally { await rm(directory, { force: true, recursive: true }); diff --git a/lambdas/services/microvm-lifecycle-hooks/src/processes.ts b/lambdas/services/microvm-lifecycle-hooks/src/processes.ts index c3670a3c71..616ff99550 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/processes.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/processes.ts @@ -1,10 +1,15 @@ import { type ChildProcess, spawn } from 'node:child_process'; -import { Readable } from 'node:stream'; +import { isAbsolute, join } from 'node:path'; import type { ManagedProcess, RunnerBootstrap, RunnerLauncher } from './contracts'; import { delay } from './timing'; -const CREDENTIAL_ENVIRONMENT_VARIABLES = [ +const LAUNCH_HANDOFF_DELAY_MS = 1_000; +const MAX_POSIX_ID = 2_147_483_647; +const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +const ALWAYS_DENIED_RUNNER_ENVIRONMENT = new Set([ + 'ACTIONS_RUNNER_INPUT_JITCONFIG', 'AWS_ACCESS_KEY_ID', 'AWS_CONFIG_FILE', 'AWS_CONTAINER_AUTHORIZATION_TOKEN', @@ -17,14 +22,23 @@ const CREDENTIAL_ENVIRONMENT_VARIABLES = [ 'AWS_ROLE_ARN', 'AWS_SECRET_ACCESS_KEY', 'AWS_SECURITY_TOKEN', - 'AWS_SHARED_CREDENTIALS_FILE', 'AWS_SESSION_TOKEN', + 'AWS_SHARED_CREDENTIALS_FILE', 'AWS_WEB_IDENTITY_TOKEN_FILE', 'ENCODED_JIT_CONFIG', - 'RUNNER_CONFIG_STORAGE_PROVIDER', + 'INTERNAL_SERVICES', + 'JIT_CONFIG', + 'MICROVM_RUNNER_ENV_DENYLIST', 'RUNNER_ALLOW_RUNASROOT', + 'RUNNER_CONFIG_SSM_ARN', + 'RUNNER_CONFIG_SSM_PATH', + 'RUNNER_CONFIG_STORAGE_PROVIDER', + 'RUNNER_TOKEN_SSM_PATH', 'SSM_TOKEN_PATH', -] as const; + 'bootstrap_payload', + 'encoded_jit_config', + 'jit_config', +]); function signalProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void { if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) { @@ -39,6 +53,121 @@ function signalProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void { } } +function parsePosixId(variable: string, fallback: number): number { + const value = process.env[variable] ?? String(fallback); + if (!/^\d+$/.test(value)) { + throw new Error(`${variable} must be a positive integer`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > MAX_POSIX_ID) { + throw new Error(`${variable} must be a positive integer`); + } + return parsed; +} + +function runnerIdentity(): { gid?: number; uid?: number } { + if (process.getuid?.() !== 0) { + return {}; + } + return { + gid: parsePosixId('RUNNER_GID', 1_000), + uid: parsePosixId('RUNNER_UID', 1_000), + }; +} + +function runnerEnvironmentDenylist(): Set { + const configured = process.env.MICROVM_RUNNER_ENV_DENYLIST; + if (configured === undefined || configured.trim() === '') { + return new Set(ALWAYS_DENIED_RUNNER_ENVIRONMENT); + } + + const denylist = new Set(ALWAYS_DENIED_RUNNER_ENVIRONMENT); + for (const name of configured.split(',')) { + const normalized = name.trim(); + if (!ENVIRONMENT_NAME_PATTERN.test(normalized)) { + throw new Error('MICROVM_RUNNER_ENV_DENYLIST contains an invalid environment name'); + } + denylist.add(normalized); + } + return denylist; +} + +function runnerEnvironment(microvmId: string, denylist: ReadonlySet): NodeJS.ProcessEnv { + const environment = { ...process.env }; + for (const name of denylist) { + delete environment[name]; + } + return { + ...environment, + HOME: process.env.RUNNER_HOME ?? '/home/runner', + LOGNAME: process.env.RUNNER_USER ?? 'runner', + MICROVM_ID: microvmId, + USER: process.env.RUNNER_USER ?? 'runner', + }; +} + +function redactSpawnArguments(child: ChildProcess, arguments_: string[], sensitiveValue: string): void { + for (let index = 0; index < arguments_.length; index += 1) { + if (arguments_[index] === sensitiveValue) { + arguments_[index] = '[redacted]'; + } + } + for (let index = 0; index < child.spawnargs.length; index += 1) { + if (child.spawnargs[index] === sensitiveValue) { + child.spawnargs[index] = '[redacted]'; + } + } +} + +function waitForLaunchHandoff(child: ChildProcess, handoffDelayMs: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let handoffTimer: NodeJS.Timeout | undefined; + + const cleanup = (): void => { + child.off('error', onError); + child.off('spawn', onSpawn); + child.off('exit', onExit); + if (handoffTimer !== undefined) { + clearTimeout(handoffTimer); + } + }; + + const fail = (error: Error): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + + const commit = (): void => { + if (settled) { + return; + } + if (child.exitCode !== null || child.signalCode !== null) { + fail(new Error('GitHub Actions runner exited before the launch handoff')); + return; + } + settled = true; + cleanup(); + child.unref(); + resolve(); + }; + + const onError = (error: Error): void => fail(error); + const onExit = (): void => fail(new Error('GitHub Actions runner exited before the launch handoff')); + const onSpawn = (): void => { + handoffTimer = setTimeout(commit, handoffDelayMs); + }; + + child.once('error', onError); + child.once('spawn', onSpawn); + child.once('exit', onExit); + }); +} + export class NodeManagedProcess implements ManagedProcess { public readonly ready: Promise; public readonly exit: Promise; @@ -72,116 +201,35 @@ export class NodeManagedProcess implements ManagedProcess { } } -function entrypointEnvironment(microvmId: string): NodeJS.ProcessEnv { - const environment = { ...process.env }; - for (const variable of CREDENTIAL_ENVIRONMENT_VARIABLES) { - delete environment[variable]; - } - return { - ...environment, - MICROVM_ID: microvmId, - }; -} - -function waitForEntrypointReady(child: ChildProcess): Promise { - const candidate = child.stdio[3]; - if (!(candidate instanceof Readable)) { - return Promise.reject(new Error('runner entrypoint readiness pipe is unavailable')); - } - const readinessStream: Readable = candidate; - readinessStream.setEncoding('utf8'); - - return new Promise((resolve, reject) => { - let buffer = ''; - let settled = false; - - function cleanup(): void { - readinessStream.off('data', onData); - readinessStream.off('end', onEnd); - readinessStream.off('error', onError); - child.off('error', onError); - } - - function succeed(): void { - if (!settled) { - settled = true; - cleanup(); - resolve(); - } - } - - function fail(error: Error): void { - if (!settled) { - settled = true; - cleanup(); - reject(error); - } - } +/** Launches the GitHub Actions runner directly from the image's runner installation. */ +export class GitHubRunnerLauncher implements RunnerLauncher { + private readonly denylist = runnerEnvironmentDenylist(); + private readonly runnerRoot = process.env.RUNNER_ROOT ?? '/opt/actions-runner'; + private readonly identity = runnerIdentity(); - function onData(chunk: string | Buffer): void { - buffer += chunk.toString(); - if (buffer === 'ready\n') { - succeed(); - } else if (buffer.includes('\n') || buffer.length > 64) { - fail(new Error('runner entrypoint emitted an invalid readiness signal')); - } - } - - function onEnd(): void { - fail(new Error('runner entrypoint exited before signaling readiness')); - } - - function onError(error: Error): void { - fail(error); + public constructor( + private readonly stopGraceMs = 30_000, + private readonly handoffDelayMs = LAUNCH_HANDOFF_DELAY_MS, + ) { + if (!isAbsolute(this.runnerRoot)) { + throw new Error('RUNNER_ROOT must be an absolute path'); } - - readinessStream.on('data', onData); - readinessStream.once('end', onEnd); - readinessStream.once('error', onError); - child.once('error', onError); - }); -} - -/** - * Sends the one-time JIT document through stdin to an image-specific supervisor. - * Neither the JIT document nor storage-provider credentials are exported to the runner. - */ -export class RunnerEntrypointLauncher implements RunnerLauncher { - private readonly entrypoint = process.env.RUNNER_ENTRYPOINT ?? '/opt/microvm/entrypoint.sh'; - - public constructor(private readonly stopGraceMs = 30_000) {} + } public launch(bootstrap: RunnerBootstrap, microvmId: string): ManagedProcess { - const child = spawn(this.entrypoint, ['run'], { + const runner = join(this.runnerRoot, 'run.sh'); + const arguments_ = ['--jitconfig', bootstrap.jitConfig]; + const child = spawn(runner, arguments_, { + cwd: this.runnerRoot, detached: true, - env: entrypointEnvironment(microvmId), - stdio: ['pipe', 'inherit', 'inherit', 'pipe'], + env: runnerEnvironment(microvmId, this.denylist), + shell: false, + stdio: ['ignore', 'inherit', 'inherit'], + ...this.identity, }); - const entrypointReady = waitForEntrypointReady(child); - const inputWritten = new Promise((resolve, reject) => { - const fail = (error: Error): void => reject(error); - child.once('error', fail); - child.once('spawn', () => { - if (child.stdin === null) { - reject(new Error('runner entrypoint stdin is unavailable')); - return; - } - child.stdin.once('error', fail); - child.stdin.end( - JSON.stringify({ - jitConfig: bootstrap.jitConfig, - microvmId, - version: 1, - }), - () => { - child.removeListener('error', fail); - child.stdin?.removeListener('error', fail); - resolve(); - }, - ); - }); - }); - const ready = Promise.all([inputWritten, entrypointReady]).then(() => undefined); + redactSpawnArguments(child, arguments_, bootstrap.jitConfig); + + const ready = waitForLaunchHandoff(child, this.handoffDelayMs); return new NodeManagedProcess(child, ready, this.stopGraceMs); } } diff --git a/lambdas/services/microvm-lifecycle-hooks/src/public.ts b/lambdas/services/microvm-lifecycle-hooks/src/public.ts index 32b385b15c..a82479fecd 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/public.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/public.ts @@ -10,7 +10,7 @@ export type { export { consoleLogger } from './contracts'; export { RunnerLifecycle } from './lifecycle'; export { HookRequestError, MAX_REQUEST_BYTES, parseRunRequest } from './payload'; -export { NodeManagedProcess, RunnerEntrypointLauncher } from './processes'; +export { GitHubRunnerLauncher, NodeManagedProcess } from './processes'; export { createHookExitRequester, createDefaultLifecycle, diff --git a/lambdas/services/microvm-lifecycle-hooks/src/server.ts b/lambdas/services/microvm-lifecycle-hooks/src/server.ts index fe735653e7..c37fd39886 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/server.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/server.ts @@ -4,7 +4,7 @@ import type { Logger } from './contracts'; import { consoleLogger } from './contracts'; import { RunnerLifecycle } from './lifecycle'; import { HookRequestError, MAX_REQUEST_BYTES } from './payload'; -import { RunnerEntrypointLauncher } from './processes'; +import { GitHubRunnerLauncher } from './processes'; import { StorageJitConfigSource } from './storage'; export const HOOK_PREFIX = '/aws/lambda-microvms/runtime/v1'; @@ -185,7 +185,7 @@ export function createHookServer( } export function createDefaultLifecycle(logger: Logger = consoleLogger): RunnerLifecycle { - return new RunnerLifecycle(new StorageJitConfigSource(), new RunnerEntrypointLauncher(), logger); + return new RunnerLifecycle(new StorageJitConfigSource(), new GitHubRunnerLauncher(), logger); } interface ClosableServer { @@ -222,7 +222,7 @@ export function watchRunnerCompletion( } else { logger.error('GitHub Actions runner exited unexpectedly with status %s', runnerExitCode ?? 'signal'); } - // Let the /run handler flush its acknowledgement if the runner exits immediately after readiness. + // Let the /run handler flush its acknowledgement if the runner exits immediately after handoff. setImmediate(() => requestExit(exitCode)); }); } From 1951d82962795adc25462beadff2f10c1fb64311 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 23 Sep 2026 14:57:29 +0200 Subject: [PATCH 26/38] test: fix smoke test --- .github/workflows/smoke-tests.yml | 15 +- .gitignore | 3 + examples/microvm-foundation/variables.tf | 2 +- .../multi-runner-webhook/.terraform.lock.hcl | 23 +- examples/multi-runner-webhook/main.tf | 10 +- examples/multi-runner-webhook/microvm.tf | 74 +++++ examples/multi-runner-webhook/outputs.tf | 4 + .../microvm-lifecycle-hooks/README.md | 43 ++- modules/microvm-foundation/storage.tf | 2 + modules/microvm-foundation/variables.tf | 26 +- .../tests/config-resolution.tftest.hcl | 2 +- tests/ministack/README.md | 17 ++ tests/ministack/run-example.sh | 8 +- tests/ministack/smoke/README.md | 259 ++++++++++++++++ tests/ministack/smoke/common.py | 63 +++- tests/ministack/smoke/ec2.py | 27 +- .../fixtures}/github-api-expectations.json | 0 .../fixtures}/workflow_job_event.json | 0 tests/ministack/smoke/lifecycle.py | 2 + tests/ministack/smoke/microvm.py | 287 +++++++++++++++++- tests/ministack/smoke/provider.py | 3 + 21 files changed, 807 insertions(+), 63 deletions(-) create mode 100644 examples/multi-runner-webhook/microvm.tf create mode 100644 tests/ministack/smoke/README.md rename tests/ministack/{ => smoke/fixtures}/github-api-expectations.json (100%) rename tests/ministack/{ => smoke/fixtures}/workflow_job_event.json (100%) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 5b519480c3..cc8067c663 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -2,7 +2,13 @@ name: "Smoke Tests" on: pull_request: - paths: ["**/*.tf", "**/*.hcl", ".github/workflows/smoke-tests.yml"] + paths: + - "**/*.tf" + - "**/*.hcl" + - "images/microvm-ubuntu/**" + - "lambdas/**" + - "tests/ministack/**" + - ".github/workflows/smoke-tests.yml" workflow_dispatch: concurrency: @@ -26,10 +32,10 @@ jobs: control_plane_smoke: name: Run webhook and pool lifecycle smoke test against MiniStack runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 120 services: ministack: - image: ghcr.io/ministackorg/ministack:1.5.13@sha256:ce3c906f2866ff953ce4c56f06b1fa3e453bc32e41c00de17b5f5a8672c5a42c + image: ministackorg/ministack-preview-build:pr-1808-d140da92 ports: - 4566:4566 options: --add-host=host.docker.internal:host-gateway @@ -66,8 +72,7 @@ jobs: - name: Build smoke-test Lambda distributions working-directory: lambdas run: | - yarn workspace @aws-github-runner/webhook dist - yarn workspace @aws-github-runner/control-plane dist + yarn dist - name: Start MockServer id: mockserver diff --git a/.gitignore b/.gitignore index 276fe10733..5ce4651603 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ secrets.auto.tfvars node_modules/ site/ + +__pycache__/ +ministack-smoke-checklist.txt \ No newline at end of file diff --git a/examples/microvm-foundation/variables.tf b/examples/microvm-foundation/variables.tf index 9163e2e81f..0f05980124 100644 --- a/examples/microvm-foundation/variables.tf +++ b/examples/microvm-foundation/variables.tf @@ -64,4 +64,4 @@ variable "ecr_repository_arns" { type = set(string) description = "Optional private ECR repository ARNs used by the image build." default = [] -} +} \ No newline at end of file diff --git a/examples/multi-runner-webhook/.terraform.lock.hcl b/examples/multi-runner-webhook/.terraform.lock.hcl index 252cc1596b..b8fbf6c256 100644 --- a/examples/multi-runner-webhook/.terraform.lock.hcl +++ b/examples/multi-runner-webhook/.terraform.lock.hcl @@ -3,7 +3,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.66.0" - constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" + constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0, >= 6.61.0" hashes = [ "h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=", "h1:hBEaeBm9nm7A/u1nnD0nfolTPP55/BoKRFWk8zG8/fk=", @@ -69,3 +69,24 @@ provider "registry.terraform.io/hashicorp/random" { "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", ] } + +provider "registry.terraform.io/hashicorp/time" { + version = "0.14.2" + constraints = ">= 0.13.0" + hashes = [ + "h1:gnP2hptiFIHSHUFBvAFKhE/Yh5u5yVEx+P7XSB58A/E=", + "zh:0aa1028d91041f4dceba193e3707dac57358d0063d97e20700e554758b67baca", + "zh:32bee9f2b2678e2a0789ad86e716d09ca1d5450180b3cd8033ee7a251bfd352e", + "zh:3aded9ef4dc6f4aec202a50c68a08b40013d325f9947f10168ebc8bee54109fc", + "zh:4d924637f3115ffa4ffc7f16d3f366bc472594f7447d400adb9def7ac92e3fc8", + "zh:5c35008e1363deafaa440ab43519409866dd7ec72aabeb2317bc16cd82756784", + "zh:6b30d97c9827501d7010fe49c8889d7a6cc8b45b77cdb1668c65af7f28a27d73", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:851e0f0e9c4de395e42220de51e7fb20d0e845e629f3cf37056da19e597304e7", + "zh:85622b4779b3ba7424780f7f8efcfa585227e245201cdb68303f2118b388e971", + "zh:9039153e3d45147183804188a1ce36c3811db9a4dca80f36fa382408d5f50b72", + "zh:a3d385413dc258a53fe8f4ded5d1fcd1eba715c65a015d74e70911bab2919207", + "zh:b9310a2327f7c8ad2aa3266f89c4d026b4bf18b09f7c4257c0ecd71a32f32db3", + "zh:cee7f2143da0c494115da94984bf3630e3e5855e27dae720a7bc29bb6acd9be2", + ] +} diff --git a/examples/multi-runner-webhook/main.tf b/examples/multi-runner-webhook/main.tf index 09ecab8674..5b1b8b94c6 100644 --- a/examples/multi-runner-webhook/main.tf +++ b/examples/multi-runner-webhook/main.tf @@ -85,9 +85,13 @@ module "runners" { } } - global_config_ssm = { - paths = { - root = "/github-action-runners/${var.environment}" + global_config_storage_provider = { + aws = { + ssm = { + paths = { + root = "/github-action-runners/${var.environment}" + } + } } } diff --git a/examples/multi-runner-webhook/microvm.tf b/examples/multi-runner-webhook/microvm.tf new file mode 100644 index 0000000000..fa2a39e758 --- /dev/null +++ b/examples/multi-runner-webhook/microvm.tf @@ -0,0 +1,74 @@ +resource "aws_cloudwatch_log_group" "aws_cloudwatch_log_group_microvm" { + name = "/aws/lambda/microvms/ubuntu24" +} + +resource "aws_ecr_repository" "base_ubuntu24" { + name = "base-ubuntu24" + force_delete = true +} + + +data "aws_iam_policy_document" "ecr_repository_policy" { + + statement { + effect = "Allow" + actions = [ + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:DescribeImages", + "ecr:GetAuthorizationToken", + "ecr:ListImages" + ] + + principals { + type = "AWS" + identifiers = [data.aws_caller_identity.current.account_id] + } + } +} + +resource "aws_ecr_repository_policy" "repository_policy" { + repository = "base-ubuntu24" + policy = data.aws_iam_policy_document.ecr_repository_policy.json +} + + +locals { + network_connectors = { + ministack = { + name = "ministack" + vpc_id = module.base.vpc.vpc_id + subnet_ids = module.base.vpc.private_subnets + } + } +} + +data "aws_caller_identity" "current" {} + +module "microvm_foundation" { + source = "../../modules/microvm-foundation" + + aws_region = var.aws_region + tags = { + Component = "microvm-foundation" + } + build_policy_name_prefix = "gha-microvm-build-policy-" + build_role_name_prefix = "gha-microvm-build-" + network_connector_operator_role_name_prefix = "gha-microvm-network-operator-" + usage_policy_name_prefix = "gha-microvm-runtime-usage-policy-" + artifact_bucket_name = "ministack-microvm-artifacts-${var.aws_region}" + artifact_retention_days = 30 + image_name_prefix = "gha-ubuntu-arm64" + ecr_repository_arns = ["arn:aws:ecr:${var.aws_region}:${data.aws_caller_identity.current.account_id}:repository/base-ubuntu24"] + network_connectors = local.network_connectors + force_destroy_artifact_bucket = true +} + +locals { + microvm = { + ecr_repo = aws_ecr_repository.base_ubuntu24.repository_url + log_group = aws_cloudwatch_log_group.aws_cloudwatch_log_group_microvm.name + microvm_foundation = module.microvm_foundation + } +} \ No newline at end of file diff --git a/examples/multi-runner-webhook/outputs.tf b/examples/multi-runner-webhook/outputs.tf index f1e5f7cdb5..333ef57ea6 100644 --- a/examples/multi-runner-webhook/outputs.tf +++ b/examples/multi-runner-webhook/outputs.tf @@ -5,3 +5,7 @@ output "webhook_secret" { sensitive = true value = var.github_app.webhook_secret } + +output "microvm" { + value = local.microvm +} \ No newline at end of file diff --git a/lambdas/services/microvm-lifecycle-hooks/README.md b/lambdas/services/microvm-lifecycle-hooks/README.md index 01a16e7732..075de5295c 100644 --- a/lambdas/services/microvm-lifecycle-hooks/README.md +++ b/lambdas/services/microvm-lifecycle-hooks/README.md @@ -32,7 +32,6 @@ To build before invoking Docker, run the workspace build above. In the existing ```dockerfile COPY lambdas/services/microvm-lifecycle-hooks/dist/ /opt/microvm-lifecycle-hooks/ -ENV RUNNER_ENTRYPOINT=/opt/microvm/entrypoint.sh ENTRYPOINT ["/init"] CMD ["/command/with-contenv", "/opt/actions-runner/externals/node24/bin/node", "/opt/microvm-lifecycle-hooks/server.js"] ``` @@ -58,7 +57,6 @@ Then copy the builder output into the existing final runner stage and use its su COPY --from=lifecycle-build \ /source/lambdas/services/microvm-lifecycle-hooks/dist/ \ /opt/microvm-lifecycle-hooks/ -ENV RUNNER_ENTRYPOINT=/opt/microvm/entrypoint.sh ENTRYPOINT ["/init"] CMD ["/command/with-contenv", "/opt/actions-runner/externals/node24/bin/node", "/opt/microvm-lifecycle-hooks/server.js"] ``` @@ -72,7 +70,7 @@ AWS sends an outer JSON object whose `runHookPayload` is itself a JSON string. V ```json { "microvmId": "microvm-bdd2d536-3d87-35e4-8b40-18664608ebc1", - "runHookPayload": "{\"version\":1,\"runnerConfigSsmPath\":\"/github-action-runners/example/token\"}" + "runHookPayload": "{\"version\":1,\"imageArn\":\"arn:aws:lambda:eu-west-1:166060576821:function:microvm-image\",\"imageVersion\":\"8.0\",\"runnerConfigSsmPath\":\"/github-action-runners/example/config\",\"runnerTokenSsmPath\":\"/github-action-runners/example/token\"}" } ``` @@ -98,34 +96,33 @@ For a rolling upgrade, keep emitting version 1 SSM payloads until every deployed ## Entrypoint contract -On `/run`, the hook starts `${RUNNER_ENTRYPOINT:-/opt/microvm/entrypoint.sh} run` without a shell. It writes this versioned document to stdin: +On `/run`, the hook starts `${RUNNER_ROOT:-/opt/actions-runner}/run.sh --jitconfig ` directly without a shell. The JIT configuration is passed only as the `--jitconfig` argument to the runner process: -```json -{ - "jitConfig": "", - "microvmId": "", - "version": 1 -} +```text +run.sh --jitconfig ``` -The entrypoint must write exactly `ready\n` to file descriptor 3 after the runner is ready. The JIT configuration, storage context, and AWS credential environment variables are not passed to the child process. `/terminate` sends `SIGTERM` to the detached process group and escalates to `SIGKILL` after the grace period. +The hook waits for a short process-launch handoff before acknowledging `/run`; it does not require a custom readiness pipe. The JIT configuration, storage context, and AWS credential environment variables are not inherited by the runner process. `/terminate` sends `SIGTERM` to the detached process group and escalates to `SIGKILL` after the grace period. After the runner entrypoint exits on its own, the hook closes its HTTP server and exits with status `0` only when the runner exited cleanly. In the documented s6-overlay image layout above, that makes the foreground container command exit so s6 can stop the remaining image services and shut down the application container's PID 1. This path does not require `lambda:TerminateMicrovm` in the runner role. AWS documents only explicit termination and maximum duration as MicroVM termination triggers, so retain trusted control-plane cleanup and the maximum duration as failure backstops, and verify the container-exit behavior against a restored MicroVM before relying on it operationally. Useful environment variables are: -| Variable | Default | Purpose | -| --------------------------------- | ---------------------------- | --------------------------------------------- | -| `HOOK_PORT` | `8080` | Lifecycle-hook HTTP port | -| `RUNNER_ENTRYPOINT` | `/opt/microvm/entrypoint.sh` | Image-specific runner supervisor | -| `RUN_HOOK_TIMEOUT_SECONDS` | `55` | Total `/run` budget, bounded to 40–55 seconds | -| `HOOK_HEADERS_TIMEOUT_SECONDS` | `5` | HTTP header receive timeout | -| `HOOK_REQUEST_TIMEOUT_SECONDS` | `10` | HTTP request receive timeout | -| `HOOK_KEEP_ALIVE_TIMEOUT_SECONDS` | `5` | Idle keep-alive timeout | -| `AWS_SDK_CALL_TIMEOUT_SECONDS` | `5` | Individual storage-provider call timeout | -| `RUNNER_CONFIG_TIMEOUT_SECONDS` | `20` | Total runner-configuration polling timeout | -| `RUNNER_CONFIG_POLL_SECONDS` | `2` | Delay between provider polling attempts | -| `RUNNER_CONFIG_DELETE_ATTEMPTS` | `3` | SSM one-time configuration delete attempts | +| Variable | Default | Purpose | +| --------------------------------- | --------------------- | --------------------------------------------- | +| `HOOK_PORT` | `8080` | Lifecycle-hook HTTP port | +| `RUNNER_ROOT` | `/opt/actions-runner` | GitHub Actions runner installation | +| `RUNNER_USER` | `runner` | Runner process user name | +| `RUNNER_UID` | `1000` | Runner UID when the hook runs as root | +| `RUNNER_GID` | `1000` | Runner GID when the hook runs as root | +| `RUN_HOOK_TIMEOUT_SECONDS` | `55` | Total `/run` budget, bounded to 40–55 seconds | +| `HOOK_HEADERS_TIMEOUT_SECONDS` | `5` | HTTP header receive timeout | +| `HOOK_REQUEST_TIMEOUT_SECONDS` | `10` | HTTP request receive timeout | +| `HOOK_KEEP_ALIVE_TIMEOUT_SECONDS` | `5` | Idle keep-alive timeout | +| `AWS_SDK_CALL_TIMEOUT_SECONDS` | `5` | Individual storage-provider call timeout | +| `RUNNER_CONFIG_TIMEOUT_SECONDS` | `20` | Total runner-configuration polling timeout | +| `RUNNER_CONFIG_POLL_SECONDS` | `2` | Delay between provider polling attempts | +| `RUNNER_CONFIG_DELETE_ATTEMPTS` | `3` | SSM one-time configuration delete attempts | The request body is capped at 20 KiB and HTTP headers at 16 KiB. Internal errors are returned generically and secret-bearing provider errors are never logged. diff --git a/modules/microvm-foundation/storage.tf b/modules/microvm-foundation/storage.tf index 73ff52e172..fb717f0697 100644 --- a/modules/microvm-foundation/storage.tf +++ b/modules/microvm-foundation/storage.tf @@ -8,6 +8,8 @@ resource "aws_s3_bucket" "artifacts" { #checkov:skip=CKV2_AWS_62:The publisher uploads artifacts synchronously and no event-driven consumer requires S3 notifications. bucket = var.artifact_bucket_name tags = var.tags + + force_destroy = var.force_destroy_artifact_bucket } resource "aws_s3_bucket_ownership_controls" "artifacts" { diff --git a/modules/microvm-foundation/variables.tf b/modules/microvm-foundation/variables.tf index c21e6068c0..e2d5ae2fa5 100644 --- a/modules/microvm-foundation/variables.tf +++ b/modules/microvm-foundation/variables.tf @@ -13,8 +13,8 @@ variable "build_policy_name_prefix" { description = "Name prefix for the Lambda MicroVM build policy." validation { - condition = length(var.build_policy_name_prefix) >= 1 && length(var.build_policy_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.build_policy_name_prefix)) - error_message = "build_policy_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." + condition = length(var.build_policy_name_prefix) >= 1 && length(var.build_policy_name_prefix) <= 38 && can(regex("^[a-zA-Z0-9-_]+-$", var.build_policy_name_prefix)) + error_message = "build_policy_name_prefix must be 1 to 38 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." } } @@ -23,8 +23,8 @@ variable "usage_policy_name_prefix" { description = "Name prefix for the Lambda MicroVM runtime usage policy." validation { - condition = length(var.usage_policy_name_prefix) >= 1 && length(var.usage_policy_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.usage_policy_name_prefix)) - error_message = "usage_policy_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." + condition = length(var.usage_policy_name_prefix) >= 1 && length(var.usage_policy_name_prefix) <= 38 && can(regex("^[a-zA-Z0-9-_]+-$", var.usage_policy_name_prefix)) + error_message = "usage_policy_name_prefix must be 1 to 38 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." } } @@ -33,8 +33,8 @@ variable "build_role_name_prefix" { description = "Name prefix for the Lambda MicroVM build role." validation { - condition = length(var.build_role_name_prefix) >= 1 && length(var.build_role_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.build_role_name_prefix)) - error_message = "build_role_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." + condition = length(var.build_role_name_prefix) >= 1 && length(var.build_role_name_prefix) <= 38 && can(regex("^[a-zA-Z0-9-_]+-$", var.build_role_name_prefix)) + error_message = "build_role_name_prefix must be 1 to 38 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." } } @@ -43,8 +43,8 @@ variable "network_connector_operator_role_name_prefix" { description = "Name prefix for the Lambda Network Connector operator role." validation { - condition = length(var.network_connector_operator_role_name_prefix) >= 1 && length(var.network_connector_operator_role_name_prefix) <= 64 && can(regex("^[a-zA-Z0-9-_]+-$", var.network_connector_operator_role_name_prefix)) - error_message = "network_connector_operator_role_name_prefix must be 1 to 64 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." + condition = length(var.network_connector_operator_role_name_prefix) >= 1 && length(var.network_connector_operator_role_name_prefix) <= 38 && can(regex("^[a-zA-Z0-9-_]+-$", var.network_connector_operator_role_name_prefix)) + error_message = "network_connector_operator_role_name_prefix must be 1 to 38 characters, contain only letters, numbers, hyphens, or underscores, and end with a hyphen." } } @@ -111,11 +111,11 @@ variable "network_connectors" { condition = alltrue([ for connector in values(var.network_connectors) : ( length(connector.name) >= 1 - && length(connector.name) <= 64 + && length(connector.name) <= 38 && can(regex("^[a-zA-Z0-9_-]+$", connector.name)) ) ]) - error_message = "Each network connector name must contain only letters, numbers, hyphens, or underscores and be at most 64 characters." + error_message = "Each network connector name must contain only letters, numbers, hyphens, or underscores and be at most 38 characters." } validation { @@ -152,3 +152,9 @@ variable "network_connectors" { error_message = "Each network connector network_protocol must be IPv4 or DualStack." } } + +variable "force_destroy_artifact_bucket" { + type = bool + description = "Whether to force destroy the S3 bucket containing Lambda MicroVM image source artifacts." + default = false +} \ No newline at end of file diff --git a/modules/multi-runner/tests/config-resolution.tftest.hcl b/modules/multi-runner/tests/config-resolution.tftest.hcl index 145eb3686c..7b5d8e229c 100644 --- a/modules/multi-runner/tests/config-resolution.tftest.hcl +++ b/modules/multi-runner/tests/config-resolution.tftest.hcl @@ -582,7 +582,7 @@ run "v2_microvm_inputs_route_to_microvm_provider" { } } - global_storage_provider = { + global_config_storage_provider = { aws = { ssm = { housekeeper = { diff --git a/tests/ministack/README.md b/tests/ministack/README.md index e9f9838407..bcd3c17529 100644 --- a/tests/ministack/README.md +++ b/tests/ministack/README.md @@ -76,6 +76,23 @@ route checks, and compute-resource assertions. The shared example accepts the built runner-control and webhook Lambda ZIP files as `runners_lambda_zip` and `webhook_lambda_zip`. +To exercise the MicroVM image's lifecycle hook after each MicroVM scale-up, start +the image locally and provide its hook URL. The hook container must use the same +MiniStack endpoint as the smoke test. The scale-up Lambda creates the JIT config +in SSM; the smoke test sends the same `runHookPayload` to the hook, which consumes +that SSM value. + +```sh +MINISTACK_GITHUB_MOCK_URL=http://localhost:1080 \ + python3 tests/ministack/run-webhook-smoke.py microvm +``` + +The test sends the outer JSON request with `runHookPayload` encoded as a JSON +string, then waits for +`/github-action-runners/multi-runner-webhook/microvm/runners/tokens/` +to disappear. This proves that the lifecycle hook consumed the one-time SSM +value before the MicroVM is scaled down. + Build the two real Lambda distributions, start MockServer and MiniStack, and run: ```sh diff --git a/tests/ministack/run-example.sh b/tests/ministack/run-example.sh index ff9b66b858..01364940ec 100755 --- a/tests/ministack/run-example.sh +++ b/tests/ministack/run-example.sh @@ -38,9 +38,9 @@ case "$example" in esac case "$action" in - init | plan | apply | destroy) ;; + init | plan | apply | destroy | output) ;; *) - echo "Usage: $0 {init|plan|apply|destroy} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-webhook|microvm-foundation|migration-test|termination-watcher} [TFVARS_FILE]" >&2 + echo "Usage: $0 {init|plan|apply|destroy|output} {base|prebuilt|default|ephemeral|multi-runner|multi-runner-webhook|microvm-foundation|migration-test|termination-watcher} [TFVARS_FILE]" >&2 exit 64 ;; esac @@ -406,4 +406,8 @@ case "$action" in iac_init iac_example destroy -auto-approve -input=false -parallelism=1 ;; + output) + iac_init + iac_example output + ;; esac diff --git a/tests/ministack/smoke/README.md b/tests/ministack/smoke/README.md new file mode 100644 index 0000000000..ea7836527d --- /dev/null +++ b/tests/ministack/smoke/README.md @@ -0,0 +1,259 @@ +# MiniStack webhook smoke test + +This directory contains the provider-neutral smoke-test harness for the +`multi-runner-webhook` example. It exercises the complete webhook lifecycle +against MiniStack and a GitHub API MockServer without calling GitHub. + +The test can run the EC2 provider, the MicroVM provider, or both providers in a +single Terraform deployment. + +## Entry point + +Run the harness from the repository root: + +```sh +python3 tests/ministack/run-webhook-smoke.py [all|ec2|microvm] +``` + +The default is `all`. Use `--keep-deployment` to retain the temporary Terraform +variables and deployed resources after a failure: + +```sh +MINISTACK_GITHUB_MOCK_URL=http://localhost:1080 \ + python3 tests/ministack/run-webhook-smoke.py --keep-deployment +``` + +The ordinary smoke test requires MiniStack on `AWS_ENDPOINT_URL` and an +already-running MockServer exposed through `MINISTACK_GITHUB_MOCK_URL`. + +## Complete execution flow + +### 1. Select providers and create the checklist + +`run-webhook-smoke.py` creates one `SmokeContext`, selects the requested +providers, and initializes `ministack-smoke-checklist.txt`. Each checklist item +is marked as the corresponding assertion passes. + +The checklist records: + +- webhook acceptance; +- the webhook, EventBridge, dispatcher, SQS, and scale-up chain; +- standard and dynamic scale-up routes and resources; +- pool routes and resources; +- standard, dynamic, and pool scale-down; +- MicroVM hook consumption when the hook URL is enabled. + +### 2. Configure MockServer + +`SmokeContext.prepare()` first checks the required local commands and waits for +MockServer. It loads the static expectations from: + +```text +tests/ministack/smoke/fixtures/github-api-expectations.json +``` + +Those expectations cover the GitHub job lookup, installation-token exchange, +runner-group lookup, JIT configuration generation, and registration-token +fallback routes. + +Provider-specific expectations are added later when each provider is +configured. + +### 3. Create temporary Terraform input + +The harness generates a temporary RSA key, replaces the invalid fixture +GitHub App key, and adds the local GitHub Enterprise Server configuration. The +temporary variables also point Terraform at the real Lambda ZIPs: + +- `lambdas/functions/control-plane/runners.zip`; +- `lambdas/functions/webhook/webhook.zip`. + +The key and temporary variables are removed during cleanup unless +`--keep-deployment` is used. + +### 4. Apply the `multi-runner-webhook` example + +The harness invokes: + +```sh +tests/ministack/run-example.sh apply multi-runner-webhook +``` + +This deploys the webhook, dispatcher, scale-up, scale-down, pool, EC2, and +MicroVM configuration. After apply, the harness reads the Terraform outputs +for the webhook endpoint and webhook secret. + +The webhook endpoint is normally an API Gateway-style hostname such as +`b3855cb6.execute-api.localhost:4566`. Requests are sent to +`localhost:4566` while preserving that hostname in the HTTP `Host` header so +MiniStack routes the request correctly. + +### 5. Configure provider expectations + +Each provider adds the runner-group and JIT configuration expectations required +by its scale-up Lambda. The MicroVM provider keeps this logic in +`microvm.py`; EC2 keeps its equivalent provider setup in `ec2.py`. + +The JIT value is an internal MockServer fixture value. It is returned by the +mock GitHub API, written by the scale-up Lambda to MiniStack SSM, and consumed +from SSM by the lifecycle hook. No external JIT configuration variable is +required. + +### 6. Run the standard scale-up scenario + +For job `123456`, the harness: + +1. Deletes the provider's cached runner-group parameter. +2. Clears MockServer request history. +3. Creates and signs a `workflow_job` webhook using the configured secret. +4. Sends the webhook to the deployed endpoint. +5. Waits for the webhook, dispatcher, and provider scale-up Lambda logs. +6. Verifies the GitHub token and queued-job API routes. +7. Discovers the created compute resource. +8. Verifies provider-specific resource state and ownership metadata. + +For EC2 this resource is an instance. For MicroVM it is a MicroVM plus SSM +metadata under the configured MicroVM paths. + +### 7. Exercise the MicroVM lifecycle hook + +When the `microvm` provider is selected, the harness automatically builds and +starts the lifecycle-hook container on `127.0.0.1:8080`: + +```sh +MINISTACK_GITHUB_MOCK_URL=http://localhost:1080 \ + python3 tests/ministack/run-webhook-smoke.py microvm +``` + +When enabled, `MicrovmProvider.configure()` builds and starts the local image +once before the lifecycle scenarios: + +1. Reads the `microvm` Terraform output. +2. Logs in to MiniStack ECR. +3. Pulls, tags, and pushes the ARM64 Ubuntu base image. +4. Exports the MicroVM foundation outputs to the Packer environment. +5. Runs `packer build .` from `images/microvm-ubuntu`. +6. Copies the lifecycle-hook ZIP already produced by CI into the Docker build + context. +7. Builds the `microvm-lifecycle-hook` ARM64 Docker image. +8. Starts the `microvm-lifecycle-hook` container on + `ministack-mockserver-debug`, publishes `8080:8080`, and waits for its + readiness endpoint. + +After the MicroVM scale-up creates the resource, the test waits for the SSM +JIT parameter created by that scale-up. It then sends the same version-1 +`runHookPayload` shape used by the MicroVM control-plane code when it calls +`RunMicrovm`: + +```json +{ + "version": 1, + "imageArn": "", + "imageVersion": "", + "runnerConfigSsmPath": "/github-action-runners/multi-runner-webhook/microvm/runners/config", + "runnerTokenSsmPath": "/github-action-runners/multi-runner-webhook/microvm/runners/tokens" +} +``` + +The outer request contains the MicroVM identifier and the payload encoded as a +JSON string. The hook uses the MicroVM identifier and token path to consume the +JIT value written by scale-up. The test waits until the token parameter is +gone, proving that the one-time SSM value was consumed. + +The image build is guarded by the provider instance and happens only once per +smoke-test run, not once per scale-up. + +The lifecycle ZIP must already exist at: + +```text +lambdas/services/microvm-lifecycle-hooks/microvm-lifecycle-hooks.zip +``` + +The CI pipeline produces this artifact before running the MicroVM image build. + +### 8. Run the standard scale-down scenario + +The provider-specific scale-down implementation: + +1. Adds MockServer runner-list, runner-detail, and delete expectations. +2. Keeps all active smoke resources visible and marks only the selected runner + as removable. +3. Invokes the provider scale-down Lambda directly. +4. Waits for the scale-down log marker. +5. Verifies the installation-token, runner-list, runner-detail, and runner + deletion routes. +6. Changes the selected runner lookup to HTTP 404. +7. Verifies that the compute resource is terminated. + +For MicroVM, the hook termination endpoint is also called after the resource +termination check. + +### 9. Run the dynamic-label scenario + +The same scale-up and scale-down sequence is repeated for job `123457`, but +the event includes the provider-specific dynamic label: + +- EC2: `ghr-ec2-instance-type:m5.large`; +- MicroVM: `ghr-microvm-image-version:3.0`. + +The test verifies that the resource uses the requested dynamic configuration. + +### 10. Run the pool scenario + +The pool path is invoked directly rather than through a webhook: + +```json +{"poolSize": 1, "type": "ec2|microvm"} +``` + +The harness verifies the pool GitHub routes, discovers the created resource, +checks its provider-specific state, and then performs the same scale-down +assertions. + +### 11. Cleanup + +On success or failure, the harness: + +- marks the checklist as `cleanup`, `passed`, or `failed`; +- removes the local MicroVM Docker container when used; +- terminates discovered EC2 instances; +- terminates discovered MicroVMs; +- destroys the `multi-runner-webhook` Terraform deployment; +- removes temporary variables and response files. + +Use `--keep-deployment` or `MINISTACK_SMOKE_KEEP_DEPLOYMENT=1` when the +Terraform deployment and temporary variables are needed for investigation. + +## Provider responsibilities + +| File | Responsibility | +| --- | --- | +| `lifecycle.py` | Shared standard, dynamic, pool, and scale-down scenarios | +| `provider.py` | Provider interface and resource abstraction | +| `ec2.py` | EC2 discovery, tag assertions, and termination | +| `microvm.py` | MicroVM discovery, metadata assertions, image build, hook handoff, and termination | +| `common.py` | Terraform, AWS CLI, HTTP, MockServer, checklist, and cleanup plumbing | +| `fixtures/` | Static GitHub API and workflow-job test data | + +## Troubleshooting + +The checklist is the first artifact to inspect: + +```sh +cat ministack-smoke-checklist.txt +``` + +For a retained deployment, inspect Terraform state and outputs from: + +```sh +terraform -chdir=examples/multi-runner-webhook output +``` + +If a route assertion times out, check the relevant Lambda log group and the +MockServer request history. If the MicroVM hook is enabled, confirm that the +container is ready at: + +```sh +curl --fail --request POST \ + http://127.0.0.1:8080/aws/lambda-microvms/runtime/v1/ready +``` diff --git a/tests/ministack/smoke/common.py b/tests/ministack/smoke/common.py index fddb654d97..f16970d77c 100644 --- a/tests/ministack/smoke/common.py +++ b/tests/ministack/smoke/common.py @@ -19,6 +19,7 @@ class SmokeContext: def __init__(self, script_dir: Path, *, keep_deployment: bool = False) -> None: self.script_dir = script_dir + self.fixture_dir = Path(__file__).parent / "fixtures" self.source_root = script_dir.parent.parent self.example_root = self.source_root / "examples" / "multi-runner-webhook" self.aws_endpoint = os.environ.get("AWS_ENDPOINT_URL", "http://localhost:4566") @@ -44,19 +45,26 @@ def __init__(self, script_dir: Path, *, keep_deployment: bool = False) -> None: self.checklist: dict[str, list[dict[str, str | bool]]] = {} self.checklist_failure = "" self.keep_deployment = keep_deployment or os.environ.get("MINISTACK_SMOKE_KEEP_DEPLOYMENT") == "1" - self.jit_expectations_configured = False def command(self, name: str) -> None: if not shutil_which(name): raise RuntimeError(f"{name} is required to run the MiniStack smoke test") - def run(self, command: list[str], *, check: bool = True, stream: bool = False) -> subprocess.CompletedProcess[str]: + def run( + self, + command: list[str], + *, + check: bool = True, + stream: bool = False, + cwd: Path | None = None, + ) -> subprocess.CompletedProcess[str]: return subprocess.run( command, check=check, text=True, capture_output=not stream, env=self.environment, + cwd=cwd, ) def aws(self, *args: str, check: bool = True) -> Any: @@ -88,7 +96,7 @@ def http(self, method: str, url: str, body: Any = None) -> tuple[int, str]: return response.status, response.read().decode() except urllib.error.HTTPError as error: return error.code, error.read().decode() - except urllib.error.URLError as error: + except (TimeoutError, urllib.error.URLError) as error: return 0, str(error) def wait_for(self, predicate, description: str, attempts: int = 60) -> Any: @@ -115,6 +123,8 @@ def initialize_checklist(self, providers: list[str]) -> None: ("scale_down_dynamic", "Scale-down removed the dynamic-label runner and compute resource"), ("scale_down_pool", "Scale-down removed the pool runner and compute resource"), ) + if "microvm" in providers: + checks += (("microvm_hook", "MicroVM lifecycle hook consumed SSM and handed off the JIT runner"),) self.checklist = { provider: [{"key": key, "label": label, "passed": False} for key, label in checks] for provider in providers @@ -156,7 +166,7 @@ def configure_mockserver(self) -> None: if not self.mock_port: self.mock_port = urlsplit(self.mock_url).port or 1080 self.wait_for(lambda: self.http("PUT", f"{self.mock_url}/mockserver/status")[0] < 300, "MockServer") - expectations = json.loads((self.script_dir / "github-api-expectations.json").read_text()) + expectations = json.loads((self.fixture_dir / "github-api-expectations.json").read_text()) for expectation in expectations: status, body = self.http("PUT", f"{self.mock_url}/mockserver/expectation", expectation) if status >= 300: @@ -174,15 +184,14 @@ def add_expectation(self, method: str, path: str, status: int, body: Any = None) if code >= 300: raise RuntimeError(f"MockServer expectation failed: {code} {text}") - def configure_jit_expectations(self) -> None: - if self.jit_expectations_configured: - return - self.add_expectation("GET", "/api/v3/orgs/test-owner/actions/runner-groups", 200, [{"id": 1, "name": "Default"}]) - self.add_expectation( - "POST", "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig", 200, - {"runner": {"id": 987654321, "labels": [{"name": "self-hosted"}, {"name": "linux"}]}, "encoded_jit_config": "ministack-jit-config"}, + def clear_expectation(self, method: str, path: str) -> None: + code, text = self.http( + "PUT", + f"{self.mock_url}/mockserver/clear", + {"httpRequest": {"method": method, "path": path}}, ) - self.jit_expectations_configured = True + if code >= 300: + raise RuntimeError(f"MockServer expectation clear failed: {code} {text}") def clear_runner_group_cache(self, provider: str) -> None: parameter_name = ( @@ -361,7 +370,8 @@ def pool_routes(self, provider: str) -> None: def prepare(self) -> None: print("Preparing multi-runner-webhook smoke deployment", flush=True) - for command in ("aws", "openssl", "terraform"): + commands = ("aws", "openssl", "terraform") + for command in commands: self.command(command) self.configure_mockserver() key_path = Path(tempfile.mkstemp(prefix="ministack-smoke-key.")[1]) @@ -391,10 +401,37 @@ def prepare(self) -> None: ) self.webhook_endpoint = self.terraform("output", "-raw", "webhook_endpoint") self.webhook_secret = self.terraform("output", "-raw", "webhook_secret") + self.wait_for_webhook_route() print("Deployment ready; starting provider lifecycle checks", flush=True) + def wait_for_webhook_route(self) -> None: + hostname = urlsplit(self.webhook_endpoint).hostname + if not hostname: + raise RuntimeError(f"Invalid webhook endpoint: {self.webhook_endpoint}") + api_id = hostname.split(".", 1)[0] + + def route_ready() -> bool: + routes = self.aws( + "apigatewayv2", + "get-routes", + "--api-id", + api_id, + check=False, + ) or {} + return any(route.get("RouteKey") == "POST /webhook" for route in routes.get("Items", [])) + + self.wait_for(route_ready, "API Gateway POST /webhook route", attempts=30) + def cleanup(self) -> None: self.update_checklist_status("cleanup") + if "microvm" in self.checklist: + print("MicroVM lifecycle hook container logs (last 200 lines):", flush=True) + self.run( + ["docker", "logs", "--timestamps", "--tail", "200", "microvm-lifecycle-hook"], + check=False, + stream=True, + ) + self.run(["docker", "rm", "--force", "microvm-lifecycle-hook"], check=False) for instance_id in self.discovered_instance_ids: self.aws("ec2", "terminate-instances", "--instance-ids", instance_id, check=False) for microvm_id in self.discovered_microvm_ids: diff --git a/tests/ministack/smoke/ec2.py b/tests/ministack/smoke/ec2.py index 6e087ce561..3b6a35c787 100644 --- a/tests/ministack/smoke/ec2.py +++ b/tests/ministack/smoke/ec2.py @@ -1,5 +1,6 @@ """EC2 implementation of the provider smoke-test interface.""" +import base64 import json from typing import Any @@ -12,10 +13,29 @@ class Ec2Provider: display_name = "EC2" def configure(self, context: SmokeContext) -> None: - context.configure_jit_expectations() + self._configure_jit_expectations(context) + + def _configure_jit_expectations(self, context: SmokeContext) -> None: + context.add_expectation( + "GET", + "/api/v3/orgs/test-owner/actions/runner-groups", + 200, + [{"id": 1, "name": "Default"}], + ) + context.add_expectation( + "POST", + "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig", + 200, + { + "runner": {"id": 987654321, "labels": [{"name": "self-hosted"}, {"name": "linux"}]}, + # EC2 does not launch the runner in this smoke; keep the fixture + # Base64-shaped so it cannot mask a JIT handoff failure. + "encoded_jit_config": base64.b64encode(b"{}").decode(), + }, + ) def event(self, context: SmokeContext, job_id: int, dynamic: bool) -> dict[str, Any]: - value = json.loads((context.script_dir / "workflow_job_event.json").read_text()) + value = json.loads((context.fixture_dir / "workflow_job_event.json").read_text()) job = value["workflow_job"] job["id"] = job_id job["name"] = f"multi-runner-webhook-ec2-{job_id}" @@ -92,6 +112,9 @@ def assert_scale_up(self, context: SmokeContext, resource: RunnerResource, dynam raise RuntimeError(f"EC2 scale-up used {actual_type}, expected {expected_type}") self._assert_tags(context, resource, "scale-up-lambda") + def start_scale_up_runner(self, context: SmokeContext, resource: RunnerResource) -> bool: + return False + def wait_for_pool(self, context: SmokeContext, source: str) -> RunnerResource: return self._wait_for_instance(context, source, "an EC2 pool instance") diff --git a/tests/ministack/github-api-expectations.json b/tests/ministack/smoke/fixtures/github-api-expectations.json similarity index 100% rename from tests/ministack/github-api-expectations.json rename to tests/ministack/smoke/fixtures/github-api-expectations.json diff --git a/tests/ministack/workflow_job_event.json b/tests/ministack/smoke/fixtures/workflow_job_event.json similarity index 100% rename from tests/ministack/workflow_job_event.json rename to tests/ministack/smoke/fixtures/workflow_job_event.json diff --git a/tests/ministack/smoke/lifecycle.py b/tests/ministack/smoke/lifecycle.py index 6d02387b7c..0ddff11327 100644 --- a/tests/ministack/smoke/lifecycle.py +++ b/tests/ministack/smoke/lifecycle.py @@ -43,6 +43,8 @@ def _scale_up( resource = provider.wait_for_scale_up(context, source) provider.assert_scale_up(context, resource, dynamic) context.mark_check(provider.slug, "scale_up_dynamic_resource" if dynamic else "scale_up_standard_resource") + if provider.start_scale_up_runner(context, resource): + context.mark_check(provider.slug, "microvm_hook") return resource diff --git a/tests/ministack/smoke/microvm.py b/tests/ministack/smoke/microvm.py index 2db48e1ea5..8260648a8e 100644 --- a/tests/ministack/smoke/microvm.py +++ b/tests/ministack/smoke/microvm.py @@ -1,25 +1,240 @@ """MicroVM implementation of the provider smoke-test interface.""" +import base64 import json +import shutil from typing import Any from .common import SmokeContext from .provider import RunnerResource +MICROVM_HOOK_CONTAINER = "microvm-lifecycle-hook" +MICROVM_HOOK_PORT = 8080 +MICROVM_HOOK_URL = f"http://127.0.0.1:{MICROVM_HOOK_PORT}" + + +def _base64_json(value: dict[str, Any]) -> str: + return base64.b64encode(json.dumps(value, separators=(",", ":")).encode()).decode() + + +def _smoke_jit_config(context: SmokeContext) -> str: + """Return a synthetic but runner-compatible JIT configuration for MockServer.""" + # The smoke validates JIT handoff, not the runner service protocol. Keep the + # launched runner away from MockServer, whose REST expectations are for the + # control plane and GitHub API only. + runner_server_url = "http://127.0.0.1:65535" + files = { + ".runner": _base64_json( + { + "AgentId": 987654321, + "AgentName": "ministack-microvm", + "DisableUpdate": True, + "Ephemeral": True, + "PoolId": 1, + "PoolName": "Default", + "ServerUrl": runner_server_url, + "WorkFolder": "_work", + } + ), + ".credentials": _base64_json( + { + "scheme": "OAuth", + "data": { + "clientId": "00000000-0000-0000-0000-000000000000", + "authorizationUrl": f"{runner_server_url}/_apis/oauth2/token", + }, + } + ), + # This is a throwaway RSA key used only to let the runner pass its local + # JIT bootstrap. It does not authenticate against a real GitHub service. + ".credentials_rsaparams": _base64_json( + { + "d": "BnkRwk8qg/fMob7o5QboXqqTJsPX2mO7uw7QQAZdFw8FY0P7GmpaiGRPsyu6hhRHH5n6vkMw3gRWvIcP+0rBQ3S32U+tKf4+CaARP9iongbice0xUDdKZKXrTlqSZ9AUND5ZIGIuFNDFn5qXS2J6SyrvF/LopAUu13lWxDrduyEpWRtJkR4RPNKEHi7Lk8NsaZ6N8AIz3+/y0dkWI3pPmelzi+rAssDnz7soK4o6CG9RjIXzeBQzJ4BXefD6zEeXM++mDZylnVJNOoHJWNLvPN+aL5vCfBmgYkk6KZgYzMCFFsxkwtvw+6el3PumxHayginTZ9kd8QJBTypCLtWHWQ==", + "dp": "lkubjli9JdkRuXqnHXHlSDy38RaNGKy+qEa1v4yQTg6h8ni3ZBDMfDhsNhUtlgBHF1AhYod2qwkCZKRNRWAVg00G1Pxswmt57b+4jfW4J0LQ1AytrxhTSrthlyQR5ikUK3d/kEMQs+yP0f2SapkYqXzdaHiU1RA6IhT35bFhHfU=", + "dq": "r7ZtgewAZCZt21o3fBkhAB08Ct+QV0KkzCJlcDr0QbmbjLg/0Nuy6zeiA2QC609LZPgGv6BnPhHMG11bT401WsSkgu/h56L77fK8GwVKCpeZ8SSn3fwyCSpjRHQx3duTerLgi3paVuJTVgL3FdFKSko7wkdSDI1eu9BSHGYnu18=", + "exponent": "AQAB", + "inverseQ": "FSukzwvdLNKkglKWjmYcs1ZCqcgAxecU3bczzVi1TCIYmLN7bLavs15ezr2Xe7MnUJCVz9Lk61sCDVxAA1XK/Bx88iuZvC9GFuM9wZEflvibycx6KI4dvmfSgM0Gff8BnoLs5WinopSuz/fvCzpB26aNfsuv4eCnBgAn8F8bDGU=", + "modulus": "mEkM5pFWZbsCIhVBw2PHC3OfcgP6UtrabLxkAHw6NfxNxdyfRErU6BeI2e6Sh9bRNlo3GbHtq4CizVhcmwJo6CKc1/r1Zrgbb1xQ/FiiHJDA8J6b7cxY894N7rY2r0PqOAxBruGfyAUgG3eFSC5ZSxJfiJe/sd6gwtetrh1ncoCXfeI3IGzZa/dQtIZkefFoqgv5h45gy5KwcAODZ5G0M0aYksFQyUHPLEqSGESsz8LWUOMm1Fgauj2poy8ZHC4xGvfKISPoONRAbHOQDQ7IP09v/w1iAKt02qy9Xdr4vHzZK34a6/Ug/YJpYuIE4fxyTgi096FVIrOl5v+QZg8h4w==", + "p": "zEqTwQJRTY7JmI+zamHZJ/GZ+Hv6n2RSBwFX7BdBS3rxUzAxrijax7fgmhyd4WUgTnMliWZWW3B61Ez/pzXT39ZtSrelOMa6TCMZBbAfq964X5nlWwAdEfHN0SAffKLjXlVBcF6Ov0nhjB8Ci081kjebO1hEgH8ri5awtJLw2S0=", + "q": "vtSnRA6EtRsr+o2gq0E0RA44hBpMe7NMyMIIAxcM4vyMRsKhGc2+vaHIyXLejiaomPlTYWLjCBGoNx8wFN/K0ZoshEJAPAYBVgX9hX9eywigdoGWufkaJqHG1a5YmIqTRqO9dQs8rItpGGPeJmToPLwPaPz1rZH1/BoBO7pksU8=", + } + ), + } + return _base64_json(files) + class MicrovmProvider: slug = "microvm" display_name = "MicroVM" image_arn = "arn:aws:lambda:eu-west-1:000000000000:microvm-image:ministack" image_version = "3.0" + hook_url = MICROVM_HOOK_URL + runner_config_path = "/github-action-runners/multi-runner-webhook/microvm/runners/config" + runner_token_path = "/github-action-runners/multi-runner-webhook/microvm/runners/tokens" metadata_path = "/github-action-runners/multi-runner-webhook/microvm/runners/config/microvm-metadata" + def __init__(self) -> None: + self._runner_image_built = False + def configure(self, context: SmokeContext) -> None: - context.configure_jit_expectations() + self.build_runner_image(context) + self._configure_jit_expectations(context) context.before_microvm_ids = set(self._metadata_by_path(context)) + def _configure_jit_expectations(self, context: SmokeContext) -> None: + runner_group_path = "/api/v3/orgs/test-owner/actions/runner-groups" + jit_config_path = "/api/v3/orgs/test-owner/actions/runners/generate-jitconfig" + context.clear_expectation("GET", runner_group_path) + context.clear_expectation("POST", jit_config_path) + context.add_expectation( + "GET", + runner_group_path, + 200, + [{"id": 1, "name": "Default"}], + ) + context.add_expectation( + "POST", + jit_config_path, + 200, + { + "runner": {"id": 987654321, "labels": [{"name": "self-hosted"}, {"name": "linux"}]}, + "encoded_jit_config": _smoke_jit_config(context), + }, + ) + + def build_runner_image(self, context: SmokeContext) -> None: + """Build the local ARM64 runner image once before lifecycle checks.""" + if self._runner_image_built: + return + + output = json.loads(context.terraform("output", "-json", "microvm")) + foundation = output["microvm_foundation"] + ecr_repository_uri = output["ecr_repo"] + repository_name = ecr_repository_uri.rsplit("/", 1)[-1] + docker_registry = "localhost:4566" + image_tag = "latest" + docker_base_image = f"{docker_registry}/{repository_name}:{image_tag}" + ubuntu_image = ( + ecr_repository_uri + if ":" in ecr_repository_uri.rsplit("/", 1)[-1] + else f"{ecr_repository_uri}:{image_tag}" + ) + image_root = context.source_root / "images" / "microvm-ubuntu" + image_context = image_root / "packer" / "scripts" / "microvm" / "image" + lifecycle_hook_zip = ( + context.source_root + / "lambdas" + / "services" + / "microvm-lifecycle-hooks" + / "microvm-lifecycle-hooks.zip" + ) + + context.run( + [ + "bash", + "-o", + "pipefail", + "-c", + "aws ecr get-login-password | " + f"docker login --username AWS --password-stdin {docker_registry}", + ], + stream=True, + ) + context.run(["docker", "pull", "--platform", "linux/arm64", "ubuntu:24.04"], stream=True) + context.run(["docker", "tag", "ubuntu:24.04", docker_base_image], stream=True) + context.run(["docker", "push", docker_base_image], stream=True) + + context.environment.update( + { + "MICROVM_ARTIFACT_BUCKET": foundation["artifact_bucket_name"], + "MICROVM_BUILD_ROLE_ARN": foundation["build_role_arn"], + "MICROVM_EGRESS_NETWORK_CONNECTOR_ARN": foundation["connector_arns"]["ministack"], + "MICROVM_IMAGE_NAME": "micro-ubuntu24", + "MICROVM_MEMORY_MIB": "8192", + "MICROVM_IDEMPOTENCY_NONCE": context.environment.get( + "MICROVM_IDEMPOTENCY_NONCE", "ministack-smoke" + ), + "MICROVM_LOG_GROUP": output.get("log_group", "/aws/lambda/microvms/ubuntu24"), + "MICROVM_UBUNTU_IMAGE": ubuntu_image, + "MICROVM_LIFECYCLE_HOOK_ZIP": str(lifecycle_hook_zip), + } + ) + + context.run(["packer", "build", "."], cwd=image_root, stream=True) + + shutil.copy2(lifecycle_hook_zip, image_context / "microvm-lifecycle-hooks.zip") + context.run( + [ + "docker", + "build", + "--platform", + "linux/arm64", + "-f", + str(image_context / "ubuntu24.arm64.Dockerfile"), + "--build-arg", + f"UBUNTU_IMAGE={docker_base_image}", + "--tag", + MICROVM_HOOK_CONTAINER, + str(image_context), + ], + stream=True, + ) + + context.run(["docker", "rm", "--force", MICROVM_HOOK_CONTAINER], check=False) + context.run( + [ + "docker", + "run", + "--detach", + "--rm", + "--platform", + "linux/arm64", + "--name", + MICROVM_HOOK_CONTAINER, + "--network", + "ministack-mockserver-debug", + "--add-host=host.docker.internal:host-gateway", + "--publish", + f"{MICROVM_HOOK_PORT}:8080", + "--env", + "AWS_ENDPOINT_URL=http://host.docker.internal:4566", + "--env", + "AWS_REGION=eu-west-1", + "--env", + "AWS_DEFAULT_REGION=eu-west-1", + "--env", + "AWS_ACCESS_KEY_ID=000000000000", + "--env", + "AWS_SECRET_ACCESS_KEY=test", + "--env", + "MICROVM_ID=ministack-microvm", + "--env", + f"RUNNER_CONFIG_SSM_PATH={self.runner_config_path}", + MICROVM_HOOK_CONTAINER, + ], + stream=True, + ) + context.wait_for( + lambda: context.run( + [ + "curl", + "--fail", + "--silent", + "--show-error", + "--request", + "POST", + f"http://127.0.0.1:{MICROVM_HOOK_PORT}/aws/lambda-microvms/runtime/v1/ready", + ], + check=False, + ).returncode + == 0, + "MicroVM lifecycle hook container readiness", + attempts=30, + ) + self._runner_image_built = True + def event(self, context: SmokeContext, job_id: int, dynamic: bool) -> dict[str, Any]: - value = json.loads((context.script_dir / "workflow_job_event.json").read_text()) + value = json.loads((context.fixture_dir / "workflow_job_event.json").read_text()) job = value["workflow_job"] job["id"] = job_id job["name"] = f"multi-runner-webhook-microvm-{job_id}" @@ -118,6 +333,64 @@ def wait_for_scale_up(self, context: SmokeContext, source: str) -> RunnerResourc def assert_scale_up(self, context: SmokeContext, resource: RunnerResource, dynamic: bool) -> None: self._assert_resource(context, resource, "scale-up-lambda", dynamic) + def start_scale_up_runner(self, context: SmokeContext, resource: RunnerResource) -> bool: + details = self._details(context, resource) + image_arn = details.get("imageArn") + image_version = details.get("imageVersion") + if not isinstance(image_arn, str) or not isinstance(image_version, str): + raise RuntimeError(f"MicroVM {resource.identifier} has incomplete image details: {details}") + + parameter_name = f"{self.runner_token_path.rstrip('/')}/{resource.identifier}" + context.wait_for( + lambda: context.aws("ssm", "get-parameter", "--name", parameter_name, check=False), + f"MicroVM scale-up to create {parameter_name}", + ) + + run_hook_payload = json.dumps( + { + "version": 1, + "imageArn": image_arn, + "imageVersion": image_version, + "runnerConfigSsmPath": self.runner_config_path, + "runnerTokenSsmPath": self.runner_token_path, + }, + separators=(",", ":"), + ) + request_body = json.dumps( + {"microvmId": resource.identifier, "runHookPayload": run_hook_payload}, + separators=(",", ":"), + ) + print(f" Curling MicroVM runner hook for {resource.identifier}", flush=True) + result = context.run( + [ + "curl", + "--fail-with-body", + "--silent", + "--show-error", + "--max-time", + "10", + "--request", + "POST", + f"{self.hook_url}/aws/lambda-microvms/runtime/v1/run", + "--header", + "Content-Type: application/json", + "--data-raw", + request_body, + ], + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + "MicroVM runner hook curl failed with " + f"exit code {result.returncode}: {result.stderr.strip() or result.stdout.strip()}" + ) + + context.wait_for( + lambda: not context.aws("ssm", "get-parameter", "--name", parameter_name, check=False), + f"MicroVM runner hook to consume {parameter_name}", + ) + return True + def wait_for_pool(self, context: SmokeContext, source: str) -> RunnerResource: return self._wait_for_microvm(context, source, "a MicroVM pool resource") @@ -161,6 +434,16 @@ def scale_down( context.configure_runner_removed(runner_id) context.assert_runner_removed(runner_id) self._wait_for_termination(context, resource) + self.stop_microvm_hook(context) + + def stop_microvm_hook(self, context: SmokeContext) -> None: + status, body = context.http( + "POST", + f"{self.hook_url}/aws/lambda-microvms/runtime/v1/terminate", + {}, + ) + if status not in (0, 200, 404): + raise RuntimeError(f"MicroVM lifecycle hook termination failed with HTTP {status}: {body}") provider = MicrovmProvider() diff --git a/tests/ministack/smoke/provider.py b/tests/ministack/smoke/provider.py index 81383fa52d..c154e03883 100644 --- a/tests/ministack/smoke/provider.py +++ b/tests/ministack/smoke/provider.py @@ -37,6 +37,9 @@ def wait_for_scale_up(self, context: SmokeContext, source: str) -> RunnerResourc def assert_scale_up(self, context: SmokeContext, resource: RunnerResource, dynamic: bool) -> None: """Check provider-specific scale-up state and ownership.""" + def start_scale_up_runner(self, context: SmokeContext, resource: RunnerResource) -> bool: + """Start the provider-specific runner lifecycle after scale-up.""" + def wait_for_pool(self, context: SmokeContext, source: str) -> RunnerResource: """Find the resource created by a pool Lambda.""" From 977aef66f404148b200cf08ae46f57eddbb9c305 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 23 Sep 2026 16:03:15 +0200 Subject: [PATCH 27/38] ci: fix job --- .github/workflows/smoke-tests.yml | 2 +- .../aws/microvm/src/control-plane/runner-config.test.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index cc8067c663..3ecda0aa74 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -87,4 +87,4 @@ jobs: MINISTACK_GITHUB_MOCK_HOST: host.docker.internal MINISTACK_GITHUB_MOCK_PORT: "1080" MINISTACK_GITHUB_MOCK_URL: ${{ steps.mockserver.outputs.url }} - run: sh tests/ministack/run-smoke.sh + run: python tests/ministack/run-smoke.py diff --git a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts index 8014d3d8fc..f5eab5fb8a 100644 --- a/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts +++ b/lambdas/libs/compute-providers/aws/microvm/src/control-plane/runner-config.test.ts @@ -97,7 +97,6 @@ describe('createMicrovmRunHookPayload', () => { runnerTokenSsmPath: '/runner/token', }); }); - }); describe('createMicrovmRunners', () => { From 2876b4acc34bc0be4c8cdee1e1a4eb7f8677c7cd Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 23 Sep 2026 18:31:14 +0200 Subject: [PATCH 28/38] fix: fix test --- .github/workflows/smoke-tests.yml | 2 +- .../microvm-lifecycle-hooks/src/processes.test.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 3ecda0aa74..c425d1badb 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -87,4 +87,4 @@ jobs: MINISTACK_GITHUB_MOCK_HOST: host.docker.internal MINISTACK_GITHUB_MOCK_PORT: "1080" MINISTACK_GITHUB_MOCK_URL: ${{ steps.mockserver.outputs.url }} - run: python tests/ministack/run-smoke.py + run: python3 tests/ministack/run-webhook-smoke.py diff --git a/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts b/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts index 9a1da0717b..46b46dafce 100644 --- a/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts +++ b/lambdas/services/microvm-lifecycle-hooks/src/processes.test.ts @@ -1,9 +1,20 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chown, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { GitHubRunnerLauncher } from './processes'; +async function prepareRunnerFixture(directory: string, runner: string): Promise { + if (process.getuid?.() !== 0) { + return; + } + + const uid = Number(process.env.RUNNER_UID ?? 1_000); + const gid = Number(process.env.RUNNER_GID ?? 1_000); + await chown(directory, uid, gid); + await chown(runner, uid, gid); +} + afterEach(() => { vi.unstubAllEnvs(); }); @@ -37,6 +48,7 @@ sleep 0.2 `, { mode: 0o700 }, ); + await prepareRunnerFixture(directory, runner); vi.stubEnv('RUNNER_ROOT', directory); vi.stubEnv('TEST_RUNNER_OUTPUT', output); @@ -72,6 +84,7 @@ sleep 0.2 const runner = join(directory, 'run.sh'); await writeFile(runner, '#!/bin/sh\nexit 7\n', { mode: 0o700 }); + await prepareRunnerFixture(directory, runner); vi.stubEnv('RUNNER_ROOT', directory); try { const processHandle = new GitHubRunnerLauncher().launch({ jitConfig: 'encoded-jit' }, 'mvm-1234'); From abb675523d4bb98b35c4c3327f16d105cfa9cc39 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 23 Sep 2026 21:22:20 +0200 Subject: [PATCH 29/38] ci: fix smoke test --- .gitignore | 3 +- .../multi-runner-webhook/.terraform.lock.hcl | 1 + .../.terraform.lock.hcl.tofu | 39 ++- .../webhook/scale-runners/scale-down.tf | 1 + .../tests/scale-runners.tftest.hcl | 1 + tests/ministack/run-webhook-smoke.py | 8 +- tests/ministack/smoke/README.md | 6 + tests/ministack/smoke/common.py | 207 ++++++++++--- tests/ministack/smoke/ec2.py | 2 +- tests/ministack/smoke/lifecycle.py | 157 +++++----- tests/ministack/smoke/microvm.py | 285 ++++++++++-------- 11 files changed, 480 insertions(+), 230 deletions(-) diff --git a/.gitignore b/.gitignore index 5ce4651603..e95da29855 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ node_modules/ site/ __pycache__/ -ministack-smoke-checklist.txt \ No newline at end of file +ministack-smoke-checklist.txt +ministack-smoke.log diff --git a/examples/multi-runner-webhook/.terraform.lock.hcl b/examples/multi-runner-webhook/.terraform.lock.hcl index b8fbf6c256..c1f4433ff3 100644 --- a/examples/multi-runner-webhook/.terraform.lock.hcl +++ b/examples/multi-runner-webhook/.terraform.lock.hcl @@ -74,6 +74,7 @@ provider "registry.terraform.io/hashicorp/time" { version = "0.14.2" constraints = ">= 0.13.0" hashes = [ + "h1:eQRXh8mZFlUJfzYXKdaYRHRMhiS2cFyCfgP1mjkrtuI=", "h1:gnP2hptiFIHSHUFBvAFKhE/Yh5u5yVEx+P7XSB58A/E=", "zh:0aa1028d91041f4dceba193e3707dac57358d0063d97e20700e554758b67baca", "zh:32bee9f2b2678e2a0789ad86e716d09ca1d5450180b3cd8033ee7a251bfd352e", diff --git a/examples/multi-runner-webhook/.terraform.lock.hcl.tofu b/examples/multi-runner-webhook/.terraform.lock.hcl.tofu index 373b8d1c29..8737446421 100644 --- a/examples/multi-runner-webhook/.terraform.lock.hcl.tofu +++ b/examples/multi-runner-webhook/.terraform.lock.hcl.tofu @@ -3,7 +3,7 @@ provider "registry.opentofu.org/hashicorp/aws" { version = "6.65.0" - constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0" + constraints = ">= 5.0.0, >= 6.21.0, >= 6.33.0, >= 6.61.0" hashes = [ "h1:/D/KChJMHHi6N2Ae8pDT2CoZ1ZVPgmO4DvT3TM1uYdA=", "h1:0KBMNN4G86DISLGy8e7PdtgcjgLWmFM0Tu/+PlcI6Xc=", @@ -111,3 +111,40 @@ provider "registry.opentofu.org/hashicorp/random" { "zh:f39d4bce213ed9bba3474bad468136af08ff6c4c33adaafcc10c1f78067adfe3", ] } + +provider "registry.opentofu.org/hashicorp/time" { + version = "0.14.2" + constraints = ">= 0.13.0" + hashes = [ + "h1:0lkmuDlyUBEK2vAxb9r8jY8kMpqYbMoSb4GWnSbA9iY=", + "h1:4ccOXW03+ENKJieXGwTfMvRlkpT9o+ra6dw240C1UFE=", + "h1:8+b7rm7aVI0cNDoegUPuEKNAQeEC9jYHaaqgVWGvGag=", + "h1:BzLQYmKbF3aM81kS9GZQ0mnJPU/bVFa3Jgk9ZIFJP80=", + "h1:EHyMvebIwqieUVzq6WbbheNWUHRgy8B5hhRv++V10qA=", + "h1:ICdTbU+IeBH2xihtoYENxmLIGfObCbcb8l2XK2abJGw=", + "h1:JKIAzWzVxRY6Q+ybCjmZ6DnMfkyp/zdZmWkTIB1JzOo=", + "h1:JkTAWz5bbrSgrnkkF5XhoMbSLSihtDDb5V+SYAZYOes=", + "h1:TNzAoSy5lcv/8pjzlb4nz+m92N3G/osbU+FV+uh0SQs=", + "h1:XJLK9UX0/LxUM7Z4pxB1tf6TP8iCz0bxM87ot4wq1ns=", + "h1:bkdzFk//GNj0iHkXgupa0XqwNjYx3O6+czgIi+IkXjA=", + "h1:cbpg0fadPwbhtL0EMnPj+AkwpPUvflGGeYB31SKTHdw=", + "h1:hv6Fp4zk1JQ7Dj+cmUJZmuI3aLEGjw4VSFyqgUvisDg=", + "h1:oMHgtUYEDs1DJrTXNPgSNoQ0f+0FZ+nAYJWJYpHBtAI=", + "h1:qdSn+kIg2bcZkgGLJ5zQ1K03qoRWYYBxGv6J7SWlShY=", + "zh:0c5caf61f978612c78eead85fac256ce42a62e5a04616afedf41065a98639c9f", + "zh:1356743176e6522d6f0a998cf04edc9c5b3e3a99893562fc3a9bc7c4f7ac738e", + "zh:1aae0a419092d5e20d6c38199b6c406a73abe12f7f665fa1bd9fed07f454d427", + "zh:4fc26faa672af3806d5760c88c7ad223ae8a04002ae77372d7744fa44e56b3f6", + "zh:503189a08d6468a1c24099f3aa9b22aa9f6fd82bdab118ee6a2af665441fc8a7", + "zh:53f1ca144377f8c42a1fc71cff22e6c3ad5e2ae9d9eaaf0a95cbe70c896b30cb", + "zh:64ac1844933a18767bb1b9ea19b5432f83acf682fcff1e49680e50d341420e78", + "zh:6819c08b9228375f56fd2d33881fb7fbb0a59581acabfcd662b4eb002aae3917", + "zh:c7e0df3698ad0f75114ce72049d8d8be242e6510806e13bdf8ca8f416e3d9f1c", + "zh:c8a7d8601dcc700efdfc65194bb8450ca28f04c0e42aac8a355a7b758c82e252", + "zh:d311cd2ef10b5b9762f248e54c9d9211c8f6c24a5efc80fcddeba95d0347713d", + "zh:d34865e994a91b764bd1283dfa25ee3d84571d65959987d4a7f17405dcf27e43", + "zh:ee5ff12288f160969dc6a2764b634295cfe73f5705172f0d2f5d32a358cf29f7", + "zh:eec9f3327f2cd180af6fa5e16370c7d5cf0c79874f03d2c1a48e22c7c1af4951", + "zh:f0fc458693aec12dcf84bb7d7b2f49283a818282072729a26765d679fb2984ed", + ] +} diff --git a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf index efe57570f6..b5a82fbe01 100644 --- a/modules/orchestration-providers/webhook/scale-runners/scale-down.tf +++ b/modules/orchestration-providers/webhook/scale-runners/scale-down.tf @@ -36,6 +36,7 @@ resource "aws_lambda_function" "scale_down" { PARAMETER_GITHUB_APP_ID_NAME = var.config.github.app_parameters.id.name PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github.app_parameters.key_base64.name PARAMETER_GITHUB_APPS_MANIFEST_NAME = var.config.github.app_parameters.additional_apps_manifest != null ? var.config.github.app_parameters.additional_apps_manifest.name : "" + SSM_TOKEN_PATH = var.storage_provider.aws.ssm.token_path }, var.storage_provider.scale_down.environment_variables) } diff --git a/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl b/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl index 22b695aac2..9cb752cf72 100644 --- a/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl +++ b/modules/orchestration-providers/webhook/scale-runners/tests/scale-runners.tftest.hcl @@ -237,6 +237,7 @@ run "assembles_provider_neutral_scaling_control_plane" { && aws_lambda_function.scale_down.environment[0].variables["RUNNER_BOOT_TIME_IN_MINUTES"] == "12" && aws_lambda_function.scale_up.environment[0].variables["MICROVM_CLUSTER"] == "runner-cluster" && aws_lambda_function.scale_down.environment[0].variables["MICROVM_CLUSTER"] == "runner-cluster" + && aws_lambda_function.scale_down.environment[0].variables["SSM_TOKEN_PATH"] == "/github-runner/tokens" && !contains(keys(aws_lambda_function.scale_up.environment[0].variables), "INSTANCE_TYPES") ) error_message = "The common scaling Lambdas must select the compute provider while injecting webhook-owned capacity and boot-time settings." diff --git a/tests/ministack/run-webhook-smoke.py b/tests/ministack/run-webhook-smoke.py index 0d8d9ed514..10a2afab00 100644 --- a/tests/ministack/run-webhook-smoke.py +++ b/tests/ministack/run-webhook-smoke.py @@ -32,9 +32,11 @@ def main() -> int: context.initialize_checklist([item.slug for item in selected]) succeeded = False try: - context.prepare() - for selected_provider in selected: - run(context, selected_provider) + with context.step("Webhook Test"): + with context.step("Prepare deployment"): + context.prepare() + for selected_provider in selected: + run(context, selected_provider) tested = "EC2 and MicroVM" if args.provider == "all" else providers[args.provider].display_name print(f"MiniStack multi-runner-webhook smoke tests passed for {tested}.", flush=True) succeeded = True diff --git a/tests/ministack/smoke/README.md b/tests/ministack/smoke/README.md index ea7836527d..9ac1e80d5a 100644 --- a/tests/ministack/smoke/README.md +++ b/tests/ministack/smoke/README.md @@ -26,6 +26,12 @@ MINISTACK_GITHUB_MOCK_URL=http://localhost:1080 \ The ordinary smoke test requires MiniStack on `AWS_ENDPOINT_URL` and an already-running MockServer exposed through `MINISTACK_GITHUB_MOCK_URL`. +Step progress is written to the test step. Detailed subprocess output is +tee'd to `MINISTACK_SMOKE_LOG_FILE` (default: `ministack-smoke.log`) and is +printed to the test step only when a command fails. The checklist and command +log are separate files: use `MINISTACK_SMOKE_CHECKLIST_FILE` for assertion +status and the log file for Terraform, Packer, Docker, and AWS CLI output. + ## Complete execution flow ### 1. Select providers and create the checklist diff --git a/tests/ministack/smoke/common.py b/tests/ministack/smoke/common.py index f16970d77c..569f476da8 100644 --- a/tests/ministack/smoke/common.py +++ b/tests/ministack/smoke/common.py @@ -3,17 +3,20 @@ from __future__ import annotations import base64 +from contextlib import contextmanager import hashlib import hmac import json import os +import shlex import subprocess +import sys import tempfile import time import urllib.error import urllib.request from pathlib import Path -from typing import Any +from typing import Any, Iterator from urllib.parse import urlsplit, urlunsplit class SmokeContext: @@ -42,9 +45,68 @@ def __init__(self, script_dir: Path, *, keep_deployment: bool = False) -> None: self.before_microvm_ids: set[str] = set() self.response_path = Path(tempfile.mkstemp(prefix="ministack-smoke-response.")[1]) self.checklist_path = Path(os.environ.get("MINISTACK_SMOKE_CHECKLIST_FILE", "ministack-smoke-checklist.txt")) + self.log_path = Path(os.environ.get("MINISTACK_SMOKE_LOG_FILE", "ministack-smoke.log")) self.checklist: dict[str, list[dict[str, str | bool]]] = {} self.checklist_failure = "" self.keep_deployment = keep_deployment or os.environ.get("MINISTACK_SMOKE_KEEP_DEPLOYMENT") == "1" + self.step_depth = 0 + + def _redact_log(self, value: str) -> str: + for secret in ( + self.webhook_secret, + self.environment.get("AWS_ACCESS_KEY_ID", ""), + self.environment.get("AWS_SECRET_ACCESS_KEY", ""), + ): + if secret: + value = value.replace(secret, "[REDACTED]") + return value + + def _append_log(self, value: str) -> None: + if not value: + return + self.log_path.parent.mkdir(parents=True, exist_ok=True) + with self.log_path.open("a", encoding="utf-8") as log_file: + log_file.write(self._redact_log(value)) + + def progress(self, message: str) -> None: + line = f"{' ' * self.step_depth}{message}" + print(line, flush=True) + self._append_log(f"{line}\n") + + @contextmanager + def step(self, name: str) -> Iterator[None]: + self.progress(name) + github_actions = self.environment.get("GITHUB_ACTIONS") == "true" + if github_actions: + group_start = f"::group::{name}\n" + print(group_start, end="", flush=True) + self._append_log(group_start) + self.step_depth += 1 + try: + yield + finally: + self.step_depth -= 1 + if github_actions: + group_end = "::endgroup::\n" + print(group_end, end="", flush=True) + self._append_log(group_end) + + def _log_command_output( + self, + command: list[str], + stdout: str | None, + stderr: str | None, + *, + include_command: bool = True, + ) -> None: + output = "" + if include_command: + output += f"\n$ {shlex.join(command)}\n" + if stdout: + output += stdout + if stderr: + output += stderr + self._append_log(output) def command(self, name: str) -> None: if not shutil_which(name): @@ -57,15 +119,64 @@ def run( check: bool = True, stream: bool = False, cwd: Path | None = None, + log_output: bool = True, ) -> subprocess.CompletedProcess[str]: - return subprocess.run( - command, - check=check, - text=True, - capture_output=not stream, - env=self.environment, - cwd=cwd, - ) + if not stream: + try: + result = subprocess.run( + command, + check=check, + text=True, + capture_output=True, + env=self.environment, + cwd=cwd, + ) + except subprocess.CalledProcessError as error: + if log_output: + self._log_command_output(command, error.stdout, error.stderr) + raise + if log_output: + self._log_command_output(command, result.stdout, result.stderr) + return result + + log_file = self.log_path.open("a", encoding="utf-8") if log_output else None + try: + process = subprocess.Popen( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + env=self.environment, + cwd=cwd, + ) + except BaseException: + if log_file is not None: + log_file.close() + raise + + output: list[str] = [] + try: + if log_file is not None: + log_file.write(self._redact_log(f"\n$ {shlex.join(command)}\n")) + assert process.stdout is not None + for line in process.stdout: + output.append(line) + if log_file is not None: + log_file.write(self._redact_log(line)) + log_file.flush() + return_code = process.wait() + finally: + if log_file is not None: + log_file.close() + + result = subprocess.CompletedProcess(command, return_code, "".join(output), None) + if check and return_code != 0: + self.progress(f"Command failed: {shlex.join(command)}") + sys.stdout.write(self._redact_log(result.stdout)) + sys.stdout.flush() + raise subprocess.CalledProcessError(return_code, command, output=result.stdout) + return result def aws(self, *args: str, check: bool = True) -> Any: result = self.run( @@ -79,8 +190,12 @@ def aws(self, *args: str, check: bool = True) -> Any: except json.JSONDecodeError: return result.stdout.strip() - def terraform(self, *args: str, check: bool = True) -> str: - result = self.run(["terraform", f"-chdir={self.example_root}", *args], check=check) + def terraform(self, *args: str, check: bool = True, log_output: bool = True) -> str: + result = self.run( + ["terraform", f"-chdir={self.example_root}", *args], + check=check, + log_output=log_output, + ) return result.stdout.strip() def http(self, method: str, url: str, body: Any = None) -> tuple[int, str]: @@ -105,7 +220,7 @@ def wait_for(self, predicate, description: str, attempts: int = 60) -> Any: if result: return result if attempt == 1 or attempt % 10 == 0: - print(f" Still waiting for {description} ({attempt}/{attempts})", flush=True) + self.progress(f"Still waiting for {description} ({attempt}/{attempts})") time.sleep(2) raise RuntimeError(f"Timed out waiting for {description}") @@ -130,7 +245,8 @@ def initialize_checklist(self, providers: list[str]) -> None: for provider in providers } self._write_checklist("running") - print(f"Writing smoke checklist to {self.checklist_path}", flush=True) + self.progress(f"Writing smoke checklist to {self.checklist_path}") + self.progress(f"Writing smoke command output to {self.log_path}") def mark_check(self, provider: str, key: str) -> None: for check in self.checklist.get(provider, []): @@ -162,7 +278,7 @@ def _write_checklist(self, status: str) -> None: def configure_mockserver(self) -> None: if not self.mock_url: raise RuntimeError("MINISTACK_GITHUB_MOCK_URL must point to an already-running MockServer") - print(f"Using external MockServer at {self.mock_url}", flush=True) + self.progress(f"Using external MockServer at {self.mock_url}") if not self.mock_port: self.mock_port = urlsplit(self.mock_url).port or 1080 self.wait_for(lambda: self.http("PUT", f"{self.mock_url}/mockserver/status")[0] < 300, "MockServer") @@ -197,15 +313,15 @@ def clear_runner_group_cache(self, provider: str) -> None: parameter_name = ( f"/github-action-runners/multi-runner-webhook/{provider}/runners/config/runner-group/Default" ) - print(f" Clearing {provider} runner-group cache", flush=True) + self.progress(f"Clearing {provider} runner-group cache") self.aws("ssm", "delete-parameter", "--name", parameter_name, check=False) def clear_requests(self) -> None: - print(" Clearing MockServer request history", flush=True) + self.progress("Clearing MockServer request history") code, _ = self.http("PUT", f"{self.mock_url}/mockserver/clear?type=log") if code >= 300: raise RuntimeError("Failed to clear MockServer request history") - print(" MockServer request history cleared", flush=True) + self.progress("MockServer request history cleared") def verify_route(self, method: str, path: str, description: str) -> None: def verify() -> bool: @@ -216,14 +332,19 @@ def verify() -> bool: ) return code < 300 - self.wait_for(verify, description) + try: + self.wait_for(verify, description) + except RuntimeError as error: + raise RuntimeError( + f"{error}. Recent messages in the provider Lambda logs may be available in {self.log_path}" + ) from error def send_webhook(self, event: dict[str, Any], delivery_id: str) -> None: payload = json.dumps(event).encode() signature = hmac.new(self.webhook_secret.encode(), payload, hashlib.sha256).hexdigest() parsed_endpoint = urlsplit(self.webhook_endpoint) endpoint = urlunsplit((parsed_endpoint.scheme, f"localhost:{parsed_endpoint.port or 4566}", parsed_endpoint.path, parsed_endpoint.query, parsed_endpoint.fragment)) - print(f" Sending webhook {delivery_id} to {endpoint} (Host: {parsed_endpoint.netloc})", flush=True) + self.progress(f"Sending webhook {delivery_id} to {endpoint} (Host: {parsed_endpoint.netloc})") request = urllib.request.Request( endpoint, data=payload, @@ -249,10 +370,10 @@ def send_webhook(self, event: dict[str, Any], delivery_id: str) -> None: ) from error if status != 201: raise RuntimeError(f"Webhook smoke request failed with HTTP {status}") - print(f" Webhook {delivery_id} accepted with HTTP 201", flush=True) + self.progress(f"Webhook {delivery_id} accepted with HTTP 201") def wait_for_log(self, group: str, marker: str, description: str) -> None: - print(f" Waiting for {description} ({group})", flush=True) + self.progress(f"Waiting for {description} ({group})") def found() -> bool: events = self.aws("logs", "filter-log-events", "--log-group-name", group, "--filter-pattern", marker, "--limit", "1", check=False) return bool(events and events.get("events")) @@ -274,7 +395,19 @@ def found() -> bool: f"{error}. Available smoke log groups: {available}. " f"Recent messages in {group}: {messages}" ) from error - print(f" Found {description}", flush=True) + self.progress(f"Found {description}") + + def recent_log_messages(self, group: str, limit: int = 50) -> list[str]: + events = self.aws( + "logs", + "filter-log-events", + "--log-group-name", + group, + "--limit", + str(limit), + check=False, + ) or {} + return [item.get("message", "") for item in events.get("events", [])] def invoke(self, function_name: str, payload: dict[str, Any], description: str) -> None: payload_path = Path(tempfile.mkstemp(prefix="ministack-smoke-payload.")[1]) @@ -353,11 +486,17 @@ def assert_runner_removed(self, runner_id: int) -> None: if code != 404: raise RuntimeError(f"Expected runner {runner_id} to be removed, got HTTP {code}") - def scale_down_routes(self, runner_id: int) -> None: - self.verify_route("POST", "/api/v3/app/installations/123/access_tokens", "Scale-down requested a GitHub App token") - self.verify_route("GET", "/api/v3/orgs/test-owner/actions/runners", "Scale-down listed organization runners") - self.verify_route("GET", f"/api/v3/orgs/test-owner/actions/runners/{runner_id}", "Scale-down checked runner state") - self.verify_route("DELETE", f"/api/v3/orgs/test-owner/actions/runners/{runner_id}", "Scale-down deleted the GitHub runner") + def scale_down_routes(self, runner_id: int, log_group: str | None = None) -> None: + try: + self.verify_route("POST", "/api/v3/app/installations/123/access_tokens", "Scale-down requested a GitHub App token") + self.verify_route("GET", "/api/v3/orgs/test-owner/actions/runners", "Scale-down listed organization runners") + self.verify_route("GET", f"/api/v3/orgs/test-owner/actions/runners/{runner_id}", "Scale-down checked runner state") + self.verify_route("DELETE", f"/api/v3/orgs/test-owner/actions/runners/{runner_id}", "Scale-down deleted the GitHub runner") + except RuntimeError as error: + if log_group is None: + raise + messages = self.recent_log_messages(log_group) + raise RuntimeError(f"{error}. Recent messages in {log_group}: {messages}") from error def scale_up_routes(self, job_id: int, provider: str) -> None: self.verify_route("POST", "/api/v3/app/installations/123/access_tokens", f"{provider} scale-up requested a GitHub token for {job_id}") @@ -369,7 +508,7 @@ def pool_routes(self, provider: str) -> None: self.verify_route("GET", "/api/v3/orgs/test-owner/actions/runners", f"{provider} pool listed organization runners") def prepare(self) -> None: - print("Preparing multi-runner-webhook smoke deployment", flush=True) + self.progress("Preparing multi-runner-webhook smoke deployment") commands = ("aws", "openssl", "terraform") for command in commands: self.command(command) @@ -394,15 +533,15 @@ def prepare(self) -> None: if "webhook_lambda_zip" not in text: additions.append(f'webhook_lambda_zip = "{self.source_root / "lambdas/functions/webhook/webhook.zip"}"') self.tfvars_path.write_text(text + "\n" + "\n".join(additions) + "\n") - print("Applying multi-runner-webhook Terraform example", flush=True) + self.progress("Applying multi-runner-webhook Terraform example") self.run( [str(self.source_root / "tests/ministack/run-example.sh"), "apply", "multi-runner-webhook", str(self.tfvars_path)], stream=True, ) self.webhook_endpoint = self.terraform("output", "-raw", "webhook_endpoint") - self.webhook_secret = self.terraform("output", "-raw", "webhook_secret") + self.webhook_secret = self.terraform("output", "-raw", "webhook_secret", log_output=False) self.wait_for_webhook_route() - print("Deployment ready; starting provider lifecycle checks", flush=True) + self.progress("Deployment ready; starting provider lifecycle checks") def wait_for_webhook_route(self) -> None: hostname = urlsplit(self.webhook_endpoint).hostname @@ -425,7 +564,7 @@ def route_ready() -> bool: def cleanup(self) -> None: self.update_checklist_status("cleanup") if "microvm" in self.checklist: - print("MicroVM lifecycle hook container logs (last 200 lines):", flush=True) + self.progress("MicroVM lifecycle hook container logs (last 200 lines):") self.run( ["docker", "logs", "--timestamps", "--tail", "200", "microvm-lifecycle-hook"], check=False, @@ -443,9 +582,9 @@ def cleanup(self) -> None: check=False, ) if self.tfvars_path and self.keep_deployment: - print(f"Terraform deployment retained; tfvars file: {self.tfvars_path}", flush=True) + self.progress(f"Terraform deployment retained; tfvars file: {self.tfvars_path}") elif self.tfvars_path: - print("Destroying multi-runner-webhook Terraform deployment", flush=True) + self.progress("Destroying multi-runner-webhook Terraform deployment") self.run( [ str(self.source_root / "tests/ministack/run-example.sh"), diff --git a/tests/ministack/smoke/ec2.py b/tests/ministack/smoke/ec2.py index 3b6a35c787..085309be35 100644 --- a/tests/ministack/smoke/ec2.py +++ b/tests/ministack/smoke/ec2.py @@ -151,7 +151,7 @@ def scale_down( "EC2 scale-down Lambda invoked", ) context.wait_for_log("/aws/lambda/multi-runner-webhook-ec2-scale-down", marker, "EC2 scale-down Lambda started") - context.scale_down_routes(runner_id) + context.scale_down_routes(runner_id, "/aws/lambda/multi-runner-webhook-ec2-scale-down") context.configure_runner_removed(runner_id) context.assert_runner_removed(runner_id) self._wait_for_termination(context, resource) diff --git a/tests/ministack/smoke/lifecycle.py b/tests/ministack/smoke/lifecycle.py index 0ddff11327..cef2f725a8 100644 --- a/tests/ministack/smoke/lifecycle.py +++ b/tests/ministack/smoke/lifecycle.py @@ -2,6 +2,8 @@ from __future__ import annotations +from uuid import uuid4 + from .common import SmokeContext from .provider import RunnerResource, SmokeProvider @@ -30,42 +32,59 @@ def _scale_up( source: str, ) -> RunnerResource: label_mode = "with dynamic label" if dynamic else "without dynamic label" - print(f" {provider.display_name}: scale-up {label_mode} (job {job_id})", flush=True) - context.clear_runner_group_cache(provider.slug) - context.clear_requests() - context.send_webhook(provider.event(context, job_id, dynamic), f"multi-runner-webhook-{provider.slug}-{job_id}") - context.mark_check(provider.slug, "webhook") - _wait_for_webhook_chain(context, provider, job_id) - context.mark_check(provider.slug, "chain") - context.scale_up_routes(job_id, provider.display_name) - provider.verify_scale_up_routes(context, job_id) - context.mark_check(provider.slug, "scale_up_dynamic_routes" if dynamic else "scale_up_standard_routes") - resource = provider.wait_for_scale_up(context, source) - provider.assert_scale_up(context, resource, dynamic) - context.mark_check(provider.slug, "scale_up_dynamic_resource" if dynamic else "scale_up_standard_resource") - if provider.start_scale_up_runner(context, resource): - context.mark_check(provider.slug, "microvm_hook") - return resource + with context.step(f"{provider.display_name}: scale-up {label_mode} (job {job_id})"): + with context.step("Prepare scale-up fixtures"): + context.clear_runner_group_cache(provider.slug) + context.clear_requests() + with context.step("Send webhook"): + context.send_webhook( + provider.event(context, job_id, dynamic), + f"multi-runner-webhook-{provider.slug}-{job_id}", + ) + context.mark_check(provider.slug, "webhook") + with context.step("Wait for webhook chain"): + _wait_for_webhook_chain(context, provider, job_id) + context.mark_check(provider.slug, "chain") + with context.step("Verify shared scale-up routes"): + context.scale_up_routes(job_id, provider.display_name) + with context.step("Verify provider scale-up routes"): + provider.verify_scale_up_routes(context, job_id) + context.mark_check(provider.slug, "scale_up_dynamic_routes" if dynamic else "scale_up_standard_routes") + with context.step("Wait for compute resource"): + resource = provider.wait_for_scale_up(context, source) + with context.step("Validate compute resource"): + provider.assert_scale_up(context, resource, dynamic) + context.mark_check(provider.slug, "scale_up_dynamic_resource" if dynamic else "scale_up_standard_resource") + with context.step("Start runner"): + if provider.start_scale_up_runner(context, resource): + context.mark_check(provider.slug, "microvm_hook") + return resource def _pool(context: SmokeContext, provider: SmokeProvider, pool_size: int) -> RunnerResource: - print(f" {provider.display_name}: pool scale-up (target size {pool_size})", flush=True) - context.configure_empty_runner_list() - context.clear_runner_group_cache(provider.slug) - context.clear_requests() - context.invoke( - f"multi-runner-webhook-{provider.slug}-pool", - {"poolSize": pool_size, "type": provider.slug}, - f"{provider.display_name} pool Lambda invoked", - ) - context.pool_routes(provider.display_name) - provider.verify_pool_routes(context) - context.mark_check(provider.slug, "pool_routes") - resource = provider.wait_for_pool(context, "pool-lambda") - print(f" {provider.display_name}: pool created resource {resource.identifier}", flush=True) - provider.assert_pool(context, resource) - context.mark_check(provider.slug, "pool_resource") - return resource + with context.step(f"{provider.display_name}: pool scale-up (target size {pool_size})"): + with context.step("Prepare pool fixtures"): + context.configure_empty_runner_list() + context.clear_runner_group_cache(provider.slug) + context.clear_requests() + with context.step("Invoke pool Lambda"): + context.invoke( + f"multi-runner-webhook-{provider.slug}-pool", + {"poolSize": pool_size, "type": provider.slug}, + f"{provider.display_name} pool Lambda invoked", + ) + with context.step("Verify shared pool routes"): + context.pool_routes(provider.display_name) + with context.step("Verify provider pool routes"): + provider.verify_pool_routes(context) + context.mark_check(provider.slug, "pool_routes") + with context.step("Wait for compute resource"): + resource = provider.wait_for_pool(context, "pool-lambda") + with context.step("Validate compute resource"): + context.progress(f"Pool created resource {resource.identifier}") + provider.assert_pool(context, resource) + context.mark_check(provider.slug, "pool_resource") + return resource def _scale_down( @@ -76,41 +95,43 @@ def _scale_down( marker: str, check: str, ) -> None: - print(f" {provider.display_name}: scale-down resource {resource.identifier}", flush=True) - provider.scale_down(context, resource, runner_id, marker, [(runner_id, resource)]) - context.mark_check(provider.slug, check) + with context.step(f"{provider.display_name}: scale-down resource {resource.identifier}"): + with context.step("Run provider scale-down checks"): + provider.scale_down(context, resource, runner_id, marker, [(runner_id, resource)]) + context.mark_check(provider.slug, check) def run(context: SmokeContext, provider: SmokeProvider) -> None: - print(f"Running {provider.display_name} webhook lifecycle scenarios", flush=True) - provider.configure(context) - - scale_up = _scale_up(context, provider, 123456, False, "scale-up-lambda") - _scale_down( - context, - provider, - scale_up, - MOCK_JIT_RUNNER_ID, - f"multi-runner-webhook-{provider.slug}-scale-up-scale-down", - "scale_down_standard", - ) - - dynamic_scale_up = _scale_up(context, provider, 123457, True, "scale-up-lambda") - _scale_down( - context, - provider, - dynamic_scale_up, - MOCK_JIT_RUNNER_ID, - f"multi-runner-webhook-{provider.slug}-dynamic-scale-down", - "scale_down_dynamic", - ) - - pool = _pool(context, provider, pool_size=1) - _scale_down( - context, - provider, - pool, - MOCK_JIT_RUNNER_ID, - f"multi-runner-webhook-{provider.slug}-pool-scale-down", - "scale_down_pool", - ) + with context.step(f"{provider.display_name} lifecycle"): + with context.step("Configure"): + provider.configure(context) + + scale_up = _scale_up(context, provider, 123456, False, "scale-up-lambda") + _scale_down( + context, + provider, + scale_up, + MOCK_JIT_RUNNER_ID, + f"multi-runner-webhook-{provider.slug}-scale-up-scale-down-{uuid4().hex}", + "scale_down_standard", + ) + + dynamic_scale_up = _scale_up(context, provider, 123457, True, "scale-up-lambda") + _scale_down( + context, + provider, + dynamic_scale_up, + MOCK_JIT_RUNNER_ID, + f"multi-runner-webhook-{provider.slug}-dynamic-scale-down-{uuid4().hex}", + "scale_down_dynamic", + ) + + pool = _pool(context, provider, pool_size=1) + _scale_down( + context, + provider, + pool, + MOCK_JIT_RUNNER_ID, + f"multi-runner-webhook-{provider.slug}-pool-scale-down-{uuid4().hex}", + "scale_down_pool", + ) diff --git a/tests/ministack/smoke/microvm.py b/tests/ministack/smoke/microvm.py index 8260648a8e..a341881f4e 100644 --- a/tests/ministack/smoke/microvm.py +++ b/tests/ministack/smoke/microvm.py @@ -75,6 +75,7 @@ class MicrovmProvider: def __init__(self) -> None: self._runner_image_built = False + self._hook_needs_restart = False def configure(self, context: SmokeContext) -> None: self.build_runner_image(context) @@ -107,131 +108,140 @@ def build_runner_image(self, context: SmokeContext) -> None: if self._runner_image_built: return - output = json.loads(context.terraform("output", "-json", "microvm")) - foundation = output["microvm_foundation"] - ecr_repository_uri = output["ecr_repo"] - repository_name = ecr_repository_uri.rsplit("/", 1)[-1] - docker_registry = "localhost:4566" - image_tag = "latest" - docker_base_image = f"{docker_registry}/{repository_name}:{image_tag}" - ubuntu_image = ( - ecr_repository_uri - if ":" in ecr_repository_uri.rsplit("/", 1)[-1] - else f"{ecr_repository_uri}:{image_tag}" - ) - image_root = context.source_root / "images" / "microvm-ubuntu" - image_context = image_root / "packer" / "scripts" / "microvm" / "image" - lifecycle_hook_zip = ( - context.source_root - / "lambdas" - / "services" - / "microvm-lifecycle-hooks" - / "microvm-lifecycle-hooks.zip" - ) - - context.run( - [ - "bash", - "-o", - "pipefail", - "-c", - "aws ecr get-login-password | " - f"docker login --username AWS --password-stdin {docker_registry}", - ], - stream=True, - ) - context.run(["docker", "pull", "--platform", "linux/arm64", "ubuntu:24.04"], stream=True) - context.run(["docker", "tag", "ubuntu:24.04", docker_base_image], stream=True) - context.run(["docker", "push", docker_base_image], stream=True) - - context.environment.update( - { - "MICROVM_ARTIFACT_BUCKET": foundation["artifact_bucket_name"], - "MICROVM_BUILD_ROLE_ARN": foundation["build_role_arn"], - "MICROVM_EGRESS_NETWORK_CONNECTOR_ARN": foundation["connector_arns"]["ministack"], - "MICROVM_IMAGE_NAME": "micro-ubuntu24", - "MICROVM_MEMORY_MIB": "8192", - "MICROVM_IDEMPOTENCY_NONCE": context.environment.get( - "MICROVM_IDEMPOTENCY_NONCE", "ministack-smoke" - ), - "MICROVM_LOG_GROUP": output.get("log_group", "/aws/lambda/microvms/ubuntu24"), - "MICROVM_UBUNTU_IMAGE": ubuntu_image, - "MICROVM_LIFECYCLE_HOOK_ZIP": str(lifecycle_hook_zip), - } - ) + with context.step("Test Packer"): + output = json.loads(context.terraform("output", "-json", "microvm")) + foundation = output["microvm_foundation"] + ecr_repository_uri = output["ecr_repo"] + repository_name = ecr_repository_uri.rsplit("/", 1)[-1] + docker_registry = "localhost:4566" + image_tag = "latest" + docker_base_image = f"{docker_registry}/{repository_name}:{image_tag}" + ubuntu_image = ( + ecr_repository_uri + if ":" in ecr_repository_uri.rsplit("/", 1)[-1] + else f"{ecr_repository_uri}:{image_tag}" + ) + image_root = context.source_root / "images" / "microvm-ubuntu" + image_context = image_root / "packer" / "scripts" / "microvm" / "image" + lifecycle_hook_zip = ( + context.source_root + / "lambdas" + / "services" + / "microvm-lifecycle-hooks" + / "microvm-lifecycle-hooks.zip" + ) - context.run(["packer", "build", "."], cwd=image_root, stream=True) + with context.step("Build base image"): + context.run( + [ + "bash", + "-o", + "pipefail", + "-c", + "aws ecr get-login-password | " + f"docker login --username AWS --password-stdin {docker_registry}", + ], + stream=True, + ) + context.run(["docker", "pull", "--platform", "linux/arm64", "ubuntu:24.04"], stream=True) + context.run(["docker", "tag", "ubuntu:24.04", docker_base_image], stream=True) + context.run(["docker", "push", docker_base_image], stream=True) + + context.environment.update( + { + "MICROVM_ARTIFACT_BUCKET": foundation["artifact_bucket_name"], + "MICROVM_BUILD_ROLE_ARN": foundation["build_role_arn"], + "MICROVM_EGRESS_NETWORK_CONNECTOR_ARN": foundation["connector_arns"]["ministack"], + "MICROVM_IMAGE_NAME": "micro-ubuntu24", + "MICROVM_MEMORY_MIB": "8192", + "MICROVM_IDEMPOTENCY_NONCE": context.environment.get( + "MICROVM_IDEMPOTENCY_NONCE", "ministack-smoke" + ), + "MICROVM_LOG_GROUP": output.get("log_group", "/aws/lambda/microvms/ubuntu24"), + "MICROVM_UBUNTU_IMAGE": ubuntu_image, + "MICROVM_LIFECYCLE_HOOK_ZIP": str(lifecycle_hook_zip), + } + ) - shutil.copy2(lifecycle_hook_zip, image_context / "microvm-lifecycle-hooks.zip") - context.run( - [ - "docker", - "build", - "--platform", - "linux/arm64", - "-f", - str(image_context / "ubuntu24.arm64.Dockerfile"), - "--build-arg", - f"UBUNTU_IMAGE={docker_base_image}", - "--tag", - MICROVM_HOOK_CONTAINER, - str(image_context), - ], - stream=True, - ) + with context.step("Build MicroVM image"): + context.run(["packer", "build", "."], cwd=image_root, stream=True) + + with context.step("Build lifecycle-hook image"): + shutil.copy2(lifecycle_hook_zip, image_context / "microvm-lifecycle-hooks.zip") + context.run( + [ + "docker", + "build", + "--platform", + "linux/arm64", + "-f", + str(image_context / "ubuntu24.arm64.Dockerfile"), + "--build-arg", + f"UBUNTU_IMAGE={docker_base_image}", + "--tag", + MICROVM_HOOK_CONTAINER, + str(image_context), + ], + stream=True, + ) + + self._start_microvm_hook(context) + self._runner_image_built = True - context.run(["docker", "rm", "--force", MICROVM_HOOK_CONTAINER], check=False) - context.run( - [ - "docker", - "run", - "--detach", - "--rm", - "--platform", - "linux/arm64", - "--name", - MICROVM_HOOK_CONTAINER, - "--network", - "ministack-mockserver-debug", - "--add-host=host.docker.internal:host-gateway", - "--publish", - f"{MICROVM_HOOK_PORT}:8080", - "--env", - "AWS_ENDPOINT_URL=http://host.docker.internal:4566", - "--env", - "AWS_REGION=eu-west-1", - "--env", - "AWS_DEFAULT_REGION=eu-west-1", - "--env", - "AWS_ACCESS_KEY_ID=000000000000", - "--env", - "AWS_SECRET_ACCESS_KEY=test", - "--env", - "MICROVM_ID=ministack-microvm", - "--env", - f"RUNNER_CONFIG_SSM_PATH={self.runner_config_path}", - MICROVM_HOOK_CONTAINER, - ], - stream=True, - ) - context.wait_for( - lambda: context.run( + def _start_microvm_hook(self, context: SmokeContext) -> None: + with context.step("Start lifecycle-hook container"): + context.run(["docker", "rm", "--force", MICROVM_HOOK_CONTAINER], check=False) + context.run( [ - "curl", - "--fail", - "--silent", - "--show-error", - "--request", - "POST", - f"http://127.0.0.1:{MICROVM_HOOK_PORT}/aws/lambda-microvms/runtime/v1/ready", + "docker", + "run", + "--detach", + "--rm", + "--platform", + "linux/arm64", + "--name", + MICROVM_HOOK_CONTAINER, + "--network", + "ministack-mockserver-debug", + "--add-host=host.docker.internal:host-gateway", + "--publish", + f"{MICROVM_HOOK_PORT}:8080", + "--env", + "AWS_ENDPOINT_URL=http://host.docker.internal:4566", + "--env", + "AWS_REGION=eu-west-1", + "--env", + "AWS_DEFAULT_REGION=eu-west-1", + "--env", + "AWS_ACCESS_KEY_ID=000000000000", + "--env", + "AWS_SECRET_ACCESS_KEY=test", + "--env", + "MICROVM_ID=ministack-microvm", + "--env", + f"RUNNER_CONFIG_SSM_PATH={self.runner_config_path}", + MICROVM_HOOK_CONTAINER, ], - check=False, - ).returncode - == 0, - "MicroVM lifecycle hook container readiness", - attempts=30, - ) - self._runner_image_built = True + stream=True, + ) + with context.step("Wait for lifecycle-hook readiness"): + context.wait_for( + lambda: context.run( + [ + "curl", + "--fail", + "--silent", + "--show-error", + "--request", + "POST", + f"http://127.0.0.1:{MICROVM_HOOK_PORT}/aws/lambda-microvms/runtime/v1/ready", + ], + check=False, + ).returncode + == 0, + "MicroVM lifecycle hook container readiness", + attempts=30, + ) def event(self, context: SmokeContext, job_id: int, dynamic: bool) -> dict[str, Any]: value = json.loads((context.fixture_dir / "workflow_job_event.json").read_text()) @@ -334,6 +344,11 @@ def assert_scale_up(self, context: SmokeContext, resource: RunnerResource, dynam self._assert_resource(context, resource, "scale-up-lambda", dynamic) def start_scale_up_runner(self, context: SmokeContext, resource: RunnerResource) -> bool: + if self._hook_needs_restart: + with context.step("Restart lifecycle-hook container"): + self._start_microvm_hook(context) + self._hook_needs_restart = False + details = self._details(context, resource) image_arn = details.get("imageArn") image_version = details.get("imageVersion") @@ -360,7 +375,7 @@ def start_scale_up_runner(self, context: SmokeContext, resource: RunnerResource) {"microvmId": resource.identifier, "runHookPayload": run_hook_payload}, separators=(",", ":"), ) - print(f" Curling MicroVM runner hook for {resource.identifier}", flush=True) + context.progress(f"Curling MicroVM runner hook for {resource.identifier}") result = context.run( [ "curl", @@ -389,6 +404,19 @@ def start_scale_up_runner(self, context: SmokeContext, resource: RunnerResource) lambda: not context.aws("ssm", "get-parameter", "--name", parameter_name, check=False), f"MicroVM runner hook to consume {parameter_name}", ) + github_runner_id_parameter = f"{self.metadata_path}/{resource.identifier}.github-runner-id" + context.wait_for( + lambda: bool( + context.aws( + "ssm", + "get-parameter", + "--name", + github_runner_id_parameter, + check=False, + ) + ), + f"MicroVM scale-up to persist {github_runner_id_parameter}", + ) return True def wait_for_pool(self, context: SmokeContext, source: str) -> RunnerResource: @@ -410,6 +438,17 @@ def terminated() -> bool: context.wait_for(terminated, f"MicroVM {resource.identifier} termination") + def _wait_for_listed_microvm(self, context: SmokeContext, resource: RunnerResource) -> None: + def listed() -> bool: + result = context.aws("lambda-microvms", "list-microvms", check=False) or {} + return any( + item.get("microvmId") == resource.identifier + and item.get("state") in ("PENDING", "RUNNING", "SUSPENDING", "SUSPENDED") + for item in result.get("items", []) + ) + + context.wait_for(listed, f"MicroVM {resource.identifier} to appear in ListMicrovms") + def scale_down( self, context: SmokeContext, @@ -418,6 +457,7 @@ def scale_down( marker: str, active_runners: list[tuple[int, RunnerResource]], ) -> None: + self._wait_for_listed_microvm(context, resource) context.configure_runner_fixtures( self.slug, [(active_runner_id, active_resource.identifier) for active_runner_id, active_resource in active_runners], @@ -430,7 +470,7 @@ def scale_down( "MicroVM scale-down Lambda invoked", ) context.wait_for_log("/aws/lambda/multi-runner-webhook-microvm-scale-down", marker, "MicroVM scale-down Lambda started") - context.scale_down_routes(runner_id) + context.scale_down_routes(runner_id, "/aws/lambda/multi-runner-webhook-microvm-scale-down") context.configure_runner_removed(runner_id) context.assert_runner_removed(runner_id) self._wait_for_termination(context, resource) @@ -444,6 +484,7 @@ def stop_microvm_hook(self, context: SmokeContext) -> None: ) if status not in (0, 200, 404): raise RuntimeError(f"MicroVM lifecycle hook termination failed with HTTP {status}: {body}") + self._hook_needs_restart = True provider = MicrovmProvider() From 4135f149465b4a069c81f6c1630358b9715a41eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:24:37 +0000 Subject: [PATCH 30/38] docs: auto update terraform docs --- examples/multi-runner-webhook/README.md | 14 ++++++++++++-- modules/microvm-foundation/README.md | 1 + modules/webhook/README.md | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/examples/multi-runner-webhook/README.md b/examples/multi-runner-webhook/README.md index fc653a2f70..1d63205c7c 100644 --- a/examples/multi-runner-webhook/README.md +++ b/examples/multi-runner-webhook/README.md @@ -56,18 +56,27 @@ or execution-role setup steps. ## Providers -No providers. +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 6.66.0 | ## Modules | Name | Source | Version | |------|--------|---------| | [base](#module\_base) | ../base | n/a | +| [microvm\_foundation](#module\_microvm\_foundation) | ../../modules/microvm-foundation | n/a | | [runners](#module\_runners) | ../../modules/multi-runner | n/a | ## Resources -No resources. +| Name | Type | +|------|------| +| [aws_cloudwatch_log_group.aws_cloudwatch_log_group_microvm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_ecr_repository.base_ubuntu24](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_repository) | resource | +| [aws_ecr_repository_policy.repository_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_repository_policy) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.ecr_repository_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs @@ -85,6 +94,7 @@ No resources. | Name | Description | |------|-------------| +| [microvm](#output\_microvm) | n/a | | [webhook\_endpoint](#output\_webhook\_endpoint) | n/a | | [webhook\_secret](#output\_webhook\_secret) | n/a | diff --git a/modules/microvm-foundation/README.md b/modules/microvm-foundation/README.md index 9680f03622..dde8e4a2a1 100644 --- a/modules/microvm-foundation/README.md +++ b/modules/microvm-foundation/README.md @@ -132,6 +132,7 @@ No modules. | [build\_policy\_name\_prefix](#input\_build\_policy\_name\_prefix) | Name prefix for the Lambda MicroVM build policy. | `string` | n/a | yes | | [build\_role\_name\_prefix](#input\_build\_role\_name\_prefix) | Name prefix for the Lambda MicroVM build role. | `string` | n/a | yes | | [ecr\_repository\_arns](#input\_ecr\_repository\_arns) | Optional regional ECR repository ARNs from which MicroVM image builds can pull runner base images. | `set(string)` | `[]` | no | +| [force\_destroy\_artifact\_bucket](#input\_force\_destroy\_artifact\_bucket) | Whether to force destroy the S3 bucket containing Lambda MicroVM image source artifacts. | `bool` | `false` | no | | [image\_name\_prefix](#input\_image\_name\_prefix) | IAM namespace prefix reserved for externally published Lambda MicroVM image names. This module does not create or enumerate images. | `string` | n/a | yes | | [network\_connector\_operator\_role\_name\_prefix](#input\_network\_connector\_operator\_role\_name\_prefix) | Name prefix for the Lambda Network Connector operator role. | `string` | n/a | yes | | [network\_connectors](#input\_network\_connectors) | Regional Lambda MicroVM Network Connectors keyed by a stable consumer-defined identity. |
map(object({
name = string
vpc_id = string
subnet_ids = list(string)
network_protocol = optional(string, "IPv4")
}))
| n/a | yes | diff --git a/modules/webhook/README.md b/modules/webhook/README.md index b4a7d3ba28..043f0cca8e 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -88,7 +88,7 @@ yarn run dist | [repository\_white\_list](#input\_repository\_white\_list) | List of github repository full names (owner/repo\_name) that will be allowed to use the github app. Leave empty for no filtering. | `list(string)` | `[]` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the environment name will be used. | `string` | `null` | no | | [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 `allowed_keys = []`, `blocked_keys = []` (cannot be used together with `allowed_keys`), and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`. |
map(object({
arn = string
id = string
computeProvider = optional(string, "ec2")
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(object({
allowed_keys = optional(list(string), [])
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
| n/a | yes | +| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `computeProvider` defaults to `ec2` and identifies the compute provider that owns the queue. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type` or `image-version` for `ghr-microvm-image-version`. |
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(object({
allowed_keys = optional(list(string), [])
blocked_keys = optional(list(string), [])
restricted_keys = optional(map(object({
allowed = optional(list(string), [])
denied = optional(list(string), [])
max = optional(string, null)
})), {})
}), null)
})
}))
| n/a | yes | | [storage\_provider](#input\_storage\_provider) | Storage-provider configuration used by the webhook resources. |
object({
aws = object({
kms_key_id = optional(string, null)
ssm = object({
paths = object({
root = string
webhook = string
})
})
})
})
| n/a | yes | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no | | [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 | From b96a5dff146c159df2eebcef2e5634a024a64822 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 23 Sep 2026 21:29:41 +0200 Subject: [PATCH 31/38] ci: fix step to build lambdas in smoke tests --- .github/workflows/smoke-tests.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index c425d1badb..6b1e17424f 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -65,14 +65,9 @@ jobs: terraform_version: latest terraform_wrapper: false - - name: Install Lambda dependencies - working-directory: lambdas - run: yarn install --frozen-lockfile - - - name: Build smoke-test Lambda distributions - working-directory: lambdas + - name: Build Lambda distributions for smoke tests run: | - yarn dist + ./.ci/build.sh - name: Start MockServer id: mockserver From b0226c76503bd376356226b6963d173a380dacd0 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 23 Sep 2026 21:50:59 +0200 Subject: [PATCH 32/38] ci: fix path for lifecycle_hook_zip --- tests/ministack/smoke/microvm.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/ministack/smoke/microvm.py b/tests/ministack/smoke/microvm.py index a341881f4e..e65ef3c7bf 100644 --- a/tests/ministack/smoke/microvm.py +++ b/tests/ministack/smoke/microvm.py @@ -125,9 +125,7 @@ def build_runner_image(self, context: SmokeContext) -> None: image_context = image_root / "packer" / "scripts" / "microvm" / "image" lifecycle_hook_zip = ( context.source_root - / "lambdas" - / "services" - / "microvm-lifecycle-hooks" + / "lambda_output/" / "microvm-lifecycle-hooks.zip" ) From 6a5ce070ae51f2f22b41b2ecbc98061ba9cec291 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 23 Sep 2026 21:54:13 +0200 Subject: [PATCH 33/38] fix: use same envvar as v2 --- modules/runners/scale-down.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/runners/scale-down.tf b/modules/runners/scale-down.tf index ff7c91dff8..344199645d 100644 --- a/modules/runners/scale-down.tf +++ b/modules/runners/scale-down.tf @@ -47,6 +47,7 @@ resource "aws_lambda_function" "scale_down" { POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error COMPUTE_PROVIDER_TYPE = "ec2" + SSM_TOKEN_PATH = local.token_path } } From 086d96e813e481c32027ef00da1d3dba77f4030a Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 23 Sep 2026 22:27:07 +0200 Subject: [PATCH 34/38] ci: install boto3 --- .github/workflows/smoke-tests.yml | 5 ++++- images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl | 8 -------- .../packer/scripts/microvm/build-microvm-image.py | 2 +- tests/ministack/smoke/common.py | 3 ++- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 6b1e17424f..de5919668c 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -35,7 +35,7 @@ jobs: timeout-minutes: 120 services: ministack: - image: ministackorg/ministack-preview-build:pr-1808-d140da92 + image: ministackorg/ministack:latest ports: - 4566:4566 options: --add-host=host.docker.internal:host-gateway @@ -77,6 +77,9 @@ jobs: port: '1080' startup-timeout: '60' + - name: Install boto3 + run: python3 -m pip install --upgrade boto3 botocore + - name: Run webhook and pool lifecycle smoke test env: MINISTACK_GITHUB_MOCK_HOST: host.docker.internal diff --git a/images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl b/images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl index f8d0638d94..7e4965f7d0 100644 --- a/images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl +++ b/images/microvm-ubuntu/github_agent.microvm.ubuntu.pkr.hcl @@ -3,13 +3,6 @@ # while delegating packaging, regional publication, and polling to boto3. # The null builder and shell-local provisioner are Packer built-ins, so this # template intentionally has no required_plugins entry for them. - -variable "aws_data_path" { - description = "Botocore data path containing the Lambda MicroVM service model." - type = string - default = env("AWS_DATA_PATH") -} - variable "aws_region" { description = "AWS Region for the S3 artifact, Ubuntu ECR mirror, and Lambda MicroVM image." type = string @@ -96,7 +89,6 @@ build { # MICROVM_ENVIRONMENT_VARIABLES is inherited from the build step. Do not # add it here: shell-local renders environment_vars into the shell argv. environment_vars = [ - "AWS_DATA_PATH=${var.aws_data_path}", "AWS_REGION=${var.aws_region}", "MICROVM_ARTIFACT_BUCKET=${var.artifact_bucket}", "MICROVM_BUILD_ROLE_ARN=${var.build_role_arn}", diff --git a/images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py b/images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py index 81c13beb2e..c8fb9e5b6d 100755 --- a/images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py +++ b/images/microvm-ubuntu/packer/scripts/microvm/build-microvm-image.py @@ -256,7 +256,7 @@ def microvm_client(session: Any, region: str) -> Any: except Exception as error: if type(error).__name__ == 'UnknownServiceError': raise BuildError( - 'AWS_DATA_PATH must contain the Lambda MicroVM service model' + 'Version of boto3 must contain the Lambda MicroVM service model' ) from error raise diff --git a/tests/ministack/smoke/common.py b/tests/ministack/smoke/common.py index 569f476da8..0bb53f5d40 100644 --- a/tests/ministack/smoke/common.py +++ b/tests/ministack/smoke/common.py @@ -75,12 +75,13 @@ def progress(self, message: str) -> None: @contextmanager def step(self, name: str) -> Iterator[None]: - self.progress(name) github_actions = self.environment.get("GITHUB_ACTIONS") == "true" if github_actions: group_start = f"::group::{name}\n" print(group_start, end="", flush=True) self._append_log(group_start) + else: + self.progress(name) self.step_depth += 1 try: yield From 5666277d3c9abc6135f965fb638a79525cdf1862 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 23 Sep 2026 23:15:37 +0200 Subject: [PATCH 35/38] ci: fix MiniStack smoke workflow --- .github/workflows/ministack.yml | 2 +- .github/workflows/smoke-tests.yml | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ministack.yml b/.github/workflows/ministack.yml index c47cb9d048..6fd6e41d95 100644 --- a/.github/workflows/ministack.yml +++ b/.github/workflows/ministack.yml @@ -57,7 +57,7 @@ jobs: - termination-watcher services: ministack: - image: ghcr.io/ministackorg/ministack:1.5.13@sha256:ce3c906f2866ff953ce4c56f06b1fa3e453bc32e41c00de17b5f5a8672c5a42c + image: ghcr.io/ministackorg/ministack:1.5.16@sha256:9813da34285a0760477c761c1c03717e0290d259213c0ca97f551fefd87b292d ports: - 4566:4566 env: diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index de5919668c..85535f1036 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -35,7 +35,7 @@ jobs: timeout-minutes: 120 services: ministack: - image: ministackorg/ministack:latest + image: ghcr.io/ministackorg/ministack:1.5.16@sha256:9813da34285a0760477c761c1c03717e0290d259213c0ca97f551fefd87b292d ports: - 4566:4566 options: --add-host=host.docker.internal:host-gateway @@ -80,6 +80,11 @@ jobs: - name: Install boto3 run: python3 -m pip install --upgrade boto3 botocore + - name: Set up ARM64 emulation + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + with: + platforms: arm64 + - name: Run webhook and pool lifecycle smoke test env: MINISTACK_GITHUB_MOCK_HOST: host.docker.internal From 06bf86bef40aefdb72088d5321307bd3b5b97f8b Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Wed, 23 Sep 2026 23:40:32 +0200 Subject: [PATCH 36/38] fix(ministack): use host gateway for microvm hook --- tests/ministack/smoke/README.md | 7 ++++--- tests/ministack/smoke/microvm.py | 2 -- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/ministack/smoke/README.md b/tests/ministack/smoke/README.md index 9ac1e80d5a..fe13be8253 100644 --- a/tests/ministack/smoke/README.md +++ b/tests/ministack/smoke/README.md @@ -142,9 +142,10 @@ once before the lifecycle scenarios: 6. Copies the lifecycle-hook ZIP already produced by CI into the Docker build context. 7. Builds the `microvm-lifecycle-hook` ARM64 Docker image. -8. Starts the `microvm-lifecycle-hook` container on - `ministack-mockserver-debug`, publishes `8080:8080`, and waits for its - readiness endpoint. +8. Starts the `microvm-lifecycle-hook` container with Docker's default bridge + network, publishes `8080:8080`, and waits for its readiness endpoint. The + `host.docker.internal` host-gateway mapping lets it reach MiniStack on the + host-published port. After the MicroVM scale-up creates the resource, the test waits for the SSM JIT parameter created by that scale-up. It then sends the same version-1 diff --git a/tests/ministack/smoke/microvm.py b/tests/ministack/smoke/microvm.py index e65ef3c7bf..66e5d01941 100644 --- a/tests/ministack/smoke/microvm.py +++ b/tests/ministack/smoke/microvm.py @@ -199,8 +199,6 @@ def _start_microvm_hook(self, context: SmokeContext) -> None: "linux/arm64", "--name", MICROVM_HOOK_CONTAINER, - "--network", - "ministack-mockserver-debug", "--add-host=host.docker.internal:host-gateway", "--publish", f"{MICROVM_HOOK_PORT}:8080", From 3c59156a9754c80af26ac11f038b55e532c9e607 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 24 Sep 2026 00:18:24 +0200 Subject: [PATCH 37/38] fix(ministack): stabilize smoke log groups --- .github/workflows/smoke-tests.yml | 11 +++++++++++ tests/ministack/smoke/common.py | 26 ++++++++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 85535f1036..1277328ae4 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -91,3 +91,14 @@ jobs: MINISTACK_GITHUB_MOCK_PORT: "1080" MINISTACK_GITHUB_MOCK_URL: ${{ steps.mockserver.outputs.url }} run: python3 tests/ministack/run-webhook-smoke.py + + - name: Upload MiniStack smoke diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ministack-smoke-diagnostics + path: | + ministack-smoke.log + ministack-smoke-checklist.txt + if-no-files-found: warn + retention-days: 7 diff --git a/tests/ministack/smoke/common.py b/tests/ministack/smoke/common.py index 0bb53f5d40..4155376fc4 100644 --- a/tests/ministack/smoke/common.py +++ b/tests/ministack/smoke/common.py @@ -73,24 +73,34 @@ def progress(self, message: str) -> None: print(line, flush=True) self._append_log(f"{line}\n") + def _emit_github_command(self, command: str) -> None: + sys.stdout.write(f"{command}\n\n") + sys.stdout.flush() + self._append_log(f"{command}\n") + @contextmanager def step(self, name: str) -> Iterator[None]: github_actions = self.environment.get("GITHUB_ACTIONS") == "true" - if github_actions: - group_start = f"::group::{name}\n" - print(group_start, end="", flush=True) - self._append_log(group_start) + grouped = github_actions and self.step_depth < 2 + if grouped: + self._emit_github_command(f"::group::{name}") else: self.progress(name) self.step_depth += 1 + failed = False try: yield + except BaseException: + failed = True + raise finally: self.step_depth -= 1 - if github_actions: - group_end = "::endgroup::\n" - print(group_end, end="", flush=True) - self._append_log(group_end) + if grouped: + self._emit_github_command("::endgroup::") + if failed and github_actions and self.step_depth == 0: + self._emit_github_command( + f"::error::Smoke test failed in {name}; see ministack-smoke.log" + ) def _log_command_output( self, From 3b1f630ce82022f5272e82d38847c7efd8441ac6 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Thu, 24 Sep 2026 00:21:56 +0200 Subject: [PATCH 38/38] fix(ministack): use plain smoke step logging --- tests/ministack/smoke/common.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/tests/ministack/smoke/common.py b/tests/ministack/smoke/common.py index 4155376fc4..a75f582bb9 100644 --- a/tests/ministack/smoke/common.py +++ b/tests/ministack/smoke/common.py @@ -73,34 +73,14 @@ def progress(self, message: str) -> None: print(line, flush=True) self._append_log(f"{line}\n") - def _emit_github_command(self, command: str) -> None: - sys.stdout.write(f"{command}\n\n") - sys.stdout.flush() - self._append_log(f"{command}\n") - @contextmanager def step(self, name: str) -> Iterator[None]: - github_actions = self.environment.get("GITHUB_ACTIONS") == "true" - grouped = github_actions and self.step_depth < 2 - if grouped: - self._emit_github_command(f"::group::{name}") - else: - self.progress(name) + self.progress(name) self.step_depth += 1 - failed = False try: yield - except BaseException: - failed = True - raise finally: self.step_depth -= 1 - if grouped: - self._emit_github_command("::endgroup::") - if failed and github_actions and self.step_depth == 0: - self._emit_github_command( - f"::error::Smoke test failed in {name}; see ministack-smoke.log" - ) def _log_command_output( self,