diff --git a/controllers/component_image_controller.go b/controllers/component_image_controller.go index 65da830..f927b01 100644 --- a/controllers/component_image_controller.go +++ b/controllers/component_image_controller.go @@ -18,13 +18,10 @@ package controllers import ( "context" - "encoding/base64" "encoding/json" "fmt" - "strings" "time" - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -35,8 +32,8 @@ import ( ctrllog "sigs.k8s.io/controller-runtime/pkg/log" "github.com/go-logr/logr" + 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/metrics" "github.com/konflux-ci/image-controller/pkg/quay" appstudioredhatcomv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" ) @@ -59,10 +56,6 @@ type GenerateRepositoryOpts struct { // ImageRepositoryStatus defines the structure of the Repository information being exposed to external systems. type ImageRepositoryStatus struct { - Image string `json:"image,omitempty"` - Visibility string `json:"visibility,omitempty"` - Secret string `json:"secret,omitempty"` - Message string `json:"message,omitempty"` } @@ -90,7 +83,6 @@ func (r *ComponentReconciler) SetupWithManager(mgr ctrl.Manager) error { func (r *ComponentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := ctrllog.FromContext(ctx).WithName("ComponentImageRepository") ctx = ctrllog.IntoContext(ctx, log) - reconcileStartTime := time.Now() // Fetch the Component instance component := &appstudioredhatcomv1alpha1.Component{} @@ -106,60 +98,19 @@ func (r *ComponentReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, fmt.Errorf("error reading component: %w", err) } - componentIdForMetrics := getComponentIdForMetrics(component) - if !component.ObjectMeta.DeletionTimestamp.IsZero() { - // remove component from metrics map - delete(metrics.RepositoryTimesForMetrics, componentIdForMetrics) - if controllerutil.ContainsFinalizer(component, ImageRepositoryComponentFinalizer) { - pushRobotAccountName, pullRobotAccountName := generateRobotAccountsNames(component) - - quayClient := r.BuildQuayClient(log) - isPushRobotAccountDeleted, err := quayClient.DeleteRobotAccount(r.QuayOrganization, pushRobotAccountName) - if err != nil { - log.Error(err, "failed to delete push robot account", l.Action, l.ActionDelete, l.Audit, "true") - // Do not block Component deletion if failed to delete robot account - } - if isPushRobotAccountDeleted { - log.Info(fmt.Sprintf("Deleted push robot account %s", pushRobotAccountName), l.Action, l.ActionDelete) - } - - isPullRobotAccountDeleted, err := quayClient.DeleteRobotAccount(r.QuayOrganization, pullRobotAccountName) - if err != nil { - log.Error(err, "failed to delete pull robot account", l.Action, l.ActionDelete, l.Audit, "true") - // Do not block Component deletion if failed to delete robot account - } - if isPullRobotAccountDeleted { - log.Info(fmt.Sprintf("Deleted pull robot account %s", pullRobotAccountName), l.Action, l.ActionDelete) - } - - imageRepo := generateRepositoryName(component) - isRepoDeleted, err := quayClient.DeleteRepository(r.QuayOrganization, imageRepo) - if err != nil { - log.Error(err, "failed to delete image repository", l.Action, l.ActionDelete, l.Audit, "true") - // Do not block Component deletion if failed to delete image repository - } - if isRepoDeleted { - log.Info(fmt.Sprintf("Deleted image repository %s", imageRepo), l.Action, l.ActionDelete) - } - - if err := r.Client.Get(ctx, req.NamespacedName, component); err != nil { - log.Error(err, "failed to get Component", l.Action, l.ActionView) - return ctrl.Result{}, err - } controllerutil.RemoveFinalizer(component, ImageRepositoryComponentFinalizer) if err := r.Client.Update(ctx, component); err != nil { - log.Error(err, "failed to remove image repository finalizer", l.Action, l.ActionUpdate) + log.Error(err, "failed to remove image repository finalizer", l.Action, l.ActionUpdate, "componentName", component.Name) return ctrl.Result{}, err } - log.Info("Image repository finalizer removed from the Component", l.Action, l.ActionDelete) + log.Info("Image repository finalizer removed from the Component", l.Action, l.ActionDelete, "componentName", component.Name) r.waitComponentUpdateInCache(ctx, req.NamespacedName, func(component *appstudioredhatcomv1alpha1.Component) bool { return !controllerutil.ContainsFinalizer(component, ImageRepositoryComponentFinalizer) }) } - return ctrl.Result{}, nil } @@ -187,122 +138,66 @@ func (r *ComponentReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, r.reportError(ctx, component, message) } - setMetricsTime(componentIdForMetrics, reconcileStartTime) - - imageRepositoryExists := false - repositoryInfo := ImageRepositoryStatus{} - repositoryInfoStr, imageAnnotationExist := component.Annotations[ImageAnnotationName] - if imageAnnotationExist { - if err := json.Unmarshal([]byte(repositoryInfoStr), &repositoryInfo); err == nil { - imageRepositoryExists = repositoryInfo.Image != "" && repositoryInfo.Secret != "" - repositoryInfo.Message = "" - } else { - // Image repository info annotation contains invalid JSON. - // This means that the annotation was edited manually. - repositoryInfo.Message = "Invalid image status annotation" - } + // Search if imageRepository for the component exists already + imageRepositoriesList := &imagerepositoryv1alpha1.ImageRepositoryList{} + if err := r.Client.List(ctx, imageRepositoriesList, &client.ListOptions{Namespace: component.Namespace}); err != nil { + log.Error(err, "failed to list image repositories") + return ctrl.Result{}, err } - // Do something only if no error has been detected before - if repositoryInfo.Message == "" { - if imageRepositoryExists { - // Check if need to change image repository visibility - if repositoryInfo.Visibility != requestRepositoryOpts.Visibility { - // quay.io/org/reposito/ryName - imageUrlParts := strings.SplitN(repositoryInfo.Image, "/", 3) - if len(imageUrlParts) > 2 { - repositoryName := imageUrlParts[2] - quayClient := r.BuildQuayClient(log) - if err := quayClient.ChangeRepositoryVisibility(r.QuayOrganization, repositoryName, requestRepositoryOpts.Visibility); err == nil { - repositoryInfo.Visibility = requestRepositoryOpts.Visibility - } else { - if err.Error() == "payment required" { - log.Info("failed to make image repository private due to quay plan limit", l.Audit, "true") - repositoryInfo.Message = "Quay organization plan doesn't allow private image repositories" - } else { - log.Error(err, "failed to change image repository visibility") - return ctrl.Result{}, err - } - } - } else { - repositoryInfo.Message = "Invalid image url" - } - } - } else { - // Image repository doesn't exist, create it. - quayClient := r.BuildQuayClient(log) - repo, pushRobotAccount, pullRobotAccount, err := r.generateImageRepository(ctx, quayClient, component, requestRepositoryOpts) - if err != nil { - if err.Error() == "payment required" { - log.Info("failed to create private image repository due to quay plan limit", l.Audit, "true") - repositoryInfo.Message = "Quay organization plan doesn't allow private image repositories" - } else { - log.Error(err, "Error in the repository generation process", l.Audit, "true") - return ctrl.Result{}, r.reportError(ctx, component, "failed to generate image repository") - } - } else { - if repo == nil || pushRobotAccount == nil || pullRobotAccount == nil { - log.Error(nil, "Unknown error in the repository generation process", l.Audit, "true") - return ctrl.Result{}, r.reportError(ctx, component, "failed to generate image repository: unknown error") - } - log.Info(fmt.Sprintf("Prepared image repository %s for Component", repo.Name), l.Action, l.ActionAdd) - - imageURL := fmt.Sprintf("quay.io/%s/%s", r.QuayOrganization, repo.Name) - - // Create secrets with the repository credentials - pushSecretName := component.Name - _, err := r.ensureRobotAccountSecret(ctx, component, pushRobotAccount, pushSecretName, imageURL) - if err != nil { - return ctrl.Result{}, err - } - log.Info(fmt.Sprintf("Prepared image registry push secret %s for Component", pushRobotAccount.Name), l.Action, l.ActionUpdate) - - // Propagate the pull secret into all environments - pullSecretName := pushSecretName + "-pull" - if err := r.ensureComponentPullSecret(ctx, component, pullSecretName, pullRobotAccount, imageURL); err != nil { - return ctrl.Result{}, err - } - log.Info(fmt.Sprintf("Prepared remote secret %s for Component", pullSecretName), l.Action, l.ActionUpdate) - - // Prepare data to update the component with - repositoryInfo = ImageRepositoryStatus{ - Image: imageURL, - Visibility: requestRepositoryOpts.Visibility, - Secret: pushSecretName, - } + imageRepositoryFound := "" + for _, imageRepository := range imageRepositoriesList.Items { + for _, owner := range imageRepository.ObjectMeta.OwnerReferences { + if owner.UID == component.UID { + imageRepositoryFound = imageRepository.Name + break } } } - repositoryInfoBytes, _ := json.Marshal(repositoryInfo) - // Update component with the generated data and add finalizer - err = r.Client.Get(ctx, req.NamespacedName, component) - if err != nil { - return ctrl.Result{}, fmt.Errorf("error reading component: %w", err) - } - if component.ObjectMeta.DeletionTimestamp.IsZero() { - component.Annotations[ImageAnnotationName] = string(repositoryInfoBytes) - delete(component.Annotations, GenerateImageAnnotationName) + if imageRepositoryFound == "" { + imageRepositoryName := fmt.Sprintf("imagerepository-for-%s-%s", component.Spec.Application, component.Name) + log.Info("Will create image repository", "ImageRepositoryName", imageRepositoryName, "ComponentName", component.Name) - if repositoryInfo.Image != "" && !controllerutil.ContainsFinalizer(component, ImageRepositoryComponentFinalizer) { - controllerutil.AddFinalizer(component, ImageRepositoryComponentFinalizer) - log.Info("Image repository finalizer added to the Component update", l.Action, l.ActionUpdate) + imageRepository := &imagerepositoryv1alpha1.ImageRepository{ + TypeMeta: metav1.TypeMeta{ + Kind: "ImageRepository", + APIVersion: "pipelinesascode.tekton.dev/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: imageRepositoryName, + Namespace: component.Namespace, + Labels: map[string]string{ + ApplicationNameLabelName: component.Spec.Application, + ComponentNameLabelName: component.Name, + }, + Annotations: map[string]string{ + updateComponentAnnotationName: "true", + }, + }, + Spec: imagerepositoryv1alpha1.ImageRepositorySpec{ + Image: imagerepositoryv1alpha1.ImageParameters{ + Visibility: imagerepositoryv1alpha1.ImageVisibility(requestRepositoryOpts.Visibility), + }, + }, } - if err := r.Client.Update(ctx, component); err != nil { - return ctrl.Result{}, fmt.Errorf("error updating the component: %w", err) + if err := r.Client.Create(ctx, imageRepository); err != nil { + log.Error(err, "failed to create image repository", "ImageRepositoryName", imageRepositoryName, "ComponentName", component.Name) + return ctrl.Result{}, err } - log.Info("Component updated successfully", l.Action, l.ActionUpdate) - - r.waitComponentUpdateInCache(ctx, req.NamespacedName, func(component *appstudioredhatcomv1alpha1.Component) bool { - _, exists := component.Annotations[GenerateImageAnnotationName] - return !exists - }) + log.Info("Image repository created", "ImageRepositoryName", imageRepositoryName, "ComponentName", component.Name) + } else { + log.Info("Image repository already exists", "ImageRepositoryName", imageRepositoryFound, "ComponentName", component.Name) } - metrics.ImageRepositoryProvisionTimeMetric.Observe(time.Since(metrics.RepositoryTimesForMetrics[componentIdForMetrics]).Seconds()) - // remove component from metrics map - delete(metrics.RepositoryTimesForMetrics, componentIdForMetrics) + delete(component.Annotations, GenerateImageAnnotationName) + + if err := r.Client.Update(ctx, component); err != nil { + log.Error(err, "failed to update Component after 'generate' annotation removal", "ComponentName", component.Name) + return ctrl.Result{}, fmt.Errorf("error updating the component: %w", err) + } + log.Info("Component updated successfully, 'generate' annotation removed", "ComponentName", component.Name) return ctrl.Result{}, nil } @@ -316,10 +211,6 @@ func (r *ComponentReconciler) reportError(ctx context.Context, component *appstu component.Annotations[ImageAnnotationName] = string(messageBytes) delete(component.Annotations, GenerateImageAnnotationName) - componentIdForMetrics := getComponentIdForMetrics(component) - // remove component from metrics map, permanent error - delete(metrics.RepositoryTimesForMetrics, componentIdForMetrics) - return r.Client.Update(ctx, component) } @@ -357,166 +248,3 @@ func (r *ComponentReconciler) waitComponentUpdateInCache(ctx context.Context, co log.Info("failed to wait for updated cache. Requested action could be repeated.", l.Audit, "true") } } - -// ensureRobotAccountSecret creates or updates robot account secret. -// Returns secret string data. -func (r *ComponentReconciler) ensureRobotAccountSecret(ctx context.Context, component *appstudioredhatcomv1alpha1.Component, robotAccount *quay.RobotAccount, secretName, imageURL string) (map[string]string, error) { - log := ctrllog.FromContext(ctx) - - robotAccountSecret := generateSecret(component, robotAccount, secretName, imageURL) - secretData := robotAccountSecret.StringData - - robotAccountSecretKey := types.NamespacedName{Namespace: robotAccountSecret.Namespace, Name: robotAccountSecret.Name} - existingRobotAccountSecret := &corev1.Secret{} - if err := r.Client.Get(ctx, robotAccountSecretKey, existingRobotAccountSecret); err == nil { - existingRobotAccountSecret.StringData = secretData - if err := r.Client.Update(ctx, existingRobotAccountSecret); err != nil { - log.Error(err, fmt.Sprintf("failed to update robot account secret %v", robotAccountSecretKey), l.Action, l.ActionUpdate) - return nil, err - } - } else { - if !errors.IsNotFound(err) { - log.Error(err, fmt.Sprintf("failed to read robot account secret %v", robotAccountSecretKey), l.Action, l.ActionView) - return nil, err - } - if err := r.Client.Create(ctx, robotAccountSecret); err != nil { - log.Error(err, fmt.Sprintf("error writing robot account token into Secret: %v", robotAccountSecretKey), l.Action, l.ActionAdd) - return nil, err - } - } - - return secretData, nil -} - -// ensureComponentPullSecret creates secret for component image repository pull token. -func (r *ComponentReconciler) ensureComponentPullSecret(ctx context.Context, component *appstudioredhatcomv1alpha1.Component, secretName string, robotAccount *quay.RobotAccount, imageURL string) error { - log := ctrllog.FromContext(ctx) - - pullSecret := &corev1.Secret{} - pullSecretKey := types.NamespacedName{Namespace: component.Namespace, Name: secretName} - if err := r.Client.Get(ctx, pullSecretKey, pullSecret); err != nil { - if !errors.IsNotFound(err) { - log.Error(err, fmt.Sprintf("failed to get pull secret: %v", pullSecretKey), l.Action, l.ActionView) - return err - } - - pullSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: component.Namespace, - Labels: map[string]string{ - InternalSecretLabelName: "true", - }, - }, - Type: corev1.SecretTypeDockerConfigJson, - StringData: generateDockerconfigSecretData(imageURL, robotAccount), - } - - if err := controllerutil.SetOwnerReference(component, pullSecret, r.Scheme); err != nil { - log.Error(err, "failed to set owner for pull secret") - return err - } - - if err := r.Client.Create(ctx, pullSecret); err != nil { - log.Error(err, fmt.Sprintf("failed to create pull secret: %v", pullSecretKey), l.Action, l.ActionAdd, l.Audit, "true") - return err - } - - } - return nil -} - -// generateRobotAccountsNames returns push and pull robot account names for the given Component -func generateRobotAccountsNames(component *appstudioredhatcomv1alpha1.Component) (string, string) { - pushRobotAccountName := component.Namespace + component.Spec.Application + component.Name - pullRobotAccountName := pushRobotAccountName + "-pull" - return pushRobotAccountName, pullRobotAccountName -} - -func generateRepositoryName(component *appstudioredhatcomv1alpha1.Component) string { - return component.Namespace + "/" + component.Spec.Application + "/" + component.Name -} - -func (r *ComponentReconciler) generateImageRepository( - ctx context.Context, - quayClient quay.QuayService, - component *appstudioredhatcomv1alpha1.Component, - opts *GenerateRepositoryOpts, -) ( - *quay.Repository, - *quay.RobotAccount, - *quay.RobotAccount, - error, -) { - log := ctrllog.FromContext(ctx) - - imageRepositoryName := generateRepositoryName(component) - repo, err := quayClient.CreateRepository(quay.RepositoryRequest{ - Namespace: r.QuayOrganization, - Visibility: opts.Visibility, - Description: "AppStudio repository for the user", - Repository: imageRepositoryName, - }) - if err != nil { - log.Error(err, fmt.Sprintf("failed to create image repository %s", imageRepositoryName), l.Action, l.ActionAdd, l.Audit, "true") - return nil, nil, nil, err - } - - pushRobotAccountName, pullRobotAccountName := generateRobotAccountsNames(component) - - pushRobotAccount, err := quayClient.CreateRobotAccount(r.QuayOrganization, pushRobotAccountName) - if err != nil { - log.Error(err, fmt.Sprintf("failed to create robot account %s", pushRobotAccountName), l.Action, l.ActionAdd, l.Audit, "true") - return nil, nil, nil, err - } - err = quayClient.AddPermissionsForRepositoryToRobotAccount(r.QuayOrganization, repo.Name, pushRobotAccount.Name, true) - if err != nil { - log.Error(err, fmt.Sprintf("failed to add permissions to robot account %s", pushRobotAccount.Name), l.Action, l.ActionUpdate, l.Audit, "true") - return nil, nil, nil, err - } - - pullRobotAccount, err := quayClient.CreateRobotAccount(r.QuayOrganization, pullRobotAccountName) - if err != nil { - log.Error(err, fmt.Sprintf("failed to create robot account %s", pullRobotAccountName), l.Action, l.ActionAdd, l.Audit, "true") - return nil, nil, nil, err - } - err = quayClient.AddPermissionsForRepositoryToRobotAccount(r.QuayOrganization, repo.Name, pullRobotAccount.Name, false) - if err != nil { - log.Error(err, fmt.Sprintf("failed to add permissions to robot account %s", pullRobotAccount.Name), l.Action, l.ActionUpdate, l.Audit, "true") - return nil, nil, nil, err - } - - return repo, pushRobotAccount, pullRobotAccount, nil -} - -// generateSecret dumps the robot account token into a Secret for future consumption. -func generateSecret(c *appstudioredhatcomv1alpha1.Component, robotAccount *quay.RobotAccount, secretName, quayImageURL string) *corev1.Secret { - return &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: c.Namespace, - OwnerReferences: []metav1.OwnerReference{ - { - Name: c.Name, - APIVersion: c.APIVersion, - Kind: c.Kind, - UID: c.UID, - }, - }, - }, - Type: corev1.SecretTypeDockerConfigJson, - StringData: generateDockerconfigSecretData(quayImageURL, robotAccount), - } -} - -func generateDockerconfigSecretData(quayImageURL string, robotAccount *quay.RobotAccount) map[string]string { - secretData := map[string]string{} - authString := fmt.Sprintf("%s:%s", robotAccount.Name, robotAccount.Token) - secretData[corev1.DockerConfigJsonKey] = fmt.Sprintf(`{"auths":{"%s":{"auth":"%s"}}}`, - quayImageURL, base64.StdEncoding.EncodeToString([]byte(authString))) - return secretData -} - -func getComponentIdForMetrics(component *appstudioredhatcomv1alpha1.Component) string { - return component.Name + "=" + component.Namespace -} diff --git a/controllers/component_image_controller_remote_test.go b/controllers/component_image_controller_remote_test.go deleted file mode 100644 index 8c3a7fc..0000000 --- a/controllers/component_image_controller_remote_test.go +++ /dev/null @@ -1,77 +0,0 @@ -/* -Copyright 2023 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" - "net/http" - "os" - "testing" - - "github.com/go-logr/logr" - "github.com/h2non/gock" - "github.com/konflux-ci/image-controller/pkg/quay" - appstudioredhatcomv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestGenerateRemoteImage(t *testing.T) { - defer gock.Off() - defer gock.Observe(gock.DumpRequest) - - testComponent := appstudioredhatcomv1alpha1.Component{ - ObjectMeta: metav1.ObjectMeta{ - Name: "automation-repo", // required for repo name generation - Namespace: "shbose", // required for repo name generation - }, - Spec: appstudioredhatcomv1alpha1.ComponentSpec{ - Application: "applicationname", // required for repo name generation - }, - } - - expectedRepoName := testComponent.Namespace + "/" + testComponent.Spec.Application + "/" + testComponent.Name - expectedRobotAccountName := testComponent.Namespace + testComponent.Spec.Application + testComponent.Name - returnedRobotAccountName := "redhat-user-workloads" + "+" + expectedRobotAccountName - - client := &http.Client{Transport: &http.Transport{}} - - quayToken := os.Getenv("DEV_QUAY_TOKEN") - if quayToken == "" { - //skip test. - return - } - - quayClient := quay.NewQuayClient(client, quayToken, "https://quay.io/api/v1") - - r := ComponentReconciler{ - BuildQuayClient: func(logr.Logger) quay.QuayService { return quayClient }, - QuayOrganization: "redhat-user-workloads", - } - createdRepository, pushRobotAccount, pullRobotAccount, err := r.generateImageRepository(context.TODO(), quayClient, &testComponent, &GenerateRepositoryOpts{Visibility: "public"}) - - if err != nil { - t.Errorf("Error generating repository and setting up robot account, Expected nil, got %v", err) - } - if createdRepository.Name != expectedRepoName { - t.Errorf("Error creating repository, Expected %s, got %v", expectedRepoName, createdRepository.Name) - } - if pushRobotAccount.Name != returnedRobotAccountName { - t.Errorf("Error creating robot account, Expected %s, got %v", returnedRobotAccountName, pushRobotAccount.Name) - } - if pullRobotAccount.Name != returnedRobotAccountName+"-pull" { - t.Errorf("Error creating robot account, Expected %s, got %v", returnedRobotAccountName+"-pull", pullRobotAccount.Name) - } -} diff --git a/controllers/component_image_controller_test.go b/controllers/component_image_controller_test.go index ee2a1b9..b9789d3 100644 --- a/controllers/component_image_controller_test.go +++ b/controllers/component_image_controller_test.go @@ -16,665 +16,230 @@ limitations under the License. package controllers import ( - "encoding/base64" "encoding/json" "fmt" - "regexp" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + imagerepositoryv1alpha1 "github.com/konflux-ci/image-controller/api/v1alpha1" "github.com/konflux-ci/image-controller/pkg/quay" ) var _ = Describe("Component image controller", func() { + var imageTestNamespace = "component-image-controller-test" - var ( - authRegexp = regexp.MustCompile(`.*{"auth":"([A-Za-z0-9+/=]*)"}.*`) - - resourceKey = types.NamespacedName{Name: defaultComponentName, Namespace: defaultNamespace} - uploadSecretKey = types.NamespacedName{Name: "upload-secret-" + defaultComponentName + "-pull", Namespace: defaultNamespace} - - pushToken string - pullToken string - expectedPushRobotAccountName string - expectedPullRobotAccountName string - expectedPullSecretName string - expectedRepoName string - expectedImage string - ) + BeforeEach(func() { + createNamespace(imageTestNamespace) + }) Context("Image repository provision flow", func() { + var resourceImageProvisionKey = types.NamespacedName{Name: defaultComponentName + "-imageprovision", Namespace: imageTestNamespace} + var imageRepositoryName = types.NamespacedName{ + Name: fmt.Sprintf("imagerepository-for-%s-%s", defaultComponentApplication, resourceImageProvisionKey.Name), + Namespace: resourceImageProvisionKey.Namespace, + } - It("should prepare environment", func() { - createNamespace(defaultNamespace) - + BeforeEach(func() { quay.ResetTestQuayClient() + }) - pushToken = "push-token1234" - pullToken = "pull-token1234" - expectedPushRobotAccountName = fmt.Sprintf("%s%s%s", defaultNamespace, defaultComponentApplication, defaultComponentName) - expectedPullRobotAccountName = expectedPushRobotAccountName + "-pull" - expectedPullSecretName = resourceKey.Name + "-pull" - expectedRepoName = fmt.Sprintf("%s/%s/%s", defaultNamespace, defaultComponentApplication, defaultComponentName) - expectedImage = fmt.Sprintf("quay.io/%s/%s", quay.TestQuayOrg, expectedRepoName) + AfterEach(func() { + deleteComponent(resourceImageProvisionKey) }) - It("should do image repository provision", func() { - isCreateRepositoryInvoked := false - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - isCreateRepositoryInvoked = true - Expect(repository.Repository).To(Equal(expectedRepoName)) - Expect(repository.Namespace).To(Equal(quay.TestQuayOrg)) - Expect(repository.Visibility).To(Equal("private")) - Expect(repository.Description).ToNot(BeEmpty()) - return &quay.Repository{Name: expectedRepoName}, nil - } - isCreatePushRobotAccountInvoked := false - isCreatePullRobotAccountInvoked := false - quay.CreateRobotAccountFunc = func(organization, robotName string) (*quay.RobotAccount, error) { - defer GinkgoRecover() - Expect(organization).To(Equal(quay.TestQuayOrg)) - switch robotName { - case expectedPushRobotAccountName: - isCreatePushRobotAccountInvoked = true - return &quay.RobotAccount{ - Name: expectedPushRobotAccountName, - Token: pushToken, - }, nil - case expectedPullRobotAccountName: - isCreatePullRobotAccountInvoked = true - return &quay.RobotAccount{ - Name: expectedPullRobotAccountName, - Token: pullToken, - }, nil - } - Fail("Unexpected robot account name: " + robotName) - return nil, nil - } - isAddPushPermissionsToRobotAccountInvoked := false - isAddPullPermissionsToRobotAccountInvoked := false - quay.AddPermissionsForRepositoryToRobotAccountFunc = func(organization, imageRepository, robotAccountName string, isWrite bool) error { - defer GinkgoRecover() - Expect(organization).To(Equal(quay.TestQuayOrg)) - Expect(imageRepository).To(Equal(expectedRepoName)) - if isWrite { - isAddPushPermissionsToRobotAccountInvoked = true - Expect(robotAccountName).To(Equal(expectedPushRobotAccountName)) - } else { - isAddPullPermissionsToRobotAccountInvoked = true - Expect(robotAccountName).To(Equal(expectedPullRobotAccountName)) - } - return nil - } + It("should prepare environment", func() { + createServiceAccount(imageTestNamespace, buildPipelineServiceAccountName) + }) + It("should do image repository provision", func() { + expectedVisibility := imagerepositoryv1alpha1.ImageVisibility("private") createComponent(componentConfig{ - ComponentKey: resourceKey, + ComponentKey: resourceImageProvisionKey, Annotations: map[string]string{ GenerateImageAnnotationName: "{\"visibility\": \"private\"}", }, }) + // wait for component_image_controller to finish + waitComponentAnnotationGone(resourceImageProvisionKey, GenerateImageAnnotationName) - Eventually(func() bool { return isCreateRepositoryInvoked }, timeout, interval).Should(BeTrue()) - Eventually(func() bool { return isCreatePushRobotAccountInvoked }, timeout, interval).Should(BeTrue()) - 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()) - - waitImageRepositoryFinalizerOnComponent(resourceKey) - - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) - - repoImageInfo := &ImageRepositoryStatus{} - component := getComponent(resourceKey) - Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(BeEmpty()) - Expect(repoImageInfo.Image).To(Equal(expectedImage)) - Expect(repoImageInfo.Visibility).To(Equal("private")) - Expect(repoImageInfo.Secret).To(Equal(resourceKey.Name)) - - secret := &corev1.Secret{} - var authDataJson interface{} - - pushSecretKey := resourceKey - waitSecretExist(pushSecretKey) - Expect(k8sClient.Get(ctx, pushSecretKey, secret)).To(Succeed()) - pushDockerconfigJson := string(secret.Data[corev1.DockerConfigJsonKey]) - Expect(json.Unmarshal([]byte(pushDockerconfigJson), &authDataJson)).To(Succeed()) - Expect(pushDockerconfigJson).To(ContainSubstring(expectedImage)) - pushAuthString, err := base64.StdEncoding.DecodeString(authRegexp.FindStringSubmatch(pushDockerconfigJson)[1]) - Expect(err).To(Succeed()) - Expect(string(pushAuthString)).To(Equal(fmt.Sprintf("%s:%s", expectedPushRobotAccountName, pushToken))) - }) - - It("should propagate pull secret to environments", func() { - component := getComponent(resourceKey) + imageRepositoriesList := &imagerepositoryv1alpha1.ImageRepositoryList{} + Expect(k8sClient.List(ctx, imageRepositoriesList, &client.ListOptions{Namespace: resourceImageProvisionKey.Namespace})).To(Succeed()) + Expect(imageRepositoriesList.Items).To(HaveLen(1)) - pullSecretKey := types.NamespacedName{Name: expectedPullSecretName, Namespace: defaultNamespace} - pullSecret := waitSecretExist(pullSecretKey) - Expect(pullSecret.Labels[InternalSecretLabelName]).To(Equal("true")) - Expect(pullSecret.OwnerReferences).To(HaveLen(1)) - Expect(pullSecret.OwnerReferences[0].Name).To(Equal(component.Name)) - Expect(pullSecret.OwnerReferences[0].Kind).To(Equal("Component")) + component := getComponent(resourceImageProvisionKey) + // wait for imagerepository_controller to finish + waitImageRepositoryFinalizerOnImageRepository(imageRepositoryName) + imageRepository := getImageRepository(imageRepositoryName) - Expect(pullSecret.Name).To(Equal(pullSecretKey.Name)) - Expect(pullSecret.Type).To(Equal(corev1.SecretTypeDockerConfigJson)) + Expect(imageRepository.ObjectMeta.Labels[ApplicationNameLabelName]).To(Equal(component.Spec.Application)) + Expect(imageRepository.ObjectMeta.Labels[ComponentNameLabelName]).To(Equal(component.Name)) + Expect(imageRepository.Spec.Image.Visibility).To(Equal(expectedVisibility)) + Expect(imageRepository.ObjectMeta.OwnerReferences[0].UID).To(Equal(component.UID)) + Expect(imageRepository.ObjectMeta.Annotations[updateComponentAnnotationName]).To(BeEmpty()) - uploadSecretDockerconfigJson := string(pullSecret.Data[corev1.DockerConfigJsonKey]) - var authDataJson interface{} - Expect(json.Unmarshal([]byte(uploadSecretDockerconfigJson), &authDataJson)).To(Succeed()) - Expect(uploadSecretDockerconfigJson).To(ContainSubstring(expectedImage)) - uploadSecretAuthString, err := base64.StdEncoding.DecodeString(authRegexp.FindStringSubmatch(uploadSecretDockerconfigJson)[1]) - Expect(err).To(Succeed()) - Expect(string(uploadSecretAuthString)).To(Equal(fmt.Sprintf("%s:%s", expectedPullRobotAccountName, pullToken))) + component = getComponent(resourceImageProvisionKey) + Expect(component.Annotations[ImageAnnotationName]).To(BeEmpty()) + Expect(component.Spec.ContainerImage).ToNot(BeEmpty()) - deleteSecret(uploadSecretKey) + deleteImageRepository(imageRepositoryName) }) - It("should be able to switch image visibility", func() { - isChangeRepositoryVisibilityInvoked := false - quay.ChangeRepositoryVisibilityFunc = func(organization, imageRepository, visibility string) error { - isChangeRepositoryVisibilityInvoked = true - Expect(organization).To(Equal(quay.TestQuayOrg)) - Expect(imageRepository).To(Equal(expectedRepoName)) - Expect(visibility).To(Equal("public")) - return nil - } - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - defer GinkgoRecover() - Fail("Should not invoke repository creation on clean up") - return nil, nil - } - - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "public"}`) - - Eventually(func() bool { return isChangeRepositoryVisibilityInvoked }, timeout, interval).Should(BeTrue()) + It("should accept deprecated true value for repository options", func() { + expectedVisibility := imagerepositoryv1alpha1.ImageVisibility("public") + createComponent(componentConfig{ + ComponentKey: resourceImageProvisionKey, + Annotations: map[string]string{ + GenerateImageAnnotationName: "true", + }, + }) - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) + // wait for component_image_controller to finish + waitComponentAnnotationGone(resourceImageProvisionKey, GenerateImageAnnotationName) - repoImageInfo := &ImageRepositoryStatus{} - component := getComponent(resourceKey) - Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(BeEmpty()) - Expect(repoImageInfo.Image).To(Equal(expectedImage)) - Expect(repoImageInfo.Visibility).To(Equal("public")) - Expect(repoImageInfo.Secret).To(Equal(resourceKey.Name)) - }) + imageRepositoriesList := &imagerepositoryv1alpha1.ImageRepositoryList{} + Expect(k8sClient.List(ctx, imageRepositoriesList, &client.ListOptions{Namespace: resourceImageProvisionKey.Namespace})).To(Succeed()) + Expect(imageRepositoriesList.Items).To(HaveLen(1)) - It("should do nothing if the same as current visibility requested", func() { - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - defer GinkgoRecover() - Fail("Image repository creation should not be invoked") - return nil, nil - } - quay.ChangeRepositoryVisibilityFunc = func(organization, imageRepository, visibility string) error { - defer GinkgoRecover() - Fail("Image repository visibility changing should not be invoked") - return nil - } + component := getComponent(resourceImageProvisionKey) + // wait for imagerepository_controller to finish + waitImageRepositoryFinalizerOnImageRepository(imageRepositoryName) + imageRepository := getImageRepository(imageRepositoryName) - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "public"}`) + Expect(imageRepository.ObjectMeta.Labels[ApplicationNameLabelName]).To(Equal(component.Spec.Application)) + Expect(imageRepository.ObjectMeta.Labels[ComponentNameLabelName]).To(Equal(component.Name)) + Expect(imageRepository.Spec.Image.Visibility).To(Equal(expectedVisibility)) + Expect(imageRepository.ObjectMeta.OwnerReferences[0].UID).To(Equal(component.UID)) + Expect(imageRepository.ObjectMeta.Annotations[updateComponentAnnotationName]).To(BeEmpty()) - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) + component = getComponent(resourceImageProvisionKey) + Expect(component.Annotations[ImageAnnotationName]).To(BeEmpty()) + Expect(component.Spec.ContainerImage).ToNot(BeEmpty()) - repoImageInfo := &ImageRepositoryStatus{} - component := getComponent(resourceKey) - Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(BeEmpty()) - Expect(repoImageInfo.Image).To(Equal(expectedImage)) - Expect(repoImageInfo.Visibility).To(Equal("public")) - Expect(repoImageInfo.Secret).To(Equal(resourceKey.Name)) + deleteImageRepository(imageRepositoryName) }) - It("should delete robot account and image repository on component deletion", func() { - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - defer GinkgoRecover() - Fail("Should not invoke repository creation on clean up") - return nil, nil - } - quay.CreateRobotAccountFunc = func(organization, robotName string) (*quay.RobotAccount, error) { - defer GinkgoRecover() - Fail("Should not invoke robot account creation on clean up") - return nil, nil - } - quay.AddPermissionsForRepositoryToRobotAccountFunc = func(organization, imageRepository, robotAccountName string, isWrite bool) error { - defer GinkgoRecover() - Fail("Should not invoke permission adding on clean up") - return nil - } - - isDeletePushRobotAccountInvoked := false - isDeletePullRobotAccountInvoked := false - quay.DeleteRobotAccountFunc = func(organization, robotAccountName string) (bool, error) { - defer GinkgoRecover() - Expect(organization).To(Equal(quay.TestQuayOrg)) - switch robotAccountName { - case expectedPushRobotAccountName: - isDeletePushRobotAccountInvoked = true - return true, nil - case expectedPullRobotAccountName: - isDeletePullRobotAccountInvoked = true - return true, nil - } - Fail("Unexpected robot account name: " + robotAccountName) - return false, nil - } - isDeleteRepositoryInvoked := false - quay.DeleteRepositoryFunc = func(organization, imageRepository string) (bool, error) { - isDeleteRepositoryInvoked = true - Expect(organization).To(Equal(quay.TestQuayOrg)) - Expect(imageRepository).To(Equal(expectedRepoName)) - return true, nil - } - - deleteComponent(resourceKey) - - Eventually(func() bool { return isDeletePushRobotAccountInvoked }, timeout, interval).Should(BeTrue()) - Eventually(func() bool { return isDeletePullRobotAccountInvoked }, timeout, interval).Should(BeTrue()) - Eventually(func() bool { return isDeleteRepositoryInvoked }, timeout, interval).Should(BeTrue()) - }) }) Context("Image repository provision error cases", func() { + var resourceImageErrorKey = types.NamespacedName{Name: defaultComponentName + "-imageerrors", Namespace: imageTestNamespace} It("should prepare environment", func() { - createNamespace(defaultNamespace) - + deleteComponent(resourceImageErrorKey) quay.ResetTestQuayClient() - - deleteComponent(resourceKey) - - expectedImage = fmt.Sprintf("quay.io/%s/%s", quay.TestQuayOrg, expectedRepoName) }) It("should do nothing if generate annotation is not set", func() { - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - defer GinkgoRecover() - Fail("Image repository creation should not be invoked") - return nil, nil - } - - createComponent(componentConfig{ComponentKey: resourceKey}) + createComponent(componentConfig{ComponentKey: resourceImageErrorKey}) time.Sleep(ensureTimeout) - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotationGone(resourceKey, ImageAnnotationName) - }) - - It("should do nothing and set error if generate annotation is invalid JSON", func() { - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - defer GinkgoRecover() - Fail("Image repository creation should not be invoked") - return nil, nil - } - - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "public"`) - - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) + waitComponentAnnotationGone(resourceImageErrorKey, GenerateImageAnnotationName) + waitComponentAnnotationGone(resourceImageErrorKey, ImageAnnotationName) - repoImageInfo := &ImageRepositoryStatus{} - component := getComponent(resourceKey) - Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(ContainSubstring("invalid JSON")) - Expect(repoImageInfo.Image).To(BeEmpty()) - Expect(repoImageInfo.Visibility).To(BeEmpty()) - Expect(repoImageInfo.Secret).To(BeEmpty()) - - Expect(controllerutil.ContainsFinalizer(component, ImageRepositoryComponentFinalizer)).To(BeFalse()) + imageRepositoriesList := &imagerepositoryv1alpha1.ImageRepositoryList{} + Expect(k8sClient.List(ctx, imageRepositoriesList, &client.ListOptions{Namespace: resourceImageErrorKey.Namespace})).To(Succeed()) + Expect(imageRepositoriesList.Items).To(HaveLen(0)) }) - It("should do nothing and set error if generate annotation has invalid visibility value", func() { - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - defer GinkgoRecover() - Fail("Image repository creation should not be invoked") - return nil, nil + It("should do nothing if imageRepository for the component already exists, with expected name", func() { + component := getComponent(resourceImageErrorKey) + imageRepositoryName := fmt.Sprintf("imagerepository-for-%s-%s", component.Spec.Application, component.Name) + imageRepository := types.NamespacedName{Name: imageRepositoryName, Namespace: component.Namespace} + ownerReferences := []metav1.OwnerReference{ + {Kind: "Component", Name: component.Name, UID: types.UID(component.UID), APIVersion: "appstudio.redhat.com/v1alpha1"}, } - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "none"}`) - - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) - - repoImageInfo := &ImageRepositoryStatus{} - component := getComponent(resourceKey) - Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(ContainSubstring("invalid value: none in visibility field")) - Expect(repoImageInfo.Image).To(BeEmpty()) - Expect(repoImageInfo.Visibility).To(BeEmpty()) - Expect(repoImageInfo.Secret).To(BeEmpty()) - - Expect(controllerutil.ContainsFinalizer(component, ImageRepositoryComponentFinalizer)).To(BeFalse()) - }) - - It("should set error if quay organization plan doesn't allow private repositories", func() { - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - Expect(repository.Visibility).To(Equal("private")) - return nil, fmt.Errorf("payment required") - } - quay.ChangeRepositoryVisibilityFunc = func(organization, imageRepository, visibility string) error { - defer GinkgoRecover() - Fail("Image repository visibility change should not be invoked") - return nil - } - - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "private"}`) + createImageRepository(imageRepositoryConfig{ + ResourceKey: &imageRepository, + OwnerReferences: ownerReferences, + }) + // wait for imagerepository_controller to finish + waitImageRepositoryFinalizerOnImageRepository(imageRepository) + // add generate annotation and it will not create new ImageRepository + setComponentAnnotationValue(resourceImageErrorKey, GenerateImageAnnotationName, `{"visibility": "public"}`) + waitComponentAnnotationGone(resourceImageErrorKey, GenerateImageAnnotationName) - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) + component = getComponent(resourceImageErrorKey) + Expect(component.Annotations[ImageAnnotationName]).To(BeEmpty()) + // just to double check that new ImageRepository wasn't created, which would add ContainerImage + Expect(component.Spec.ContainerImage).To(BeEmpty()) - repoImageInfo := &ImageRepositoryStatus{} - component := getComponent(resourceKey) - Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(ContainSubstring("organization plan doesn't allow private image repositories")) - Expect(repoImageInfo.Image).To(BeEmpty()) - Expect(repoImageInfo.Visibility).To(BeEmpty()) - Expect(repoImageInfo.Secret).To(BeEmpty()) + imageRepositoriesList := &imagerepositoryv1alpha1.ImageRepositoryList{} + Expect(k8sClient.List(ctx, imageRepositoriesList, &client.ListOptions{Namespace: resourceImageErrorKey.Namespace})).To(Succeed()) + Expect(imageRepositoriesList.Items).To(HaveLen(1)) - Expect(controllerutil.ContainsFinalizer(component, ImageRepositoryComponentFinalizer)).To(BeFalse()) + deleteImageRepository(imageRepository) }) - It("should add message and stop if it's not possible to switch image repository visibility", func() { - isChangeRepositoryVisibilityInvoked := false - quay.ChangeRepositoryVisibilityFunc = func(organization, imageRepository, visibility string) error { - if isChangeRepositoryVisibilityInvoked { - defer GinkgoRecover() - Fail("Image repository visibility change should not be invoked second time") - } - isChangeRepositoryVisibilityInvoked = true - return fmt.Errorf("payment required") + It("should do nothing if imageRepository for the component already exists, with different name", func() { + component := getComponent(resourceImageErrorKey) + imageRepositoryName := fmt.Sprintf("differently-named-%s-%s", component.Spec.Application, component.Name) + imageRepository := types.NamespacedName{Name: imageRepositoryName, Namespace: component.Namespace} + ownerReferences := []metav1.OwnerReference{ + {Kind: "Component", Name: component.Name, UID: types.UID(component.UID), APIVersion: "appstudio.redhat.com/v1alpha1"}, } - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - defer GinkgoRecover() - Fail("Should not invoke repository creation") - return nil, nil - } - - repositoryInfo := ImageRepositoryStatus{ - Image: expectedImage, - Visibility: "public", - Secret: resourceKey.Name, - } - repositoryInfoJsonBytes, _ := json.Marshal(repositoryInfo) - setComponentAnnotationValue(resourceKey, ImageAnnotationName, string(repositoryInfoJsonBytes)) - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "private"}`) - - Eventually(func() bool { return isChangeRepositoryVisibilityInvoked }, timeout, interval).Should(BeTrue()) - - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) - repoImageInfo := &ImageRepositoryStatus{} - component := getComponent(resourceKey) - Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(ContainSubstring("organization plan doesn't allow private image repositories")) - Expect(repoImageInfo.Image).To(Equal(expectedImage)) - Expect(repoImageInfo.Visibility).To(Equal("public")) - Expect(repoImageInfo.Secret).To(Equal(resourceKey.Name)) - }) - - It("should stop and report error if image repository creation fails", func() { - isCreateRepositoryInvoked := false - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - isCreateRepositoryInvoked = true - return nil, fmt.Errorf("fail to marshal data") - } + createImageRepository(imageRepositoryConfig{ + ResourceKey: &imageRepository, + OwnerReferences: ownerReferences, + }) + // wait for imagerepository_controller to finish + waitImageRepositoryFinalizerOnImageRepository(imageRepository) + // add generate annotation and it will not create new ImageRepository + setComponentAnnotationValue(resourceImageErrorKey, GenerateImageAnnotationName, `{"visibility": "public"}`) + waitComponentAnnotationGone(resourceImageErrorKey, GenerateImageAnnotationName) - repositoryInfoJsonBytes, _ := json.Marshal(ImageRepositoryStatus{}) - setComponentAnnotationValue(resourceKey, ImageAnnotationName, string(repositoryInfoJsonBytes)) - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "public"}`) + component = getComponent(resourceImageErrorKey) + Expect(component.Annotations[ImageAnnotationName]).To(BeEmpty()) + // just to double check that new ImageRepository wasn't created, which would add ContainerImage + Expect(component.Spec.ContainerImage).To(BeEmpty()) - Eventually(func() bool { return isCreateRepositoryInvoked }, timeout, interval).Should(BeTrue()) + imageRepositoriesList := &imagerepositoryv1alpha1.ImageRepositoryList{} + Expect(k8sClient.List(ctx, imageRepositoriesList, &client.ListOptions{Namespace: resourceImageErrorKey.Namespace})).To(Succeed()) + Expect(imageRepositoriesList.Items).To(HaveLen(1)) - expectedValue, _ := json.Marshal(&ImageRepositoryStatus{Message: "failed to generate image repository"}) - waitComponentAnnotationWithValue(resourceKey, ImageAnnotationName, string(expectedValue)) + deleteImageRepository(imageRepository) }) - It("should do nothing and set error for changing visibility if image is invalid in image annotation", func() { - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - defer GinkgoRecover() - Fail("Image repository creation should not be invoked") - return nil, nil - } - - // An invalid image is set, which does not include registry. - setComponentAnnotationValue(resourceKey, ImageAnnotationName, `{"image": "ns/img:tag", "secret": "1234"}`) - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "private"}`) + It("should do nothing and set error if generate annotation is invalid JSON", func() { + setComponentAnnotationValue(resourceImageErrorKey, GenerateImageAnnotationName, `{"visibility": "public"`) - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) + waitComponentAnnotationGone(resourceImageErrorKey, GenerateImageAnnotationName) + waitComponentAnnotation(resourceImageErrorKey, ImageAnnotationName) repoImageInfo := &ImageRepositoryStatus{} - component := getComponent(resourceKey) + component := getComponent(resourceImageErrorKey) Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(Equal("Invalid image url")) - }) - - It("nothing is changed and keep doing reconcile if fail to change repository visibility", func() { - // Work with a specific component in order to avoid potential conflict error happened in any subsequent test. - testComponentKey := types.NamespacedName{ - Name: defaultComponentName + "-stop-if-fail-to-change-repo-visibility", - Namespace: defaultNamespace, - } - createComponent(componentConfig{ComponentKey: testComponentKey}) - - isChangeRepositoryVisibilityInvoked := false - quay.ChangeRepositoryVisibilityFunc = func(string, string, string) error { - isChangeRepositoryVisibilityInvoked = true - return fmt.Errorf("failed to change repository visibility") - } - - repoInfo := map[string]string{ - "name": "img", - "image": "registry/ns/img:0.1", - "secret": "1234", - "visibility": "public", - } - imageAnnotationValue, _ := json.Marshal(repoInfo) - setComponentAnnotationValue(testComponentKey, ImageAnnotationName, string(imageAnnotationValue)) - - // Start to change visibility to private - generateAnnotationValue := `{"visibility": "private"}` - setComponentAnnotationValue(testComponentKey, GenerateImageAnnotationName, generateAnnotationValue) - - Eventually(func() bool { return isChangeRepositoryVisibilityInvoked }, timeout, interval).Should(BeTrue()) - - // Failed to change the visibility, reconciler should return immediately and annotations are not changed - ensureComponentAnnotationUnchangedWithValue(testComponentKey, ImageAnnotationName, string(imageAnnotationValue)) - ensureComponentAnnotationUnchangedWithValue(testComponentKey, GenerateImageAnnotationName, generateAnnotationValue) + Expect(repoImageInfo.Message).To(ContainSubstring("invalid JSON")) - deleteComponent(testComponentKey) + imageRepositoriesList := &imagerepositoryv1alpha1.ImageRepositoryList{} + Expect(k8sClient.List(ctx, imageRepositoriesList, &client.ListOptions{Namespace: resourceImageErrorKey.Namespace})).To(Succeed()) + Expect(imageRepositoriesList.Items).To(HaveLen(0)) }) - It("should do nothing and set error if image annotation is invalid JSON", func() { - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - defer GinkgoRecover() - Fail("Image repository creation should not be invoked") - return nil, nil - } - - setComponentAnnotationValue(resourceKey, ImageAnnotationName, `{"image": "registry/ns/img:tag}`) - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "private"}`) + It("should do nothing and set error if generate annotation has invalid visibility value", func() { + setComponentAnnotationValue(resourceImageErrorKey, GenerateImageAnnotationName, `{"visibility": "none"}`) - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) + waitComponentAnnotationGone(resourceImageErrorKey, GenerateImageAnnotationName) + waitComponentAnnotation(resourceImageErrorKey, ImageAnnotationName) repoImageInfo := &ImageRepositoryStatus{} - component := getComponent(resourceKey) + component := getComponent(resourceImageErrorKey) Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(Equal("Invalid image status annotation")) - }) - - It("should not block component deletion if clean up fails", func() { - waitImageRepositoryFinalizerOnComponent(resourceKey) - - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - defer GinkgoRecover() - Fail("Should not invoke repository creation") - return nil, nil - } - quay.DeleteRepositoryFunc = func(organization, imageRepository string) (bool, error) { - return false, fmt.Errorf("failed to delete repository") - } - quay.DeleteRobotAccountFunc = func(organization, robotName string) (bool, error) { - return false, fmt.Errorf("failed to delete robot account") - } - - deleteComponent(resourceKey) - }) - }) - - Context("Image repository provision other cases", func() { - - _ = BeforeEach(func() { - createNamespace(defaultNamespace) - - quay.ResetTestQuayClient() - - deleteComponent(resourceKey) - deleteSecret(uploadSecretKey) - - pushToken = "push-token1234" - pullToken = "pull-token1234" - expectedPushRobotAccountName = fmt.Sprintf("%s%s%s", defaultNamespace, defaultComponentApplication, defaultComponentName) - expectedPullRobotAccountName = expectedPushRobotAccountName + "-pull" - expectedRepoName = fmt.Sprintf("%s/%s/%s", defaultNamespace, defaultComponentApplication, defaultComponentName) - expectedImage = fmt.Sprintf("quay.io/%s/%s", quay.TestQuayOrg, expectedRepoName) - }) - - _ = AfterEach(func() { - deleteComponent(resourceKey) - - deleteSecret(resourceKey) - }) - - It("should accept deprecated true value for repository options", func() { - isCreateRepositoryInvoked := false - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - isCreateRepositoryInvoked = true - return &quay.Repository{Name: "repo-name"}, nil - } - - createComponent(componentConfig{ - ComponentKey: resourceKey, - Annotations: map[string]string{ - GenerateImageAnnotationName: "true", - }, - }) - - Eventually(func() bool { return isCreateRepositoryInvoked }, timeout, interval).Should(BeTrue()) - - waitImageRepositoryFinalizerOnComponent(resourceKey) - - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) + Expect(repoImageInfo.Message).To(ContainSubstring("invalid value: none in visibility field")) - repoImageInfo := &ImageRepositoryStatus{} - component := getComponent(resourceKey) - Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(BeEmpty()) - Expect(repoImageInfo.Image).ToNot(BeEmpty()) - Expect(repoImageInfo.Visibility).To(Equal("public")) - Expect(repoImageInfo.Secret).ToNot(BeEmpty()) + imageRepositoriesList := &imagerepositoryv1alpha1.ImageRepositoryList{} + Expect(k8sClient.List(ctx, imageRepositoriesList, &client.ListOptions{Namespace: resourceImageErrorKey.Namespace})).To(Succeed()) + Expect(imageRepositoriesList.Items).To(HaveLen(0)) }) - It("should create pull robot account for existing image repositories with only push robot account and propagate it via remote secret", func() { - deleteSecret(types.NamespacedName{Name: expectedPullSecretName, Namespace: resourceKey.Namespace}) - - pullSecretKey := types.NamespacedName{Name: expectedPullSecretName, Namespace: defaultNamespace} - - isCreateRepositoryInvoked := false - quay.CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { - isCreateRepositoryInvoked = true - Expect(repository.Repository).To(Equal(expectedRepoName)) - Expect(repository.Namespace).To(Equal(quay.TestQuayOrg)) - Expect(repository.Visibility).To(Equal("public")) - Expect(repository.Description).ToNot(BeEmpty()) - return &quay.Repository{Name: expectedRepoName}, nil - } - isCreatePushRobotAccountInvoked := false - isCreatePullRobotAccountInvoked := false - quay.CreateRobotAccountFunc = func(organization, robotName string) (*quay.RobotAccount, error) { - defer GinkgoRecover() - Expect(organization).To(Equal(quay.TestQuayOrg)) - switch robotName { - case expectedPushRobotAccountName: - isCreatePushRobotAccountInvoked = true - return &quay.RobotAccount{ - Name: expectedPushRobotAccountName, - Token: pushToken, - }, nil - case expectedPullRobotAccountName: - isCreatePullRobotAccountInvoked = true - return &quay.RobotAccount{ - Name: expectedPullRobotAccountName, - Token: pullToken, - }, nil - } - Fail("Unexpected robot account name: " + robotName) - return nil, nil - } - isAddPushPermissionsToRobotAccountInvoked := false - isAddPullPermissionsToRobotAccountInvoked := false - quay.AddPermissionsForRepositoryToRobotAccountFunc = func(organization, imageRepository, robotAccountName string, isWrite bool) error { - defer GinkgoRecover() - Expect(organization).To(Equal(quay.TestQuayOrg)) - Expect(imageRepository).To(Equal(expectedRepoName)) - if isWrite { - isAddPushPermissionsToRobotAccountInvoked = true - Expect(robotAccountName).To(Equal(expectedPushRobotAccountName)) - } else { - isAddPullPermissionsToRobotAccountInvoked = true - Expect(robotAccountName).To(Equal(expectedPullRobotAccountName)) - } - return nil - } - - createComponent(componentConfig{ComponentKey: resourceKey}) - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "public"}`) - - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) - waitImageRepositoryFinalizerOnComponent(resourceKey) - deleteSecret(uploadSecretKey) - - setComponentAnnotationValue(resourceKey, GenerateImageAnnotationName, `{"visibility": "public"}`) - - Eventually(func() bool { return isCreateRepositoryInvoked }, timeout, interval).Should(BeTrue()) - Eventually(func() bool { return isCreatePushRobotAccountInvoked }, timeout, interval).Should(BeTrue()) - 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()) - - waitComponentAnnotationGone(resourceKey, GenerateImageAnnotationName) - waitComponentAnnotation(resourceKey, ImageAnnotationName) - - component := getComponent(resourceKey) - repoImageInfo := &ImageRepositoryStatus{} - Expect(json.Unmarshal([]byte(component.Annotations[ImageAnnotationName]), repoImageInfo)).To(Succeed()) - Expect(repoImageInfo.Message).To(BeEmpty()) - Expect(repoImageInfo.Image).To(Equal(expectedImage)) - Expect(repoImageInfo.Visibility).To(Equal("public")) - Expect(repoImageInfo.Secret).To(Equal(resourceKey.Name)) - - pullSecret := waitSecretExist(pullSecretKey) - Expect(pullSecret.Labels[InternalSecretLabelName]).To(Equal("true")) - Expect(pullSecret.OwnerReferences).To(HaveLen(1)) - Expect(pullSecret.OwnerReferences[0].Name).To(Equal(component.Name)) - Expect(pullSecret.OwnerReferences[0].Kind).To(Equal("Component")) - - Expect(pullSecret.Name).To(Equal(pullSecretKey.Name)) - Expect(pullSecret.Type).To(Equal(corev1.SecretTypeDockerConfigJson)) - Expect(pullSecret.Data).To(HaveKey(".dockerconfigjson")) + It("should clean environment", func() { + deleteComponent(resourceImageErrorKey) }) }) - }) diff --git a/controllers/imagerepository_controller.go b/controllers/imagerepository_controller.go index 51c8d23..a7418fe 100644 --- a/controllers/imagerepository_controller.go +++ b/controllers/imagerepository_controller.go @@ -19,6 +19,7 @@ package controllers import ( "context" "crypto/rand" + "encoding/base64" "encoding/hex" "fmt" "strings" @@ -662,3 +663,11 @@ func (r *ImageRepositoryReconciler) UpdateImageRepositoryStatusMessage(ctx conte return nil } + +func generateDockerconfigSecretData(quayImageURL string, robotAccount *quay.RobotAccount) map[string]string { + secretData := map[string]string{} + authString := fmt.Sprintf("%s:%s", robotAccount.Name, robotAccount.Token) + secretData[corev1.DockerConfigJsonKey] = fmt.Sprintf(`{"auths":{"%s":{"auth":"%s"}}}`, + quayImageURL, base64.StdEncoding.EncodeToString([]byte(authString))) + return secretData +} diff --git a/controllers/suite_util_test.go b/controllers/suite_util_test.go index 289cdb8..db271b9 100644 --- a/controllers/suite_util_test.go +++ b/controllers/suite_util_test.go @@ -52,12 +52,13 @@ const ( ) type imageRepositoryConfig struct { - ResourceKey *types.NamespacedName - ImageName string - Visibility string - Labels map[string]string - Annotations map[string]string - Notifications []imagerepositoryv1alpha1.Notifications + ResourceKey *types.NamespacedName + ImageName string + Visibility string + Labels map[string]string + Annotations map[string]string + Notifications []imagerepositoryv1alpha1.Notifications + OwnerReferences []metav1.OwnerReference } func getImageRepositoryConfig(config imageRepositoryConfig) *imagerepositoryv1alpha1.ImageRepository { @@ -80,10 +81,11 @@ func getImageRepositoryConfig(config imageRepositoryConfig) *imagerepositoryv1al return &imagerepositoryv1alpha1.ImageRepository{ ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - Labels: config.Labels, - Annotations: annotations, + Name: name, + Namespace: namespace, + Labels: config.Labels, + Annotations: annotations, + OwnerReferences: config.OwnerReferences, }, Spec: imagerepositoryv1alpha1.ImageRepositorySpec{ Image: imagerepositoryv1alpha1.ImageParameters{ @@ -222,39 +224,6 @@ func waitComponentAnnotation(componentKey types.NamespacedName, annotationName s }, timeout, interval).Should(BeTrue()) } -// waitComponentAnnotationWithValue waits for a component have had an annotation with a specific value. -func waitComponentAnnotationWithValue(componentKey types.NamespacedName, annotationName, value string) { - Eventually(func() bool { - component := getComponent(componentKey) - annotations := component.GetAnnotations() - if annotations == nil { - return false - } - val, exists := annotations[annotationName] - if exists { - return val == value - } else { - return false - } - }, timeout, interval).Should(BeTrue()) -} - -func ensureComponentAnnotationUnchangedWithValue(componentKey types.NamespacedName, annotationName, value string) { - Consistently(func() bool { - component := getComponent(componentKey) - annotations := component.GetAnnotations() - if annotations == nil { - return false - } - val, exists := annotations[annotationName] - if exists { - return val == value - } else { - return false - } - }, timeout, interval).Should(BeTrue()) -} - func waitComponentAnnotationGone(componentKey types.NamespacedName, annotationName string) { Eventually(func() bool { component := getComponent(componentKey) @@ -267,25 +236,6 @@ func waitComponentAnnotationGone(componentKey types.NamespacedName, annotationNa }, timeout, interval).Should(BeTrue()) } -func waitFinalizerOnComponent(componentKey types.NamespacedName, finalizerName string, finalizerShouldBePresent bool) { - component := &appstudioapiv1alpha1.Component{} - Eventually(func() bool { - if err := k8sClient.Get(ctx, componentKey, component); err != nil { - return false - } - - if finalizerShouldBePresent { - return controllerutil.ContainsFinalizer(component, finalizerName) - } else { - return !controllerutil.ContainsFinalizer(component, finalizerName) - } - }, timeout, interval).Should(BeTrue()) -} - -func waitImageRepositoryFinalizerOnComponent(componentKey types.NamespacedName) { - waitFinalizerOnComponent(componentKey, ImageRepositoryComponentFinalizer, true) -} - func waitImageRepositoryFinalizerOnImageRepository(imageRepositoryKey types.NamespacedName) { imageRepository := &imagerepositoryv1alpha1.ImageRepository{} Eventually(func() bool {