Skip to content
Open
49 changes: 49 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,31 @@ 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');
});
});

describe('ConfigWebhookEventBridge', () => {
Expand Down Expand Up @@ -229,6 +254,30 @@ 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
36 changes: 30 additions & 6 deletions lambdas/functions/webhook/src/ConfigLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,42 @@ abstract class BaseConfig {
}
}

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

protected async loadMatcherConfig(paramPathsEnv: string | undefined) {
if (!paramPathsEnv) return;

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

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

try {
this.matcherConfig = JSON.parse(combinedString);
} catch (error) {
this.configLoadingErrors.push(`Failed to parse combined matcher config: ${(error as Error).message}`);
this.matcherConfig = [];
}
}
}


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 +146,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
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
11 changes: 8 additions & 3 deletions modules/webhook/eventbridge/dispatcher.tf
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ resource "aws_lambda_function" "dispatcher" {
POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests
POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error
# Parameters required for lambda configuration
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = 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
REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list)
} : k => v if v != null
}
Expand Down Expand Up @@ -129,7 +129,12 @@ resource "aws_iam_role_policy" "dispatcher_ssm" {
role = aws_iam_role.dispatcher_lambda.name

policy = templatefile("${path.module}/../policies/lambda-ssm.json", {
resource_arns = jsonencode([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
4 changes: 2 additions & 2 deletions modules/webhook/eventbridge/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ 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
})
}))
accept_events = optional(list(string), null)
})
}
2 changes: 1 addition & 1 deletion modules/webhook/eventbridge/webhook.tf
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ resource "aws_lambda_function" "webhook" {
ACCEPT_EVENTS = jsonencode(var.config.accept_events)
EVENT_BUS_NAME = aws_cloudwatch_event_bus.main.name
PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.ssm_parameter_runner_matcher_config.name
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name])
} : k => v if v != null
}
}
Expand Down
41 changes: 36 additions & 5 deletions modules/webhook/webhook.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,31 @@ locals {

# sorted list
runner_matcher_config_sorted = [for k in sort(keys(local.runner_matcher_config)) : local.runner_matcher_config[k]]

# Encode the sorted matcher config as JSON
matcher_json = jsonencode(local.runner_matcher_config_sorted)

# Set max chunk size based on SSM tier
# AWS SSM limits:
# - Standard: 4096 bytes
# - Advanced: 8192 bytes
# We leave a small safety margin to avoid hitting the exact limit
# (e.g., escaped characters or minor overhead could exceed the limit)
max_chunk_size = var.matcher_config_parameter_store_tier == "Advanced" ? 8000 : 4000

# Split JSON into chunks safely under the SSM limit
matcher_chunks = [
for i in range(0, length(local.matcher_json), local.max_chunk_size) :
substr(local.matcher_json, i, local.max_chunk_size)
]
}

resource "aws_ssm_parameter" "runner_matcher_config" {
name = "${var.ssm_paths.root}/${var.ssm_paths.webhook}/runner-matcher-config"
for_each = { for idx, val in local.matcher_chunks : idx => val }

name = "${var.ssm_paths.root}/${var.ssm_paths.webhook}/runner-matcher-config${length(local.matcher_chunks) > 1 ? "-${each.key}" : ""}"
type = "String"
value = jsonencode(local.runner_matcher_config_sorted)
value = each.value
tier = var.matcher_config_parameter_store_tier
}

Expand Down Expand Up @@ -46,7 +65,13 @@ module "direct" {
lambda_tags = var.lambda_tags,
matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier,
api_gw_source_arn = "${aws_apigatewayv2_api.webhook.execution_arn}/*/*/${local.webhook_endpoint}"
ssm_parameter_runner_matcher_config = aws_ssm_parameter.runner_matcher_config
ssm_parameter_runner_matcher_config = [
for p in aws_ssm_parameter.runner_matcher_config : {
name = p.name
arn = p.arn
version = p.version
}
]
}
}

Expand Down Expand Up @@ -81,8 +106,14 @@ module "eventbridge" {
tracing_config = var.tracing_config,
lambda_tags = var.lambda_tags,
api_gw_source_arn = "${aws_apigatewayv2_api.webhook.execution_arn}/*/*/${local.webhook_endpoint}"
ssm_parameter_runner_matcher_config = aws_ssm_parameter.runner_matcher_config
accept_events = var.eventbridge.accept_events
ssm_parameter_runner_matcher_config = [
for p in aws_ssm_parameter.runner_matcher_config : {
name = p.name
arn = p.arn
version = p.version
}
]
accept_events = var.eventbridge.accept_events
}

}