Files
my-rook-config/tests/integration/object/notification/notification.go
T
Joshua Hoblitt 8dfc5d408d test: add an OBC update helper to util/obc
Add obc.Update, a get/mutate/update helper for live ObjectBucketClaims,
generalized from the notification suite's label updater, which becomes a
thin wrapper over it. The bucket quota conversion needs the same shape for
additionalConfig updates.

Signed-off-by: Joshua Hoblitt <josh@hoblitt.com>
2026-07-06 10:35:37 -07:00

445 lines
17 KiB
Go

/*
Copyright 2025 The Rook Authors. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package notification
import (
"context"
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
bktv1alpha1 "github.com/kube-object-storage/lib-bucket-provisioner/pkg/apis/objectbucket.io/v1alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/util/intstr"
cephv1 "github.com/rook/rook/pkg/apis/ceph.rook.io/v1"
rgw "github.com/rook/rook/pkg/operator/ceph/object"
"github.com/rook/rook/tests/framework/utils"
"github.com/rook/rook/tests/integration/object/util/fixture"
"github.com/rook/rook/tests/integration/object/util/obc"
"github.com/rook/rook/tests/integration/object/util/sharedstore"
"github.com/rook/rook/tests/integration/object/util/wait4"
)
const sinkPort int32 = 8080
const (
putEvent = "ObjectCreated:Put"
deleteEvent = "ObjectRemoved:Delete"
)
// httpTopic builds a CephBucketTopic that delivers to an HTTP endpoint.
func httpTopic(name, namespace string, store *cephv1.CephObjectStore, uri string) *cephv1.CephBucketTopic {
return &cephv1.CephBucketTopic{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: cephv1.BucketTopicSpec{
ObjectStoreName: store.Name,
ObjectStoreNamespace: store.Namespace,
Endpoint: cephv1.TopicEndpointSpec{HTTP: &cephv1.HTTPEndpointSpec{URI: uri}},
},
}
}
// bucketNotification builds a CephBucketNotification for object put and delete events.
func bucketNotification(name, namespace, topic string) *cephv1.CephBucketNotification {
return &cephv1.CephBucketNotification{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: cephv1.BucketNotificationSpec{
Topic: topic,
Events: []cephv1.BucketNotificationEvent{"s3:ObjectCreated:Put", "s3:ObjectRemoved:Delete"},
},
}
}
// notificationLabel is the OBC label the operator watches to wire a notification to a bucket.
func notificationLabel(notification string) string {
return "bucket-notification-" + notification
}
// notificationOBC builds an OBC labelled to subscribe its bucket to notification.
func notificationOBC(name, namespace, storageClass, notification string) *bktv1alpha1.ObjectBucketClaim {
return &bktv1alpha1.ObjectBucketClaim{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Labels: map[string]string{notificationLabel(notification): notification},
},
Spec: bktv1alpha1.ObjectBucketClaimSpec{
BucketName: name,
StorageClassName: storageClass,
},
}
}
const Namespace = "test-notification"
func TestBucketNotification(t *testing.T, k8sh *utils.K8sHelper, store *sharedstore.Sharedstore) {
var (
defaultName = Namespace
objectStore = store.ObjectStore()
ns = &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: defaultName,
},
}
sinkName = defaultName + "-sink"
sinkSel = labels.SelectorFromSet(labels.Set{"app": sinkName})
sinkURI = fmt.Sprintf("http://%s.%s:%d", sinkName, ns.Name, sinkPort)
storageClass = obc.StorageClass(defaultName, objectStore)
topic = httpTopic(defaultName+"-topic", ns.Name, objectStore, sinkURI)
notification = bucketNotification(defaultName+"-notification", ns.Name, topic.Name)
obc1 = notificationOBC(defaultName+"-obc", ns.Name, storageClass.Name, notification.Name)
body = "test bucket notification payload"
contentType = "text/plain"
key1 = "obj1"
key2 = "obj2"
btClient = k8sh.RookClientset.CephV1().CephBucketTopics(ns.Name)
bnClient = k8sh.RookClientset.CephV1().CephBucketNotifications(ns.Name)
obcClient = k8sh.BucketClientset.ObjectbucketV1alpha1().ObjectBucketClaims(ns.Name)
)
t.Run("CephBucketNotification http", func(t *testing.T) {
ctx := t.Context()
fixture.RequireNamespace(t, k8sh, ns)
requireHTTPSink(ctx, t, k8sh, ns.Name, sinkName)
fixture.RequireStorageClass(t, k8sh, storageClass)
t.Run(fmt.Sprintf("create CephBucketTopic %q", topic.Name), func(t *testing.T) {
// topic creation may be slow right after rgw start up
wait4.RequireCreate(ctx, t, btClient, topic, wait4.BucketTopic, wait4.TimeoutMedium)
})
t.Run(fmt.Sprintf("create CephBucketNotification %q", notification.Name), func(t *testing.T) {
_, err := bnClient.Create(ctx, notification, metav1.CreateOptions{})
require.NoError(t, err)
})
obc.RequireBound(ctx, t, k8sh, obc1)
s3agent := obc.NewS3Agent(ctx, t, k8sh, store, ns.Name, obc1.Name)
t.Run(fmt.Sprintf("notification %q is configured on bucket %q", notification.Name, obc1.Spec.BucketName), func(t *testing.T) {
requireBucketNotification(ctx, t, s3agent, obc1.Spec.BucketName, notification.Name, true)
})
t.Run(fmt.Sprintf("put object %q delivers a notification", key1), func(t *testing.T) {
_, err := s3agent.PutObjectInBucket(ctx, obc1.Spec.BucketName, body, key1, contentType)
require.NoError(t, err)
requireNotificationReceived(ctx, t, k8sh, ns.Name, sinkSel, putEvent, key1)
assertNotificationNotReceived(ctx, t, k8sh, ns.Name, sinkSel, deleteEvent, key1)
})
t.Run(fmt.Sprintf("delete object %q delivers a notification", key1), func(t *testing.T) {
_, err := s3agent.DeleteObjectInBucket(ctx, obc1.Spec.BucketName, key1)
require.NoError(t, err)
requireNotificationReceived(ctx, t, k8sh, ns.Name, sinkSel, deleteEvent, key1)
assertNotificationNotReceived(ctx, t, k8sh, ns.Name, sinkSel, putEvent, key1)
})
t.Run("an unrelated OBC label change leaves the notification configured", func(t *testing.T) {
t.Run(fmt.Sprintf("add a non-notification label to obc %q", obc1.Name), func(t *testing.T) {
updateOBCLabels(ctx, t, k8sh, ns.Name, obc1.Name, func(l map[string]string) {
l["test-label"] = "test-value"
})
})
requireBucketNotification(ctx, t, s3agent, obc1.Spec.BucketName, notification.Name, true)
t.Run(fmt.Sprintf("remove the non-notification label from obc %q", obc1.Name), func(t *testing.T) {
updateOBCLabels(ctx, t, k8sh, ns.Name, obc1.Name, func(l map[string]string) {
delete(l, "test-label")
})
})
requireBucketNotification(ctx, t, s3agent, obc1.Spec.BucketName, notification.Name, true)
})
t.Run("removing the notification label removes the notification from the bucket", func(t *testing.T) {
t.Run(fmt.Sprintf("remove the notification label from obc %q", obc1.Name), func(t *testing.T) {
updateOBCLabels(ctx, t, k8sh, ns.Name, obc1.Name, func(l map[string]string) {
delete(l, notificationLabel(notification.Name))
})
})
requireBucketNotification(ctx, t, s3agent, obc1.Spec.BucketName, notification.Name, false)
})
t.Run("an unsubscribed bucket delivers no notifications", func(t *testing.T) {
t.Run(fmt.Sprintf("put object %q", key2), func(t *testing.T) {
_, err := s3agent.PutObjectInBucket(ctx, obc1.Spec.BucketName, body, key2, contentType)
require.NoError(t, err)
assertNotificationNotReceived(ctx, t, k8sh, ns.Name, sinkSel, putEvent, key2)
})
t.Run(fmt.Sprintf("delete object %q", key2), func(t *testing.T) {
_, err := s3agent.DeleteObjectInBucket(ctx, obc1.Spec.BucketName, key2)
require.NoError(t, err)
assertNotificationNotReceived(ctx, t, k8sh, ns.Name, sinkSel, deleteEvent, key2)
})
})
t.Run("a notification added to an existing OBC is configured on its bucket", func(t *testing.T) {
addTopic := httpTopic(defaultName+"-topic-add", ns.Name, objectStore, sinkURI)
addNotification := bucketNotification(defaultName+"-notification-add", ns.Name, addTopic.Name)
t.Run(fmt.Sprintf("create CephBucketTopic %q", addTopic.Name), func(t *testing.T) {
wait4.RequireCreate(ctx, t, btClient, addTopic, wait4.BucketTopic, wait4.TimeoutMedium)
})
t.Run(fmt.Sprintf("create CephBucketNotification %q", addNotification.Name), func(t *testing.T) {
_, err := bnClient.Create(ctx, addNotification, metav1.CreateOptions{})
require.NoError(t, err)
})
t.Run(fmt.Sprintf("add the notification label for %q to obc %q", addNotification.Name, obc1.Name), func(t *testing.T) {
updateOBCLabels(ctx, t, k8sh, ns.Name, obc1.Name, func(l map[string]string) {
l[notificationLabel(addNotification.Name)] = addNotification.Name
})
})
requireBucketNotification(ctx, t, s3agent, obc1.Spec.BucketName, addNotification.Name, true)
t.Run(fmt.Sprintf("delete CephBucketNotification %q", addNotification.Name), func(t *testing.T) {
wait4.AssertDelete(ctx, t, bnClient, addNotification.Name, wait4.TimeoutShort)
})
t.Run(fmt.Sprintf("delete CephBucketTopic %q", addTopic.Name), func(t *testing.T) {
wait4.AssertDelete(ctx, t, btClient, addTopic.Name, wait4.TimeoutShort)
})
})
t.Run("a notification created before its topic is configured once the topic exists", func(t *testing.T) {
revTopic := httpTopic(defaultName+"-topic-rev", ns.Name, objectStore, sinkURI)
revNotification := bucketNotification(defaultName+"-notification-rev", ns.Name, revTopic.Name)
obcRev := notificationOBC(defaultName+"-obc-rev", ns.Name, storageClass.Name, revNotification.Name)
obc.RequireBound(ctx, t, k8sh, obcRev)
revS3 := obc.NewS3Agent(ctx, t, k8sh, store, ns.Name, obcRev.Name)
t.Run(fmt.Sprintf("create CephBucketNotification %q before its topic", revNotification.Name), func(t *testing.T) {
_, err := bnClient.Create(ctx, revNotification, metav1.CreateOptions{})
require.NoError(t, err)
})
t.Run(fmt.Sprintf("create CephBucketTopic %q", revTopic.Name), func(t *testing.T) {
wait4.RequireCreate(ctx, t, btClient, revTopic, wait4.BucketTopic, wait4.TimeoutMedium)
})
t.Run(fmt.Sprintf("notification %q is configured on bucket %q once its topic exists", revNotification.Name, obcRev.Spec.BucketName), func(t *testing.T) {
requireBucketNotification(ctx, t, revS3, obcRev.Spec.BucketName, revNotification.Name, true)
})
t.Run(fmt.Sprintf("delete CephBucketNotification %q", revNotification.Name), func(t *testing.T) {
wait4.AssertDelete(ctx, t, bnClient, revNotification.Name, wait4.TimeoutShort)
})
t.Run(fmt.Sprintf("delete CephBucketTopic %q", revTopic.Name), func(t *testing.T) {
wait4.AssertDelete(ctx, t, btClient, revTopic.Name, wait4.TimeoutShort)
})
t.Run(fmt.Sprintf("delete obc %q", obcRev.Name), func(t *testing.T) {
obc.DeleteAndWait(ctx, t, k8sh, ns.Name, obcRev.Name)
})
})
t.Run(fmt.Sprintf("delete obc %q", obc1.Name), func(t *testing.T) {
obc.DeleteAndWait(ctx, t, k8sh, ns.Name, obc1.Name)
})
t.Run(fmt.Sprintf("delete CephBucketNotification %q", notification.Name), func(t *testing.T) {
wait4.AssertDelete(ctx, t, bnClient, notification.Name, wait4.TimeoutShort)
})
t.Run(fmt.Sprintf("delete CephBucketTopic %q", topic.Name), func(t *testing.T) {
wait4.AssertDelete(ctx, t, btClient, topic.Name, wait4.TimeoutShort)
})
t.Run(fmt.Sprintf("no obc(s) in ns %q", ns.Name), func(t *testing.T) {
list, err := obcClient.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
assert.Len(t, list.Items, 0)
})
t.Run(fmt.Sprintf("no CephBucketNotification(s) in ns %q", ns.Name), func(t *testing.T) {
list, err := bnClient.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
assert.Len(t, list.Items, 0)
})
t.Run(fmt.Sprintf("no CephBucketTopic(s) in ns %q", ns.Name), func(t *testing.T) {
list, err := btClient.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
assert.Len(t, list.Items, 0)
})
})
}
// updateOBCLabels applies mutate to a live OBC's labels and updates it.
func updateOBCLabels(ctx context.Context, t *testing.T, k8sh *utils.K8sHelper, namespace, name string, mutate func(map[string]string)) {
t.Helper()
obc.Update(ctx, t, k8sh, namespace, name, func(live *bktv1alpha1.ObjectBucketClaim) {
if live.Labels == nil {
live.Labels = map[string]string{}
}
mutate(live.Labels)
})
}
// requireHTTPSink deploys a sample HTTP server that records the bucket
// notifications rgw pushes to it, and waits for it to be ready. It needs no
// explicit teardown: deleting the test namespace removes it.
func requireHTTPSink(ctx context.Context, t *testing.T, k8sh *utils.K8sHelper, namespace, name string) {
t.Helper()
podLabels := map[string]string{"app": name}
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{MatchLabels: podLabels},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: podLabels},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: name,
// TODO: third party image, see https://github.com/rook/rook/issues/9741
Image: "quay.io/jthottan/pythonwebserver:latest",
Ports: []corev1.ContainerPort{{ContainerPort: sinkPort}},
}},
},
},
},
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: corev1.ServiceSpec{
Selector: podLabels,
Ports: []corev1.ServicePort{{
Port: sinkPort,
TargetPort: intstr.FromInt32(sinkPort),
}},
},
}
t.Run(fmt.Sprintf("create notification sink %q", name), func(t *testing.T) {
wait4.RequireCreate(ctx, t, k8sh.Clientset.AppsV1().Deployments(namespace), deployment,
func(d *appsv1.Deployment) bool { return d.Status.ReadyReplicas >= 1 }, wait4.TimeoutLong)
wait4.RequireCreate(ctx, t, k8sh.Clientset.CoreV1().Services(namespace), service, nil, wait4.TimeoutShort)
})
}
// requireBucketNotification waits until notificationName is (want=true) or is not
// (want=false) configured on the rgw bucket, read via the S3 API.
func requireBucketNotification(ctx context.Context, t *testing.T, agent *rgw.S3Agent, bucket, notificationName string, want bool) {
t.Helper()
desc := fmt.Sprintf("notification %q configured=%v on bucket %q", notificationName, want, bucket)
wait4.RequireEventually(ctx, t, wait4.TimeoutMedium, desc, func(ctx context.Context) error {
conf, err := agent.Client.GetBucketNotificationConfiguration(ctx, &s3.GetBucketNotificationConfigurationInput{
Bucket: &bucket,
})
if err != nil {
return err
}
found := false
for _, tc := range conf.TopicConfigurations {
if tc.Id != nil && *tc.Id == notificationName {
found = true
break
}
}
if found != want {
return fmt.Errorf("notification %q configured=%v, want %v", notificationName, found, want)
}
return nil
})
}
// requireNotificationReceived blocks until the sink logs a line for the given
// event on the given object key.
func requireNotificationReceived(ctx context.Context, t *testing.T, k8sh *utils.K8sHelper, namespace string, sel labels.Selector, event, key string) {
t.Helper()
wait4.RequirePodLog(ctx, t, k8sh, namespace, sel, wait4.TimeoutShort,
func(line string) bool { return strings.Contains(line, event) && strings.Contains(line, key) },
"notification sink received %s for %q", event, key)
}
// notificationSettleDelay gives an unwanted notification time to arrive (if it
// were going to) before we assert its absence.
const notificationSettleDelay = 5 * time.Second
// assertNotificationNotReceived asserts the sink did not recently log the given
// event for the given key. It matches only the most recent log lines: an earlier,
// legitimately-received event for the same key may still be in the full history.
func assertNotificationNotReceived(ctx context.Context, t *testing.T, k8sh *utils.K8sHelper, namespace string, sel labels.Selector, event, key string) {
t.Helper()
time.Sleep(notificationSettleDelay)
recent := tailSinkLog(ctx, t, k8sh, namespace, sel)
assert.False(t, strings.Contains(recent, event) && strings.Contains(recent, key),
"notification sink unexpectedly received %s for %q", event, key)
}
// sinkLogTailLines bounds assertNotificationNotReceived to recent log lines.
const sinkLogTailLines = 5
// tailSinkLog returns the last sinkLogTailLines log lines of the first pod
// matching sel.
func tailSinkLog(ctx context.Context, t *testing.T, k8sh *utils.K8sHelper, namespace string, sel labels.Selector) string {
t.Helper()
pods, err := k8sh.Clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: sel.String()})
require.NoError(t, err)
require.NotEmpty(t, pods.Items)
tail := int64(sinkLogTailLines)
stream, err := k8sh.Clientset.CoreV1().Pods(namespace).
GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{TailLines: &tail}).
Stream(ctx)
require.NoError(t, err)
defer stream.Close()
out, err := io.ReadAll(stream)
require.NoError(t, err)
return string(out)
}