Skip to content

Commit aaa02b5

Browse files
committed
feat: add CloudEvents webhook support for AWS ECR
Adds CloudEvents v1.0 webhook handler to support AWS ECR push events via EventBridge, using the CloudEvents specification for a standardized approach instead of registry-specific handlers. Changes: - Add pkg/webhook/cloudevents.go with CloudEvents webhook handler - Add pkg/webhook/cloudevents_test.go with comprehensive test suite (60+ tests) - Add --cloudevents-webhook-secret flag to webhook/run commands - Add documentation in docs/configuration/webhook.md - Add Terraform example for EventBridge setup - Add trace logging for debugging webhook payloads - Use crypto/subtle.ConstantTimeCompare for secret validation Signed-off-by: Tanjim Hossain <[email protected]>
1 parent 73263e7 commit aaa02b5

File tree

11 files changed

+1166
-2
lines changed

11 files changed

+1166
-2
lines changed

cmd/common.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type WebhookConfig struct {
2626
GHCRSecret string
2727
QuaySecret string
2828
HarborSecret string
29+
CloudEventsSecret string
2930
RateLimitNumAllowedRequests int
3031
}
3132

@@ -117,6 +118,7 @@ func SetupWebhookServer(webhookCfg *WebhookConfig, reconciler *controller.ImageU
117118
handler.RegisterHandler(webhook.NewGHCRWebhook(webhookCfg.GHCRSecret))
118119
handler.RegisterHandler(webhook.NewHarborWebhook(webhookCfg.HarborSecret))
119120
handler.RegisterHandler(webhook.NewQuayWebhook(webhookCfg.QuaySecret))
121+
handler.RegisterHandler(webhook.NewCloudEventsWebhook(webhookCfg.CloudEventsSecret))
120122

121123
// Create webhook server
122124
server := webhook.NewWebhookServer(webhookCfg.Port, handler, reconciler)

cmd/run.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ This enables a CRD-driven approach to automated image updates with Argo CD.
304304
controllerCmd.Flags().StringVar(&webhookCfg.GHCRSecret, "ghcr-webhook-secret", env.GetStringVal("GHCR_WEBHOOK_SECRET", ""), "Secret for validating GitHub Container Registry webhooks")
305305
controllerCmd.Flags().StringVar(&webhookCfg.QuaySecret, "quay-webhook-secret", env.GetStringVal("QUAY_WEBHOOK_SECRET", ""), "Secret for validating Quay webhooks")
306306
controllerCmd.Flags().StringVar(&webhookCfg.HarborSecret, "harbor-webhook-secret", env.GetStringVal("HARBOR_WEBHOOK_SECRET", ""), "Secret for validating Harbor webhooks")
307+
controllerCmd.Flags().StringVar(&webhookCfg.CloudEventsSecret, "cloudevents-webhook-secret", env.GetStringVal("CLOUDEVENTS_WEBHOOK_SECRET", ""), "Secret for validating CloudEvents webhooks")
307308
controllerCmd.Flags().IntVar(&webhookCfg.RateLimitNumAllowedRequests, "webhook-ratelimit-allowed", env.ParseNumFromEnv("WEBHOOK_RATELIMIT_ALLOWED", 0, 0, math.MaxInt), "The number of allowed requests in an hour for webhook rate limiting, setting to 0 disables ratelimiting")
308309

309310
return controllerCmd

cmd/webhook.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ Supported registries:
4141
- Docker Hub
4242
- GitHub Container Registry (GHCR)
4343
- Quay
44-
- Harbor
44+
- Harbor
45+
- AWS ECR (via EventBridge CloudEvents)
4546
`,
4647
RunE: func(cmd *cobra.Command, args []string) error {
4748
if err := log.SetLogLevel(cfg.LogLevel); err != nil {
@@ -89,6 +90,7 @@ Supported registries:
8990
webhookCmd.Flags().StringVar(&webhookCfg.GHCRSecret, "ghcr-webhook-secret", env.GetStringVal("GHCR_WEBHOOK_SECRET", ""), "Secret for validating GitHub Container Registry webhooks")
9091
webhookCmd.Flags().StringVar(&webhookCfg.QuaySecret, "quay-webhook-secret", env.GetStringVal("QUAY_WEBHOOK_SECRET", ""), "Secret for validating Quay webhooks")
9192
webhookCmd.Flags().StringVar(&webhookCfg.HarborSecret, "harbor-webhook-secret", env.GetStringVal("HARBOR_WEBHOOK_SECRET", ""), "Secret for validating Harbor webhooks")
93+
webhookCmd.Flags().StringVar(&webhookCfg.CloudEventsSecret, "cloudevents-webhook-secret", env.GetStringVal("CLOUDEVENTS_WEBHOOK_SECRET", ""), "Secret for validating CloudEvents webhooks")
9294
webhookCmd.Flags().IntVar(&webhookCfg.RateLimitNumAllowedRequests, "webhook-ratelimit-allowed", env.ParseNumFromEnv("WEBHOOK_RATELIMIT_ALLOWED", 0, 0, math.MaxInt), "The number of allowed requests in an hour for webhook rate limiting, setting to 0 disables ratelimiting")
9395

9496
return webhookCmd
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# CloudEvents Webhook Example
2+
3+
Example Terraform configuration for setting up AWS EventBridge to send ECR push events to ArgoCD Image Updater via CloudEvents.
4+
5+
## Quick Start
6+
7+
### 1. Configure EventBridge with Terraform
8+
9+
```bash
10+
cd terraform
11+
12+
# Set your variables
13+
export TF_VAR_webhook_url="https://your-domain.com/webhook?type=cloudevents"
14+
export TF_VAR_webhook_secret="your-webhook-secret"
15+
export TF_VAR_aws_region="us-east-1"
16+
17+
# Apply the configuration
18+
terraform init
19+
terraform apply
20+
21+
# Return to parent directory
22+
cd ..
23+
```
24+
25+
### 2. Test the Webhook
26+
27+
```bash
28+
./test-webhook.sh https://your-webhook-url/webhook?type=cloudevents your-secret
29+
```
30+
31+
## Files
32+
33+
- `terraform/` - EventBridge configuration with input transformer for ECR events
34+
- `test-webhook.sh` - Script to test the webhook endpoint
35+
36+
## Documentation
37+
38+
For complete setup instructions, see the [webhook documentation](../../../docs/configuration/webhook.md#aws-ecr-via-eventbridge-cloudevents).
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
terraform {
2+
required_version = ">= 1.0"
3+
4+
required_providers {
5+
aws = {
6+
source = "hashicorp/aws"
7+
version = "~> 5.0"
8+
}
9+
}
10+
}
11+
12+
provider "aws" {
13+
region = var.aws_region
14+
}
15+
16+
# EventBridge Rule to capture ECR push events
17+
resource "aws_cloudwatch_event_rule" "ecr_push" {
18+
name = "argocd-image-updater-ecr-push"
19+
description = "Capture ECR image push events for ArgoCD Image Updater"
20+
21+
event_pattern = jsonencode({
22+
source = ["aws.ecr"]
23+
detail-type = ["ECR Image Action"]
24+
detail = {
25+
action-type = ["PUSH"]
26+
result = ["SUCCESS"]
27+
# Filter for events with image tags (excludes untagged/manifest-only pushes)
28+
image-tag = [{
29+
exists = true
30+
}]
31+
# Optional: Filter by specific repositories
32+
repository-name = length(var.ecr_repository_filter) > 0 ? var.ecr_repository_filter : null
33+
}
34+
})
35+
36+
tags = var.tags
37+
}
38+
39+
# EventBridge Connection for API authentication
40+
resource "aws_cloudwatch_event_connection" "webhook" {
41+
name = "argocd-image-updater-webhook"
42+
description = "Connection to ArgoCD Image Updater webhook"
43+
authorization_type = "API_KEY"
44+
45+
auth_parameters {
46+
api_key {
47+
key = "X-Webhook-Secret"
48+
value = var.webhook_secret
49+
}
50+
}
51+
}
52+
53+
# API Destination pointing to ArgoCD Image Updater webhook
54+
resource "aws_cloudwatch_event_api_destination" "webhook" {
55+
name = "argocd-image-updater-webhook"
56+
description = "ArgoCD Image Updater CloudEvents webhook endpoint"
57+
invocation_endpoint = var.webhook_url
58+
http_method = "POST"
59+
invocation_rate_limit_per_second = 10
60+
connection_arn = aws_cloudwatch_event_connection.webhook.arn
61+
}
62+
63+
# IAM Role for EventBridge to invoke API Destination
64+
resource "aws_iam_role" "eventbridge" {
65+
name = "argocd-image-updater-eventbridge-role"
66+
assume_role_policy = data.aws_iam_policy_document.eventbridge_assume_role.json
67+
tags = var.tags
68+
}
69+
70+
data "aws_iam_policy_document" "eventbridge_assume_role" {
71+
statement {
72+
effect = "Allow"
73+
74+
principals {
75+
type = "Service"
76+
identifiers = ["events.amazonaws.com"]
77+
}
78+
79+
actions = ["sts:AssumeRole"]
80+
}
81+
}
82+
83+
# IAM Policy for EventBridge to invoke API Destination
84+
resource "aws_iam_role_policy" "eventbridge_invoke_api_destination" {
85+
name = "invoke-api-destination"
86+
role = aws_iam_role.eventbridge.id
87+
policy = data.aws_iam_policy_document.eventbridge_invoke_api_destination.json
88+
}
89+
90+
data "aws_iam_policy_document" "eventbridge_invoke_api_destination" {
91+
statement {
92+
effect = "Allow"
93+
94+
actions = [
95+
"events:InvokeApiDestination"
96+
]
97+
98+
resources = [
99+
aws_cloudwatch_event_api_destination.webhook.arn
100+
]
101+
}
102+
}
103+
104+
# EventBridge Target with Input Transformer (ECR -> CloudEvents)
105+
resource "aws_cloudwatch_event_target" "api_destination" {
106+
rule = aws_cloudwatch_event_rule.ecr_push.name
107+
target_id = "ArgocdImageUpdaterCloudEvent"
108+
arn = aws_cloudwatch_event_api_destination.webhook.arn
109+
role_arn = aws_iam_role.eventbridge.arn
110+
111+
input_transformer {
112+
input_paths = {
113+
id = "$.id"
114+
time = "$.time"
115+
account = "$.account"
116+
region = "$.region"
117+
repo = "$.detail.repository-name"
118+
digest = "$.detail.image-digest"
119+
tag = "$.detail.image-tag"
120+
}
121+
122+
input_template = <<-EOF
123+
{
124+
"specversion": "1.0",
125+
"id": "<id>",
126+
"type": "com.amazon.ecr.image.push",
127+
"source": "urn:aws:ecr:<region>:<account>:repository/<repo>",
128+
"subject": "<repo>:<tag>",
129+
"time": "<time>",
130+
"datacontenttype": "application/json",
131+
"data": {
132+
"repositoryName": "<repo>",
133+
"imageDigest": "<digest>",
134+
"imageTag": "<tag>",
135+
"registryId": "<account>"
136+
}
137+
}
138+
EOF
139+
}
140+
141+
retry_policy {
142+
maximum_event_age_in_seconds = 3600
143+
maximum_retry_attempts = 3
144+
}
145+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
output "eventbridge_rule_arn" {
2+
description = "ARN of the EventBridge rule"
3+
value = aws_cloudwatch_event_rule.ecr_push.arn
4+
}
5+
6+
output "api_destination_arn" {
7+
description = "ARN of the API destination"
8+
value = aws_cloudwatch_event_api_destination.webhook.arn
9+
}
10+
11+
output "eventbridge_role_arn" {
12+
description = "ARN of the EventBridge IAM role"
13+
value = aws_iam_role.eventbridge.arn
14+
}
15+
16+
output "webhook_endpoint" {
17+
description = "Configured webhook endpoint URL"
18+
value = var.webhook_url
19+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
variable "aws_region" {
2+
description = "AWS region for ECR and EventBridge"
3+
type = string
4+
default = "us-east-1"
5+
}
6+
7+
variable "webhook_url" {
8+
description = "ArgoCD Image Updater webhook endpoint URL"
9+
type = string
10+
# Example: "https://image-updater-webhook.example.com/webhook?type=cloudevents"
11+
}
12+
13+
variable "webhook_secret" {
14+
description = "Secret for webhook authentication"
15+
type = string
16+
sensitive = true
17+
}
18+
19+
variable "ecr_repository_filter" {
20+
description = "List of ECR repository names to monitor (empty list = all repositories)"
21+
type = list(string)
22+
default = []
23+
}
24+
25+
variable "tags" {
26+
description = "Tags to apply to all resources"
27+
type = map(string)
28+
default = {
29+
ManagedBy = "Terraform"
30+
Purpose = "ArgoCD-Image-Updater"
31+
}
32+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/bin/bash
2+
# Test script for CloudEvents webhook
3+
# Usage: ./test-webhook.sh <webhook-url> <secret>
4+
5+
set -e
6+
7+
WEBHOOK_URL="${1:-http://localhost:8080/webhook?type=cloudevents}"
8+
SECRET="${2:-test-secret}"
9+
10+
echo "Testing CloudEvents webhook..."
11+
echo "URL: ${WEBHOOK_URL}"
12+
echo ""
13+
14+
# Test 1: ECR event
15+
echo "Test 1: AWS ECR push event"
16+
curl -X POST "${WEBHOOK_URL}" \
17+
-H "Content-Type: application/json" \
18+
-H "X-Webhook-Secret: ${SECRET}" \
19+
-d '{
20+
"specversion": "1.0",
21+
"id": "test-ecr-event-001",
22+
"type": "com.amazon.ecr.image.push",
23+
"source": "urn:aws:ecr:us-east-1:123456789012:repository/my-app",
24+
"subject": "my-app:v1.2.3",
25+
"time": "2025-11-27T10:00:00Z",
26+
"datacontenttype": "application/json",
27+
"data": {
28+
"repositoryName": "my-app",
29+
"imageDigest": "sha256:abcdef1234567890",
30+
"imageTag": "v1.2.3",
31+
"registryId": "123456789012"
32+
}
33+
}' \
34+
-w "\nHTTP Status: %{http_code}\n\n"
35+
36+
# Test 2: Generic container event
37+
echo "Test 2: Generic container push event"
38+
curl -X POST "${WEBHOOK_URL}" \
39+
-H "Content-Type: application/cloudevents+json" \
40+
-H "X-Webhook-Secret: ${SECRET}" \
41+
-d '{
42+
"specversion": "1.0",
43+
"id": "test-generic-event-001",
44+
"type": "container.image.push",
45+
"source": "https://registry.example.com",
46+
"subject": "myapp/backend:v2.0.0",
47+
"time": "2025-11-27T10:05:00Z",
48+
"datacontenttype": "application/json",
49+
"data": {
50+
"repository": "myapp/backend",
51+
"tag": "v2.0.0",
52+
"digest": "sha256:fedcba0987654321",
53+
"registryUrl": "registry.example.com"
54+
}
55+
}' \
56+
-w "\nHTTP Status: %{http_code}\n\n"
57+
58+
# Test 3: Missing secret (should fail)
59+
echo "Test 3: Missing secret (expected to fail)"
60+
curl -X POST "${WEBHOOK_URL}" \
61+
-H "Content-Type: application/json" \
62+
-d '{
63+
"specversion": "1.0",
64+
"id": "test-fail-event-001",
65+
"type": "com.amazon.ecr.image.push",
66+
"source": "urn:aws:ecr:us-east-1:123456789012:repository/test",
67+
"subject": "test:latest",
68+
"data": {
69+
"repositoryName": "test",
70+
"imageTag": "latest",
71+
"registryId": "123456789012"
72+
}
73+
}' \
74+
-w "\nHTTP Status: %{http_code}\n\n" || echo "Expected failure"
75+
76+
# Test 4: Invalid event type (should fail)
77+
echo "Test 4: Invalid event type (expected to fail)"
78+
curl -X POST "${WEBHOOK_URL}" \
79+
-H "Content-Type: application/json" \
80+
-H "X-Webhook-Secret: ${SECRET}" \
81+
-d '{
82+
"specversion": "1.0",
83+
"id": "test-invalid-event-001",
84+
"type": "com.example.database.updated",
85+
"source": "https://db.example.com",
86+
"data": {
87+
"table": "users"
88+
}
89+
}' \
90+
-w "\nHTTP Status: %{http_code}\n\n" || echo "Expected failure"
91+
92+
echo "Testing complete!"

0 commit comments

Comments
 (0)