Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions api/v1alpha1/source_webhook_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,6 @@ package v1alpha1

// WebhookConfig contains configuration for Webhook notifications.
type WebhookConfig struct {
// Path that the webhook will receive the notifications.
// If not present `/webhook` will be used. The path always expects a POST and this is not configurable
// +optional
Path string `json:"path"`

// Address is the address where the webhook will be served in your infrastructure.
// If not present, defaults to `:8090`
// +optional
Address string `json:"address"`

Comment on lines -5 to -14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will warrant 2.0.0.

// SecretIdentifierOnPayload is the key that the reloader will look for in the payload.
// The value of this key should be the same name as in the external secret. It will default to `0.data.ObjectName` if not set
// +optional
Expand Down
15 changes: 12 additions & 3 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"
crtwebhook "sigs.k8s.io/controller-runtime/pkg/webhook"

externalsecrets "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
pushsecrets "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"

"github.com/external-secrets/reloader/api/v1alpha1"
"github.com/external-secrets/reloader/internal/controller"
"github.com/external-secrets/reloader/internal/listener/webhook"
// +kubebuilder:scaffold:imports
)

Expand Down Expand Up @@ -99,10 +100,12 @@ func main() {
tlsOpts = append(tlsOpts, disableHTTP2)
}

webhookServer := webhook.NewServer(webhook.Options{
crdWebhookServer := crtwebhook.NewServer(crtwebhook.Options{
TLSOpts: tlsOpts,
})

notificationWebhook := webhook.NewWebhookServer(webhookAddr, ctrl.Log.WithName("notification"))

metricsServerOptions := metricsserver.Options{
BindAddress: metricsAddr,
SecureServing: secureMetrics,
Expand All @@ -118,7 +121,7 @@ func main() {
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
WebhookServer: webhookServer,
WebhookServer: crdWebhookServer,
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "0cd7d2f7.externalsecrets.com",
Expand All @@ -128,9 +131,15 @@ func main() {
os.Exit(1)
}

if err := mgr.Add(notificationWebhook); err != nil {
setupLog.Error(err, "unable to add notification webhook server")
os.Exit(1)
}

if err = (controller.NewReloaderReconciler(
mgr.GetClient(),
mgr.GetScheme(),
notificationWebhook,
)).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Reloader")
os.Exit(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1184,21 +1184,11 @@ spec:
webhook:
description: Webhook configuration (required if Type is Webhook).
properties:
address:
description: |-
Address is the address where the webhook will be served in your infrastructure.
If not present, defaults to `:8090`
type: string
identifierPathOnPayload:
description: |-
SecretIdentifierOnPayload is the key that the reloader will look for in the payload.
The value of this key should be the same name as in the external secret. It will default to `0.data.ObjectName` if not set
type: string
path:
description: |-
Path that the webhook will receive the notifications.
If not present `/webhook` will be used. The path always expects a POST and this is not configurable
type: string
retryPolicy:
description: |-
RetryPolicy represents the policy to retry when a message fails.
Expand Down
11 changes: 8 additions & 3 deletions deploy/charts/reloader/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ spec:
args:
- --health-probe-bind-address=:8081
- --metrics-bind-address=:{{ .Values.metrics.listen.port }}
{{- if .Values.service.webhook.enabled }}
- --webhook-bind-address=:{{ .Values.service.webhook.listenPort }}
{{- end }}
{{- if .Values.leaderElect }}
- --leader-elect
{{- end }}
Expand All @@ -66,15 +69,17 @@ spec:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
- name: healthz
containerPort: 8081
protocol: TCP
- name: metrics
containerPort: {{ .Values.metrics.listen.port }}
protocol: TCP
{{- if .Values.service.webhook.enabled }}
- name: webhook
containerPort: {{ .Values.service.webhook.listenPort }}
protocol: TCP
{{- end }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
Expand Down
11 changes: 7 additions & 4 deletions deploy/charts/reloader/templates/ingress.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
{{- if .Values.ingress.enabled -}}
{{- if not .Values.service.webhook.enabled }}
{{- fail "ingress.enabled requires service.webhook.enabled (Ingress routes to the webhook Service)" }}
{{- end }}
{{- $fullName := include "reloader.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- $svcPort := .Values.service.webhook.port -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}
name: {{ $fullName }}-webhook
namespace: {{ include "reloader.namespace" . }}
labels:
{{- include "reloader.labels" . | nindent 4 }}
Expand Down Expand Up @@ -33,10 +36,10 @@ spec:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "ImplementationSpecific" }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
name: {{ $fullName }}
name: {{ $fullName }}-webhook
port:
number: {{ $svcPort }}
{{- end }}
Expand Down
19 changes: 1 addition & 18 deletions deploy/charts/reloader/templates/service.yaml
Original file line number Diff line number Diff line change
@@ -1,20 +1,3 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "reloader.fullname" . }}
namespace: {{ include "reloader.namespace" . }}
labels:
{{- include "reloader.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "reloader.selectorLabels" . | nindent 4 }}
---
{{- if .Values.service.webhook.enabled }}
apiVersion: v1
kind: Service
Expand All @@ -33,8 +16,8 @@ spec:
selector:
{{- include "reloader.selectorLabels" . | nindent 4 }}
{{- end }}
---
{{- if .Values.service.socket.enabled }}
---
apiVersion: v1
kind: Service
metadata:
Expand Down
13 changes: 7 additions & 6 deletions deploy/charts/reloader/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ securityContext:
type: RuntimeDefault

service:
type: ClusterIP
port: 8080

# When enabled, the chart sets --webhook-bind-address and exposes the webhook port on the Deployment.
# Send POST to http://<release>-webhook.<namespace>.svc:<port>/webhook/<Config.metadata.name> (cluster-scoped Config name).
webhook:
enabled: false
type: ClusterIP
port: 8090
targetPort: 8090
listenPort: 8090
targetPort: webhook

socket:
enabled: false
Expand Down Expand Up @@ -126,11 +126,12 @@ certificate:

ingress:
enabled: false
# Routes to the webhook Service (<release>-webhook). Requires service.webhook.enabled.
className: ""
annotations: {}
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
- path: /webhook
pathType: Prefix
tls: []
4 changes: 2 additions & 2 deletions docs/reference/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,8 @@ WebhookConfig contains configuration for Webhook notifications.

| Field | Type | Description | Validation |
|--------------------------|-------------------------------|-------------------------------------------------------------------------------------------------------|------------|
| `path` | string | Endpoint path (default: `/webhook`). Always expects a POST request. | |
| `address` | string | Address where the webhook is served. Defaults to `:8090`. | |
| `identifierPathOnPayload`| string | Key in the payload used to identify the secret. Defaults to `0.data.ObjectName` if not set. | |
| `webhookAuth` | [WebhookAuth](#webhookauth) | Authentication method for the webhook. | |
| `retryPolicy` | [RetryPolicy](#retrypolicy) | Policy to retry failed messages. If not set, 4xx will be returned and no retry will be attempted. | |

The controller serves all webhook `Config` resources on one HTTP listener (`--webhook-bind-address`). Each `Config` is reachable at `POST /webhook/<Config.metadata.name>`.
41 changes: 28 additions & 13 deletions docs/sources/webhook.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
# Webhook Source

This guide explains how to set up the Webhook notification source for the Reloader component in your environment. Using Webhooks as a notification source allows you to trigger secret rotation events via HTTP calls to your Webhook endpoint.
This guide explains how to set up the Webhook notification source for the Reloader component in your environment. Using webhooks as a notification source lets you trigger secret rotation events by sending HTTP POST requests to the Reloader process.

## How it works

The controller runs a **single shared HTTP server** for all `Config` resources. The listen address is set with the controller flag **`--webhook-bind-address`** (default `:8082`). Each cluster-scoped `Config` is exposed at:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a mismatch between 8082 and whatever the helm template is setting up with 8090.. Something is fishy there. Please double check.


`POST /webhook/<Config.metadata.name>`

There is no per-CR URL path or bind address; callers use the `Config` name in the path.

## Configuration

To configure a Webhook as a notification source, the Reloader needs to be provided with the URL path to listen on, as well as the identifier in the payload that refers to the secret being rotated.
Configure a `NotificationSource` with `type: Webhook` and a `webhook` block. The main field is **`identifierPathOnPayload`** (JSON path in the body where the secret name appears).

### Key Fields
### Key fields

* **path**: Specifies the Webhook path that the Reloader will listen to. This is the endpoint where Webhook notifications will be received.
* **identifierPathOnPayload**: Defines the key in the payload that contains the secret identifier. The identifier must match the name of the secret being rotated. By default, the path is `0.data.ObjectName`.
* **identifierPathOnPayload**: JSON path in the POST body for the secret identifier. It must match the name of the secret being rotated. If omitted, the default path is `0.data.ObjectName`.
* **webhookAuth** (optional): Basic or bearer authentication for incoming requests.
* **retryPolicy** (optional): Retry failed publishes to the internal event channel.

### Payload Structure
### Payload structure

The Webhook notification must contain a payload with a secret identifier. The Reloader will extract this identifier based on the path defined in the configuration.
The POST body must be JSON containing the secret identifier at the configured path.

#### Example Payload
#### Example payload

```json
{
Expand All @@ -27,14 +36,14 @@ The Webhook notification must contain a payload with a secret identifier. The Re
}
```

In this example, the Webhook payload contains a secret identifier at `0.data.ObjectName`, which corresponds to the secret named `my-secret`. The Reloader will use this identifier to rotate the appropriate secret.
Here the identifier is at `0.data.ObjectName`, matching the secret name `my-secret`.

### Triggering a webhook notification

To trigger a secret rotation, send an HTTP POST request to the Webhook endpoint you've configured.
Send an HTTP POST to the Reloader webhook base URL with path `/webhook/<your-config-name>`.

```bash
curl -X POST https://your-rotator-endpoint/webhook \
curl -X POST "http://<reloader-host>:<webhook-port>/webhook/my-reloader-config" \
-H "Content-Type: application/json" \
-d '{
"0": {
Expand All @@ -45,6 +54,12 @@ curl -X POST https://your-rotator-endpoint/webhook \
}'
```

Once this request is received by the Reloader, it will extract the secret identifier and proceed with the rotation process for the specified secret.
Replace `my-reloader-config` with the `metadata.name` of your `Config` CR.

### Helm

If you use the chart under `deploy/charts/reloader`, set **`service.webhook.enabled: true`**. The chart then adds **`--webhook-bind-address`** and a **`webhook`** container port using **`service.webhook.listenPort`** (default `8090`, aligned with the optional `*-webhook` Service). You can still override the flag with **`extraArgs`** if needed.

There is no default “main” HTTP `Service` on port 8080. **`ingress.enabled`** requires **`service.webhook.enabled`**: the Ingress targets the **`{{ release }}-webhook`** Service on **`service.webhook.port`** (paths such as **`/webhook/...`**).

Any service that can call an endpoint can trigger the rotation as long as you configure the keys accordingly.
Any client that can reach the Service or host on that port can trigger rotation as long as the JSON path and optional auth match your `Config`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apiVersion: reloader.external-secrets.io/v1alpha1
kind: Config
metadata:
name: keeper # will listen on the webhook at /webhook/keeper
spec:
notificationSources:
- type: Webhook
webhook:
identifierPathOnPayload: "record_uid"
destinationsToWatch:
- type: ExternalSecret
externalSecret:
labelSelectors:
matchLabels: {}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/external-secrets/reloader

go 1.26.1
go 1.26.2

require (
cloud.google.com/go/iam v1.8.0
Expand Down
30 changes: 17 additions & 13 deletions internal/controller/reloader_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/external-secrets/reloader/internal/events"
"github.com/external-secrets/reloader/internal/handler"
"github.com/external-secrets/reloader/internal/listener"
"github.com/external-secrets/reloader/internal/listener/webhook"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
Expand Down Expand Up @@ -38,26 +39,28 @@ type ReloaderReconciler struct {

// Internal fields
listenerManager *listener.Manager
webhookServer *webhook.WebhookServer

// eventChan is a channel that transports SecretRotationEvent instances between various parts of the system, such as event handlers and listeners.
eventChan chan events.SecretRotationEvent
eventHandler *handler.EventHandler
}

// NewReloaderReconciler creates a new ReloaderReconciler with the default factory.
func NewReloaderReconciler(client client.Client, scheme *runtime.Scheme) *ReloaderReconciler {
func NewReloaderReconciler(client client.Client, scheme *runtime.Scheme, hook *webhook.WebhookServer) *ReloaderReconciler {
return &ReloaderReconciler{
Client: client,
Scheme: scheme,
eventChan: make(chan events.SecretRotationEvent),
eventHandler: handler.NewEventHandler(client),
Client: client,
Scheme: scheme,
webhookServer: hook,
eventChan: make(chan events.SecretRotationEvent),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs a sane buffer. 🤔 Especially since it's a shared channel.

eventHandler: handler.NewEventHandler(client),
}
}

// SetupWithManager sets up the controller with the Manager.
func (r *ReloaderReconciler) SetupWithManager(mgr ctrl.Manager) error {
ctx, cancel := context.WithCancel(context.Background())
r.listenerManager = listener.NewListenerManager(ctx, r.eventChan, r.Client, log.FromContext(ctx))
r.listenerManager = listener.NewListenerManager(ctx, r.eventChan, r.Client, log.FromContext(ctx), r.webhookServer)

// Start a goroutine to process events
go r.processEvents(ctx)
Expand Down Expand Up @@ -107,17 +110,18 @@ func (r *ReloaderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c

if err := r.Get(ctx, req.NamespacedName, &cfg); err != nil {
if apierrors.IsNotFound(err) {
if err := r.listenerManager.StopAll(); err != nil {
// Object is gone (e.g. after finalizer). Only tear down listeners for this Config — not all Configs.
manifestName := types.NamespacedName{
Namespace: req.Namespace,
Name: req.Name,
}
if err := r.listenerManager.ManageListeners(manifestName, nil); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}

// Error reading the object - requeue the request.
if !apierrors.IsNotFound(err) {
logger.Error(err, "unable to fetch Reloader deployment")
return ctrl.Result{}, err
}
logger.Error(err, "unable to fetch Config")
return ctrl.Result{}, err
}
if cfg.DeletionTimestamp != nil && controllerutil.ContainsFinalizer(&cfg, reloaderFinalizer) {
// Handle any cleanup logic here, as this is a DELETE request
Expand Down
Loading
Loading