Skip to content
Open
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}`);
}
}
}

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