diff --git a/docs/assets/ghcr-package-event.png b/docs/assets/ghcr-package-event.png new file mode 100644 index 0000000000..04f7edb569 Binary files /dev/null and b/docs/assets/ghcr-package-event.png differ diff --git a/docs/operator-manual/webhook.md b/docs/operator-manual/webhook.md index e8b7de2981..fd86395724 100644 --- a/docs/operator-manual/webhook.md +++ b/docs/operator-manual/webhook.md @@ -1,11 +1,17 @@ -# Git Webhook Configuration +# Webhook Configuration ## Overview -Argo CD polls Git repositories every three minutes to detect changes to the manifests. To eliminate -this delay from polling, the API server can be configured to receive webhook events. Argo CD supports -Git webhook notifications from GitHub, GitLab, Bitbucket, Bitbucket Server, Azure DevOps and Gogs. The following explains how to configure -a Git webhook for GitHub, but the same process should be applicable to other providers. +Argo CD polls Git/OCI/Helm repositories every three minutes to detect changes to the manifests. To eliminate +this delay from polling, the API server can be configured to receive webhook events. + +### Git Webhooks + +Argo CD supports Git webhook notifications from GitHub, GitLab, Bitbucket, Bitbucket Server, Azure DevOps and Gogs. The following explains how to configure a Git webhook for GitHub, but the same process should be applicable to other providers. + +### OCI Registry Webhooks + +Argo CD also supports webhooks from OCI-compliant container registries to trigger application refreshes when new OCI artifacts are pushed. See [Webhook Configuration for OCI-Compliant Registries](#3-webhook-configuration-for-oci-compliant-registries) for details. Application Sets use a separate webhook configuration for generating applications. [Webhook support for the Git Generator can be found here](applicationset/Generators-Git.md#webhook-configuration). @@ -113,7 +119,7 @@ Syntax: `$:` 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() }