From fe6ea2f2e231b81984f6de94122b4da8307895bb Mon Sep 17 00:00:00 2001 From: Mykola Morhun Date: Fri, 14 Jul 2023 17:12:23 +0300 Subject: [PATCH] Draft implementation, wip --- PROJECT | 8 +- api/v1beta1/groupversion_info.go | 36 ++ api/v1beta1/imagerepository_types.go | 146 +++++ api/v1beta1/zz_generated.deepcopy.go | 188 ++++++ ...ppstudio.redhat.com_imagerepositories.yaml | 138 +++++ config/crd/kustomization.yaml | 3 + .../cainjection_in_imagerepositories.yaml | 7 + .../patches/webhook_in_imagerepositories.yaml | 16 + config/rbac/imagerepository_editor_role.yaml | 24 + config/rbac/imagerepository_viewer_role.yaml | 20 + config/rbac/role.yaml | 26 + ...io.redhat.com_v1beta1_imagerepository.yaml | 6 + config/samples/kustomization.yaml | 1 + controllers/component_image_controller.go | 12 +- .../component_image_controller_test.go | 30 +- controllers/imagerepository_controller.go | 566 ++++++++++++++++++ .../imagerepository_controller_test.go | 548 +++++++++++++++++ controllers/suite_test.go | 13 + controllers/suite_util_quay_client_test.go | 46 +- controllers/suite_util_test.go | 86 ++- hack/boilerplate.go.txt | 2 +- main.go | 15 +- pkg/quay/quay.go | 38 ++ pkg/quay/quay_debug_test.go | 19 + 24 files changed, 1963 insertions(+), 31 deletions(-) create mode 100644 api/v1beta1/groupversion_info.go create mode 100644 api/v1beta1/imagerepository_types.go create mode 100644 api/v1beta1/zz_generated.deepcopy.go create mode 100644 config/crd/bases/appstudio.redhat.com.appstudio.redhat.com_imagerepositories.yaml create mode 100644 config/crd/patches/cainjection_in_imagerepositories.yaml create mode 100644 config/crd/patches/webhook_in_imagerepositories.yaml create mode 100644 config/rbac/imagerepository_editor_role.yaml create mode 100644 config/rbac/imagerepository_viewer_role.yaml create mode 100644 config/samples/appstudio.redhat.com_v1beta1_imagerepository.yaml create mode 100644 controllers/imagerepository_controller.go create mode 100644 controllers/imagerepository_controller_test.go diff --git a/PROJECT b/PROJECT index 0c43299..9d4a1ab 100644 --- a/PROJECT +++ b/PROJECT @@ -8,12 +8,12 @@ projectName: image-controller repo: github.com/redhat-appstudio/image-controller resources: - api: - crdVersion: v1 + crdVersion: v1beta1 namespaced: true controller: true domain: appstudio.redhat.com group: appstudio.redhat.com - kind: Controller - path: github.com/redhat-appstudio/image-controller/api/v1alpha1 - version: v1alpha1 + kind: ImageRepository + path: github.com/redhat-appstudio/image-controller/api/v1beta1 + version: v1beta1 version: "3" diff --git a/api/v1beta1/groupversion_info.go b/api/v1beta1/groupversion_info.go new file mode 100644 index 0000000..2915f32 --- /dev/null +++ b/api/v1beta1/groupversion_info.go @@ -0,0 +1,36 @@ +/* +Copyright 2023. + +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 v1beta1 contains API Schema definitions for the appstudio.redhat.com v1beta1 API group +// +kubebuilder:object:generate=true +// +groupName=appstudio.redhat.com.appstudio.redhat.com +package v1beta1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "appstudio.redhat.com.appstudio.redhat.com", Version: "v1beta1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1beta1/imagerepository_types.go b/api/v1beta1/imagerepository_types.go new file mode 100644 index 0000000..0a6ba4f --- /dev/null +++ b/api/v1beta1/imagerepository_types.go @@ -0,0 +1,146 @@ +/* +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 v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// / ImageRepositorySpec defines the desired state of ImageRepository +type ImageRepositorySpec struct { + // Requested image repository configuration. + // +optional + Image ImageParameters `json:"image,omitempty"` + + // Credentials management. + // +optional + Credentials ImageCredentials `json:"credentials,omitempty"` +} + +// ImageParameters describes requested image repository configuration. +type ImageParameters struct { + // Name of the image within configured Quay organization. + // If ommited, then defaults to "cr-namespace/cr-name". + // This field cannot be changed after the resource creation. + // +optional + // +kubebuilder:validation:Pattern="[a-z0-9][.a-z0-9_-]*(/[a-z0-9][.a-z0-9_-]*)*" + Name string `json:"name,omitempty"` + + // Visibility defines whether the image is publicly visible. + // Allowed values are public and private. + // "public" is the default. + // +optional + Visibility ImageVisibility `json:"visibility,omitempty"` +} + +// +kubebuilder:validation:Enum=public;private +type ImageVisibility string + +const ( + ImageVisibilityPublic ImageVisibility = "public" + ImageVisibilityPrivate ImageVisibility = "private" +) + +type ImageCredentials struct { + // RegenerateToken defines a request to refresh image accessing credentials. + // Refreshes both, push and pull tokens. + // The field gets cleared after the refresh. + RegenerateToken *bool `json:"regenerate-token,omitempty"` +} + +// ImageRepositoryStatus defines the observed state of ImageRepository +type ImageRepositoryStatus struct { + // State shows if image repository could be used. + // "ready" means repository was created and usable, + // "failed" means that the image repository creation request failed. + State ImageRepositoryState `json:"state,omitempty"` + + // Message shows error information for the request. + // It could contain non critical error, like failed to change image visibility, + // while the state is ready and image resitory could be used. + Message string `json:"message,omitempty"` + + // Image describes actual state of the image repository. + Image ImageStatus `json:"image,omitempty"` + + // Credentials contain information related to image repository credentials. + Credentials CredentialsStatus `json:"credentials,omitempty"` +} + +type ImageRepositoryState string + +const ( + ImageRepositoryStateReady ImageRepositoryState = "ready" + ImageRepositoryStateFailed ImageRepositoryState = "failed" +) + +// ImageStatus shows actual generated image repository parameters. +type ImageStatus struct { + // URL is the full image repository url to push into / pull from. + URL string `json:"url,omitempty"` + + // Visibility shows actual generated image repository visibility. + Visibility ImageVisibility `json:"visibility,omitempty"` +} + +// CredentialsStatus shows information about generated image repository credentials. +type CredentialsStatus struct { + // GenerationTime shows timestamp when the current credentials were generated. + GenerationTimestamp *metav1.Time `json:"generationTimestamp,omitempty"` + + // PushSecretName holds name of the dockerconfig secret with credentials to push (and pull) into the generated repository. + PushSecretName string `json:"push-secret,omitempty"` + + // PullSecretName is present only if ImageRepository has labels that connect it to Application and Component. + // Holds name of the dockerconfig secret with credentials to pull only from the generated repository. + // The secret is not present in the same namespace as ImageRepository, but created in + PullSecretName string `json:"pull-secret,omitempty"` + + // PushRobotAccountName holds name of the quay robot account with write (push and pull) permissions into the generated repository. + PushRobotAccountName string `json:"push-robot-account,omitempty"` + + // PullRobotAccountName is present only if ImageRepository has labels that connect it to Application and Component. + // Holds name of the quay robot account with real (pull only) permissions from the generated repository. + PullRobotAccountName string `json:"pull-robot-account,omitempty"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// ImageRepository is the Schema for the imagerepositories API +// +kubebuilder:printcolumn:name="Image",type="string",JSONPath=".status.image.url" +// +kubebuilder:printcolumn:name="Visibility",type="string",JSONPath=".status.image.visibility" +type ImageRepository struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ImageRepositorySpec `json:"spec,omitempty"` + Status ImageRepositoryStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// ImageRepositoryList contains a list of ImageRepository +type ImageRepositoryList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ImageRepository `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ImageRepository{}, &ImageRepositoryList{}) +} diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go new file mode 100644 index 0000000..0821bec --- /dev/null +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,188 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CredentialsStatus) DeepCopyInto(out *CredentialsStatus) { + *out = *in + if in.GenerationTimestamp != nil { + in, out := &in.GenerationTimestamp, &out.GenerationTimestamp + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialsStatus. +func (in *CredentialsStatus) DeepCopy() *CredentialsStatus { + if in == nil { + return nil + } + out := new(CredentialsStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageCredentials) DeepCopyInto(out *ImageCredentials) { + *out = *in + if in.RegenerateToken != nil { + in, out := &in.RegenerateToken, &out.RegenerateToken + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageCredentials. +func (in *ImageCredentials) DeepCopy() *ImageCredentials { + if in == nil { + return nil + } + out := new(ImageCredentials) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageParameters) DeepCopyInto(out *ImageParameters) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageParameters. +func (in *ImageParameters) DeepCopy() *ImageParameters { + if in == nil { + return nil + } + out := new(ImageParameters) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageRepository) DeepCopyInto(out *ImageRepository) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageRepository. +func (in *ImageRepository) DeepCopy() *ImageRepository { + if in == nil { + return nil + } + out := new(ImageRepository) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ImageRepository) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageRepositoryList) DeepCopyInto(out *ImageRepositoryList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ImageRepository, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageRepositoryList. +func (in *ImageRepositoryList) DeepCopy() *ImageRepositoryList { + if in == nil { + return nil + } + out := new(ImageRepositoryList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ImageRepositoryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageRepositorySpec) DeepCopyInto(out *ImageRepositorySpec) { + *out = *in + out.Image = in.Image + in.Credentials.DeepCopyInto(&out.Credentials) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageRepositorySpec. +func (in *ImageRepositorySpec) DeepCopy() *ImageRepositorySpec { + if in == nil { + return nil + } + out := new(ImageRepositorySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageRepositoryStatus) DeepCopyInto(out *ImageRepositoryStatus) { + *out = *in + out.Image = in.Image + in.Credentials.DeepCopyInto(&out.Credentials) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageRepositoryStatus. +func (in *ImageRepositoryStatus) DeepCopy() *ImageRepositoryStatus { + if in == nil { + return nil + } + out := new(ImageRepositoryStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageStatus) DeepCopyInto(out *ImageStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageStatus. +func (in *ImageStatus) DeepCopy() *ImageStatus { + if in == nil { + return nil + } + out := new(ImageStatus) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/appstudio.redhat.com.appstudio.redhat.com_imagerepositories.yaml b/config/crd/bases/appstudio.redhat.com.appstudio.redhat.com_imagerepositories.yaml new file mode 100644 index 0000000..637967c --- /dev/null +++ b/config/crd/bases/appstudio.redhat.com.appstudio.redhat.com_imagerepositories.yaml @@ -0,0 +1,138 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.9.2 + creationTimestamp: null + name: imagerepositories.appstudio.redhat.com.appstudio.redhat.com +spec: + group: appstudio.redhat.com.appstudio.redhat.com + names: + kind: ImageRepository + listKind: ImageRepositoryList + plural: imagerepositories + singular: imagerepository + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.image.url + name: Image + type: string + - jsonPath: .status.image.visibility + name: Visibility + type: string + name: v1beta1 + schema: + openAPIV3Schema: + description: ImageRepository is the Schema for the imagerepositories API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: / ImageRepositorySpec defines the desired state of ImageRepository + properties: + credentials: + description: Credentials management. + properties: + regenerate-token: + description: RegenerateToken defines a request to refresh image + accessing credentials. Refreshes both, push and pull tokens. + The field gets cleared after the refresh. + type: boolean + type: object + image: + description: Requested image repository configuration. + properties: + name: + description: Name of the image within configured Quay organization. + If ommited, then defaults to "cr-namespace/cr-name". This field + cannot be changed after the resource creation. + pattern: '[a-z0-9][.a-z0-9_-]*(/[a-z0-9][.a-z0-9_-]*)*' + type: string + visibility: + description: Visibility defines whether the image is publicly + visible. Allowed values are public and private. "public" is + the default. + enum: + - public + - private + type: string + type: object + type: object + status: + description: ImageRepositoryStatus defines the observed state of ImageRepository + properties: + credentials: + description: Credentials contain information related to image repository + credentials. + properties: + generationTimestamp: + description: GenerationTime shows timestamp when the current credentials + were generated. + format: date-time + type: string + pull-robot-account: + description: PullRobotAccountName is present only if ImageRepository + has labels that connect it to Application and Component. Holds + name of the quay robot account with real (pull only) permissions + from the generated repository. + type: string + pull-secret: + description: PullSecretName is present only if ImageRepository + has labels that connect it to Application and Component. Holds + name of the dockerconfig secret with credentials to pull only + from the generated repository. The secret is not present in + the same namespace as ImageRepository, but created in + type: string + push-robot-account: + description: PushRobotAccountName holds name of the quay robot + account with write (push and pull) permissions into the generated + repository. + type: string + push-secret: + description: PushSecretName holds name of the dockerconfig secret + with credentials to push (and pull) into the generated repository. + type: string + type: object + image: + description: Image describes actual state of the image repository. + properties: + url: + description: URL is the full image repository url to push into + / pull from. + type: string + visibility: + description: Visibility shows actual generated image repository + visibility. + enum: + - public + - private + type: string + type: object + message: + description: Message shows error information for the request. It could + contain non critical error, like failed to change image visibility, + while the state is ready and image resitory could be used. + type: string + state: + description: State shows if image repository could be used. "ready" + means repository was created and usable, "failed" means that the + image repository creation request failed. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 35e01df..5bb484b 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,17 +3,20 @@ # It should be run by config/default #resources: #- bases/appstudio.redhat.com_components.yaml +- bases/appstudio.redhat.com.appstudio.redhat.com_imagerepositories.yaml #+kubebuilder:scaffold:crdkustomizeresource #patchesStrategicMerge: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # patches here are for enabling the conversion webhook for each CRD #- patches/webhook_in_controllers.yaml +#- patches/webhook_in_imagerepositories.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. # patches here are for enabling the CA injection for each CRD #- patches/cainjection_in_controllers.yaml +#- patches/cainjection_in_imagerepositories.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_imagerepositories.yaml b/config/crd/patches/cainjection_in_imagerepositories.yaml new file mode 100644 index 0000000..a2812dc --- /dev/null +++ b/config/crd/patches/cainjection_in_imagerepositories.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: imagerepositories.appstudio.redhat.com.appstudio.redhat.com diff --git a/config/crd/patches/webhook_in_imagerepositories.yaml b/config/crd/patches/webhook_in_imagerepositories.yaml new file mode 100644 index 0000000..3fee3a4 --- /dev/null +++ b/config/crd/patches/webhook_in_imagerepositories.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: imagerepositories.appstudio.redhat.com.appstudio.redhat.com +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/imagerepository_editor_role.yaml b/config/rbac/imagerepository_editor_role.yaml new file mode 100644 index 0000000..05133c4 --- /dev/null +++ b/config/rbac/imagerepository_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit imagerepositories. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: imagerepository-editor-role +rules: +- apiGroups: + - appstudio.redhat.com.appstudio.redhat.com + resources: + - imagerepositories + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - appstudio.redhat.com.appstudio.redhat.com + resources: + - imagerepositories/status + verbs: + - get diff --git a/config/rbac/imagerepository_viewer_role.yaml b/config/rbac/imagerepository_viewer_role.yaml new file mode 100644 index 0000000..3f3d4e8 --- /dev/null +++ b/config/rbac/imagerepository_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view imagerepositories. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: imagerepository-viewer-role +rules: +- apiGroups: + - appstudio.redhat.com.appstudio.redhat.com + resources: + - imagerepositories + verbs: + - get + - list + - watch +- apiGroups: + - appstudio.redhat.com.appstudio.redhat.com + resources: + - imagerepositories/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 63b6689..69fbd26 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -27,6 +27,32 @@ rules: - patch - update - watch +- apiGroups: + - appstudio.redhat.com + resources: + - imagerepositories + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - appstudio.redhat.com + resources: + - imagerepositories/finalizers + verbs: + - update +- apiGroups: + - appstudio.redhat.com + resources: + - imagerepositories/status + verbs: + - get + - patch + - update - apiGroups: - appstudio.redhat.com resources: diff --git a/config/samples/appstudio.redhat.com_v1beta1_imagerepository.yaml b/config/samples/appstudio.redhat.com_v1beta1_imagerepository.yaml new file mode 100644 index 0000000..d83b021 --- /dev/null +++ b/config/samples/appstudio.redhat.com_v1beta1_imagerepository.yaml @@ -0,0 +1,6 @@ +apiVersion: appstudio.redhat.com.appstudio.redhat.com/v1beta1 +kind: ImageRepository +metadata: + name: imagerepository-sample +spec: + # TODO(user): Add fields here diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index f39bc1b..9af3997 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,4 +1,5 @@ ## Append samples you want in your CSV to this file as resources ## resources: - appstudio.redhat.com_v1alpha1_controller.yaml +- appstudio.redhat.com_v1beta1_imagerepository.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/controllers/component_image_controller.go b/controllers/component_image_controller.go index 3292dcb..bed1688 100644 --- a/controllers/component_image_controller.go +++ b/controllers/component_image_controller.go @@ -45,7 +45,7 @@ const ( ImageAnnotationName = "image.redhat.com/image" GenerateImageAnnotationName = "image.redhat.com/generate" - ImageRepositoryFinalizer = "image-controller.appstudio.openshift.io/image-repository" + ImageRepositoryComponentFinalizer = "image-controller.appstudio.openshift.io/image-repository" ApplicationNameLabelName = "appstudio.redhat.com/application" ComponentNameLabelName = "appstudio.redhat.com/component" @@ -109,7 +109,7 @@ func (r *ComponentReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } if !component.ObjectMeta.DeletionTimestamp.IsZero() { - if controllerutil.ContainsFinalizer(component, ImageRepositoryFinalizer) { + if controllerutil.ContainsFinalizer(component, ImageRepositoryComponentFinalizer) { pushRobotAccountName, pullRobotAccountName := generateRobotAccountsNames(component) quayClient := r.BuildQuayClient(log) @@ -145,7 +145,7 @@ func (r *ComponentReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( log.Error(err, "failed to get Component", l.Action, l.ActionView) return ctrl.Result{}, err } - controllerutil.RemoveFinalizer(component, ImageRepositoryFinalizer) + 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) return ctrl.Result{}, err @@ -288,8 +288,8 @@ func (r *ComponentReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( component.Annotations[ImageAnnotationName] = string(repositoryInfoBytes) delete(component.Annotations, GenerateImageAnnotationName) - if repositoryInfo.Image != "" && !controllerutil.ContainsFinalizer(component, ImageRepositoryFinalizer) { - controllerutil.AddFinalizer(component, ImageRepositoryFinalizer) + 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) } @@ -380,7 +380,7 @@ func (r *ComponentReconciler) ensureComponentPullSecretRemoteSecret(ctx context. remoteSecretKey := types.NamespacedName{Namespace: component.Namespace, Name: remoteSecretName} if err := r.Client.Get(ctx, remoteSecretKey, remoteSecret); err != nil { if !errors.IsNotFound(err) { - log.Error(err, fmt.Sprintf("failed to get remote secret: %v", remoteSecretKey), l.Action, l.ActionAdd) + log.Error(err, fmt.Sprintf("failed to get remote secret: %v", remoteSecretKey), l.Action, l.ActionView) return err } diff --git a/controllers/component_image_controller_test.go b/controllers/component_image_controller_test.go index 16e9058..e1b8749 100644 --- a/controllers/component_image_controller_test.go +++ b/controllers/component_image_controller_test.go @@ -39,8 +39,8 @@ var _ = Describe("Component image controller", func() { var ( authRegexp = regexp.MustCompile(`.*{"auth":"([A-Za-z0-9+/=]*)"}.*`) - resourceKey = types.NamespacedName{Name: defaultComponentName, Namespace: defaultComponentNamespace} - uploadSecretKey = types.NamespacedName{Name: "upload-secret-" + defaultComponentName + "-pull", Namespace: defaultComponentNamespace} + resourceKey = types.NamespacedName{Name: defaultComponentName, Namespace: defaultNamespace} + uploadSecretKey = types.NamespacedName{Name: "upload-secret-" + defaultComponentName + "-pull", Namespace: defaultNamespace} pushToken string pullToken string @@ -54,17 +54,17 @@ var _ = Describe("Component image controller", func() { Context("Image repository provision flow", func() { It("should prepare environment", func() { - deleteNamespace(defaultComponentNamespace) - createNamespace(defaultComponentNamespace) + deleteNamespace(defaultNamespace) + createNamespace(defaultNamespace) ResetTestQuayClient() pushToken = "push-token1234" pullToken = "pull-token1234" - expectedPushRobotAccountName = fmt.Sprintf("%s%s%s", defaultComponentNamespace, defaultComponentApplication, defaultComponentName) + expectedPushRobotAccountName = fmt.Sprintf("%s%s%s", defaultNamespace, defaultComponentApplication, defaultComponentName) expectedPullRobotAccountName = expectedPushRobotAccountName + "-pull" expectedRemoteSecretName = resourceKey.Name + "-pull" - expectedRepoName = fmt.Sprintf("%s/%s/%s", defaultComponentNamespace, defaultComponentApplication, defaultComponentName) + expectedRepoName = fmt.Sprintf("%s/%s/%s", defaultNamespace, defaultComponentApplication, defaultComponentName) expectedImage = fmt.Sprintf("quay.io/%s/%s", testQuayOrg, expectedRepoName) }) @@ -159,7 +159,7 @@ var _ = Describe("Component image controller", func() { It("should propagate pull secret to environments", func() { component := getComponent(resourceKey) - remoteSecretKey := types.NamespacedName{Name: expectedRemoteSecretName, Namespace: defaultComponentNamespace} + remoteSecretKey := types.NamespacedName{Name: expectedRemoteSecretName, Namespace: defaultNamespace} remoteSecret := waitRemoteSecretExist(remoteSecretKey) Expect(remoteSecret.Labels[ApplicationNameLabelName]).To(Equal(component.Spec.Application)) Expect(remoteSecret.Labels[ComponentNameLabelName]).To(Equal(component.Spec.ComponentName)) @@ -300,7 +300,7 @@ var _ = Describe("Component image controller", func() { Context("Image repository provision error cases", func() { It("should prepare environment", func() { - createNamespace(defaultComponentNamespace) + createNamespace(defaultNamespace) ResetTestQuayClient() @@ -343,7 +343,7 @@ var _ = Describe("Component image controller", func() { Expect(repoImageInfo.Visibility).To(BeEmpty()) Expect(repoImageInfo.Secret).To(BeEmpty()) - Expect(controllerutil.ContainsFinalizer(component, ImageRepositoryFinalizer)).To(BeFalse()) + Expect(controllerutil.ContainsFinalizer(component, ImageRepositoryComponentFinalizer)).To(BeFalse()) }) It("should do nothing and set error if generate annotation has invalid visibility value", func() { @@ -366,7 +366,7 @@ var _ = Describe("Component image controller", func() { Expect(repoImageInfo.Visibility).To(BeEmpty()) Expect(repoImageInfo.Secret).To(BeEmpty()) - Expect(controllerutil.ContainsFinalizer(component, ImageRepositoryFinalizer)).To(BeFalse()) + Expect(controllerutil.ContainsFinalizer(component, ImageRepositoryComponentFinalizer)).To(BeFalse()) }) It("should set error if quay organization plan doesn't allow private repositories", func() { @@ -393,7 +393,7 @@ var _ = Describe("Component image controller", func() { Expect(repoImageInfo.Visibility).To(BeEmpty()) Expect(repoImageInfo.Secret).To(BeEmpty()) - Expect(controllerutil.ContainsFinalizer(component, ImageRepositoryFinalizer)).To(BeFalse()) + Expect(controllerutil.ContainsFinalizer(component, ImageRepositoryComponentFinalizer)).To(BeFalse()) }) It("should add message and stop if it's not possible to switch image repository visibility", func() { @@ -457,7 +457,7 @@ var _ = Describe("Component image controller", func() { Context("Image repository provision other cases", func() { _ = BeforeEach(func() { - createNamespace(defaultComponentNamespace) + createNamespace(defaultNamespace) ResetTestQuayClient() @@ -466,9 +466,9 @@ var _ = Describe("Component image controller", func() { pushToken = "push-token1234" pullToken = "pull-token1234" - expectedPushRobotAccountName = fmt.Sprintf("%s%s%s", defaultComponentNamespace, defaultComponentApplication, defaultComponentName) + expectedPushRobotAccountName = fmt.Sprintf("%s%s%s", defaultNamespace, defaultComponentApplication, defaultComponentName) expectedPullRobotAccountName = expectedPushRobotAccountName + "-pull" - expectedRepoName = fmt.Sprintf("%s/%s/%s", defaultComponentNamespace, defaultComponentApplication, defaultComponentName) + expectedRepoName = fmt.Sprintf("%s/%s/%s", defaultNamespace, defaultComponentApplication, defaultComponentName) expectedImage = fmt.Sprintf("quay.io/%s/%s", testQuayOrg, expectedRepoName) }) @@ -511,7 +511,7 @@ var _ = Describe("Component image controller", func() { 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: expectedRemoteSecretName, Namespace: resourceKey.Namespace}) - remoteSecretKey := types.NamespacedName{Name: expectedRemoteSecretName, Namespace: defaultComponentNamespace} + remoteSecretKey := types.NamespacedName{Name: expectedRemoteSecretName, Namespace: defaultNamespace} Expect(k8sErrors.IsNotFound(k8sClient.Get(ctx, remoteSecretKey, &remotesecretv1beta1.RemoteSecret{}))) isCreateRepositoryInvoked := false diff --git a/controllers/imagerepository_controller.go b/controllers/imagerepository_controller.go new file mode 100644 index 0000000..bdeae02 --- /dev/null +++ b/controllers/imagerepository_controller.go @@ -0,0 +1,566 @@ +/* +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" + "crypto/rand" + "encoding/hex" + "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" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/go-logr/logr" + imagerepositoryv1beta1 "github.com/redhat-appstudio/image-controller/api/v1beta1" + l "github.com/redhat-appstudio/image-controller/pkg/logs" + "github.com/redhat-appstudio/image-controller/pkg/quay" + remotesecretv1beta1 "github.com/redhat-appstudio/remote-secret/api/v1beta1" +) + +const ( + ImageRepositoryFinalizer = "appstudio.openshift.io/image-repository" +) + +// ImageRepositoryReconciler reconciles a ImageRepository object +type ImageRepositoryReconciler 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 *ImageRepositoryReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&imagerepositoryv1beta1.ImageRepository{}). + Complete(r) +} + +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=imagerepositories,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=imagerepositories/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=imagerepositories/finalizers,verbs=update +//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=remotesecrets,verbs=get;list;watch;create + +func (r *ImageRepositoryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrllog.FromContext(ctx).WithName("ImageRepository") + ctx = ctrllog.IntoContext(ctx, log) + + // Fetch the image repository instance + imageRepository := &imagerepositoryv1beta1.ImageRepository{} + err := r.Client.Get(ctx, req.NamespacedName, imageRepository) + if err != nil { + if errors.IsNotFound(err) { + // The object is deleted, nothing to do + return ctrl.Result{}, nil + } + log.Error(err, "failed to get image repository", l.Action, l.ActionView) + return ctrl.Result{}, err + } + + if !imageRepository.DeletionTimestamp.IsZero() { + // Reread quay token + r.QuayClient = r.BuildQuayClient(log) + + if controllerutil.ContainsFinalizer(imageRepository, ImageRepositoryFinalizer) { + // Do not block deletion on failures + r.CleanupImageRepository(ctx, imageRepository) + + controllerutil.RemoveFinalizer(imageRepository, ImageRepositoryFinalizer) + if err := r.Client.Update(ctx, imageRepository); err != nil { + log.Error(err, "failed to remove image repository finalizer", l.Action, l.ActionUpdate) + return ctrl.Result{}, err + } + log.Info("Image repository finalizer removed", l.Action, l.ActionDelete) + } + return ctrl.Result{}, nil + } + + if imageRepository.Status.State == imagerepositoryv1beta1.ImageRepositoryStateFailed { + return ctrl.Result{}, nil + } + + // Reread quay token + r.QuayClient = r.BuildQuayClient(log) + + // Provision image repository if it hasn't been done yet + if !controllerutil.ContainsFinalizer(imageRepository, ImageRepositoryFinalizer) { + if err := r.ProvisionImageRepository(ctx, imageRepository); err != nil { + log.Error(err, "provision of image repository failed") + return ctrl.Result{}, err + } + if imageRepository.Status.State == imagerepositoryv1beta1.ImageRepositoryStateFailed { + log.Error(err, "provision of image repository failed permanently") + return ctrl.Result{}, nil + } + + // Add finalizer + if err := r.Client.Get(ctx, req.NamespacedName, imageRepository); err != nil { + log.Error(err, "failed to get image repository", l.Action, l.ActionView) + return ctrl.Result{}, err + } + controllerutil.AddFinalizer(imageRepository, ImageRepositoryFinalizer) + if err := r.Client.Update(ctx, imageRepository); err != nil { + log.Error(err, "failed to add image repository finalizer", l.Action, l.ActionUpdate) + return ctrl.Result{}, err + } else { + log.Info("added image repository finalizer") + } + + return ctrl.Result{}, nil + } + + // Make sure, that image repository name is the same as on creation. + // Do it here to aviod webhook creation. + if !strings.HasSuffix(imageRepository.Status.Image.URL, imageRepository.Spec.Image.Name) { + imageRepositoryName := strings.TrimPrefix(imageRepository.Status.Image.URL, fmt.Sprintf("quay.io/%s/", r.QuayOrganization)) + imageRepository.Spec.Image.Name = imageRepositoryName + if err := r.Client.Update(ctx, imageRepository); err != nil { + log.Error(err, "failed to revert image repository name", l.Action, l.ActionUpdate) + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Change image visibility if requested + if imageRepository.Status.Image.Visibility != imageRepository.Spec.Image.Visibility { + if err := r.ChangeImageRepositoryVisibility(ctx, imageRepository); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Rotate credentials + regenerateToken := imageRepository.Spec.Credentials.RegenerateToken + if regenerateToken != nil && *regenerateToken { + if err := r.RegenerateImageRepositoryCredentials(ctx, imageRepository); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + return ctrl.Result{}, nil +} + +// ProvisionImageRepository creates image repository, robot account(s) and secret(s) to acces the image repository. +// If labels with Application and Component name are present, robot account with pull only access +// will be created and pull token will be propagated to all environments via Remote Secret. +func (r *ImageRepositoryReconciler) ProvisionImageRepository(ctx context.Context, imageRepository *imagerepositoryv1beta1.ImageRepository) error { + log := ctrllog.FromContext(ctx).WithName("ImageRepositoryProvision") + ctx = ctrllog.IntoContext(ctx, log) + + imageRepositoryName := "" + if imageRepository.Spec.Image.Name == "" { + imageRepositoryName = imageRepository.Namespace + "-" + imageRepository.Name + } else { + imageRepositoryName = imageRepository.Namespace + "-" + imageRepository.Spec.Image.Name + } + + visibility := "public" + if imageRepository.Spec.Image.Visibility != "" { + visibility = string(imageRepository.Spec.Image.Visibility) + } + + repository, err := r.QuayClient.CreateRepository(quay.RepositoryRequest{ + Namespace: r.QuayOrganization, + Repository: imageRepositoryName, + Visibility: visibility, + Description: "AppStudio repository for the user", + }) + if err != nil { + log.Error(err, "failed to create image repository", l.Action, l.ActionAdd, l.Audit, "true") + imageRepository.Status.State = imagerepositoryv1beta1.ImageRepositoryStateFailed + if err.Error() == "payment required" { + imageRepository.Status.Message = "Number of private repositories exceeds current quay plan limit" + } else { + imageRepository.Status.Message = err.Error() + } + _ = r.Client.Status().Update(ctx, imageRepository) + return nil + } + + robotAccountName := generateQuayRobotAccountName(imageRepositoryName, false) + robotAccount, err := r.QuayClient.CreateRobotAccount(r.QuayOrganization, robotAccountName) + if err != nil { + log.Error(err, "failed to create robot account", "RobotAccountName", robotAccountName, l.Action, l.ActionAdd, l.Audit, "true") + return err + } + + err = r.QuayClient.AddPermissionsForRepositoryToRobotAccount(r.QuayOrganization, repository.Name, robotAccount.Name, true) + if err != nil { + log.Error(err, "failed to add permissions to robot account", "RobotAccountName", robotAccountName, l.Action, l.ActionUpdate, l.Audit, "true") + return err + } + + quayImageURL := fmt.Sprintf("quay.io/%s/%s", r.QuayOrganization, repository.Name) + secretName := strings.ReplaceAll(robotAccountName, "_", "-") + if err := r.EnsureDockerSecret(ctx, imageRepository, robotAccount, secretName, quayImageURL); err != nil { + return err + } + + if isComponentLinked(imageRepository) { + // Pull secret provision and propagation + pullRobotAccountName := generateQuayRobotAccountName(imageRepositoryName, true) + pullRobotAccount, err := r.QuayClient.CreateRobotAccount(r.QuayOrganization, pullRobotAccountName) + if err != nil { + log.Error(err, "failed to create pull robot account", "RobotAccountName", pullRobotAccountName, l.Action, l.ActionAdd, l.Audit, "true") + return err + } + + err = r.QuayClient.AddPermissionsForRepositoryToRobotAccount(r.QuayOrganization, repository.Name, pullRobotAccount.Name, false) + if err != nil { + log.Error(err, "failed to add permissions to pull robot account", "RobotAccountName", robotAccountName, l.Action, l.ActionUpdate, l.Audit, "true") + return err + } + + remoteSecretName := getRemoteSecretName(imageRepository) + if err := r.EnsureRemotePullSecret(ctx, imageRepository, remoteSecretName); err != nil { + return err + } + + if err := r.CreateRemotePullSecretUploadSecret(ctx, pullRobotAccount, imageRepository.Namespace, remoteSecretName, quayImageURL); err != nil { + return err + } + + imageRepository.Status.Credentials.PullRobotAccountName = pullRobotAccountName + imageRepository.Status.Credentials.PullSecretName = remoteSecretName + } + + imageRepository.Status.State = imagerepositoryv1beta1.ImageRepositoryStateReady + imageRepository.Status.Image.URL = quayImageURL + imageRepository.Status.Image.Visibility = imageRepository.Spec.Image.Visibility + imageRepository.Status.Credentials.PushRobotAccountName = robotAccountName + imageRepository.Status.Credentials.PushSecretName = secretName + imageRepository.Status.Credentials.GenerationTimestamp = &metav1.Time{Time: time.Now()} + if err := r.Client.Status().Update(ctx, imageRepository); err != nil { + return err + } + + return nil +} + +// RegenerateImageRepositoryCredentials rotates robot account(s) token and updates corresponding secret(s) +func (r *ImageRepositoryReconciler) RegenerateImageRepositoryCredentials(ctx context.Context, imageRepository *imagerepositoryv1beta1.ImageRepository) error { + log := ctrllog.FromContext(ctx) + + quayImageURL := imageRepository.Status.Image.URL + robotAccountName := imageRepository.Status.Credentials.PushRobotAccountName + + robotAccount, err := r.QuayClient.RegenerateRobotAccountToken(r.QuayOrganization, robotAccountName) + if err != nil { + log.Error(err, "failed to refresh push token") + return err + } + secretName := strings.ReplaceAll(robotAccountName, "_", "-") + if err := r.EnsureDockerSecret(ctx, imageRepository, robotAccount, secretName, quayImageURL); err != nil { + return err + } + log.WithValues("RobotAccountName", robotAccountName).Info("Regenerated push token") + + if isComponentLinked(imageRepository) { + pullRobotAccountName := imageRepository.Status.Credentials.PushRobotAccountName + pullRobotAccount, err := r.QuayClient.RegenerateRobotAccountToken(r.QuayOrganization, pullRobotAccountName) + if err != nil { + log.Error(err, "failed to refresh pull token") + return err + } + + remoteSecretName := getRemoteSecretName(imageRepository) + if err := r.CreateRemotePullSecretUploadSecret(ctx, pullRobotAccount, imageRepository.Namespace, remoteSecretName, quayImageURL); err != nil { + return err + } + log.WithValues("RobotAccountName", pullRobotAccountName).Info("Regenerated pull token") + } + + imageRepositoryKey := types.NamespacedName{Namespace: imageRepository.Namespace, Name: imageRepository.Name} + if err := r.Client.Get(ctx, imageRepositoryKey, imageRepository); err != nil { + log.Error(err, "failed to get image repository") + return err + } + imageRepository.Spec.Credentials.RegenerateToken = nil + if err := r.Client.Update(ctx, imageRepository); err != nil { + log.Error(err, "failed to update image repository", l.Action, l.ActionUpdate) + return err + } + + if err := r.Client.Get(ctx, imageRepositoryKey, imageRepository); err != nil { + log.Error(err, "failed to get image repository") + return err + } + imageRepository.Status.Credentials.GenerationTimestamp = &metav1.Time{Time: time.Now()} + if err := r.Client.Status().Update(ctx, imageRepository); err != nil { + log.Error(err, "failed to update image repository status", l.Action, l.ActionUpdate) + return err + } + + return nil +} + +// CleanupImageRepository deletes image repository and corresponding robot account(s). +func (r *ImageRepositoryReconciler) CleanupImageRepository(ctx context.Context, imageRepository *imagerepositoryv1beta1.ImageRepository) { + log := ctrllog.FromContext(ctx).WithName("RepositoryCleanup") + + robotAccountName := imageRepository.Status.Credentials.PushRobotAccountName + isRobotAccountDeleted, err := r.QuayClient.DeleteRobotAccount(r.QuayOrganization, robotAccountName) + if err != nil { + log.Error(err, "failed to delete push robot account", l.Action, l.ActionDelete, l.Audit, "true") + } + if isRobotAccountDeleted { + log.WithValues("RobotAccountName", robotAccountName).Info("Deleted push robot account", l.Action, l.ActionDelete) + } + + if isComponentLinked(imageRepository) { + pullRobotAccountName := imageRepository.Status.Credentials.PullRobotAccountName + isPullRobotAccountDeleted, err := r.QuayClient.DeleteRobotAccount(r.QuayOrganization, pullRobotAccountName) + if err != nil { + log.Error(err, "failed to delete pull robot account", l.Action, l.ActionDelete, l.Audit, "true") + } + if isPullRobotAccountDeleted { + log.WithValues("RobotAccountName", pullRobotAccountName).Info("Deleted pull robot account", l.Action, l.ActionDelete) + } + } + + imageRepositoryName := imageRepository.Spec.Image.Name + isImageRepositoryDeleted, err := r.QuayClient.DeleteRepository(r.QuayOrganization, imageRepositoryName) + if err != nil { + log.Error(err, "failed to delete image repository", l.Action, l.ActionDelete, l.Audit, "true") + } + if isImageRepositoryDeleted { + log.WithValues("ImageRepository", imageRepositoryName).Info("Deleted image repository", l.Action, l.ActionDelete) + } +} + +func (r *ImageRepositoryReconciler) ChangeImageRepositoryVisibility(ctx context.Context, imageRepository *imagerepositoryv1beta1.ImageRepository) error { + if imageRepository.Status.Image.Visibility == imageRepository.Spec.Image.Visibility { + return nil + } + + log := ctrllog.FromContext(ctx) + + imageRepositoryName := imageRepository.Spec.Image.Name + requestedVisibility := string(imageRepository.Spec.Image.Visibility) + err := r.QuayClient.ChangeRepositoryVisibility(r.QuayOrganization, imageRepositoryName, requestedVisibility) + if err == nil { + imageRepository.Status.Image.Visibility = imageRepository.Spec.Image.Visibility + imageRepository.Status.Message = "" + if err := r.Client.Status().Update(ctx, imageRepository); err != nil { + log.Error(err, "failed to update image repository name", l.Action, l.ActionUpdate) + return err + } + return nil + } + + if err.Error() == "payment required" { + log.Info("failed to make image repository private due to quay plan limit", l.Audit, "true") + + imageRepository.Spec.Image.Visibility = imageRepository.Status.Image.Visibility + if err := r.Client.Update(ctx, imageRepository); err != nil { + log.Error(err, "failed to update image repository", l.Action, l.ActionUpdate) + return err + } + + imageRepositoryKey := types.NamespacedName{Namespace: imageRepository.Namespace, Name: imageRepository.Name} + if err := r.Client.Get(ctx, imageRepositoryKey, imageRepository); err != nil { + log.Error(err, "failed to get image repository", l.Action, l.ActionView) + return err + } + imageRepository.Status.Message = "Quay organization plan private repositories limit exceeded" + if err := r.Client.Status().Update(ctx, imageRepository); err != nil { + log.Error(err, "failed to update image repository", l.Action, l.ActionUpdate) + return err + } + } + + log.Error(err, "failed to change image repository visibility") + return err +} + +// EnsureDockerSecret makes sure that secret for given robot account exists and contains up to date credentials. +func (r *ImageRepositoryReconciler) EnsureDockerSecret(ctx context.Context, imageRepository *imagerepositoryv1beta1.ImageRepository, robotAccount *quay.RobotAccount, secretName, imageURL string) error { + log := ctrllog.FromContext(ctx).WithValues("SecretName", secretName) + + secretKey := types.NamespacedName{Namespace: imageRepository.Namespace, Name: secretName} + secret := &corev1.Secret{} + if err := r.Client.Get(ctx, secretKey, secret); err != nil { + if !errors.IsNotFound(err) { + log.Error(err, "failed to get push secret", l.Action, l.ActionView) + return err + } + // Cretate secret + secret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: imageRepository.Namespace, + OwnerReferences: []metav1.OwnerReference{ + { + Name: imageRepository.Name, + APIVersion: imageRepository.APIVersion, + Kind: imageRepository.Kind, + UID: imageRepository.UID, + }, + }, + }, + Type: corev1.SecretTypeDockerConfigJson, + StringData: generateDockerconfigSecretData(imageURL, robotAccount), + } + if err := r.Client.Create(ctx, secret); err != nil { + log.Error(err, "failed to create secret", l.Action, l.ActionAdd) + return err + } + } else { + // Update the secret + secret.StringData = generateDockerconfigSecretData(imageURL, robotAccount) + if err := r.Client.Update(ctx, secret); err != nil { + log.Error(err, "failed to update secret", l.Action, l.ActionUpdate) + return err + } + } + + return nil +} + +func (r *ImageRepositoryReconciler) EnsureRemotePullSecret(ctx context.Context, imageRepository *imagerepositoryv1beta1.ImageRepository, remoteSecretName string) error { + log := ctrllog.FromContext(ctx).WithValues("RemoteSecretName", remoteSecretName) + + remoteSecret := &remotesecretv1beta1.RemoteSecret{} + remoteSecretKey := types.NamespacedName{Namespace: imageRepository.Namespace, Name: remoteSecretName} + if err := r.Client.Get(ctx, remoteSecretKey, remoteSecret); err != nil { + if !errors.IsNotFound(err) { + log.Error(err, "failed to get remote secret", l.Action, l.ActionView) + return err + } + + remoteSecret := &remotesecretv1beta1.RemoteSecret{ + ObjectMeta: metav1.ObjectMeta{ + Name: remoteSecretName, + Namespace: imageRepository.Namespace, + Labels: map[string]string{ + ApplicationNameLabelName: imageRepository.Labels[ApplicationNameLabelName], + ComponentNameLabelName: imageRepository.Labels[ComponentNameLabelName], + }, + OwnerReferences: []metav1.OwnerReference{ + { + Name: imageRepository.Name, + APIVersion: imageRepository.APIVersion, + Kind: imageRepository.Kind, + UID: imageRepository.UID, + }, + }, + }, + Spec: remotesecretv1beta1.RemoteSecretSpec{ + Secret: remotesecretv1beta1.LinkableSecretSpec{ + Name: remoteSecretName, + Type: corev1.SecretTypeDockerConfigJson, + LinkedTo: []remotesecretv1beta1.SecretLink{ + { + ServiceAccount: remotesecretv1beta1.ServiceAccountLink{ + Reference: corev1.LocalObjectReference{ + Name: defaultServiceAccountName, + }, + }, + }, + }, + }, + }, + } + if err := r.Client.Create(ctx, remoteSecret); err != nil { + log.Error(err, "failed to create remote secret", l.Action, l.ActionAdd, l.Audit, "true") + return err + } + } + + return nil +} + +// CreateRemotePullSecretUploadSecret propagates credentials from given robot account to corresponding remote secret. +func (r *ImageRepositoryReconciler) CreateRemotePullSecretUploadSecret(ctx context.Context, robotAccount *quay.RobotAccount, namespace, remoteSecretName, imageURL string) error { + uploadSecretName := "upload-secret-" + remoteSecretName + log := ctrllog.FromContext(ctx).WithValues("RemoteSecretName", remoteSecretName).WithValues("UploadSecretName", uploadSecretName) + + uploadSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: uploadSecretName, + Namespace: namespace, + Labels: map[string]string{ + remotesecretv1beta1.UploadSecretLabel: "remotesecret", + }, + Annotations: map[string]string{ + remotesecretv1beta1.RemoteSecretNameAnnotation: remoteSecretName, + }, + }, + StringData: generateDockerconfigSecretData(imageURL, robotAccount), + } + if err := r.Client.Create(ctx, uploadSecret); err != nil { + log.Error(err, "failed to create upload secret", l.Action, l.ActionAdd, l.Audit, "true") + return err + } + + return nil +} + +// generateQuayRobotAccountName generates valid robot account name for given image repository name. +func generateQuayRobotAccountName(imageRepositoryName string, isPullOnly bool) string { + // Robot account name must match ^[a-z][a-z0-9_]{1,254}$ + + imageNamePrefix := imageRepositoryName + if len(imageNamePrefix) > 220 { + imageNamePrefix = imageNamePrefix[:220] + } + imageNamePrefix = strings.ReplaceAll(imageNamePrefix, "/", "_") + imageNamePrefix = strings.ReplaceAll(imageNamePrefix, ".", "_") + imageNamePrefix = strings.ReplaceAll(imageNamePrefix, "-", "_") + + randomSuffix := getRandomString(10) + + robotAccountName := fmt.Sprintf("%s_%s", imageNamePrefix, randomSuffix) + if isPullOnly { + robotAccountName += "_pull" + } + return robotAccountName +} + +func getRemoteSecretName(imageRepository *imagerepositoryv1beta1.ImageRepository) string { + componentName := imageRepository.Labels[ComponentNameLabelName] + if len(componentName) > 220 { + componentName = componentName[:220] + } + return componentName + "-image-pull" +} + +func isComponentLinked(imageRepository *imagerepositoryv1beta1.ImageRepository) bool { + return imageRepository.Labels[ApplicationNameLabelName] != "" && imageRepository.Labels[ComponentNameLabelName] != "" +} + +func getRandomString(length int) string { + bytes := make([]byte, length/2+1) + if _, err := rand.Read(bytes); err != nil { + panic("Failed to read from random generator") + } + return hex.EncodeToString(bytes)[0:length] +} diff --git a/controllers/imagerepository_controller_test.go b/controllers/imagerepository_controller_test.go new file mode 100644 index 0000000..da9c8ca --- /dev/null +++ b/controllers/imagerepository_controller_test.go @@ -0,0 +1,548 @@ +/* +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 ( + "encoding/base64" + "encoding/json" + "fmt" + "regexp" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/redhat-appstudio/image-controller/pkg/quay" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + imagerepositoryv1beta1 "github.com/redhat-appstudio/image-controller/api/v1beta1" + remotesecretv1beta1 "github.com/redhat-appstudio/remote-secret/api/v1beta1" +) + +var _ = Describe("Image repository controller", func() { + + var ( + authRegexp = regexp.MustCompile(`.*{"auth":"([A-Za-z0-9+/=]*)"}.*`) + + resourceKey = types.NamespacedName{Name: defaultImageRepositoryName, Namespace: defaultNamespace} + + pushToken string + pullToken string + expectedRobotAccountPrefix string + expectedRemoteSecretName string + expectedImageName string + expectedImage string + ) + + Context("Image repository provision", func() { + + It("should prepare environment", func() { + createNamespace(defaultNamespace) + + pushToken = "push-token1234" + expectedImageName = fmt.Sprintf("%s-%s", defaultNamespace, defaultImageRepositoryName) + expectedImage = fmt.Sprintf("quay.io/%s/%s", testQuayOrg, expectedImageName) + expectedRobotAccountPrefix = strings.ReplaceAll(expectedImageName, "-", "_") + }) + + It("should provision image repository", func() { + ResetTestQuayClientToFails() + + isCreateRepositoryInvoked := false + CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { + defer GinkgoRecover() + isCreateRepositoryInvoked = true + Expect(repository.Repository).To(Equal(expectedImageName)) + Expect(repository.Namespace).To(Equal(testQuayOrg)) + Expect(repository.Visibility).To(Equal("public")) + Expect(repository.Description).ToNot(BeEmpty()) + return &quay.Repository{Name: expectedImageName}, nil + } + isCreateRobotAccountInvoked := false + CreateRobotAccountFunc = func(organization, robotName string) (*quay.RobotAccount, error) { + defer GinkgoRecover() + isCreateRobotAccountInvoked = true + Expect(organization).To(Equal(testQuayOrg)) + Expect(strings.HasPrefix(robotName, expectedRobotAccountPrefix)).To(BeTrue()) + return &quay.RobotAccount{Name: robotName, Token: pushToken}, nil + } + isAddPushPermissionsToRobotAccountInvoked := false + AddPermissionsForRepositoryToRobotAccountFunc = func(organization, imageRepository, robotAccountName string, isWrite bool) error { + defer GinkgoRecover() + isAddPushPermissionsToRobotAccountInvoked = true + Expect(organization).To(Equal(testQuayOrg)) + Expect(imageRepository).To(Equal(expectedImageName)) + Expect(isWrite).To(BeTrue()) + Expect(strings.HasPrefix(robotAccountName, expectedRobotAccountPrefix)).To(BeTrue()) + return nil + } + + createImageRepository(imageRepositoryConfig{}) + + Eventually(func() bool { return isCreateRepositoryInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isCreateRobotAccountInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isAddPushPermissionsToRobotAccountInvoked }, timeout, interval).Should(BeTrue()) + + waitImageRepositoryFinalizerOnImageRepository(resourceKey) + + imageRepository := getImageRepository(resourceKey) + + // TODO fix it + // Expect(imageRepository.Spec.Image.Name).To(Equal(expectedImageName)) + Expect(imageRepository.Spec.Image.Visibility).To(Equal(imagerepositoryv1beta1.ImageVisibilityPublic)) + Expect(imageRepository.Status.State).To(Equal(imagerepositoryv1beta1.ImageRepositoryStateReady)) + Expect(imageRepository.Status.Message).To(BeEmpty()) + Expect(imageRepository.Status.Image.URL).To(Equal(expectedImage)) + Expect(imageRepository.Status.Image.Visibility).To(Equal(imagerepositoryv1beta1.ImageVisibilityPublic)) + Expect(imageRepository.Status.Credentials.PushRobotAccountName).To(HavePrefix(expectedRobotAccountPrefix)) + Expect(imageRepository.Status.Credentials.PushSecretName).To(HavePrefix(expectedImageName)) + Expect(imageRepository.Status.Credentials.GenerationTimestamp).ToNot(BeNil()) + + secret := &corev1.Secret{} + secretName := imageRepository.Status.Credentials.PushSecretName + secretKey := types.NamespacedName{Name: secretName, Namespace: defaultNamespace} + waitSecretExist(secretKey) + Expect(k8sClient.Get(ctx, secretKey, secret)).To(Succeed()) + dockerconfigJson := string(secret.Data[corev1.DockerConfigJsonKey]) + var authDataJson interface{} + Expect(json.Unmarshal([]byte(dockerconfigJson), &authDataJson)).To(Succeed()) + Expect(dockerconfigJson).To(ContainSubstring(expectedImage)) + authString, err := base64.StdEncoding.DecodeString(authRegexp.FindStringSubmatch(dockerconfigJson)[1]) + Expect(err).To(Succeed()) + pushRobotAccountName := imageRepository.Status.Credentials.PushRobotAccountName + Expect(string(authString)).To(Equal(fmt.Sprintf("%s:%s", pushRobotAccountName, pushToken))) + + }) + + It("should regenerate token", func() { + newToken := "push-token5678" + + ResetTestQuayClientToFails() + + isRegenerateRobotAccountTokenInvoked := false + RegenerateRobotAccountTokenFunc = func(organization, robotName string) (*quay.RobotAccount, error) { + defer GinkgoRecover() + isRegenerateRobotAccountTokenInvoked = true + Expect(organization).To(Equal(testQuayOrg)) + Expect(strings.HasPrefix(robotName, expectedRobotAccountPrefix)).To(BeTrue()) + return &quay.RobotAccount{Name: robotName, Token: newToken}, nil + } + + imageRepository := getImageRepository(resourceKey) + oldTokenGenerationTimestamp := *imageRepository.Status.Credentials.GenerationTimestamp + regenerateToken := true + imageRepository.Spec.Credentials.RegenerateToken = ®enerateToken + Expect(k8sClient.Update(ctx, imageRepository)).To(Succeed()) + + Eventually(func() bool { return isRegenerateRobotAccountTokenInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { + imageRepository := getImageRepository(resourceKey) + return imageRepository.Spec.Credentials.RegenerateToken == nil && + imageRepository.Status.Credentials.GenerationTimestamp != nil && + *imageRepository.Status.Credentials.GenerationTimestamp != oldTokenGenerationTimestamp + }, timeout, interval).Should(BeTrue()) + + secret := &corev1.Secret{} + secretName := imageRepository.Status.Credentials.PushSecretName + secretKey := types.NamespacedName{Name: secretName, Namespace: defaultNamespace} + Expect(k8sClient.Get(ctx, secretKey, secret)).To(Succeed()) + dockerconfigJson := string(secret.Data[corev1.DockerConfigJsonKey]) + var authDataJson interface{} + Expect(json.Unmarshal([]byte(dockerconfigJson), &authDataJson)).To(Succeed()) + Expect(dockerconfigJson).To(ContainSubstring(expectedImage)) + authString, err := base64.StdEncoding.DecodeString(authRegexp.FindStringSubmatch(dockerconfigJson)[1]) + Expect(err).To(Succeed()) + Expect(string(authString)).To(Equal(fmt.Sprintf("%s:%s", expectedRobotAccountPrefix, newToken))) + }) + + It("should update image visibility", func() { + ResetTestQuayClientToFails() + + isChangeRepositoryVisibilityInvoked := false + ChangeRepositoryVisibilityFunc = func(organization, imageRepository, visibility string) error { + defer GinkgoRecover() + isChangeRepositoryVisibilityInvoked = true + Expect(organization).To(Equal(testQuayOrg)) + Expect(imageRepository).To(Equal(expectedImageName)) + Expect(visibility).To(Equal(imagerepositoryv1beta1.ImageVisibilityPrivate)) + return nil + } + + imageRepository := getImageRepository(resourceKey) + imageRepository.Spec.Image.Visibility = imagerepositoryv1beta1.ImageVisibilityPrivate + Expect(k8sClient.Update(ctx, imageRepository)).To(Succeed()) + + Eventually(func() bool { return isChangeRepositoryVisibilityInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { + imageRepository := getImageRepository(resourceKey) + return imageRepository.Spec.Image.Visibility == imagerepositoryv1beta1.ImageVisibilityPrivate && + imageRepository.Status.Image.Visibility == imagerepositoryv1beta1.ImageVisibilityPrivate && + imageRepository.Status.Message == "" + }, timeout, interval).Should(BeTrue()) + }) + + It("should revert image name if edited", func() { + ResetTestQuayClientToFails() + + imageRepository := getImageRepository(resourceKey) + imageRepository.Spec.Image.Name = "renamed" + Expect(k8sClient.Update(ctx, imageRepository)).To(Succeed()) + + Eventually(func() bool { + imageRepository := getImageRepository(resourceKey) + return imageRepository.Spec.Image.Name == expectedImageName + }, timeout, interval).Should(BeTrue()) + }) + + It("should cleanup repository", func() { + ResetTestQuayClientToFails() + + isDeleteRobotAccountInvoked := false + DeleteRobotAccountFunc = func(organization, robotAccountName string) (bool, error) { + defer GinkgoRecover() + isDeleteRobotAccountInvoked = true + Expect(organization).To(Equal(testQuayOrg)) + Expect(strings.HasPrefix(robotAccountName, expectedRobotAccountPrefix)).To(BeTrue()) + return true, nil + } + isDeleteRepositoryInvoked := false + DeleteRepositoryFunc = func(organization, imageRepository string) (bool, error) { + defer GinkgoRecover() + isDeleteRepositoryInvoked = true + Expect(organization).To(Equal(testQuayOrg)) + Expect(imageRepository).To(Equal(expectedImageName)) + return true, nil + } + + deleteImageRepository(resourceKey) + + Eventually(func() bool { return isDeleteRobotAccountInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isDeleteRepositoryInvoked }, timeout, interval).Should(BeTrue()) + }) + }) + + Context("Image repository for component provision", func() { + + It("should prepare environment", func() { + deleteNamespace(defaultNamespace) + createNamespace(defaultNamespace) + + pushToken = "push-token1234" + pullToken = "pull-token1234" + expectedImageName = fmt.Sprintf("%s-%s/%s", defaultNamespace, defaultComponentApplication, defaultComponentName) + expectedImage = fmt.Sprintf("quay.io/%s/%s", testQuayOrg, expectedImageName) + expectedRobotAccountPrefix = strings.ReplaceAll(strings.ReplaceAll(expectedImageName, "-", "_"), "/", "_") + expectedRemoteSecretName = defaultComponentName + "-image-pull" + }) + + It("should provision image repository for component", func() { + ResetTestQuayClientToFails() + + isCreateRepositoryInvoked := false + CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { + defer GinkgoRecover() + isCreateRepositoryInvoked = true + // Expect(repository.Repository).To(Equal(expectedImageName)) + // Expect(repository.Namespace).To(Equal(testQuayOrg)) + // Expect(repository.Visibility).To(Equal("public")) + // Expect(repository.Description).ToNot(BeEmpty()) + return &quay.Repository{Name: expectedImageName}, nil + } + isCreatePushRobotAccountInvoked := false + isCreatePullRobotAccountInvoked := false + CreateRobotAccountFunc = func(organization, robotName string) (*quay.RobotAccount, error) { + defer GinkgoRecover() + // Expect(organization).To(Equal(testQuayOrg)) + // Expect(strings.HasPrefix(robotName, expectedRobotAccountPrefix)).To(BeTrue()) + if strings.HasSuffix(robotName, "_pull") { + isCreatePullRobotAccountInvoked = true + return &quay.RobotAccount{Name: robotName, Token: pullToken}, nil + } + isCreatePushRobotAccountInvoked = true + return &quay.RobotAccount{Name: robotName, Token: pushToken}, nil + } + isAddPushPermissionsToRobotAccountInvoked := false + isAddPullPermissionsToRobotAccountInvoked := false + AddPermissionsForRepositoryToRobotAccountFunc = func(organization, imageRepository, robotAccountName string, isWrite bool) error { + defer GinkgoRecover() + Expect(organization).To(Equal(testQuayOrg)) + Expect(imageRepository).To(Equal(expectedImageName)) + Expect(strings.HasPrefix(robotAccountName, expectedRobotAccountPrefix)).To(BeTrue()) + if strings.HasSuffix(robotAccountName, "_pull") { + Expect(isWrite).To(BeFalse()) + isAddPullPermissionsToRobotAccountInvoked = true + } else { + Expect(isWrite).To(BeTrue()) + isAddPushPermissionsToRobotAccountInvoked = true + } + return nil + } + + createImageRepository(imageRepositoryConfig{ + ImageName: fmt.Sprintf("%s/%s", defaultComponentApplication, defaultComponentName), + Labels: map[string]string{ + ApplicationNameLabelName: defaultComponentApplication, + ComponentNameLabelName: defaultComponentName, + }, + }) + + 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()) + + waitImageRepositoryFinalizerOnImageRepository(resourceKey) + + imageRepository := getImageRepository(resourceKey) + // TODO fix + // Expect(imageRepository.Spec.Image.Name).To(Equal(expectedImageName)) + Expect(imageRepository.Spec.Image.Visibility).To(Equal(imagerepositoryv1beta1.ImageVisibilityPublic)) + Expect(imageRepository.Status.State).To(Equal(imagerepositoryv1beta1.ImageRepositoryStateReady)) + Expect(imageRepository.Status.Message).To(BeEmpty()) + Expect(imageRepository.Status.Image.URL).To(Equal(expectedImage)) + Expect(imageRepository.Status.Image.Visibility).To(Equal(imagerepositoryv1beta1.ImageVisibilityPublic)) + Expect(imageRepository.Status.Credentials.PushRobotAccountName).To(HavePrefix(expectedRobotAccountPrefix)) + Expect(imageRepository.Status.Credentials.PushSecretName).To(HavePrefix(expectedImageName)) + Expect(imageRepository.Status.Credentials.PullRobotAccountName).To(HavePrefix(expectedRobotAccountPrefix)) + Expect(imageRepository.Status.Credentials.PullRobotAccountName).To(HaveSuffix("_pull")) + Expect(imageRepository.Status.Credentials.PullSecretName).To(Equal(expectedRemoteSecretName)) + Expect(imageRepository.Status.Credentials.GenerationTimestamp).ToNot(BeNil()) + + var authDataJson interface{} + secret := &corev1.Secret{} + secretName := imageRepository.Status.Credentials.PushSecretName + secretKey := types.NamespacedName{Name: secretName, Namespace: defaultNamespace} + waitSecretExist(secretKey) + Expect(k8sClient.Get(ctx, secretKey, secret)).To(Succeed()) + dockerconfigJson := string(secret.Data[corev1.DockerConfigJsonKey]) + Expect(json.Unmarshal([]byte(dockerconfigJson), &authDataJson)).To(Succeed()) + Expect(dockerconfigJson).To(ContainSubstring(expectedImage)) + authString, err := base64.StdEncoding.DecodeString(authRegexp.FindStringSubmatch(dockerconfigJson)[1]) + Expect(err).To(Succeed()) + pushRobotAccountName := imageRepository.Status.Credentials.PushRobotAccountName + Expect(string(authString)).To(Equal(fmt.Sprintf("%s:%s", pushRobotAccountName, pushToken))) + + remoteSecretKey := types.NamespacedName{Name: expectedRemoteSecretName, Namespace: defaultNamespace} + remoteSecret := waitRemoteSecretExist(remoteSecretKey) + Expect(remoteSecret.Labels[ApplicationNameLabelName]).To(Equal(defaultComponentApplication)) + Expect(remoteSecret.Labels[ComponentNameLabelName]).To(Equal(defaultComponentName)) + Expect(remoteSecret.OwnerReferences).To(HaveLen(1)) + Expect(remoteSecret.OwnerReferences[0].Name).To(Equal(imageRepository.Name)) + Expect(remoteSecret.OwnerReferences[0].Kind).To(Equal("ImageRepository")) + Expect(remoteSecret.Spec.Secret.Name).To(Equal(remoteSecretKey.Name)) + Expect(remoteSecret.Spec.Secret.Type).To(Equal(corev1.SecretTypeDockerConfigJson)) + Expect(remoteSecret.Spec.Secret.LinkedTo).To(HaveLen(1)) + Expect(remoteSecret.Spec.Secret.LinkedTo[0].ServiceAccount.Reference.Name).To(Equal(defaultServiceAccountName)) + + uploadSecretKey := types.NamespacedName{Name: "upload-secret-" + expectedRemoteSecretName, Namespace: defaultNamespace} + uploadSecret := waitSecretExist(uploadSecretKey) + Expect(uploadSecret.Labels[remotesecretv1beta1.UploadSecretLabel]).To(Equal("remotesecret")) + Expect(uploadSecret.Annotations[remotesecretv1beta1.RemoteSecretNameAnnotation]).To(Equal(expectedRemoteSecretName)) + uploadSecretDockerconfigJson := string(uploadSecret.Data[corev1.DockerConfigJsonKey]) + 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()) + pullRobotAccountName := imageRepository.Status.Credentials.PullRobotAccountName + Expect(string(uploadSecretAuthString)).To(Equal(fmt.Sprintf("%s:%s", pullRobotAccountName, pullToken))) + + deleteSecret(uploadSecretKey) + }) + + It("should regenerate tokens and update remote secret", func() { + newPushToken := "push-token5678" + newPullToken := "pull-token5678" + + ResetTestQuayClientToFails() + + isRegenerateRobotAccountTokenForPushInvoked := false + isRegenerateRobotAccountTokenForPullInvoked := false + RegenerateRobotAccountTokenFunc = func(organization, robotName string) (*quay.RobotAccount, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(testQuayOrg)) + Expect(strings.HasPrefix(robotName, expectedRobotAccountPrefix)).To(BeTrue()) + if strings.HasSuffix(robotName, "_pull") { + isRegenerateRobotAccountTokenForPullInvoked = true + return &quay.RobotAccount{Name: robotName, Token: newPullToken}, nil + } + isRegenerateRobotAccountTokenForPushInvoked = true + return &quay.RobotAccount{Name: robotName, Token: newPushToken}, nil + } + + imageRepository := getImageRepository(resourceKey) + oldTokenGenerationTimestamp := *imageRepository.Status.Credentials.GenerationTimestamp + regenerateToken := true + imageRepository.Spec.Credentials.RegenerateToken = ®enerateToken + Expect(k8sClient.Update(ctx, imageRepository)).To(Succeed()) + + Eventually(func() bool { return isRegenerateRobotAccountTokenForPushInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isRegenerateRobotAccountTokenForPullInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { + imageRepository := getImageRepository(resourceKey) + return imageRepository.Spec.Credentials.RegenerateToken == nil && + imageRepository.Status.Credentials.GenerationTimestamp != nil && + *imageRepository.Status.Credentials.GenerationTimestamp != oldTokenGenerationTimestamp + }, timeout, interval).Should(BeTrue()) + + secret := &corev1.Secret{} + secretName := imageRepository.Status.Credentials.PushSecretName + secretKey := types.NamespacedName{Name: secretName, Namespace: defaultNamespace} + Expect(k8sClient.Get(ctx, secretKey, secret)).To(Succeed()) + dockerconfigJson := string(secret.Data[corev1.DockerConfigJsonKey]) + var authDataJson interface{} + Expect(json.Unmarshal([]byte(dockerconfigJson), &authDataJson)).To(Succeed()) + Expect(dockerconfigJson).To(ContainSubstring(expectedImage)) + authString, err := base64.StdEncoding.DecodeString(authRegexp.FindStringSubmatch(dockerconfigJson)[1]) + Expect(err).To(Succeed()) + Expect(string(authString)).To(Equal(fmt.Sprintf("%s:%s", expectedRobotAccountPrefix, newPushToken))) + + uploadSecretKey := types.NamespacedName{Name: "upload-secret-" + expectedRemoteSecretName, Namespace: defaultNamespace} + uploadSecret := waitSecretExist(uploadSecretKey) + Expect(uploadSecret.Labels[remotesecretv1beta1.UploadSecretLabel]).To(Equal("remotesecret")) + Expect(uploadSecret.Annotations[remotesecretv1beta1.RemoteSecretNameAnnotation]).To(Equal(expectedRemoteSecretName)) + uploadSecretDockerconfigJson := string(uploadSecret.Data[corev1.DockerConfigJsonKey]) + 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()) + pullRobotAccountName := imageRepository.Status.Credentials.PullRobotAccountName + Expect(string(uploadSecretAuthString)).To(Equal(fmt.Sprintf("%s:%s", pullRobotAccountName, newPullToken))) + + deleteSecret(uploadSecretKey) + }) + + It("should cleanup component repository", func() { + ResetTestQuayClientToFails() + + isDeleteRobotAccountForPushInvoked := false + isDeleteRobotAccountForPullInvoked := false + DeleteRobotAccountFunc = func(organization, robotAccountName string) (bool, error) { + defer GinkgoRecover() + Expect(organization).To(Equal(testQuayOrg)) + Expect(strings.HasPrefix(robotAccountName, expectedRobotAccountPrefix)).To(BeTrue()) + if strings.HasSuffix(robotAccountName, "_pull") { + isDeleteRobotAccountForPushInvoked = true + } else { + isDeleteRobotAccountForPullInvoked = true + } + return true, nil + } + isDeleteRepositoryInvoked := false + DeleteRepositoryFunc = func(organization, imageRepository string) (bool, error) { + defer GinkgoRecover() + isDeleteRepositoryInvoked = true + Expect(organization).To(Equal(testQuayOrg)) + Expect(imageRepository).To(Equal(expectedImageName)) + return true, nil + } + + deleteImageRepository(resourceKey) + + Eventually(func() bool { return isDeleteRobotAccountForPushInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isDeleteRobotAccountForPullInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { return isDeleteRepositoryInvoked }, timeout, interval).Should(BeTrue()) + }) + }) + + Context("Image repository error scenarios", func() { + + It("should prepare environment", func() { + createNamespace(defaultNamespace) + + pushToken = "push-token1234" + expectedImageName = fmt.Sprintf("%s-%s", defaultNamespace, defaultImageRepositoryName) + expectedImage = fmt.Sprintf("quay.io/%s/%s", testQuayOrg, expectedImageName) + expectedRobotAccountPrefix = strings.ReplaceAll(expectedImageName, "-", "_") + }) + + It("should permanently fail if private image repository requested on creation but quota exceeded", func() { + ResetTestQuayClient() + + isCreateRepositoryInvoked := false + CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { + defer GinkgoRecover() + isCreateRepositoryInvoked = true + Expect(repository.Repository).To(Equal(expectedImageName)) + Expect(repository.Namespace).To(Equal(testQuayOrg)) + Expect(repository.Visibility).To(Equal("private")) + Expect(repository.Description).ToNot(BeEmpty()) + return nil, fmt.Errorf("payment required") + } + + createImageRepository(imageRepositoryConfig{}) + + Eventually(func() bool { return isCreateRepositoryInvoked }, timeout, interval).Should(BeTrue()) + + imageRepository := getImageRepository(resourceKey) + Expect(imageRepository.Status.State).To(Equal(imagerepositoryv1beta1.ImageRepositoryStateFailed)) + Expect(imageRepository.Status.Message).ToNot(BeEmpty()) + Expect(imageRepository.Status.Message).To(ContainSubstring("exceeds current quay plan limit")) + + deleteImageRepository(resourceKey) + }) + + It("should add error message and revert visibility in spec if private visibility requested after provision but quota exceeded", func() { + ResetTestQuayClient() + + CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { + return &quay.Repository{Name: expectedImageName}, nil + } + CreateRobotAccountFunc = func(organization, robotName string) (*quay.RobotAccount, error) { + return &quay.RobotAccount{Name: robotName, Token: pushToken}, nil + } + createImageRepository(imageRepositoryConfig{}) + waitImageRepositoryFinalizerOnImageRepository(resourceKey) + + ResetTestQuayClientToFails() + + isChangeRepositoryVisibilityInvoked := false + ChangeRepositoryVisibilityFunc = func(organization, imageRepository, visibility string) error { + defer GinkgoRecover() + isChangeRepositoryVisibilityInvoked = true + Expect(organization).To(Equal(testQuayOrg)) + Expect(imageRepository).To(Equal(expectedImageName)) + Expect(visibility).To(Equal(imagerepositoryv1beta1.ImageVisibilityPrivate)) + return fmt.Errorf("payment required") + } + + imageRepository := getImageRepository(resourceKey) + imageRepository.Spec.Image.Visibility = imagerepositoryv1beta1.ImageVisibilityPrivate + Expect(k8sClient.Update(ctx, imageRepository)).To(Succeed()) + + Eventually(func() bool { return isChangeRepositoryVisibilityInvoked }, timeout, interval).Should(BeTrue()) + Eventually(func() bool { + imageRepository := getImageRepository(resourceKey) + return imageRepository.Spec.Image.Visibility == imagerepositoryv1beta1.ImageVisibilityPublic && + imageRepository.Status.Image.Visibility == imagerepositoryv1beta1.ImageVisibilityPublic && + imageRepository.Status.Message != "" + }, timeout, interval).Should(BeTrue()) + + deleteImageRepository(resourceKey) + }) + + It("should fail if invalid image repository name given", func() { + deleteImageRepository(resourceKey) + + imageRepository := getImageRepositoryConfig(imageRepositoryConfig{ + ImageName: "wrong&name", + }) + Expect(k8sClient.Create(ctx, imageRepository)).ToNot(Succeed()) + }) + }) + +}) diff --git a/controllers/suite_test.go b/controllers/suite_test.go index a7ed393..77aa343 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -37,6 +37,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" appstudioredhatcomv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + imagerepositoryv1beta1 "github.com/redhat-appstudio/image-controller/api/v1beta1" "github.com/redhat-appstudio/image-controller/pkg/quay" remotesecretv1beta1 "github.com/redhat-appstudio/remote-secret/api/v1beta1" //+kubebuilder:scaffold:imports @@ -73,6 +74,7 @@ var _ = BeforeSuite(func() { testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{ + filepath.Join("..", "config", "crd", "bases"), filepath.Join(build.Default.GOPATH, "pkg", "mod", "github.com", "redhat-appstudio", "application-api@"+applicationApiDepVersion, "config", "crd", "bases"), filepath.Join(build.Default.GOPATH, "pkg", "mod", "github.com", "redhat-appstudio", "remote-secret@"+remoteSecretApiDepVersion, "config", "crd", "bases"), }, @@ -93,6 +95,9 @@ var _ = BeforeSuite(func() { err = remotesecretv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = imagerepositoryv1beta1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + //+kubebuilder:scaffold:scheme k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) @@ -104,6 +109,14 @@ var _ = BeforeSuite(func() { }) Expect(err).ToNot(HaveOccurred()) + err = (&ImageRepositoryReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + BuildQuayClient: func(l logr.Logger) quay.QuayService { return testQuayClient }, + QuayOrganization: testQuayOrg, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + err = (&ComponentReconciler{ Client: k8sManager.GetClient(), Scheme: k8sManager.GetScheme(), diff --git a/controllers/suite_util_quay_client_test.go b/controllers/suite_util_quay_client_test.go index 7487fa6..2197eb3 100644 --- a/controllers/suite_util_quay_client_test.go +++ b/controllers/suite_util_quay_client_test.go @@ -16,7 +16,11 @@ limitations under the License. package controllers -import "github.com/redhat-appstudio/image-controller/pkg/quay" +import ( + . "github.com/onsi/ginkgo/v2" + + "github.com/redhat-appstudio/image-controller/pkg/quay" +) const ( testQuayOrg = "user-workloads" @@ -37,6 +41,7 @@ var ( CreateRobotAccountFunc func(organization string, robotName string) (*quay.RobotAccount, error) DeleteRobotAccountFunc func(organization string, robotName string) (bool, error) AddPermissionsForRepositoryToRobotAccountFunc func(organization, imageRepository, robotAccountName string, isWrite bool) error + RegenerateRobotAccountTokenFunc func(organization string, robotName string) (*quay.RobotAccount, error) ) func ResetTestQuayClient() { @@ -47,6 +52,42 @@ func ResetTestQuayClient() { CreateRobotAccountFunc = func(organization, robotName string) (*quay.RobotAccount, error) { return &quay.RobotAccount{}, nil } DeleteRobotAccountFunc = func(organization, robotName string) (bool, error) { return true, nil } AddPermissionsForRepositoryToRobotAccountFunc = func(organization, imageRepository, robotAccountName string, isWrite bool) error { return nil } + RegenerateRobotAccountTokenFunc = func(organization, robotName string) (*quay.RobotAccount, error) { return &quay.RobotAccount{}, nil } +} + +func ResetTestQuayClientToFails() { + CreateRepositoryFunc = func(repository quay.RepositoryRequest) (*quay.Repository, error) { + Fail("CreateRepositoryFunc invoked") + return nil, nil + } + DeleteRepositoryFunc = func(organization, imageRepository string) (bool, error) { + Fail("DeleteRepository invoked") + return true, nil + } + ChangeRepositoryVisibilityFunc = func(organization, imageRepository string, visibility string) error { + Fail("ChangeRepositoryVisibility invoked") + return nil + } + GetRobotAccountFunc = func(organization, robotName string) (*quay.RobotAccount, error) { + Fail("GetRobotAccount invoked") + return nil, nil + } + CreateRobotAccountFunc = func(organization, robotName string) (*quay.RobotAccount, error) { + Fail("CreateRobotAccount invoked") + return nil, nil + } + DeleteRobotAccountFunc = func(organization, robotName string) (bool, error) { + Fail("DeleteRobotAccount invoked") + return true, nil + } + AddPermissionsForRepositoryToRobotAccountFunc = func(organization, imageRepository, robotAccountName string, isWrite bool) error { + Fail("AddPermissionsForRepositoryToRobotAccount invoked") + return nil + } + RegenerateRobotAccountTokenFunc = func(organization, robotName string) (*quay.RobotAccount, error) { + Fail("RegenerateRobotAccountToken invoked") + return nil, nil + } } func (c *TestQuayClient) CreateRepository(repositoryRequest quay.RepositoryRequest) (*quay.Repository, error) { @@ -70,6 +111,9 @@ func (c *TestQuayClient) DeleteRobotAccount(organization string, robotName strin func (c *TestQuayClient) AddPermissionsForRepositoryToRobotAccount(organization, imageRepository, robotAccountName string, isWrite bool) error { return AddPermissionsForRepositoryToRobotAccountFunc(organization, imageRepository, robotAccountName, isWrite) } +func (c *TestQuayClient) RegenerateRobotAccountToken(organization string, robotName string) (*quay.RobotAccount, error) { + return RegenerateRobotAccountTokenFunc(organization, robotName) +} func (c *TestQuayClient) GetAllRepositories(organization string) ([]quay.Repository, error) { return nil, nil } diff --git a/controllers/suite_util_test.go b/controllers/suite_util_test.go index 8129988..87e5499 100644 --- a/controllers/suite_util_test.go +++ b/controllers/suite_util_test.go @@ -24,11 +24,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" appstudioapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + imagerepositoryv1beta1 "github.com/redhat-appstudio/image-controller/api/v1beta1" remotesecretv1beta1 "github.com/redhat-appstudio/remote-secret/api/v1beta1" ) @@ -43,11 +45,79 @@ const ( ) const ( + defaultNamespace = "test-namespace" + + defaultImageRepositoryName = "image-repository" + defaultComponentName = "test-component" - defaultComponentNamespace = "test-namespace" defaultComponentApplication = "test-application" ) +type imageRepositoryConfig struct { + ResourceKey *types.NamespacedName + ImageName string + IsPrivate bool + Labels map[string]string +} + +func getImageRepositoryConfig(config imageRepositoryConfig) *imagerepositoryv1beta1.ImageRepository { + name := defaultImageRepositoryName + namespace := defaultNamespace + if config.ResourceKey != nil { + name = config.ResourceKey.Name + namespace = config.ResourceKey.Namespace + } + imageName := defaultImageRepositoryName + if config.ImageName != "" { + imageName = config.ImageName + } + visibility := "public" + if config.IsPrivate { + visibility = "private" + } + return &imagerepositoryv1beta1.ImageRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: config.Labels, + }, + Spec: imagerepositoryv1beta1.ImageRepositorySpec{ + Image: imagerepositoryv1beta1.ImageParameters{ + Name: imageName, + Visibility: imagerepositoryv1beta1.ImageVisibility(visibility), + }, + }, + } +} + +func createImageRepository(config imageRepositoryConfig) { + imageRepository := getImageRepositoryConfig(config) + Expect(k8sClient.Create(ctx, imageRepository)).To(Succeed()) +} + +func getImageRepository(imageRepositoryKey types.NamespacedName) *imagerepositoryv1beta1.ImageRepository { + imageRepository := &imagerepositoryv1beta1.ImageRepository{} + Eventually(func() bool { + Expect(k8sClient.Get(ctx, imageRepositoryKey, imageRepository)).Should(Succeed()) + return imageRepository.ResourceVersion != "" + }, timeout, interval).Should(BeTrue()) + return imageRepository +} + +func deleteImageRepository(imageRepositoryKey types.NamespacedName) { + imageRepository := &imagerepositoryv1beta1.ImageRepository{} + if err := k8sClient.Get(ctx, imageRepositoryKey, imageRepository); err != nil { + if errors.IsNotFound(err) { + return + } + Fail("Failed to get image repository") + } + Expect(k8sClient.Delete(ctx, imageRepository)).To(Succeed()) + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, imageRepositoryKey, imageRepository)) + }, timeout, interval).Should(BeTrue()) +} + type componentConfig struct { ComponentKey types.NamespacedName ComponentApplication string @@ -61,7 +131,7 @@ func getSampleComponentData(config componentConfig) *appstudioapiv1alpha1.Compon } namespace := config.ComponentKey.Namespace if namespace == "" { - namespace = defaultComponentNamespace + namespace = defaultNamespace } application := config.ComponentApplication if application == "" { @@ -202,7 +272,17 @@ func waitFinalizerOnComponent(componentKey types.NamespacedName, finalizerName s } func waitImageRepositoryFinalizerOnComponent(componentKey types.NamespacedName) { - waitFinalizerOnComponent(componentKey, ImageRepositoryFinalizer, true) + waitFinalizerOnComponent(componentKey, ImageRepositoryComponentFinalizer, true) +} + +func waitImageRepositoryFinalizerOnImageRepository(imageRepositoryKey types.NamespacedName) { + imageRepository := &imagerepositoryv1beta1.ImageRepository{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, imageRepositoryKey, imageRepository); err != nil { + return false + } + return controllerutil.ContainsFinalizer(imageRepository, ImageRepositoryFinalizer) + }, timeout, interval).Should(BeTrue()) } func createNamespace(name string) { diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt index 65b8622..77835b6 100644 --- a/hack/boilerplate.go.txt +++ b/hack/boilerplate.go.txt @@ -1,5 +1,5 @@ /* -Copyright 2023. +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. diff --git a/main.go b/main.go index 31155a4..d90c256 100644 --- a/main.go +++ b/main.go @@ -39,9 +39,11 @@ import ( "github.com/go-logr/logr" appstudioredhatcomv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + remotesecretv1beta1 "github.com/redhat-appstudio/remote-secret/api/v1beta1" + + imagerepositoryv1beta1 "github.com/redhat-appstudio/image-controller/api/v1beta1" "github.com/redhat-appstudio/image-controller/controllers" "github.com/redhat-appstudio/image-controller/pkg/quay" - remotesecretv1beta1 "github.com/redhat-appstudio/remote-secret/api/v1beta1" //+kubebuilder:scaffold:imports ) @@ -60,6 +62,7 @@ func init() { utilruntime.Must(appstudioredhatcomv1alpha1.AddToScheme(scheme)) utilruntime.Must(remotesecretv1beta1.AddToScheme(scheme)) + utilruntime.Must(imagerepositoryv1beta1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme } @@ -133,6 +136,16 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Controller") os.Exit(1) } + + if err = (&controllers.ImageRepositoryReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + BuildQuayClient: buildQuayClientFunc, + QuayOrganization: quayOrganization, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ImageRepository") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/pkg/quay/quay.go b/pkg/quay/quay.go index 6292d1e..bc0fffe 100644 --- a/pkg/quay/quay.go +++ b/pkg/quay/quay.go @@ -35,6 +35,7 @@ type QuayService interface { CreateRobotAccount(organization string, robotName string) (*RobotAccount, error) DeleteRobotAccount(organization string, robotName string) (bool, error) AddPermissionsForRepositoryToRobotAccount(organization, imageRepository, robotAccountName string, isWrite bool) error + RegenerateRobotAccountToken(organization string, robotName string) (*RobotAccount, error) GetAllRepositories(organization string) ([]Repository, error) GetAllRobotAccounts(organization string) ([]RobotAccount, error) GetTagsFromPage(organization, repository string, page int) ([]Tag, bool, error) @@ -412,6 +413,43 @@ func (c *QuayClient) AddPermissionsForRepositoryToRobotAccount(organization, ima 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) + + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + fmt.Println(err) + return nil, err + } + req.Header.Add("Authorization", fmt.Sprintf("%s %s", "Bearer", c.AuthToken)) + req.Header.Add("Content-Type", "application/json") + + res, err := c.httpClient.Do(req) + if err != nil { + fmt.Println(err) + return nil, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + fmt.Println(err) + return nil, err + } + + data := &RobotAccount{} + err = json.Unmarshal(body, data) + if err != nil { + fmt.Println(err) + return nil, err + } + + if data.Message != "" { + return data, errors.New(data.Message) + } + return data, nil +} + // Returns all repositories of the DEFAULT_QUAY_ORG organization (used in e2e-tests) func (c *QuayClient) GetAllRepositories(organization string) ([]Repository, error) { url := fmt.Sprintf("%s/repository", c.url) diff --git a/pkg/quay/quay_debug_test.go b/pkg/quay/quay_debug_test.go index e21bdac..bdc6a7d 100644 --- a/pkg/quay/quay_debug_test.go +++ b/pkg/quay/quay_debug_test.go @@ -152,3 +152,22 @@ func TestAddPermissionsToRobotAccount(t *testing.T) { t.Fatal(err) } } + +func TestRegenerateRobotAccountToken(t *testing.T) { + if quayToken == "" { + return + } + + quayClient := NewQuayClient(&http.Client{Transport: &http.Transport{}}, quayToken, quayApiUrl) + + robotAccount, err := quayClient.RegenerateRobotAccountToken(quayOrgName, quayRobotAccountName) + if err != nil { + t.Fatal(err) + } + if robotAccount == nil { + t.Fatal("Updated robot account should not be nil") + } + if robotAccount.Token == "" { + t.Fatal("Token must be updated") + } +}