Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| <a name="input_runner_binaries_syncer_lambda_timeout"></a> [runner\_binaries\_syncer\_lambda\_timeout](#input\_runner\_binaries\_syncer\_lambda\_timeout) | Time out of the binaries sync lambda in seconds. | `number` | `300` | no |
| <a name="input_runner_binaries_syncer_lambda_zip"></a> [runner\_binaries\_syncer\_lambda\_zip](#input\_runner\_binaries\_syncer\_lambda\_zip) | File location of the binaries sync lambda zip file. | `string` | `null` | no |
| <a name="input_runner_boot_time_in_minutes"></a> [runner\_boot\_time\_in\_minutes](#input\_runner\_boot\_time\_in\_minutes) | The minimum time for an EC2 runner to boot and register as a runner. | `number` | `5` | no |
| <a name="input_runner_cpu_options"></a> [runner\_cpu\_options](#input\_runner\_cpu\_options) | TThe CPU options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#cpu-options for details. Note that not all instance types support CPU options, see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html#instance-cpu-options | <pre>object({<br/> core_count = number<br/> threads_per_core = number<br/> })</pre> | `null` | no |
| <a name="input_runner_credit_specification"></a> [runner\_credit\_specification](#input\_runner\_credit\_specification) | The credit option for CPU usage of a T instance. Can be unset, "standard" or "unlimited". | `string` | `null` | no |
| <a name="input_runner_disable_default_labels"></a> [runner\_disable\_default\_labels](#input\_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. | `bool` | `false` | no |
| <a name="input_runner_ec2_tags"></a> [runner\_ec2\_tags](#input\_runner\_ec2\_tags) | Map of tags that will be added to the launch template instance tag specifications. | `map(string)` | `{}` | no |
Expand Down
74 changes: 74 additions & 0 deletions lambdas/functions/webhook/src/ConfigLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,54 @@ describe('ConfigLoader Tests', () => {
'Failed to load config: Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config', // eslint-disable-line max-len
);
});

it('should load config successfully from multiple paths', async () => {
process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2';
process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret';

const partialMatcher1 =
'[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["a"]],"exactMatch":true}}';
const partialMatcher2 =
',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["b"]],"exactMatch":true}}]';

const combinedMatcherConfig = [
{ id: '1', arn: 'arn:aws:sqs:queue1', matcherConfig: { labelMatchers: [['a']], exactMatch: true } },
{ id: '2', arn: 'arn:aws:sqs:queue2', matcherConfig: { labelMatchers: [['b']], exactMatch: true } },
];

vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
if (paramPath === '/path/to/matcher/config-1') return partialMatcher1;
if (paramPath === '/path/to/matcher/config-2') return partialMatcher2;
if (paramPath === '/path/to/webhook/secret') return 'secret';
return '';
});

const config: ConfigWebhook = await ConfigWebhook.load();

expect(config.matcherConfig).toEqual(combinedMatcherConfig);
expect(config.webhookSecret).toBe('secret');
});

it('should throw error if config loading fails from multiple paths', async () => {
process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2';
process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret';

const partialMatcher1 =
'[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["a"]],"exactMatch":true}}';
const partialMatcher2 =
',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["b"]],"exactMatch":true}}';

vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
if (paramPath === '/path/to/matcher/config-1') return partialMatcher1;
if (paramPath === '/path/to/matcher/config-2') return partialMatcher2;
if (paramPath === '/path/to/webhook/secret') return 'secret';
return '';
});

await expect(ConfigWebhook.load()).rejects.toThrow(
"Failed to load config: Failed to parse combined matcher config: Expected ',' or ']' after array element in JSON at position 196 (line 1 column 197)", // eslint-disable-line max-len
);
});
});

describe('ConfigWebhookEventBridge', () => {
Expand Down Expand Up @@ -229,6 +277,32 @@ describe('ConfigLoader Tests', () => {
expect(config.matcherConfig).toEqual(matcherConfig);
});

it('should load config successfully from multiple paths', async () => {
process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]';
process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2';

const partial1 =
'[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["x"]],"exactMatch":true}}';
const partial2 =
',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["y"]],"exactMatch":true}}]';

const combined: RunnerMatcherConfig[] = [
{ id: '1', arn: 'arn:aws:sqs:queue1', matcherConfig: { labelMatchers: [['x']], exactMatch: true } },
{ id: '2', arn: 'arn:aws:sqs:queue2', matcherConfig: { labelMatchers: [['y']], exactMatch: true } },
];

vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
if (paramPath === '/path/to/matcher/config-1') return partial1;
if (paramPath === '/path/to/matcher/config-2') return partial2;
return '';
});

const config: ConfigDispatcher = await ConfigDispatcher.load();

expect(config.repositoryAllowList).toEqual(['repo1', 'repo2']);
expect(config.matcherConfig).toEqual(combined);
});

it('should throw error if config loading fails', async () => {
vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
throw new Error(`Parameter ${paramPath} not found`);
Expand Down
38 changes: 32 additions & 6 deletions lambdas/functions/webhook/src/ConfigLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,44 @@ abstract class BaseConfig {
}
}

export class ConfigWebhook extends BaseConfig {
repositoryAllowList: string[] = [];
abstract class MatcherAwareConfig extends BaseConfig {
matcherConfig: RunnerMatcherConfig[] = [];

protected async loadMatcherConfig(paramPathsEnv: string) {
if (!paramPathsEnv || paramPathsEnv === 'undefined' || paramPathsEnv === 'null' || !paramPathsEnv.includes(':')) {
await this.loadParameter(paramPathsEnv, 'matcherConfig');
return;
}

const paths = paramPathsEnv
.split(':')
.map((p) => p.trim())
.filter(Boolean);
let combinedString = '';

for (const path of paths) {
await this.loadParameter(path, 'matcherConfig');
combinedString += this.matcherConfig;
}

try {
this.matcherConfig = JSON.parse(combinedString);
} catch (error) {
this.configLoadingErrors.push(`Failed to parse combined matcher config: ${(error as Error).message}`);
}
Comment on lines +94 to +114
Copy link

Copilot AI Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition logic is incorrect. When there's no colon in the path (single parameter), the function should still load the parameter, but the condition will return true and call loadParameter with the full path. However, when there IS a colon (multiple parameters), the condition returns false and proceeds to the multi-path logic. The condition should be !paramPathsEnv.includes(':') to handle single parameters correctly.

Suggested change
if (!paramPathsEnv || paramPathsEnv === 'undefined' || paramPathsEnv === 'null' || !paramPathsEnv.includes(':')) {
await this.loadParameter(paramPathsEnv, 'matcherConfig');
return;
}
const paths = paramPathsEnv
.split(':')
.map((p) => p.trim())
.filter(Boolean);
let combinedString = '';
for (const path of paths) {
await this.loadParameter(path, 'matcherConfig');
combinedString += this.matcherConfig;
}
try {
this.matcherConfig = JSON.parse(combinedString);
} catch (error) {
this.configLoadingErrors.push(`Failed to parse combined matcher config: ${(error as Error).message}`);
}
if (!paramPathsEnv || paramPathsEnv === 'undefined' || paramPathsEnv === 'null') {
// Optionally, log or handle missing matcher config path
return;
} else if (paramPathsEnv.includes(':')) {
const paths = paramPathsEnv
.split(':')
.map((p) => p.trim())
.filter(Boolean);
let combinedString = '';
for (const path of paths) {
await this.loadParameter(path, 'matcherConfig');
combinedString += this.matcherConfig;
}
try {
this.matcherConfig = JSON.parse(combinedString);
} catch (error) {
this.configLoadingErrors.push(`Failed to parse combined matcher config: ${(error as Error).message}`);
}
} else {
await this.loadParameter(paramPathsEnv, 'matcherConfig');
}

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +114
Copy link

Copilot AI Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code concatenates this.matcherConfig as a string, but matcherConfig is typed as RunnerMatcherConfig[] (an array). This will result in [object Object] being concatenated instead of the JSON string. Should concatenate the parameter value directly or store it in a temporary variable.

Suggested change
let combinedString = '';
for (const path of paths) {
await this.loadParameter(path, 'matcherConfig');
combinedString += this.matcherConfig;
}
try {
this.matcherConfig = JSON.parse(combinedString);
} catch (error) {
this.configLoadingErrors.push(`Failed to parse combined matcher config: ${(error as Error).message}`);
}
let combinedMatcherConfig: RunnerMatcherConfig[] = [];
for (const path of paths) {
await this.loadParameter(path, 'matcherConfig');
if (Array.isArray(this.matcherConfig)) {
combinedMatcherConfig.push(...this.matcherConfig);
} else {
this.configLoadingErrors.push(`Matcher config at path "${path}" is not an array`);
}
}
this.matcherConfig = combinedMatcherConfig;

Copilot uses AI. Check for mistakes.
}
}

export class ConfigWebhook extends MatcherAwareConfig {
repositoryAllowList: string[] = [];
webhookSecret: string = '';
workflowJobEventSecondaryQueue: string = '';

async loadConfig(): Promise<void> {
this.loadEnvVar(process.env.REPOSITORY_ALLOW_LIST, 'repositoryAllowList', []);

await Promise.all([
this.loadParameter(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH, 'matcherConfig'),
this.loadMatcherConfig(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH),
this.loadParameter(process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET, 'webhookSecret'),
]);

Expand All @@ -121,14 +148,13 @@ export class ConfigWebhookEventBridge extends BaseConfig {
}
}

export class ConfigDispatcher extends BaseConfig {
export class ConfigDispatcher extends MatcherAwareConfig {
repositoryAllowList: string[] = [];
matcherConfig: RunnerMatcherConfig[] = [];
workflowJobEventSecondaryQueue: string = ''; // Deprecated

async loadConfig(): Promise<void> {
this.loadEnvVar(process.env.REPOSITORY_ALLOW_LIST, 'repositoryAllowList', []);
await this.loadParameter(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH, 'matcherConfig');
await this.loadMatcherConfig(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH);

validateRunnerMatcherConfig(this);
}
Expand Down
2 changes: 1 addition & 1 deletion modules/multi-runner/README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions modules/runners/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ yarn run dist
| <a name="input_aws_region"></a> [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes |
| <a name="input_block_device_mappings"></a> [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`. | <pre>list(object({<br/> delete_on_termination = optional(bool, true)<br/> device_name = optional(string, "/dev/xvda")<br/> encrypted = optional(bool, true)<br/> iops = optional(number)<br/> kms_key_id = optional(string)<br/> snapshot_id = optional(string)<br/> throughput = optional(number)<br/> volume_size = number<br/> volume_type = optional(string, "gp3")<br/> }))</pre> | <pre>[<br/> {<br/> "volume_size": 30<br/> }<br/>]</pre> | no |
| <a name="input_cloudwatch_config"></a> [cloudwatch\_config](#input\_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. | `string` | `null` | no |
| <a name="input_cpu_options"></a> [cpu\_options](#input\_cpu\_options) | The CPU options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#cpu-options for details. Note that not all instance types support CPU options, see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html#instance-cpu-options | <pre>object({<br/> core_count = number<br/> threads_per_core = number<br/> })</pre> | `null` | no |
| <a name="input_create_service_linked_role_spot"></a> [create\_service\_linked\_role\_spot](#input\_create\_service\_linked\_role\_spot) | (optional) create the service linked role for spot instances that is required by the scale-up lambda. | `bool` | `false` | no |
| <a name="input_credit_specification"></a> [credit\_specification](#input\_credit\_specification) | The credit option for CPU usage of a T instance. Can be unset, "standard" or "unlimited". | `string` | `null` | no |
| <a name="input_disable_runner_autoupdate"></a> [disable\_runner\_autoupdate](#input\_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/) | `bool` | `false` | no |
Expand Down
2 changes: 1 addition & 1 deletion modules/webhook/direct/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ No modules.

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

## Outputs

Expand Down
4 changes: 2 additions & 2 deletions modules/webhook/direct/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ variable "config" {
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = object({
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
})
}))
})
}
11 changes: 8 additions & 3 deletions modules/webhook/direct/webhook.tf
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ resource "aws_lambda_function" "webhook" {
POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error
PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name
REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list)
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.ssm_parameter_runner_matcher_config.name
PARAMETER_RUNNER_MATCHER_VERSION = var.config.ssm_parameter_runner_matcher_config.version # enforce cold start after Changes in SSM parameter
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name])
PARAMETER_RUNNER_MATCHER_VERSION = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) # enforce cold start after Changes in SSM parameter
} : k => v if v != null
}
}
Expand Down Expand Up @@ -134,7 +134,12 @@ resource "aws_iam_role_policy" "webhook_ssm" {
role = aws_iam_role.webhook_lambda.name

policy = templatefile("${path.module}/../policies/lambda-ssm.json", {
resource_arns = jsonencode([var.config.github_app_parameters.webhook_secret.arn, var.config.ssm_parameter_runner_matcher_config.arn])
resource_arns = jsonencode(
concat(
[var.config.github_app_parameters.webhook_secret.arn],
[for p in var.config.ssm_parameter_runner_matcher_config : p.arn]
)
)
})
}

Expand Down
Loading