From 57f62f400c06301bf4aa4954b38f0a5c54ca1b46 Mon Sep 17 00:00:00 2001 From: Raffael Sahli Date: Mon, 20 Mar 2023 08:11:30 +0000 Subject: [PATCH] feat: support multi receiver by matchLabels Signed-off-by: Raffael Sahli --- api/v1beta2/reference_types.go | 2 + ...notification.toolkit.fluxcd.io_alerts.yaml | 5 +- ...ification.toolkit.fluxcd.io_receivers.yaml | 5 +- docs/api/notification.md | 6 +- internal/server/receiver_handler_test.go | 441 +++++++++++++++++- internal/server/receiver_handlers.go | 80 +++- 6 files changed, 492 insertions(+), 47 deletions(-) diff --git a/api/v1beta2/reference_types.go b/api/v1beta2/reference_types.go index 50594f3fd..b3e484b3e 100644 --- a/api/v1beta2/reference_types.go +++ b/api/v1beta2/reference_types.go @@ -29,6 +29,7 @@ type CrossNamespaceObjectReference struct { Kind string `json:"kind,omitempty"` // Name of the referent. + // If multiple resources are targeted `*` may be set. // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=53 // +required @@ -44,6 +45,7 @@ type CrossNamespaceObjectReference struct { // MatchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels // map is equivalent to an element of matchExpressions, whose key field is "key", the // operator is "In", and the values array contains only "value". The requirements are ANDed. + // MatchLabels requires the name to be set to `*`. // +optional MatchLabels map[string]string `json:"matchLabels,omitempty"` } diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml index 1502aaddb..fbf395bca 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml @@ -279,10 +279,11 @@ spec: {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements - are ANDed. + are ANDed. MatchLabels requires the name to be set to `*`. type: object name: - description: Name of the referent. + description: Name of the referent. If multiple resources are + targeted `*` may be set. maxLength: 53 minLength: 1 type: string diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml index 4291168e0..021d1882b 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml @@ -288,10 +288,11 @@ spec: {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements - are ANDed. + are ANDed. MatchLabels requires the name to be set to `*`. type: object name: - description: Name of the referent. + description: Name of the referent. If multiple resources are + targeted `*` may be set. maxLength: 53 minLength: 1 type: string diff --git a/docs/api/notification.md b/docs/api/notification.md index cc2837b5e..f61bd7616 100644 --- a/docs/api/notification.md +++ b/docs/api/notification.md @@ -741,7 +741,8 @@ string -

Name of the referent.

+

Name of the referent. +If multiple resources are targeted * may be set.

@@ -767,7 +768,8 @@ map[string]string (Optional)

MatchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is “key”, the -operator is “In”, and the values array contains only “value”. The requirements are ANDed.

+operator is “In”, and the values array contains only “value”. The requirements are ANDed. +MatchLabels requires the name to be set to *.

diff --git a/internal/server/receiver_handler_test.go b/internal/server/receiver_handler_test.go index c28436e40..186365b1a 100644 --- a/internal/server/receiver_handler_test.go +++ b/internal/server/receiver_handler_test.go @@ -23,13 +23,16 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "net/http" "net/http/httptest" "testing" "github.com/google/go-github/v41/github" + "github.com/onsi/gomega" 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" "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/fluxcd/pkg/apis/meta" @@ -38,21 +41,23 @@ import ( apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" ) -func Test_validate(t *testing.T) { +func Test_handlePayload(t *testing.T) { type hashOpts struct { calculate bool header string } tests := []struct { - name string - hashOpts hashOpts - headers map[string]string - payload map[string]interface{} - receiver *apiv1.Receiver - receiverType string - secret *corev1.Secret - expectedErr bool + name string + hashOpts hashOpts + headers map[string]string + payload map[string]interface{} + receiver *apiv1.Receiver + receiverType string + secret *corev1.Secret + resources []client.Object + expectedResourcesAnnotated int + expectedResponseCode int }{ { name: "Generic receiver", @@ -66,6 +71,10 @@ func Test_validate(t *testing.T) { Name: "token", }, }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, }, secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -75,7 +84,7 @@ func Test_validate(t *testing.T) { "token": []byte("token"), }, }, - expectedErr: false, + expectedResponseCode: http.StatusOK, }, { name: "gitlab receiver", @@ -89,6 +98,10 @@ func Test_validate(t *testing.T) { Name: "token", }, }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, }, headers: map[string]string{ "X-Gitlab-Token": "token", @@ -101,7 +114,7 @@ func Test_validate(t *testing.T) { "token": []byte("token"), }, }, - expectedErr: false, + expectedResponseCode: http.StatusOK, }, { name: "github receiver", @@ -115,6 +128,10 @@ func Test_validate(t *testing.T) { Name: "token", }, }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, }, hashOpts: hashOpts{ calculate: true, @@ -134,7 +151,7 @@ func Test_validate(t *testing.T) { "token": []byte("token"), }, }, - expectedErr: false, + expectedResponseCode: http.StatusOK, }, { name: "generic hmac receiver", @@ -148,6 +165,10 @@ func Test_validate(t *testing.T) { Name: "token", }, }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, }, hashOpts: hashOpts{ calculate: true, @@ -164,7 +185,7 @@ func Test_validate(t *testing.T) { "token": []byte("token"), }, }, - expectedErr: false, + expectedResponseCode: http.StatusOK, }, { name: "bitbucket receiver", @@ -179,6 +200,10 @@ func Test_validate(t *testing.T) { Name: "token", }, }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, }, hashOpts: hashOpts{ calculate: true, @@ -196,7 +221,7 @@ func Test_validate(t *testing.T) { "token": []byte("token"), }, }, - expectedErr: false, + expectedResponseCode: http.StatusOK, }, { name: "quay receiver", @@ -210,6 +235,10 @@ func Test_validate(t *testing.T) { Name: "token", }, }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, }, secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -225,7 +254,7 @@ func Test_validate(t *testing.T) { "v0.0.1", }, }, - expectedErr: false, + expectedResponseCode: http.StatusOK, }, { name: "harbor receiver", @@ -239,6 +268,10 @@ func Test_validate(t *testing.T) { Name: "token", }, }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, }, secret: &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -251,7 +284,7 @@ func Test_validate(t *testing.T) { headers: map[string]string{ "Authorization": "token", }, - expectedErr: false, + expectedResponseCode: http.StatusOK, }, { name: "missing secret", @@ -265,8 +298,351 @@ func Test_validate(t *testing.T) { Name: "non-existing", }, }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, + }, + expectedResponseCode: http.StatusBadRequest, + }, + { + name: "no receiver configured", + expectedResponseCode: http.StatusNotFound, + }, + { + name: "not ready receiver is ignored", + receiver: &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "notready-receiver", + }, + Spec: apiv1.ReceiverSpec{}, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.StalledCondition, Status: metav1.ConditionFalse}}, + }, + }, + expectedResponseCode: http.StatusNotFound, + }, + { + name: "suspended receiver ignored", + receiver: &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "suspended-receiver", + }, + Spec: apiv1.ReceiverSpec{ + Suspend: true, + }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, + }, + expectedResponseCode: http.StatusNotFound, + }, + { + name: "missing apiVersion in resource", + receiver: &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "receiver", + }, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, + SecretRef: meta.LocalObjectReference{ + Name: "token", + }, + Resources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: apiv1.ReceiverKind, + MatchLabels: map[string]string{ + "label": "match", + }, + }, + }, + }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "token", + }, + Data: map[string][]byte{ + "token": []byte("token"), + }, + }, + expectedResponseCode: http.StatusBadRequest, + }, + { + name: "resource by name not found", + receiver: &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "receiver", + }, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, + SecretRef: meta.LocalObjectReference{ + Name: "token", + }, + Resources: []apiv1.CrossNamespaceObjectReference{ + { + APIVersion: apiv1.GroupVersion.String(), + Kind: apiv1.ReceiverKind, + Name: "does-not-exists", + }, + }, + }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, }, - expectedErr: true, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "token", + }, + Data: map[string][]byte{ + "token": []byte("token"), + }, + }, + expectedResponseCode: http.StatusBadRequest, + }, + { + name: "annotating resources by label match", + receiver: &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "receiver", + }, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, + SecretRef: meta.LocalObjectReference{ + Name: "token", + }, + Resources: []apiv1.CrossNamespaceObjectReference{ + { + APIVersion: apiv1.GroupVersion.String(), + Kind: apiv1.ReceiverKind, + Name: "*", + MatchLabels: map[string]string{ + "label": "match", + }, + }, + }, + }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "token", + }, + Data: map[string][]byte{ + "token": []byte("token"), + }, + }, + resources: []client.Object{ + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource-2", + Labels: map[string]string{ + "label": "does-not-match", + }, + }, + }, + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource", + Labels: map[string]string{ + "label": "match", + }, + }, + }, + }, + expectedResourcesAnnotated: 1, + expectedResponseCode: http.StatusOK, + }, + { + name: "annotating resource by name", + receiver: &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "receiver", + }, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, + SecretRef: meta.LocalObjectReference{ + Name: "token", + }, + Resources: []apiv1.CrossNamespaceObjectReference{ + { + APIVersion: apiv1.GroupVersion.String(), + Kind: apiv1.ReceiverKind, + Name: "dummy-resource", + }, + }, + }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "token", + }, + Data: map[string][]byte{ + "token": []byte("token"), + }, + }, + resources: []client.Object{ + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource-2", + }, + }, + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource", + }, + }, + }, + expectedResourcesAnnotated: 1, + expectedResponseCode: http.StatusOK, + }, + { + name: "annotating all resources if name is *", + receiver: &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "receiver", + }, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, + SecretRef: meta.LocalObjectReference{ + Name: "token", + }, + Resources: []apiv1.CrossNamespaceObjectReference{ + { + APIVersion: apiv1.GroupVersion.String(), + Kind: apiv1.ReceiverKind, + Name: "*", + }, + }, + }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "token", + }, + Data: map[string][]byte{ + "token": []byte("token"), + }, + }, + resources: []client.Object{ + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource-2", + }, + }, + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource", + }, + }, + }, + expectedResourcesAnnotated: 3, // it is 3 because we target receivers itself in this test and the receiver here is included with * + expectedResponseCode: http.StatusOK, + }, + { + name: "resource matchLabels is ignored if name is not *", + receiver: &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "receiver", + }, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, + SecretRef: meta.LocalObjectReference{ + Name: "token", + }, + Resources: []apiv1.CrossNamespaceObjectReference{ + { + APIVersion: apiv1.GroupVersion.String(), + Kind: apiv1.ReceiverKind, + Name: "dummy-resource", + MatchLabels: map[string]string{ + "label": "match", + }, + }, + }, + }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "token", + }, + Data: map[string][]byte{ + "token": []byte("token"), + }, + }, + resources: []client.Object{ + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource-2", + }, + }, + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource", + }, + }, + }, + expectedResourcesAnnotated: 1, + expectedResponseCode: http.StatusOK, }, } @@ -276,10 +652,17 @@ func Test_validate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + g := gomega.NewGomegaWithT(t) builder := fake.NewClientBuilder() builder.WithScheme(scheme) - builder.WithObjects(tt.receiver) + + if tt.receiver != nil { + builder.WithObjects(tt.receiver) + } + + builder.WithObjects(tt.resources...) + if tt.secret != nil { builder.WithObjects(tt.secret) } @@ -295,7 +678,7 @@ func Test_validate(t *testing.T) { if err != nil { t.Errorf("error marshalling test payload: '%s'", err) } - req := httptest.NewRequest("POST", "/", bytes.NewBuffer(data)) + req := httptest.NewRequest("POST", "/hook/", bytes.NewBuffer(data)) for key, val := range tt.headers { req.Header.Set(key, val) } @@ -308,14 +691,22 @@ func Test_validate(t *testing.T) { req.Header.Set(tt.hashOpts.header, "sha256="+hex.EncodeToString(mac.Sum(nil))) } - err = s.validate(context.Background(), *tt.receiver, req) - if tt.expectedErr && err == nil { - t.Errorf("expected error but got %s", err) - } + rr := httptest.NewRecorder() + handler := s.handlePayload() + handler(rr, req) + g.Expect(rr.Result().StatusCode).To(gomega.Equal(tt.expectedResponseCode)) - if !tt.expectedErr && err != nil { - t.Errorf("unexpected error: '%s'", err) + var allReceivers apiv1.ReceiverList + err = client.List(context.TODO(), &allReceivers) + + var annotatedResources int + for _, obj := range allReceivers.Items { + if _, ok := obj.GetAnnotations()[meta.ReconcileRequestAnnotation]; ok { + annotatedResources++ + } } + + g.Expect(annotatedResources).To(gomega.Equal(tt.expectedResourcesAnnotated)) }) } } diff --git a/internal/server/receiver_handlers.go b/internal/server/receiver_handlers.go index 28ca40d70..9f855f64f 100644 --- a/internal/server/receiver_handlers.go +++ b/internal/server/receiver_handlers.go @@ -32,6 +32,7 @@ import ( "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/runtime/conditions" + "github.com/go-logr/logr" "github.com/google/go-github/v41/github" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -45,6 +46,7 @@ import ( // defaultFluxAPIVersions is a map of Flux API kinds to their API versions. var defaultFluxAPIVersions = map[string]string{ "Bucket": "source.toolkit.fluxcd.io/v1beta2", + "HelmChart": "source.toolkit.fluxcd.io/v1beta2", "HelmRepository": "source.toolkit.fluxcd.io/v1beta2", "GitRepository": "source.toolkit.fluxcd.io/v1beta2", "OCIRepository": "source.toolkit.fluxcd.io/v1beta2", @@ -94,13 +96,9 @@ func (s *ReceiverServer) handlePayload() func(w http.ResponseWriter, r *http.Req } for _, resource := range receiver.Spec.Resources { - if err := s.annotate(ctx, resource, receiver.Namespace); err != nil { - logger.Error(err, fmt.Sprintf("unable to annotate resource '%s/%s.%s'", - resource.Kind, resource.Name, resource.Namespace)) + if err := s.requestReconciliation(ctx, logger, resource, receiver.Namespace); err != nil { + logger.Error(err, "unable to process resource") withErrors = true - } else { - logger.Info(fmt.Sprintf("resource '%s/%s.%s' annotated", - resource.Kind, resource.Name, resource.Namespace)) } } } @@ -347,15 +345,12 @@ func (s *ReceiverServer) token(ctx context.Context, receiver apiv1.Receiver) (st return token, nil } -func (s *ReceiverServer) annotate(ctx context.Context, resource apiv1.CrossNamespaceObjectReference, defaultNamespace string) error { +// requestReconciliation requests reconciliation of all the resources matching the given CrossNamespaceObjectReference by annotating them accordingly. +func (s *ReceiverServer) requestReconciliation(ctx context.Context, logger logr.Logger, resource apiv1.CrossNamespaceObjectReference, defaultNamespace string) error { namespace := defaultNamespace if resource.Namespace != "" { namespace = resource.Namespace } - objectKey := client.ObjectKey{ - Namespace: namespace, - Name: resource.Name, - } apiVersion := resource.APIVersion if apiVersion == "" { @@ -367,6 +362,36 @@ func (s *ReceiverServer) annotate(ctx context.Context, resource apiv1.CrossNames group, version := getGroupVersion(apiVersion) + if resource.Name == "*" { + logger.Info(fmt.Sprintf("annotate resources by matchLabel for kind '%s' in '%s'", + resource.Kind, namespace), "matchLabels", resource.MatchLabels) + + var resources metav1.PartialObjectMetadataList + resources.SetGroupVersionKind(schema.GroupVersionKind{ + Group: group, + Kind: resource.Kind, + Version: version, + }) + + if err := s.kubeClient.List(ctx, &resources, + client.InNamespace(namespace), + client.MatchingLabels(resource.MatchLabels), + ); err != nil { + return fmt.Errorf("failed listing resources in namespace %q by matching labels %q: %w", namespace, resource.MatchLabels, err) + } + + for _, resource := range resources.Items { + if err := s.annotate(ctx, &resource); err != nil { + return fmt.Errorf("failed to annotate resource: '%s/%s.%s': %w", resource.Kind, resource.Name, namespace, err) + } else { + logger.V(1).Info(fmt.Sprintf("resource '%s/%s.%s' annotated", + resource.Kind, resource.Name, namespace)) + } + } + + return nil + } + u := &metav1.PartialObjectMetadata{} u.SetGroupVersionKind(schema.GroupVersionKind{ Group: group, @@ -374,19 +399,42 @@ func (s *ReceiverServer) annotate(ctx context.Context, resource apiv1.CrossNames Version: version, }) + objectKey := client.ObjectKey{ + Namespace: namespace, + Name: resource.Name, + } + if err := s.kubeClient.Get(ctx, objectKey, u); err != nil { return fmt.Errorf("unable to read %s '%s' error: %w", resource.Kind, objectKey, err) } - patch := client.MergeFrom(u.DeepCopy()) - sourceAnnotations := u.GetAnnotations() + err := s.annotate(ctx, u) + if err != nil { + return fmt.Errorf("failed to annotate resource: '%s/%s.%s': %w", resource.Kind, resource.Name, namespace, err) + } else { + logger.Info(fmt.Sprintf("resource '%s/%s.%s' annotated", + resource.Kind, resource.Name, namespace)) + } + + return nil +} + +func (s *ReceiverServer) annotate(ctx context.Context, resource *metav1.PartialObjectMetadata) error { + patch := client.MergeFrom(resource.DeepCopy()) + sourceAnnotations := resource.GetAnnotations() + if sourceAnnotations == nil { sourceAnnotations = make(map[string]string) } + sourceAnnotations[meta.ReconcileRequestAnnotation] = metav1.Now().String() - u.SetAnnotations(sourceAnnotations) - if err := s.kubeClient.Patch(ctx, u, patch); err != nil { - return fmt.Errorf("unable to annotate %s '%s' error: %w", resource.Kind, objectKey, err) + resource.SetAnnotations(sourceAnnotations) + + if err := s.kubeClient.Patch(ctx, resource, patch); err != nil { + return fmt.Errorf("unable to annotate %s '%s' error: %w", resource.Kind, client.ObjectKey{ + Namespace: resource.Namespace, + Name: resource.Name, + }, err) } return nil