From 4577b7fa98ff3ac9707c7e42d4057d3544d7d19d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Raszka?= Date: Wed, 29 May 2024 11:54:32 +0200 Subject: [PATCH] Quay notification support for ImageRepository (#112) The ImageRepository CR is extended to allow defining a Quay notifications. The notification schema is based on Quay API schema and supports webhook and email configuration. Quay notification is created using the rest API client and added to a CR status field. JIRA: ISV-4756 Example: apiVersion: appstudio.redhat.com/v1alpha1 kind: ImageRepository metadata: name: imagerepository-sample namespace: image-controller-system spec: notifications: - title: MyNotif event: repo_push method: webhook config: url: https://foo.com Signed-off-by: Ales Raszka --- Dockerfile | 2 +- api/v1alpha1/imagerepository_types.go | 45 +++++++++++ api/v1alpha1/zz_generated.deepcopy.go | 56 +++++++++++++ ...ppstudio.redhat.com_imagerepositories.yaml | 40 ++++++++++ controllers/imagerepository_controller.go | 51 ++++++++++++ .../imagerepository_controller_test.go | 29 +++++++ controllers/suite_util_test.go | 12 +-- go.mod | 4 +- go.sum | 8 ++ pkg/quay/api.go | 16 ++++ pkg/quay/quay.go | 62 +++++++++++++++ pkg/quay/quay_debug_test.go | 47 +++++++++++ pkg/quay/quay_test.go | 78 +++++++++++++++++++ pkg/quay/test_quay_client.go | 24 ++++++ 14 files changed, 467 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7db155d..ae1a1fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Build the manager binary # For more details and updates, refer to # https://catalog.redhat.com/software/containers/ubi9/go-toolset/61e5c00b4ec9945c18787690 -FROM registry.access.redhat.com/ubi9/go-toolset:1.20.10 as builder +FROM registry.access.redhat.com/ubi9/go-toolset:1.21.9-1 as builder # Copy the Go Modules manifests COPY go.mod go.mod diff --git a/api/v1alpha1/imagerepository_types.go b/api/v1alpha1/imagerepository_types.go index c5bce05..8189044 100644 --- a/api/v1alpha1/imagerepository_types.go +++ b/api/v1alpha1/imagerepository_types.go @@ -29,6 +29,10 @@ type ImageRepositorySpec struct { // Credentials management. // +optional Credentials *ImageCredentials `json:"credentials,omitempty"` + + // Notifications defines configuration for image repository notifications. + // +optional + Notifications []Notifications `json:"notifications,omitempty"` } // ImageParameters describes requested image repository configuration. @@ -62,6 +66,37 @@ type ImageCredentials struct { RegenerateToken *bool `json:"regenerate-token,omitempty"` } +type Notifications struct { + Title string `json:"title,omitempty"` + // +kubebuilder:validation:Enum=repo_push + Event NotificationEvent `json:"event,omitempty"` + // +kubebuilder:validation:Enum=email;webhook + Method NotificationMethod `json:"method,omitempty"` + Config NotificationConfig `json:"config,omitempty"` +} + +type NotificationEvent string + +const ( + NotificationEventRepoPush NotificationEvent = "repo_push" +) + +type NotificationMethod string + +const ( + NotificationMethodEmail NotificationMethod = "email" + NotificationMethodWebhook NotificationMethod = "webhook" +) + +type NotificationConfig struct { + // Email is the email address to send notifications to. + // +optional + Email string `json:"email,omitempty"` + // Webhook is the URL to send notifications to. + // +optional + Url string `json:"url,omitempty"` +} + // ImageRepositoryStatus defines the observed state of ImageRepository type ImageRepositoryStatus struct { // State shows if image repository could be used. @@ -79,6 +114,10 @@ type ImageRepositoryStatus struct { // Credentials contain information related to image repository credentials. Credentials CredentialsStatus `json:"credentials,omitempty"` + + // Notifications shows the status of the notifications configuration. + // +optional + Notifications []NotificationStatus `json:"notifications,omitempty"` } type ImageRepositoryState string @@ -126,6 +165,12 @@ type CredentialsStatus struct { PullRemoteSecretName string `json:"pull-remote-secret,omitempty"` } +// NotificationStatus shows the status of the notification configuration. +type NotificationStatus struct { + Title string `json:"title,omitempty"` + UUID string `json:"uuid,omitempty"` +} + //+kubebuilder:object:root=true //+kubebuilder:subresource:status diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 62eaeaf..c26f491 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -147,6 +147,11 @@ func (in *ImageRepositorySpec) DeepCopyInto(out *ImageRepositorySpec) { *out = new(ImageCredentials) (*in).DeepCopyInto(*out) } + if in.Notifications != nil { + in, out := &in.Notifications, &out.Notifications + *out = make([]Notifications, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageRepositorySpec. @@ -164,6 +169,11 @@ func (in *ImageRepositoryStatus) DeepCopyInto(out *ImageRepositoryStatus) { *out = *in out.Image = in.Image in.Credentials.DeepCopyInto(&out.Credentials) + if in.Notifications != nil { + in, out := &in.Notifications, &out.Notifications + *out = make([]NotificationStatus, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageRepositoryStatus. @@ -190,3 +200,49 @@ func (in *ImageStatus) DeepCopy() *ImageStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NotificationConfig) DeepCopyInto(out *NotificationConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationConfig. +func (in *NotificationConfig) DeepCopy() *NotificationConfig { + if in == nil { + return nil + } + out := new(NotificationConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NotificationStatus) DeepCopyInto(out *NotificationStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationStatus. +func (in *NotificationStatus) DeepCopy() *NotificationStatus { + if in == nil { + return nil + } + out := new(NotificationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Notifications) DeepCopyInto(out *Notifications) { + *out = *in + out.Config = in.Config +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Notifications. +func (in *Notifications) DeepCopy() *Notifications { + if in == nil { + return nil + } + out := new(Notifications) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/appstudio.redhat.com_imagerepositories.yaml b/config/crd/bases/appstudio.redhat.com_imagerepositories.yaml index 28b3dbc..44cf54a 100644 --- a/config/crd/bases/appstudio.redhat.com_imagerepositories.yaml +++ b/config/crd/bases/appstudio.redhat.com_imagerepositories.yaml @@ -69,6 +69,34 @@ spec: - private type: string type: object + notifications: + description: Notifications defines configuration for image repository + notifications. + items: + properties: + config: + properties: + email: + description: Email is the email address to send notifications + to. + type: string + url: + description: Webhook is the URL to send notifications to. + type: string + type: object + event: + enum: + - repo_push + type: string + method: + enum: + - email + - webhook + type: string + title: + type: string + type: object + type: array type: object status: description: ImageRepositoryStatus defines the observed state of ImageRepository @@ -140,6 +168,18 @@ spec: contain non critical error, like failed to change image visibility, while the state is ready and image resitory could be used. type: string + notifications: + description: Notifications shows the status of the notifications configuration. + items: + description: NotificationStatus shows the status of the notification + configuration. + properties: + title: + type: string + uuid: + type: string + type: object + type: array state: description: State shows if image repository could be used. "ready" means repository was created and usable, "failed" means that the diff --git a/controllers/imagerepository_controller.go b/controllers/imagerepository_controller.go index d283fef..60576eb 100644 --- a/controllers/imagerepository_controller.go +++ b/controllers/imagerepository_controller.go @@ -231,6 +231,51 @@ func (r *ImageRepositoryReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, nil } +func (r *ImageRepositoryReconciler) AddNotifications(ctx context.Context, imageRepository *imagerepositoryv1alpha1.ImageRepository) ([]imagerepositoryv1alpha1.NotificationStatus, error) { + log := ctrllog.FromContext(ctx).WithName("ConfigureNotifications") + + if imageRepository.Spec.Notifications == nil { + // No notifications to configure + return nil, nil + } + + log.Info("Configuring notifications") + notificationStatus := []imagerepositoryv1alpha1.NotificationStatus{} + + for _, notification := range imageRepository.Spec.Notifications { + log.Info("Creating notification in Quay", "Title", notification.Title, "Event", notification.Event, "Method", notification.Method) + quayNotification, err := r.QuayClient.CreateNotification( + r.QuayOrganization, + imageRepository.Spec.Image.Name, + quay.Notification{ + Title: notification.Title, + Event: string(notification.Event), + Method: string(notification.Method), + Config: quay.NotificationConfig{ + Url: notification.Config.Url, + }, + EventConfig: quay.NotificationEventConfig{}, + }) + if err != nil { + log.Error(err, "failed to create notification", "Title", notification.Title, "Event", notification.Event, "Method", notification.Method) + return nil, err + } + notificationStatus = append( + notificationStatus, + imagerepositoryv1alpha1.NotificationStatus{ + UUID: quayNotification.UUID, + Title: notification.Title, + }) + + log.Info("Notification added", + "Title", notification.Title, + "Event", notification.Event, + "Method", notification.Method, + "QuayNotification", quayNotification) + } + return notificationStatus, nil +} + // ProvisionImageRepository creates image repository, robot account(s) and secret(s) to access the image repository. // If labels with Application and Component name are present, robot account with pull only access // will be created and pull token will be propagated to all environments via Remote Secret. @@ -319,6 +364,11 @@ func (r *ImageRepositoryReconciler) ProvisionImageRepository(ctx context.Context } } + var notificationStatus []imagerepositoryv1alpha1.NotificationStatus + if notificationStatus, err = r.AddNotifications(ctx, imageRepository); err != nil { + return err + } + status := imagerepositoryv1alpha1.ImageRepositoryStatus{} status.State = imagerepositoryv1alpha1.ImageRepositoryStateReady status.Image.URL = quayImageURL @@ -332,6 +382,7 @@ func (r *ImageRepositoryReconciler) ProvisionImageRepository(ctx context.Context status.Credentials.PullRemoteSecretName = pullCredentialsInfo.RemoteSecretName status.Credentials.PullSecretName = pullCredentialsInfo.SecretName } + status.Notifications = notificationStatus imageRepository.Spec.Image.Name = imageRepositoryName controllerutil.AddFinalizer(imageRepository, ImageRepositoryFinalizer) diff --git a/controllers/imagerepository_controller_test.go b/controllers/imagerepository_controller_test.go index 191fd55..8e75bcb 100644 --- a/controllers/imagerepository_controller_test.go +++ b/controllers/imagerepository_controller_test.go @@ -94,6 +94,13 @@ var _ = Describe("Image repository controller", func() { return nil } + isCreateNotificationInvoked := false + quay.CreateNotificationFunc = func(organization, repository string, notification quay.Notification) (*quay.Notification, error) { + isCreateNotificationInvoked = true + Expect(organization).To(Equal(quay.TestQuayOrg)) + return &quay.Notification{UUID: "uuid"}, nil + } + createImageRepository(imageRepositoryConfig{}) uploadSecretKey := types.NamespacedName{Name: "upload-secret-" + resourceKey.Name + "-image-push", Namespace: resourceKey.Namespace} @@ -102,6 +109,7 @@ var _ = Describe("Image repository controller", func() { Eventually(func() bool { return isCreateRepositoryInvoked }, timeout, interval).Should(BeTrue()) Eventually(func() bool { return isCreateRobotAccountInvoked }, timeout, interval).Should(BeTrue()) Eventually(func() bool { return isAddPushPermissionsToRobotAccountInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isCreateNotificationInvoked }, timeout, interval).Should(BeFalse()) waitImageRepositoryFinalizerOnImageRepository(resourceKey) @@ -117,6 +125,7 @@ var _ = Describe("Image repository controller", func() { Expect(imageRepository.Status.Credentials.PushRemoteSecretName).To(Equal(imageRepository.Name + "-image-push")) Expect(imageRepository.Status.Credentials.PushSecretName).To(Equal(imageRepository.Name + "-image-push")) Expect(imageRepository.Status.Credentials.GenerationTimestamp).ToNot(BeNil()) + Expect(imageRepository.Status.Notifications).To(HaveLen(0)) remoteSecretKey := types.NamespacedName{Name: imageRepository.Status.Credentials.PushRemoteSecretName, Namespace: imageRepository.Namespace} remoteSecret := waitRemoteSecretExist(remoteSecretKey) @@ -316,12 +325,28 @@ var _ = Describe("Image repository controller", func() { } return nil } + isCreateNotificationInvoked := false + quay.CreateNotificationFunc = func(organization, repository string, notification quay.Notification) (*quay.Notification, error) { + isCreateNotificationInvoked = true + Expect(organization).To(Equal(quay.TestQuayOrg)) + return &quay.Notification{UUID: "uuid"}, nil + } imageRepositoryConfigObject := imageRepositoryConfig{ Labels: map[string]string{ ApplicationNameLabelName: defaultComponentApplication, ComponentNameLabelName: defaultComponentName, }, + Notifications: []imagerepositoryv1alpha1.Notifications{ + { + Title: "test-notification", + Event: imagerepositoryv1alpha1.NotificationEventRepoPush, + Method: imagerepositoryv1alpha1.NotificationMethodWebhook, + Config: imagerepositoryv1alpha1.NotificationConfig{ + Url: "http://test-url", + }, + }, + }, } if updateComponentAnnotation { @@ -340,6 +365,7 @@ var _ = Describe("Image repository controller", func() { Eventually(func() bool { return isCreatePullRobotAccountInvoked }, timeout, interval).Should(BeTrue()) Eventually(func() bool { return isAddPushPermissionsToRobotAccountInvoked }, timeout, interval).Should(BeTrue()) Eventually(func() bool { return isAddPullPermissionsToRobotAccountInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isCreateNotificationInvoked }, timeout, interval).Should(BeTrue()) waitImageRepositoryFinalizerOnImageRepository(resourceKey) @@ -371,6 +397,9 @@ var _ = Describe("Image repository controller", func() { Expect(imageRepository.Status.Credentials.PullRemoteSecretName).To(Equal(imageRepository.Name + "-image-pull")) Expect(imageRepository.Status.Credentials.PullSecretName).To(Equal(imageRepository.Name + "-image-pull")) Expect(imageRepository.Status.Credentials.GenerationTimestamp).ToNot(BeNil()) + Expect(imageRepository.Status.Notifications).To(HaveLen(1)) + Expect(imageRepository.Status.Notifications[0].UUID).To(Equal("uuid")) + Expect(imageRepository.Status.Notifications[0].Title).To(Equal("test-notification")) pushRemoteSecretKey := types.NamespacedName{Name: imageRepository.Status.Credentials.PushRemoteSecretName, Namespace: imageRepository.Namespace} pushRemoteSecret := waitRemoteSecretExist(pushRemoteSecretKey) diff --git a/controllers/suite_util_test.go b/controllers/suite_util_test.go index df5ec07..f72cfd3 100644 --- a/controllers/suite_util_test.go +++ b/controllers/suite_util_test.go @@ -56,11 +56,12 @@ const ( ) type imageRepositoryConfig struct { - ResourceKey *types.NamespacedName - ImageName string - Visibility string - Labels map[string]string - Annotations map[string]string + ResourceKey *types.NamespacedName + ImageName string + Visibility string + Labels map[string]string + Annotations map[string]string + Notifications []imagerepositoryv1alpha1.Notifications } func getImageRepositoryConfig(config imageRepositoryConfig) *imagerepositoryv1alpha1.ImageRepository { @@ -93,6 +94,7 @@ func getImageRepositoryConfig(config imageRepositoryConfig) *imagerepositoryv1al Name: config.ImageName, Visibility: imagerepositoryv1alpha1.ImageVisibility(visibility), }, + Notifications: config.Notifications, }, } } diff --git a/go.mod b/go.mod index 0559c98..f1bb949 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/konflux-ci/image-controller -go 1.20 +go 1.21 + +toolchain go1.21.9 require ( github.com/go-logr/logr v1.4.1 diff --git a/go.sum b/go.sum index bbaeff5..60b6bd7 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,7 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.7.0 h1:nJqP7uwL84RJInrohHfW0Fx3awjbm8qZeFv0nW9SYGc= github.com/evanphx/json-patch/v5 v5.7.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -60,7 +61,9 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= @@ -82,6 +85,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= @@ -95,16 +99,19 @@ github.com/redhat-appstudio/application-api v0.0.0-20231026192857-89515ad2504f/g github.com/redhat-appstudio/remote-secret v0.0.0-20240103070316-c146261dd544 h1:QOyNP7ZEP9Q+7H6r7kCOtREhRgnpk7gz3AsqF6iHlVg= github.com/redhat-appstudio/remote-secret v0.0.0-20240103070316-c146261dd544/go.mod h1:kK35B2x2Q0pfVzR85L5gMG0lwhsUsrRkSNa0s4qaufQ= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= @@ -174,6 +181,7 @@ google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7 google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/quay/api.go b/pkg/quay/api.go index 078e984..afe2df4 100644 --- a/pkg/quay/api.go +++ b/pkg/quay/api.go @@ -51,3 +51,19 @@ type QuayError struct { Error string `json:"error,omitempty"` ErrorMessage string `json:"error_message,omitempty"` } + +type Notification struct { + UUID string `json:"uuid"` + Title string `json:"title"` + Event string `json:"event"` + Method string `json:"method"` + Config NotificationConfig `json:"config"` + EventConfig NotificationEventConfig `json:"eventConfig"` +} + +type NotificationConfig struct { + Url string `json:"url,omitempty"` +} + +type NotificationEventConfig struct { +} diff --git a/pkg/quay/quay.go b/pkg/quay/quay.go index f55ec9e..75c2a5e 100644 --- a/pkg/quay/quay.go +++ b/pkg/quay/quay.go @@ -40,6 +40,8 @@ type QuayService interface { GetAllRobotAccounts(organization string) ([]RobotAccount, error) GetTagsFromPage(organization, repository string, page int) ([]Tag, bool, error) DeleteTag(organization, repository, tag string) (bool, error) + GetNotifications(organization, repository string) ([]Notification, error) + CreateNotification(organization, repository string, notification Notification) (*Notification, error) } var _ QuayService = (*QuayClient)(nil) @@ -547,3 +549,63 @@ func (c *QuayClient) DeleteTag(organization, repository, tag string) (bool, erro } return false, errors.New(data.ErrorMessage) } + +func (c *QuayClient) GetNotifications(organization, repository string) ([]Notification, error) { + url := fmt.Sprintf("%s/repository/%s/%s/notification/", c.url, organization, repository) + + resp, err := c.doRequest(url, http.MethodGet, nil) + if err != nil { + return nil, err + } + + if resp.GetStatusCode() != 200 { + return nil, fmt.Errorf("failed to get repository notifications. Status code: %d", resp.GetStatusCode()) + } + + var response struct { + Notifications []Notification `json:"notifications"` + Page int `json:"page"` + HasAdditional bool `json:"has_additional"` + } + // var notifications []Notification + if err := resp.GetJson(&response); err != nil { + return nil, err + } + return response.Notifications, nil +} + +func (c *QuayClient) CreateNotification(organization, repository string, notification Notification) (*Notification, error) { + allNotifications, err := c.GetNotifications(organization, repository) + if err != nil { + return nil, err + } + for _, currentNotification := range allNotifications { + if currentNotification.Title == notification.Title { + return ¤tNotification, nil + } + } + url := fmt.Sprintf("%s/repository/%s/%s/notification/", c.url, organization, repository) + + b, err := json.Marshal(notification) + if err != nil { + return nil, fmt.Errorf("failed to marshal notification data: %w", err) + } + + resp, err := c.doRequest(url, http.MethodPost, bytes.NewReader(b)) + if err != nil { + return nil, err + } + + if resp.GetStatusCode() != 201 { + quay_error := &QuayError{} + if err := resp.GetJson(quay_error); err != nil { + return nil, err + } + return nil, fmt.Errorf("failed to create repository notification. Status code: %d, error: %s", resp.GetStatusCode(), quay_error.ErrorMessage) + } + var notificationResponse Notification + if err := resp.GetJson(¬ificationResponse); err != nil { + return nil, err + } + return ¬ificationResponse, nil +} diff --git a/pkg/quay/quay_debug_test.go b/pkg/quay/quay_debug_test.go index 63bddfb..be6135b 100644 --- a/pkg/quay/quay_debug_test.go +++ b/pkg/quay/quay_debug_test.go @@ -186,3 +186,50 @@ func TestRegenerateRobotAccountToken(t *testing.T) { t.Fatal("Token must be updated") } } + +func TestCreateNotification(t *testing.T) { + if quayToken == "" { + return + } + + quayClient := NewQuayClient(&http.Client{Transport: &http.Transport{}}, quayToken, quayApiUrl) + notification := Notification{ + Title: "Test notification", + Event: "repo_push", + Method: "webhook", + Config: NotificationConfig{ + Url: "https://example.com", + }, + } + + quayNotification, err := quayClient.CreateNotification(quayOrgName, quayImageRepoName, notification) + if err != nil { + t.Fatal(err) + } + if quayNotification == nil { + t.Fatal("Notification should not be nil") + } + if quayNotification.UUID == "" { + t.Fatal("Notification UUID should not be empty") + } + + allNotifications, err := quayClient.GetNotifications(quayOrgName, quayImageRepoName) + if err != nil { + t.Fatal(err) + } + if len(allNotifications) == 0 { + t.Fatal("No notifications found") + } + + found := false + for _, n := range allNotifications { + if n.UUID == quayNotification.UUID { + found = true + break + } + } + if !found { + t.Fatalf("Notification %s not found", quayNotification.UUID) + } + +} diff --git a/pkg/quay/quay_test.go b/pkg/quay/quay_test.go index fb10063..ea0e278 100644 --- a/pkg/quay/quay_test.go +++ b/pkg/quay/quay_test.go @@ -1392,6 +1392,84 @@ func TestQuayClient_RegenerateRobotAccountToken(t *testing.T) { } } +func TestQuayClient_CreateNotification(t *testing.T) { + client := &http.Client{Transport: &http.Transport{}} + gock.InterceptClient(client) + + testCases := []struct { + name string + notification *Notification + expectedErr string + statusCode int + response interface{} + }{ + { + name: "Notification is created", + notification: &Notification{UUID: "1234", Title: "notification title", Config: NotificationConfig{}, EventConfig: NotificationEventConfig{}}, + expectedErr: "", + statusCode: 201, + response: "{\"uuid\": \"1234\", \"title\": \"notification title\"}", + }, + { + name: "Notification to be created already exists", + notification: &Notification{UUID: "1234", Title: "notification title", Config: NotificationConfig{}, EventConfig: NotificationEventConfig{}}, + expectedErr: "", + statusCode: 201, + response: "{\"uuid\": \"1234\", \"title\": \"notification title\"}", + }, + { + name: "Repository is not found", + notification: nil, + expectedErr: "ailed to create repository notification. Status code: 404, error", + statusCode: 404, + response: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer gock.Off() + + req := gock.New(testQuayApiUrl). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer authtoken"). + Post(fmt.Sprintf("repository/%s/%s/notification/", org, repo)) + req.Reply(tc.statusCode).JSON(tc.response) + + if tc.name == "stop if http request fails" { + req.AddMatcher(gock.MatchPath).Delete("another-path") + } + + if strings.HasPrefix(tc.name, "Notification to be created already exists") { + gock.New(testQuayApiUrl). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer authtoken"). + Get(fmt.Sprintf("repository/%s/%s/notification/", org, repo)). + Reply(200). + JSON(map[string][]Notification{"notifications": []Notification{*tc.notification}}) + } else { + gock.New(testQuayApiUrl). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer authtoken"). + Get(fmt.Sprintf("repository/%s/%s/notification/", org, repo)). + Reply(200). + JSON(map[string][]Notification{"notifications": []Notification{}}) + } + + quayClient := NewQuayClient(client, "authtoken", testQuayApiUrl) + notification, err := quayClient.CreateNotification(org, repo, Notification{}) + if !reflect.DeepEqual(notification, tc.notification) { + t.Error("notifications are not the same") + } + if tc.expectedErr == "" { + assert.NilError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectedErr) + } + }) + } +} + func TestMakeRequest(t *testing.T) { client := &http.Client{Transport: &http.Transport{}} gock.InterceptClient(client) diff --git a/pkg/quay/test_quay_client.go b/pkg/quay/test_quay_client.go index eb2de6e..5c58e6f 100644 --- a/pkg/quay/test_quay_client.go +++ b/pkg/quay/test_quay_client.go @@ -38,6 +38,8 @@ var ( DeleteRobotAccountFunc func(organization string, robotName string) (bool, error) AddPermissionsForRepositoryToRobotAccountFunc func(organization, imageRepository, robotAccountName string, isWrite bool) error RegenerateRobotAccountTokenFunc func(organization string, robotName string) (*RobotAccount, error) + GetNotificationsFunc func(organization, repository string) ([]Notification, error) + CreateNotificationFunc func(organization, repository string, notification Notification) (*Notification, error) ) func ResetTestQuayClient() { @@ -49,6 +51,11 @@ func ResetTestQuayClient() { DeleteRobotAccountFunc = func(organization, robotName string) (bool, error) { return true, nil } AddPermissionsForRepositoryToRobotAccountFunc = func(organization, imageRepository, robotAccountName string, isWrite bool) error { return nil } RegenerateRobotAccountTokenFunc = func(organization, robotName string) (*RobotAccount, error) { return &RobotAccount{}, nil } + GetNotificationsFunc = func(organization, repository string) ([]Notification, error) { return []Notification{}, nil } + CreateNotificationFunc = func(organization, repository string, notification Notification) (*Notification, error) { + return &Notification{}, nil + } + } func ResetTestQuayClientToFails() { @@ -92,6 +99,16 @@ func ResetTestQuayClientToFails() { Fail("RegenerateRobotAccountToken invoked") return nil, nil } + GetNotificationsFunc = func(organization, repository string) ([]Notification, error) { + defer GinkgoRecover() + Fail("RegenerateRobotAccountToken invoked") + return nil, nil + } + CreateNotificationFunc = func(organization, repository string, notification Notification) (*Notification, error) { + defer GinkgoRecover() + Fail("CreateNotification invoked") + return nil, nil + } } func (c TestQuayClient) CreateRepository(repositoryRequest RepositoryRequest) (*Repository, error) { @@ -130,3 +147,10 @@ func (TestQuayClient) DeleteTag(organization string, repository string, tag stri func (TestQuayClient) GetTagsFromPage(organization string, repository string, page int) ([]Tag, bool, error) { return nil, false, nil } +func (TestQuayClient) GetNotifications(organization string, repository string) ([]Notification, error) { + return GetNotificationsFunc(organization, repository) +} + +func (TestQuayClient) CreateNotification(organization, repository string, notification Notification) (*Notification, error) { + return CreateNotificationFunc(organization, repository, notification) +}