che-operator/pkg/deploy/image-puller/imagepuller.go

441 lines
15 KiB
Go

//
// Copyright (c) 2019-2021 Red Hat, Inc.
// This program and the accompanying materials are made
// available under the terms of the Eclipse Public License 2.0
// which is available at https://www.eclipse.org/legal/epl-2.0/
//
// SPDX-License-Identifier: EPL-2.0
//
// Contributors:
// Red Hat, Inc. - initial API and implementation
//
package imagepuller
import (
"context"
goerror "errors"
"fmt"
"sort"
"strings"
"time"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/go-cmp/cmp"
"github.com/sirupsen/logrus"
ctrl "sigs.k8s.io/controller-runtime"
chev1alpha1 "github.com/che-incubator/kubernetes-image-puller-operator/api/v1alpha1"
"github.com/eclipse-che/che-operator/pkg/common/chetypes"
"github.com/eclipse-che/che-operator/pkg/common/constants"
"github.com/eclipse-che/che-operator/pkg/common/utils"
"github.com/eclipse-che/che-operator/pkg/deploy"
operatorsv1 "github.com/operator-framework/api/pkg/operators/v1"
operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1"
packagesv1 "github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
var (
log = ctrl.Log.WithName("image-puller")
defaultImagePatterns = [...]string{
"^RELATED_IMAGE_.*_theia.*",
"^RELATED_IMAGE_.*_code.*",
"^RELATED_IMAGE_.*_idea.*",
"^RELATED_IMAGE_.*_machine(_)?exec(_.*)?_plugin_registry_image.*",
"^RELATED_IMAGE_.*_kubernetes(_.*)?_plugin_registry_image.*",
"^RELATED_IMAGE_.*_openshift(_.*)?_plugin_registry_image.*",
"^RELATED_IMAGE_universal(_)?developer(_)?image(_.*)?_devfile_registry_image.*",
}
kubernetesImagePullerDiffOpts = cmp.Options{
cmpopts.IgnoreFields(chev1alpha1.KubernetesImagePuller{}, "TypeMeta", "ObjectMeta", "Status"),
}
)
const (
subscriptionName = "kubernetes-imagepuller-operator"
operatorGroupName = "kubernetes-imagepuller-operator"
packageName = "kubernetes-imagepuller-operator"
componentName = "kubernetes-image-puller"
imagePullerFinalizerName = "kubernetesimagepullers.finalizers.che.eclipse.org"
defaultConfigMapName = "k8s-image-puller"
defaultDeploymentName = "kubernetes-image-puller"
defaultImagePullerImage = "quay.io/eclipse/kubernetes-image-puller:next"
)
type Images2Pull = map[string]string
type ImagePuller struct {
deploy.Reconcilable
}
func NewImagePuller() *ImagePuller {
return &ImagePuller{}
}
func (ip *ImagePuller) Reconcile(ctx *chetypes.DeployContext) (reconcile.Result, bool, error) {
if ctx.CheCluster.Spec.Components.ImagePuller.Enable {
if foundPackagesAPI, foundOperatorsAPI, _, err := ip.discoverImagePullerApis(ctx); !foundPackagesAPI || !foundOperatorsAPI {
if err != nil {
return reconcile.Result{}, false, err
}
errorMsg := "couldn't find Operator Lifecycle Manager types to install the Kubernetes Image Puller Operator. Please install Operator Lifecycle Manager to install the operator or disable the image puller by setting `spec.imagePuller.enable` to false"
return reconcile.Result{RequeueAfter: time.Second}, false, fmt.Errorf(errorMsg)
}
if err := deploy.AppendFinalizer(ctx, imagePullerFinalizerName); err != nil {
return reconcile.Result{}, false, err
}
if done, err := ip.syncOperatorGroup(ctx); !done {
return reconcile.Result{}, false, err
}
if done, err := ip.syncSubscription(ctx); !done {
return reconcile.Result{}, false, err
}
if done, err := ip.syncDefaultImages(ctx); !done {
return reconcile.Result{}, false, err
}
// Wait for KubernetesImagePuller API
if _, _, foundKubernetesImagePullerAPI, err := ip.discoverImagePullerApis(ctx); !foundKubernetesImagePullerAPI {
if err != nil {
return reconcile.Result{}, false, err
}
logrus.Infof("Waiting 15 seconds for kubernetesimagepullers.che.eclipse.org API")
return reconcile.Result{RequeueAfter: 15 * time.Second}, false, nil
}
if done, err := ip.syncKubernetesImagePuller(ctx); !done {
return reconcile.Result{}, false, err
}
} else {
if done, err := ip.uninstallImagePullerOperator(ctx); !done {
return reconcile.Result{}, false, err
}
}
return reconcile.Result{}, true, nil
}
func (ip *ImagePuller) Finalize(ctx *chetypes.DeployContext) bool {
done, err := ip.uninstallImagePullerOperator(ctx)
if err != nil {
log.Error(err, "Failed to uninstall KubernetesImagePuller")
}
return done
}
// Uninstall the CSV, OperatorGroup, Subscription, KubernetesImagePuller, and update the CheCluster to remove
// the image puller spec. Returns true if the CheCluster was updated
func (ip *ImagePuller) uninstallImagePullerOperator(ctx *chetypes.DeployContext) (bool, error) {
_, foundOperatorsAPI, foundKubernetesImagePullerAPI, err := ip.discoverImagePullerApis(ctx)
if err != nil {
return false, err
}
if foundKubernetesImagePullerAPI {
if done, err := deploy.DeleteByKeyWithClient(
ctx.ClusterAPI.NonCachingClient,
types.NamespacedName{Namespace: ctx.CheCluster.Namespace, Name: getImagePullerOperatorName(ctx)},
&chev1alpha1.KubernetesImagePuller{},
); !done {
return false, err
}
}
if foundOperatorsAPI {
// Delete the Subscription and ClusterServiceVersion
subscription := &operatorsv1alpha1.Subscription{}
if exists, err := deploy.GetWithClient(
ctx.ClusterAPI.NonCachingClient,
types.NamespacedName{Namespace: ctx.CheCluster.Namespace, Name: subscriptionName},
subscription,
); err != nil {
return false, err
} else if exists {
if subscription.Status.InstalledCSV != "" {
if done, err := deploy.DeleteByKeyWithClient(
ctx.ClusterAPI.NonCachingClient,
types.NamespacedName{Namespace: ctx.CheCluster.Namespace, Name: subscription.Status.InstalledCSV},
&operatorsv1alpha1.ClusterServiceVersion{}); !done {
return false, err
}
}
if done, err := deploy.DeleteByKeyWithClient(
ctx.ClusterAPI.NonCachingClient,
types.NamespacedName{Namespace: ctx.CheCluster.Namespace, Name: subscriptionName},
&operatorsv1alpha1.Subscription{}); !done {
return false, err
}
}
// Delete the OperatorGroup
if done, err := deploy.DeleteByKeyWithClient(
ctx.ClusterAPI.NonCachingClient,
types.NamespacedName{Namespace: ctx.CheCluster.Namespace, Name: operatorGroupName},
&operatorsv1.OperatorGroup{},
); !done {
return false, err
}
}
if err := deploy.DeleteFinalizer(ctx, imagePullerFinalizerName); err != nil {
return false, err
}
return true, nil
}
// CheckNeededImagePullerApis check if the API server can discover the API groups
// for packages.operators.coreos.com, operators.coreos.com, and che.eclipse.org.
// Returns:
// foundPackagesAPI - true if the server discovers the packages.operators.coreos.com API
// foundOperatorsAPI - true if the server discovers the operators.coreos.com API
// foundKubernetesImagePullerAPI - true if the server discovers the che.eclipse.org API
// error - any error returned by the call to discoveryClient.ServerGroups()
func (ip *ImagePuller) discoverImagePullerApis(ctx *chetypes.DeployContext) (bool, bool, bool, error) {
groupList, resourcesList, err := ctx.ClusterAPI.DiscoveryClient.ServerGroupsAndResources()
if err != nil {
return false, false, false, err
}
foundPackagesAPI := false
foundOperatorsAPI := false
foundKubernetesImagePullerAPI := false
for _, group := range groupList {
if group.Name == packagesv1.SchemeGroupVersion.Group {
foundPackagesAPI = true
}
if group.Name == operatorsv1alpha1.SchemeGroupVersion.Group {
foundOperatorsAPI = true
}
}
for _, l := range resourcesList {
for _, r := range l.APIResources {
if l.GroupVersion == chev1alpha1.SchemeBuilder.GroupVersion.String() && r.Kind == "KubernetesImagePuller" {
foundKubernetesImagePullerAPI = true
}
}
}
return foundPackagesAPI, foundOperatorsAPI, foundKubernetesImagePullerAPI, nil
}
func (ip *ImagePuller) syncDefaultImages(ctx *chetypes.DeployContext) (bool, error) {
defaultImages := getDefaultImages()
specImages := stringToImages(ctx.CheCluster.Spec.Components.ImagePuller.Spec.Images)
if len(specImages) == 0 {
specImages = defaultImages
} else {
for specImageName, specImage := range specImages {
for defaultImageName, defaultImage := range defaultImages {
if specImageName == defaultImageName && specImage != defaultImage {
specImages[specImageName] = defaultImage
}
}
}
}
specImagesAsString := imagesToString(specImages)
if ctx.CheCluster.Spec.Components.ImagePuller.Spec.Images != specImagesAsString {
ctx.CheCluster.Spec.Components.ImagePuller.Spec.Images = specImagesAsString
err := deploy.UpdateCheCRSpec(ctx, "components.imagePuller.spec.images ", specImagesAsString)
return err == nil, err
}
return true, nil
}
func (ip *ImagePuller) syncOperatorGroup(ctx *chetypes.DeployContext) (bool, error) {
operatorGroupList := &operatorsv1.OperatorGroupList{}
if err := ctx.ClusterAPI.NonCachingClient.List(context.TODO(), operatorGroupList, &client.ListOptions{Namespace: ctx.CheCluster.Namespace}); err != nil {
return false, err
}
if len(operatorGroupList.Items) != 0 {
return true, nil
}
operatorGroup := &operatorsv1.OperatorGroup{
TypeMeta: metav1.TypeMeta{
Kind: "OperatorGroup",
APIVersion: operatorsv1.SchemeGroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: operatorGroupName,
Namespace: ctx.CheCluster.Namespace,
Labels: map[string]string{
constants.KubernetesPartOfLabelKey: constants.CheEclipseOrg,
constants.KubernetesComponentLabelKey: componentName,
constants.KubernetesManagedByLabelKey: deploy.GetManagedByLabel(),
},
},
Spec: operatorsv1.OperatorGroupSpec{
TargetNamespaces: []string{},
},
}
_, err := deploy.CreateIfNotExistsWithClient(ctx.ClusterAPI.NonCachingClient, ctx, operatorGroup)
return err == nil, err
}
func (ip *ImagePuller) syncSubscription(ctx *chetypes.DeployContext) (bool, error) {
packageManifest := &packagesv1.PackageManifest{}
if exists, err := deploy.GetWithClient(ctx.ClusterAPI.NonCachingClient, types.NamespacedName{Namespace: ctx.CheCluster.Namespace, Name: packageName}, packageManifest); !exists {
if err != nil {
return false, err
}
return false, fmt.Errorf("there is no PackageManifest for the Kubernetes Image Puller Operator. Install the Operator Lifecycle Manager and the Community Operators Catalog")
}
subscription := &operatorsv1alpha1.Subscription{
TypeMeta: metav1.TypeMeta{
Kind: "Subscription",
APIVersion: operatorsv1alpha1.SchemeGroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: subscriptionName,
Namespace: ctx.CheCluster.Namespace,
Labels: map[string]string{
constants.KubernetesPartOfLabelKey: constants.CheEclipseOrg,
constants.KubernetesComponentLabelKey: componentName,
constants.KubernetesManagedByLabelKey: deploy.GetManagedByLabel(),
},
},
Spec: &operatorsv1alpha1.SubscriptionSpec{
CatalogSource: packageManifest.Status.CatalogSource,
CatalogSourceNamespace: packageManifest.Status.CatalogSourceNamespace,
Channel: packageManifest.Status.DefaultChannel,
InstallPlanApproval: operatorsv1alpha1.ApprovalAutomatic,
Package: packageName,
},
}
_, err := deploy.CreateIfNotExistsWithClient(ctx.ClusterAPI.NonCachingClient, ctx, subscription)
return err == nil, err
}
func (ip *ImagePuller) syncKubernetesImagePuller(ctx *chetypes.DeployContext) (bool, error) {
imagePuller := &chev1alpha1.KubernetesImagePuller{
TypeMeta: metav1.TypeMeta{
APIVersion: chev1alpha1.GroupVersion.String(),
Kind: "KubernetesImagePuller",
},
ObjectMeta: metav1.ObjectMeta{
Name: getImagePullerOperatorName(ctx),
Namespace: ctx.CheCluster.Namespace,
Labels: map[string]string{
constants.KubernetesPartOfLabelKey: constants.CheEclipseOrg,
constants.KubernetesComponentLabelKey: componentName,
constants.KubernetesManagedByLabelKey: deploy.GetManagedByLabel(),
},
},
Spec: ctx.CheCluster.Spec.Components.ImagePuller.Spec,
}
// Set default values to avoid syncing object on every loop
// See https://github.com/che-incubator/kubernetes-image-puller-operator/blob/main/controllers/kubernetesimagepuller_controller.go
imagePuller.Spec.ConfigMapName = utils.GetValue(imagePuller.Spec.ConfigMapName, defaultConfigMapName)
imagePuller.Spec.DeploymentName = utils.GetValue(imagePuller.Spec.DeploymentName, defaultDeploymentName)
imagePuller.Spec.ImagePullerImage = utils.GetValue(imagePuller.Spec.ImagePullerImage, defaultImagePullerImage)
return deploy.SyncWithClient(ctx.ClusterAPI.NonCachingClient, ctx, imagePuller, kubernetesImagePullerDiffOpts)
}
func getImagePullerOperatorName(ctx *chetypes.DeployContext) string {
return ctx.CheCluster.Name + "-image-puller"
}
// imagesToString returns a string representation of the provided image slice,
// suitable for the imagePuller.spec.images field
func imagesToString(images Images2Pull) string {
imageNames := make([]string, 0, len(images))
for k := range images {
imageNames = append(imageNames, k)
}
sort.Strings(imageNames)
imagesAsString := ""
for _, imageName := range imageNames {
if name, err := convertToRFC1123(imageName); err == nil {
imagesAsString += name + "=" + images[imageName] + ";"
}
}
return imagesAsString
}
// stringToImages returns a slice of ImageAndName structs from the provided semi-colon seperated string
// of key value pairs
func stringToImages(imagesString string) Images2Pull {
currentImages := strings.Split(imagesString, ";")
for i, image := range currentImages {
currentImages[i] = strings.TrimSpace(image)
}
// Remove the last element, if empty
if currentImages[len(currentImages)-1] == "" {
currentImages = currentImages[:len(currentImages)-1]
}
images := map[string]string{}
for _, image := range currentImages {
nameAndImage := strings.Split(image, "=")
if len(nameAndImage) != 2 {
logrus.Warnf("Malformed image name/tag: %s. Ignoring.", image)
continue
}
images[nameAndImage[0]] = nameAndImage[1]
}
return images
}
// convertToRFC1123 converts input string to RFC 1123 format ([a-z0-9]([-a-z0-9]*[a-z0-9])?) max 63 characters, if possible
func convertToRFC1123(str string) (string, error) {
result := strings.ToLower(str)
if len(str) > validation.DNS1123LabelMaxLength {
result = result[:validation.DNS1123LabelMaxLength]
}
// Remove illegal trailing characters
i := len(result) - 1
for i >= 0 && !isRFC1123Char(result[i]) {
i -= 1
}
result = result[:i+1]
result = strings.ReplaceAll(result, "_", "-")
if errs := validation.IsDNS1123Label(result); len(errs) > 0 {
return "", goerror.New("Cannot convert the following string to RFC 1123 format: " + str)
}
return result, nil
}
func isRFC1123Char(ch byte) bool {
errs := validation.IsDNS1123Label(string(ch))
return len(errs) == 0
}
// GetDefaultImages returns the current default images from the environment variables
func getDefaultImages() Images2Pull {
images := map[string]string{}
for _, pattern := range defaultImagePatterns {
matches := utils.GetEnvsByRegExp(pattern)
sort.SliceStable(matches, func(i, j int) bool {
return strings.Compare(matches[i].Name, matches[j].Name) < 0
})
for _, match := range matches {
images[match.Name[len("RELATED_IMAGE_"):]] = match.Value
}
}
return images
}