Skip to content
Draft
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
18 changes: 17 additions & 1 deletion api/v1alpha1/strategy_match_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,21 @@ const (
ConditionOperationNotEqual ConditionOperation = "NotEqual"
ConditionOperationContains ConditionOperation = "Contains"
ConditionOperationNotContains ConditionOperation = "NotContains"
ConditionOperationIn ConditionOperation = "RegularExpression"
// ConditionOperationIn matches when the value found at Path matches the
// regular expression given in Value. Named "In" for historical reasons;
// ConditionOperationRegularExpression is provided as a clearer alias.
ConditionOperationIn ConditionOperation = "RegularExpression"
// ConditionOperationContainedBy matches when the value found at Path is a
// substring of Value - the inverse direction of Contains. This is what
// lets a MatchStrategy correlate a destination's own configured key
// (e.g. an ExternalSecret's spec.dataFrom[].extract.key) against a
// broader identifier surfaced by a notification source (e.g. an AWS
// Secrets Manager ARN), where the destination's key is a substring of,
// rather than equal to, the identifier on the incoming event.
ConditionOperationContainedBy ConditionOperation = "ContainedBy"
// ConditionOperationNotContainedBy is the negation of ConditionOperationContainedBy.
ConditionOperationNotContainedBy ConditionOperation = "NotContainedBy"
)

// ConditionOperationRegularExpression is a clearer alias for ConditionOperationIn.
const ConditionOperationRegularExpression = ConditionOperationIn
39 changes: 31 additions & 8 deletions docs/reference/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,23 @@ destinationsToWatch:

## Match Strategy

The Match Strategy controls how the Reloader determines whether a destination is affected by a given secret event. It evaluates a JSON path on the destination object against one or more conditions.
By default, a destination decides for itself whether it's affected by an event: an `ExternalSecret`, for example, is considered affected if the event's identifier exactly matches one of its configured remote keys. A Match Strategy replaces that default check with your own: it evaluates a JSON path on the destination object against one or more conditions, so you can match on a different field, a substring, or a regular expression instead.

A condition's `value` is rendered as a Go template before comparison, with the event bound to the template root, so you can reference the specific event being processed - for example `{{ .SecretIdentifier }}`. Available fields are `SecretIdentifier`, `RotationTimestamp`, `TriggerSource`, and `Namespace`. A `value` with no template actions is used as a literal string.

If `path` resolves to multiple values (for example, a path containing `[*]`), the destination matches if *any* of those values satisfies *all* of the given conditions.

### Condition Operations

| Operation | Description |
|---------------------|------------------------------------------------|
| `Equal` | Exact value match. |
| `NotEqual` | Value does not match. |
| `Contains` | Value contains the given substring. |
| `NotContains` | Value does not contain the given substring. |
| `RegularExpression` | Value matches the given regular expression. |
| Operation | Description |
|---------------------|----------------------------------------------|
| `Equal` | Exact value match. |
| `NotEqual` | Value does not match. |
| `Contains` | Value contains the given substring. |
| `NotContains` | Value does not contain the given substring. |
| `ContainedBy` | Value is a substring of the given value. |
| `NotContainedBy` | Value is not a substring of the given value.|
| `RegularExpression` | Value matches the given regular expression. |

### Example: Custom Match Path

Expand Down Expand Up @@ -88,6 +94,23 @@ destinationsToWatch:
operation: RegularExpression
```

### Example: Matching a Friendly Name Against an ARN

Some sources - such as `AwsSqs` reading CloudTrail's `PutSecretValue` events - surface a secret's full ARN as the event identifier, while an `ExternalSecret` conventionally references that same secret by its friendly name. `ContainedBy`, combined with templating, lets the friendly name match against the ARN it's embedded in:

```yaml
destinationsToWatch:
- type: ExternalSecret
externalSecret:
labelSelectors:
matchLabels: {}
matchStrategy:
path: "spec.dataFrom[*].extract.key"
conditions:
- value: "{{ .SecretIdentifier }}"
operation: ContainedBy
```

## Wait Strategy

The Wait Strategy controls how the Reloader waits between reconciling multiple destination objects. This is useful to avoid overwhelming the cluster with simultaneous rollouts.
Expand Down
60 changes: 60 additions & 0 deletions internal/controller/reloader_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,66 @@ var _ = Describe("Reloader Controller", func() {
assertAnnotationsWithSource(fakeClient, esName, "2024-09-19T12:00:00Z", "KubernetesConfigMap")
})
})

Context("When a destination configures a MatchStrategy", func() {
It("should annotate an ExternalSecret referenced by friendly name when the event identifier is the full AWS Secrets Manager ARN", func() {
esName := "test-external-secret-matchstrategy"
friendlyName := "platform/ai-gateway/service-secrets"
arn := "arn:aws:secretsmanager:us-east-1:051826739313:secret:platform/ai-gateway/service-secrets-78YXTj"

// Add a second destination entry with its own MatchStrategy, so
// it doesn't affect the default References() behavior exercised
// by the other Contexts in this suite.
updatedConfig := &esov1.Config{}
Expect(fakeClient.Get(ctx, types.NamespacedName{Name: config.Name, Namespace: config.Namespace}, updatedConfig)).To(Succeed())
updatedConfig.Spec.DestinationsToWatch = append(updatedConfig.Spec.DestinationsToWatch, esov1.DestinationToWatch{
Type: "ExternalSecret",
ExternalSecret: &esov1.ExternalSecretDestination{
Names: []string{esName},
},
MatchStrategy: &esov1.MatchStrategy{
Path: "spec.dataFrom[*].extract.key",
Conditions: []esov1.Condition{
{Value: "{{ .SecretIdentifier }}", Operation: esov1.ConditionOperationContainedBy},
},
},
})
Expect(fakeClient.Update(ctx, updatedConfig)).To(Succeed())
_, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{Name: config.Name, Namespace: config.Namespace},
})
Expect(err).NotTo(HaveOccurred())

externalSecret = &esv1.ExternalSecret{
ObjectMeta: metav1.ObjectMeta{
Name: esName,
Namespace: "default",
},
Spec: esv1.ExternalSecretSpec{
SecretStoreRef: esv1.SecretStoreRef{
Name: "my-secret-store",
Kind: "SecretStore",
},
DataFrom: []esv1.ExternalSecretDataFromRemoteRef{
{
Extract: &esv1.ExternalSecretDataRemoteRef{
Key: friendlyName,
},
},
},
},
}
Expect(fakeClient.Create(context.Background(), externalSecret)).To(Succeed())

eventChan <- events.SecretRotationEvent{
SecretIdentifier: arn,
RotationTimestamp: "2026-07-23T17:25:37Z",
TriggerSource: "aws-secretsmanager",
}

assertAnnotationsWithSource(fakeClient, esName, "2026-07-23T17:25:37Z", "aws-secretsmanager")
})
})
})

func assertAnnotations(fakeClient client.Client, secretName string) {
Expand Down
19 changes: 14 additions & 5 deletions internal/handler/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
esov1alpha1 "github.com/external-secrets/reloader/api/v1alpha1"
"github.com/external-secrets/reloader/internal/events"
"github.com/external-secrets/reloader/internal/handler/schema"
"github.com/external-secrets/reloader/internal/matchstrategy"
)

type EventHandler struct {
Expand Down Expand Up @@ -40,20 +41,28 @@ func (h *EventHandler) HandleEvent(ctx context.Context, event events.SecretRotat
continue
}
h := prov.NewHandler(ctx, h.client, watchCriteria)
// Mutate Handler for different Update and Match Strategies
// Mutate Handler for different Update Strategies
if watchCriteria.UpdateStrategy != nil {
logger.Info("Optional Update strategies are not implemented", "UpdateStrategy", watchCriteria.UpdateStrategy)
}
if watchCriteria.MatchStrategy != nil {
logger.Info("Optional Match strategies are not implemented", "MatchStrategy", watchCriteria.MatchStrategy)
}
objs, err := h.Filter(&watchCriteria, event)
if err != nil {
return fmt.Errorf("failed to filter objects:%w", err)
}
// Use Handler methods to figure out and apply objects
for _, obj := range objs {
isReferenced, err := h.References(obj, event.SecretIdentifier)
// When a MatchStrategy is configured, it replaces the destination
// type's built-in References() check entirely, so callers can
// correlate a destination against the event on their own terms
// (e.g. a custom path, or a comparison the built-in check doesn't
// support).
var isReferenced bool
var err error
if watchCriteria.MatchStrategy != nil {
isReferenced, err = matchstrategy.Evaluate(watchCriteria.MatchStrategy, obj, event)
} else {
isReferenced, err = h.References(obj, event.SecretIdentifier)
}
if err != nil {
// This error means something went wrong on a reference check - which is typically very bad
logger.Error(err, "failed to check if object is referenced", "name", obj.GetName(), "namespace", obj.GetNamespace(), "type", watchCriteria.Type)
Expand Down
Loading
Loading