diff --git a/internal/cmd/internal/olmv1/operator_update.go b/internal/cmd/internal/olmv1/operator_update.go new file mode 100644 index 00000000..c0a76c28 --- /dev/null +++ b/internal/cmd/internal/olmv1/operator_update.go @@ -0,0 +1,42 @@ +package olmv1 + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" + v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" + "github.com/operator-framework/kubectl-operator/pkg/action" +) + +// NewOperatorUpdateCmd allows updating a selected operator +func NewOperatorUpdateCmd(cfg *action.Configuration) *cobra.Command { + i := v1action.NewOperatorUpdate(cfg) + i.Logf = log.Printf + + cmd := &cobra.Command{ + Use: "operator ", + Short: "Update an operator", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + i.Package = args[0] + _, err := i.Run(cmd.Context()) + if err != nil { + log.Fatalf("failed to update operator: %v", err) + } + log.Printf("operator %q updated", i.Package) + }, + } + bindOperatorUpdateFlags(cmd.Flags(), i) + + return cmd +} + +func bindOperatorUpdateFlags(fs *pflag.FlagSet, i *v1action.OperatorUpdate) { + fs.StringVar(&i.Version, "version", "", "desired operator version (single or range) in semVer format. AND operation with channels") + fs.StringVar(&i.Selector, "selector", "", "filters the set of catalogs used in the bundle selection process. Empty means that all catalogs will be used in the bundle selection process") + fs.StringArrayVar(&i.Channels, "channels", []string{}, "desired channels for operator versions. AND operation with version. Empty list means all available channels will be taken into consideration") + fs.StringVar(&i.UpgradeConstraintPolicy, "upgrade-constraint-policy", "", "controls whether the upgrade path(s) defined in the catalog are enforced. One of CatalogProvided|SelfCertified), Default: CatalogProvided") + fs.StringToStringVar(&i.Labels, "labels", map[string]string{}, "labels that will be set on the operator") + fs.BoolVar(&i.IgnoreUnset, "ignore-unset", true, "when enabled, any unset flag value will not be changed. Disabling means that for each unset value a default will be used instead") +} diff --git a/internal/cmd/olmv1.go b/internal/cmd/olmv1.go index 8e7c39d3..a1a0defb 100644 --- a/internal/cmd/olmv1.go +++ b/internal/cmd/olmv1.go @@ -38,12 +38,22 @@ func newOlmV1Cmd(cfg *action.Configuration) *cobra.Command { } deleteCmd.AddCommand(olmv1.NewCatalogDeleteCmd(cfg)) + updateCmd := &cobra.Command{ + Use: "update", + Short: "Update a resource", + Long: "Update a resource", + } + updateCmd.AddCommand( + olmv1.NewOperatorUpdateCmd(cfg), + ) + cmd.AddCommand( olmv1.NewOperatorInstallCmd(cfg), olmv1.NewOperatorUninstallCmd(cfg), getCmd, createCmd, deleteCmd, + updateCmd, ) return cmd diff --git a/internal/pkg/v1/action/action_suite_test.go b/internal/pkg/v1/action/action_suite_test.go index ab81ec06..e01046b2 100644 --- a/internal/pkg/v1/action/action_suite_test.go +++ b/internal/pkg/v1/action/action_suite_test.go @@ -8,7 +8,9 @@ import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" olmv1 "github.com/operator-framework/operator-controller/api/v1" @@ -63,3 +65,69 @@ func newClusterCatalog(name string) *olmv1.ClusterCatalog { ObjectMeta: metav1.ObjectMeta{Name: name}, } } + +type extensionOpt func(*olmv1.ClusterExtension) + +func withVersion(version string) extensionOpt { + return func(ext *olmv1.ClusterExtension) { + ext.Spec.Source.Catalog.Version = version + } +} + +func withSourceType(sourceType string) extensionOpt { + return func(ext *olmv1.ClusterExtension) { + ext.Spec.Source.SourceType = sourceType + } +} + +// nolint: unparam +func withConstraintPolicy(policy string) extensionOpt { + return func(ext *olmv1.ClusterExtension) { + ext.Spec.Source.Catalog.UpgradeConstraintPolicy = olmv1.UpgradeConstraintPolicy(policy) + } +} + +func withChannels(channels ...string) extensionOpt { + return func(ext *olmv1.ClusterExtension) { + ext.Spec.Source.Catalog.Channels = channels + } +} + +func withLabels(labels map[string]string) extensionOpt { + return func(ext *olmv1.ClusterExtension) { + ext.SetLabels(labels) + } +} + +func buildExtension(packageName string, opts ...extensionOpt) *olmv1.ClusterExtension { + ext := &olmv1.ClusterExtension{ + Spec: olmv1.ClusterExtensionSpec{ + Source: olmv1.SourceConfig{ + Catalog: &olmv1.CatalogFilter{PackageName: packageName}, + }, + }, + } + ext.SetName(packageName) + for _, opt := range opts { + opt(ext) + } + + return ext +} + +func updateOperatorConditionStatus(name string, cl client.Client, typ string, status metav1.ConditionStatus) error { + var ext olmv1.ClusterExtension + key := types.NamespacedName{Name: name} + + if err := cl.Get(context.TODO(), key, &ext); err != nil { + return err + } + + apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ + Type: typ, + Status: status, + ObservedGeneration: ext.GetGeneration(), + }) + + return cl.Update(context.TODO(), &ext) +} diff --git a/internal/pkg/v1/action/catalog_delete.go b/internal/pkg/v1/action/catalog_delete.go index 8d5bb580..d3888271 100644 --- a/internal/pkg/v1/action/catalog_delete.go +++ b/internal/pkg/v1/action/catalog_delete.go @@ -28,7 +28,7 @@ func NewCatalogDelete(cfg *action.Configuration) *CatalogDelete { func (cd *CatalogDelete) Run(ctx context.Context) ([]string, error) { // validate if cd.DeleteAll && cd.CatalogName != "" { - return nil, errNameAndSelector + return nil, ErrNameAndSelector } // delete single, specified catalog @@ -42,7 +42,7 @@ func (cd *CatalogDelete) Run(ctx context.Context) ([]string, error) { return nil, err } if len(catatalogList.Items) == 0 { - return nil, errNoResourcesFound + return nil, ErrNoResourcesFound } errs := make([]error, 0, len(catatalogList.Items)) diff --git a/internal/pkg/v1/action/errors.go b/internal/pkg/v1/action/errors.go index d8cea85c..194562d7 100644 --- a/internal/pkg/v1/action/errors.go +++ b/internal/pkg/v1/action/errors.go @@ -3,6 +3,7 @@ package action import "errors" var ( - errNoResourcesFound = errors.New("no resources found") - errNameAndSelector = errors.New("name cannot be provided when a selector is specified") + ErrNoResourcesFound = errors.New("no resources found") + ErrNameAndSelector = errors.New("name cannot be provided when a selector is specified") + ErrNoChange = errors.New("no changes detected - operator already in desired state") ) diff --git a/internal/pkg/v1/action/helpers.go b/internal/pkg/v1/action/helpers.go index 084f82aa..e3002300 100644 --- a/internal/pkg/v1/action/helpers.go +++ b/internal/pkg/v1/action/helpers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "slices" + "strings" "time" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -46,20 +47,26 @@ func waitUntilCatalogStatusCondition( }) } -func waitForDeletion(ctx context.Context, cl client.Client, obj client.Object) error { - key := objectKeyForObject(obj) - if err := wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) { - if err := cl.Get(conditionCtx, key, obj); apierrors.IsNotFound(err) { - return true, nil - } else if err != nil { +func waitUntilOperatorStatusCondition( + ctx context.Context, + cl getter, + operator *olmv1.ClusterExtension, + conditionType string, + conditionStatus metav1.ConditionStatus, +) error { + opKey := objectKeyForObject(operator) + return wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) { + if err := cl.Get(conditionCtx, opKey, operator); err != nil { return false, err } - return false, nil - }); err != nil { - return fmt.Errorf("waiting for deletion: %w", err) - } - return nil + if slices.ContainsFunc(operator.Status.Conditions, func(cond metav1.Condition) bool { + return cond.Type == conditionType && cond.Status == conditionStatus + }) { + return true, nil + } + return false, nil + }) } func deleteWithTimeout(cl deleter, obj client.Object, timeout time.Duration) error { @@ -72,3 +79,22 @@ func deleteWithTimeout(cl deleter, obj client.Object, timeout time.Duration) err return nil } + +func waitForDeletion(ctx context.Context, cl client.Client, objs ...client.Object) error { + for _, obj := range objs { + obj := obj + lowerKind := strings.ToLower(obj.GetObjectKind().GroupVersionKind().Kind) + key := objectKeyForObject(obj) + if err := wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) { + if err := cl.Get(conditionCtx, key, obj); apierrors.IsNotFound(err) { + return true, nil + } else if err != nil { + return false, err + } + return false, nil + }); err != nil { + return fmt.Errorf("wait for %s %q deleted: %v", lowerKind, key.Name, err) + } + } + return nil +} diff --git a/internal/pkg/v1/action/operator_update.go b/internal/pkg/v1/action/operator_update.go new file mode 100644 index 00000000..830ae5d5 --- /dev/null +++ b/internal/pkg/v1/action/operator_update.go @@ -0,0 +1,146 @@ +package action + +import ( + "context" + "fmt" + "maps" + "slices" + "time" + + "github.com/blang/semver/v4" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" + + "github.com/operator-framework/kubectl-operator/pkg/action" +) + +type OperatorUpdate struct { + cfg *action.Configuration + + Package string + + Version string + Channels []string + Selector string + // parsedSelector is used internally to avoid potentially costly transformations + // between string and metav1.LabelSelector formats + parsedSelector *metav1.LabelSelector + UpgradeConstraintPolicy string + Labels map[string]string + IgnoreUnset bool + + CleanupTimeout time.Duration + + Logf func(string, ...interface{}) +} + +func NewOperatorUpdate(cfg *action.Configuration) *OperatorUpdate { + return &OperatorUpdate{ + cfg: cfg, + Logf: func(string, ...interface{}) {}, + } +} + +func (ou *OperatorUpdate) Run(ctx context.Context) (*olmv1.ClusterExtension, error) { + var ext olmv1.ClusterExtension + var err error + + opKey := types.NamespacedName{Name: ou.Package} + if err = ou.cfg.Client.Get(ctx, opKey, &ext); err != nil { + return nil, err + } + + if ext.Spec.Source.SourceType != olmv1.SourceTypeCatalog { + return nil, fmt.Errorf("unrecognized source type: %q", ext.Spec.Source.SourceType) + } + + ou.setDefaults(ext) + + if ou.Version != "" { + if _, err = semver.ParseRange(ou.Version); err != nil { + return nil, fmt.Errorf("failed parsing version: %w", err) + } + } + if ou.Selector != "" && ou.parsedSelector == nil { + ou.parsedSelector, err = metav1.ParseToLabelSelector(ou.Selector) + if err != nil { + return nil, fmt.Errorf("failed parsing selector: %w", err) + } + } + + constraintPolicy := olmv1.UpgradeConstraintPolicy(ou.UpgradeConstraintPolicy) + if !ou.needsUpdate(ext, constraintPolicy) { + return nil, ErrNoChange + } + + ou.prepareUpdatedExtension(&ext, constraintPolicy) + if err := ou.cfg.Client.Update(ctx, &ext); err != nil { + return nil, err + } + + if err := waitUntilOperatorStatusCondition(ctx, ou.cfg.Client, &ext, olmv1.TypeInstalled, metav1.ConditionTrue); err != nil { + return nil, fmt.Errorf("timed out waiting for operator: %w", err) + } + + return &ext, nil +} + +func (ou *OperatorUpdate) setDefaults(ext olmv1.ClusterExtension) { + if !ou.IgnoreUnset { + if ou.UpgradeConstraintPolicy == "" { + ou.UpgradeConstraintPolicy = string(olmv1.UpgradeConstraintPolicyCatalogProvided) + } + + return + } + + // IgnoreUnset is enabled + // set all unset values to what they are on the current object + catalogSrc := ext.Spec.Source.Catalog + if ou.Version == "" { + ou.Version = catalogSrc.Version + } + if len(ou.Channels) == 0 { + ou.Channels = catalogSrc.Channels + } + if ou.UpgradeConstraintPolicy == "" { + ou.UpgradeConstraintPolicy = string(catalogSrc.UpgradeConstraintPolicy) + } + if len(ou.Labels) == 0 { + ou.Labels = ext.Labels + } + if ou.Selector == "" && catalogSrc.Selector != nil { + ou.parsedSelector = catalogSrc.Selector + } +} + +func (ou *OperatorUpdate) needsUpdate(ext olmv1.ClusterExtension, constraintPolicy olmv1.UpgradeConstraintPolicy) bool { + catalogSrc := ext.Spec.Source.Catalog + + // object string form is used for comparison to: + // - remove the need for potentially costly metav1.FormatLabelSelector calls + // - avoid having to handle potential reordering of items from on cluster state + sameSelectors := (catalogSrc.Selector == nil && ou.parsedSelector == nil) || + (catalogSrc.Selector != nil && ou.parsedSelector != nil && + catalogSrc.Selector.String() == ou.parsedSelector.String()) + + if catalogSrc.Version == ou.Version && + slices.Equal(catalogSrc.Channels, ou.Channels) && + catalogSrc.UpgradeConstraintPolicy == constraintPolicy && + maps.Equal(ext.Labels, ou.Labels) && + sameSelectors { + return false + } + + return true +} + +func (ou *OperatorUpdate) prepareUpdatedExtension(ext *olmv1.ClusterExtension, constraintPolicy olmv1.UpgradeConstraintPolicy) { + ext.SetLabels(ou.Labels) + ext.Spec.Source.Catalog.Version = ou.Version + ext.Spec.Source.Catalog.Selector = ou.parsedSelector + ext.Spec.Source.Catalog.Channels = ou.Channels + ext.Spec.Source.Catalog.UpgradeConstraintPolicy = constraintPolicy +} diff --git a/internal/pkg/v1/action/operator_update_test.go b/internal/pkg/v1/action/operator_update_test.go new file mode 100644 index 00000000..f4459f7b --- /dev/null +++ b/internal/pkg/v1/action/operator_update_test.go @@ -0,0 +1,189 @@ +package action_test + +import ( + "context" + "maps" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" + + internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" + "github.com/operator-framework/kubectl-operator/pkg/action" +) + +var _ = Describe("OperatorUpdate", func() { + setupEnv := func(extensions ...client.Object) action.Configuration { + var cfg action.Configuration + + sch, err := action.NewScheme() + Expect(err).To(BeNil()) + + cl := fake.NewClientBuilder(). + WithObjects(extensions...). + WithScheme(sch). + Build() + cfg.Scheme = sch + cfg.Client = cl + + return cfg + } + + It("fails finding existing operator", func() { + cfg := setupEnv() + + updater := internalaction.NewOperatorUpdate(&cfg) + updater.Package = "does-not-exist" + ext, err := updater.Run(context.TODO()) + + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("not found")) + Expect(ext).To(BeNil()) + }) + + It("fails to handle operator with non-catalog source type", func() { + cfg := setupEnv(buildExtension("test", withSourceType("unknown"))) + + updater := internalaction.NewOperatorUpdate(&cfg) + updater.Package = "test" + ext, err := updater.Run(context.TODO()) + + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("unrecognized source type")) + Expect(ext).To(BeNil()) + }) + + It("fails because desired operator state matches current", func() { + cfg := setupEnv(buildExtension( + "test", + withSourceType(olmv1.SourceTypeCatalog), + withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided))), + ) + + updater := internalaction.NewOperatorUpdate(&cfg) + updater.Package = "test" + ext, err := updater.Run(context.TODO()) + + Expect(err).NotTo(BeNil()) + Expect(err).To(MatchError(internalaction.ErrNoChange)) + Expect(ext).To(BeNil()) + }) + + It("fails because desired operator state matches current with IgnoreUnset enabled", func() { + cfg := setupEnv(buildExtension( + "test", + withSourceType(olmv1.SourceTypeCatalog), + withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided)), + withChannels("a", "b"), + withLabels(map[string]string{"c": "d"}), + withVersion("10.0.4"), + )) + + updater := internalaction.NewOperatorUpdate(&cfg) + updater.Package = "test" + updater.IgnoreUnset = true + ext, err := updater.Run(context.TODO()) + + Expect(err).NotTo(BeNil()) + Expect(err).To(MatchError(internalaction.ErrNoChange)) + Expect(ext).To(BeNil()) + }) + + It("fails validating operator version", func() { + cfg := setupEnv(buildExtension( + "test", + withSourceType(olmv1.SourceTypeCatalog), + withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided))), + ) + + updater := internalaction.NewOperatorUpdate(&cfg) + updater.Package = "test" + updater.Version = "10-4" + ext, err := updater.Run(context.TODO()) + + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("parsing version")) + Expect(ext).To(BeNil()) + }) + + It("fails updating operator", func() { + testExt := buildExtension( + "test", + withSourceType(olmv1.SourceTypeCatalog), + withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided)), + ) + cfg := setupEnv(testExt) + + ctx, cancel := context.WithCancel(context.TODO()) + cancel() + + updater := internalaction.NewOperatorUpdate(&cfg) + updater.Package = "test" + updater.Version = "10.0.4" + updater.Channels = []string{"a", "b"} + updater.Labels = map[string]string{"c": "d"} + updater.UpgradeConstraintPolicy = string(olmv1.UpgradeConstraintPolicySelfCertified) + ext, err := updater.Run(ctx) + + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("timed out")) + Expect(ext).To(BeNil()) + }) + + It("successfully updates operator", func() { + testExt := buildExtension( + "test", + withSourceType(olmv1.SourceTypeCatalog), + withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided)), + ) + cfg := setupEnv(testExt, buildExtension("test2"), buildExtension("test3")) + + go func() { + Eventually(updateOperatorConditionStatus("test", cfg.Client, olmv1.TypeInstalled, metav1.ConditionTrue)). + WithTimeout(5 * time.Second).WithPolling(200 * time.Second). + Should(Succeed()) + }() + + updater := internalaction.NewOperatorUpdate(&cfg) + updater.Package = "test" + updater.Version = "10.0.4" + updater.Channels = []string{"a", "b"} + updater.Labels = map[string]string{"c": "d"} + updater.UpgradeConstraintPolicy = string(olmv1.UpgradeConstraintPolicySelfCertified) + ext, err := updater.Run(context.TODO()) + + Expect(err).To(BeNil()) + Expect(ext).NotTo(BeNil()) + Expect(ext.Spec.Source.Catalog.Version).To(Equal(updater.Version)) + Expect(maps.Equal(ext.Labels, updater.Labels)).To(BeTrue()) + Expect(ext.Spec.Source.Catalog.Channels).To(ContainElements(updater.Channels)) + Expect(ext.Spec.Source.Catalog.UpgradeConstraintPolicy). + To(Equal(olmv1.UpgradeConstraintPolicy(updater.UpgradeConstraintPolicy))) + + // also verify that other objects were not updated + validateNonUpdatedExtensions(cfg.Client, "test") + }) +}) + +func validateNonUpdatedExtensions(c client.Client, exceptName string) { + var extList olmv1.ClusterExtensionList + err := c.List(context.TODO(), &extList) + Expect(err).To(BeNil()) + + for _, ext := range extList.Items { + if ext.Name == exceptName { + continue + } + + Expect(ext.Spec.Source.Catalog.Version).To(BeEmpty()) + Expect(ext.Labels).To(BeEmpty()) + Expect(ext.Spec.Source.Catalog.Channels).To(BeEmpty()) + Expect(ext.Spec.Source.Catalog.UpgradeConstraintPolicy).To(BeEmpty()) + } +}