From 147c189f757e384c553edbf1b667558ff5c67a88 Mon Sep 17 00:00:00 2001 From: Robert Cerven Date: Wed, 14 Aug 2024 21:37:01 +0200 Subject: [PATCH] New controller watching config maps named: image-controller-additional-users which creates team in quay.io for users in the config map, and grants permissions to repositories in all imageRepositories STONEBLD-2667 Signed-off-by: Robert Cerven --- config/rbac/role.yaml | 2 +- controllers/imagerepository_controller.go | 37 +- .../imagerepository_controller_test.go | 101 ++- controllers/suite_test.go | 8 + controllers/suite_util_test.go | 49 ++ controllers/users_config_map_controller.go | 290 +++++++++ .../users_config_map_controller_test.go | 379 +++++++++++ main.go | 11 + pkg/quay/api.go | 18 + pkg/quay/quay.go | 259 +++++++- pkg/quay/quay_test.go | 615 +++++++++++++++++- pkg/quay/test_quay_client.go | 96 ++- 12 files changed, 1808 insertions(+), 57 deletions(-) create mode 100644 controllers/users_config_map_controller.go create mode 100644 controllers/users_config_map_controller_test.go diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index d885cdc..a8f5db9 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -70,5 +70,5 @@ rules: verbs: - get - list + - update - watch - diff --git a/controllers/imagerepository_controller.go b/controllers/imagerepository_controller.go index 4831e4d..c822e91 100644 --- a/controllers/imagerepository_controller.go +++ b/controllers/imagerepository_controller.go @@ -346,7 +346,7 @@ func (r *ImageRepositoryReconciler) ProvisionImageRepository(ctx context.Context } } - if err = r.GrantAdditionalRepositoryAccess(ctx, imageRepository); err != nil { + if err = r.GrantRepositoryAccessToTeam(ctx, imageRepository); err != nil { return err } @@ -436,8 +436,9 @@ func (r *ImageRepositoryReconciler) ProvisionImageRepositoryAccess(ctx context.C return data, nil } -func (r *ImageRepositoryReconciler) GrantAdditionalRepositoryAccess(ctx context.Context, imageRepository *imagerepositoryv1alpha1.ImageRepository) error { - log := ctrllog.FromContext(ctx).WithName("GrantAdditionalRepositoryAccess") +// GrantRepositoryAccessToTeam will add additional repository access to team, based on config map +func (r *ImageRepositoryReconciler) GrantRepositoryAccessToTeam(ctx context.Context, imageRepository *imagerepositoryv1alpha1.ImageRepository) error { + log := ctrllog.FromContext(ctx).WithName("GrantAdditionalRepositoryAccessToTeam") additionalUsersConfigMap := &corev1.ConfigMap{} if err := r.Client.Get(ctx, types.NamespacedName{Name: additionalUsersConfigMapName, Namespace: imageRepository.Namespace}, additionalUsersConfigMap); err != nil { @@ -448,30 +449,30 @@ func (r *ImageRepositoryReconciler) GrantAdditionalRepositoryAccess(ctx context. log.Error(err, "failed to read config map with additional users", "ConfigMapName", additionalUsersConfigMapName, l.Action, l.ActionView) return err } - additionalUsersStr, usersExist := additionalUsersConfigMap.Data[additionalUsersConfigMapKey] + _, usersExist := additionalUsersConfigMap.Data[additionalUsersConfigMapKey] if !usersExist { log.Info("Config map with additional users doesn't have the key", "ConfigMapName", additionalUsersConfigMapName, "ConfigMapKey", additionalUsersConfigMapKey, l.Action, l.ActionView) return nil } - additionalUsers := strings.Fields(strings.TrimSpace(additionalUsersStr)) - log.Info("Additional users configured in config map", "AdditionalUsers", additionalUsers) - imageRepositoryName := imageRepository.Spec.Image.Name + teamName := getQuayTeamName(imageRepository.Namespace) - for _, user := range additionalUsers { - err := r.QuayClient.AddPermissionsForRepositoryToAccount(r.QuayOrganization, imageRepositoryName, user, false, false) - if err != nil { - if strings.Contains(err.Error(), "Invalid username:") { - log.Info("failed to add permissions for account, because it doesn't exist", "AccountName", user) - continue - } + // get team, if team doesn't exist it will be created, we don't care about users as that will be taken care of by config map controller + // so in this case if config map exists, team already exists as well with appropriate users + log.Info("Ensure team", "TeamName", teamName) + if _, err := r.QuayClient.EnsureTeam(r.QuayOrganization, teamName); err != nil { + log.Error(err, "failed to get or create team", "TeamName", teamName, l.Action, l.ActionView) + return err + } - log.Error(err, "failed to add permissions for account", "AccountName", user, l.Action, l.ActionUpdate, l.Audit, "true") - return err - } - log.Info("Additional user access was granted for", "UserName", user) + // add repo permission to the team + log.Info("Adding repository permission to the team", "TeamName", teamName, "RepositoryName", imageRepositoryName) + if err := r.QuayClient.AddReadPermissionsForRepositoryToTeam(r.QuayOrganization, imageRepositoryName, teamName); err != nil { + log.Error(err, "failed to grant repo permission to the team", "TeamName", teamName, "RepositoryName", imageRepositoryName, l.Action, l.ActionAdd) + return err } + return nil } diff --git a/controllers/imagerepository_controller_test.go b/controllers/imagerepository_controller_test.go index 5453d09..e6fec2a 100644 --- a/controllers/imagerepository_controller_test.go +++ b/controllers/imagerepository_controller_test.go @@ -476,7 +476,7 @@ var _ = Describe("Image repository controller", func() { createServiceAccount(defaultNamespace, buildPipelineServiceAccountName) }) - assertProvisionRepository := func(updateComponentAnnotation bool, additionalUser string) { + assertProvisionRepository := func(updateComponentAnnotation, grantRepoPermission bool) { isCreateRepositoryInvoked := false quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { defer GinkgoRecover() @@ -506,22 +506,37 @@ var _ = Describe("Image repository controller", func() { defer GinkgoRecover() Expect(organization).To(Equal(quay.TestQuayOrg)) Expect(imageRepository).To(Equal(expectedImageName)) - - if isRobot { - Expect(strings.HasPrefix(accountName, expectedRobotAccountPrefix)).To(BeTrue()) - if strings.HasSuffix(accountName, "_pull") { - Expect(isWrite).To(BeFalse()) - isAddPullPermissionsToAccountInvoked = true - } else { - Expect(isWrite).To(BeTrue()) - isAddPushPermissionsToAccountInvoked = true - } - } else { - Expect(accountName).To(Equal(additionalUser)) + Expect(strings.HasPrefix(accountName, expectedRobotAccountPrefix)).To(BeTrue()) + if strings.HasSuffix(accountName, "_pull") { Expect(isWrite).To(BeFalse()) + isAddPullPermissionsToAccountInvoked = true + } else { + Expect(isWrite).To(BeTrue()) + isAddPushPermissionsToAccountInvoked = true } return nil } + isEnsureTeamInvoked := false + isAddReadPermissionsForRepositoryToTeamInvoked := false + if grantRepoPermission { + quay.EnsureTeamFunc = func(organization, teamName string) ([]quay.Member, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + expectedTeamName := getQuayTeamName(resourceKey.Namespace) + Expect(teamName).To(Equal(expectedTeamName)) + isEnsureTeamInvoked = true + return nil, nil + } + quay.AddReadPermissionsForRepositoryToTeamFunc = func(organization, imageRepository, teamName string) error { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(imageRepository).To(Equal(expectedImageName)) + expectedTeamName := getQuayTeamName(resourceKey.Namespace) + Expect(teamName).To(Equal(expectedTeamName)) + isAddReadPermissionsForRepositoryToTeamInvoked = true + return nil + } + } isCreateNotificationInvoked := false quay.CreateNotificationFunc = func(organization, repository string, notification quay.Notification) (*quay.Notification, error) { isCreateNotificationInvoked = true @@ -574,6 +589,10 @@ var _ = Describe("Image repository controller", func() { Eventually(func() bool { return isAddPushPermissionsToAccountInvoked }, timeout, interval).Should(BeTrue()) Eventually(func() bool { return isAddPullPermissionsToAccountInvoked }, timeout, interval).Should(BeTrue()) Eventually(func() bool { return isCreateNotificationInvoked }, timeout, interval).Should(BeTrue()) + if grantRepoPermission { + Eventually(func() bool { return isEnsureTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isAddReadPermissionsForRepositoryToTeamInvoked }, timeout, interval).Should(BeTrue()) + } waitImageRepositoryFinalizerOnImageRepository(resourceKey) @@ -654,7 +673,7 @@ var _ = Describe("Image repository controller", func() { } It("should provision image repository for component, without update component annotation", func() { - assertProvisionRepository(false, "") + assertProvisionRepository(false, false) quay.DeleteRobotAccountFunc = func(organization, robotAccountName string) (bool, error) { return true, nil @@ -666,10 +685,54 @@ var _ = Describe("Image repository controller", func() { deleteImageRepository(resourceKey) }) - It("should provision image repository for component, with update component annotation and add additional user from config map", func() { + It("should provision image repository for component, with update component annotation and grant permission to team", func() { usersConfigMapKey := types.NamespacedName{Name: additionalUsersConfigMapName, Namespace: resourceKey.Namespace} - createUsersConfigMap(usersConfigMapKey, []string{"user1"}) - assertProvisionRepository(true, "user1") + expectedTeamName := getQuayTeamName(resourceKey.Namespace) + isEnsureTeamInvoked := false + isListRepositoryPermissionsForTeamInvoked := false + countAddUserToTeamInvoked := 0 + isDeleteTeamInvoked := false + + quay.EnsureTeamFunc = func(organization, teamName string) ([]quay.Member, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isEnsureTeamInvoked = true + return []quay.Member{}, nil + } + quay.ListRepositoryPermissionsForTeamFunc = func(organization, teamName string) ([]quay.TeamPermission, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isListRepositoryPermissionsForTeamInvoked = true + return []quay.TeamPermission{}, nil + } + quay.AddUserToTeamFunc = func(organization, teamName, userName string) (bool, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + Expect(userName).To(BeElementOf([]string{"user1", "user2"})) + countAddUserToTeamInvoked++ + return false, nil + } + + createUsersConfigMap(usersConfigMapKey, []string{"user1", "user2"}) + Eventually(func() bool { return isEnsureTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isListRepositoryPermissionsForTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() int { return countAddUserToTeamInvoked }, timeout, interval).Should(Equal(2)) + waitQuayTeamUsersFinalizerOnConfigMap(usersConfigMapKey) + + assertProvisionRepository(true, true) + + quay.DeleteTeamFunc = func(organization, teamName string) error { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isDeleteTeamInvoked = true + return nil + } + deleteUsersConfigMap(usersConfigMapKey) + Eventually(func() bool { return isDeleteTeamInvoked }, timeout, interval).Should(BeTrue()) quay.DeleteRobotAccountFunc = func(organization, robotAccountName string) (bool, error) { return true, nil @@ -677,13 +740,11 @@ var _ = Describe("Image repository controller", func() { quay.DeleteRepositoryFunc = func(organization, imageRepository string) (bool, error) { return true, nil } - - deleteUsersConfigMap(usersConfigMapKey) deleteImageRepository(resourceKey) }) It("should provision image repository for component, with update component annotation", func() { - assertProvisionRepository(true, "") + assertProvisionRepository(true, false) }) It("should regenerate tokens and update secrets", func() { diff --git a/controllers/suite_test.go b/controllers/suite_test.go index 8e8bea8..744552b 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -119,6 +119,14 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) + err = (&QuayUsersConfigMapReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + BuildQuayClient: func(l logr.Logger) quay.QuayService { return quay.TestQuayClient{} }, + QuayOrganization: quay.TestQuayOrg, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx) diff --git a/controllers/suite_util_test.go b/controllers/suite_util_test.go index 07eeae8..88c1918 100644 --- a/controllers/suite_util_test.go +++ b/controllers/suite_util_test.go @@ -17,6 +17,7 @@ limitations under the License. package controllers import ( + "fmt" "strings" "time" @@ -284,6 +285,22 @@ func createNamespace(name string) { } } +func deleteNamespace(name string) { + namespace := corev1.Namespace{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + + if err := k8sClient.Delete(ctx, &namespace); err != nil && !k8sErrors.IsNotFound(err) { + Fail(err.Error()) + } +} + func waitSecretExist(secretKey types.NamespacedName) *corev1.Secret { secret := &corev1.Secret{} Eventually(func() bool { @@ -375,4 +392,36 @@ func deleteUsersConfigMap(configMapKey types.NamespacedName) { if err := k8sClient.Delete(ctx, &usersConfigMap); err != nil && !k8sErrors.IsNotFound(err) { Fail(err.Error()) } + Eventually(func() bool { + return k8sErrors.IsNotFound(k8sClient.Get(ctx, configMapKey, &usersConfigMap)) + }, timeout, interval).Should(BeTrue()) +} + +func addUsersToUsersConfigMap(configMapKey types.NamespacedName, addUsers []string) { + usersConfigMap := corev1.ConfigMap{} + Eventually(func() bool { + Expect(k8sClient.Get(ctx, configMapKey, &usersConfigMap)).Should(Succeed()) + return usersConfigMap.ResourceVersion != "" + }, timeout, interval).Should(BeTrue()) + + currentUsers, usersExist := usersConfigMap.Data[additionalUsersConfigMapKey] + if !usersExist { + Fail("users config map is missing key") + } + + newUsers := strings.Join(addUsers, " ") + allUsers := fmt.Sprintf("%s %s", currentUsers, newUsers) + usersConfigMap.Data[additionalUsersConfigMapKey] = allUsers + + Expect(k8sClient.Update(ctx, &usersConfigMap)).Should(Succeed()) +} + +func waitQuayTeamUsersFinalizerOnConfigMap(usersConfigMapKey types.NamespacedName) { + usersConfigMap := &corev1.ConfigMap{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, usersConfigMapKey, usersConfigMap); err != nil { + return false + } + return controllerutil.ContainsFinalizer(usersConfigMap, ConfigMapFinalizer) + }, timeout, interval).Should(BeTrue()) } diff --git a/controllers/users_config_map_controller.go b/controllers/users_config_map_controller.go new file mode 100644 index 0000000..5b13409 --- /dev/null +++ b/controllers/users_config_map_controller.go @@ -0,0 +1,290 @@ +/* +Copyright 2024 Red Hat, Inc. + +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 controllers + +import ( + "context" + "fmt" + "strings" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + imagerepositoryv1alpha1 "github.com/konflux-ci/image-controller/api/v1alpha1" + l "github.com/konflux-ci/image-controller/pkg/logs" + "github.com/konflux-ci/image-controller/pkg/quay" +) + +const ( + ConfigMapFinalizer = "appstudio.openshift.io/quay-team-users" +) + +// QuayUsersConfigMapReconciler reconciles a ConfigMap object with users +type QuayUsersConfigMapReconciler struct { + client.Client + Scheme *runtime.Scheme + + QuayClient quay.QuayService + BuildQuayClient func(logr.Logger) quay.QuayService + QuayOrganization string +} + +// SetupWithManager sets up the controller with the Manager. +func (r *QuayUsersConfigMapReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.ConfigMap{}, builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + new, ok := e.Object.(*corev1.ConfigMap) + if !ok { + return false + } + return IsAdditionalUsersConfigMap(new) + }, + UpdateFunc: func(e event.UpdateEvent) bool { + new, ok := e.ObjectNew.(*corev1.ConfigMap) + if !ok { + return false + } + return IsAdditionalUsersConfigMap(new) + }, + DeleteFunc: func(e event.DeleteEvent) bool { + new, ok := e.Object.(*corev1.ConfigMap) + if !ok { + return false + } + return IsAdditionalUsersConfigMap(new) + }, + GenericFunc: func(e event.GenericEvent) bool { + new, ok := e.Object.(*corev1.ConfigMap) + if !ok { + return false + } + return IsAdditionalUsersConfigMap(new) + }, + })). + Complete(r) +} + +func IsAdditionalUsersConfigMap(configMap *corev1.ConfigMap) bool { + return configMap.Name == additionalUsersConfigMapName +} + +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=imagerepositories,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;update + +func (r *QuayUsersConfigMapReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrllog.FromContext(ctx).WithName("QuayUsersConfigMap") + ctx = ctrllog.IntoContext(ctx, log) + + // fetch the config map instance + configMap := &corev1.ConfigMap{} + if err := r.Client.Get(ctx, req.NamespacedName, configMap); err != nil { + if errors.IsNotFound(err) { + // The object is deleted, nothing to do + return ctrl.Result{}, nil + } + log.Error(err, "failed to get config map", l.Action, l.ActionView) + return ctrl.Result{}, err + } + + if !controllerutil.ContainsFinalizer(configMap, ConfigMapFinalizer) { + controllerutil.AddFinalizer(configMap, ConfigMapFinalizer) + + if err := r.Client.Update(ctx, configMap); err != nil { + log.Error(err, "failed to add finalizer to config map", "ConfigMapName", additionalUsersConfigMapName, "Finalizer", ConfigMapFinalizer, l.Action, l.ActionUpdate) + return ctrl.Result{}, err + } + log.Info("finalizer added to configmap") + // leave all other steps to reconcile from update action + return ctrl.Result{}, nil + } + + teamName := getQuayTeamName(configMap.Namespace) + removeTeam := false + + if !configMap.DeletionTimestamp.IsZero() { + removeTeam = true + log.Info("Config map with additional users is being removed, will delete team", "TeamName", teamName) + } + + var additionalUsers []string + if !removeTeam { + // get additional users from config map + additionalUsersStr, usersExist := configMap.Data[additionalUsersConfigMapKey] + if !usersExist { + log.Info("Config map with additional users doesn't have the key", "ConfigMapName", additionalUsersConfigMapName, "ConfigMapKey", additionalUsersConfigMapKey, l.Action, l.ActionView) + removeTeam = true + } else { + additionalUsers = strings.Fields(strings.TrimSpace(additionalUsersStr)) + log.Info("Additional users configured in config map", "AdditionalUsers", additionalUsers) + } + } + + // reread quay token + r.QuayClient = r.BuildQuayClient(log) + + // remove team if config map is being removed, or doesn't contain key additionalUsersConfigMapKey + if removeTeam { + log.Info("Will remove team", "TeamName", teamName) + if err := r.QuayClient.DeleteTeam(r.QuayOrganization, teamName); err != nil { + log.Error(err, "failed to remove team", "TeamName", teamName, l.Action, l.ActionDelete) + return ctrl.Result{}, err + } + + // remove finalizer after team is removed + if !configMap.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(configMap, ConfigMapFinalizer) { + controllerutil.RemoveFinalizer(configMap, ConfigMapFinalizer) + if err := r.Client.Update(ctx, configMap); err != nil { + log.Error(err, "failed to remove config map finalizer", "ConfigMapName", additionalUsersConfigMapName, "Finalizer", ConfigMapFinalizer, l.Action, l.ActionUpdate) + return ctrl.Result{}, err + } + log.Info("finalizer removed from config map", l.Action, l.ActionDelete) + } + } + + return ctrl.Result{}, nil + } + + allImageRepos, err := r.getAllImageRepositoryNames(ctx, configMap.Namespace) + if err != nil { + return ctrl.Result{}, err + } + + // get team members, if team doesn't exist it will be created + log.Info("Ensure team", "TeamName", teamName) + teamMembers, err := r.QuayClient.EnsureTeam(r.QuayOrganization, teamName) + if err != nil { + log.Error(err, "failed to get team members", "TeamName", teamName, l.Action, l.ActionView) + return ctrl.Result{}, err + } + + // get team permissions + teamPermissions, err := r.QuayClient.ListRepositoryPermissionsForTeam(r.QuayOrganization, teamName) + if err != nil { + log.Error(err, "failed to get team permissions", "TeamName", teamName, l.Action, l.ActionView) + return ctrl.Result{}, err + } + // get repositories for which team has permissions + imageReposTeamHasPermissions := []string{} + for _, repoPermission := range teamPermissions { + imageReposTeamHasPermissions = append(imageReposTeamHasPermissions, repoPermission.Repository.Name) + } + log.Info("Team has repository permissions", "TeamName", teamName, "Repositories", imageReposTeamHasPermissions) + + // grant repo permissions to the team + imageReposToAddToTeam := filterListDifference(allImageRepos, imageReposTeamHasPermissions) + + for _, repoToUpdate := range imageReposToAddToTeam { + log.Info("Grant repository permission to the team", "TeamName", teamName, "RepositoryName", repoToUpdate) + if err := r.QuayClient.AddReadPermissionsForRepositoryToTeam(r.QuayOrganization, repoToUpdate, teamName); err != nil { + log.Error(err, "failed to grant repo permission to the team", "TeamName", teamName, "RepositoryName", repoToUpdate, l.Action, l.ActionAdd) + return ctrl.Result{}, err + } + } + + // get users in the team + usersInTeam := []string{} + for _, user := range teamMembers { + usersInTeam = append(usersInTeam, user.Name) + } + log.Info("Users in the team", "TeamName", teamName, "Users", usersInTeam) + + // add users to the team + usersToAdd := filterListDifference(additionalUsers, usersInTeam) + for _, userToAdd := range usersToAdd { + log.Info("Add user to the team", "TeamName", teamName, "UserName", userToAdd) + if permanentError, err := r.QuayClient.AddUserToTeam(r.QuayOrganization, teamName, userToAdd); err != nil { + if !permanentError { + log.Error(err, "failed to add user to the team", "TeamName", teamName, "UserName", userToAdd, l.Action, l.ActionAdd) + return ctrl.Result{}, err + } + // if user doesn't exist just log we don't have to fail because of that + log.Info(err.Error()) + } + } + + // remove users from the team + usersToRemove := filterListDifference(usersInTeam, additionalUsers) + for _, userToRemove := range usersToRemove { + log.Info("Remove user from the team", "TeamName", teamName, "UserName", userToRemove) + if err := r.QuayClient.RemoveUserFromTeam(r.QuayOrganization, teamName, userToRemove); err != nil { + log.Error(err, "failed to remove user from the team", "TeamName", teamName, "UserName", userToRemove, l.Action, l.ActionDelete) + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil +} + +func (r *QuayUsersConfigMapReconciler) getAllImageRepositoryNames(ctx context.Context, namespaceName string) ([]string, error) { + log := ctrllog.FromContext(ctx) + + // fetch image repositories in the namespace + imageRepositoryList := &imagerepositoryv1alpha1.ImageRepositoryList{} + if err := r.Client.List(ctx, imageRepositoryList, &client.ListOptions{Namespace: namespaceName}); err != nil { + log.Error(err, "failed to list ImageRepositories", l.Action, l.ActionView) + return nil, err + } + + // get image repositories names + allImageRepos := []string{} + for _, repository := range imageRepositoryList.Items { + allImageRepos = append(allImageRepos, repository.Spec.Image.Name) + } + return allImageRepos, nil +} + +// getQuayTeamName returns team name based on sanitized namespace +func getQuayTeamName(namespace string) string { + // quay team allowed chars are : ^[a-z][a-z0-9]+$. + // namespace allowed chars are : ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + validNamespaceString := strings.ReplaceAll(namespace, "-", "x") + + if validNamespaceString[0] >= '0' && validNamespaceString[0] <= '9' { + validNamespaceString = fmt.Sprintf("x%s", validNamespaceString) + } + + return fmt.Sprintf("%sxteam", validNamespaceString) +} + +// filterListDifference returns list with values which are in the 1st list, but aren't in 2nd list +func filterListDifference(firstList, secondList []string) []string { + filteredList := []string{} + for _, itemToAdd := range firstList { + shouldAdd := true + for _, itemInList := range secondList { + if itemToAdd == itemInList { + shouldAdd = false + break + } + } + if shouldAdd { + filteredList = append(filteredList, itemToAdd) + } + } + return filteredList +} diff --git a/controllers/users_config_map_controller_test.go b/controllers/users_config_map_controller_test.go new file mode 100644 index 0000000..83bcc2c --- /dev/null +++ b/controllers/users_config_map_controller_test.go @@ -0,0 +1,379 @@ +/* +Copyright 2024 Red Hat, Inc. + +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 controllers + +import ( + "fmt" + + "github.com/konflux-ci/image-controller/pkg/quay" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" +) + +var _ = Describe("Users config map controller", func() { + Context("Users config map creation, update, removal, namespace doesn't start with number", func() { + var configTestNamespace = "config-map-namespace-test" + var usersConfigMapKey = types.NamespacedName{Name: additionalUsersConfigMapName, Namespace: configTestNamespace} + expectedTeamName := "configxmapxnamespacextestxteam" + imageRepositoryName1 := fmt.Sprintf("%s/some1/image1", configTestNamespace) + imageRepositoryName2 := fmt.Sprintf("%s/other2/image2", configTestNamespace) + + BeforeEach(func() { + quay.ResetTestQuayClientToFails() + }) + + It("should prepare environment", func() { + createNamespace(configTestNamespace) + }) + + It("team doesn't exist, requested 2 users, imageRepositories don't exist, create team with and add 2 users", func() { + isEnsureTeamInvoked := false + isListRepositoryPermissionsForTeamInvoked := false + countAddUserToTeamInvoked := 0 + isDeleteTeamInvoked := false + + quay.EnsureTeamFunc = func(organization, teamName string) ([]quay.Member, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isEnsureTeamInvoked = true + return []quay.Member{}, nil + } + quay.ListRepositoryPermissionsForTeamFunc = func(organization, teamName string) ([]quay.TeamPermission, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isListRepositoryPermissionsForTeamInvoked = true + return []quay.TeamPermission{}, nil + } + quay.AddUserToTeamFunc = func(organization, teamName, userName string) (bool, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + Expect(userName).To(BeElementOf([]string{"user1", "user2"})) + countAddUserToTeamInvoked++ + return false, nil + } + + createUsersConfigMap(usersConfigMapKey, []string{"user1", "user2"}) + waitQuayTeamUsersFinalizerOnConfigMap(usersConfigMapKey) + + Eventually(func() bool { return isEnsureTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isListRepositoryPermissionsForTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() int { return countAddUserToTeamInvoked }, timeout, interval).Should(Equal(2)) + + quay.DeleteTeamFunc = func(organization, teamName string) error { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isDeleteTeamInvoked = true + return nil + } + deleteUsersConfigMap(usersConfigMapKey) + Eventually(func() bool { return isDeleteTeamInvoked }, timeout, interval).Should(BeTrue()) + }) + + It("team exists and has already 1 of requested users, add 1 more user to the team, imageRepositories don't exist", func() { + isEnsureTeamInvoked := false + isListRepositoryPermissionsForTeamInvoked := false + countAddUserToTeamInvoked := 0 + isDeleteTeamInvoked := false + + quay.EnsureTeamFunc = func(organization, teamName string) ([]quay.Member, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isEnsureTeamInvoked = true + return []quay.Member{{Name: "user1", Kind: "user", IsRobot: false, Invited: false}}, nil + } + quay.ListRepositoryPermissionsForTeamFunc = func(organization, teamName string) ([]quay.TeamPermission, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isListRepositoryPermissionsForTeamInvoked = true + return []quay.TeamPermission{}, nil + } + quay.AddUserToTeamFunc = func(organization, teamName, userName string) (bool, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + Expect(userName).To(Equal("user2")) + countAddUserToTeamInvoked++ + return false, nil + } + + createUsersConfigMap(usersConfigMapKey, []string{"user1", "user2"}) + waitQuayTeamUsersFinalizerOnConfigMap(usersConfigMapKey) + + Eventually(func() bool { return isEnsureTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isListRepositoryPermissionsForTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() int { return countAddUserToTeamInvoked }, timeout, interval).Should(Equal(1)) + + quay.DeleteTeamFunc = func(organization, teamName string) error { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isDeleteTeamInvoked = true + return nil + } + deleteUsersConfigMap(usersConfigMapKey) + Eventually(func() bool { return isDeleteTeamInvoked }, timeout, interval).Should(BeTrue()) + }) + + It("team exists and has already 2 users which weren't requested, add 2 users to the team, remove 2 users from team, imageRepositories don't exist", func() { + isEnsureTeamInvoked := false + isListRepositoryPermissionsForTeamInvoked := false + countAddUserToTeamInvoked := 0 + countRemoveUserFromTeamInvoked := 0 + + quay.EnsureTeamFunc = func(organization, teamName string) ([]quay.Member, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isEnsureTeamInvoked = true + return []quay.Member{ + {Name: "not-requested-user1", Kind: "user", IsRobot: false, Invited: false}, + {Name: "not-requested-user2", Kind: "user", IsRobot: false, Invited: false}}, nil + } + quay.ListRepositoryPermissionsForTeamFunc = func(organization, teamName string) ([]quay.TeamPermission, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isListRepositoryPermissionsForTeamInvoked = true + return []quay.TeamPermission{}, nil + } + quay.AddUserToTeamFunc = func(organization, teamName, userName string) (bool, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + Expect(userName).To(BeElementOf([]string{"user1", "user2"})) + countAddUserToTeamInvoked++ + return false, nil + } + quay.RemoveUserFromTeamFunc = func(organization, teamName, userName string) error { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + Expect(userName).To(BeElementOf([]string{"not-requested-user1", "not-requested-user2"})) + countRemoveUserFromTeamInvoked++ + return nil + } + + createUsersConfigMap(usersConfigMapKey, []string{"user1", "user2"}) + waitQuayTeamUsersFinalizerOnConfigMap(usersConfigMapKey) + + Eventually(func() bool { return isEnsureTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isListRepositoryPermissionsForTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() int { return countAddUserToTeamInvoked }, timeout, interval).Should(Equal(2)) + Eventually(func() int { return countRemoveUserFromTeamInvoked }, timeout, interval).Should(Equal(2)) + }) + + It("config map was updated, 2 new users added, team exists and has already 2 users, add 1 new user to the team, imageRepositories don't exist", func() { + isEnsureTeamInvoked := false + isListRepositoryPermissionsForTeamInvoked := false + countAddUserToTeamInvoked := 0 + isDeleteTeamInvoked := false + + quay.EnsureTeamFunc = func(organization, teamName string) ([]quay.Member, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isEnsureTeamInvoked = true + return []quay.Member{ + {Name: "user1", Kind: "user", IsRobot: false, Invited: false}, + {Name: "user2", Kind: "user", IsRobot: false, Invited: false}}, nil + } + quay.ListRepositoryPermissionsForTeamFunc = func(organization, teamName string) ([]quay.TeamPermission, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isListRepositoryPermissionsForTeamInvoked = true + return []quay.TeamPermission{}, nil + } + quay.AddUserToTeamFunc = func(organization, teamName, userName string) (bool, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + Expect(userName).To(BeElementOf([]string{"user3", "user4"})) + countAddUserToTeamInvoked++ + return false, nil + } + + addUsersToUsersConfigMap(usersConfigMapKey, []string{"user3", "user4"}) + + Eventually(func() bool { return isEnsureTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isListRepositoryPermissionsForTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() int { return countAddUserToTeamInvoked }, timeout, interval).Should(Equal(2)) + + quay.DeleteTeamFunc = func(organization, teamName string) error { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isDeleteTeamInvoked = true + return nil + } + deleteUsersConfigMap(usersConfigMapKey) + Eventually(func() bool { return isDeleteTeamInvoked }, timeout, interval).Should(BeTrue()) + }) + + It("create image repositories", func() { + quay.ResetTestQuayClient() + + imageRepository1 := types.NamespacedName{Name: "imagerepository1", Namespace: configTestNamespace} + imageRepository2 := types.NamespacedName{Name: "imagerepository2", Namespace: configTestNamespace} + createImageRepository(imageRepositoryConfig{ImageName: imageRepositoryName1, ResourceKey: &imageRepository1}) + waitImageRepositoryFinalizerOnImageRepository(imageRepository1) + createImageRepository(imageRepositoryConfig{ImageName: imageRepositoryName2, ResourceKey: &imageRepository2}) + waitImageRepositoryFinalizerOnImageRepository(imageRepository2) + }) + + It("should create team with 2 users, team doesn't exist, imageRepositories exist and add permissions to the team", func() { + isEnsureTeamInvoked := false + isListRepositoryPermissionsForTeamInvoked := false + countAddUserToTeamInvoked := 0 + countAddReadPermissionsForRepositoryToTeamInvoked := 0 + isDeleteTeamInvoked := false + + quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { + defer GinkgoRecover() + return &quay.Repository{}, nil + } + quay.CreateRobotAccountFunc = func(organization, robotName string) (*quay.RobotAccount, error) { + return &quay.RobotAccount{}, nil + } + + quay.EnsureTeamFunc = func(organization, teamName string) ([]quay.Member, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isEnsureTeamInvoked = true + return []quay.Member{}, nil + } + quay.ListRepositoryPermissionsForTeamFunc = func(organization, teamName string) ([]quay.TeamPermission, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isListRepositoryPermissionsForTeamInvoked = true + return []quay.TeamPermission{}, nil + } + + quay.AddUserToTeamFunc = func(organization, teamName, userName string) (bool, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + Expect(userName).To(BeElementOf([]string{"user1", "user2"})) + countAddUserToTeamInvoked++ + return false, nil + } + quay.AddReadPermissionsForRepositoryToTeamFunc = func(organization, imageRepository, teamName string) error { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(imageRepository).To(BeElementOf([]string{imageRepositoryName1, imageRepositoryName2})) + Expect(teamName).To(Equal(expectedTeamName)) + countAddReadPermissionsForRepositoryToTeamInvoked++ + return nil + } + createUsersConfigMap(usersConfigMapKey, []string{"user1", "user2"}) + waitQuayTeamUsersFinalizerOnConfigMap(usersConfigMapKey) + + Eventually(func() bool { return isEnsureTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isListRepositoryPermissionsForTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() int { return countAddUserToTeamInvoked }, timeout, interval).Should(Equal(2)) + Eventually(func() int { return countAddReadPermissionsForRepositoryToTeamInvoked }, timeout, interval).Should(Equal(2)) + + quay.DeleteTeamFunc = func(organization, teamName string) error { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isDeleteTeamInvoked = true + return nil + } + deleteUsersConfigMap(usersConfigMapKey) + Eventually(func() bool { return isDeleteTeamInvoked }, timeout, interval).Should(BeTrue()) + }) + + It("should cleanup environment", func() { + deleteNamespace(configTestNamespace) + }) + + }) + + Context("Users config map creation, namespace starts with number", func() { + var configTestNamespace = "1config-map-namespace-test" + var usersConfigMapKey = types.NamespacedName{Name: additionalUsersConfigMapName, Namespace: configTestNamespace} + expectedTeamName := "x1configxmapxnamespacextestxteam" + + BeforeEach(func() { + quay.ResetTestQuayClientToFails() + }) + + It("should prepare environment", func() { + createNamespace(configTestNamespace) + }) + + It("team doesn't exist, requested 2 users, imageRepositories don't exist, create team with and add 2 users", func() { + isEnsureTeamInvoked := false + isListRepositoryPermissionsForTeamInvoked := false + countAddUserToTeamInvoked := 0 + isDeleteTeamInvoked := false + + quay.EnsureTeamFunc = func(organization, teamName string) ([]quay.Member, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isEnsureTeamInvoked = true + return []quay.Member{}, nil + } + quay.ListRepositoryPermissionsForTeamFunc = func(organization, teamName string) ([]quay.TeamPermission, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isListRepositoryPermissionsForTeamInvoked = true + return []quay.TeamPermission{}, nil + } + quay.AddUserToTeamFunc = func(organization, teamName, userName string) (bool, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + Expect(userName).To(BeElementOf([]string{"user1", "user2"})) + countAddUserToTeamInvoked++ + return false, nil + } + + createUsersConfigMap(usersConfigMapKey, []string{"user1", "user2"}) + waitQuayTeamUsersFinalizerOnConfigMap(usersConfigMapKey) + + Eventually(func() bool { return isEnsureTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isListRepositoryPermissionsForTeamInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() int { return countAddUserToTeamInvoked }, timeout, interval).Should(Equal(2)) + + quay.DeleteTeamFunc = func(organization, teamName string) error { + defer GinkgoRecover() + Expect(organization).To(Equal(quay.TestQuayOrg)) + Expect(teamName).To(Equal(expectedTeamName)) + isDeleteTeamInvoked = true + return nil + } + deleteUsersConfigMap(usersConfigMapKey) + Eventually(func() bool { return isDeleteTeamInvoked }, timeout, interval).Should(BeTrue()) + }) + + It("should cleanup environment", func() { + deleteNamespace(configTestNamespace) + }) + }) +}) diff --git a/main.go b/main.go index ec1cb0f..2f4c87d 100644 --- a/main.go +++ b/main.go @@ -158,6 +158,17 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "ImageRepository") os.Exit(1) } + + if err = (&controllers.QuayUsersConfigMapReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + BuildQuayClient: buildQuayClientFunc, + QuayOrganization: quayOrganization, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ConfigMap") + os.Exit(1) + } + //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/pkg/quay/api.go b/pkg/quay/api.go index ad8c850..53448a9 100644 --- a/pkg/quay/api.go +++ b/pkg/quay/api.go @@ -34,6 +34,7 @@ type RepositoryRequest struct { Description string `json:"description"` //Kind string `json:"repo_kind"` } + type RobotAccount struct { Description string `json:"description"` Created string `json:"created"` @@ -51,6 +52,23 @@ type UserAccount struct { IsOrgMember bool `json:"is_org_member"` } +type TeamPermission struct { + Repository TeamPermissionRepository `json:"repository"` + Role string `json:"role"` +} + +type TeamPermissionRepository struct { + Name string `json:"name"` + IsPublic bool `json:"is_public"` +} + +type Member struct { + Name string `json:"name"` + Kind string `json:"kind"` + IsRobot bool `json:"is_robot"` + Invited bool `json:"invited"` +} + // Quay API can sometimes return {"error": "..."} and sometimes {"error_message": "..."} without the field error // In some cases the error is send alongside the response in the {"message": "..."} field. type QuayError struct { diff --git a/pkg/quay/quay.go b/pkg/quay/quay.go index cff67de..0fc81c4 100644 --- a/pkg/quay/quay.go +++ b/pkg/quay/quay.go @@ -36,6 +36,13 @@ type QuayService interface { DeleteRobotAccount(organization string, robotName string) (bool, error) AddPermissionsForRepositoryToAccount(organization, imageRepository, accountName string, isRobot, isWrite bool) error ListPermissionsForRepository(organization, imageRepository string) (map[string]UserAccount, error) + AddReadPermissionsForRepositoryToTeam(organization, imageRepository, teamName string) error + ListRepositoryPermissionsForTeam(organization, teamName string) ([]TeamPermission, error) + AddUserToTeam(organization, teamName, userName string) (bool, error) + RemoveUserFromTeam(organization, teamName, userName string) error + DeleteTeam(organization, teamName string) error + EnsureTeam(organization, teamName string) ([]Member, error) + GetTeamMembers(organization, teamName string) ([]Member, error) RegenerateRobotAccountToken(organization string, robotName string) (*RobotAccount, error) GetAllRepositories(organization string) ([]Repository, error) GetAllRobotAccounts(organization string) ([]RobotAccount, error) @@ -362,15 +369,25 @@ func (c *QuayClient) DeleteRobotAccount(organization string, robotName string) ( // ListPermissionsForRepository list permissions for the given repository. func (c *QuayClient) ListPermissionsForRepository(organization, imageRepository string) (map[string]UserAccount, error) { - url := fmt.Sprintf("%s/repository/%s/%s/permissions/user/", c.url, organization, imageRepository) + url := fmt.Sprintf("%s/repository/%s/%s/permissions/user", c.url, organization, imageRepository) resp, err := c.doRequest(url, http.MethodGet, nil) if err != nil { return nil, fmt.Errorf("failed to Do request, error: %s", err) } + if resp.GetStatusCode() != 200 { - return nil, fmt.Errorf("error getting permissions, got status code %d", resp.GetStatusCode()) + var message string + data := &QuayError{} + if err := resp.GetJson(data); err == nil { + if data.ErrorMessage != "" { + message = data.ErrorMessage + } else { + message = data.Error + } + } + return nil, fmt.Errorf("failed to get permissions for repository: %s, got status code %d, message: %s", imageRepository, resp.GetStatusCode(), message) } type Response struct { @@ -378,12 +395,221 @@ func (c *QuayClient) ListPermissionsForRepository(organization, imageRepository } var response Response if err := resp.GetJson(&response); err != nil { - return nil, err + return nil, fmt.Errorf("failed to get permissions for repository: %s, got status code %d, message: %s", imageRepository, resp.GetStatusCode(), err.Error()) + } + + return response.Permissions, nil +} + +// ListRepositoryPermissionsForTeam list permissions for the given team +func (c *QuayClient) ListRepositoryPermissionsForTeam(organization, teamName string) ([]TeamPermission, error) { + url := fmt.Sprintf("%s/organization/%s/team/%s/permissions", c.url, organization, teamName) + + resp, err := c.doRequest(url, http.MethodGet, nil) + if err != nil { + return nil, fmt.Errorf("failed to Do request, error: %s", err) + } + + if resp.GetStatusCode() != 200 { + var message string + data := &QuayError{} + if err := resp.GetJson(data); err == nil { + if data.ErrorMessage != "" { + message = data.ErrorMessage + } else { + message = data.Error + } + } else { + message = err.Error() + } + return nil, fmt.Errorf("failed to get permissions for team: %s, got status code %d, message: %s", teamName, resp.GetStatusCode(), message) + } + + type Response struct { + Permissions []TeamPermission `json:"permissions"` + } + var response Response + if err := resp.GetJson(&response); err != nil { + return nil, fmt.Errorf("failed to get permissions for team: %s, got status code %d, message: %s", teamName, resp.GetStatusCode(), err.Error()) } return response.Permissions, nil } +// AddUserToTeam adds user to the given team +// bool return value is indicating if it is permanent error (user doesn't exist) +func (c *QuayClient) AddUserToTeam(organization, teamName, userName string) (bool, error) { + url := fmt.Sprintf("%s/organization/%s/team/%s/members/%s", c.url, organization, teamName, userName) + + resp, err := c.doRequest(url, http.MethodPut, nil) + if err != nil { + return false, fmt.Errorf("failed to Do request, error: %s", err) + } + + if resp.GetStatusCode() != 200 { + // 400 is returned when user doesn't exist + // 404 just in case + if resp.GetStatusCode() == 400 || resp.GetStatusCode() == 404 { + return true, fmt.Errorf("failed to add user: %s, to the team team: %s, user doesn't exist", userName, teamName) + } + + var message string + data := &QuayError{} + if err := resp.GetJson(data); err == nil { + if data.ErrorMessage != "" { + message = data.ErrorMessage + } else { + message = data.Error + } + } else { + message = err.Error() + } + return false, fmt.Errorf("failed to add user: %s, to the team team: %s, got status code %d, message: %s", userName, teamName, resp.GetStatusCode(), message) + } + return false, nil +} + +// RemoveUserToTeam remove user from the given team +func (c *QuayClient) RemoveUserFromTeam(organization, teamName, userName string) error { + url := fmt.Sprintf("%s/organization/%s/team/%s/members/%s", c.url, organization, teamName, userName) + + resp, err := c.doRequest(url, http.MethodDelete, nil) + if err != nil { + return fmt.Errorf("failed to Do request, error: %s", err) + } + + // 400 is returned when user isn't anymore in the team + // 404 is returned when user doesn't exist + if resp.GetStatusCode() == 204 || resp.GetStatusCode() == 404 || resp.GetStatusCode() == 400 { + return nil + } + + var message string + data := &QuayError{} + if err := resp.GetJson(data); err == nil { + if data.ErrorMessage != "" { + message = data.ErrorMessage + } else { + message = data.Error + } + } else { + message = err.Error() + } + return fmt.Errorf("failed to remove user: %s, from the team team: %s, got status code %d, message: %s", userName, teamName, resp.GetStatusCode(), message) +} + +func (c *QuayClient) DeleteTeam(organization, teamName string) error { + url := fmt.Sprintf("%s/organization/%s/team/%s", c.url, organization, teamName) + + resp, err := c.doRequest(url, http.MethodDelete, nil) + if err != nil { + return fmt.Errorf("failed to Do request, error: %s", err) + } + + // 400 is returned when team doesn't exist + // 404 just in case + if resp.GetStatusCode() == 204 || resp.GetStatusCode() == 404 || resp.GetStatusCode() == 400 { + return nil + } + + var message string + data := &QuayError{} + if err := resp.GetJson(data); err == nil { + if data.ErrorMessage != "" { + message = data.ErrorMessage + } else { + message = data.Error + } + } else { + message = err.Error() + } + return fmt.Errorf("failed to remove team: %s, got status code %d, message: %s", teamName, resp.GetStatusCode(), message) +} + +// EnsureTeam ensures that team exists, if it doesn't it will create it +// returns list of team members +func (c *QuayClient) EnsureTeam(organization, teamName string) ([]Member, error) { + members, err := c.GetTeamMembers(organization, teamName) + if err != nil { + return nil, err + } + // team exists + if members != nil { + return members, nil + } + + // create team + url := fmt.Sprintf("%s/organization/%s/team/%s", c.url, organization, teamName) + body := strings.NewReader(`{"role": "member"}`) + + resp, err := c.doRequest(url, http.MethodPut, body) + if err != nil { + return nil, fmt.Errorf("failed to Do request, error: %s", err) + } + + if resp.GetStatusCode() != 200 { + var message string + data := &QuayError{} + if err := resp.GetJson(data); err == nil { + if data.ErrorMessage != "" { + message = data.ErrorMessage + } else { + message = data.Error + } + } else { + message = err.Error() + } + return nil, fmt.Errorf("failed to create team: %s, got status code %d, message: %s", teamName, resp.GetStatusCode(), message) + } + + members, err = c.GetTeamMembers(organization, teamName) + if err != nil { + return nil, err + } + return members, nil +} + +// GetTeamMembers gets members of the team, when nil is returned that means that team doesn't exist +func (c *QuayClient) GetTeamMembers(organization, teamName string) ([]Member, error) { + url := fmt.Sprintf("%s/organization/%s/team/%s/members", c.url, organization, teamName) + + resp, err := c.doRequest(url, http.MethodGet, nil) + + if err != nil { + return nil, fmt.Errorf("failed to Do request, error: %s", err) + } + if resp.GetStatusCode() != 200 { + // team doesn't exist + if resp.GetStatusCode() == 404 { + return nil, nil + } + + var message string + data := &QuayError{} + if err := resp.GetJson(data); err == nil { + if data.ErrorMessage != "" { + message = data.ErrorMessage + } else { + message = data.Error + } + } else { + message = err.Error() + } + return nil, fmt.Errorf("failed to get team members for team: %s, got status code %d, message: %s", teamName, resp.GetStatusCode(), message) + } + + type Response struct { + Members []Member `json:"members"` + } + var response Response + + if err := resp.GetJson(&response); err != nil { + return nil, err + } + + return response.Members, nil +} + // AddPermissionsForRepositoryToAccount allows given account to access to the given repository. // If isWrite is true, then pull and push permissions are added, otherwise - pull access only. func (c *QuayClient) AddPermissionsForRepositoryToAccount(organization, imageRepository, accountName string, isRobot, isWrite bool) error { @@ -426,6 +652,33 @@ func (c *QuayClient) AddPermissionsForRepositoryToAccount(organization, imageRep return nil } +// AddReadPermissionsForRepositoryToTeam allows given team read access to the given repository. +func (c *QuayClient) AddReadPermissionsForRepositoryToTeam(organization, imageRepository, teamName string) error { + url := fmt.Sprintf("%s/repository/%s/%s/permissions/team/%s", c.url, organization, imageRepository, teamName) + body := strings.NewReader(`{"role": "read"}`) + + resp, err := c.doRequest(url, http.MethodPut, body) + if err != nil { + return err + } + + if resp.GetStatusCode() != 200 { + var message string + data := &QuayError{} + if err := resp.GetJson(data); err == nil { + if data.ErrorMessage != "" { + message = data.ErrorMessage + } else { + message = data.Error + } + } else { + message = err.Error() + } + return fmt.Errorf("failed to add permissions to the team: %s. Status code: %d, message: %s", teamName, resp.GetStatusCode(), message) + } + return nil +} + func (c *QuayClient) RegenerateRobotAccountToken(organization string, robotName string) (*RobotAccount, error) { url := fmt.Sprintf("%s/organization/%s/robots/%s/regenerate", c.url, organization, robotName) diff --git a/pkg/quay/quay_test.go b/pkg/quay/quay_test.go index 969c687..af38041 100644 --- a/pkg/quay/quay_test.go +++ b/pkg/quay/quay_test.go @@ -368,6 +368,71 @@ func TestQuayClient_AddPermissions(t *testing.T) { } } +func TestQuayClient_AddReadPermissionsForRepositoryToTeam(t *testing.T) { + client := &http.Client{Transport: &http.Transport{}} + gock.InterceptClient(client) + + testCases := []struct { + name string + statusCode int + responseData interface{} + expectedErr string // Empty string means that no error is expected + }{ + { + name: "add permissions normally", + statusCode: 200, + responseData: "", + expectedErr: "", + }, + // The following test cases are for testing non-200 response code from server + { + name: "return error got from error field within response", + statusCode: 400, + responseData: map[string]string{"error": "something is wrong"}, + expectedErr: "something is wrong", + }, + { + name: "return error got from error_message field within response", + statusCode: 400, + responseData: map[string]string{"error_message": "something is wrong"}, + expectedErr: "something is wrong", + }, + { + name: "server responds an invalid JSON string", + statusCode: 400, + responseData: "{\"name: \"info\"}", + expectedErr: "failed to unmarshal response body", + }, + { + name: "stop if http request fails", + expectedErr: "failed to Do request:", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer gock.Off() + + req := gock.New(testQuayApiUrl). + Put("/repository/org/repository/permissions/team/teamname") + req.Reply(tc.statusCode).JSON(tc.responseData) + + if tc.name == "stop if http request fails" { + req.AddMatcher(gock.MatchPath).Put("another-path") + } + + quayClient := NewQuayClient(client, "authtoken", testQuayApiUrl) + err := quayClient.AddReadPermissionsForRepositoryToTeam("org", "repository", "teamname") + + if tc.expectedErr == "" { + assert.NilError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectedErr) + } + }) + } +} + func TestQuayClient_GetAllRepositories(t *testing.T) { type Response struct { Repositories []Repository `json:"repositories"` @@ -549,7 +614,7 @@ func TestQuayClient_ListPermisssionsForRepository(t *testing.T) { { name: "server does not respond 200", statusCode: 400, - expectedErr: "error getting permissions", + expectedErr: "failed to get permissions for repository", responseData: "", expectedPermissions: nil, }, @@ -573,7 +638,7 @@ func TestQuayClient_ListPermisssionsForRepository(t *testing.T) { req := gock.New(testQuayApiUrl). MatchHeader("Content-Type", "application/json"). MatchHeader("Authorization", "Bearer authtoken"). - Get("/repository/test_org/test_repository/permissions/user/") + Get("/repository/test_org/test_repository/permissions/user") req.Reply(tc.statusCode).JSON(tc.responseData) if tc.name == "stop if http request fails" { @@ -597,6 +662,299 @@ func TestQuayClient_ListPermisssionsForRepository(t *testing.T) { } } +func TestQuayClient_ListPermisssionsForTeam(t *testing.T) { + testCases := []struct { + name string + statusCode int + responseData interface{} + expectedPermissions []TeamPermission + expectedErr string + }{ + { + name: "list permissions for repository normally", + statusCode: 200, + responseData: "{\"permissions\": [{\"repository\": {\"name\": \"repository1\", \"is_public\": true}, \"role\": \"read\"}, {\"repository\": {\"name\": \"repository2\", \"is_public\": false}, \"role\": \"read\"}]}", + expectedPermissions: []TeamPermission{{Repository: TeamPermissionRepository{Name: "repository1", IsPublic: true}, Role: "read"}, {Repository: TeamPermissionRepository{Name: "repository2", IsPublic: false}, Role: "read"}}, + expectedErr: "", + }, + { + name: "server does not respond 200", + statusCode: 400, + expectedErr: "failed to get permissions for team", + responseData: "", + expectedPermissions: nil, + }, + { + name: "server does not respond invalid a JSON string 200", + statusCode: 200, + responseData: "{\"permissions\": {\"repository\": {\"name}}}", + expectedPermissions: nil, + expectedErr: "failed to unmarshal response body", + }, + { + name: "server does not respond invalid a JSON string 400", + statusCode: 200, + responseData: "{\"}", + expectedPermissions: nil, + expectedErr: "failed to unmarshal response body", + }, + { + name: "stop if http request fails", + expectedErr: "failed to Do request:", + }, + } + + 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"). + Get("/organization/test_org/team/test_team/permissions") + req.Reply(tc.statusCode).JSON(tc.responseData) + + if tc.name == "stop if http request fails" { + req.AddMatcher(gock.MatchPath).Get("another-path") + } + + client := &http.Client{Transport: &http.Transport{}} + gock.InterceptClient(client) + + quayClient := NewQuayClient(client, "authtoken", testQuayApiUrl) + permissions, err := quayClient.ListRepositoryPermissionsForTeam("test_org", "test_team") + + if tc.expectedErr == "" { + assert.NilError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectedErr) + } + + assert.DeepEqual(t, tc.expectedPermissions, permissions) + }) + } +} + +func TestQuayClient_EnsureTeam(t *testing.T) { + testCases := []struct { + name string + statusCode1 int + responseData1 interface{} + statusCode2 int + responseData2 interface{} + statusCode3 int + responseData3 interface{} + expectedMembers []Member + expectedErr string + requestCalls int + }{ + { + name: "team already exists and has members", + statusCode1: 200, + responseData1: "{\"members\": [{\"name\": \"user1\", \"kind\": \"user\", \"is_robot\": false, \"invited\": false}, {\"name\": \"user2\", \"kind\": \"user\", \"is_robot\": true, \"invited\": false}]}", + expectedMembers: []Member{{Name: "user1", Kind: "user", IsRobot: false, Invited: false}, {Name: "user2", Kind: "user", IsRobot: true, Invited: false}}, + expectedErr: "", + requestCalls: 1, + }, + { + name: "team already exists and has no members", + statusCode1: 200, + responseData1: "{\"members\": []}", + expectedMembers: []Member{}, + expectedErr: "", + requestCalls: 1, + }, + { + name: "get members fails 200", + statusCode1: 400, + responseData1: "", + expectedErr: "failed to get team members for team", + expectedMembers: nil, + requestCalls: 1, + }, + { + name: "team doesn't exist, will be created", + statusCode1: 404, + responseData1: "", + statusCode2: 200, + responseData2: "", + statusCode3: 200, + responseData3: "{\"members\": [{\"name\": \"user1\", \"kind\": \"user\", \"is_robot\": false, \"invited\": false}, {\"name\": \"user2\", \"kind\": \"user\", \"is_robot\": true, \"invited\": false}]}", + expectedMembers: []Member{{Name: "user1", Kind: "user", IsRobot: false, Invited: false}, {Name: "user2", Kind: "user", IsRobot: true, Invited: false}}, + expectedErr: "", + requestCalls: 3, + }, + { + name: "team doesn't exist, create fails, error in error field", + statusCode1: 404, + responseData1: "", + statusCode2: 400, + responseData2: "{\"error\": \"something is wrong in the server\"}", + expectedMembers: nil, + expectedErr: "something is wrong in the server", + requestCalls: 2, + }, + { + name: "team doesn't exist, create fails, error in error_message field", + statusCode1: 404, + responseData1: "", + statusCode2: 400, + responseData2: "{\"error_message\": \"something is wrong\"}", + expectedMembers: nil, + expectedErr: "something is wrong", + requestCalls: 2, + }, + { + name: "team doesn't exist, create fails, invalid a JSON string", + statusCode1: 404, + responseData1: "", + statusCode2: 400, + responseData2: "{\"error_message\": \"}", + expectedMembers: nil, + expectedErr: "failed to unmarshal response body", + requestCalls: 2, + }, + { + name: "stop if http request fails", + statusCode1: 404, + responseData1: "", + expectedErr: "failed to Do request:", + requestCalls: 2, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer gock.Off() + + req1 := gock.New(testQuayApiUrl). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer authtoken"). + Get("/organization/test_org/team/test_team/members") + req1.Reply(tc.statusCode1).JSON(tc.responseData1) + + if tc.requestCalls >= 1 { + req2 := gock.New(testQuayApiUrl). + Put("/organization/test_org/team/test_team") + req2.Reply(tc.statusCode2).JSON(tc.responseData2) + + if tc.name == "stop if http request fails" { + req2.AddMatcher(gock.MatchPath).Get("another-path") + } + } + if tc.requestCalls >= 2 { + req3 := gock.New(testQuayApiUrl). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer authtoken"). + Get("/organization/test_org/team/test_team/members") + req3.Reply(tc.statusCode3).JSON(tc.responseData3) + } + + client := &http.Client{Transport: &http.Transport{}} + gock.InterceptClient(client) + + quayClient := NewQuayClient(client, "authtoken", testQuayApiUrl) + members, err := quayClient.EnsureTeam("test_org", "test_team") + + if tc.expectedErr == "" { + assert.NilError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectedErr) + } + + assert.DeepEqual(t, tc.expectedMembers, members) + }) + } +} + +func TestQuayClient_GetTeamMembers(t *testing.T) { + testCases := []struct { + name string + statusCode int + responseData interface{} + expectedMembers []Member + expectedErr string + }{ + { + name: "list members for team normally", + statusCode: 200, + responseData: "{\"members\": [{\"name\": \"user1\", \"kind\": \"user\", \"is_robot\": false, \"invited\": false}, {\"name\": \"user2\", \"kind\": \"user\", \"is_robot\": true, \"invited\": false}]}", + expectedMembers: []Member{{Name: "user1", Kind: "user", IsRobot: false, Invited: false}, {Name: "user2", Kind: "user", IsRobot: true, Invited: false}}, + expectedErr: "", + }, + { + name: "list members for team normally, no members", + statusCode: 200, + responseData: "{\"members\": []}", + expectedMembers: []Member{}, + expectedErr: "", + }, + { + name: "team doesn't exist responds 404", + statusCode: 404, + responseData: "", + expectedMembers: nil, + expectedErr: "", + }, + { + name: "server does not respond 200", + statusCode: 400, + expectedErr: "failed to get team members for team", + responseData: "", + expectedMembers: nil, + }, + { + name: "server does not respond invalid a JSON string 200", + statusCode: 200, + responseData: "{\"members\": [{\"name\": \"}}]", + expectedMembers: nil, + expectedErr: "failed to unmarshal response body", + }, + { + name: "server does not respond invalid a JSON string 400", + statusCode: 500, + responseData: "{\"]", + expectedMembers: nil, + expectedErr: "failed to unmarshal response body", + }, + + { + name: "stop if http request fails", + expectedErr: "failed to Do request:", + }, + } + + 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"). + Get("/organization/test_org/team/test_team/members") + req.Reply(tc.statusCode).JSON(tc.responseData) + + if tc.name == "stop if http request fails" { + req.AddMatcher(gock.MatchPath).Get("another-path") + } + + client := &http.Client{Transport: &http.Transport{}} + gock.InterceptClient(client) + + quayClient := NewQuayClient(client, "authtoken", testQuayApiUrl) + members, err := quayClient.GetTeamMembers("test_org", "test_team") + + if tc.expectedErr == "" { + assert.NilError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectedErr) + } + + assert.DeepEqual(t, tc.expectedMembers, members) + }) + } +} + func TestQuayClient_handleRobotName(t *testing.T) { invalidRobotNameErr := fmt.Errorf("robot name is invalid, must match `^([a-z0-9]+(?:[._-][a-z0-9]+)*)$` (one plus sign in the middle is also allowed)") testCases := []struct { @@ -1777,3 +2135,256 @@ func TestDoRequest(t *testing.T) { }) } } + +func TestQuayClient_DeleteTeam(t *testing.T) { + client := &http.Client{Transport: &http.Transport{}} + gock.InterceptClient(client) + + testCases := []struct { + name string + expectedErr string + statusCode int + response interface{} + }{ + { + name: "Delete existing robot account", + expectedErr: "", + statusCode: 204, + response: nil, + }, + { + name: "Unauthorized access", + expectedErr: "Unauthorized", + statusCode: 403, + response: responseUnauthorized, + }, + { + name: "team doesn't exist 400", + expectedErr: "", + statusCode: 400, + response: "", + }, + { + name: "team doesn't exist 404", + expectedErr: "", + statusCode: 404, + response: nil, + }, + { + name: "server responds error in error field", + expectedErr: "something is wrong in the server", + statusCode: 500, // can be any status code except 204 and 404 + response: "{\"error\": \"something is wrong in the server\"}", + }, + { + name: "return error got from error_message field within response", + expectedErr: "something is wrong", + statusCode: 500, + response: "{\"error_message\": \"something is wrong\"}", + }, + + { + name: "stop if http request fails", + expectedErr: "failed to Do request:", + }, + { + name: "server responds an invalid JSON string", + expectedErr: "failed to unmarshal response body", + response: "{\"error\": \"something is wrong}", + }, + } + + 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"). + Delete(fmt.Sprintf("organization/%s/team/%s", org, "teamname")) + req.Reply(tc.statusCode).JSON(tc.response) + + if tc.name == "stop if http request fails" { + req.AddMatcher(gock.MatchPath).Delete("another-path") + } + + quayClient := NewQuayClient(client, "authtoken", testQuayApiUrl) + err := quayClient.DeleteTeam(org, "teamname") + if tc.expectedErr == "" { + assert.NilError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectedErr) + } + }) + } +} + +func TestQuayClient_AddUserToTeam(t *testing.T) { + client := &http.Client{Transport: &http.Transport{}} + gock.InterceptClient(client) + + testCases := []struct { + name string + statusCode int + responseData interface{} + expectedErr string // Empty string means that no error is expected + expectedPermanent bool + }{ + { + name: "add user normally", + statusCode: 200, + responseData: "", + expectedErr: "", + expectedPermanent: false, + }, + // The following test cases are for testing non-200 response code from server + { + name: "user doesn't exist 400", + statusCode: 400, + responseData: "", + expectedErr: "user doesn't exist", + expectedPermanent: true, + }, + { + name: "user doesn't exist 404", + statusCode: 404, + responseData: "", + expectedErr: "user doesn't exist", + expectedPermanent: true, + }, + { + name: "return error got from error field within response", + statusCode: 500, + responseData: map[string]string{"error": "something is wrong"}, + expectedErr: "something is wrong", + expectedPermanent: false, + }, + { + name: "return error got from error_message field within response", + statusCode: 500, + responseData: map[string]string{"error_message": "something is wrong"}, + expectedErr: "something is wrong", + expectedPermanent: false, + }, + + { + name: "server responds an invalid JSON string", + statusCode: 500, + responseData: "{\"name: \"info}", + expectedErr: "failed to unmarshal response", + expectedPermanent: false, + }, + { + name: "stop if http request fails", + expectedErr: "failed to Do request:", + expectedPermanent: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer gock.Off() + + req := gock.New(testQuayApiUrl). + Put("/organization/org/team/teamname/members/user1") + req.Reply(tc.statusCode).JSON(tc.responseData) + + if tc.name == "stop if http request fails" { + req.AddMatcher(gock.MatchPath).Put("another-path") + } + + quayClient := NewQuayClient(client, "authtoken", testQuayApiUrl) + permanent, err := quayClient.AddUserToTeam("org", "teamname", "user1") + + assert.DeepEqual(t, tc.expectedPermanent, permanent) + + if tc.expectedErr == "" { + assert.NilError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectedErr) + } + }) + } +} + +func TestQuayClient_RemoveUserFromTeam(t *testing.T) { + client := &http.Client{Transport: &http.Transport{}} + gock.InterceptClient(client) + + testCases := []struct { + name string + expectedErr string + statusCode int + response interface{} + }{ + { + name: "Delete user from team", + expectedErr: "", + statusCode: 204, + response: nil, + }, + { + name: "Unauthorized access", + expectedErr: "Unauthorized", + statusCode: 403, + response: responseUnauthorized, + }, + { + name: "user isn't anymore in the team 400", + expectedErr: "", + statusCode: 400, + response: "", + }, + { + name: "user doesn't exist 404", + expectedErr: "", + statusCode: 404, + response: nil, + }, + { + name: "server responds error in error field", + expectedErr: "something is wrong in the server", + statusCode: 500, // can be any status code except 204 and 404 + response: "{\"error\": \"something is wrong in the server\"}", + }, + { + name: "return error got from error_message field within response", + expectedErr: "something is wrong", + statusCode: 500, + response: "{\"error_message\": \"something is wrong\"}", + }, + { + name: "stop if http request fails", + expectedErr: "failed to Do request:", + }, + { + name: "server responds an invalid JSON string", + expectedErr: "failed to unmarshal response body", + response: "{\"error\": \"something is wrong}", + }, + } + + 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"). + Delete(fmt.Sprintf("organization/%s/team/%s/members/%s", org, "teamname", "user1")) + req.Reply(tc.statusCode).JSON(tc.response) + + if tc.name == "stop if http request fails" { + req.AddMatcher(gock.MatchPath).Delete("another-path") + } + + quayClient := NewQuayClient(client, "authtoken", testQuayApiUrl) + err := quayClient.RemoveUserFromTeam(org, "teamname", "user1") + if tc.expectedErr == "" { + assert.NilError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectedErr) + } + }) + } +} diff --git a/pkg/quay/test_quay_client.go b/pkg/quay/test_quay_client.go index 201ff39..7945d1a 100644 --- a/pkg/quay/test_quay_client.go +++ b/pkg/quay/test_quay_client.go @@ -30,19 +30,26 @@ type TestQuayClient struct{} var _ QuayService = (*TestQuayClient)(nil) var ( - CreateRepositoryFunc func(repository RepositoryRequest) (*Repository, error) - DeleteRepositoryFunc func(organization, imageRepository string) (bool, error) - ChangeRepositoryVisibilityFunc func(organization, imageRepository string, visibility string) error - GetRobotAccountFunc func(organization string, robotName string) (*RobotAccount, error) - CreateRobotAccountFunc func(organization string, robotName string) (*RobotAccount, error) - DeleteRobotAccountFunc func(organization string, robotName string) (bool, error) - AddPermissionsForRepositoryToAccountFunc func(organization, imageRepository, accountName string, isRobot, isWrite bool) error - ListPermissionsForRepositoryFunc func(organization, imageRepository string) (map[string]UserAccount, 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) - UpdateNotificationFunc func(organization, repository string, notificationUuid string, notification Notification) (*Notification, error) - DeleteNotificationFunc func(organization, repository string, notificationUuid string) (bool, error) + CreateRepositoryFunc func(repository RepositoryRequest) (*Repository, error) + DeleteRepositoryFunc func(organization, imageRepository string) (bool, error) + ChangeRepositoryVisibilityFunc func(organization, imageRepository string, visibility string) error + GetRobotAccountFunc func(organization string, robotName string) (*RobotAccount, error) + CreateRobotAccountFunc func(organization string, robotName string) (*RobotAccount, error) + DeleteRobotAccountFunc func(organization string, robotName string) (bool, error) + AddPermissionsForRepositoryToAccountFunc func(organization, imageRepository, accountName string, isRobot, isWrite bool) error + ListPermissionsForRepositoryFunc func(organization, imageRepository string) (map[string]UserAccount, error) + AddReadPermissionsForRepositoryToTeamFunc func(organization, imageRepository, teamName string) error + ListRepositoryPermissionsForTeamFunc func(organization, teamName string) ([]TeamPermission, error) + AddUserToTeamFunc func(organization, teamName, userName string) (bool, error) + RemoveUserFromTeamFunc func(organization, teamName, userName string) error + DeleteTeamFunc func(organization, teamName string) error + EnsureTeamFunc func(organization, teamName string) ([]Member, error) + GetTeamMembersFunc func(organization, teamName string) ([]Member, 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) + UpdateNotificationFunc func(organization, repository string, notificationUuid string, notification Notification) (*Notification, error) + DeleteNotificationFunc func(organization, repository string, notificationUuid string) (bool, error) ) func ResetTestQuayClient() { @@ -54,6 +61,13 @@ func ResetTestQuayClient() { DeleteRobotAccountFunc = func(organization, robotName string) (bool, error) { return true, nil } AddPermissionsForRepositoryToAccountFunc = func(organization, imageRepository, accountName string, isRobot, isWrite bool) error { return nil } ListPermissionsForRepositoryFunc = func(organization, imageRepository string) (map[string]UserAccount, error) { return nil, nil } + AddReadPermissionsForRepositoryToTeamFunc = func(organization, imageRepository, teamName string) error { return nil } + ListRepositoryPermissionsForTeamFunc = func(organization, teamName string) ([]TeamPermission, error) { return []TeamPermission{}, nil } + AddUserToTeamFunc = func(organization, teamName, userName string) (bool, error) { return false, nil } + RemoveUserFromTeamFunc = func(organization, teamName, userName string) error { return nil } + DeleteTeamFunc = func(organization, teamName string) error { return nil } + EnsureTeamFunc = func(organization, teamName string) ([]Member, error) { return []Member{}, nil } + GetTeamMembersFunc = func(organization, teamName string) ([]Member, error) { return []Member{}, 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) { @@ -108,6 +122,41 @@ func ResetTestQuayClientToFails() { Fail("ListPermissionsForRepository invoked") return nil, nil } + AddReadPermissionsForRepositoryToTeamFunc = func(organization, imageRepository, teamName string) error { + defer GinkgoRecover() + Fail("AddPermissionsForRepositoryToTeam invoked") + return nil + } + ListRepositoryPermissionsForTeamFunc = func(organization, teamName string) ([]TeamPermission, error) { + defer GinkgoRecover() + Fail("ListRepositoryPermissionsForTeam invoked") + return nil, nil + } + AddUserToTeamFunc = func(organization, teamName, userName string) (bool, error) { + defer GinkgoRecover() + Fail("AddUserToTeam invoked") + return false, nil + } + RemoveUserFromTeamFunc = func(organization, teamName, userName string) error { + defer GinkgoRecover() + Fail("RemoveUserFromTeam invoked") + return nil + } + DeleteTeamFunc = func(organization, teamName string) error { + defer GinkgoRecover() + Fail("DeleteTeam invoked") + return nil + } + EnsureTeamFunc = func(organization, teamName string) ([]Member, error) { + defer GinkgoRecover() + Fail("EnsureTeam invoked") + return nil, nil + } + GetTeamMembersFunc = func(organization, teamName string) ([]Member, error) { + defer GinkgoRecover() + Fail("GetTeamMembers invoked") + return nil, nil + } RegenerateRobotAccountTokenFunc = func(organization, robotName string) (*RobotAccount, error) { defer GinkgoRecover() Fail("RegenerateRobotAccountToken invoked") @@ -159,6 +208,27 @@ func (c TestQuayClient) AddPermissionsForRepositoryToAccount(organization, image func (c TestQuayClient) ListPermissionsForRepository(organization, imageRepository string) (map[string]UserAccount, error) { return ListPermissionsForRepositoryFunc(organization, imageRepository) } +func (c TestQuayClient) AddReadPermissionsForRepositoryToTeam(organization, imageRepository, teamName string) error { + return AddReadPermissionsForRepositoryToTeamFunc(organization, imageRepository, teamName) +} +func (c TestQuayClient) ListRepositoryPermissionsForTeam(organization, teamName string) ([]TeamPermission, error) { + return ListRepositoryPermissionsForTeamFunc(organization, teamName) +} +func (c TestQuayClient) AddUserToTeam(organization, teamName, userName string) (bool, error) { + return AddUserToTeamFunc(organization, teamName, userName) +} +func (c TestQuayClient) RemoveUserFromTeam(organization, teamName, userName string) error { + return RemoveUserFromTeamFunc(organization, teamName, userName) +} +func (c TestQuayClient) DeleteTeam(organization, teamName string) error { + return DeleteTeamFunc(organization, teamName) +} +func (c TestQuayClient) EnsureTeam(organization, teamName string) ([]Member, error) { + return EnsureTeamFunc(organization, teamName) +} +func (c TestQuayClient) GetTeamMembers(organization, teamName string) ([]Member, error) { + return GetTeamMembersFunc(organization, teamName) +} func (c TestQuayClient) RegenerateRobotAccountToken(organization string, robotName string) (*RobotAccount, error) { return RegenerateRobotAccountTokenFunc(organization, robotName) }