From db7d672f05cca2a525cbcc66cf7d64439f71c086 Mon Sep 17 00:00:00 2001 From: Nitish Kumar Date: Thu, 16 Apr 2026 16:41:31 +0530 Subject: [PATCH] feat(webhooks): add webhook support for GHCR (#26462) Signed-off-by: nitishfy --- docs/assets/ghcr-package-event.png | Bin 0 -> 11090 bytes docs/operator-manual/webhook.md | 96 ++++++- util/webhook/ghcr.go | 139 ++++++++++ util/webhook/ghcr_test.go | 180 +++++++++++++ util/webhook/registry.go | 136 ++++++++++ util/webhook/registry_test.go | 237 ++++++++++++++++++ util/webhook/testdata/ghcr-package-event.json | 19 ++ util/webhook/webhook.go | 131 +++++++--- util/webhook/webhook_test.go | 23 +- 9 files changed, 917 insertions(+), 44 deletions(-) create mode 100644 docs/assets/ghcr-package-event.png create mode 100644 util/webhook/ghcr.go create mode 100644 util/webhook/ghcr_test.go create mode 100644 util/webhook/registry.go create mode 100644 util/webhook/registry_test.go create mode 100644 util/webhook/testdata/ghcr-package-event.json diff --git a/docs/assets/ghcr-package-event.png b/docs/assets/ghcr-package-event.png new file mode 100644 index 0000000000000000000000000000000000000000..04f7edb569d51a1b008d0b18589fbc16e43fb7be GIT binary patch literal 11090 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:` For more information refer to the corresponding section in the [User Management Documentation](user-management/index.md#alternative). -## Special handling for BitBucket Cloud +### Special handling for BitBucket Cloud BitBucket does not include the list of changed files in the webhook request body. This prevents the [Manifest Paths Annotation](high_availability.md#manifest-paths-annotation) feature from working with repositories hosted on BitBucket Cloud. BitBucket provides the `diffstat` API to determine the list of changed files between two commits. @@ -126,3 +132,81 @@ For private repositories, the Argo CD webhook handler searches for a valid repos The webhook handler uses this OAuth token to make the API request to the originating server. If the Argo CD webhook handler cannot find a matching repository credential, the list of changed files would remain empty. If errors occur during the callback, the list of changed files will be empty. + +## 3. Webhook Configuration for OCI-Compliant Registries + +In addition to Git webhooks, Argo CD supports webhooks from OCI-compliant container registries. This enables instant application refresh when +new artifacts are pushed, eliminating the delay from polling. + +### GitHub Container Registry (GHCR) + +Webhooks cannot be registered directly on a GHCR image repository. Instead, `package` events are delivered from the associated GitHub repository. + +> [!NOTE] +> If your GHCR image repository is not yet linked to a GitHub repository, see [Connecting a repository to a package](https://docs.github.com/en/packages/learn-github-packages/connecting-a-repository-to-a-package). + +#### Configure the Webhook + +1. Go to your GitHub repository **Settings** → **Webhooks** → **Add webhook** +2. Set **Payload URL** to `https:///api/webhook` +3. Set **Content type** to `application/json` +4. Set **Secret** to a secure value +5. Under **Events**, select **Let me select individual events** and enable **Packages** + +> [!NOTE] +> Only `published` events for `container` package types trigger a refresh. Other package types (npm, maven, etc.) and actions are ignored. + +> [!WARNING] +> GitHub does not send `package` webhook events for artifacts with unknown media types. If your OCI artifact uses a custom or non-standard media type, the webhook will not be triggered. See [GitHub documentation on supported package types](https://docs.github.com/en/packages/learn-github-packages/about-permissions-for-github-packages). + +#### Configure the Webhook Secret + +GHCR webhooks use the same secret as GitHub Git webhooks (`webhook.github.secret`): + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: argocd-secret + namespace: argocd +type: Opaque +stringData: + webhook.github.secret: +``` + +#### Example Application + +When a OCI artifact with a known media type is pushed to GHCR, Argo CD refreshes Applications with a matching `repoURL` and `targetRevision`: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: my-app +spec: + source: + repoURL: oci://ghcr.io/myorg/myimage + targetRevision: v1.0.0 + chart: mychart + destination: + server: https://kubernetes.default.svc + namespace: default +``` + +The `targetRevision` field supports exact tags and [semver constraints](https://github.com/Masterminds/semver#checking-version-constraints): + +| Constraint | Webhook triggers on push of | +|------------|----------------------------| +| `1.0.0` | Only `1.0.0` | +| `^1.2.0` | `>=1.2.0` and `<2.0.0` (e.g., `1.2.1`, `1.9.0`) | +| `~1.2.0` | `>=1.2.0` and `<1.3.0` (e.g., `1.2.1`, `1.2.9`) | +| `>=1.0.0` | Any version `>=1.0.0` | + +#### URL Matching + +Argo CD normalizes OCI repository URLs before comparison to ensure consistent matching: + +For example, these `repoURL` values all match a webhook event for `ghcr.io/myorg/myimage`: +- `oci://ghcr.io/myorg/myimage` +- `oci://GHCR.IO/MyOrg/MyImage` +- `oci://ghcr.io/myorg/myimage/` diff --git a/util/webhook/ghcr.go b/util/webhook/ghcr.go new file mode 100644 index 0000000000..7936f3d4d6 --- /dev/null +++ b/util/webhook/ghcr.go @@ -0,0 +1,139 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + log "github.com/sirupsen/logrus" +) + +// GHCRParser parses webhook payloads sent by GitHub Container Registry (GHCR). +// +// It extracts container image publication events from GitHub package webhooks +// and converts them into a normalized WebhookRegistryEvent structure. +type GHCRParser struct { + secret string +} + +// GHCRPayload represents the webhook payload sent by GitHub for +// package events. +type GHCRPayload struct { + Action string `json:"action"` + Package struct { + Name string `json:"name"` + PackageType string `json:"package_type"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` + PackageVersion struct { + ContainerMetadata struct { + Tag struct { + Name string `json:"name"` + } `json:"tag"` + } `json:"container_metadata"` + } `json:"package_version"` + } `json:"package"` +} + +// NewGHCRParser creates a new GHCRParser instance. +// +// The parser supports GitHub package webhook events for container images +// published to GitHub Container Registry (ghcr.io). +func NewGHCRParser(secret string) *GHCRParser { + if secret == "" { + log.Warn("GHCR webhook secret is not configured; incoming webhook events will not be validated") + } + return &GHCRParser{secret: secret} +} + +// ProcessWebhook reads the request body and parses the GHCR webhook payload. +// Returns nil, nil for events that should be skipped. +func (p *GHCRParser) ProcessWebhook(r *http.Request) (*RegistryEvent, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + return p.Parse(r, body) +} + +// CanHandle reports whether the HTTP request corresponds to a GHCR webhook. +// +// It checks the GitHub event header and returns true for package-related +// events that may contain container registry updates. +func (p *GHCRParser) CanHandle(r *http.Request) bool { + return r.Header.Get("X-GitHub-Event") == "package" +} + +// Parse validates the request signature and extracts container publication +// details from a GHCR webhook payload. +// +// The method expects a GitHub package event with action "published" for a +// container package. It returns a normalized WebhookRegistryEvent containing +// the registry host, repository, tag, and digest. Returns nil, nil for events +// that are intentionally skipped (unsupported actions, non-container packages, +// or missing tags). Only returns an error for genuinely malformed payloads or +// signature verification failures. +func (p *GHCRParser) Parse(r *http.Request, body []byte) (*RegistryEvent, error) { + if err := p.validateSignature(r, body); err != nil { + return nil, err + } + var payload GHCRPayload + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("failed to unmarshal GHCR webhook payload: %w", err) + } + + if payload.Action != "published" { + log.Debugf("Skipping GHCR webhook event: unsupported action %q", payload.Action) + return nil, nil + } + + if !strings.EqualFold(payload.Package.PackageType, "container") { + log.Debugf("Skipping GHCR webhook event: unsupported package type %q", payload.Package.PackageType) + return nil, nil + } + + repository := payload.Package.Owner.Login + "/" + payload.Package.Name + tag := payload.Package.PackageVersion.ContainerMetadata.Tag.Name + + if tag == "" { + log.Debugf("Skipping GHCR webhook event: missing tag for repository %q", repository) + return nil, nil + } + + return &RegistryEvent{ + RegistryURL: "ghcr.io", + Repository: repository, + Tag: tag, + }, nil +} + +// validateSignature verifies the webhook request signature using HMAC-SHA256. +// +// If a secret is configured, the method checks the X-Hub-Signature-256 header +// against the computed signature of the request body. An error is returned if +// the signature is missing or does not match. If no secret is configured, +// validation is skipped. +func (p *GHCRParser) validateSignature(r *http.Request, body []byte) error { + if p.secret != "" { + signature := r.Header.Get("X-Hub-Signature-256") + if signature == "" { + return fmt.Errorf("%w: missing X-Hub-Signature-256 header", ErrHMACVerificationFailed) + } + + mac := hmac.New(sha256.New, []byte(p.secret)) + mac.Write(body) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(signature), []byte(expected)) { + return fmt.Errorf("%w: signature mismatch", ErrHMACVerificationFailed) + } + } + + return nil +} diff --git a/util/webhook/ghcr_test.go b/util/webhook/ghcr_test.go new file mode 100644 index 0000000000..a9226b3e79 --- /dev/null +++ b/util/webhook/ghcr_test.go @@ -0,0 +1,180 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGHCRParser_Parse(t *testing.T) { + parser := NewGHCRParser("") + tests := []struct { + name string + body string + expectErr bool + expectSkip bool + expected *RegistryEvent + }{ + { + name: "valid container package event", + body: `{ + "action": "published", + "package": { + "name": "repo", + "package_type": "container", + "owner": { "login": "user" }, + "package_version": { + "container_metadata": { + "tag": { + "name": "1.0.0", + "digest": "sha256:abc123" + } + } + } + } + }`, + expected: &RegistryEvent{ + RegistryURL: "ghcr.io", + Repository: "user/repo", + Tag: "1.0.0", + }, + }, + { + name: "ignore non-published action", + body: `{ + "action": "updated", + "package": { + "name": "repo", + "package_type": "container" + } + }`, + expectSkip: true, + }, + { + name: "ignore non-container package", + body: `{ + "action": "published", + "package": { + "name": "repo", + "package_type": "npm" + } + }`, + expectSkip: true, + }, + { + name: "missing tag", + body: `{ + "action": "published", + "package": { + "name": "repo", + "package_type": "container", + "owner": { "login": "user" }, + "package_version": { + "container_metadata": { + "tag": { "name": "" } + } + } + } + }`, + expectSkip: true, + }, + { + name: "invalid json", + body: `{invalid}`, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", http.NoBody) + event, err := parser.Parse(req, []byte(tt.body)) + + if tt.expectErr { + require.Error(t, err) + require.Nil(t, event) + return + } + + if tt.expectSkip { + require.NoError(t, err) + require.Nil(t, event) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expected, event) + }) + } +} + +func TestValidateSignature(t *testing.T) { + body := []byte(`{"test":"payload"}`) + secret := "my-secret" + + computeSig := func(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) + } + + tests := []struct { + name string + secret string + headerSig string + expectError bool + expectHMAC bool + }{ + { + name: "valid signature", + secret: secret, + headerSig: computeSig(secret, body), + }, + { + name: "missing signature header", + secret: secret, + expectError: true, + expectHMAC: true, + }, + { + name: "invalid signature", + secret: secret, + headerSig: "sha256=deadbeef", + expectError: true, + expectHMAC: true, + }, + { + name: "no secret configured (skip validation)", + secret: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewGHCRParser(tt.secret) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", http.NoBody) + + if tt.headerSig != "" { + req.Header.Set("X-Hub-Signature-256", tt.headerSig) + } + + err := parser.validateSignature(req, body) + + if tt.expectError { + require.Error(t, err) + if tt.expectHMAC { + require.ErrorIs(t, err, ErrHMACVerificationFailed) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/util/webhook/registry.go b/util/webhook/registry.go new file mode 100644 index 0000000000..b889047c01 --- /dev/null +++ b/util/webhook/registry.go @@ -0,0 +1,136 @@ +package webhook + +import ( + "errors" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" + "github.com/argoproj/argo-cd/v3/util/argo" + "github.com/argoproj/argo-cd/v3/util/glob" + + "k8s.io/apimachinery/pkg/labels" +) + +// RegistryEvent represents a normalized container registry webhook event. +// +// It captures the essential information needed to identify an OCI artifact +// update, including the registry host, repository name, tag, and optional +// content digest. This structure is produced by registry-specific parsers +// and consumed by the registry webhook handler to trigger application refreshes. +type RegistryEvent struct { + // RegistryURL is the hostname of the registry, without protocol or trailing slash. + // e.g. "ghcr.io", "docker.io", "123456789.dkr.ecr.us-east-1.amazonaws.com" + // Together with Repository, it forms the OCI repo URL: oci://RegistryURL/Repository. + // Parsers must ensure this value is consistent with how users configure repoURL + // in their Argo CD Applications (e.g. oci://ghcr.io/owner/repo). + RegistryURL string `json:"registryUrl,omitempty"` + // Repository is the full repository path within the registry, without a leading slash. + // e.g. "owner/repo" for ghcr.io, "library/nginx" for docker.io. + // Together with RegistryURL, it forms the OCI repo URL: oci://RegistryURL/Repository. + Repository string `json:"repository,omitempty"` + // Tag is the image tag + // eg. 0.3.0 + Tag string `json:"tag,omitempty"` +} + +// OCIRepoURL returns the full OCI repository URL for use in Argo CD Application +// source matching, e.g. "oci://ghcr.io/owner/repo". +func (e *RegistryEvent) OCIRepoURL() string { + return fmt.Sprintf("oci://%s/%s", e.RegistryURL, e.Repository) +} + +// ErrHMACVerificationFailed is returned when a registry webhook signature check fails. +var ErrHMACVerificationFailed = errors.New("HMAC verification failed") + +// HandleRegistryEvent processes a normalized registry event and refreshes +// matching Argo CD Applications. +// +// It constructs the full OCI repository URL from the event, finds Applications +// whose sources reference that repository and revision, and triggers a refresh +// for each matching Application. Namespace filters are applied according to the +// handler configuration. +func (a *ArgoCDWebhookHandler) HandleRegistryEvent(event *RegistryEvent) { + repoURL := event.OCIRepoURL() + normalizedRepoURL := normalizeOCI(repoURL) + revision := event.Tag + + log.WithFields(log.Fields{ + "repo": repoURL, + "tag": revision, + }).Info("Received registry webhook event") + + // Determine namespaces to search + nsFilter := a.ns + if len(a.appNs) > 0 { + nsFilter = "" + } + appIf := a.appsLister.Applications(nsFilter) + apps, err := appIf.List(labels.Everything()) + if err != nil { + log.Errorf("Failed to list applications: %v", err) + return + } + + var filteredApps []v1alpha1.Application + for _, app := range apps { + if app.Namespace == a.ns || glob.MatchStringInList(a.appNs, app.Namespace, glob.REGEXP) { + filteredApps = append(filteredApps, *app) + } + } + + for _, app := range filteredApps { + sources := app.Spec.GetSources() + if app.Spec.SourceHydrator != nil { + sources = append(sources, app.Spec.SourceHydrator.GetDrySource()) + } + + for _, source := range sources { + if normalizeOCI(source.RepoURL) != normalizedRepoURL { + log.WithFields(log.Fields{ + "sourceRepoURL": source.RepoURL, + "eventRepoURL": repoURL, + }).Debug("Skipping app: OCI repository URLs do not match") + continue + } + if !compareRevisions(revision, source.TargetRevision) { + log.WithFields(log.Fields{ + "revision": revision, + "targetRevision": source.TargetRevision, + }).Debug("Skipping app: revision does not match targetRevision") + continue + } + log.Infof("Refreshing app '%s' due to OCI push %s:%s", + app.Name, repoURL, revision, + ) + + namespacedAppInterface := a.appClientset.ArgoprojV1alpha1(). + Applications(app.Namespace) + + if _, err := argo.RefreshApp( + namespacedAppInterface, + app.Name, + v1alpha1.RefreshTypeNormal, + false, + ); err != nil { + log.Errorf("Failed to refresh app '%s': %v", + app.Name, err) + } + + break // no need to check other sources + } + } +} + +// normalizeOCI normalizes an OCI repository URL for comparison. +// +// It removes the oci:// prefix, converts to lowercase, and removes any +// trailing slash to ensure consistent matching between webhook events +// and Application source URLs. +func normalizeOCI(url string) string { + url = strings.TrimPrefix(url, "oci://") + url = strings.TrimSuffix(url, "/") + return strings.ToLower(url) +} diff --git a/util/webhook/registry_test.go b/util/webhook/registry_test.go new file mode 100644 index 0000000000..caecb1dbf3 --- /dev/null +++ b/util/webhook/registry_test.go @@ -0,0 +1,237 @@ +package webhook + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kubetesting "k8s.io/client-go/testing" +) + +func TestNormalizeOCI(t *testing.T) { + tests := []struct { + name string + url string + expected string + }{ + {"strips oci:// prefix and lowercases", "oci://GHCR.IO/USER/REPO", "ghcr.io/user/repo"}, + {"strips oci:// prefix and trailing slash", "oci://ghcr.io/user/repo/", "ghcr.io/user/repo"}, + {"already normalized with prefix", "oci://ghcr.io/user/repo", "ghcr.io/user/repo"}, + {"without oci:// prefix", "ghcr.io/user/repo", "ghcr.io/user/repo"}, + {"uppercase without prefix", "GHCR.IO/USER/REPO", "ghcr.io/user/repo"}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeOCI(tt.url) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestGHCRHandlerCanHandle(t *testing.T) { + h := NewGHCRParser("") + + tests := []struct { + name string + event string + expected bool + }{ + {"package event", "package", true}, + {"registry package event", "registry_package", false}, + {"push event", "push", false}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", http.NoBody) + req.Header.Set("X-GitHub-Event", tt.event) + assert.Equal(t, tt.expected, h.CanHandle(req)) + }) + } +} + +func TestRegistryPackageEvent(t *testing.T) { + hook := test.NewGlobal() + h := NewMockHandler(nil, []string{}) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/webhook", http.NoBody) + req.Header.Set("X-GitHub-Event", "package") + payload, err := os.ReadFile("testdata/ghcr-package-event.json") + require.NoError(t, err) + req.Body = io.NopCloser(bytes.NewReader(payload)) + + w := httptest.NewRecorder() + h.Handler(w, req) + close(h.queue) + h.Wait() + + assert.Equal(t, http.StatusOK, w.Code) + assertLogContains(t, hook, "Received registry webhook event") +} + +func TestHandleRegistryEvent_RefreshMatchingApp(t *testing.T) { + hook := test.NewGlobal() + + patchedApps := []string{} + + reaction := func(action kubetesting.Action) (bool, runtime.Object, error) { + patch := action.(kubetesting.PatchAction) + patchedApps = append(patchedApps, patch.GetName()) + return true, nil, nil + } + + h := NewMockHandler( + &reactorDef{"patch", "applications", reaction}, + []string{}, + &v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "oci-app", + Namespace: "argocd", + }, + Spec: v1alpha1.ApplicationSpec{ + Sources: v1alpha1.ApplicationSources{ + { + RepoURL: "oci://ghcr.io/user/repo", + TargetRevision: "1.0.0", + }, + }, + }, + }, + ) + + event := &RegistryEvent{ + RegistryURL: "ghcr.io", + Repository: "user/repo", + Tag: "1.0.0", + } + + h.HandleRegistryEvent(event) + + assert.Contains(t, patchedApps, "oci-app") + assert.Contains(t, hook.LastEntry().Message, "Requested app 'oci-app' refresh") +} + +func TestHandleRegistryEvent_RepoMismatch(t *testing.T) { + logrus.SetLevel(logrus.DebugLevel) + hook := test.NewGlobal() + + h := NewMockHandler(nil, []string{}, + &v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "oci-app", + Namespace: "argocd", + }, + Spec: v1alpha1.ApplicationSpec{ + Sources: v1alpha1.ApplicationSources{ + { + RepoURL: "oci://ghcr.io/other/repo", + TargetRevision: "1.0.0", + }, + }, + }, + }, + ) + + event := &RegistryEvent{ + RegistryURL: "ghcr.io", + Repository: "user/repo", + Tag: "1.0.0", + } + + h.HandleRegistryEvent(event) + assert.Contains(t, hook.LastEntry().Message, "Skipping app: OCI repository URLs do not match") +} + +func TestHandleRegistryEvent_RevisionMismatch(t *testing.T) { + logrus.SetLevel(logrus.DebugLevel) + hook := test.NewGlobal() + + h := NewMockHandler( + nil, + []string{}, + &v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "oci-app", + Namespace: "argocd", + }, + Spec: v1alpha1.ApplicationSpec{ + Sources: v1alpha1.ApplicationSources{ + { + RepoURL: "oci://ghcr.io/user/repo", + TargetRevision: "2.0.0", + }, + }, + }, + }, + ) + + event := &RegistryEvent{ + RegistryURL: "ghcr.io", + Repository: "user/repo", + Tag: "1.0.0", + } + + h.HandleRegistryEvent(event) + assert.Contains(t, hook.LastEntry().Message, "Skipping app: revision does not match targetRevision") +} + +func TestHandleRegistryEvent_NamespaceFiltering(t *testing.T) { + patched := []string{} + + reaction := func(action kubetesting.Action) (bool, runtime.Object, error) { + patch := action.(kubetesting.PatchAction) + patched = append(patched, patch.GetNamespace()) + return true, nil, nil + } + + h := NewMockHandler( + &reactorDef{"patch", "applications", reaction}, + []string{"team-*"}, + &v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "team-a", + }, + Spec: v1alpha1.ApplicationSpec{ + Sources: v1alpha1.ApplicationSources{ + {RepoURL: "oci://ghcr.io/user/repo", TargetRevision: "1.0.0"}, + }, + }, + }, + &v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app2", + Namespace: "kube-system", + }, + Spec: v1alpha1.ApplicationSpec{ + Sources: v1alpha1.ApplicationSources{ + {RepoURL: "oci://ghcr.io/user/repo", TargetRevision: "1.0.0"}, + }, + }, + }, + ) + + event := &RegistryEvent{ + RegistryURL: "ghcr.io", + Repository: "user/repo", + Tag: "1.0.0", + } + + h.HandleRegistryEvent(event) + + assert.Contains(t, patched, "team-a") + assert.NotContains(t, patched, "kube-system") +} diff --git a/util/webhook/testdata/ghcr-package-event.json b/util/webhook/testdata/ghcr-package-event.json new file mode 100644 index 0000000000..36d22dcdac --- /dev/null +++ b/util/webhook/testdata/ghcr-package-event.json @@ -0,0 +1,19 @@ +{ + "action": "published", + "package": { + "name": "guestbook", + "namespace": "user", + "package_type": "CONTAINER", + "owner": { + "login": "nitishfy" + }, + "package_version": { + "container_metadata": { + "tag": { + "name": "5.5.9", + "digest": "sha256:abc123" + } + } + } + } +} diff --git a/util/webhook/webhook.go b/util/webhook/webhook.go index e475ddae0b..0e77ec96b8 100644 --- a/util/webhook/webhook.go +++ b/util/webhook/webhook.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "html" "net/http" "net/url" "os" @@ -13,6 +12,8 @@ import ( "sync" "time" + "github.com/argoproj/argo-cd/v3/common" + bb "github.com/ktrysmt/go-bitbucket" "k8s.io/apimachinery/pkg/labels" @@ -30,7 +31,6 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" log "github.com/sirupsen/logrus" - "github.com/argoproj/argo-cd/v3/common" "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" appclientset "github.com/argoproj/argo-cd/v3/pkg/client/clientset/versioned" "github.com/argoproj/argo-cd/v3/reposerver/cache" @@ -97,9 +97,13 @@ type ArgoCDWebhookHandler struct { settingsSrc settingsSource queue chan any maxWebhookPayloadSizeB int64 + ghcrHandler *GHCRParser } -func NewHandler(namespace string, applicationNamespaces []string, webhookParallelism int, appClientset appclientset.Interface, appsLister alpha1.ApplicationLister, set *settings.ArgoCDSettings, settingsSrc settingsSource, repoCache *cache.Cache, serverCache *servercache.Cache, argoDB db.ArgoDB, maxWebhookPayloadSizeB int64) *ArgoCDWebhookHandler { +func NewHandler(namespace string, applicationNamespaces []string, webhookParallelism int, appClientset appclientset.Interface, appsLister alpha1.ApplicationLister, + set *settings.ArgoCDSettings, settingsSrc settingsSource, repoCache *cache.Cache, + serverCache *servercache.Cache, argoDB db.ArgoDB, maxWebhookPayloadSizeB int64, +) *ArgoCDWebhookHandler { githubWebhook, err := github.New(github.Options.Secret(set.GetWebhookGitHubSecret())) if err != nil { log.Warnf("Unable to init the GitHub webhook") @@ -124,7 +128,6 @@ func NewHandler(namespace string, applicationNamespaces []string, webhookParalle if err != nil { log.Warnf("Unable to init the Azure DevOps webhook") } - acdWebhook := ArgoCDWebhookHandler{ ns: namespace, appNs: applicationNamespaces, @@ -143,6 +146,7 @@ func NewHandler(namespace string, applicationNamespaces []string, webhookParalle queue: make(chan any, payloadQueueSize), maxWebhookPayloadSizeB: maxWebhookPayloadSizeB, appsLister: appsLister, + ghcrHandler: NewGHCRParser(set.GetWebhookGitHubSecret()), } acdWebhook.startWorkerPool(webhookParallelism) @@ -343,6 +347,10 @@ func (a *ArgoCDWebhookHandler) HandleEvent(payload any) { log.Infof("Webhook handler completed in %v", time.Since(start)) }() + if e, ok := payload.(*RegistryEvent); ok { + a.HandleRegistryEvent(e) + return + } webURLs, revision, change, touchedHead, changedFiles := a.affectedRevisionInfo(payload) // NOTE: the webURL does not include the .git extension if len(webURLs) == 0 { @@ -684,6 +692,93 @@ func (a *ArgoCDWebhookHandler) Handler(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, a.maxWebhookPayloadSizeB) + if event, handled, err := a.processRegistryWebhook(r); handled { + if err != nil { + if errors.Is(err, ErrHMACVerificationFailed) { + log.WithField(common.SecurityField, common.SecurityHigh).Infof("Registry webhook HMAC verification failed") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + log.Infof("Registry webhook processing failed: %s", err) + http.Error(w, "Registry webhook processing failed", http.StatusBadRequest) + return + } + if event == nil { + w.WriteHeader(http.StatusOK) + return + } + select { + case a.queue <- event: + default: + log.Info("Queue is full, discarding registry webhook payload") + http.Error(w, "Queue is full, discarding registry webhook payload", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("Registry event received. Processing triggered.")) + return + } + + payload, err = a.processSCMWebhook(r) + if err == nil && payload == nil { + http.Error(w, "Unknown webhook event", http.StatusBadRequest) + return + } + if err != nil { + // If the error is due to a large payload, return a more user-friendly error message + if isParsingPayloadError(err) { + log.WithField(common.SecurityField, common.SecurityHigh).Warnf("Webhook processing failed: payload too large or corrupted (limit %v MB): %v", a.maxWebhookPayloadSizeB/1024/1024, err) + http.Error(w, fmt.Sprintf("Webhook processing failed: payload must be valid JSON under %v MB", a.maxWebhookPayloadSizeB/1024/1024), http.StatusBadRequest) + return + } + + status := http.StatusBadRequest + if r.Method != http.MethodPost { + status = http.StatusMethodNotAllowed + } + log.Infof("Webhook processing failed: %v", err) + http.Error(w, "Webhook processing failed", status) + return + } + + if payload != nil { + select { + case a.queue <- payload: + default: + log.Info("Queue is full, discarding webhook payload") + http.Error(w, "Queue is full, discarding webhook payload", http.StatusServiceUnavailable) + return + } + } +} + +// isParsingPayloadError returns a bool if the error is parsing payload error +func isParsingPayloadError(err error) bool { + return errors.Is(err, github.ErrParsingPayload) || + errors.Is(err, gitlab.ErrParsingPayload) || + errors.Is(err, gogs.ErrParsingPayload) || + errors.Is(err, bitbucket.ErrParsingPayload) || + errors.Is(err, bitbucketserver.ErrParsingPayload) || + errors.Is(err, azuredevops.ErrParsingPayload) +} + +// processRegistryWebhook routes an incoming request to the appropriate registry +// handler. It returns the parsed event, a boolean indicating whether any handler +// claimed the request, and any error from parsing. When handled is false, the +// caller should fall through to SCM webhook processing. +func (a *ArgoCDWebhookHandler) processRegistryWebhook(r *http.Request) (*RegistryEvent, bool, error) { + if a.ghcrHandler.CanHandle(r) { + event, err := a.ghcrHandler.ProcessWebhook(r) + return event, true, err + // TODO: add dockerhub, ecr handler cases in future + } + return nil, false, nil +} + +// processSCMWebhook processes an SCM webhook +func (a *ArgoCDWebhookHandler) processSCMWebhook(r *http.Request) (any, error) { + var payload any + var err error switch { case r.Header.Get("X-Vss-Activityid") != "": payload, err = a.azuredevops.Parse(r, azuredevops.GitPushEventType) @@ -718,32 +813,8 @@ func (a *ArgoCDWebhookHandler) Handler(w http.ResponseWriter, r *http.Request) { } default: log.Debug("Ignoring unknown webhook event") - http.Error(w, "Unknown webhook event", http.StatusBadRequest) - return + return nil, nil } - if err != nil { - // If the error is due to a large payload, return a more user-friendly error message - if err.Error() == "error parsing payload" { - msg := fmt.Sprintf("Webhook processing failed: The payload is either too large or corrupted. Please check the payload size (must be under %v MB) and ensure it is valid JSON", a.maxWebhookPayloadSizeB/1024/1024) - log.WithField(common.SecurityField, common.SecurityHigh).Warn(msg) - http.Error(w, msg, http.StatusBadRequest) - return - } - - log.Infof("Webhook processing failed: %s", err) - status := http.StatusBadRequest - if r.Method != http.MethodPost { - status = http.StatusMethodNotAllowed - } - http.Error(w, "Webhook processing failed: "+html.EscapeString(err.Error()), status) - return - } - - select { - case a.queue <- payload: - default: - log.Info("Queue is full, discarding webhook payload") - http.Error(w, "Queue is full, discarding webhook payload", http.StatusServiceUnavailable) - } + return payload, err } diff --git a/util/webhook/webhook_test.go b/util/webhook/webhook_test.go index 61dd0aabe2..92465a33d0 100644 --- a/util/webhook/webhook_test.go +++ b/util/webhook/webhook_test.go @@ -80,6 +80,16 @@ func assertLogContains(t *testing.T, hook *test.Hook, msg string) { t.Errorf("log hook did not contain message: %q", msg) } +func assertLogContainsSubstr(t *testing.T, hook *test.Hook, substr string) { + t.Helper() + for _, entry := range hook.Entries { + if strings.Contains(entry.Message, substr) { + return + } + } + t.Errorf("log hook did not contain message with substring: %q", substr) +} + func NewMockHandler(reactor *reactorDef, applicationNamespaces []string, objects ...runtime.Object) *ArgoCDWebhookHandler { defaultMaxPayloadSize := int64(50) * 1024 * 1024 return NewMockHandlerWithPayloadLimit(reactor, applicationNamespaces, defaultMaxPayloadSize, objects...) @@ -429,9 +439,8 @@ func TestInvalidMethod(t *testing.T) { close(h.queue) h.Wait() assert.Equal(t, http.StatusMethodNotAllowed, w.Code) - expectedLogResult := "Webhook processing failed: invalid HTTP Method" - assertLogContains(t, hook, expectedLogResult) - assert.Equal(t, expectedLogResult+"\n", w.Body.String()) + assertLogContains(t, hook, "Webhook processing failed: invalid HTTP Method") + assert.Equal(t, "Webhook processing failed\n", w.Body.String()) hook.Reset() } @@ -445,9 +454,8 @@ func TestInvalidEvent(t *testing.T) { close(h.queue) h.Wait() assert.Equal(t, http.StatusBadRequest, w.Code) - expectedLogResult := "Webhook processing failed: The payload is either too large or corrupted. Please check the payload size (must be under 50 MB) and ensure it is valid JSON" - assertLogContains(t, hook, expectedLogResult) - assert.Equal(t, expectedLogResult+"\n", w.Body.String()) + assertLogContainsSubstr(t, hook, "Webhook processing failed: payload too large or corrupted (limit 50 MB)") + assert.Equal(t, "Webhook processing failed: payload must be valid JSON under 50 MB\n", w.Body.String()) hook.Reset() } @@ -763,8 +771,7 @@ func TestGitHubCommitEventMaxPayloadSize(t *testing.T) { close(h.queue) h.Wait() assert.Equal(t, http.StatusBadRequest, w.Code) - expectedLogResult := "Webhook processing failed: The payload is either too large or corrupted. Please check the payload size (must be under 0 MB) and ensure it is valid JSON" - assertLogContains(t, hook, expectedLogResult) + assertLogContainsSubstr(t, hook, "Webhook processing failed: payload too large or corrupted (limit 0 MB)") hook.Reset() }