Skip to content

✨ Add command to update olmv1 operator #225

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions internal/cmd/internal/olmv1/operator_update.go
Original file line number Diff line number Diff line change
@@ -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 <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")
}
10 changes: 10 additions & 0 deletions internal/cmd/olmv1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions internal/pkg/v1/action/action_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which lint error is this masking?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

internal/pkg/v1/action/action_suite_test.go:83:27 unparam withConstraintPolicy - policy always receives string(olmv1.UpgradeConstraintPolicyCatalogProvided) ("CatalogProvided")

I don't think this needs to be 'fixed', hence nolint. withConstraintPolicy is used only to build test operators (extension) in tests. We do have test cases where we check setting a different value, but the base remains the same.

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)
}
4 changes: 2 additions & 2 deletions internal/pkg/v1/action/catalog_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down
5 changes: 3 additions & 2 deletions internal/pkg/v1/action/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
48 changes: 37 additions & 11 deletions internal/pkg/v1/action/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"slices"
"strings"
"time"

apierrors "k8s.io/apimachinery/pkg/api/errors"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
146 changes: 146 additions & 0 deletions internal/pkg/v1/action/operator_update.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading