Skip to content

Commit 2d5991f

Browse files
feat(webhook): support multiple SSM parameters for runner matcher config
1 parent 83cc3f2 commit 2d5991f

File tree

8 files changed

+136
-22
lines changed

8 files changed

+136
-22
lines changed

lambdas/functions/webhook/src/ConfigLoader.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,31 @@ describe('ConfigLoader Tests', () => {
168168
'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
169169
);
170170
});
171+
172+
it('should load config successfully from multiple paths', async () => {
173+
process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2';
174+
process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret';
175+
176+
const partialMatcher1 = '[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["a"]],"exactMatch":true}}';
177+
const partialMatcher2 = ',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["b"]],"exactMatch":true}}]';
178+
179+
const combinedMatcherConfig = [
180+
{ id: '1', arn: 'arn:aws:sqs:queue1', matcherConfig: { labelMatchers: [['a']], exactMatch: true } },
181+
{ id: '2', arn: 'arn:aws:sqs:queue2', matcherConfig: { labelMatchers: [['b']], exactMatch: true } },
182+
];
183+
184+
vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
185+
if (paramPath === '/path/to/matcher/config-1') return partialMatcher1;
186+
if (paramPath === '/path/to/matcher/config-2') return partialMatcher2;
187+
if (paramPath === '/path/to/webhook/secret') return 'secret';
188+
return '';
189+
});
190+
191+
const config: ConfigWebhook = await ConfigWebhook.load();
192+
193+
expect(config.matcherConfig).toEqual(combinedMatcherConfig);
194+
expect(config.webhookSecret).toBe('secret');
195+
});
171196
});
172197

173198
describe('ConfigWebhookEventBridge', () => {
@@ -229,6 +254,30 @@ describe('ConfigLoader Tests', () => {
229254
expect(config.matcherConfig).toEqual(matcherConfig);
230255
});
231256

257+
it('should load config successfully from multiple paths', async () => {
258+
process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]';
259+
process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2';
260+
261+
const partial1 = '[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["x"]],"exactMatch":true}}';
262+
const partial2 = ',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["y"]],"exactMatch":true}}]';
263+
264+
const combined: RunnerMatcherConfig[] = [
265+
{ id: '1', arn: 'arn:aws:sqs:queue1', matcherConfig: { labelMatchers: [['x']], exactMatch: true } },
266+
{ id: '2', arn: 'arn:aws:sqs:queue2', matcherConfig: { labelMatchers: [['y']], exactMatch: true } },
267+
];
268+
269+
vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
270+
if (paramPath === '/path/to/matcher/config-1') return partial1;
271+
if (paramPath === '/path/to/matcher/config-2') return partial2;
272+
return '';
273+
});
274+
275+
const config: ConfigDispatcher = await ConfigDispatcher.load();
276+
277+
expect(config.repositoryAllowList).toEqual(['repo1', 'repo2']);
278+
expect(config.matcherConfig).toEqual(combined);
279+
});
280+
232281
it('should throw error if config loading fails', async () => {
233282
vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
234283
throw new Error(`Parameter ${paramPath} not found`);

lambdas/functions/webhook/src/ConfigLoader.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,42 @@ abstract class BaseConfig {
8787
}
8888
}
8989

90-
export class ConfigWebhook extends BaseConfig {
91-
repositoryAllowList: string[] = [];
90+
abstract class MatcherAwareConfig extends BaseConfig {
9291
matcherConfig: RunnerMatcherConfig[] = [];
92+
93+
protected async loadMatcherConfig(paramPathsEnv: string | undefined) {
94+
if (!paramPathsEnv) return;
95+
96+
const paths = paramPathsEnv.split(':').map(p => p.trim()).filter(Boolean);
97+
let combinedString = '';
98+
99+
for (const path of paths) {
100+
await this.loadParameter(path, 'matcherConfig');
101+
if (typeof this.matcherConfig === 'string') {
102+
combinedString += this.matcherConfig;
103+
}
104+
}
105+
106+
try {
107+
this.matcherConfig = JSON.parse(combinedString);
108+
} catch (error) {
109+
this.configLoadingErrors.push(`Failed to parse combined matcher config: ${(error as Error).message}`);
110+
this.matcherConfig = [];
111+
}
112+
}
113+
}
114+
115+
116+
export class ConfigWebhook extends MatcherAwareConfig {
117+
repositoryAllowList: string[] = [];
93118
webhookSecret: string = '';
94119
workflowJobEventSecondaryQueue: string = '';
95120

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

99124
await Promise.all([
100-
this.loadParameter(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH, 'matcherConfig'),
125+
this.loadMatcherConfig(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH),
101126
this.loadParameter(process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET, 'webhookSecret'),
102127
]);
103128

@@ -121,14 +146,13 @@ export class ConfigWebhookEventBridge extends BaseConfig {
121146
}
122147
}
123148

124-
export class ConfigDispatcher extends BaseConfig {
149+
export class ConfigDispatcher extends MatcherAwareConfig {
125150
repositoryAllowList: string[] = [];
126-
matcherConfig: RunnerMatcherConfig[] = [];
127151
workflowJobEventSecondaryQueue: string = ''; // Deprecated
128152

129153
async loadConfig(): Promise<void> {
130154
this.loadEnvVar(process.env.REPOSITORY_ALLOW_LIST, 'repositoryAllowList', []);
131-
await this.loadParameter(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH, 'matcherConfig');
155+
await this.loadMatcherConfig(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH);
132156

133157
validateRunnerMatcherConfig(this);
134158
}

modules/webhook/direct/variables.tf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ variable "config" {
4141
}), {})
4242
lambda_tags = optional(map(string), {})
4343
api_gw_source_arn = string
44-
ssm_parameter_runner_matcher_config = object({
44+
ssm_parameter_runner_matcher_config = list(object({
4545
name = string
4646
arn = string
4747
version = string
48-
})
48+
}))
4949
})
5050
}

modules/webhook/direct/webhook.tf

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ resource "aws_lambda_function" "webhook" {
2626
POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error
2727
PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name
2828
REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list)
29-
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.ssm_parameter_runner_matcher_config.name
30-
PARAMETER_RUNNER_MATCHER_VERSION = var.config.ssm_parameter_runner_matcher_config.version # enforce cold start after Changes in SSM parameter
29+
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name])
30+
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
3131
} : k => v if v != null
3232
}
3333
}
@@ -134,7 +134,12 @@ resource "aws_iam_role_policy" "webhook_ssm" {
134134
role = aws_iam_role.webhook_lambda.name
135135

136136
policy = templatefile("${path.module}/../policies/lambda-ssm.json", {
137-
resource_arns = jsonencode([var.config.github_app_parameters.webhook_secret.arn, var.config.ssm_parameter_runner_matcher_config.arn])
137+
resource_arns = jsonencode(
138+
concat(
139+
[var.config.github_app_parameters.webhook_secret.arn],
140+
[for p in var.config.ssm_parameter_runner_matcher_config : p.arn]
141+
)
142+
)
138143
})
139144
}
140145

modules/webhook/eventbridge/dispatcher.tf

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ resource "aws_lambda_function" "dispatcher" {
4444
POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests
4545
POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error
4646
# Parameters required for lambda configuration
47-
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.ssm_parameter_runner_matcher_config.name
48-
PARAMETER_RUNNER_MATCHER_VERSION = var.config.ssm_parameter_runner_matcher_config.version # enforce cold start after Changes in SSM parameter
47+
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name])
48+
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
4949
REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list)
5050
} : k => v if v != null
5151
}
@@ -129,7 +129,12 @@ resource "aws_iam_role_policy" "dispatcher_ssm" {
129129
role = aws_iam_role.dispatcher_lambda.name
130130

131131
policy = templatefile("${path.module}/../policies/lambda-ssm.json", {
132-
resource_arns = jsonencode([var.config.ssm_parameter_runner_matcher_config.arn])
132+
resource_arns = jsonencode(
133+
concat(
134+
[var.config.github_app_parameters.webhook_secret.arn],
135+
[for p in var.config.ssm_parameter_runner_matcher_config : p.arn]
136+
)
137+
)
133138
})
134139
}
135140

modules/webhook/eventbridge/variables.tf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ variable "config" {
4141
}), {})
4242
lambda_tags = optional(map(string), {})
4343
api_gw_source_arn = string
44-
ssm_parameter_runner_matcher_config = object({
44+
ssm_parameter_runner_matcher_config = list(object({
4545
name = string
4646
arn = string
4747
version = string
48-
})
48+
}))
4949
accept_events = optional(list(string), null)
5050
})
5151
}

modules/webhook/eventbridge/webhook.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ resource "aws_lambda_function" "webhook" {
3131
ACCEPT_EVENTS = jsonencode(var.config.accept_events)
3232
EVENT_BUS_NAME = aws_cloudwatch_event_bus.main.name
3333
PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name
34-
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.ssm_parameter_runner_matcher_config.name
34+
PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name])
3535
} : k => v if v != null
3636
}
3737
}

modules/webhook/webhook.tf

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,31 @@ locals {
44

55
# sorted list
66
runner_matcher_config_sorted = [for k in sort(keys(local.runner_matcher_config)) : local.runner_matcher_config[k]]
7+
8+
# Encode the sorted matcher config as JSON
9+
matcher_json = jsonencode(local.runner_matcher_config_sorted)
10+
11+
# Set max chunk size based on SSM tier
12+
# AWS SSM limits:
13+
# - Standard: 4096 bytes
14+
# - Advanced: 8192 bytes
15+
# We leave a small safety margin to avoid hitting the exact limit
16+
# (e.g., escaped characters or minor overhead could exceed the limit)
17+
max_chunk_size = var.matcher_config_parameter_store_tier == "Advanced" ? 8000 : 4000
18+
19+
# Split JSON into chunks safely under the SSM limit
20+
matcher_chunks = [
21+
for i in range(0, length(local.matcher_json), local.max_chunk_size) :
22+
substr(local.matcher_json, i, local.max_chunk_size)
23+
]
724
}
825

926
resource "aws_ssm_parameter" "runner_matcher_config" {
10-
name = "${var.ssm_paths.root}/${var.ssm_paths.webhook}/runner-matcher-config"
27+
for_each = { for idx, val in local.matcher_chunks : idx => val }
28+
29+
name = "${var.ssm_paths.root}/${var.ssm_paths.webhook}/runner-matcher-config${length(local.matcher_chunks) > 1 ? "-${each.key}" : ""}"
1130
type = "String"
12-
value = jsonencode(local.runner_matcher_config_sorted)
31+
value = each.value
1332
tier = var.matcher_config_parameter_store_tier
1433
}
1534

@@ -46,7 +65,13 @@ module "direct" {
4665
lambda_tags = var.lambda_tags,
4766
matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier,
4867
api_gw_source_arn = "${aws_apigatewayv2_api.webhook.execution_arn}/*/*/${local.webhook_endpoint}"
49-
ssm_parameter_runner_matcher_config = aws_ssm_parameter.runner_matcher_config
68+
ssm_parameter_runner_matcher_config = [
69+
for p in aws_ssm_parameter.runner_matcher_config : {
70+
name = p.name
71+
arn = p.arn
72+
version = p.version
73+
}
74+
]
5075
}
5176
}
5277

@@ -81,8 +106,14 @@ module "eventbridge" {
81106
tracing_config = var.tracing_config,
82107
lambda_tags = var.lambda_tags,
83108
api_gw_source_arn = "${aws_apigatewayv2_api.webhook.execution_arn}/*/*/${local.webhook_endpoint}"
84-
ssm_parameter_runner_matcher_config = aws_ssm_parameter.runner_matcher_config
85-
accept_events = var.eventbridge.accept_events
109+
ssm_parameter_runner_matcher_config = [
110+
for p in aws_ssm_parameter.runner_matcher_config : {
111+
name = p.name
112+
arn = p.arn
113+
version = p.version
114+
}
115+
]
116+
accept_events = var.eventbridge.accept_events
86117
}
87118

88119
}

0 commit comments

Comments
 (0)