feat(webhooks): add webhook support for GHCR (#26462)

Signed-off-by: nitishfy <justnitish06@gmail.com>
This commit is contained in:
Nitish Kumar 2026-04-16 16:41:31 +05:30 committed by GitHub
parent 04fa70c4a4
commit db7d672f05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 917 additions and 44 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -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: `$<k8s_secret_name>:<a_key_in_that_k8s_secret>`
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://<argocd-server>/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: <your-webhook-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/`

139
util/webhook/ghcr.go Normal file
View file

@ -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
}

180
util/webhook/ghcr_test.go Normal file
View file

@ -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)
}
})
}
}

136
util/webhook/registry.go Normal file
View file

@ -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)
}

View file

@ -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")
}

View file

@ -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"
}
}
}
}
}

View file

@ -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
}

View file

@ -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()
}