mirror of
https://github.com/argoproj/argo-cd
synced 2026-04-21 08:57:17 +00:00
feat(webhooks): add webhook support for GHCR (#26462)
Signed-off-by: nitishfy <justnitish06@gmail.com>
This commit is contained in:
parent
04fa70c4a4
commit
db7d672f05
9 changed files with 917 additions and 44 deletions
BIN
docs/assets/ghcr-package-event.png
Normal file
BIN
docs/assets/ghcr-package-event.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -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
139
util/webhook/ghcr.go
Normal 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
180
util/webhook/ghcr_test.go
Normal 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
136
util/webhook/registry.go
Normal 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)
|
||||
}
|
||||
237
util/webhook/registry_test.go
Normal file
237
util/webhook/registry_test.go
Normal 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")
|
||||
}
|
||||
19
util/webhook/testdata/ghcr-package-event.json
vendored
Normal file
19
util/webhook/testdata/ghcr-package-event.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue