diff --git a/api/v1/reference_types.go b/api/v1/reference_types.go index 2dcb3c116..8b0dbfda6 100644 --- a/api/v1/reference_types.go +++ b/api/v1/reference_types.go @@ -36,6 +36,8 @@ type CrossNamespaceObjectReference struct { Name string `json:"name"` // Namespace of the referent + // If multiple resources across all namespaces are targeted `*` may be set, + // which requires the name to also be set to `*`. // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=253 // +kubebuilder:validation:Optional diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml index 89f03d2f1..3108daf41 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml @@ -116,7 +116,10 @@ spec: minLength: 1 type: string namespace: - description: Namespace of the referent + description: |- + Namespace of the referent + If multiple resources across all namespaces are targeted `*` may be set, + which requires the name to also be set to `*`. maxLength: 253 minLength: 1 type: string @@ -335,7 +338,10 @@ spec: minLength: 1 type: string namespace: - description: Namespace of the referent + description: |- + Namespace of the referent + If multiple resources across all namespaces are targeted `*` may be set, + which requires the name to also be set to `*`. maxLength: 253 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 6a544de10..1d92c274b 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml @@ -216,7 +216,10 @@ spec: minLength: 1 type: string namespace: - description: Namespace of the referent + description: |- + Namespace of the referent + If multiple resources across all namespaces are targeted `*` may be set, + which requires the name to also be set to `*`. maxLength: 253 minLength: 1 type: string @@ -457,7 +460,10 @@ spec: minLength: 1 type: string namespace: - description: Namespace of the referent + description: |- + Namespace of the referent + If multiple resources across all namespaces are targeted `*` may be set, + which requires the name to also be set to `*`. maxLength: 253 minLength: 1 type: string diff --git a/docs/api/v1/notification.md b/docs/api/v1/notification.md index 344e6a28f..231d14c57 100644 --- a/docs/api/v1/notification.md +++ b/docs/api/v1/notification.md @@ -273,7 +273,9 @@ string (Optional) -

Namespace of the referent

+

Namespace of the referent +If multiple resources across all namespaces are targeted * may be set, +which requires the name to also be set to *.

diff --git a/internal/server/receiver_handler_test.go b/internal/server/receiver_handler_test.go index afdcd8d30..cf8d60cba 100644 --- a/internal/server/receiver_handler_test.go +++ b/internal/server/receiver_handler_test.go @@ -552,6 +552,139 @@ func Test_handlePayload(t *testing.T) { expectedResourcesAnnotated: 1, expectedResponseCode: http.StatusOK, }, + { + name: "annotating resources by label match across all namespaces", + receiver: &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "receiver", + }, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, + SecretRef: &meta.LocalObjectReference{ + Name: "token", + }, + Resources: []apiv1.ReceiverResource{ + {CrossNamespaceObjectReference: apiv1.CrossNamespaceObjectReference{ + APIVersion: apiv1.GroupVersion.String(), + Kind: apiv1.ReceiverKind, + Name: "*", + Namespace: "*", + MatchLabels: map[string]string{ + "label": "match", + }, + }}, + }, + }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, + }, + secret: testSecretWithToken, + resources: []client.Object{ + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource-ns-a", + Namespace: "namespace-a", + Labels: map[string]string{ + "label": "match", + }, + }, + }, + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource-ns-b", + Namespace: "namespace-b", + Labels: map[string]string{ + "label": "match", + }, + }, + }, + &apiv1.Receiver{ + TypeMeta: metav1.TypeMeta{ + Kind: apiv1.ReceiverKind, + APIVersion: apiv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-resource-no-match", + Namespace: "namespace-c", + Labels: map[string]string{ + "label": "does-not-match", + }, + }, + }, + }, + expectedResourcesAnnotated: 2, + expectedResponseCode: http.StatusOK, + }, + { + name: "cannot annotate across all namespaces if namespace is * but name is not *", + receiver: &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "receiver", + }, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, + SecretRef: &meta.LocalObjectReference{ + Name: "token", + }, + Resources: []apiv1.ReceiverResource{ + {CrossNamespaceObjectReference: apiv1.CrossNamespaceObjectReference{ + APIVersion: apiv1.GroupVersion.String(), + Kind: apiv1.ReceiverKind, + Name: "dummy-resource", + Namespace: "*", + }}, + }, + }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, + }, + secret: testSecretWithToken, + expectedResponseCode: http.StatusInternalServerError, + }, + { + name: "cannot annotate across all namespaces if cross-namespace refs are disabled", + receiver: &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "receiver", + }, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, + SecretRef: &meta.LocalObjectReference{ + Name: "token", + }, + Resources: []apiv1.ReceiverResource{ + {CrossNamespaceObjectReference: apiv1.CrossNamespaceObjectReference{ + APIVersion: apiv1.GroupVersion.String(), + Kind: apiv1.ReceiverKind, + Name: "*", + Namespace: "*", + MatchLabels: map[string]string{ + "label": "match", + }, + }}, + }, + }, + Status: apiv1.ReceiverStatus{ + WebhookPath: apiv1.ReceiverWebhookPath, + Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}}, + }, + }, + secret: testSecretWithToken, + noCrossNamespaceRefs: true, + expectedResponseCode: http.StatusInternalServerError, + }, { name: "annotating resource by name", receiver: &apiv1.Receiver{ @@ -1511,7 +1644,7 @@ func Test_handlePayload(t *testing.T) { logger: logger.NewLogger(logger.Options{}), kubeClient: client, noCrossNamespaceRefs: tt.noCrossNamespaceRefs, - gcrTokenValidator: func(_ context.Context, bearer string, expectedEmail string, expectedAudience string) error { + gcrTokenValidator: func(_ context.Context, bearer, expectedEmail, expectedAudience string) error { if bearer == "" { return fmt.Errorf("missing authorization header") } diff --git a/internal/server/receiver_handlers.go b/internal/server/receiver_handlers.go index 323716efc..bb1c3c56f 100644 --- a/internal/server/receiver_handlers.go +++ b/internal/server/receiver_handlers.go @@ -186,7 +186,7 @@ func (s *ReceiverServer) notifyDynamicResources(ctx context.Context, logger logr } logger.V(1).Info(fmt.Sprintf("annotate resources by matchLabel for kind %q in %q", - resource.Kind, namespace), "matchLabels", resource.MatchLabels) + resource.Kind, resource.Namespace), "matchLabels", resource.MatchLabels) var resources metav1.PartialObjectMetadataList resources.SetGroupVersionKind(schema.GroupVersionKind{ @@ -195,10 +195,14 @@ func (s *ReceiverServer) notifyDynamicResources(ctx context.Context, logger logr Version: version, }) - if err := s.kubeClient.List(ctx, &resources, - client.InNamespace(namespace), - client.MatchingLabels(resource.MatchLabels), - ); err != nil { + // List resources matching the labels in the specified namespace + // or across all namespaces if namespace is '*' (kube client list all resources when namespace is not specified). + listOpts := []client.ListOption{client.MatchingLabels(resource.MatchLabels)} + if namespace != "*" { + listOpts = append(listOpts, client.InNamespace(namespace)) + } + + if err := s.kubeClient.List(ctx, &resources, listOpts...); err != nil { return fmt.Errorf("failed listing resources in namespace %q by matching labels %q: %w", namespace, resource.MatchLabels, err) } @@ -571,6 +575,9 @@ func (s *ReceiverServer) requestReconciliation(ctx context.Context, logger logr. if s.noCrossNamespaceRefs && resource.Namespace != defaultNamespace { return fmt.Errorf("cross-namespace references are not allowed") } + if resource.Namespace == "*" && resource.Name != "*" { + return fmt.Errorf("name must be set to '*' when namespace is set to '*'") + } namespace = resource.Namespace } @@ -623,7 +630,7 @@ func (s *ReceiverServer) annotate(ctx context.Context, resource *metav1.PartialO // authenticateGCRRequest validates the OIDC ID token according to // https://docs.cloud.google.com/pubsub/docs/authenticate-push-subscriptions#go. -func authenticateGCRRequest(ctx context.Context, bearer string, expectedEmail string, expectedAudience string) error { +func authenticateGCRRequest(ctx context.Context, bearer, expectedEmail, expectedAudience string) error { const bearerPrefix = "Bearer " if !strings.HasPrefix(bearer, bearerPrefix) { return fmt.Errorf("the Authorization header is missing or malformed")