Skip to content
Merged
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
4 changes: 2 additions & 2 deletions api/v1alpha1/reloader_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ type ConfigSpec struct {

// NotificationSource represents a notification system configuration.
type NotificationSource struct {
// Type of the notification source (e.g., AwsSqs, AzureEventGrid, GooglePubSub, HashicorpVault, Webhook, TCPSocket, KubernetesSecret).
// +kubebuilder:validation:Enum=AwsSqs;AzureEventGrid;GooglePubSub;HashicorpVault;Webhook;TCPSocket;KubernetesSecret
// Type of the notification source (e.g., AwsSqs, AzureEventGrid, GooglePubSub, HashicorpVault, Webhook, TCPSocket, KubernetesSecret, KubernetesConfigMap).
// +kubebuilder:validation:Enum=AwsSqs;AzureEventGrid;GooglePubSub;HashicorpVault;Webhook;TCPSocket;KubernetesSecret;KubernetesConfigMap
// +required
Type string `json:"type"`

Expand Down
3 changes: 2 additions & 1 deletion config/crd/bases/reloader.external-secrets.io_configs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,7 @@ spec:
type:
description: Type of the notification source (e.g., AwsSqs,
AzureEventGrid, GooglePubSub, HashicorpVault, Webhook, TCPSocket,
KubernetesSecret).
KubernetesSecret, KubernetesConfigMap).
enum:
- AwsSqs
- AzureEventGrid
Expand All @@ -1179,6 +1179,7 @@ spec:
- Webhook
- TCPSocket
- KubernetesSecret
- KubernetesConfigMap
type: string
webhook:
description: Webhook configuration (required if Type is Webhook).
Expand Down
1 change: 1 addition & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ rules:
- apiGroups:
- ""
resources:
- configmaps
- secrets
verbs:
- get
Expand Down
2 changes: 2 additions & 0 deletions internal/controller/reloader_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ func (r *ReloaderReconciler) SetupWithManager(mgr ctrl.Manager) error {
// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;create;update;patch
// For k8s Secret notification source
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
// For k8s ConfigMap notification source
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch

// Reconcile reconciles a Config object, ensuring that the internal state aligns with the desired state.
// It fetches the Reloader instance, updates the internal cache, and manages notification listeners.
Expand Down
78 changes: 78 additions & 0 deletions internal/controller/reloader_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,58 @@ var _ = Describe("Reloader Controller", func() {
assertAnnotations(fakeClient, "test-external-secret-datafrom-find")
})
})

Context("When a secret rotation event is received and ExternalSecret uses target.template.templateFrom.configMap", func() {
It("should annotate the ExternalSecret when templateFrom.configMap.name matches the event secret identifier", func() {
configMapName := "operator-config"
esName := "test-external-secret-templatefrom"

// Update config to watch this ExternalSecret by name
updatedConfig := &esov1.Config{}
Expect(fakeClient.Get(ctx, types.NamespacedName{Name: config.Name, Namespace: config.Namespace}, updatedConfig)).To(Succeed())
updatedConfig.Spec.DestinationsToWatch[0].ExternalSecret.Names = append(
updatedConfig.Spec.DestinationsToWatch[0].ExternalSecret.Names,
esName,
)
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())

// Create ExternalSecret that references the ConfigMap via templateFrom (e.g. ConfigMap-triggered event)
externalSecret = &esv1.ExternalSecret{
ObjectMeta: metav1.ObjectMeta{
Name: esName,
Namespace: "default",
},
Spec: esv1.ExternalSecretSpec{
Target: esv1.ExternalSecretTarget{
Template: &esv1.ExternalSecretTemplate{
TemplateFrom: []esv1.TemplateFrom{
{
ConfigMap: &esv1.TemplateRef{
Name: configMapName,
Items: []esv1.TemplateRefItem{{Key: "config", TemplateAs: esv1.TemplateScopeValues}},
},
},
},
},
},
},
}
Expect(fakeClient.Create(context.Background(), externalSecret)).To(Succeed())

// Send event with ConfigMap name as identifier (as would happen for KubernetesConfigMap source)
eventChan <- events.SecretRotationEvent{
SecretIdentifier: configMapName,
RotationTimestamp: "2024-09-19T12:00:00Z",
TriggerSource: "KubernetesConfigMap",
}

assertAnnotationsWithSource(fakeClient, esName, "2024-09-19T12:00:00Z", "KubernetesConfigMap")
})
})
})

func assertAnnotations(fakeClient client.Client, secretName string) {
Expand Down Expand Up @@ -311,3 +363,29 @@ func assertNotWatchedAnnotations(fakeClient client.Client, secretName string) {
return nil
}, "5s", "500ms").Should(Succeed())
}

func assertAnnotationsWithSource(fakeClient client.Client, secretName, expectedTimestamp, expectedTriggerSource string) {
updatedES := &esv1.ExternalSecret{}
key := types.NamespacedName{
Namespace: "default",
Name: secretName,
}
Eventually(func() error {
updatedES = &esv1.ExternalSecret{}
err := fakeClient.Get(context.Background(), key, updatedES)
if err != nil {
return err
}
annotations := updatedES.GetAnnotations()
if annotations == nil {
return fmt.Errorf("ExternalSecret annotations should not be nil")
}
if annotations["reloader/last-rotated"] != expectedTimestamp {
return fmt.Errorf("reloader/last-rotated annotation should be %q, got %q", expectedTimestamp, annotations["reloader/last-rotated"])
}
if annotations["reloader/trigger-source"] != expectedTriggerSource {
return fmt.Errorf("reloader/trigger-source annotation should be %q, got %q", expectedTriggerSource, annotations["reloader/trigger-source"])
}
return nil
}, "5s", "500ms").Should(Succeed())
}
14 changes: 14 additions & 0 deletions internal/handler/externalsecret/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ func (h *Handler) _references(obj client.Object, secretIdentifier string) (bool,
}
}
}

// Check target.template.templateFrom for ConfigMap/Secret references (e.g. when the
// trigger is a ConfigMap or in-cluster Secret and the ExternalSecret uses it for templating).
if es.Spec.Target.Template != nil {
for _, tf := range es.Spec.Target.Template.TemplateFrom {
if tf.ConfigMap != nil && tf.ConfigMap.Name == secretIdentifier {
return true, nil
}
if tf.Secret != nil && tf.Secret.Name == secretIdentifier {
return true, nil
}
}
}

return false, nil
}

Expand Down
218 changes: 218 additions & 0 deletions internal/handler/externalsecret/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package externalsecret

import (
"context"
"testing"

esov1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

"github.com/external-secrets/reloader/api/v1alpha1"
)

func externalSecretWithTemplateFromConfigMap(name string, configMapName string) *esov1.ExternalSecret {
return &esov1.ExternalSecret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Spec: esov1.ExternalSecretSpec{
Target: esov1.ExternalSecretTarget{
Template: &esov1.ExternalSecretTemplate{
TemplateFrom: []esov1.TemplateFrom{
{
ConfigMap: &esov1.TemplateRef{
Name: configMapName,
Items: []esov1.TemplateRefItem{
{Key: "config", TemplateAs: esov1.TemplateScopeValues},
},
},
},
},
},
},
},
}
}

func externalSecretWithTemplateFromSecret(name string, secretName string) *esov1.ExternalSecret {
return &esov1.ExternalSecret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Spec: esov1.ExternalSecretSpec{
Target: esov1.ExternalSecretTarget{
Template: &esov1.ExternalSecretTemplate{
TemplateFrom: []esov1.TemplateFrom{
{
Secret: &esov1.TemplateRef{
Name: secretName,
Items: []esov1.TemplateRefItem{
{Key: "tpl", TemplateAs: esov1.TemplateScopeValues},
},
},
},
},
},
},
},
}
}

func externalSecretWithTemplateFromMultiple(configMapName, secretName string) *esov1.ExternalSecret {
templateFrom := []esov1.TemplateFrom{}
if configMapName != "" {
templateFrom = append(templateFrom, esov1.TemplateFrom{
ConfigMap: &esov1.TemplateRef{Name: configMapName, Items: []esov1.TemplateRefItem{{Key: "config"}}},
})
}
if secretName != "" {
templateFrom = append(templateFrom, esov1.TemplateFrom{
Secret: &esov1.TemplateRef{Name: secretName, Items: []esov1.TemplateRefItem{{Key: "tpl"}}},
})
}
return &esov1.ExternalSecret{
ObjectMeta: metav1.ObjectMeta{Name: "multi", Namespace: "default"},
Spec: esov1.ExternalSecretSpec{
Target: esov1.ExternalSecretTarget{
Template: &esov1.ExternalSecretTemplate{TemplateFrom: templateFrom},
},
},
}
}

func externalSecretWithRemoteRefKey(name string, remoteKey string) *esov1.ExternalSecret {
return &esov1.ExternalSecret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Spec: esov1.ExternalSecretSpec{
Data: []esov1.ExternalSecretData{
{SecretKey: "key", RemoteRef: esov1.ExternalSecretDataRemoteRef{Key: remoteKey}},
},
},
}
}

func newHandlerWithDefaults() *Handler {
ctx := context.Background()
scheme := newScheme()
c := fake.NewClientBuilder().WithScheme(scheme).Build()
dest := v1alpha1.DestinationToWatch{
Type: "ExternalSecret",
ExternalSecret: &v1alpha1.ExternalSecretDestination{},
}
h := &Handler{
ctx: ctx,
client: c,
destinationCache: dest,
}
h.referenceFn = h._references
return h
}

func newScheme() *runtime.Scheme {
scheme := runtime.NewScheme()
_ = esov1.AddToScheme(scheme)
return scheme
}

// TestHandler_References_TemplateFromConfigMap verifies that an ExternalSecret
// that uses target.template.templateFrom[].configMap.name is considered to
// reference that ConfigMap (e.g. when the event is from a KubernetesConfigMap source).
func TestHandler_References_TemplateFromConfigMap(t *testing.T) {
h := newHandlerWithDefaults()
es := externalSecretWithTemplateFromConfigMap("operator", "operator-config")
ref, err := h.References(es, "operator-config")
if err != nil {
t.Fatalf("References: %v", err)
}
if !ref {
t.Error("expected References to return true when templateFrom.configMap.name matches secretIdentifier")
}
}

// TestHandler_References_TemplateFromConfigMapNoMatch verifies that when the
// ConfigMap name in templateFrom does not match the secret identifier,
// References returns false.
func TestHandler_References_TemplateFromConfigMapNoMatch(t *testing.T) {
h := newHandlerWithDefaults()
es := externalSecretWithTemplateFromConfigMap("operator", "other-config")
ref, err := h.References(es, "operator-config")
if err != nil {
t.Fatalf("References: %v", err)
}
if ref {
t.Error("expected References to return false when templateFrom.configMap.name does not match")
}
}

// TestHandler_References_TemplateFromSecret verifies that an ExternalSecret
// that uses target.template.templateFrom[].secret.name is considered to
// reference that Secret.
func TestHandler_References_TemplateFromSecret(t *testing.T) {
h := newHandlerWithDefaults()
es := externalSecretWithTemplateFromSecret("app", "my-secret")
ref, err := h.References(es, "my-secret")
if err != nil {
t.Fatalf("References: %v", err)
}
if !ref {
t.Error("expected References to return true when templateFrom.secret.name matches secretIdentifier")
}
}

// TestHandler_References_TemplateFromMultiple verifies that when templateFrom
// has both ConfigMap and Secret refs, matching either name returns true.
func TestHandler_References_TemplateFromMultiple(t *testing.T) {
h := newHandlerWithDefaults()
es := externalSecretWithTemplateFromMultiple("my-configmap", "my-secret")

ref, err := h.References(es, "my-configmap")
if err != nil {
t.Fatalf("References(configmap): %v", err)
}
if !ref {
t.Error("expected true for configmap name")
}

ref, err = h.References(es, "my-secret")
if err != nil {
t.Fatalf("References(secret): %v", err)
}
if !ref {
t.Error("expected true for secret name")
}

ref, err = h.References(es, "other")
if err != nil {
t.Fatalf("References(other): %v", err)
}
if ref {
t.Error("expected false for non-matching name")
}
}

// TestHandler_References_RemoteRefKey verifies existing behavior: matching
// spec.data[].remoteRef.key returns true.
func TestHandler_References_RemoteRefKey(t *testing.T) {
h := newHandlerWithDefaults()
es := externalSecretWithRemoteRefKey("es", "my-remote-key")
ref, err := h.References(es, "my-remote-key")
if err != nil {
t.Fatalf("References: %v", err)
}
if !ref {
t.Error("expected References to return true when remoteRef.key matches")
}
}

// TestHandler_References_NotExternalSecret verifies that passing a non-ExternalSecret
// object returns an error.
func TestHandler_References_NotExternalSecret(t *testing.T) {
h := newHandlerWithDefaults()
obj := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default"}}
ref, err := h.References(obj, "x")
if err == nil {
t.Fatal("expected error when obj is not ExternalSecret")
}
if ref {
t.Error("expected false when error is returned")
}
}
2 changes: 1 addition & 1 deletion internal/listener/k8sconfigmap/provider.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package k8ssecret
package k8sconfigmap

import (
"context"
Expand Down
2 changes: 2 additions & 0 deletions internal/listener/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ func generateListenerKey(source esov1alpha1.NotificationSource) (string, error)
config = source.Mock
case schema.KUBERNETES_SECRET:
config = source.KubernetesSecret
case schema.KUBERNETES_CONFIG_MAP:
config = source.KubernetesConfigMap
default:
return "", fmt.Errorf("unsupported notification source type: %s", source.Type)
}
Expand Down
Loading
Loading