diff --git a/Makefile b/Makefile index 49a707b3c..90c4af06c 100644 --- a/Makefile +++ b/Makefile @@ -283,7 +283,7 @@ run: docker-build kind-cluster kind-load kind-deploy #HELP Build the operator-co .PHONY: docker-build docker-build: build-linux #EXHELP Build docker image for operator-controller with GOOS=linux and local GOARCH. - $(CONTAINER_RUNTIME) build -t $(IMG) -f Dockerfile ./bin/linux + $(CONTAINER_RUNTIME) build --load -t $(IMG) -f Dockerfile ./bin/linux #SECTION Release ifeq ($(origin ENABLE_RELEASE_PIPELINE), undefined) diff --git a/api/v1alpha1/clusterextension_types.go b/api/v1alpha1/clusterextension_types.go index ad99e7251..e1c4f6248 100644 --- a/api/v1alpha1/clusterextension_types.go +++ b/api/v1alpha1/clusterextension_types.go @@ -142,6 +142,12 @@ type ClusterExtensionInstallConfig struct { // resources that are included in the bundle of content being applied. ServiceAccount ServiceAccountReference `json:"serviceAccount"` + // configSource is an optional field that can be used to reference a configMap + // containing helm values when installing an extension that is packaged as a helm chart + // + //+optional + ConfigSources *ConfigSourceReferences `json:"configMap,omitempty"` + // preflight is an optional field that can be used to configure the preflight checks run before installation or upgrade of the content for the package specified in the packageName field. // // When specified, it overrides the default configuration of the preflight checks that are required to execute successfully during an install/upgrade operation. @@ -376,6 +382,13 @@ type ServiceAccountReference struct { Name string `json:"name"` } +// ConfigSourceReference can references a configMap, a secret, plain text config +type ConfigSourceReferences struct { + ConfigMapNames []string `json:"configMapNames,omitempty"` + SecretNames []string `json:"secretNames,omitempty"` + TextConfigs []string `json:"textConfigs,omitempty"` +} + // PreflightConfig holds the configuration for the preflight checks. If used, at least one preflight check must be non-nil. // +kubebuilder:validation:XValidation:rule="has(self.crdUpgradeSafety)",message="at least one of [crdUpgradeSafety] are required when preflight is specified" type PreflightConfig struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ccd143aec..db6c0dc0b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -107,6 +107,11 @@ func (in *ClusterExtension) DeepCopyObject() runtime.Object { func (in *ClusterExtensionInstallConfig) DeepCopyInto(out *ClusterExtensionInstallConfig) { *out = *in out.ServiceAccount = in.ServiceAccount + if in.ConfigSources != nil { + in, out := &in.ConfigSources, &out.ConfigSources + *out = new(ConfigSourceReferences) + (*in).DeepCopyInto(*out) + } if in.Preflight != nil { in, out := &in.Preflight, &out.Preflight *out = new(PreflightConfig) @@ -216,6 +221,36 @@ func (in *ClusterExtensionStatus) DeepCopy() *ClusterExtensionStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigSourceReferences) DeepCopyInto(out *ConfigSourceReferences) { + *out = *in + if in.ConfigMapNames != nil { + in, out := &in.ConfigMapNames, &out.ConfigMapNames + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.SecretNames != nil { + in, out := &in.SecretNames, &out.SecretNames + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.TextConfigs != nil { + in, out := &in.TextConfigs, &out.TextConfigs + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigSourceReferences. +func (in *ConfigSourceReferences) DeepCopy() *ConfigSourceReferences { + if in == nil { + return nil + } + out := new(ConfigSourceReferences) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PreflightConfig) DeepCopyInto(out *PreflightConfig) { *out = *in diff --git a/cmd/manager/main.go b/cmd/manager/main.go index b03472dfc..7e81dda0a 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -206,6 +206,20 @@ func main() { os.Exit(1) } + pureHelmGetter, err := helmclient.NewActionConfigGetter(mgr.GetConfig(), mgr.GetRESTMapper(), + helmclient.StorageDriverMapper(action.PureHelmStorageDriverMapper(clientRestConfigMapper, mgr.GetAPIReader())), + helmclient.ClientNamespaceMapper(func(obj client.Object) (string, error) { + ext := obj.(*ocv1alpha1.ClusterExtension) + return ext.Spec.Install.Namespace, nil + }), + // helmclient.StorageRestConfigMapper(clientRestConfigMapper), + helmclient.ClientRestConfigMapper(clientRestConfigMapper), + ) + if err != nil { + setupLog.Error(err, "unable to config for creating helm client") + os.Exit(1) + } + acg, err := action.NewWrappedActionClientGetter(cfgGetter, helmclient.WithFailureRollbacks(false), ) @@ -214,6 +228,14 @@ func main() { os.Exit(1) } + phg, err := action.NewWrappedActionClientGetter(pureHelmGetter, + helmclient.WithFailureRollbacks(false), + ) + if err != nil { + setupLog.Error(err, "unable to create helm client") + os.Exit(1) + } + certPoolWatcher, err := httputil.NewCertPoolWatcher(caCertDir, ctrl.Log.WithName("cert-pool")) if err != nil { setupLog.Error(err, "unable to create CA certificate pool") @@ -284,11 +306,33 @@ func main() { crdupgradesafety.NewPreflight(aeClient.CustomResourceDefinitions()), } - applier := &applier.Helm{ + olmApplier := &applier.Helm{ ActionClientGetter: acg, Preflights: preflights, } + helmEngine := &controllers.Engine{ + Unpacker: &source.TarGZ{ + BaseCachePath: filepath.Join(cachePath, "charts"), + }, + Applier: &applier.Helmer{ + ActionClientGetter: phg, + TokenGetter: tokenGetter, + }, + } + + olmEngine := &controllers.Engine{ + Unpacker: unpacker, + Applier: olmApplier, + } + + enginator := &controllers.Enginator{ + Router: map[string]*controllers.Engine{ + "helm": helmEngine, + }, + DefaultEngine: olmEngine, + } + cm := contentmanager.NewManager(clientRestConfigMapper, mgr.GetConfig(), mgr.GetRESTMapper()) err = clusterExtensionFinalizers.Register(controllers.ClusterExtensionCleanupContentManagerCacheFinalizer, finalizers.FinalizerFunc(func(ctx context.Context, obj client.Object) (crfinalizer.Result, error) { ext := obj.(*ocv1alpha1.ClusterExtension) @@ -303,8 +347,7 @@ func main() { if err = (&controllers.ClusterExtensionReconciler{ Client: cl, Resolver: resolver, - Unpacker: unpacker, - Applier: applier, + Enginator: enginator, InstalledBundleGetter: &controllers.DefaultInstalledBundleGetter{ActionClientGetter: acg}, Finalizers: clusterExtensionFinalizers, Manager: cm, diff --git a/config/base/crd/bases/olm.operatorframework.io_clusterextensions.yaml b/config/base/crd/bases/olm.operatorframework.io_clusterextensions.yaml index 61b81606b..63c27777c 100644 --- a/config/base/crd/bases/olm.operatorframework.io_clusterextensions.yaml +++ b/config/base/crd/bases/olm.operatorframework.io_clusterextensions.yaml @@ -51,6 +51,24 @@ spec: serviceAccount: name: example-sa properties: + configMap: + description: |- + configSource is an optional field that can be used to reference a configMap + containing helm values when installing an extension that is packaged as a helm chart + properties: + configMapNames: + items: + type: string + type: array + secretNames: + items: + type: string + type: array + textConfigs: + items: + type: string + type: array + type: object namespace: description: |- namespace is a reference to the Namespace in which the bundle of diff --git a/config/samples/argocd-helm.yaml b/config/samples/argocd-helm.yaml new file mode 100644 index 000000000..df5348b82 --- /dev/null +++ b/config/samples/argocd-helm.yaml @@ -0,0 +1,39 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: argocd-helm +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: argocd-helm-installer + namespace: argocd-helm +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: argocd-helm-cluster-admin-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: argocd-helm-installer + namespace: argocd-helm +--- +apiVersion: olm.operatorframework.io/v1alpha1 +kind: ClusterExtension +metadata: + name: argocd-helm +spec: + source: + sourceType: Catalog + catalog: + packageName: argocd-helm + version: 7.6.8 + install: + namespace: argocd-helm + serviceAccount: + name: argocd-helm-installer \ No newline at end of file diff --git a/config/samples/catalogd_operatorcatalog.yaml b/config/samples/catalogd_operatorcatalog.yaml index 48f1da573..2dc84d1e1 100644 --- a/config/samples/catalogd_operatorcatalog.yaml +++ b/config/samples/catalogd_operatorcatalog.yaml @@ -6,5 +6,5 @@ spec: source: type: Image image: - ref: quay.io/operatorhubio/catalog:latest + ref: docker.io/perdasilva/catalog:2 pollInterval: 10m diff --git a/config/samples/metrics-server.yaml b/config/samples/metrics-server.yaml new file mode 100644 index 000000000..bbe1b4199 --- /dev/null +++ b/config/samples/metrics-server.yaml @@ -0,0 +1,42 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: metrics-server +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: metrics-server-installer + namespace: metrics-server +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: metrics-server-cluster-admin-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: metrics-server-installer + namespace: metrics-server +--- +apiVersion: olm.operatorframework.io/v1alpha1 +kind: ClusterExtension +metadata: + name: metrics-server +spec: + source: + sourceType: Catalog + catalog: + packageName: metrics-server + version: 3.12.0 + install: + namespace: metrics-server + serviceAccount: + name: metrics-server-installer + configSources: + configMaps: + - "values" diff --git a/config/samples/values.yaml b/config/samples/values.yaml new file mode 100644 index 000000000..be843db41 --- /dev/null +++ b/config/samples/values.yaml @@ -0,0 +1,200 @@ +# Default values for metrics-server. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +image: + repository: registry.k8s.io/metrics-server/metrics-server + # Overrides the image tag whose default is v{{ .Chart.AppVersion }} + tag: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +# - name: registrySecretName + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + # The list of secrets mountable by this service account. + # See https://kubernetes.io/docs/reference/labels-annotations-taints/#enforce-mountable-secrets + secrets: [] + +rbac: + # Specifies whether RBAC resources should be created + create: true + # Note: PodSecurityPolicy will not be created when Kubernetes version is 1.25 or later. + pspEnabled: false + +apiService: + # Specifies if the v1beta1.metrics.k8s.io API service should be created. + # + # You typically want this enabled! If you disable API service creation you have to + # manage it outside of this chart for e.g horizontal pod autoscaling to + # work with this release. + create: true + # Annotations to add to the API service + annotations: {} + # Specifies whether to skip TLS verification + insecureSkipTLSVerify: true + # The PEM encoded CA bundle for TLS verification + caBundle: "" + +commonLabels: {} +podLabels: {} +podAnnotations: {} + +podSecurityContext: {} + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + capabilities: + drop: + - ALL + +priorityClassName: system-cluster-critical + +containerPort: 10250 + +hostNetwork: + # Specifies if metrics-server should be started in hostNetwork mode. + # + # You would require this enabled if you use alternate overlay networking for pods and + # API server unable to communicate with metrics-server. As an example, this is required + # if you use Weave network on EKS + enabled: false + +replicas: 1 + +revisionHistoryLimit: + +updateStrategy: {} +# type: RollingUpdate +# rollingUpdate: +# maxSurge: 0 +# maxUnavailable: 1 + +podDisruptionBudget: + # https://kubernetes.io/docs/tasks/run-application/configure-pdb/ + enabled: false + minAvailable: + maxUnavailable: + +defaultArgs: + - --cert-dir=/tmp + - --kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname + - --kubelet-use-node-status-port + - --metric-resolution=15s + +args: [] + +livenessProbe: + httpGet: + path: /livez + port: https + scheme: HTTPS + initialDelaySeconds: 0 + periodSeconds: 10 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /readyz + port: https + scheme: HTTPS + initialDelaySeconds: 20 + periodSeconds: 10 + failureThreshold: 3 + +service: + type: ClusterIP + port: 443 + annotations: {} + labels: {} + # Add these labels to have metrics-server show up in `kubectl cluster-info` + # kubernetes.io/cluster-service: "true" + # kubernetes.io/name: "Metrics-server" + +addonResizer: + enabled: false + image: + repository: registry.k8s.io/autoscaling/addon-resizer + tag: 1.8.21 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + capabilities: + drop: + - ALL + resources: + requests: + cpu: 40m + memory: 25Mi + limits: + cpu: 40m + memory: 25Mi + nanny: + cpu: 0m + extraCpu: 1m + memory: 0Mi + extraMemory: 2Mi + minClusterSize: 100 + pollPeriod: 300000 + threshold: 5 + +metrics: + enabled: false + +serviceMonitor: + enabled: false + additionalLabels: {} + interval: 1m + scrapeTimeout: 10s + metricRelabelings: [] + relabelings: [] + +# See https://github.com/kubernetes-sigs/metrics-server#scaling +resources: + requests: + cpu: 100m + memory: 200Mi + # limits: + # cpu: + # memory: + +extraVolumeMounts: [] + +extraVolumes: [] + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +topologySpreadConstraints: [] + +dnsConfig: {} + +# Annotations to add to the deployment +deploymentAnnotations: {} + +schedulerName: "" + +tmpVolume: + emptyDir: {} diff --git a/internal/action/storagedriver.go b/internal/action/storagedriver.go index db8c02ddb..d34ebfd43 100644 --- a/internal/action/storagedriver.go +++ b/internal/action/storagedriver.go @@ -3,7 +3,6 @@ package action import ( "context" "fmt" - "helm.sh/helm/v3/pkg/storage/driver" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -19,6 +18,28 @@ import ( "github.com/operator-framework/helm-operator-plugins/pkg/storage" ) +// ObjectToRestConfigMapper +func PureHelmStorageDriverMapper(mapper helmclient.ObjectToRestConfigMapper, reader client.Reader) helmclient.ObjectToStorageDriverMapper { + return func(ctx context.Context, object client.Object, config *rest.Config) (driver.Driver, error) { + //ext := object.(*ocv1alpha1.ClusterExtension) + //namespace := ext.Spec.Install.Namespace + cfg, err := mapper(ctx, object, config) + extSaClient, err := clientcorev1.NewForConfig(cfg) + if err != nil { + return nil, err + } + secretsClient := newSecretsDelegatingClient(extSaClient, reader, "olmv1-system") + log := logf.FromContext(ctx).V(2) + ownerRefs := []metav1.OwnerReference{*metav1.NewControllerRef(object, object.GetObjectKind().GroupVersionKind())} + ownerRefSecretClient := helmclient.NewOwnerRefSecretClient(secretsClient, ownerRefs, nil) + s := driver.NewSecrets(ownerRefSecretClient) + s.Log = func(s string, i ...interface{}) { + log.Info(s, i...) + } + return s, nil + } +} + func ChunkedStorageDriverMapper(secretsGetter clientcorev1.SecretsGetter, reader client.Reader, namespace string) helmclient.ObjectToStorageDriverMapper { secretsClient := newSecretsDelegatingClient(secretsGetter, reader, namespace) return func(ctx context.Context, object client.Object, config *rest.Config) (driver.Driver, error) { diff --git a/internal/applier/helmer.go b/internal/applier/helmer.go new file mode 100644 index 000000000..25b84c47b --- /dev/null +++ b/internal/applier/helmer.go @@ -0,0 +1,298 @@ +package applier + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "log" + "strings" + + "github.com/operator-framework/operator-controller/internal/authentication" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/postrender" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage/driver" + corev1 "k8s.io/api/core/v1" + errv1 "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client" + + ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal/rukpak/util" +) + +type Helmer struct { + ActionClientGetter helmclient.ActionClientGetter + TokenGetter *authentication.TokenGetter +} + +func loadChartFromFS(fsys fs.FS) (*chart.Chart, error) { + var files []*loader.BufferedFile + + // Walk through the file system and gather the chart files + err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Ignore directories + if d.IsDir() { + return nil + } + + // Open the file from fs.FS + file, err := fsys.Open(path) + if err != nil { + return err + } + defer file.Close() + + // Read the file content + content, err := io.ReadAll(file) + if err != nil { + return err + } + + // Create a BufferedFile with the content + files = append(files, &loader.BufferedFile{Name: path, Data: content}) + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("error walking file system: %v", err) + } + + // Load the chart from the in-memory files + chart, err := loader.LoadFiles(files) + if err != nil { + return nil, fmt.Errorf("failed to load chart: %v", err) + } + + return chart, nil +} + +func (h *Helmer) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1alpha1.ClusterExtension, objectLabels map[string]string, storageLabels map[string]string) ([]client.Object, string, error) { + chartFromFS, err := loadChartFromFS(contentFS) + if err != nil { + return nil, "", err + } + values := chartutil.Values{} + + values, err = processConfig(ctx, h.TokenGetter, ext, values) + if err != nil { + return nil, "", fmt.Errorf("Unable to process config: %v", err) + } + + ac, err := h.ActionClientGetter.ActionClientFor(ctx, ext) + if err != nil { + return nil, "", err + } + + post := &postrenderer{ + labels: objectLabels, + } + + rel, _, state, err := h.getReleaseState(ac, ext, chartFromFS, values, post) + if err != nil { + return nil, "", err + } + + switch state { + case StateNeedsInstall: + rel, err = ac.Install(ext.GetName(), ext.Spec.Install.Namespace, chartFromFS, values, func(install *action.Install) error { + install.CreateNamespace = false + install.Labels = storageLabels + return nil + }, helmclient.AppendInstallPostRenderer(post)) + if err != nil { + return nil, state, err + } + case StateNeedsUpgrade: + rel, err = ac.Upgrade(ext.GetName(), ext.Spec.Install.Namespace, chartFromFS, values, func(upgrade *action.Upgrade) error { + upgrade.MaxHistory = maxHelmReleaseHistory + upgrade.Labels = storageLabels + return nil + }, helmclient.AppendUpgradePostRenderer(post)) + if err != nil { + return nil, state, err + } + case StateUnchanged: + if err := ac.Reconcile(rel); err != nil { + return nil, state, err + } + default: + return nil, state, fmt.Errorf("unexpected release state %q", state) + } + + relObjects, err := util.ManifestObjects(strings.NewReader(rel.Manifest), fmt.Sprintf("%s-release-manifest", rel.Name)) + if err != nil { + return nil, state, err + } + + return relObjects, state, nil +} + +func (h *Helmer) getReleaseState(cl helmclient.ActionInterface, ext *ocv1alpha1.ClusterExtension, chrt *chart.Chart, values chartutil.Values, post postrender.PostRenderer) (*release.Release, *release.Release, string, error) { + currentRelease, err := cl.Get(ext.GetName()) + if err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { + return nil, nil, StateError, err + } + if errors.Is(err, driver.ErrReleaseNotFound) { + return nil, nil, StateNeedsInstall, nil + } + + if errors.Is(err, driver.ErrReleaseNotFound) { + desiredRelease, err := cl.Install(ext.GetName(), ext.Spec.Install.Namespace, chrt, values, func(i *action.Install) error { + i.DryRun = true + i.DryRunOption = "server" + return nil + }, helmclient.AppendInstallPostRenderer(post)) + if err != nil { + return nil, nil, StateError, err + } + return nil, desiredRelease, StateNeedsInstall, nil + } + desiredRelease, err := cl.Upgrade(ext.GetName(), ext.Spec.Install.Namespace, chrt, values, func(upgrade *action.Upgrade) error { + upgrade.MaxHistory = maxHelmReleaseHistory + upgrade.DryRun = true + upgrade.DryRunOption = "server" + return nil + }, helmclient.AppendUpgradePostRenderer(post)) + if err != nil { + return currentRelease, nil, StateError, err + } + relState := StateUnchanged + if desiredRelease.Manifest != currentRelease.Manifest || + currentRelease.Info.Status == release.StatusFailed || + currentRelease.Info.Status == release.StatusSuperseded { + relState = StateNeedsUpgrade + } + return currentRelease, desiredRelease, relState, nil +} + +// createClientWithToken creates a new client that uses the specified token. +func createClientWithToken(token string) (*kubernetes.Clientset, error) { + + // Get the default config + cfg, err := rest.InClusterConfig() + + // Remove existing credentials + anonCfg := rest.AnonymousClientConfig(cfg) + + // Create a custom rest config using the token + cfgCopy := rest.CopyConfig(anonCfg) + cfgCopy.BearerToken = token + + clientSet, err := kubernetes.NewForConfig(cfgCopy) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes client with token: %w", err) + } + + return clientSet, nil +} + +// Looks for the ConfigSources by name in the install namespace and gathers values +func processConfig(ctx context.Context, tokenGetter *authentication.TokenGetter, ext *ocv1alpha1.ClusterExtension, values chartutil.Values) (chartutil.Values, error) { + + // Create or get a token for the provided service account + token, err := tokenGetter.Get(ctx, types.NamespacedName{Namespace: ext.Spec.Install.Namespace, Name: ext.Spec.Install.ServiceAccount.Name}) + if err != nil { + log.Fatalf("failed to get token for service account %s/%s: %v", ext.Spec.Install.Namespace, ext.Spec.Install.ServiceAccount.Name, err) + } + + // Create a client with the provided service account token + authedClientSet, err := createClientWithToken(token) + if err != nil { + log.Fatalf("failed to create client with token: %v", err) + } + + var configMapList []corev1.ConfigMap + var secretList []corev1.Secret + var textConfigList []string + + if ext.Spec.Install.ConfigSources != nil { + configSources := *ext.Spec.Install.ConfigSources + // Process config map names + if configSources.ConfigMapNames != nil && len(configSources.ConfigMapNames) > 0 { + for _, configMapName := range configSources.ConfigMapNames { + configMap := &corev1.ConfigMap{} + configMap, err := authedClientSet.CoreV1().ConfigMaps(ext.Spec.Install.Namespace).Get(ctx, configMapName, metav1.GetOptions{}) + if err != nil && !errv1.IsNotFound(err) { + return values, fmt.Errorf("failed to retrieve ConfigMap: %v", err) + } + configMapList = append(configMapList, *configMap) + } + } + // Process secrets + if configSources.SecretNames != nil && len(configSources.SecretNames) > 0 { + for _, secretName := range configSources.SecretNames { + secret := &corev1.Secret{} + secret, err := authedClientSet.CoreV1().Secrets(ext.Spec.Install.Namespace).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil && !errv1.IsNotFound(err) { + return values, fmt.Errorf("failed to retrieve ConfigMap: %v", err) + } + secretList = append(secretList, *secret) + } + } + // Process plain text configs + if configSources.TextConfigs != nil && len(configSources.TextConfigs) > 0 { + for _, textConfig := range configSources.TextConfigs { + textConfigList = append(textConfigList, textConfig) + } + } + } + + // If the ConfigSources have been found, build values from the data + if len(configMapList) > 0 || len(secretList) > 0 || len(textConfigList) > 0 { + // combine all configMaps + for _, configMap := range configMapList { + valuesYaml, found := configMap.Data["values.yaml"] + if found { + userValuesMap, err := chartutil.ReadValues([]byte(valuesYaml)) + if err != nil { + return values, fmt.Errorf("failed to parse values.yaml from ConfigMap %v: %v", configMap.Name, err) + } + // combine all values files + values = chartutil.MergeTables(values, userValuesMap) + } + } + + //combine all secrets + for _, secret := range secretList { + valuesYaml, found := secret.Data["values.yaml"] + if found { + userValuesMap, err := chartutil.ReadValues([]byte(valuesYaml)) + if err != nil { + return nil, fmt.Errorf("failed to parse values.yaml from secret %v: %v", secret.Name, err) + } + // combine all values files + values = chartutil.MergeTables(values, userValuesMap) + } + } + + //combine all secrets + for _, textConfig := range textConfigList { + if len(textConfig) > 0 { + userValuesMap, err := chartutil.ReadValues([]byte(textConfig)) + if err != nil { + return nil, fmt.Errorf("failed to parse values from textConfig: %v", err) + } + // combine all values files + values = chartutil.MergeTables(values, userValuesMap) + } + } + } + + return values, nil +} diff --git a/internal/controllers/clusterextension_controller.go b/internal/controllers/clusterextension_controller.go index 2601d97f0..e02f69c72 100644 --- a/internal/controllers/clusterextension_controller.go +++ b/internal/controllers/clusterextension_controller.go @@ -18,6 +18,7 @@ package controllers import ( "context" + "encoding/json" "errors" "fmt" "io/fs" @@ -62,12 +63,40 @@ const ( ClusterExtensionCleanupContentManagerCacheFinalizer = "olm.operatorframework.io/cleanup-contentmanager-cache" ) +type Engine struct { + rukpaksource.Unpacker + Applier +} + +type Enginator struct { + Router map[string]*Engine + DefaultEngine *Engine +} + +func (e *Enginator) GetEngine(bundle *declcfg.Bundle) (*Engine, error) { + contentType := "" + for _, property := range bundle.Properties { + if property.Type == "olm.content-type" { + if err := json.Unmarshal(property.Value, &contentType); err != nil { + return nil, fmt.Errorf("error unmarshalling package property: %w", err) + } + break + } + } + if contentType == "" { + return e.DefaultEngine, nil + } + if _, ok := e.Router[contentType]; !ok { + return nil, fmt.Errorf("unknown content type: %s", contentType) + } + return e.Router[contentType], nil +} + // ClusterExtensionReconciler reconciles a ClusterExtension object type ClusterExtensionReconciler struct { client.Client Resolver resolve.Resolver - Unpacker rukpaksource.Unpacker - Applier Applier + Enginator *Enginator Manager contentmanager.Manager controller crcontroller.Controller cache cache.Cache @@ -246,8 +275,15 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1alp Ref: resolvedBundle.Image, }, } + + engine, err := r.Enginator.GetEngine(resolvedBundle) + if err != nil { + setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedBundleMetadata, err)) + return ctrl.Result{}, err + } + l.Info("unpacking resolved bundle") - unpackResult, err := r.Unpacker.Unpack(ctx, bundleSource) + unpackResult, err := engine.Unpack(ctx, bundleSource) if err != nil { // Wrap the error passed to this with the resolution information until we have successfully // installed since we intend for the progressing condition to replace the resolved condition @@ -281,7 +317,7 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1alp // to ensure exponential backoff can occur: // - Permission errors (it is not possible to watch changes to permissions. // The only way to eventually recover from permission errors is to keep retrying). - managedObjs, _, err := r.Applier.Apply(ctx, unpackResult.Bundle, ext, objLbls, storeLbls) + managedObjs, _, err := engine.Apply(ctx, unpackResult.Bundle, ext, objLbls, storeLbls) if err != nil { setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedBundleMetadata, err)) // If bundle is not already installed, set Installed status condition to False diff --git a/internal/controllers/clusterextension_controller_test.go b/internal/controllers/clusterextension_controller_test.go deleted file mode 100644 index 8f7331384..000000000 --- a/internal/controllers/clusterextension_controller_test.go +++ /dev/null @@ -1,1402 +0,0 @@ -package controllers_test - -import ( - "context" - "errors" - "fmt" - "testing" - "testing/fstest" - - bsemver "github.com/blang/semver/v4" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/rand" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/operator-framework/operator-registry/alpha/declcfg" - - ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" - "github.com/operator-framework/operator-controller/internal/conditionsets" - "github.com/operator-framework/operator-controller/internal/controllers" - "github.com/operator-framework/operator-controller/internal/finalizers" - "github.com/operator-framework/operator-controller/internal/resolve" - "github.com/operator-framework/operator-controller/internal/rukpak/source" -) - -// Describe: ClusterExtension Controller Test -func TestClusterExtensionDoesNotExist(t *testing.T) { - _, reconciler := newClientAndReconciler(t) - - t.Log("When the cluster extension does not exist") - t.Log("It returns no error") - res, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: "non-existent"}}) - require.Equal(t, ctrl.Result{}, res) - require.NoError(t, err) -} - -func TestClusterExtensionResolutionFails(t *testing.T) { - pkgName := fmt.Sprintf("non-existent-%s", rand.String(6)) - cl, reconciler := newClientAndReconciler(t) - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1alpha1.ClusterExtension, _ *ocv1alpha1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - return nil, nil, nil, fmt.Errorf("no package %q found", pkgName) - }) - ctx := context.Background() - extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} - - t.Log("When the cluster extension specifies a non-existent package") - t.Log("By initializing cluster state") - clusterExtension := &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{Name: extKey.Name}, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - PackageName: pkgName, - }, - }, - Install: ocv1alpha1.ClusterExtensionInstallConfig{ - Namespace: "default", - ServiceAccount: ocv1alpha1.ServiceAccountReference{ - Name: "default", - }, - }, - }, - } - require.NoError(t, cl.Create(ctx, clusterExtension)) - - t.Log("It sets resolution failure status") - t.Log("By running reconcile") - res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) - require.EqualError(t, err, fmt.Sprintf("no package %q found", pkgName)) - - t.Log("By fetching updated cluster extension after reconcile") - require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) - - t.Log("By checking the status fields") - require.Empty(t, clusterExtension.Status.Install) - - t.Log("By checking the expected conditions") - cond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeProgressing) - require.NotNil(t, cond) - require.Equal(t, metav1.ConditionTrue, cond.Status) - require.Equal(t, ocv1alpha1.ReasonRetrying, cond.Reason) - require.Equal(t, fmt.Sprintf("no package %q found", pkgName), cond.Message) - - verifyInvariants(ctx, t, reconciler.Client, clusterExtension) - require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) -} - -func TestClusterExtensionResolutionSuccessfulUnpackFails(t *testing.T) { - type testCase struct { - name string - unpackErr error - expectTerminal bool - } - for _, tc := range []testCase{ - { - name: "non-terminal unpack failure", - unpackErr: errors.New("unpack failure"), - }, - { - name: "terminal unpack failure", - unpackErr: reconcile.TerminalError(errors.New("terminal unpack failure")), - expectTerminal: true, - }, - } { - t.Run(tc.name, func(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) - reconciler.Unpacker = &MockUnpacker{ - err: tc.unpackErr, - } - - ctx := context.Background() - extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} - - t.Log("When the cluster extension specifies a channel with version that exist") - t.Log("By initializing cluster state") - pkgName := "prometheus" - pkgVer := "1.0.0" - pkgChan := "beta" - namespace := fmt.Sprintf("test-ns-%s", rand.String(8)) - serviceAccount := fmt.Sprintf("test-sa-%s", rand.String(8)) - - clusterExtension := &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{Name: extKey.Name}, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - PackageName: pkgName, - Version: pkgVer, - Channels: []string{pkgChan}, - }, - }, - Install: ocv1alpha1.ClusterExtensionInstallConfig{ - Namespace: namespace, - ServiceAccount: ocv1alpha1.ServiceAccountReference{ - Name: serviceAccount, - }, - }, - }, - } - err := cl.Create(ctx, clusterExtension) - require.NoError(t, err) - - t.Log("It sets resolution success status") - t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1alpha1.ClusterExtension, _ *ocv1alpha1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") - return &declcfg.Bundle{ - Name: "prometheus.v1.0.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil - }) - res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) - require.Error(t, err) - - isTerminal := errors.Is(err, reconcile.TerminalError(nil)) - assert.Equal(t, tc.expectTerminal, isTerminal, "expected terminal error: %v, got: %v", tc.expectTerminal, isTerminal) - require.ErrorContains(t, err, tc.unpackErr.Error()) - - t.Log("By fetching updated cluster extension after reconcile") - require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) - - t.Log("By checking the status fields") - expectedBundleMetadata := ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"} - require.Empty(t, clusterExtension.Status.Install) - - t.Log("By checking the expected conditions") - expectStatus := metav1.ConditionTrue - expectReason := ocv1alpha1.ReasonRetrying - if tc.expectTerminal { - expectStatus = metav1.ConditionFalse - expectReason = ocv1alpha1.ReasonBlocked - } - progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeProgressing) - require.NotNil(t, progressingCond) - require.Equal(t, expectStatus, progressingCond.Status) - require.Equal(t, expectReason, progressingCond.Reason) - require.Contains(t, progressingCond.Message, fmt.Sprintf("for resolved bundle %q with version %q", expectedBundleMetadata.Name, expectedBundleMetadata.Version)) - - require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) - }) - } -} - -func TestClusterExtensionUnpackUnexpectedState(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) - reconciler.Unpacker = &MockUnpacker{ - result: &source.Result{ - State: "unexpected", - }, - } - - ctx := context.Background() - extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} - - t.Log("When the cluster extension specifies a channel with version that exist") - t.Log("By initializing cluster state") - pkgName := "prometheus" - pkgVer := "1.0.0" - pkgChan := "beta" - namespace := fmt.Sprintf("test-ns-%s", rand.String(8)) - serviceAccount := fmt.Sprintf("test-sa-%s", rand.String(8)) - - clusterExtension := &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{Name: extKey.Name}, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - PackageName: pkgName, - Version: pkgVer, - Channels: []string{pkgChan}, - }, - }, - Install: ocv1alpha1.ClusterExtensionInstallConfig{ - Namespace: namespace, - ServiceAccount: ocv1alpha1.ServiceAccountReference{ - Name: serviceAccount, - }, - }, - }, - } - err := cl.Create(ctx, clusterExtension) - require.NoError(t, err) - - t.Log("It sets resolution success status") - t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1alpha1.ClusterExtension, _ *ocv1alpha1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") - return &declcfg.Bundle{ - Name: "prometheus.v1.0.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil - }) - - require.Panics(t, func() { - _, _ = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - }, "reconciliation should panic on unknown unpack state") - - require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) -} - -func TestClusterExtensionResolutionAndUnpackSuccessfulApplierFails(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) - reconciler.Unpacker = &MockUnpacker{ - result: &source.Result{ - State: source.StateUnpacked, - Bundle: fstest.MapFS{}, - }, - } - - ctx := context.Background() - extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} - - t.Log("When the cluster extension specifies a channel with version that exist") - t.Log("By initializing cluster state") - pkgName := "prometheus" - pkgVer := "1.0.0" - pkgChan := "beta" - namespace := fmt.Sprintf("test-ns-%s", rand.String(8)) - serviceAccount := fmt.Sprintf("test-sa-%s", rand.String(8)) - - clusterExtension := &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{Name: extKey.Name}, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - PackageName: pkgName, - Version: pkgVer, - Channels: []string{pkgChan}, - }, - }, - Install: ocv1alpha1.ClusterExtensionInstallConfig{ - Namespace: namespace, - ServiceAccount: ocv1alpha1.ServiceAccountReference{ - Name: serviceAccount, - }, - }, - }, - } - err := cl.Create(ctx, clusterExtension) - require.NoError(t, err) - - t.Log("It sets resolution success status") - t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1alpha1.ClusterExtension, _ *ocv1alpha1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") - return &declcfg.Bundle{ - Name: "prometheus.v1.0.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil - }) - reconciler.Applier = &MockApplier{ - err: errors.New("apply failure"), - } - res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) - require.Error(t, err) - - t.Log("By fetching updated cluster extension after reconcile") - require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) - - t.Log("By checking the status fields") - expectedBundleMetadata := ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"} - require.Empty(t, clusterExtension.Status.Install) - - t.Log("By checking the expected installed conditions") - installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeInstalled) - require.NotNil(t, installedCond) - require.Equal(t, metav1.ConditionFalse, installedCond.Status) - require.Equal(t, ocv1alpha1.ReasonFailed, installedCond.Reason) - - t.Log("By checking the expected progressing conditions") - progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeProgressing) - require.NotNil(t, progressingCond) - require.Equal(t, metav1.ConditionTrue, progressingCond.Status) - require.Equal(t, ocv1alpha1.ReasonRetrying, progressingCond.Reason) - require.Contains(t, progressingCond.Message, fmt.Sprintf("for resolved bundle %q with version %q", expectedBundleMetadata.Name, expectedBundleMetadata.Version)) - - require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) -} - -func TestClusterExtensionApplierFailsWithBundleInstalled(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) - reconciler.Unpacker = &MockUnpacker{ - result: &source.Result{ - State: source.StateUnpacked, - Bundle: fstest.MapFS{}, - }, - } - - ctx := context.Background() - extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} - - t.Log("When the cluster extension specifies a channel with version that exist") - t.Log("By initializing cluster state") - pkgName := "prometheus" - pkgVer := "1.0.0" - pkgChan := "beta" - namespace := fmt.Sprintf("test-ns-%s", rand.String(8)) - serviceAccount := fmt.Sprintf("test-sa-%s", rand.String(8)) - - clusterExtension := &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{Name: extKey.Name}, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - PackageName: pkgName, - Version: pkgVer, - Channels: []string{pkgChan}, - }, - }, - Install: ocv1alpha1.ClusterExtensionInstallConfig{ - Namespace: namespace, - ServiceAccount: ocv1alpha1.ServiceAccountReference{ - Name: serviceAccount, - }, - }, - }, - } - err := cl.Create(ctx, clusterExtension) - require.NoError(t, err) - - t.Log("It sets resolution success status") - t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1alpha1.ClusterExtension, _ *ocv1alpha1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") - return &declcfg.Bundle{ - Name: "prometheus.v1.0.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil - }) - - reconciler.Manager = &MockManagedContentCacheManager{ - cache: &MockManagedContentCache{}, - } - reconciler.InstalledBundleGetter = &MockInstalledBundleGetter{ - bundle: &ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, - } - reconciler.Applier = &MockApplier{ - objs: []client.Object{}, - } - - res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) - require.NoError(t, err) - - reconciler.Applier = &MockApplier{ - err: errors.New("apply failure"), - } - - res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) - require.Error(t, err) - - t.Log("By fetching updated cluster extension after reconcile") - require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) - - t.Log("By checking the status fields") - expectedBundleMetadata := ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"} - require.Equal(t, expectedBundleMetadata, clusterExtension.Status.Install.Bundle) - - t.Log("By checking the expected installed conditions") - installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeInstalled) - require.NotNil(t, installedCond) - require.Equal(t, metav1.ConditionTrue, installedCond.Status) - require.Equal(t, ocv1alpha1.ReasonSucceeded, installedCond.Reason) - - t.Log("By checking the expected progressing conditions") - progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeProgressing) - require.NotNil(t, progressingCond) - require.Equal(t, metav1.ConditionTrue, progressingCond.Status) - require.Equal(t, ocv1alpha1.ReasonRetrying, progressingCond.Reason) - require.Contains(t, progressingCond.Message, fmt.Sprintf("for resolved bundle %q with version %q", expectedBundleMetadata.Name, expectedBundleMetadata.Version)) - - require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) -} - -func TestClusterExtensionManagerFailed(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) - reconciler.Unpacker = &MockUnpacker{ - result: &source.Result{ - State: source.StateUnpacked, - Bundle: fstest.MapFS{}, - }, - } - - ctx := context.Background() - extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} - - t.Log("When the cluster extension specifies a channel with version that exist") - t.Log("By initializing cluster state") - pkgName := "prometheus" - pkgVer := "1.0.0" - pkgChan := "beta" - namespace := fmt.Sprintf("test-ns-%s", rand.String(8)) - serviceAccount := fmt.Sprintf("test-sa-%s", rand.String(8)) - - clusterExtension := &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{Name: extKey.Name}, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - PackageName: pkgName, - Version: pkgVer, - Channels: []string{pkgChan}, - }, - }, - Install: ocv1alpha1.ClusterExtensionInstallConfig{ - Namespace: namespace, - ServiceAccount: ocv1alpha1.ServiceAccountReference{ - Name: serviceAccount, - }, - }, - }, - } - err := cl.Create(ctx, clusterExtension) - require.NoError(t, err) - - t.Log("It sets resolution success status") - t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1alpha1.ClusterExtension, _ *ocv1alpha1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") - return &declcfg.Bundle{ - Name: "prometheus.v1.0.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil - }) - reconciler.Applier = &MockApplier{ - objs: []client.Object{}, - } - reconciler.Manager = &MockManagedContentCacheManager{ - err: errors.New("manager fail"), - } - res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) - require.Error(t, err) - - t.Log("By fetching updated cluster extension after reconcile") - require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) - - t.Log("By checking the status fields") - require.Equal(t, ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, clusterExtension.Status.Install.Bundle) - - t.Log("By checking the expected installed conditions") - installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeInstalled) - require.NotNil(t, installedCond) - require.Equal(t, metav1.ConditionTrue, installedCond.Status) - require.Equal(t, ocv1alpha1.ReasonSucceeded, installedCond.Reason) - - t.Log("By checking the expected progressing conditions") - progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeProgressing) - require.NotNil(t, progressingCond) - require.Equal(t, metav1.ConditionTrue, progressingCond.Status) - require.Equal(t, ocv1alpha1.ReasonRetrying, progressingCond.Reason) - - require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) -} - -func TestClusterExtensionManagedContentCacheWatchFail(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) - reconciler.Unpacker = &MockUnpacker{ - result: &source.Result{ - State: source.StateUnpacked, - Bundle: fstest.MapFS{}, - }, - } - - ctx := context.Background() - extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} - - t.Log("When the cluster extension specifies a channel with version that exist") - t.Log("By initializing cluster state") - pkgName := "prometheus" - pkgVer := "1.0.0" - pkgChan := "beta" - installNamespace := fmt.Sprintf("test-ns-%s", rand.String(8)) - serviceAccount := fmt.Sprintf("test-sa-%s", rand.String(8)) - - clusterExtension := &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{Name: extKey.Name}, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: ocv1alpha1.SourceTypeCatalog, - - Catalog: &ocv1alpha1.CatalogSource{ - PackageName: pkgName, - Version: pkgVer, - Channels: []string{pkgChan}, - }, - }, - Install: ocv1alpha1.ClusterExtensionInstallConfig{ - Namespace: installNamespace, - ServiceAccount: ocv1alpha1.ServiceAccountReference{ - Name: serviceAccount, - }, - }, - }, - } - err := cl.Create(ctx, clusterExtension) - require.NoError(t, err) - - t.Log("It sets resolution success status") - t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1alpha1.ClusterExtension, _ *ocv1alpha1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") - return &declcfg.Bundle{ - Name: "prometheus.v1.0.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil - }) - reconciler.Applier = &MockApplier{ - objs: []client.Object{}, - } - reconciler.Manager = &MockManagedContentCacheManager{ - cache: &MockManagedContentCache{ - err: errors.New("watch error"), - }, - } - res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) - require.Error(t, err) - - t.Log("By fetching updated cluster extension after reconcile") - require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) - - t.Log("By checking the status fields") - require.Equal(t, ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, clusterExtension.Status.Install.Bundle) - - t.Log("By checking the expected installed conditions") - installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeInstalled) - require.NotNil(t, installedCond) - require.Equal(t, metav1.ConditionTrue, installedCond.Status) - require.Equal(t, ocv1alpha1.ReasonSucceeded, installedCond.Reason) - - t.Log("By checking the expected progressing conditions") - progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeProgressing) - require.NotNil(t, progressingCond) - require.Equal(t, metav1.ConditionTrue, progressingCond.Status) - require.Equal(t, ocv1alpha1.ReasonRetrying, progressingCond.Reason) - - require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) -} - -func TestClusterExtensionInstallationSucceeds(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) - reconciler.Unpacker = &MockUnpacker{ - result: &source.Result{ - State: source.StateUnpacked, - Bundle: fstest.MapFS{}, - }, - } - - ctx := context.Background() - extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} - - t.Log("When the cluster extension specifies a channel with version that exist") - t.Log("By initializing cluster state") - pkgName := "prometheus" - pkgVer := "1.0.0" - pkgChan := "beta" - namespace := fmt.Sprintf("test-ns-%s", rand.String(8)) - serviceAccount := fmt.Sprintf("test-sa-%s", rand.String(8)) - - clusterExtension := &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{Name: extKey.Name}, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - PackageName: pkgName, - Version: pkgVer, - Channels: []string{pkgChan}, - }, - }, - Install: ocv1alpha1.ClusterExtensionInstallConfig{ - Namespace: namespace, - ServiceAccount: ocv1alpha1.ServiceAccountReference{ - Name: serviceAccount, - }, - }, - }, - } - err := cl.Create(ctx, clusterExtension) - require.NoError(t, err) - - t.Log("It sets resolution success status") - t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1alpha1.ClusterExtension, _ *ocv1alpha1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") - return &declcfg.Bundle{ - Name: "prometheus.v1.0.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil - }) - reconciler.Applier = &MockApplier{ - objs: []client.Object{}, - } - reconciler.Manager = &MockManagedContentCacheManager{ - cache: &MockManagedContentCache{}, - } - res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) - require.NoError(t, err) - - t.Log("By fetching updated cluster extension after reconcile") - require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) - - t.Log("By checking the status fields") - require.Equal(t, ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, clusterExtension.Status.Install.Bundle) - - t.Log("By checking the expected installed conditions") - installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeInstalled) - require.NotNil(t, installedCond) - require.Equal(t, metav1.ConditionTrue, installedCond.Status) - require.Equal(t, ocv1alpha1.ReasonSucceeded, installedCond.Reason) - - t.Log("By checking the expected progressing conditions") - progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeProgressing) - require.NotNil(t, progressingCond) - require.Equal(t, metav1.ConditionFalse, progressingCond.Status) - require.Equal(t, ocv1alpha1.ReasonSucceeded, progressingCond.Reason) - - require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) -} - -func TestClusterExtensionDeleteFinalizerFails(t *testing.T) { - cl, reconciler := newClientAndReconciler(t) - reconciler.Unpacker = &MockUnpacker{ - result: &source.Result{ - State: source.StateUnpacked, - Bundle: fstest.MapFS{}, - }, - } - - ctx := context.Background() - extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} - - t.Log("When the cluster extension specifies a channel with version that exist") - t.Log("By initializing cluster state") - pkgName := "prometheus" - pkgVer := "1.0.0" - pkgChan := "beta" - namespace := fmt.Sprintf("test-ns-%s", rand.String(8)) - serviceAccount := fmt.Sprintf("test-sa-%s", rand.String(8)) - - clusterExtension := &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{Name: extKey.Name}, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - PackageName: pkgName, - Version: pkgVer, - Channels: []string{pkgChan}, - }, - }, - Install: ocv1alpha1.ClusterExtensionInstallConfig{ - Namespace: namespace, - ServiceAccount: ocv1alpha1.ServiceAccountReference{ - Name: serviceAccount, - }, - }, - }, - } - err := cl.Create(ctx, clusterExtension) - require.NoError(t, err) - t.Log("It sets resolution success status") - t.Log("By running reconcile") - reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1alpha1.ClusterExtension, _ *ocv1alpha1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v := bsemver.MustParse("1.0.0") - return &declcfg.Bundle{ - Name: "prometheus.v1.0.0", - Package: "prometheus", - Image: "quay.io/operatorhubio/prometheus@fake1.0.0", - }, &v, nil, nil - }) - fakeFinalizer := "fake.testfinalizer.io" - finalizersMessage := "still have finalizers" - reconciler.Applier = &MockApplier{ - objs: []client.Object{}, - } - reconciler.Manager = &MockManagedContentCacheManager{ - cache: &MockManagedContentCache{}, - } - reconciler.InstalledBundleGetter = &MockInstalledBundleGetter{ - bundle: &ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, - } - err = reconciler.Finalizers.Register(fakeFinalizer, finalizers.FinalizerFunc(func(ctx context.Context, obj client.Object) (crfinalizer.Result, error) { - return crfinalizer.Result{}, errors.New(finalizersMessage) - })) - - require.NoError(t, err) - - // Reconcile twice to simulate installing the ClusterExtension and loading in the finalizers - res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) - require.NoError(t, err) - res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) - require.NoError(t, err) - - t.Log("By fetching updated cluster extension after first reconcile") - require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) - cond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeInstalled) - expectedBundleMetadata := ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"} - require.Equal(t, expectedBundleMetadata, clusterExtension.Status.Install.Bundle) - require.NotNil(t, cond) - require.Equal(t, metav1.ConditionTrue, cond.Status) - - require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) - res, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Error(t, err, res) - - t.Log("By fetching updated cluster extension after second reconcile") - require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) - cond = apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeInstalled) - require.Equal(t, expectedBundleMetadata, clusterExtension.Status.Install.Bundle) - require.NotNil(t, cond) - require.Equal(t, metav1.ConditionTrue, cond.Status) - require.Equal(t, fakeFinalizer, clusterExtension.Finalizers[0]) - cond = apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeProgressing) - require.NotNil(t, cond) - require.Equal(t, metav1.ConditionTrue, cond.Status) - require.Contains(t, cond.Message, finalizersMessage) -} - -func verifyInvariants(ctx context.Context, t *testing.T, c client.Client, ext *ocv1alpha1.ClusterExtension) { - key := client.ObjectKeyFromObject(ext) - require.NoError(t, c.Get(ctx, key, ext)) - - verifyConditionsInvariants(t, ext) -} - -func verifyConditionsInvariants(t *testing.T, ext *ocv1alpha1.ClusterExtension) { - // Expect that the cluster extension's set of conditions contains all defined - // condition types for the ClusterExtension API. Every reconcile should always - // ensure every condition type's status/reason/message reflects the state - // read during _this_ reconcile call. - require.Len(t, ext.Status.Conditions, len(conditionsets.ConditionTypes)) - for _, tt := range conditionsets.ConditionTypes { - cond := apimeta.FindStatusCondition(ext.Status.Conditions, tt) - require.NotNil(t, cond) - require.NotEmpty(t, cond.Status) - require.Contains(t, conditionsets.ConditionReasons, cond.Reason) - require.Equal(t, ext.GetGeneration(), cond.ObservedGeneration) - } -} - -func TestSetDeprecationStatus(t *testing.T) { - for _, tc := range []struct { - name string - clusterExtension *ocv1alpha1.ClusterExtension - expectedClusterExtension *ocv1alpha1.ClusterExtension - bundle *declcfg.Bundle - deprecation *declcfg.Deprecation - }{ - { - name: "no deprecations, all deprecation statuses set to False", - clusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{}, - }, - }, - expectedClusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{ - { - Type: ocv1alpha1.TypeDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypePackageDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeChannelDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeBundleDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - }, - }, - }, - bundle: &declcfg.Bundle{}, - deprecation: nil, - }, - { - name: "deprecated channel, but no channel specified, all deprecation statuses set to False", - clusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{}, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{}, - }, - }, - expectedClusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{}, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{ - { - Type: ocv1alpha1.TypeDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypePackageDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeChannelDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeBundleDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - }, - }, - }, - bundle: &declcfg.Bundle{}, - deprecation: &declcfg.Deprecation{ - Entries: []declcfg.DeprecationEntry{{ - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaChannel, - Name: "badchannel", - }, - }}, - }, - }, - { - name: "deprecated channel, but a non-deprecated channel specified, all deprecation statuses set to False", - clusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"nondeprecated"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{}, - }, - }, - expectedClusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"nondeprecated"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{ - { - Type: ocv1alpha1.TypeDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypePackageDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeChannelDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeBundleDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - }, - }, - }, - bundle: &declcfg.Bundle{}, - deprecation: &declcfg.Deprecation{ - Entries: []declcfg.DeprecationEntry{ - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaChannel, - Name: "badchannel", - }, - }, - }, - }, - }, - { - name: "deprecated channel specified, ChannelDeprecated and Deprecated status set to true, others set to false", - clusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"badchannel"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{}, - }, - }, - expectedClusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"badchannel"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{ - { - Type: ocv1alpha1.TypeDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypePackageDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeChannelDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeBundleDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - }, - }, - }, - bundle: &declcfg.Bundle{}, - deprecation: &declcfg.Deprecation{ - Entries: []declcfg.DeprecationEntry{ - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaChannel, - Name: "badchannel", - }, - Message: "bad channel!", - }, - }, - }, - }, - { - name: "deprecated package and channel specified, deprecated bundle, all deprecation statuses set to true", - clusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"badchannel"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{}, - }, - }, - expectedClusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"badchannel"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{ - { - Type: ocv1alpha1.TypeDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypePackageDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeChannelDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeBundleDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - }, - }, - }, - bundle: &declcfg.Bundle{Name: "badbundle"}, - deprecation: &declcfg.Deprecation{ - Entries: []declcfg.DeprecationEntry{ - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaChannel, - Name: "badchannel", - }, - Message: "bad channel!", - }, - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaPackage, - }, - Message: "bad package!", - }, - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaBundle, - Name: "badbundle", - }, - Message: "bad bundle!", - }, - }, - }, - }, - { - name: "deprecated channel specified, deprecated bundle, all deprecation statuses set to true, all deprecation statuses set to true except PackageDeprecated", - clusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"badchannel"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{}, - }, - }, - expectedClusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"badchannel"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{ - { - Type: ocv1alpha1.TypeDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypePackageDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeChannelDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeBundleDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - }, - }, - }, - bundle: &declcfg.Bundle{Name: "badbundle"}, - deprecation: &declcfg.Deprecation{ - Entries: []declcfg.DeprecationEntry{ - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaChannel, - Name: "badchannel", - }, - Message: "bad channel!", - }, - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaBundle, - Name: "badbundle", - }, - Message: "bad bundle!", - }, - }, - }, - }, - { - name: "deprecated package and channel specified, all deprecation statuses set to true except BundleDeprecated", - clusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"badchannel"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{}, - }, - }, - expectedClusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"badchannel"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{ - { - Type: ocv1alpha1.TypeDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypePackageDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeChannelDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeBundleDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - }, - }, - }, - bundle: &declcfg.Bundle{}, - deprecation: &declcfg.Deprecation{ - Entries: []declcfg.DeprecationEntry{ - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaChannel, - Name: "badchannel", - }, - Message: "bad channel!", - }, - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaPackage, - }, - Message: "bad package!", - }, - }, - }, - }, - { - name: "deprecated channels specified, ChannelDeprecated and Deprecated status set to true, others set to false", - clusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"badchannel", "anotherbadchannel"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{}, - }, - }, - expectedClusterExtension: &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - Spec: ocv1alpha1.ClusterExtensionSpec{ - Source: ocv1alpha1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1alpha1.CatalogSource{ - Channels: []string{"badchannel", "anotherbadchannel"}, - }, - }, - }, - Status: ocv1alpha1.ClusterExtensionStatus{ - Conditions: []metav1.Condition{ - { - Type: ocv1alpha1.TypeDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypePackageDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeChannelDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionTrue, - ObservedGeneration: 1, - }, - { - Type: ocv1alpha1.TypeBundleDeprecated, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - }, - }, - }, - bundle: &declcfg.Bundle{}, - deprecation: &declcfg.Deprecation{ - Entries: []declcfg.DeprecationEntry{ - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaChannel, - Name: "badchannel", - }, - Message: "bad channel!", - }, - { - Reference: declcfg.PackageScopedReference{ - Schema: declcfg.SchemaChannel, - Name: "anotherbadchannel", - }, - Message: "another bad channedl!", - }, - }, - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - controllers.SetDeprecationStatus(tc.clusterExtension, tc.bundle.Name, tc.deprecation) - // TODO: we should test for unexpected changes to lastTransitionTime. We only expect - // lastTransitionTime to change when the status of the condition changes. - assert.Equal(t, "", cmp.Diff(tc.expectedClusterExtension, tc.clusterExtension, cmpopts.IgnoreFields(metav1.Condition{}, "Message", "LastTransitionTime"))) - }) - } -} diff --git a/internal/rukpak/source/tgz.go b/internal/rukpak/source/tgz.go new file mode 100644 index 000000000..cd00cf4bf --- /dev/null +++ b/internal/rukpak/source/tgz.go @@ -0,0 +1,207 @@ +package source + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "strings" + + "github.com/containers/image/v5/pkg/blobinfocache/none" + "github.com/containers/image/v5/types" + "github.com/opencontainers/go-digest" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type TarGZ struct { + BaseCachePath string +} + +func (i *TarGZ) Unpack(ctx context.Context, bundle *BundleSource) (*Result, error) { + l := log.FromContext(ctx) + + if bundle.Image == nil { + return nil, reconcile.TerminalError(fmt.Errorf("error parsing bundle, bundle %s has a nil image source", bundle.Name)) + } + + // Parse the URL + parsedURL, err := url.Parse(bundle.Image.Ref) + if err != nil { + return nil, reconcile.TerminalError(fmt.Errorf("error downloading bundle '%s': %v", bundle.Name, err)) + } + fileName := path.Base(parsedURL.Path) + + // Download the .tgz file + resp, err := http.Get(bundle.Image.Ref) + if err != nil { + return nil, reconcile.TerminalError(fmt.Errorf("error downloading bundle '%s': %v", bundle.Name, err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, reconcile.TerminalError(fmt.Errorf("error downloading bundle '%s': got status code '%d'", bundle.Name, resp.StatusCode)) + } + + // Open a gzip reader + gzReader, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, reconcile.TerminalError(fmt.Errorf("error unpacking bundle '%s': %v", bundle.Name, err)) + } + defer gzReader.Close() + + unpackDir := path.Join(i.BaseCachePath, bundle.Name, fileName) + err = os.MkdirAll(unpackDir, 0700) + if err != nil { + return nil, fmt.Errorf("error creating temporary directory: %w", err) + } + + // Open a tar reader + tarReader := tar.NewReader(gzReader) + topLevelDir := "" + // Extract tar contents + for { + header, err := tarReader.Next() + if err == io.EOF { + break // End of archive + } + if err != nil { + return nil, reconcile.TerminalError(fmt.Errorf("error unpaking bundle '%s': %v", bundle.Name, err)) + } + + // On the first entry, capture the top-level directory + if topLevelDir == "" { + topLevelDir = strings.Split(header.Name, "/")[0] + } + + // Strip the top-level directory from the path + relativePath := strings.TrimPrefix(header.Name, topLevelDir+"/") + + if relativePath == "" { + // Skip the top-level directory itself + continue + } + + // Construct the target file path + targetPath := filepath.Join(unpackDir, relativePath) + + switch header.Typeflag { + case tar.TypeDir: + // Create directory + if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { + return nil, reconcile.TerminalError(fmt.Errorf("error unpacking bundle '%s': %v", bundle.Name, err)) + } + case tar.TypeReg: + // Ensure the directory for the file exists + if err := os.MkdirAll(filepath.Dir(targetPath), os.FileMode(0700)); err != nil { + return nil, reconcile.TerminalError(fmt.Errorf("error unpacking bundle '%s': %v", bundle.Name, err)) + } + + // Create a file + outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode)) + if err != nil { + return nil, reconcile.TerminalError(fmt.Errorf("error unpacking bundle '%s': %v", bundle.Name, err)) + } + if _, err := io.Copy(outFile, tarReader); err != nil { + outFile.Close() + return nil, reconcile.TerminalError(fmt.Errorf("error unpacking bundle '%s': %v", bundle.Name, err)) + } + outFile.Close() + default: + // Handle other types of files if necessary (e.g., symlinks, etc.) + l.V(2).Info("Skipping unsupported file type in tar: %s\n", header.Name) + } + } + + return successHelmUnpackResult(bundle.Name, unpackDir, bundle.Image.Ref), nil +} + +func successHelmUnpackResult(bundleName, unpackPath string, chartgz string) *Result { + return &Result{ + Bundle: os.DirFS(unpackPath), + ResolvedSource: &BundleSource{Type: SourceTypeImage, Name: bundleName, Image: &ImageSource{Ref: chartgz}}, + State: StateUnpacked, + Message: fmt.Sprintf("unpacked %q successfully", chartgz), + } +} + +func (i *TarGZ) Cleanup(_ context.Context, bundle *BundleSource) error { + return deleteRecursive(i.bundlePath(bundle.Name)) +} + +func (i *TarGZ) bundlePath(bundleName string) string { + return filepath.Join(i.BaseCachePath, bundleName) +} + +func (i *TarGZ) unpackPath(bundleName string, digest digest.Digest) string { + return filepath.Join(i.bundlePath(bundleName), digest.String()) +} + +func (i *TarGZ) unpackImage(ctx context.Context, unpackPath string, imageReference types.ImageReference, sourceContext *types.SystemContext) error { + img, err := imageReference.NewImage(ctx, sourceContext) + if err != nil { + return fmt.Errorf("error reading image: %w", err) + } + defer func() { + if err := img.Close(); err != nil { + panic(err) + } + }() + + layoutSrc, err := imageReference.NewImageSource(ctx, sourceContext) + if err != nil { + return fmt.Errorf("error creating image source: %w", err) + } + + if err := os.MkdirAll(unpackPath, 0700); err != nil { + return fmt.Errorf("error creating unpack directory: %w", err) + } + l := log.FromContext(ctx) + l.Info("unpacking image", "path", unpackPath) + for i, layerInfo := range img.LayerInfos() { + if err := func() error { + layerReader, _, err := layoutSrc.GetBlob(ctx, layerInfo, none.NoCache) + if err != nil { + return fmt.Errorf("error getting blob for layer[%d]: %w", i, err) + } + defer layerReader.Close() + + if err := applyLayer(ctx, unpackPath, layerReader); err != nil { + return fmt.Errorf("error applying layer[%d]: %w", i, err) + } + l.Info("applied layer", "layer", i) + return nil + }(); err != nil { + return errors.Join(err, deleteRecursive(unpackPath)) + } + } + if err := setReadOnlyRecursive(unpackPath); err != nil { + return fmt.Errorf("error making unpack directory read-only: %w", err) + } + return nil +} + +func (i *TarGZ) deleteOtherImages(bundleName string, digestToKeep digest.Digest) error { + bundlePath := i.bundlePath(bundleName) + imgDirs, err := os.ReadDir(bundlePath) + if err != nil { + return fmt.Errorf("error reading image directories: %w", err) + } + for _, imgDir := range imgDirs { + if imgDir.Name() == digestToKeep.String() { + continue + } + imgDirPath := filepath.Join(bundlePath, imgDir.Name()) + if err := deleteRecursive(imgDirPath); err != nil { + return fmt.Errorf("error removing image directory: %w", err) + } + } + return nil +}