diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 8da6967fe..a7b32e577 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -47,6 +47,7 @@ import ( ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" "github.com/operator-framework/operator-controller/internal/action" + "github.com/operator-framework/operator-controller/internal/applier" "github.com/operator-framework/operator-controller/internal/authentication" "github.com/operator-framework/operator-controller/internal/catalogmetadata/cache" catalogclient "github.com/operator-framework/operator-controller/internal/catalogmetadata/client" @@ -191,6 +192,7 @@ func main() { } return tempConfig, nil } + cfgGetter, err := helmclient.NewActionConfigGetter(mgr.GetConfig(), mgr.GetRESTMapper(), helmclient.StorageNamespaceMapper(installNamespaceMapper), helmclient.ClientNamespaceMapper(installNamespaceMapper), @@ -232,16 +234,6 @@ func main() { os.Exit(1) } - aeClient, err := apiextensionsv1client.NewForConfig(mgr.GetConfig()) - if err != nil { - setupLog.Error(err, "unable to create apiextensions client") - os.Exit(1) - } - - preflights := []controllers.Preflight{ - crdupgradesafety.NewPreflight(aeClient.CustomResourceDefinitions()), - } - cl := mgr.GetClient() catalogsCachePath := filepath.Join(cachePath, "catalogs") @@ -264,16 +256,33 @@ func main() { }, catalogClient.GetPackage, ), + Validations: []resolve.ValidationFunc{ + resolve.NoDependencyValidation, + }, + } + + aeClient, err := apiextensionsv1client.NewForConfig(mgr.GetConfig()) + if err != nil { + setupLog.Error(err, "unable to create apiextensions client") + os.Exit(1) + } + + preflights := []applier.Preflight{ + crdupgradesafety.NewPreflight(aeClient.CustomResourceDefinitions()), + } + + applier := &applier.Helm{ + ActionClientGetter: acg, + Preflights: preflights, } if err = (&controllers.ClusterExtensionReconciler{ Client: cl, Resolver: resolver, - ActionClientGetter: acg, Unpacker: unpacker, + Applier: applier, InstalledBundleGetter: &controllers.DefaultInstalledBundleGetter{ActionClientGetter: acg}, Finalizers: clusterExtensionFinalizers, - Preflights: preflights, Watcher: contentmanager.New(restConfigMapper, mgr.GetConfig(), mgr.GetRESTMapper()), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClusterExtension") diff --git a/internal/applier/helm.go b/internal/applier/helm.go new file mode 100644 index 000000000..61ac7141c --- /dev/null +++ b/internal/applier/helm.go @@ -0,0 +1,166 @@ +package applier + +import ( + "context" + "errors" + "fmt" + "io/fs" + "strings" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage/driver" + corev1 "k8s.io/api/core/v1" + "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/convert" + "github.com/operator-framework/operator-controller/internal/rukpak/preflights/crdupgradesafety" + "github.com/operator-framework/operator-controller/internal/rukpak/util" +) + +const ( + StateNeedsInstall string = "NeedsInstall" + StateNeedsUpgrade string = "NeedsUpgrade" + StateUnchanged string = "Unchanged" + StateError string = "Error" + maxHelmReleaseHistory = 10 +) + +// Preflight is a check that should be run before making any changes to the cluster +type Preflight interface { + // Install runs checks that should be successful prior + // to installing the Helm release. It is provided + // a Helm release and returns an error if the + // check is unsuccessful + Install(context.Context, *release.Release) error + + // Upgrade runs checks that should be successful prior + // to upgrading the Helm release. It is provided + // a Helm release and returns an error if the + // check is unsuccessful + Upgrade(context.Context, *release.Release) error +} + +type Helm struct { + ActionClientGetter helmclient.ActionClientGetter + Preflights []Preflight +} + +func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1alpha1.ClusterExtension, labels map[string]string) ([]client.Object, string, error) { + chrt, err := convert.RegistryV1ToHelmChart(ctx, contentFS, ext.Spec.InstallNamespace, []string{corev1.NamespaceAll}) + if err != nil { + return nil, "", err + } + values := chartutil.Values{} + + ac, err := h.ActionClientGetter.ActionClientFor(ctx, ext) + if err != nil { + return nil, "", err + } + + rel, desiredRel, state, err := h.getReleaseState(ac, ext, chrt, values, labels) + if err != nil { + return nil, "", err + } + + for _, preflight := range h.Preflights { + if ext.Spec.Preflight != nil && ext.Spec.Preflight.CRDUpgradeSafety != nil { + if _, ok := preflight.(*crdupgradesafety.Preflight); ok && ext.Spec.Preflight.CRDUpgradeSafety.Disabled { + // Skip this preflight check because it is of type *crdupgradesafety.Preflight and the CRD Upgrade Safety + // preflight check has been disabled + continue + } + } + switch state { + case StateNeedsInstall: + err := preflight.Install(ctx, desiredRel) + if err != nil { + return nil, state, err + } + case StateNeedsUpgrade: + err := preflight.Upgrade(ctx, desiredRel) + if err != nil { + return nil, state, err + } + } + } + + switch state { + case StateNeedsInstall: + rel, err = ac.Install(ext.GetName(), ext.Spec.InstallNamespace, chrt, values, func(install *action.Install) error { + install.CreateNamespace = false + install.Labels = labels + return nil + }) + if err != nil { + return nil, state, err + } + case StateNeedsUpgrade: + rel, err = ac.Upgrade(ext.GetName(), ext.Spec.InstallNamespace, chrt, values, func(upgrade *action.Upgrade) error { + upgrade.MaxHistory = maxHelmReleaseHistory + upgrade.Labels = labels + return nil + }) + 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 *Helm) getReleaseState(cl helmclient.ActionInterface, ext *ocv1alpha1.ClusterExtension, chrt *chart.Chart, values chartutil.Values, labels map[string]string) (*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.InstallNamespace, chrt, values, func(i *action.Install) error { + i.DryRun = true + i.DryRunOption = "server" + i.Labels = labels + return nil + }) + if err != nil { + return nil, nil, StateError, err + } + return nil, desiredRelease, StateNeedsInstall, nil + } + desiredRelease, err := cl.Upgrade(ext.GetName(), ext.Spec.InstallNamespace, chrt, values, func(upgrade *action.Upgrade) error { + upgrade.MaxHistory = maxHelmReleaseHistory + upgrade.DryRun = true + upgrade.DryRunOption = "server" + upgrade.Labels = labels + return nil + }) + 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 +} diff --git a/internal/catalogmetadata/filter/successors.go b/internal/catalogmetadata/filter/successors.go index 8d02bb65d..f30425972 100644 --- a/internal/catalogmetadata/filter/successors.go +++ b/internal/catalogmetadata/filter/successors.go @@ -20,17 +20,17 @@ func SuccessorsOf(installedBundle *ocv1alpha1.BundleMetadata, channels ...declcf installedBundleVersion, err := mmsemver.NewVersion(installedBundle.Version) if err != nil { - return nil, err + return nil, fmt.Errorf("parsing installed bundle %q version %q: %w", installedBundle.Name, installedBundle.Version, err) } installedVersionConstraint, err := mmsemver.NewConstraint(installedBundleVersion.String()) if err != nil { - return nil, err + return nil, fmt.Errorf("parsing installed version constraint %q: %w", installedBundleVersion.String(), err) } successorsPredicate, err := successors(installedBundle, channels...) if err != nil { - return nil, err + return nil, fmt.Errorf("getting successorsPredicate: %w", err) } // We need either successors or current version (no upgrade) diff --git a/internal/controllers/clusterextension_controller.go b/internal/controllers/clusterextension_controller.go index 2df65faf9..16142ffdf 100644 --- a/internal/controllers/clusterextension_controller.go +++ b/internal/controllers/clusterextension_controller.go @@ -17,29 +17,19 @@ limitations under the License. package controllers import ( - "bytes" "context" "errors" "fmt" - "io" + "io/fs" "strings" "time" "github.com/go-logr/logr" - "helm.sh/helm/v3/pkg/action" - "helm.sh/helm/v3/pkg/chart" - "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" "k8s.io/apimachinery/pkg/api/equality" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/sets" - apimachyaml "k8s.io/apimachinery/pkg/util/yaml" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/cache" @@ -56,22 +46,15 @@ import ( catalogd "github.com/operator-framework/catalogd/api/core/v1alpha1" helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client" "github.com/operator-framework/operator-registry/alpha/declcfg" - "github.com/operator-framework/operator-registry/alpha/property" ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" + "github.com/operator-framework/operator-controller/internal/applier" "github.com/operator-framework/operator-controller/internal/bundleutil" "github.com/operator-framework/operator-controller/internal/conditionsets" "github.com/operator-framework/operator-controller/internal/contentmanager" "github.com/operator-framework/operator-controller/internal/labels" "github.com/operator-framework/operator-controller/internal/resolve" - "github.com/operator-framework/operator-controller/internal/rukpak/convert" - "github.com/operator-framework/operator-controller/internal/rukpak/preflights/crdupgradesafety" rukpaksource "github.com/operator-framework/operator-controller/internal/rukpak/source" - "github.com/operator-framework/operator-controller/internal/rukpak/util" -) - -const ( - maxHelmReleaseHistory = 10 ) // ClusterExtensionReconciler reconciles a ClusterExtension object @@ -79,32 +62,20 @@ type ClusterExtensionReconciler struct { client.Client Resolver resolve.Resolver Unpacker rukpaksource.Unpacker - ActionClientGetter helmclient.ActionClientGetter + Applier Applier Watcher contentmanager.Watcher controller crcontroller.Controller cache cache.Cache InstalledBundleGetter InstalledBundleGetter Finalizers crfinalizer.Finalizers - Preflights []Preflight } -type InstalledBundleGetter interface { - GetInstalledBundle(ctx context.Context, ext *ocv1alpha1.ClusterExtension) (*ocv1alpha1.BundleMetadata, error) +type Applier interface { + Apply(context.Context, fs.FS, *ocv1alpha1.ClusterExtension, map[string]string) ([]client.Object, string, error) } -// Preflight is a check that should be run before making any changes to the cluster -type Preflight interface { - // Install runs checks that should be successful prior - // to installing the Helm release. It is provided - // a Helm release and returns an error if the - // check is unsuccessful - Install(context.Context, *release.Release) error - - // Upgrade runs checks that should be successful prior - // to upgrading the Helm release. It is provided - // a Helm release and returns an error if the - // check is unsuccessful - Upgrade(context.Context, *release.Release) error +type InstalledBundleGetter interface { + GetInstalledBundle(ctx context.Context, ext *ocv1alpha1.ClusterExtension) (*ocv1alpha1.BundleMetadata, error) } //+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterextensions,verbs=get;list;watch;update;patch @@ -245,15 +216,6 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1alp return ctrl.Result{}, err } - l.V(1).Info("validating bundle") - if err := r.validateBundle(resolvedBundle); err != nil { - ext.Status.ResolvedBundle = nil - ext.Status.InstalledBundle = nil - setResolvedStatusConditionFailed(ext, err.Error()) - setInstalledStatusConditionFailed(ext, err.Error()) - setDeprecationStatusesUnknown(ext, "deprecation checks have not been attempted as installation has failed") - return ctrl.Result{}, err - } // set deprecation status after _successful_ resolution // TODO: // 1. It seems like deprecation status should reflect the currently installed bundle, not the resolved @@ -299,107 +261,27 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1alp return ctrl.Result{}, fmt.Errorf("unexpected unpack status: %v", unpackResult.Message) } - l.V(1).Info("converting bundle to helm chart") - chrt, err := convert.RegistryV1ToHelmChart(ctx, unpackResult.Bundle, ext.Spec.InstallNamespace, []string{corev1.NamespaceAll}) - if err != nil { - setInstalledStatusConditionFailed(ext, err.Error()) - return ctrl.Result{}, err + lbls := map[string]string{ + labels.OwnerKindKey: ocv1alpha1.ClusterExtensionKind, + labels.OwnerNameKey: ext.GetName(), + labels.BundleNameKey: resolvedBundle.Name, + labels.PackageNameKey: resolvedBundle.Package, + labels.BundleVersionKey: resolvedBundleVersion.String(), } - values := chartutil.Values{} - l.V(1).Info("getting helm client") - ac, err := r.ActionClientGetter.ActionClientFor(ctx, ext) + l.V(1).Info("applying bundle contents") + managedObjs, state, err := r.Applier.Apply(ctx, unpackResult.Bundle, ext, lbls) if err != nil { - ext.Status.InstalledBundle = nil - setInstalledStatusConditionFailed(ext, fmt.Sprintf("%s:%v", ocv1alpha1.ReasonErrorGettingClient, err)) - return ctrl.Result{}, err - } - - post := &postrenderer{ - labels: map[string]string{ - labels.OwnerKindKey: ocv1alpha1.ClusterExtensionKind, - labels.OwnerNameKey: ext.GetName(), - labels.BundleNameKey: resolvedBundle.Name, - labels.PackageNameKey: resolvedBundle.Package, - labels.BundleVersionKey: resolvedBundleVersion.String(), - }, - } - - l.V(1).Info("getting current state of helm release") - rel, desiredRel, state, err := r.getReleaseState(ac, ext, chrt, values, post) - if err != nil { - setInstalledStatusConditionFailed(ext, fmt.Sprintf("%s:%v", ocv1alpha1.ReasonErrorGettingReleaseState, err)) - return ctrl.Result{}, err - } - - l.V(1).Info("running preflight checks") - for _, preflight := range r.Preflights { - if ext.Spec.Preflight != nil && ext.Spec.Preflight.CRDUpgradeSafety != nil { - if _, ok := preflight.(*crdupgradesafety.Preflight); ok && ext.Spec.Preflight.CRDUpgradeSafety.Disabled { - // Skip this preflight check because it is of type *crdupgradesafety.Preflight and the CRD Upgrade Safety - // preflight check has been disabled - continue - } - } - switch state { - case stateNeedsInstall: - err := preflight.Install(ctx, desiredRel) - if err != nil { - setInstalledStatusConditionFailed(ext, fmt.Sprintf("%s:%v", ocv1alpha1.ReasonInstallationFailed, err)) - return ctrl.Result{}, err - } - case stateNeedsUpgrade: - err := preflight.Upgrade(ctx, desiredRel) - if err != nil { - setInstalledStatusConditionFailed(ext, fmt.Sprintf("%s:%v", ocv1alpha1.ReasonInstallationFailed, err)) - return ctrl.Result{}, err - } - } - } - - l.V(1).Info("reconciling helm release changes") - switch state { - case stateNeedsInstall: - rel, err = ac.Install(ext.GetName(), ext.Spec.InstallNamespace, chrt, values, func(install *action.Install) error { - install.CreateNamespace = false - install.Labels = map[string]string{labels.BundleNameKey: resolvedBundle.Name, labels.PackageNameKey: resolvedBundle.Package, labels.BundleVersionKey: resolvedBundleVersion.String()} - return nil - }, helmclient.AppendInstallPostRenderer(post)) - if err != nil { - setInstalledStatusConditionFailed(ext, fmt.Sprintf("%s:%v", ocv1alpha1.ReasonInstallationFailed, err)) - return ctrl.Result{}, err - } - case stateNeedsUpgrade: - rel, err = ac.Upgrade(ext.GetName(), ext.Spec.InstallNamespace, chrt, values, func(upgrade *action.Upgrade) error { - upgrade.MaxHistory = maxHelmReleaseHistory - upgrade.Labels = map[string]string{labels.BundleNameKey: resolvedBundle.Name, labels.PackageNameKey: resolvedBundle.Package, labels.BundleVersionKey: resolvedBundleVersion.String()} - return nil - }, helmclient.AppendUpgradePostRenderer(post)) - if err != nil { - setInstalledStatusConditionFailed(ext, fmt.Sprintf("%s:%v", ocv1alpha1.ReasonUpgradeFailed, err)) - return ctrl.Result{}, err - } - case stateUnchanged: - if err := ac.Reconcile(rel); err != nil { - setInstalledStatusConditionFailed(ext, fmt.Sprintf("%s:%v", ocv1alpha1.ReasonResolutionFailed, err)) - return ctrl.Result{}, err - } - default: - return ctrl.Result{}, fmt.Errorf("unexpected release state %q", state) - } - - l.V(1).Info("configuring watches for release objects") - relObjects, err := util.ManifestObjects(strings.NewReader(rel.Manifest), fmt.Sprintf("%s-release-manifest", rel.Name)) - if err != nil { - setInstalledStatusConditionFailed(ext, fmt.Sprintf("%s:%v", ocv1alpha1.ReasonInstallationFailed, err)) + setInstalledStatusConditionFailed(ext, err.Error()) return ctrl.Result{}, err } // Only attempt to watch resources if we are // installing / upgrading. Otherwise we may restart // watches that have already been established - if state != stateUnchanged { - if err := r.Watcher.Watch(ctx, r.controller, ext, relObjects); err != nil { + if state != applier.StateUnchanged { + l.V(1).Info("watching managed objects") + if err := r.Watcher.Watch(ctx, r.controller, ext, managedObjs); err != nil { ext.Status.InstalledBundle = nil setInstalledStatusConditionFailed(ext, fmt.Sprintf("%s:%v", ocv1alpha1.ReasonInstallationFailed, err)) return ctrl.Result{}, err @@ -539,50 +421,6 @@ func clusterExtensionRequestsForCatalog(c client.Reader, logger logr.Logger) crh } } -type releaseState string - -const ( - stateNeedsInstall releaseState = "NeedsInstall" - stateNeedsUpgrade releaseState = "NeedsUpgrade" - stateUnchanged releaseState = "Unchanged" - stateError releaseState = "Error" -) - -func (r *ClusterExtensionReconciler) getReleaseState(cl helmclient.ActionInterface, ext *ocv1alpha1.ClusterExtension, chrt *chart.Chart, values chartutil.Values, post *postrenderer) (*release.Release, *release.Release, releaseState, 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) { - desiredRelease, err := cl.Install(ext.GetName(), ext.Spec.InstallNamespace, 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.InstallNamespace, 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 -} - type DefaultInstalledBundleGetter struct { helmclient.ActionClientGetter } @@ -606,52 +444,3 @@ func (d *DefaultInstalledBundleGetter) GetInstalledBundle(ctx context.Context, e Version: release.Labels[labels.BundleVersionKey], }, nil } - -type postrenderer struct { - labels map[string]string - cascade postrender.PostRenderer -} - -func (p *postrenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { - var buf bytes.Buffer - dec := apimachyaml.NewYAMLOrJSONDecoder(renderedManifests, 1024) - for { - obj := unstructured.Unstructured{} - err := dec.Decode(&obj) - if errors.Is(err, io.EOF) { - break - } - if err != nil { - return nil, err - } - obj.SetLabels(util.MergeMaps(obj.GetLabels(), p.labels)) - b, err := obj.MarshalJSON() - if err != nil { - return nil, err - } - buf.Write(b) - } - if p.cascade != nil { - return p.cascade.Run(&buf) - } - return &buf, nil -} - -func (r *ClusterExtensionReconciler) validateBundle(bundle *declcfg.Bundle) error { - unsupportedProps := sets.New[string]( - property.TypePackageRequired, - property.TypeGVKRequired, - property.TypeConstraint, - ) - for i := range bundle.Properties { - if unsupportedProps.Has(bundle.Properties[i].Type) { - return fmt.Errorf( - "bundle %q has a dependency declared via property %q which is currently not supported", - bundle.Name, - bundle.Properties[i].Type, - ) - } - } - - return nil -} diff --git a/internal/controllers/clusterextension_controller_test.go b/internal/controllers/clusterextension_controller_test.go index f3a54bb0d..81293d397 100644 --- a/internal/controllers/clusterextension_controller_test.go +++ b/internal/controllers/clusterextension_controller_test.go @@ -2,14 +2,15 @@ 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/mock" "github.com/stretchr/testify/require" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -29,7 +30,7 @@ import ( // Describe: ClusterExtension Controller Test func TestClusterExtensionDoesNotExist(t *testing.T) { - _, reconciler := newClientAndReconciler(t, nil) + _, reconciler := newClientAndReconciler(t) t.Log("When the cluster extension does not exist") t.Log("It returns no error") @@ -40,7 +41,7 @@ func TestClusterExtensionDoesNotExist(t *testing.T) { func TestClusterExtensionResolutionFails(t *testing.T) { pkgName := fmt.Sprintf("non-existent-%s", rand.String(6)) - cl, reconciler := newClientAndReconciler(t, nil) + 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) }) @@ -86,12 +87,12 @@ func TestClusterExtensionResolutionFails(t *testing.T) { } func TestClusterExtensionResolutionSucceeds(t *testing.T) { - cl, reconciler := newClientAndReconciler(t, nil) - mockUnpacker := unpacker.(*MockUnpacker) - // Set up the Unpack method to return a result with StateUnpacked - mockUnpacker.On("Unpack", mock.Anything, mock.AnythingOfType("*source.BundleSource")).Return(&source.Result{ - State: source.StatePending, - }, nil) + cl, reconciler := newClientAndReconciler(t) + reconciler.Unpacker = &MockUnpacker{ + result: &source.Result{ + State: source.StatePending, + }, + } ctx := context.Background() extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} @@ -156,6 +157,468 @@ func TestClusterExtensionResolutionSucceeds(t *testing.T) { require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) } +func TestClusterExtensionUnpackFails(t *testing.T) { + cl, reconciler := newClientAndReconciler(t) + reconciler.Unpacker = &MockUnpacker{ + err: errors.New("unpack failure"), + } + + 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{ + PackageName: pkgName, + Version: pkgVer, + Channel: pkgChan, + InstallNamespace: 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 + }) + 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.ResolvedBundle) + require.Empty(t, clusterExtension.Status.InstalledBundle) + + t.Log("By checking the expected conditions") + resolvedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, resolvedCond) + require.Equal(t, metav1.ConditionTrue, resolvedCond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, resolvedCond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake1.0.0\"", resolvedCond.Message) + + t.Log("By checking the expected unpacked conditions") + unpackedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeUnpacked) + require.NotNil(t, unpackedCond) + require.Equal(t, metav1.ConditionFalse, unpackedCond.Status) + require.Equal(t, ocv1alpha1.ReasonUnpackFailed, unpackedCond.Reason) + + 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" + 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{ + PackageName: pkgName, + Version: pkgVer, + Channel: pkgChan, + InstallNamespace: 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 + }) + 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.ResolvedBundle) + require.Empty(t, clusterExtension.Status.InstalledBundle) + + t.Log("By checking the expected conditions") + resolvedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, resolvedCond) + require.Equal(t, metav1.ConditionTrue, resolvedCond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, resolvedCond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake1.0.0\"", resolvedCond.Message) + + t.Log("By checking the expected unpacked conditions") + unpackedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeUnpacked) + require.NotNil(t, unpackedCond) + require.Equal(t, metav1.ConditionFalse, unpackedCond.Status) + require.Equal(t, ocv1alpha1.ReasonUnpackFailed, unpackedCond.Reason) + + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) +} + +func TestClusterExtensionUnpackSucceeds(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{ + PackageName: pkgName, + Version: pkgVer, + Channel: pkgChan, + InstallNamespace: 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{ + 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") + require.Equal(t, &ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, clusterExtension.Status.ResolvedBundle) + require.Empty(t, clusterExtension.Status.InstalledBundle) + + t.Log("By checking the expected resolution conditions") + resolvedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, resolvedCond) + require.Equal(t, metav1.ConditionTrue, resolvedCond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, resolvedCond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake1.0.0\"", resolvedCond.Message) + + t.Log("By checking the expected unpacked conditions") + unpackedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeUnpacked) + require.NotNil(t, unpackedCond) + require.Equal(t, metav1.ConditionTrue, unpackedCond.Status) + require.Equal(t, ocv1alpha1.ReasonUnpackSuccess, unpackedCond.Reason) + + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) +} + +func TestClusterExtensionInstallationFailedApplierFails(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{ + PackageName: pkgName, + Version: pkgVer, + Channel: pkgChan, + InstallNamespace: 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{ + 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") + require.Equal(t, &ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, clusterExtension.Status.ResolvedBundle) + require.Empty(t, clusterExtension.Status.InstalledBundle) + + t.Log("By checking the expected resolution conditions") + resolvedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, resolvedCond) + require.Equal(t, metav1.ConditionTrue, resolvedCond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, resolvedCond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake1.0.0\"", resolvedCond.Message) + + t.Log("By checking the expected unpacked conditions") + unpackedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeUnpacked) + require.NotNil(t, unpackedCond) + require.Equal(t, metav1.ConditionTrue, unpackedCond.Status) + require.Equal(t, ocv1alpha1.ReasonUnpackSuccess, unpackedCond.Reason) + + 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.ReasonInstallationFailed, installedCond.Reason) + + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) +} + +func TestClusterExtensionInstallationFailedWatcherFailed(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{ + PackageName: pkgName, + Version: pkgVer, + Channel: pkgChan, + InstallNamespace: 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.Watcher = &MockWatcher{ + err: errors.New("watcher 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.ResolvedBundle) + require.Empty(t, clusterExtension.Status.InstalledBundle) + + t.Log("By checking the expected resolution conditions") + resolvedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, resolvedCond) + require.Equal(t, metav1.ConditionTrue, resolvedCond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, resolvedCond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake1.0.0\"", resolvedCond.Message) + + t.Log("By checking the expected unpacked conditions") + unpackedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeUnpacked) + require.NotNil(t, unpackedCond) + require.Equal(t, metav1.ConditionTrue, unpackedCond.Status) + require.Equal(t, ocv1alpha1.ReasonUnpackSuccess, unpackedCond.Reason) + + 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.ReasonInstallationFailed, installedCond.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" + 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{ + PackageName: pkgName, + Version: pkgVer, + Channel: pkgChan, + InstallNamespace: 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.Watcher = &MockWatcher{} + 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.ResolvedBundle) + require.Equal(t, &ocv1alpha1.BundleMetadata{Name: "prometheus.v1.0.0", Version: "1.0.0"}, clusterExtension.Status.InstalledBundle) + + t.Log("By checking the expected resolution conditions") + resolvedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeResolved) + require.NotNil(t, resolvedCond) + require.Equal(t, metav1.ConditionTrue, resolvedCond.Status) + require.Equal(t, ocv1alpha1.ReasonSuccess, resolvedCond.Reason) + require.Equal(t, "resolved to \"quay.io/operatorhubio/prometheus@fake1.0.0\"", resolvedCond.Message) + + t.Log("By checking the expected unpacked conditions") + unpackedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeUnpacked) + require.NotNil(t, unpackedCond) + require.Equal(t, metav1.ConditionTrue, unpackedCond.Status) + require.Equal(t, ocv1alpha1.ReasonUnpackSuccess, unpackedCond.Reason) + + 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.ReasonSuccess, installedCond.Reason) + + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) +} + 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)) diff --git a/internal/controllers/common_controller.go b/internal/controllers/common_controller.go index b69c76774..90f409742 100644 --- a/internal/controllers/common_controller.go +++ b/internal/controllers/common_controller.go @@ -67,26 +67,6 @@ func setInstalledStatusConditionFailed(ext *ocv1alpha1.ClusterExtension, message }) } -// setDeprecationStatusesUnknown sets the deprecation status conditions to unknown. -func setDeprecationStatusesUnknown(ext *ocv1alpha1.ClusterExtension, message string) { - conditionTypes := []string{ - ocv1alpha1.TypeDeprecated, - ocv1alpha1.TypePackageDeprecated, - ocv1alpha1.TypeChannelDeprecated, - ocv1alpha1.TypeBundleDeprecated, - } - - for _, conditionType := range conditionTypes { - apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ - Type: conditionType, - Reason: ocv1alpha1.ReasonDeprecated, - Status: metav1.ConditionUnknown, - Message: message, - ObservedGeneration: ext.GetGeneration(), - }) - } -} - func setStatusUnpackFailed(ext *ocv1alpha1.ClusterExtension, message string) { ext.Status.InstalledBundle = nil apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ diff --git a/internal/controllers/suite_test.go b/internal/controllers/suite_test.go index 6776b7265..f45b89a52 100644 --- a/internal/controllers/suite_test.go +++ b/internal/controllers/suite_test.go @@ -18,41 +18,46 @@ package controllers_test import ( "context" + "io/fs" "log" "os" "path/filepath" "testing" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/envtest" crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer" 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/contentmanager" "github.com/operator-framework/operator-controller/internal/controllers" "github.com/operator-framework/operator-controller/internal/rukpak/source" ) // MockUnpacker is a mock of Unpacker interface type MockUnpacker struct { - mock.Mock + err error + result *source.Result } // Unpack mocks the Unpack method -func (m *MockUnpacker) Unpack(ctx context.Context, bundle *source.BundleSource) (*source.Result, error) { - args := m.Called(ctx, bundle) - return args.Get(0).(*source.Result), args.Error(1) +func (m *MockUnpacker) Unpack(_ context.Context, _ *source.BundleSource) (*source.Result, error) { + if m.err != nil { + return nil, m.err + } + return m.result, nil } -func (m *MockUnpacker) Cleanup(ctx context.Context, bundle *source.BundleSource) error { - //TODO implement me +func (m *MockUnpacker) Cleanup(_ context.Context, _ *source.BundleSource) error { + // TODO implement me panic("implement me") } @@ -79,14 +84,40 @@ func (m *MockInstalledBundleGetter) GetInstalledBundle(ctx context.Context, ext return m.bundle, nil } -func newClientAndReconciler(t *testing.T, bundle *ocv1alpha1.BundleMetadata) (client.Client, *controllers.ClusterExtensionReconciler) { +var _ controllers.Applier = (*MockApplier)(nil) + +type MockApplier struct { + err error + objs []client.Object + state string +} + +func (m *MockApplier) Apply(_ context.Context, _ fs.FS, _ *ocv1alpha1.ClusterExtension, _ map[string]string) ([]client.Object, string, error) { + if m.err != nil { + return nil, m.state, m.err + } + + return m.objs, m.state, nil +} + +var _ contentmanager.Watcher = (*MockWatcher)(nil) + +type MockWatcher struct { + err error +} + +func (m *MockWatcher) Watch(_ context.Context, _ controller.Controller, _ *ocv1alpha1.ClusterExtension, _ []client.Object) error { + return m.err +} + +func (m *MockWatcher) Unwatch(_ *ocv1alpha1.ClusterExtension) {} + +func newClientAndReconciler(t *testing.T) (client.Client, *controllers.ClusterExtensionReconciler) { cl := newClient(t) reconciler := &controllers.ClusterExtensionReconciler{ Client: cl, - ActionClientGetter: helmClientGetter, - Unpacker: unpacker, - InstalledBundleGetter: &MockInstalledBundleGetter{bundle}, + InstalledBundleGetter: &MockInstalledBundleGetter{}, Finalizers: crfinalizer.NewFinalizers(), } return cl, reconciler @@ -95,13 +126,13 @@ func newClientAndReconciler(t *testing.T, bundle *ocv1alpha1.BundleMetadata) (cl var ( config *rest.Config helmClientGetter helmclient.ActionClientGetter - unpacker source.Unpacker // Interface, will be initialized as a mock in TestMain ) func TestMain(m *testing.M) { testEnv := &envtest.Environment{ CRDDirectoryPaths: []string{ - filepath.Join("..", "..", "config", "base", "crd", "bases")}, + filepath.Join("..", "..", "config", "base", "crd", "bases"), + }, ErrorIfCRDPathMissing: true, } @@ -118,8 +149,6 @@ func TestMain(m *testing.M) { helmClientGetter, err = helmclient.NewActionClientGetter(cfgGetter) utilruntime.Must(err) - unpacker = new(MockUnpacker) - code := m.Run() utilruntime.Must(testEnv.Stop()) os.Exit(code) diff --git a/internal/resolve/catalog.go b/internal/resolve/catalog.go index d4f920425..03ded45ef 100644 --- a/internal/resolve/catalog.go +++ b/internal/resolve/catalog.go @@ -18,8 +18,11 @@ import ( "github.com/operator-framework/operator-controller/internal/catalogmetadata/filter" ) +type ValidationFunc func(*declcfg.Bundle) error + type CatalogResolver struct { WalkCatalogsFunc func(context.Context, string, CatalogWalkFunc, ...client.ListOption) error + Validations []ValidationFunc } // Resolve returns a Bundle from a catalog that needs to get installed on the cluster. @@ -132,6 +135,15 @@ func (r *CatalogResolver) Resolve(ctx context.Context, ext *ocv1alpha1.ClusterEx if err != nil { return nil, nil, nil, fmt.Errorf("error getting resolved bundle version for bundle %q: %w", resolvedBundle.Name, err) } + + // Run validations against the resolved bundle to ensure only valid resolved bundles are being returned + // Open Question: Should we grab the first valid bundle earlier? + for _, validation := range r.Validations { + if err := validation(resolvedBundle); err != nil { + return nil, nil, nil, fmt.Errorf("validating bundle %q: %w", resolvedBundle.Name, err) + } + } + return resolvedBundle, resolvedBundleVersion, resolvedDeprecation, nil } diff --git a/internal/resolve/catalog_test.go b/internal/resolve/catalog_test.go index 749710ffa..a067bbe79 100644 --- a/internal/resolve/catalog_test.go +++ b/internal/resolve/catalog_test.go @@ -2,6 +2,7 @@ package resolve import ( "context" + "errors" "fmt" "testing" @@ -81,6 +82,26 @@ func TestPackageExists(t *testing.T) { assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } +func TestValidationFailed(t *testing.T) { + pkgName := randPkg() + w := staticCatalogWalker{ + "a": func() (*declcfg.DeclarativeConfig, error) { return &declcfg.DeclarativeConfig{}, nil }, + "b": func() (*declcfg.DeclarativeConfig, error) { return &declcfg.DeclarativeConfig{}, nil }, + "c": func() (*declcfg.DeclarativeConfig, error) { return genPackage(pkgName), nil }, + } + r := CatalogResolver{ + WalkCatalogsFunc: w.WalkCatalogs, + Validations: []ValidationFunc{ + func(b *declcfg.Bundle) error { + return errors.New("fail") + }, + }, + } + ce := buildFooClusterExtension(pkgName, "", "", ocv1alpha1.UpgradeConstraintPolicyEnforce) + _, _, _, err := r.Resolve(context.Background(), ce, nil) + require.Error(t, err) +} + func TestVersionDoesNotExist(t *testing.T) { pkgName := randPkg() w := staticCatalogWalker{ @@ -360,6 +381,7 @@ func TestUpgradeFoundSemver(t *testing.T) { assert.Equal(t, bsemver.MustParse("1.0.2"), *gotVersion) assert.Equal(t, ptr.To(packageDeprecation(pkgName)), gotDeprecation) } + func TestUpgradeNotFoundSemver(t *testing.T) { defer featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.ForceSemverUpgradeConstraints, true)() pkgName := randPkg() diff --git a/internal/resolve/validation.go b/internal/resolve/validation.go new file mode 100644 index 000000000..1803dfbf4 --- /dev/null +++ b/internal/resolve/validation.go @@ -0,0 +1,29 @@ +package resolve + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/property" +) + +func NoDependencyValidation(bundle *declcfg.Bundle) error { + unsupportedProps := sets.New( + property.TypePackageRequired, + property.TypeGVKRequired, + property.TypeConstraint, + ) + for i := range bundle.Properties { + if unsupportedProps.Has(bundle.Properties[i].Type) { + return fmt.Errorf( + "bundle %q has a dependency declared via property %q which is currently not supported", + bundle.Name, + bundle.Properties[i].Type, + ) + } + } + + return nil +} diff --git a/internal/controllers/clusterextension_registryv1_validation_test.go b/internal/resolve/validation_test.go similarity index 50% rename from internal/controllers/clusterextension_registryv1_validation_test.go rename to internal/resolve/validation_test.go index 83867440f..ed2599a88 100644 --- a/internal/controllers/clusterextension_registryv1_validation_test.go +++ b/internal/resolve/validation_test.go @@ -1,36 +1,16 @@ -package controllers_test +package resolve import ( - "context" "encoding/json" - "fmt" "testing" - bsemver "github.com/blang/semver/v4" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "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" - crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer" "github.com/operator-framework/operator-registry/alpha/declcfg" "github.com/operator-framework/operator-registry/alpha/property" - - ocv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1" - "github.com/operator-framework/operator-controller/internal/bundleutil" - "github.com/operator-framework/operator-controller/internal/controllers" - "github.com/operator-framework/operator-controller/internal/resolve" - "github.com/operator-framework/operator-controller/internal/rukpak/source" ) -func TestClusterExtensionRegistryV1DisallowDependencies(t *testing.T) { - ctx := context.Background() - cl := newClient(t) - +func TestNoDependencyValidation(t *testing.T) { for _, tt := range []struct { name string bundle declcfg.Bundle @@ -88,60 +68,11 @@ func TestClusterExtensionRegistryV1DisallowDependencies(t *testing.T) { }, } { t.Run(tt.name, func(t *testing.T) { - defer func() { - require.NoError(t, cl.DeleteAllOf(ctx, &ocv1alpha1.ClusterExtension{})) - }() - resolver := resolve.Func(func(_ context.Context, _ *ocv1alpha1.ClusterExtension, _ *ocv1alpha1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) { - v, err := bundleutil.GetVersion(tt.bundle) - if err != nil { - return nil, nil, nil, err - } - return &tt.bundle, v, nil, nil - }) - mockUnpacker := unpacker.(*MockUnpacker) - // Set up the Unpack method to return a result with StatePending - mockUnpacker.On("Unpack", mock.Anything, mock.AnythingOfType("*source.BundleSource")).Return(&source.Result{ - State: source.StatePending, - }, nil) - - reconciler := &controllers.ClusterExtensionReconciler{ - Client: cl, - Resolver: resolver, - ActionClientGetter: helmClientGetter, - Unpacker: unpacker, - InstalledBundleGetter: &MockInstalledBundleGetter{}, - Finalizers: crfinalizer.NewFinalizers(), - } - - installNamespace := fmt.Sprintf("test-ns-%s", rand.String(8)) - serviceAccount := fmt.Sprintf("test-sa-%s", rand.String(8)) - extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))} - clusterExtension := &ocv1alpha1.ClusterExtension{ - ObjectMeta: metav1.ObjectMeta{Name: extKey.Name}, - Spec: ocv1alpha1.ClusterExtensionSpec{ - PackageName: tt.bundle.Package, - InstallNamespace: installNamespace, - ServiceAccount: ocv1alpha1.ServiceAccountReference{ - Name: serviceAccount, - }, - }, - } - require.NoError(t, cl.Create(ctx, clusterExtension)) - - res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey}) - require.Equal(t, ctrl.Result{}, res) + err := NoDependencyValidation(&tt.bundle) if tt.wantErr == "" { assert.NoError(t, err) } else { assert.EqualError(t, err, tt.wantErr) - - // In case of an error we want it to be included in the installed condition - require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) - cond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1alpha1.TypeInstalled) - require.NotNil(t, cond) - require.Equal(t, metav1.ConditionFalse, cond.Status) - require.Equal(t, ocv1alpha1.ReasonInstallationFailed, cond.Reason) - require.Equal(t, tt.wantErr, cond.Message) } }) }