diff --git a/README.md b/README.md
index d028682a66..1c7dec9270 100644
--- a/README.md
+++ b/README.md
@@ -146,7 +146,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| [instance\_max\_spot\_price](#input\_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. | `string` | `null` | no |
| [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no |
| [instance\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used for runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no |
-| [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the instance termination watcher. This feature is Beta, changes will not trigger a major release as long in beta.
`enable`: Enable or disable the spot termination watcher.
'features': Enable or disable features of the termination watcher.
`memory_size`: Memory size limit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`timeout`: Time out of the lambda in seconds.
`zip`: File location of the lambda zip file. |
object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
}) | `{}` | no |
+| [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the instance termination watcher. This feature is Beta, changes will not trigger a major release as long in beta.object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
}) | `{}` | no |
| [instance\_types](#input\_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). | `list(string)` | [| no | | [job\_queue\_retention\_in\_seconds](#input\_job\_queue\_retention\_in\_seconds) | The number of seconds the job is held in the queue before it is purged. | `number` | `86400` | no | | [job\_retry](#input\_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.
"m5.large",
"c5.large"
]
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)
}) | `{}` | no |
diff --git a/lambdas/functions/termination-watcher/package.json b/lambdas/functions/termination-watcher/package.json
index e557d057cd..87622843a9 100644
--- a/lambdas/functions/termination-watcher/package.json
+++ b/lambdas/functions/termination-watcher/package.json
@@ -24,8 +24,15 @@
},
"dependencies": {
"@aws-github-runner/aws-powertools-util": "*",
+ "@aws-github-runner/aws-ssm-util": "*",
"@aws-sdk/client-ec2": "^3.1009.0",
- "@middy/core": "^6.4.5"
+ "@aws-sdk/client-sqs": "^3.1009.0",
+ "@middy/core": "^6.4.5",
+ "@octokit/auth-app": "8.2.0",
+ "@octokit/core": "7.0.6",
+ "@octokit/plugin-throttling": "11.0.3",
+ "@octokit/request": "^9.2.2",
+ "@octokit/rest": "22.0.1"
},
"nx": {
"includedScripts": [
diff --git a/lambdas/functions/termination-watcher/src/ConfigResolver.test.ts b/lambdas/functions/termination-watcher/src/ConfigResolver.test.ts
index 9aebb0588f..a614399066 100644
--- a/lambdas/functions/termination-watcher/src/ConfigResolver.test.ts
+++ b/lambdas/functions/termination-watcher/src/ConfigResolver.test.ts
@@ -37,6 +37,8 @@ describe('Test ConfigResolver', () => {
delete process.env.ENABLE_METRICS_SPOT_WARNING;
delete process.env.PREFIX;
delete process.env.TAG_FILTERS;
+ delete process.env.ENABLE_RUNNER_DEREGISTRATION;
+ delete process.env.GHES_URL;
});
it(description, async () => {
@@ -55,4 +57,29 @@ describe('Test ConfigResolver', () => {
expect(config.tagFilters).toEqual(output.tagFilters);
});
});
+
+ describe('runner deregistration config', () => {
+ beforeEach(() => {
+ delete process.env.ENABLE_RUNNER_DEREGISTRATION;
+ delete process.env.GHES_URL;
+ });
+
+ it('should default to disabled', () => {
+ const config = new Config();
+ expect(config.enableRunnerDeregistration).toBe(false);
+ expect(config.ghesApiUrl).toBe('');
+ });
+
+ it('should enable deregistration when env var is true', () => {
+ process.env.ENABLE_RUNNER_DEREGISTRATION = 'true';
+ const config = new Config();
+ expect(config.enableRunnerDeregistration).toBe(true);
+ });
+
+ it('should set GHES URL when provided', () => {
+ process.env.GHES_URL = 'https://github.internal.co/api/v3';
+ const config = new Config();
+ expect(config.ghesApiUrl).toBe('https://github.internal.co/api/v3');
+ });
+ });
});
diff --git a/lambdas/functions/termination-watcher/src/ConfigResolver.ts b/lambdas/functions/termination-watcher/src/ConfigResolver.ts
index 9e98b2a20a..949cefc9fb 100644
--- a/lambdas/functions/termination-watcher/src/ConfigResolver.ts
+++ b/lambdas/functions/termination-watcher/src/ConfigResolver.ts
@@ -5,6 +5,8 @@ export class Config {
createSpotTerminationMetric: boolean;
tagFilters: Recordobject({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
}) | n/a | yes |
| [iam\_overrides](#input\_iam\_overrides) | This map provides the possibility to override some IAM defaults. The following attributes are supported: `instance_profile_name` overrides the instance profile name used in the launch template. `runner_role_arn` overrides the IAM role ARN used for the runner instances. | object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}) | {
"instance_profile_name": null,
"override_instance_profile": false,
"override_runner_role": false,
"runner_role_arn": null
} | no |
| [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no |
-| [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the spot termination watcher lambda function. This feature is Beta, changes will not trigger a major release as long in beta.object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
}) | `{}` | no |
+| [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the spot termination watcher lambda function. This feature is Beta, changes will not trigger a major release as long in beta.object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
enable_runner_deregistration = optional(bool, true)
environment_variables = optional(map(string), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
}) | `{}` | no |
| [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no |
| [kms\_key\_arn](#input\_kms\_key\_arn) | Optional CMK Key ARN to be used for Parameter Store. | `string` | `null` | no |
| [lambda\_architecture](#input\_lambda\_architecture) | AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions. | `string` | `"arm64"` | no |
diff --git a/modules/multi-runner/termination-watcher.tf b/modules/multi-runner/termination-watcher.tf
index 5ddd4495bb..31e51cd216 100644
--- a/modules/multi-runner/termination-watcher.tf
+++ b/modules/multi-runner/termination-watcher.tf
@@ -1,23 +1,30 @@
locals {
lambda_instance_termination_watcher = {
- prefix = var.prefix
- tags = local.tags
- aws_partition = var.aws_partition
- architecture = var.lambda_architecture
- principals = var.lambda_principals
- runtime = var.lambda_runtime
- security_group_ids = var.lambda_security_group_ids
- subnet_ids = var.lambda_subnet_ids
- log_level = var.log_level
- log_class = var.log_class
- logging_kms_key_id = var.logging_kms_key_id
- logging_retention_in_days = var.logging_retention_in_days
- role_path = var.role_path
- role_permissions_boundary = var.role_permissions_boundary
- s3_bucket = var.lambda_s3_bucket
- tracing_config = var.tracing_config
- lambda_tags = var.lambda_tags
- metrics = var.metrics
+ prefix = var.prefix
+ tags = local.tags
+ aws_partition = var.aws_partition
+ architecture = var.lambda_architecture
+ principals = var.lambda_principals
+ runtime = var.lambda_runtime
+ security_group_ids = var.lambda_security_group_ids
+ subnet_ids = var.lambda_subnet_ids
+ log_level = var.log_level
+ log_class = var.log_class
+ logging_kms_key_id = var.logging_kms_key_id
+ logging_retention_in_days = var.logging_retention_in_days
+ role_path = var.role_path
+ role_permissions_boundary = var.role_permissions_boundary
+ s3_bucket = var.lambda_s3_bucket
+ tracing_config = var.tracing_config
+ lambda_tags = var.lambda_tags
+ metrics = var.metrics
+ enable_runner_deregistration = var.instance_termination_watcher.enable_runner_deregistration
+ github_app_parameters = var.instance_termination_watcher.enable_runner_deregistration ? {
+ id = local.github_app_parameters.id
+ key_base64 = local.github_app_parameters.key_base64
+ } : null
+ ghes_url = var.ghes_url
+ environment_variables = var.instance_termination_watcher.environment_variables
}
}
diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf
index ace6654d29..ec9af676e8 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -726,6 +726,8 @@ variable "instance_termination_watcher" {
Configuration for the spot termination watcher lambda function. This feature is Beta, changes will not trigger a major release as long in beta.
`enable`: Enable or disable the spot termination watcher.
+ `enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
+ `environment_variables`: Additional environment variables to merge into the Lambda configuration.
`memory_size`: Memory size limit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
@@ -739,11 +741,13 @@ variable "instance_termination_watcher" {
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
- memory_size = optional(number, null)
- s3_key = optional(string, null)
- s3_object_version = optional(string, null)
- timeout = optional(number, null)
- zip = optional(string, null)
+ enable_runner_deregistration = optional(bool, true)
+ environment_variables = optional(map(string), {})
+ memory_size = optional(number, null)
+ s3_key = optional(string, null)
+ s3_object_version = optional(string, null)
+ timeout = optional(number, null)
+ zip = optional(string, null)
})
default = {}
}
diff --git a/modules/termination-watcher/README.md b/modules/termination-watcher/README.md
index dc6049ffec..4cdf37f13b 100644
--- a/modules/termination-watcher/README.md
+++ b/modules/termination-watcher/README.md
@@ -65,29 +65,42 @@ yarn run dist
## Providers
-No providers.
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | >= 6.21 |
## Modules
| Name | Source | Version |
|------|--------|---------|
+| [deregister\_retry\_lambda](#module\_deregister\_retry\_lambda) | ../lambda | n/a |
| [termination\_handler](#module\_termination\_handler) | ./termination | n/a |
| [termination\_notification](#module\_termination\_notification) | ./notification | n/a |
## Resources
-No resources.
+| Name | Type |
+|------|------|
+| [aws_iam_role_policy.deregister_retry_ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
+| [aws_iam_role_policy.deregister_retry_sqs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
+| [aws_iam_role_policy.deregister_retry_ssm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
+| [aws_iam_role_policy.notification_sqs_send](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
+| [aws_iam_role_policy.termination_sqs_send](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
+| [aws_lambda_event_source_mapping.deregister_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource |
+| [aws_sqs_queue.deregister_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource |
+| [aws_sqs_queue.deregister_retry_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource |
## Inputs
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
-| [config](#input\_config) | Configuration for the spot termination watcher.object({
aws_partition = optional(string, null)
architecture = optional(string, null)
environment_variables = optional(map(string), {})
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_spot_termination = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, null)
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
tag_filters = optional(map(string), null)
tags = optional(map(string), {})
timeout = optional(number, null)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
}) | n/a | yes |
+| [config](#input\_config) | Configuration for the spot termination watcher.object({
aws_partition = optional(string, null)
architecture = optional(string, null)
environment_variables = optional(map(string), {})
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
log_class = optional(string, "STANDARD")
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_spot_termination = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, null)
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
tag_filters = optional(map(string), null)
tags = optional(map(string), {})
timeout = optional(number, null)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
enable_runner_deregistration = optional(bool, false)
github_app_parameters = optional(object({
id = map(string)
key_base64 = map(string)
}), null)
ghes_url = optional(string, null)
}) | n/a | yes |
## Outputs
| Name | Description |
|------|-------------|
+| [deregister\_retry](#output\_deregister\_retry) | n/a |
| [spot\_termination\_handler](#output\_spot\_termination\_handler) | n/a |
| [spot\_termination\_notification](#output\_spot\_termination\_notification) | n/a |
diff --git a/modules/termination-watcher/deregister-retry.tf b/modules/termination-watcher/deregister-retry.tf
new file mode 100644
index 0000000000..921d7abf5e
--- /dev/null
+++ b/modules/termination-watcher/deregister-retry.tf
@@ -0,0 +1,155 @@
+# SQS-based deregistration retry for runners that return 422 (busy executing a job).
+# When a runner can't be deregistered immediately, the termination-watcher Lambda
+# sends a message to this queue with a 5-minute delay. By the time the message
+# becomes visible, the EC2 instance has terminated and the runner appears offline,
+# allowing clean GitHub API deletion.
+
+# Dead-letter queue — messages that fail after 3 attempts land here for investigation
+resource "aws_sqs_queue" "deregister_retry_dlq" {
+ count = local.enable_runner_deregistration ? 1 : 0
+
+ name = "${var.config.prefix}-deregister-retry-dlq"
+ message_retention_seconds = 1209600 # 14 days
+ tags = var.config.tags
+}
+
+# Main retry queue — 5-minute delivery delay gives EC2 time to terminate
+resource "aws_sqs_queue" "deregister_retry" {
+ count = local.enable_runner_deregistration ? 1 : 0
+
+ name = "${var.config.prefix}-deregister-retry"
+ delay_seconds = 300 # 5 minutes
+ message_retention_seconds = 86400 # 24 hours
+ visibility_timeout_seconds = 60 # Lambda timeout + buffer
+ tags = var.config.tags
+
+ redrive_policy = jsonencode({
+ deadLetterTargetArn = aws_sqs_queue.deregister_retry_dlq[0].arn
+ maxReceiveCount = 3
+ })
+}
+
+# Dedicated Lambda function for processing SQS retry messages.
+# Uses the same code package as the termination-watcher but with
+# handler index.deregisterRetry (SQS event handler).
+module "deregister_retry_lambda" {
+ count = local.enable_runner_deregistration ? 1 : 0
+ source = "../lambda"
+
+ lambda = merge(local.config, {
+ name = "deregister-retry"
+ handler = "index.deregisterRetry"
+ environment_variables = merge(
+ local.deregistration_env_vars,
+ var.config.environment_variables,
+ {
+ DEREGISTER_RETRY_QUEUE_URL = aws_sqs_queue.deregister_retry[0].url
+ TAG_FILTERS = jsonencode(var.config.tag_filters)
+ }
+ )
+ })
+}
+
+# SQS event source mapping — triggers the retry Lambda when messages arrive
+resource "aws_lambda_event_source_mapping" "deregister_retry" {
+ count = local.enable_runner_deregistration ? 1 : 0
+
+ event_source_arn = aws_sqs_queue.deregister_retry[0].arn
+ function_name = module.deregister_retry_lambda[0].lambda.function.arn
+ batch_size = 1 # Process one retry at a time to avoid GitHub rate limits
+ enabled = true
+}
+
+# IAM: Allow the retry Lambda to receive/delete from the retry queue
+resource "aws_iam_role_policy" "deregister_retry_sqs" {
+ count = local.enable_runner_deregistration ? 1 : 0
+
+ name = "sqs-deregister-retry"
+ role = module.deregister_retry_lambda[0].lambda.role.name
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = [
+ "sqs:ReceiveMessage",
+ "sqs:DeleteMessage",
+ "sqs:GetQueueAttributes",
+ "sqs:SendMessage"
+ ]
+ Resource = [
+ aws_sqs_queue.deregister_retry[0].arn,
+ aws_sqs_queue.deregister_retry_dlq[0].arn
+ ]
+ }
+ ]
+ })
+}
+
+# IAM: Allow the retry Lambda to read SSM parameters (GitHub App credentials)
+resource "aws_iam_role_policy" "deregister_retry_ssm" {
+ count = local.enable_runner_deregistration ? 1 : 0
+
+ name = "ssm-deregister-retry"
+ role = module.deregister_retry_lambda[0].lambda.role.name
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["ssm:GetParameter"]
+ Resource = local.ssm_parameter_arns
+ }
+ ]
+ })
+}
+
+# IAM: Allow the retry Lambda to describe EC2 instances (for tag lookups)
+resource "aws_iam_role_policy" "deregister_retry_ec2" {
+ count = local.enable_runner_deregistration ? 1 : 0
+
+ name = "ec2-deregister-retry"
+ role = module.deregister_retry_lambda[0].lambda.role.name
+
+ policy = templatefile("${path.module}/policies/lambda.json", {})
+}
+
+# IAM: Allow the notification Lambda to send messages to the retry queue
+resource "aws_iam_role_policy" "notification_sqs_send" {
+ count = local.enable_runner_deregistration && var.config.features.enable_spot_termination_notification_watcher ? 1 : 0
+
+ name = "sqs-deregister-retry-send"
+ role = module.termination_notification[0].lambda.role.name
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["sqs:SendMessage"]
+ Resource = aws_sqs_queue.deregister_retry[0].arn
+ }
+ ]
+ })
+}
+
+# IAM: Allow the termination handler Lambda to send messages to the retry queue
+resource "aws_iam_role_policy" "termination_sqs_send" {
+ count = local.enable_runner_deregistration && var.config.features.enable_spot_termination_handler ? 1 : 0
+
+ name = "sqs-deregister-retry-send"
+ role = module.termination_handler[0].lambda.role.name
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["sqs:SendMessage"]
+ Resource = aws_sqs_queue.deregister_retry[0].arn
+ }
+ ]
+ })
+}
diff --git a/modules/termination-watcher/main.tf b/modules/termination-watcher/main.tf
index 1cf8ccb275..919ba3a3e5 100644
--- a/modules/termination-watcher/main.tf
+++ b/modules/termination-watcher/main.tf
@@ -2,16 +2,35 @@ locals {
lambda_zip = var.config.zip == null ? "${path.module}/../../lambdas/functions/termination-watcher/termination-watcher.zip" : var.config.zip
name = "spot-termination-watcher"
+ enable_runner_deregistration = var.config.enable_runner_deregistration && var.config.github_app_parameters != null
+
+ deregistration_env_vars = local.enable_runner_deregistration ? merge({
+ ENABLE_RUNNER_DEREGISTRATION = "true"
+ 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
+ GHES_URL = var.config.ghes_url != null ? var.config.ghes_url : ""
+ }, length(aws_sqs_queue.deregister_retry) > 0 ? {
+ DEREGISTER_RETRY_QUEUE_URL = aws_sqs_queue.deregister_retry[0].url
+ } : {}) : {}
+
+ ssm_parameter_arns = local.enable_runner_deregistration ? [
+ var.config.github_app_parameters.id.arn,
+ var.config.github_app_parameters.key_base64.arn,
+ ] : []
+
environment_variables = {
ENABLE_METRICS_SPOT_WARNING = var.config.metrics != null ? var.config.metrics.enable && var.config.metrics.metric.enable_spot_termination_warning : false
TAG_FILTERS = jsonencode(var.config.tag_filters)
}
config = merge(var.config, {
- name = local.name,
- handler = "index.interruptionWarning",
- zip = local.lambda_zip,
- environment_variables = local.environment_variables
- metrics_namespace = var.config.metrics.namespace
+ name = local.name,
+ handler = "index.interruptionWarning",
+ zip = local.lambda_zip,
+ environment_variables = local.environment_variables
+ metrics_namespace = var.config.metrics.namespace
+ _deregistration_env_vars = local.deregistration_env_vars
+ _ssm_parameter_arns = local.ssm_parameter_arns
+ _enable_runner_deregistration = local.enable_runner_deregistration
})
}
diff --git a/modules/termination-watcher/notification/main.tf b/modules/termination-watcher/notification/main.tf
index 82b961bc3c..735c34126b 100644
--- a/modules/termination-watcher/notification/main.tf
+++ b/modules/termination-watcher/notification/main.tf
@@ -4,10 +4,10 @@ locals {
config = merge(var.config, {
name = local.name,
handler = "index.interruptionWarning",
- environment_variables = {
+ environment_variables = merge({
ENABLE_METRICS_SPOT_WARNING = var.config.metrics != null ? var.config.metrics.enable && var.config.metrics.metric.enable_spot_termination_warning : false
TAG_FILTERS = jsonencode(var.config.tag_filters)
- }
+ }, var.config._deregistration_env_vars, var.config.environment_variables)
})
}
@@ -42,9 +42,67 @@ resource "aws_lambda_permission" "main" {
source_arn = aws_cloudwatch_event_rule.spot_instance_termination_warning.arn
}
+# EC2 Instance State-change Notification — catches ALL termination types
+# (scale-down, manual, spot reclamation, ASG) not just spot-specific events.
+# Uses "shutting-down" state to deregister runners while instance metadata is still available.
+# Reuses the same Lambda as the spot interruption warning handler since both event
+# types have detail['instance-id'] — the handler extracts it identically.
+resource "aws_cloudwatch_event_rule" "ec2_instance_state_change" {
+ count = var.config._enable_runner_deregistration ? 1 : 0
+
+ name = "${var.config.prefix != null ? format("%s-", var.config.prefix) : ""}instance-termination"
+ description = "EC2 Instance Termination (all causes) — deregisters runners from GitHub"
+ tags = local.config.tags
+
+ event_pattern = <