Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
}

}