diff --git a/go.mod b/go.mod index 9f4da0d2..151e62b3 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.4 require ( github.com/blang/semver/v4 v4.0.0 + github.com/briandowns/spinner v1.23.2 github.com/containerd/containerd v1.7.26 github.com/containerd/platforms v0.2.1 github.com/onsi/ginkgo v1.16.5 @@ -19,6 +20,7 @@ require ( k8s.io/api v0.32.2 k8s.io/apiextensions-apiserver v0.32.2 k8s.io/apimachinery v0.32.2 + k8s.io/cli-runtime v0.32.2 k8s.io/client-go v0.32.2 sigs.k8s.io/controller-runtime v0.20.2 sigs.k8s.io/yaml v1.4.0 @@ -27,6 +29,7 @@ require ( require ( cel.dev/expr v0.18.0 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/hcsshim v0.12.9 // indirect @@ -54,6 +57,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fatih/color v1.15.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect @@ -83,7 +87,10 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/moby/locker v1.0.1 // indirect github.com/moby/spdystream v0.5.0 // indirect github.com/moby/sys/capability v0.3.0 // indirect @@ -91,6 +98,7 @@ require ( github.com/moby/sys/sequential v0.5.0 // indirect github.com/moby/sys/user v0.3.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/go.sum b/go.sum index bf102d4d..d82af7f9 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= @@ -20,6 +22,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= +github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -62,6 +66,8 @@ github.com/containers/storage v1.56.1/go.mod h1:c6WKowcAlED/DkWGNuL9bvGYqIWCVy7i github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -97,6 +103,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -212,8 +220,15 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= @@ -230,6 +245,8 @@ github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -424,7 +441,10 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= @@ -512,6 +532,8 @@ k8s.io/apimachinery v0.32.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ= k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= k8s.io/apiserver v0.32.2 h1:WzyxAu4mvLkQxwD9hGa4ZfExo3yZZaYzoYvvVDlM6vw= k8s.io/apiserver v0.32.2/go.mod h1:PEwREHiHNU2oFdte7BjzA1ZyjWjuckORLIK/wLV5goM= +k8s.io/cli-runtime v0.32.2 h1:aKQR4foh9qeyckKRkNXUccP9moxzffyndZAvr+IXMks= +k8s.io/cli-runtime v0.32.2/go.mod h1:a/JpeMztz3xDa7GCyyShcwe55p8pbcCVQxvqZnIwXN8= k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA= k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94= k8s.io/component-base v0.32.2 h1:1aUL5Vdmu7qNo4ZsE+569PV5zFatM9hl+lb3dEea2zU= diff --git a/internal/cmd/catalog_remove.go b/internal/cmd/catalog_remove.go index fa03131a..ae1931e1 100644 --- a/internal/cmd/catalog_remove.go +++ b/internal/cmd/catalog_remove.go @@ -12,7 +12,7 @@ func newCatalogRemoveCmd(cfg *action.Configuration) *cobra.Command { u := internalaction.NewCatalogRemove(cfg) cmd := &cobra.Command{ Use: "remove ", - Short: "Remove a operator catalog", + Short: "Remove an operator catalog", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { u.CatalogName = args[0] diff --git a/internal/cmd/internal/olmv1/catalog_create.go b/internal/cmd/internal/olmv1/catalog_create.go index df76c871..2cd3fb69 100644 --- a/internal/cmd/internal/olmv1/catalog_create.go +++ b/internal/cmd/internal/olmv1/catalog_create.go @@ -5,16 +5,27 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/errors" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" "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" ) -// NewCatalogCreateCmd allows creating a new catalog +type catalogCreateOptions struct { + dryRunOptions + mutableCatalogOptions +} + +// NewCatalogCreateCmd returns a command that creates a new catalog. +// At minimum, the catalog name and the source image reference must be provided. func NewCatalogCreateCmd(cfg *action.Configuration) *cobra.Command { i := v1action.NewCatalogCreate(cfg) i.Logf = log.Printf + var opts catalogCreateOptions cmd := &cobra.Command{ Use: "catalog ", @@ -24,22 +35,52 @@ func NewCatalogCreateCmd(cfg *action.Configuration) *cobra.Command { Run: func(cmd *cobra.Command, args []string) { i.CatalogName = args[0] i.ImageSourceRef = args[1] - - if err := i.Run(cmd.Context()); err != nil { + opts.Image = i.ImageSourceRef + if err := opts.validate(); err != nil { + log.Fatalf("failed to parse flags: %v", err) + } + i.DryRun = opts.DryRun + i.Output = opts.Output + i.AvailabilityMode = opts.AvailabilityMode + i.Priority = opts.Priority + i.Labels = opts.Labels + i.PollIntervalMinutes = opts.PollIntervalMinutes + catalogObj, err := i.Run(cmd.Context()) + if err != nil { log.Fatalf("failed to create catalog %q: %v", i.CatalogName, err) } - log.Printf("catalog %q created", i.CatalogName) + if len(i.DryRun) == 0 { + log.Printf("catalog %q created", i.CatalogName) + return + } + if len(i.Output) == 0 { + log.Printf("catalog %q created (dry run)", i.CatalogName) + return + } + + catalogObj.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: "ClusterCatalog"}) + printFormattedCatalogs(i.Output, *catalogObj) }, } + bindMutableCatalogFlags(cmd.Flags(), &opts.mutableCatalogOptions) bindCatalogCreateFlags(cmd.Flags(), i) + bindDryRunFlags(cmd.Flags(), &opts.dryRunOptions) return cmd } func bindCatalogCreateFlags(fs *pflag.FlagSet, i *v1action.CatalogCreate) { - fs.Int32Var(&i.Priority, "priority", 0, "priority determines the likelihood of a catalog being selected in conflict scenarios") - fs.BoolVar(&i.Available, "available", true, "true means that the catalog should be active and serving data") - fs.IntVar(&i.PollIntervalMinutes, "source-poll-interval-minutes", 10, "catalog source polling interval [in minutes]") - fs.StringToStringVar(&i.Labels, "labels", map[string]string{}, "labels that will be added to the catalog") - fs.DurationVar(&i.CleanupTimeout, "cleanup-timeout", time.Minute, "the amount of time to wait before cancelling cleanup after a failed creation attempt") + fs.DurationVar(&i.CleanupTimeout, "cleanup-timeout", time.Minute, "the amount of time to wait before cancelling cleanup after a failed creation attempt.") +} + +func (o *catalogCreateOptions) validate() error { + var errs []error + if err := o.dryRunOptions.validate(); err != nil { + errs = append(errs, err) + } + if err := o.mutableCatalogOptions.validate(); err != nil { + errs = append(errs, err) + } + return errors.NewAggregate(errs) } diff --git a/internal/cmd/internal/olmv1/catalog_delete.go b/internal/cmd/internal/olmv1/catalog_delete.go index c7206228..4b0bffcb 100644 --- a/internal/cmd/internal/olmv1/catalog_delete.go +++ b/internal/cmd/internal/olmv1/catalog_delete.go @@ -3,17 +3,25 @@ package olmv1 import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/runtime/schema" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" "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" ) -// NewCatalogDeleteCmd allows deleting either a single or all -// existing catalogs +type catalogDeleteOptions struct { + dryRunOptions +} + +// NewCatalogDeleteCmd deletes either a specific catalog by name +// or all catalogs on cluster. func NewCatalogDeleteCmd(cfg *action.Configuration) *cobra.Command { - d := v1action.NewCatalogDelete(cfg) - d.Logf = log.Printf + i := v1action.NewCatalogDelete(cfg) + i.Logf = log.Printf + var opts catalogDeleteOptions cmd := &cobra.Command{ Use: "catalog [catalog_name]", @@ -21,30 +29,47 @@ func NewCatalogDeleteCmd(cfg *action.Configuration) *cobra.Command { Args: cobra.RangeArgs(0, 1), Short: "Delete either a single or all of the existing catalogs", Run: func(cmd *cobra.Command, args []string) { - if len(args) == 0 { - catalogs, err := d.Run(cmd.Context()) - if err != nil { - log.Fatalf("failed deleting catalogs: %v", err) + if len(args) > 0 { + if i.DeleteAll { + log.Fatalf("failed to delete catalog: cannot specify both --all and a catalog name") + } + i.CatalogName = args[0] + } + if err := opts.validate(); err != nil { + log.Fatalf("failed to parse flags: %v", err) + } + i.DryRun = opts.DryRun + i.Output = opts.Output + catalogs, err := i.Run(cmd.Context()) + if err != nil { + log.Fatalf("failed to delete catalog(s): %v", err) + } + if len(i.DryRun) == 0 { + for _, c := range catalogs { + log.Printf("catalog %q deleted", c.Name) } - for _, catalog := range catalogs { - log.Printf("catalog %q deleted", catalog) + return + } + if len(i.Output) == 0 { + for _, c := range catalogs { + log.Printf("catalog %q deleted (dry run)", c.Name) } - return } - d.CatalogName = args[0] - if _, err := d.Run(cmd.Context()); err != nil { - log.Fatalf("failed to delete catalog %q: %v", d.CatalogName, err) + for _, c := range catalogs { + c.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: "ClusterCatalog"}) } - log.Printf("catalog %q deleted", d.CatalogName) + printFormattedCatalogs(i.Output, catalogs...) }, } - bindCatalogDeleteFlags(cmd.Flags(), d) + bindCatalogDeleteFlags(cmd.Flags(), i) + bindDryRunFlags(cmd.Flags(), &opts.dryRunOptions) return cmd } func bindCatalogDeleteFlags(fs *pflag.FlagSet, d *v1action.CatalogDelete) { - fs.BoolVar(&d.DeleteAll, "all", false, "delete all catalogs") + fs.BoolVarP(&d.DeleteAll, "all", "a", false, "delete all catalogs") } diff --git a/internal/cmd/internal/olmv1/catalog_installed_get.go b/internal/cmd/internal/olmv1/catalog_get.go similarity index 61% rename from internal/cmd/internal/olmv1/catalog_installed_get.go rename to internal/cmd/internal/olmv1/catalog_get.go index 87eb5dd1..2c007b8b 100644 --- a/internal/cmd/internal/olmv1/catalog_installed_get.go +++ b/internal/cmd/internal/olmv1/catalog_get.go @@ -2,6 +2,9 @@ package olmv1 import ( "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime/schema" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" @@ -9,30 +12,40 @@ import ( ) // NewCatalogInstalledGetCmd handles get commands in the form of: -// catalog(s) [catalog_name] - this will either list all the installed operators +// catalog(s) [catalog_name] - this will either list all the installed catalogs // if no catalog_name has been provided or display the details of the specific // one otherwise func NewCatalogInstalledGetCmd(cfg *action.Configuration) *cobra.Command { i := v1action.NewCatalogInstalledGet(cfg) i.Logf = log.Printf + var opts getOptions cmd := &cobra.Command{ Use: "catalog [catalog_name]", - Aliases: []string{"catalogs"}, + Aliases: []string{"catalogs [catalog_name]"}, Args: cobra.RangeArgs(0, 1), Short: "Display one or many installed catalogs", Run: func(cmd *cobra.Command, args []string) { if len(args) == 1 { i.CatalogName = args[0] } + if err := opts.validate(); err != nil { + log.Fatalf("failed to parse flags: %v", err) + } + i.Selector = opts.ParsedSelector installedCatalogs, err := i.Run(cmd.Context()) if err != nil { log.Fatalf("failed getting installed catalog(s): %v", err) } - printFormattedCatalogs(installedCatalogs...) + for i := range installedCatalogs { + installedCatalogs[i].GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: "ClusterCatalog"}) + } + printFormattedCatalogs(opts.Output, installedCatalogs...) }, } + bindGetFlags(cmd.Flags(), &opts) return cmd } diff --git a/internal/cmd/internal/olmv1/catalog_search.go b/internal/cmd/internal/olmv1/catalog_search.go index 2178690c..bd2df0d1 100644 --- a/internal/cmd/internal/olmv1/catalog_search.go +++ b/internal/cmd/internal/olmv1/catalog_search.go @@ -21,42 +21,43 @@ import ( func NewCatalogSearchCmd(cfg *action.Configuration) *cobra.Command { i := v1action.NewCatalogSearch(cfg) i.Logf = log.Printf + var opts getOptions cmd := &cobra.Command{ Use: "catalog", Aliases: []string{"catalogs"}, - Args: cobra.RangeArgs(0, 1), - Short: "Search catalogs for installable operators matching parameters", + Short: "Search catalogs for installable packages matching parameters", Run: func(cmd *cobra.Command, args []string) { + if err := opts.validate(); err != nil { + log.Fatalf("failed to parse flags: %v", err) + } + i.Selector = opts.ParsedSelector catalogContents, err := i.Run(cmd.Context()) if err != nil { log.Fatalf("failed querying catalog(s): %v", err) } - switch i.OutputFormat { - case "", "table": + switch opts.Output { + case "": printFormattedDeclCfg(os.Stdout, catalogContents, i.ListVersions) case "json": printDeclCfgJSON(os.Stdout, catalogContents) case "yaml": printDeclCfgYAML(os.Stdout, catalogContents) default: - log.Fatalf("unsupported output format %s: allwed formats are (json|yaml|table)", i.OutputFormat) + log.Fatalf("unsupported output format %q: allowed formats are (json|yaml)", opts.Output) } }, } bindCatalogSearchFlags(cmd.Flags(), i) + bindGetFlags(cmd.Flags(), &opts) return cmd } func bindCatalogSearchFlags(fs *pflag.FlagSet, i *v1action.CatalogSearch) { - fs.StringVar(&i.CatalogName, "catalog", "", "Catalog to search on. If not provided, all available catalogs are searched.") - fs.StringVarP(&i.Selector, "selector", "l", "", "Selector (label query) to filter catalogs on, supports '=', '==', and '!='") - fs.StringVarP(&i.OutputFormat, "output", "o", "", "output format. One of: (yaml|json)") - fs.BoolVar(&i.ListVersions, "list-versions", false, "List all versions available for each package") - fs.StringVar(&i.Package, "package", "", "Search for package by name. If empty, all available packages will be listed") - fs.StringVar(&i.CatalogdNamespace, "catalogd-namespace", "olmv1-system", "Namespace for the catalogd controller") - fs.StringVar(&i.Timeout, "timeout", "5m", "Timeout for fetching catalog contents") - // installable vs uninstallable, all versions, channels - // fs.StringVar(&i.showAll, "image", "", "Image reference for the catalog source. Leave unset to retain the current image.") + fs.StringVar(&i.CatalogName, "catalog", "", "name of the catalog to search. If not provided, all available catalogs are searched.") + fs.BoolVar(&i.ListVersions, "list-versions", false, "list all versions available for each package.") + fs.StringVar(&i.Package, "package", "", "search for package by name. If empty, all available packages will be listed.") + fs.StringVar(&i.CatalogdNamespace, "catalogd-namespace", "olmv1-system", "namespace for the catalogd controller.") + fs.StringVar(&i.Timeout, "timeout", "5m", "timeout for fetching catalog contents.") } diff --git a/internal/cmd/internal/olmv1/catalog_update.go b/internal/cmd/internal/olmv1/catalog_update.go index 93b65344..569b3393 100644 --- a/internal/cmd/internal/olmv1/catalog_update.go +++ b/internal/cmd/internal/olmv1/catalog_update.go @@ -2,49 +2,86 @@ package olmv1 import ( "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/errors" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" "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" ) -// NewCatalogUpdateCmd allows updating a selected clustercatalog +type catalogUpdateOptions struct { + dryRunOptions + mutableCatalogOptions + updateDefaultFieldOptions +} + +// NewCatalogUpdateCmd updates one or more mutable fields +// of a catalog specified by name func NewCatalogUpdateCmd(cfg *action.Configuration) *cobra.Command { i := v1action.NewCatalogUpdate(cfg) i.Logf = log.Printf - - var priority int32 - var pollInterval int - var labels map[string]string + var opts catalogUpdateOptions cmd := &cobra.Command{ - Use: "catalog ", - Short: "Update a catalog", - Args: cobra.ExactArgs(1), + Use: "catalog ", + Aliases: []string{"catalogs "}, + Short: "Update a catalog", + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { i.CatalogName = args[0] + if err := opts.validate(); err != nil { + log.Fatalf("failed to parse flags: %v", err) + } if cmd.Flags().Changed("priority") { - i.Priority = &priority + i.Priority = &opts.Priority } if cmd.Flags().Changed("source-poll-interval-minutes") { - i.PollIntervalMinutes = &pollInterval + i.PollIntervalMinutes = &opts.PollIntervalMinutes } if cmd.Flags().Changed("labels") { - i.Labels = labels + i.Labels = opts.Labels } - _, err := i.Run(cmd.Context()) + i.ImageRef = opts.Image + i.AvailabilityMode = opts.AvailabilityMode + i.IgnoreUnset = opts.IgnoreUnset + i.DryRun = opts.DryRun + i.Output = opts.Output + catalogObj, err := i.Run(cmd.Context()) if err != nil { log.Fatalf("failed to update catalog: %v", err) } - log.Printf("catalog %q updated", i.CatalogName) + + if len(i.DryRun) == 0 { + log.Printf("catalog %q updated", i.CatalogName) + return + } + if len(i.Output) == 0 { + log.Printf("catalog %q updated (dry run)", i.CatalogName) + return + } + + catalogObj.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: "ClusterCatalog"}) + printFormattedCatalogs(i.Output, *catalogObj) }, } - cmd.Flags().Int32Var(&priority, "priority", 0, "priority determines the likelihood of a catalog being selected in conflict scenarios") - cmd.Flags().IntVar(&pollInterval, "source-poll-interval-minutes", 5, "catalog source polling interval [in minutes]. Set to 0 or -1 to remove the polling interval.") - cmd.Flags().StringToStringVar(&labels, "labels", map[string]string{}, "labels that will be added to the catalog") - cmd.Flags().StringVar(&i.AvailabilityMode, "availability-mode", "", "available means that the catalog should be active and serving data") - cmd.Flags().StringVar(&i.ImageRef, "image", "", "Image reference for the catalog source. Leave unset to retain the current image.") - cmd.Flags().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") + bindMutableCatalogFlags(cmd.Flags(), &opts.mutableCatalogOptions) + bindUpdateFieldOptions(cmd.Flags(), &opts.updateDefaultFieldOptions, "clustercatalog") + bindDryRunFlags(cmd.Flags(), &opts.dryRunOptions) return cmd } + +func (o *catalogUpdateOptions) validate() error { + var errs []error + if err := o.dryRunOptions.validate(); err != nil { + errs = append(errs, err) + } + if err := o.mutableCatalogOptions.validate(); err != nil { + errs = append(errs, err) + } + return errors.NewAggregate(errs) +} diff --git a/internal/cmd/internal/olmv1/extension_delete.go b/internal/cmd/internal/olmv1/extension_delete.go index bc1ff940..ae1d2c99 100644 --- a/internal/cmd/internal/olmv1/extension_delete.go +++ b/internal/cmd/internal/olmv1/extension_delete.go @@ -3,48 +3,76 @@ package olmv1 import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/runtime/schema" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" "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" ) +type extensionDeleteOptions struct { + dryRunOptions +} + +// NewExtensionDeleteCmd deletes either a specific extension by name +// or all extensions on cluster. func NewExtensionDeleteCmd(cfg *action.Configuration) *cobra.Command { - e := v1action.NewExtensionDelete(cfg) - e.Logf = log.Printf + i := v1action.NewExtensionDelete(cfg) + i.Logf = log.Printf + var opts extensionDeleteOptions cmd := &cobra.Command{ Use: "extension [extension_name]", Aliases: []string{"extensions [extension_name]"}, - Short: "Delete an extension", + Short: "Delete either a single or all of the existing extensions", Long: `Warning: Permanently deletes the named cluster extension object. If the extension contains CRDs, the CRDs will be deleted, which cascades to the deletion of all operands.`, Args: cobra.RangeArgs(0, 1), Run: func(cmd *cobra.Command, args []string) { - if len(args) == 0 { - extensions, err := e.Run(cmd.Context()) - if err != nil { - log.Fatalf("failed deleting extension: %v", err) + if len(args) > 0 { + if i.DeleteAll { + log.Fatalf("failed to delete extension: cannot specify both --all and an extension name") + } + i.ExtensionName = args[0] + } + if err := opts.validate(); err != nil { + log.Fatalf("failed to parse flags: %v", err) + } + i.DryRun = opts.DryRun + i.Output = opts.Output + extensions, err := i.Run(cmd.Context()) + if err != nil { + log.Fatalf("failed to delete extension: %v", err) + } + if len(i.DryRun) == 0 { + for _, e := range extensions { + log.Printf("extension %q deleted", e.Name) } - for _, extn := range extensions { - log.Printf("extension %q deleted", extn) + return + } + if len(i.Output) == 0 { + for _, e := range extensions { + log.Printf("extension %q deleted (dry run)", e.Name) } - return } - e.ExtensionName = args[0] - _, errs := e.Run(cmd.Context()) - if errs != nil { - log.Fatalf("delete extension: %v", errs) + + for _, e := range extensions { + e.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: olmv1.ClusterExtensionKind}) } - log.Printf("deleted extension %q", e.ExtensionName) + printFormattedExtensions(i.Output, extensions...) }, } - bindExtensionDeleteFlags(cmd.Flags(), e) + bindExtensionDeleteFlags(cmd.Flags(), i) + bindDryRunFlags(cmd.Flags(), &opts.dryRunOptions) + return cmd } func bindExtensionDeleteFlags(fs *pflag.FlagSet, e *v1action.ExtensionDeletion) { - fs.BoolVarP(&e.DeleteAll, "all", "a", false, "delete all extensions") + fs.BoolVarP(&e.DeleteAll, "all", "a", false, "delete all extensions.") } diff --git a/internal/cmd/internal/olmv1/extension_installed_get.go b/internal/cmd/internal/olmv1/extension_get.go similarity index 65% rename from internal/cmd/internal/olmv1/extension_installed_get.go rename to internal/cmd/internal/olmv1/extension_get.go index b85a93b9..776ab54d 100644 --- a/internal/cmd/internal/olmv1/extension_installed_get.go +++ b/internal/cmd/internal/olmv1/extension_get.go @@ -2,6 +2,9 @@ package olmv1 import ( "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime/schema" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" @@ -15,6 +18,7 @@ import ( func NewExtensionInstalledGetCmd(cfg *action.Configuration) *cobra.Command { i := v1action.NewExtensionInstalledGet(cfg) i.Logf = log.Printf + var opts getOptions cmd := &cobra.Command{ Use: "extension [extension_name]", @@ -25,14 +29,23 @@ func NewExtensionInstalledGetCmd(cfg *action.Configuration) *cobra.Command { if len(args) == 1 { i.ExtensionName = args[0] } + if err := opts.validate(); err != nil { + log.Fatalf("failed to parse flags: %v", err) + } + i.Selector = opts.ParsedSelector installedExtensions, err := i.Run(cmd.Context()) if err != nil { log.Fatalf("failed getting installed extension(s): %v", err) } - printFormattedExtensions(installedExtensions...) + for i := range installedExtensions { + installedExtensions[i].GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: olmv1.ClusterExtensionKind}) + } + printFormattedExtensions(opts.Output, installedExtensions...) }, } + bindGetFlags(cmd.Flags(), &opts) return cmd } diff --git a/internal/cmd/internal/olmv1/extension_install.go b/internal/cmd/internal/olmv1/extension_install.go index 6bee6f00..7f019dc4 100644 --- a/internal/cmd/internal/olmv1/extension_install.go +++ b/internal/cmd/internal/olmv1/extension_install.go @@ -5,39 +5,89 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/errors" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" "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" ) +type extensionInstallOptions struct { + dryRunOptions + mutableExtensionOptions +} + +// NewExtensionInstallCmd installs a new extension for a package, requiring a minimum +// of a name for the new extension and the name of the package to install func NewExtensionInstallCmd(cfg *action.Configuration) *cobra.Command { i := v1action.NewExtensionInstall(cfg) i.Logf = log.Printf + var opts extensionInstallOptions cmd := &cobra.Command{ - Use: "extension ", - Short: "Install an extension", - Args: cobra.ExactArgs(1), + Use: "extension ", + Aliases: []string{"extensions "}, + Short: "Install an extension", + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { i.ExtensionName = args[0] - _, err := i.Run(cmd.Context()) + if err := opts.validate(); err != nil { + log.Fatalf("failed to parse flags: %v", err) + } + i.Version = opts.Version + i.Channels = opts.Channels + i.Labels = opts.Labels + i.UpgradeConstraintPolicy = opts.UpgradeConstraintPolicy + i.CRDUpgradeSafetyEnforcement = opts.CRDUpgradeSafetyEnforcement + i.CatalogSelector = opts.ParsedSelector + i.DryRun = opts.DryRun + i.Output = opts.Output + extObj, err := i.Run(cmd.Context()) if err != nil { - log.Fatalf("failed to install extension: %v", err) + log.Fatalf("failed to install extension %q: %v", i.ExtensionName, err) } - log.Printf("extension %q created", i.ExtensionName) + if len(i.DryRun) == 0 { + log.Printf("extension %q created", i.ExtensionName) + return + } + if len(i.Output) == 0 { + log.Printf("extension %q created (dry run)", i.ExtensionName) + return + } + + extObj.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: olmv1.ClusterExtensionKind}) + printFormattedExtensions(i.Output, *extObj) }, } - bindOperatorInstallFlags(cmd.Flags(), i) + bindMutableExtensionFlags(cmd.Flags(), &opts.mutableExtensionOptions) + bindExtensionInstallFlags(cmd.Flags(), i) + bindDryRunFlags(cmd.Flags(), &opts.dryRunOptions) return cmd } -func bindOperatorInstallFlags(fs *pflag.FlagSet, i *v1action.ExtensionInstall) { - fs.StringVarP(&i.Namespace.Name, "namespace", "n", "", "namespace to install the operator in") - fs.StringVarP(&i.PackageName, "package-name", "p", "", "package name of the operator to install") - fs.StringSliceVarP(&i.Channels, "channels", "c", []string{}, "channels which would be to used for getting updates e.g --channels \"stable,dev-preview,preview\"") - fs.StringVarP(&i.Version, "version", "v", "", "version (or version range) from which to resolve bundles") - fs.StringVarP(&i.ServiceAccount, "service-account", "s", "default", "service account name to use for the extension installation") - fs.DurationVarP(&i.CleanupTimeout, "cleanup-timeout", "d", time.Minute, "the amount of time to wait before cancelling cleanup after a failed creation attempt") +func bindExtensionInstallFlags(fs *pflag.FlagSet, i *v1action.ExtensionInstall) { + fs.StringVarP(&i.Namespace.Name, "namespace", "n", "olmv1-system", "namespace to install the extension in.") + fs.StringVarP(&i.PackageName, "package-name", "p", "", "package name of the extension to install. Required.") + fs.StringVarP(&i.ServiceAccount, "service-account", "s", "default", "service account name to use for the extension installation.") + fs.DurationVar(&i.CleanupTimeout, "cleanup-timeout", time.Minute, "the amount of time to wait before cancelling cleanup after a failed creation attempt.") + + if err := cobra.MarkFlagRequired(fs, "package-name"); err != nil { + log.Fatalf("failed to process command flags: %v", err) + } +} + +func (o *extensionInstallOptions) validate() error { + var errs []error + if err := o.dryRunOptions.validate(); err != nil { + errs = append(errs, err) + } + if err := o.mutableExtensionOptions.validate(); err != nil { + errs = append(errs, err) + } + return errors.NewAggregate(errs) } diff --git a/internal/cmd/internal/olmv1/extension_update.go b/internal/cmd/internal/olmv1/extension_update.go index 9fe1f544..3c9c2e12 100644 --- a/internal/cmd/internal/olmv1/extension_update.go +++ b/internal/cmd/internal/olmv1/extension_update.go @@ -2,41 +2,80 @@ package olmv1 import ( "github.com/spf13/cobra" - "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/errors" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" "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" ) -// NewExtensionUpdateCmd allows updating a selected operator +type extensionUpdateOptions struct { + dryRunOptions + mutableExtensionOptions + updateDefaultFieldOptions +} + +// NewExtensionUpdateCmd updates one or more mutable fields +// of an extension specified by name func NewExtensionUpdateCmd(cfg *action.Configuration) *cobra.Command { i := v1action.NewExtensionUpdate(cfg) i.Logf = log.Printf + var opts extensionUpdateOptions cmd := &cobra.Command{ - Use: "extension ", - Short: "Update an extension", - Args: cobra.ExactArgs(1), + Use: "extension ", + Aliases: []string{"extensions "}, + Short: "Update an extension", + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - i.Package = args[0] - _, err := i.Run(cmd.Context()) + i.ExtensionName = args[0] + if err := opts.validate(); err != nil { + log.Fatalf("failed to parse flags: %v", err) + } + i.Version = opts.Version + i.Channels = opts.Channels + i.Labels = opts.Labels + i.UpgradeConstraintPolicy = opts.UpgradeConstraintPolicy + i.CRDUpgradeSafetyEnforcement = opts.CRDUpgradeSafetyEnforcement + i.CatalogSelector = opts.ParsedSelector + i.IgnoreUnset = opts.IgnoreUnset + i.DryRun = opts.DryRun + i.Output = opts.Output + extObj, err := i.Run(cmd.Context()) if err != nil { log.Fatalf("failed to update extension: %v", err) } - log.Printf("extension %q updated", i.Package) + if len(i.DryRun) == 0 { + log.Printf("extension %q updated", i.ExtensionName) + return + } + if len(i.Output) == 0 { + log.Printf("extension %q updated (dry run)", i.ExtensionName) + return + } + + extObj.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: olmv1.ClusterExtensionKind}) + printFormattedExtensions(i.Output, *extObj) }, } - bindExtensionUpdateFlags(cmd.Flags(), i) + bindMutableExtensionFlags(cmd.Flags(), &opts.mutableExtensionOptions) + bindUpdateFieldOptions(cmd.Flags(), &opts.updateDefaultFieldOptions, "clusterextension") + bindDryRunFlags(cmd.Flags(), &opts.dryRunOptions) return cmd } -func bindExtensionUpdateFlags(fs *pflag.FlagSet, i *v1action.ExtensionUpdate) { - fs.StringVar(&i.Version, "version", "", "desired extension 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 extension 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 extension") - 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") +func (o *extensionUpdateOptions) validate() error { + var errs []error + if err := o.dryRunOptions.validate(); err != nil { + errs = append(errs, err) + } + if err := o.mutableExtensionOptions.validate(); err != nil { + errs = append(errs, err) + } + return errors.NewAggregate(errs) } diff --git a/internal/cmd/internal/olmv1/flags.go b/internal/cmd/internal/olmv1/flags.go new file mode 100644 index 00000000..fc88cbda --- /dev/null +++ b/internal/cmd/internal/olmv1/flags.go @@ -0,0 +1,175 @@ +package olmv1 + +import ( + "fmt" + + "github.com/blang/semver/v4" + "github.com/containerd/containerd/reference" + "github.com/spf13/pflag" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/errors" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" + + v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" +) + +// getOptions is used in searching catalogs and listing resources +type getOptions struct { + Output string + Selector string + ParsedSelector labels.Selector +} + +func bindGetFlags(fs *pflag.FlagSet, o *getOptions) { + fs.StringVarP(&o.Output, "output", "o", "", "output format. One of: (json, yaml)") + fs.StringVarP(&o.Selector, "selector", "l", "", "selector (label query) to filter on, "+ + "supports '=', '==', '!=', 'in', 'notin'.(e.g. -l key1=value1,key2=value2,key3 "+ + "in (value3)). Matching objects must satisfy all of the specified label constraints.") +} + +func (o *getOptions) validate() error { + var errs []error + switch o.Output { + case "json", "yaml", "": + default: + errs = append(errs, fmt.Errorf("unrecognized output format %q: must be one of (json, yaml)", o.Output)) + } + + if len(o.Selector) > 0 { + var err error + o.ParsedSelector, err = labels.Parse(o.Selector) + if err != nil { + errs = append(errs, fmt.Errorf("invalid `--selector` value %q: %w", o.Selector, err)) + } + } + return errors.NewAggregate(errs) +} + +type dryRunOptions struct { + DryRun string + Output string +} + +func bindDryRunFlags(fs *pflag.FlagSet, o *dryRunOptions) { + fs.StringVar(&o.DryRun, "dry-run", "", fmt.Sprintf("display the object that would be sent on a request without applying it. One of: (%s)", v1action.DryRunAll)) + fs.StringVarP(&o.Output, "output", "o", "", "output format for dry-run manifests. One of: (json, yaml)") +} + +func (o *dryRunOptions) validate() error { + var errs []error + if len(o.DryRun) > 0 && o.DryRun != v1action.DryRunAll { + errs = append(errs, fmt.Errorf("invalid value for `--dry-run` %q, must be one of (%s)", o.DryRun, v1action.DryRunAll)) + } + switch o.Output { + case "json", "yaml", "": + default: + errs = append(errs, fmt.Errorf("unrecognized output format %q: must be one of (json, yaml)", o.Output)) + } + return errors.NewAggregate(errs) +} + +type updateDefaultFieldOptions struct { + IgnoreUnset bool +} + +func bindUpdateFieldOptions(fs *pflag.FlagSet, o *updateDefaultFieldOptions, resourceType string) { + fs.BoolVar(&o.IgnoreUnset, "ignore-unset", true, fmt.Sprintf("set to false to revert all values not specifically set with flags in the command to their default as defined by the %s customresourcedefinition.", resourceType)) +} + +type mutableExtensionOptions struct { + Channels []string + Version string + Labels map[string]string + UpgradeConstraintPolicy string + CRDUpgradeSafetyEnforcement string + CatalogSelector string + ParsedSelector *metav1.LabelSelector +} + +func bindMutableExtensionFlags(fs *pflag.FlagSet, o *mutableExtensionOptions) { + fs.StringSliceVarP(&o.Channels, "channels", "c", []string{}, "channels to be used for getting updates. If omitted, extension versions in all channels will be "+ + "considered for upgrades. When used with '--version', only package versions meeting both constraints will be considered.") + fs.StringVarP(&o.Version, "version", "v", "", "version (or version range) in semver format to limit the allowable package versions to. If used with '--channel', "+ + "only package versions meeting both constraints will be considered.") + fs.StringToStringVar(&o.Labels, "labels", map[string]string{}, "labels to add to the extension. Set a label's value as empty to remove that label.") + fs.StringVar(&o.CRDUpgradeSafetyEnforcement, "crd-upgrade-safety-enforcement", "", fmt.Sprintf("policy for preflight CRD Upgrade safety checks. One of: %v, (default %s)", + []string{string(olmv1.CRDUpgradeSafetyEnforcementStrict), string(olmv1.CRDUpgradeSafetyEnforcementNone)}, olmv1.CRDUpgradeSafetyEnforcementStrict)) + fs.StringVar(&o.UpgradeConstraintPolicy, "upgrade-constraint-policy", "", "controls whether the package upgrade path(s) defined in the catalog are enforced."+ + fmt.Sprintf(" One of %v, (default %s)", []string{string(olmv1.UpgradeConstraintPolicyCatalogProvided), string(olmv1.UpgradeConstraintPolicySelfCertified)}, + olmv1.UpgradeConstraintPolicyCatalogProvided)) + fs.StringVarP(&o.CatalogSelector, "catalog-selector", "l", "", "selector (label query) to filter catalogs to search for the package, "+ + "supports '=', '==', '!=', 'in', 'notin'.(e.g. -l key1=value1,key2=value2,key3 "+ + "in (value3)). Matching objects must satisfy all of the specified label constraints.") +} + +func (o *mutableExtensionOptions) validate() error { + var errs []error + if len(o.Version) > 0 { + if _, err := semver.ParseRange(o.Version); err != nil { + errs = append(errs, fmt.Errorf("invalid `--version` %q: %w", o.Version, err)) + } + } + switch o.CRDUpgradeSafetyEnforcement { + case string(olmv1.CRDUpgradeSafetyEnforcementStrict), string(olmv1.CRDUpgradeSafetyEnforcementNone), "": + default: + errs = append(errs, fmt.Errorf("invalid `--crd-upgrade-safety-enforcement` %q: must be one of: %v", o.CRDUpgradeSafetyEnforcement, + []string{string(olmv1.CRDUpgradeSafetyEnforcementStrict), string(olmv1.CRDUpgradeSafetyEnforcementNone)})) + } + switch o.UpgradeConstraintPolicy { + case string(olmv1.UpgradeConstraintPolicyCatalogProvided), string(olmv1.UpgradeConstraintPolicySelfCertified), "": + default: + errs = append(errs, fmt.Errorf("invalid `--upgrade-constraint-policy` %q: must be one of: %v", o.UpgradeConstraintPolicy, + []string{string(olmv1.UpgradeConstraintPolicyCatalogProvided), string(olmv1.UpgradeConstraintPolicySelfCertified)})) + } + if len(o.CatalogSelector) > 0 { + var err error + o.ParsedSelector, err = metav1.ParseToLabelSelector(o.CatalogSelector) + if err != nil { + errs = append(errs, fmt.Errorf("invalid `--catalog-selector` value %q: %w", o.CatalogSelector, err)) + } + } + return errors.NewAggregate(errs) +} + +type mutableCatalogOptions struct { + Priority int32 + AvailabilityMode string + PollIntervalMinutes int + Labels map[string]string + Image string +} + +func bindMutableCatalogFlags(fs *pflag.FlagSet, o *mutableCatalogOptions) { + fs.Int32Var(&o.Priority, "priority", 0, "relative priority of the catalog among all on-cluster catalogs for installing or updating packages."+ + " A higher number equals greater priority; negative values indicate less priority than the default.") + fs.StringVar(&o.AvailabilityMode, "available", "", "determines whether a catalog should be active and serving data. Setting the flag to false "+ + "means the catalog will not serve its contents. Set to true by default for new catalogs.") + fs.IntVar(&o.PollIntervalMinutes, "source-poll-interval-minutes", 0, "the interval in minutes to poll the catalog's source image for new content."+ + " Only valid for tag based source image references. Set to 0 or -1 to disable polling.") + fs.StringToStringVar(&o.Labels, "labels", map[string]string{}, "labels to add to the catalog. Set a label's value as empty to remove it.") + fs.StringVar(&o.Image, "image", "", "image reference for the catalog source. Leave unset to retain the current image.") +} + +func (o *mutableCatalogOptions) validate() error { + var errs []error + switch o.AvailabilityMode { + case "": + case "true": + o.AvailabilityMode = string(olmv1.AvailabilityModeAvailable) + case "false": + o.AvailabilityMode = string(olmv1.AvailabilityModeUnavailable) + default: + errs = append(errs, fmt.Errorf("invalid `--available` value %q: must be one of: [true, false]", o.AvailabilityMode)) + } + if o.PollIntervalMinutes > 0 && len(o.Image) > 0 { + ref, err := reference.Parse(o.Image) + if err != nil { + errs = append(errs, fmt.Errorf("invalid catalog source image %q: %w", o.Image, err)) + } else if len(ref.Digest()) != 0 { + errs = append(errs, fmt.Errorf("cannot specify a non-zero --source-poll-interval-minutes for a digest based catalog image %q", o.Image)) + } + } + return errors.NewAggregate(errs) +} diff --git a/internal/cmd/internal/olmv1/printing.go b/internal/cmd/internal/olmv1/printing.go index 18e49683..d1e1af85 100644 --- a/internal/cmd/internal/olmv1/printing.go +++ b/internal/cmd/internal/olmv1/printing.go @@ -13,15 +13,55 @@ import ( "github.com/blang/semver/v4" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/duration" "k8s.io/apimachinery/pkg/util/json" + "k8s.io/cli-runtime/pkg/printers" olmv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-registry/alpha/declcfg" "github.com/operator-framework/operator-registry/alpha/property" ) -func printFormattedExtensions(extensions ...olmv1.ClusterExtension) { +func printFormattedExtensions(outputFormat string, extensions ...olmv1.ClusterExtension) { + switch outputFormat { + case "yaml": + printer := printers.YAMLPrinter{} + if len(extensions) != 1 { + obj := &olmv1.ClusterExtensionList{Items: extensions} + obj.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: olmv1.ClusterExtensionKind + "List"}) + if err := printer.PrintObj(obj, os.Stdout); err != nil { + fmt.Printf("failed to marshal response to YAML: %v\n", err) + } + return + } + if err := printer.PrintObj(&extensions[0], os.Stdout); err != nil { + fmt.Printf("failed to marshal response to YAML: %v\n", err) + } + return + case "json": + printer := printers.JSONPrinter{} + if len(extensions) != 1 { + obj := &olmv1.ClusterExtensionList{Items: extensions} + obj.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: olmv1.ClusterExtensionKind + "List"}) + + if err := printer.PrintObj(obj, os.Stdout); err != nil { + fmt.Printf("failed to marshal response to JSON: %v\n", err) + } + return + } + if err := printer.PrintObj(&extensions[0], os.Stdout); err != nil { + fmt.Printf("failed to marshal response to JSON: %v\n", err) + } + return + default: + } + if len(extensions) == 0 { + fmt.Println("No resources found") + return + } tw := tabwriter.NewWriter(os.Stdout, 3, 4, 2, ' ', 0) _, _ = fmt.Fprint(tw, "NAME\tINSTALLED BUNDLE\tVERSION\tSOURCE TYPE\tINSTALLED\tPROGRESSING\tAGE\n") @@ -46,7 +86,44 @@ func printFormattedExtensions(extensions ...olmv1.ClusterExtension) { _ = tw.Flush() } -func printFormattedCatalogs(catalogs ...olmv1.ClusterCatalog) { +func printFormattedCatalogs(outputFormat string, catalogs ...olmv1.ClusterCatalog) { + switch outputFormat { + case "yaml": + printer := printers.YAMLPrinter{} + if len(catalogs) != 1 { + obj := &olmv1.ClusterCatalogList{Items: catalogs} + obj.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: "ClusterCatalogList"}) + if err := printer.PrintObj(obj, os.Stdout); err != nil { + fmt.Printf("failed to marshal response to YAML: %v\n", err) + } + return + } + if err := printer.PrintObj(&catalogs[0], os.Stdout); err != nil { + fmt.Printf("failed to marshal response to YAML: %v\n", err) + } + return + case "json": + printer := printers.JSONPrinter{} + if len(catalogs) != 1 { + obj := &olmv1.ClusterCatalogList{Items: catalogs} + obj.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{Group: olmv1.GroupVersion.Group, + Version: olmv1.GroupVersion.Version, Kind: "ClusterCatalogList"}) + if err := printer.PrintObj(obj, os.Stdout); err != nil { + fmt.Printf("failed to marshal response to JSON: %v\n", err) + } + return + } + if err := printer.PrintObj(&catalogs[0], os.Stdout); err != nil { + fmt.Printf("failed to marshal response to JSON: %v\n", err) + } + return + default: + } + if len(catalogs) == 0 { + fmt.Println("No resources found") + return + } tw := tabwriter.NewWriter(os.Stdout, 3, 4, 2, ' ', 0) _, _ = fmt.Fprint(tw, "NAME\tAVAILABILITY\tPRIORITY\tLASTUNPACKED\tSERVING\tAGE\n") @@ -54,7 +131,7 @@ func printFormattedCatalogs(catalogs ...olmv1.ClusterCatalog) { for _, cat := range catalogs { var lastUnpacked string if cat.Status.LastUnpacked != nil { - duration.HumanDuration(time.Since(cat.Status.LastUnpacked.Time)) + lastUnpacked = duration.HumanDuration(time.Since(cat.Status.LastUnpacked.Time)) } age := time.Since(cat.CreationTimestamp.Time) _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\t%s\n", @@ -142,7 +219,7 @@ func printFormattedDeclCfg(w io.Writer, catalogDcfg map[string]*declcfg.Declarat } } if !printedHeaders { - _, _ = fmt.Fprint(tw, "No resources found.\n") + _, _ = fmt.Fprintln(tw, "No resources found") } _ = tw.Flush() } diff --git a/internal/cmd/olmv1.go b/internal/cmd/olmv1.go index a4ce21dd..86597226 100644 --- a/internal/cmd/olmv1.go +++ b/internal/cmd/olmv1.go @@ -10,8 +10,8 @@ import ( func newOlmV1Cmd(cfg *action.Configuration) *cobra.Command { cmd := &cobra.Command{ Use: "olmv1", - Short: "Manage extensions via OLMv1 in a cluster from the command line", - Long: "Manage extensions via OLMv1 in a cluster from the command line.", + Short: "Manage OLMv1 extensions and catalogs", + Long: "Manage OLMv1 resources like clusterextensions and clustercatalogs from the command line.", } getCmd := &cobra.Command{ diff --git a/internal/pkg/v1/action/action_suite_test.go b/internal/pkg/v1/action/action_suite_test.go index 8fd62041..659663dc 100644 --- a/internal/pkg/v1/action/action_suite_test.go +++ b/internal/pkg/v1/action/action_suite_test.go @@ -143,6 +143,21 @@ func withLabels(labels map[string]string) extensionOpt { } } +func withCRDUpgradePolicy(policy string) extensionOpt { + return func(ext *olmv1.ClusterExtension) { + if ext.Spec.Install == nil { + ext.Spec.Install = &olmv1.ClusterExtensionInstallConfig{} + } + if ext.Spec.Install.Preflight == nil { + ext.Spec.Install.Preflight = &olmv1.PreflightConfig{} + } + if ext.Spec.Install.Preflight.CRDUpgradeSafety == nil { + ext.Spec.Install.Preflight.CRDUpgradeSafety = &olmv1.CRDUpgradeSafetyPreflightConfig{} + } + ext.Spec.Install.Preflight.CRDUpgradeSafety.Enforcement = olmv1.CRDUpgradeSafetyEnforcement(policy) + } +} + func withCatalogSourceType(sourceType olmv1.SourceType) catalogOpt { return func(catalog *olmv1.ClusterCatalog) { catalog.Spec.Source.Type = sourceType diff --git a/internal/pkg/v1/action/catalog_create.go b/internal/pkg/v1/action/catalog_create.go index e8196a01..8db5790f 100644 --- a/internal/pkg/v1/action/catalog_create.go +++ b/internal/pkg/v1/action/catalog_create.go @@ -5,6 +5,7 @@ import ( "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" olmv1 "github.com/operator-framework/operator-controller/api/v1" @@ -12,17 +13,19 @@ import ( ) type CatalogCreate struct { - config *action.Configuration - CatalogName string - ImageSourceRef string + config *action.Configuration + CatalogName string + ImageSourceRef string Priority int32 PollIntervalMinutes int Labels map[string]string - Available bool + AvailabilityMode string CleanupTimeout time.Duration - Logf func(string, ...interface{}) + DryRun string + Output string + Logf func(string, ...interface{}) } func NewCatalogCreate(config *action.Configuration) *CatalogCreate { @@ -32,14 +35,20 @@ func NewCatalogCreate(config *action.Configuration) *CatalogCreate { } } -func (i *CatalogCreate) Run(ctx context.Context) error { +func (i *CatalogCreate) Run(ctx context.Context) (*olmv1.ClusterCatalog, error) { catalog := i.buildCatalog() + if i.DryRun == DryRunAll { + if err := i.config.Client.Create(ctx, &catalog, client.DryRunAll); err != nil { + return nil, err + } + return &catalog, nil + } if err := i.config.Client.Create(ctx, &catalog); err != nil { - return err + return nil, err } var err error - if i.Available { + if i.AvailabilityMode == string(olmv1.AvailabilityModeAvailable) { err = waitUntilCatalogStatusCondition(ctx, i.config.Client, &catalog, olmv1.TypeServing, metav1.ConditionTrue) } else { err = waitUntilCatalogStatusCondition(ctx, i.config.Client, &catalog, olmv1.TypeServing, metav1.ConditionFalse) @@ -49,10 +58,10 @@ func (i *CatalogCreate) Run(ctx context.Context) error { if cleanupErr := deleteWithTimeout(i.config.Client, &catalog, i.CleanupTimeout); cleanupErr != nil { i.Logf("cleaning up failed catalog: %v", cleanupErr) } - return err + return nil, err } - return nil + return &catalog, nil } func (i *CatalogCreate) buildCatalog() olmv1.ClusterCatalog { @@ -65,16 +74,18 @@ func (i *CatalogCreate) buildCatalog() olmv1.ClusterCatalog { Source: olmv1.CatalogSource{ Type: olmv1.SourceTypeImage, Image: &olmv1.ImageSource{ - Ref: i.ImageSourceRef, - PollIntervalMinutes: &i.PollIntervalMinutes, + Ref: i.ImageSourceRef, }, }, Priority: i.Priority, AvailabilityMode: olmv1.AvailabilityModeAvailable, }, } - if !i.Available { - catalog.Spec.AvailabilityMode = olmv1.AvailabilityModeUnavailable + if len(i.AvailabilityMode) != 0 { + catalog.Spec.AvailabilityMode = olmv1.AvailabilityMode(i.AvailabilityMode) + } + if i.PollIntervalMinutes > 0 { + catalog.Spec.Source.Image.PollIntervalMinutes = &i.PollIntervalMinutes } return catalog diff --git a/internal/pkg/v1/action/catalog_create_test.go b/internal/pkg/v1/action/catalog_create_test.go index e8729460..e0551dda 100644 --- a/internal/pkg/v1/action/catalog_create_test.go +++ b/internal/pkg/v1/action/catalog_create_test.go @@ -44,13 +44,13 @@ var _ = Describe("CatalogCreate", func() { Expect(testClient.Initialize()).To(Succeed()) creator := internalaction.NewCatalogCreate(&action.Configuration{Client: testClient}) - creator.Available = true + creator.AvailabilityMode = string(olmv1.AvailabilityModeAvailable) creator.CatalogName = expectedCatalog.Name creator.ImageSourceRef = expectedCatalog.Spec.Source.Image.Ref creator.Priority = expectedCatalog.Spec.Priority creator.Labels = expectedCatalog.Labels creator.PollIntervalMinutes = *expectedCatalog.Spec.Source.Image.PollIntervalMinutes - err := creator.Run(context.TODO()) + _, err := creator.Run(context.TODO()) Expect(err).NotTo(BeNil()) Expect(err).To(MatchError(expectedErr)) @@ -65,7 +65,7 @@ var _ = Describe("CatalogCreate", func() { creator := internalaction.NewCatalogCreate(&action.Configuration{Client: testClient}) // fakeClient requires at least the catalogName to be set to run creator.CatalogName = expectedCatalog.Name - err := creator.Run(context.TODO()) + _, err := creator.Run(context.TODO()) Expect(err).NotTo(BeNil()) Expect(err).To(MatchError(expectedErr)) @@ -83,7 +83,7 @@ var _ = Describe("CatalogCreate", func() { creator := internalaction.NewCatalogCreate(&action.Configuration{Client: testClient}) // fakeClient requires at least the catalogName to be set to run creator.CatalogName = expectedCatalog.Name - err := creator.Run(context.TODO()) + _, err := creator.Run(context.TODO()) Expect(err).NotTo(BeNil()) Expect(err).To(MatchError(getErr)) @@ -113,17 +113,18 @@ var _ = Describe("CatalogCreate", func() { Expect(testClient.Initialize()).To(Succeed()) creator := internalaction.NewCatalogCreate(&action.Configuration{Client: testClient}) - creator.Available = true + creator.AvailabilityMode = string(olmv1.AvailabilityModeAvailable) creator.CatalogName = expectedCatalog.Name creator.ImageSourceRef = expectedCatalog.Spec.Source.Image.Ref creator.Priority = expectedCatalog.Spec.Priority creator.Labels = expectedCatalog.Labels creator.PollIntervalMinutes = *expectedCatalog.Spec.Source.Image.PollIntervalMinutes - Expect(creator.Run(context.TODO())).To(Succeed()) + _, err := creator.Run(context.TODO()) + Expect(err).ToNot(HaveOccurred()) Expect(testClient.createCalled).To(Equal(1)) - actualCatalog := &olmv1.ClusterCatalog{TypeMeta: metav1.TypeMeta{Kind: "ClusterCatalog", APIVersion: "olm.operatorframework.io/v1"}} + actualCatalog := &olmv1.ClusterCatalog{TypeMeta: metav1.TypeMeta{Kind: "ClusterCatalog", APIVersion: olmv1.GroupVersion.String()}} Expect(testClient.Client.Get(context.TODO(), types.NamespacedName{Name: catalogName}, actualCatalog)).To(Succeed()) validateCreateCatalog(actualCatalog, &expectedCatalog) }) diff --git a/internal/pkg/v1/action/catalog_delete.go b/internal/pkg/v1/action/catalog_delete.go index d3888271..eaf122f8 100644 --- a/internal/pkg/v1/action/catalog_delete.go +++ b/internal/pkg/v1/action/catalog_delete.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" + "sigs.k8s.io/controller-runtime/pkg/client" + olmv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/kubectl-operator/pkg/action" @@ -13,9 +15,12 @@ import ( type CatalogDelete struct { config *action.Configuration CatalogName string - DeleteAll bool - Logf func(string, ...interface{}) + DeleteAll bool + + DryRun string + Output string + Logf func(string, ...interface{}) } func NewCatalogDelete(cfg *action.Configuration) *CatalogDelete { @@ -25,20 +30,24 @@ func NewCatalogDelete(cfg *action.Configuration) *CatalogDelete { } } -func (cd *CatalogDelete) Run(ctx context.Context) ([]string, error) { +func (i *CatalogDelete) Run(ctx context.Context) ([]olmv1.ClusterCatalog, error) { // validate - if cd.DeleteAll && cd.CatalogName != "" { + if i.DeleteAll && i.CatalogName != "" { return nil, ErrNameAndSelector } // delete single, specified catalog - if !cd.DeleteAll { - return nil, cd.deleteCatalog(ctx, cd.CatalogName) + if !i.DeleteAll { + obj, err := i.deleteCatalog(ctx, i.CatalogName) + if err != nil { + return nil, err + } + return []olmv1.ClusterCatalog{obj}, nil } // delete all existing catalogs var catatalogList olmv1.ClusterCatalogList - if err := cd.config.Client.List(ctx, &catatalogList); err != nil { + if err := i.config.Client.List(ctx, &catatalogList); err != nil { return nil, err } if len(catatalogList.Items) == 0 { @@ -46,24 +55,29 @@ func (cd *CatalogDelete) Run(ctx context.Context) ([]string, error) { } errs := make([]error, 0, len(catatalogList.Items)) - names := make([]string, 0, len(catatalogList.Items)) + result := []olmv1.ClusterCatalog{} for _, catalog := range catatalogList.Items { - names = append(names, catalog.Name) - if err := cd.deleteCatalog(ctx, catalog.Name); err != nil { + if obj, err := i.deleteCatalog(ctx, catalog.Name); err != nil { errs = append(errs, fmt.Errorf("failed deleting catalog %q: %w", catalog.Name, err)) + } else { + result = append(result, obj) } } - return names, errors.Join(errs...) + return result, errors.Join(errs...) } -func (cd *CatalogDelete) deleteCatalog(ctx context.Context, name string) error { +func (i *CatalogDelete) deleteCatalog(ctx context.Context, name string) (olmv1.ClusterCatalog, error) { op := &olmv1.ClusterCatalog{} op.SetName(name) - if err := cd.config.Client.Delete(ctx, op); err != nil { - return err + if i.DryRun == DryRunAll { + err := i.config.Client.Delete(ctx, op, client.DryRunAll) + return *op, err + } + if err := i.config.Client.Delete(ctx, op); err != nil { + return *op, err } - return waitForDeletion(ctx, cd.config.Client, op) + return *op, waitForDeletion(ctx, i.config.Client, op) } diff --git a/internal/pkg/v1/action/catalog_delete_test.go b/internal/pkg/v1/action/catalog_delete_test.go index 237b27b3..f7bbe10c 100644 --- a/internal/pkg/v1/action/catalog_delete_test.go +++ b/internal/pkg/v1/action/catalog_delete_test.go @@ -39,9 +39,9 @@ var _ = Describe("CatalogDelete", func() { deleter := internalaction.NewCatalogDelete(&cfg) deleter.CatalogName = "name" deleter.DeleteAll = true - catNames, err := deleter.Run(context.TODO()) + catalogs, err := deleter.Run(context.TODO()) Expect(err).NotTo(BeNil()) - Expect(catNames).To(BeEmpty()) + Expect(catalogs).To(BeEmpty()) validateExistingCatalogs(cfg.Client, []string{"cat1", "cat2"}) }) @@ -51,9 +51,9 @@ var _ = Describe("CatalogDelete", func() { deleter := internalaction.NewCatalogDelete(&cfg) deleter.CatalogName = "does-not-exist" - catNames, err := deleter.Run(context.TODO()) + catalogs, err := deleter.Run(context.TODO()) Expect(err).NotTo(BeNil()) - Expect(catNames).To(BeEmpty()) + Expect(catalogs).To(BeEmpty()) validateExistingCatalogs(cfg.Client, []string{"cat1", "cat2"}) }) @@ -63,9 +63,10 @@ var _ = Describe("CatalogDelete", func() { deleter := internalaction.NewCatalogDelete(&cfg) deleter.CatalogName = "cat2" - catNames, err := deleter.Run(context.TODO()) + catalogs, err := deleter.Run(context.TODO()) Expect(err).To(BeNil()) - Expect(catNames).To(BeEmpty()) + Expect(catalogs).To(HaveLen(1)) + validateCatalogList(catalogs, []string{deleter.CatalogName}) validateExistingCatalogs(cfg.Client, []string{"cat1", "cat3"}) }) @@ -75,9 +76,9 @@ var _ = Describe("CatalogDelete", func() { deleter := internalaction.NewCatalogDelete(&cfg) deleter.DeleteAll = true - catNames, err := deleter.Run(context.TODO()) + catalogs, err := deleter.Run(context.TODO()) Expect(err).NotTo(BeNil()) - Expect(catNames).To(BeEmpty()) + Expect(catalogs).To(BeEmpty()) validateExistingCatalogs(cfg.Client, []string{}) }) @@ -87,9 +88,9 @@ var _ = Describe("CatalogDelete", func() { deleter := internalaction.NewCatalogDelete(&cfg) deleter.DeleteAll = true - catNames, err := deleter.Run(context.TODO()) + catalogs, err := deleter.Run(context.TODO()) Expect(err).To(BeNil()) - Expect(catNames).To(ContainElements([]string{"cat1", "cat2", "cat3"})) + validateCatalogList(catalogs, []string{"cat1", "cat2", "cat3"}) validateExistingCatalogs(cfg.Client, []string{}) }) @@ -102,6 +103,10 @@ func validateExistingCatalogs(c client.Client, wantedNames []string) { catalogs := catalogsList.Items Expect(catalogs).To(HaveLen(len(wantedNames))) + validateCatalogList(catalogs, wantedNames) +} + +func validateCatalogList(catalogs []olmv1.ClusterCatalog, wantedNames []string) { for _, wantedName := range wantedNames { Expect(slices.ContainsFunc(catalogs, func(cat olmv1.ClusterCatalog) bool { return cat.Name == wantedName diff --git a/internal/pkg/v1/action/catalog_installed_get.go b/internal/pkg/v1/action/catalog_get.go similarity index 75% rename from internal/pkg/v1/action/catalog_installed_get.go rename to internal/pkg/v1/action/catalog_get.go index 2a27a391..8fdb163f 100644 --- a/internal/pkg/v1/action/catalog_installed_get.go +++ b/internal/pkg/v1/action/catalog_get.go @@ -2,7 +2,6 @@ package action import ( "context" - "fmt" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" @@ -17,7 +16,7 @@ type CatalogInstalledGet struct { config *action.Configuration CatalogName string - Selector string + Selector labels.Selector Logf func(string, ...interface{}) } @@ -45,15 +44,11 @@ func (i *CatalogInstalledGet) Run(ctx context.Context) ([]olmv1.ClusterCatalog, // list var result olmv1.ClusterCatalogList - listOptions := &client.ListOptions{} - if len(i.Selector) > 0 { - labelSelector, err := labels.Parse(i.Selector) - if err != nil { - return nil, fmt.Errorf("unable to parse selector %s: %v", i.Selector, err) - } - listOptions.LabelSelector = labelSelector + listOpts := &client.ListOptions{} + if i.Selector != nil { + listOpts.LabelSelector = i.Selector } - err := i.config.Client.List(ctx, &result, listOptions) + err := i.config.Client.List(ctx, &result, listOpts) return result.Items, err } diff --git a/internal/pkg/v1/action/catalog_installed_get_test.go b/internal/pkg/v1/action/catalog_get_test.go similarity index 95% rename from internal/pkg/v1/action/catalog_installed_get_test.go rename to internal/pkg/v1/action/catalog_get_test.go index 9e74f92a..8561e936 100644 --- a/internal/pkg/v1/action/catalog_installed_get_test.go +++ b/internal/pkg/v1/action/catalog_get_test.go @@ -7,6 +7,7 @@ import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -93,7 +94,9 @@ var _ = Describe("CatalogInstalledGet", func() { cfg := setupEnv(initCatalogs...) getter := internalaction.NewCatalogInstalledGet(&cfg) - getter.Selector = "foo=bar" + var err error + getter.Selector, err = labels.Parse("foo=bar") + Expect(err).To(BeNil()) catalogs, err := getter.Run(context.TODO()) Expect(err).To(BeNil()) Expect(catalogs).To(HaveLen(2)) diff --git a/internal/pkg/v1/action/catalog_search.go b/internal/pkg/v1/action/catalog_search.go index 0d4fc769..85cd65dc 100644 --- a/internal/pkg/v1/action/catalog_search.go +++ b/internal/pkg/v1/action/catalog_search.go @@ -7,6 +7,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" olmv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-registry/alpha/declcfg" @@ -16,10 +17,10 @@ import ( ) type CatalogSearch struct { - config *action.Configuration - CatalogName string - OutputFormat string - Selector string + config *action.Configuration + CatalogName string + + Selector labels.Selector ListVersions bool Package string CatalogdNamespace string @@ -39,7 +40,7 @@ func (i *CatalogSearch) Run(ctx context.Context) (map[string]*declcfg.Declarativ if len(i.Timeout) > 0 { catalogListTimeout, err := time.ParseDuration(i.Timeout) if err != nil { - return nil, fmt.Errorf("failed to parse timeout %s: %w", i.Timeout, err) + return nil, fmt.Errorf("failed to parse timeout %q: %w", i.Timeout, err) } i.config.Config.Timeout = catalogListTimeout } @@ -60,8 +61,8 @@ func (i *CatalogSearch) Run(ctx context.Context) (map[string]*declcfg.Declarativ if len(i.CatalogName) != 0 { return nil, fmt.Errorf("failed to query for catalog contents: catalog(s) unhealthy") } - if len(i.Selector) > 0 { - return nil, fmt.Errorf("no serving catalogs matching label selector %v found", i.Selector) + if i.Selector != nil { + return nil, fmt.Errorf("no serving catalogs matching label selector %q found", i.Selector) } return nil, fmt.Errorf("no serving catalogs found") } @@ -93,12 +94,12 @@ func (i *CatalogSearch) Run(ctx context.Context) (map[string]*declcfg.Declarativ if !foundPackage { // package name was specified and query was empty across all available catalogs. if len(i.CatalogName) != 0 { - return nil, fmt.Errorf("package %s was not found in ClusterCatalog %s", i.Package, i.CatalogName) + return nil, fmt.Errorf("package %q was not found in ClusterCatalog %q", i.Package, i.CatalogName) } - if len(i.Selector) > 0 { - return nil, fmt.Errorf("package %s was not found in ClusterCatalogs matching label %s", i.Package, i.Selector) + if i.Selector != nil { + return nil, fmt.Errorf("package %q was not found in ClusterCatalogs matching label %q", i.Package, i.Selector) } - return nil, fmt.Errorf("package %s was not found in any serving ClusterCatalog", i.Package) + return nil, fmt.Errorf("package %q was not found in any serving ClusterCatalog", i.Package) } return catalogDeclCfg, nil } diff --git a/internal/pkg/v1/action/catalog_update.go b/internal/pkg/v1/action/catalog_update.go index 7fa7c80e..b2b0f456 100644 --- a/internal/pkg/v1/action/catalog_update.go +++ b/internal/pkg/v1/action/catalog_update.go @@ -6,6 +6,7 @@ import ( "regexp" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" olmv1 "github.com/operator-framework/operator-controller/api/v1" @@ -23,7 +24,9 @@ type CatalogUpdate struct { ImageRef string IgnoreUnset bool - Logf func(string, ...interface{}) + DryRun string + Output string + Logf func(string, ...interface{}) } func NewCatalogUpdate(config *action.Configuration) *CatalogUpdate { @@ -33,15 +36,15 @@ func NewCatalogUpdate(config *action.Configuration) *CatalogUpdate { } } -func (cu *CatalogUpdate) Run(ctx context.Context) (*olmv1.ClusterCatalog, error) { +func (i *CatalogUpdate) Run(ctx context.Context) (*olmv1.ClusterCatalog, error) { var catalog olmv1.ClusterCatalog var err error cuKey := types.NamespacedName{ - Name: cu.CatalogName, - Namespace: cu.config.Namespace, + Name: i.CatalogName, + Namespace: i.config.Namespace, } - if err = cu.config.Client.Get(ctx, cuKey, &catalog); err != nil { + if err = i.config.Client.Get(ctx, cuKey, &catalog); err != nil { return nil, err } @@ -49,29 +52,36 @@ func (cu *CatalogUpdate) Run(ctx context.Context) (*olmv1.ClusterCatalog, error) return nil, fmt.Errorf("unrecognized source type: %q", catalog.Spec.Source.Type) } - if cu.ImageRef != "" && !isValidImageRef(cu.ImageRef) { - return nil, fmt.Errorf("invalid image reference: %q, it must be a valid image reference format", cu.ImageRef) + if i.ImageRef != "" && !isValidImageRef(i.ImageRef) { + return nil, fmt.Errorf("invalid image reference: %q, it must be a valid image reference format", i.ImageRef) } - cu.setDefaults(&catalog) + i.setDefaults(&catalog) - cu.setUpdatedCatalog(&catalog) - if err := cu.config.Client.Update(ctx, &catalog); err != nil { + i.setUpdatedCatalog(&catalog) + if i.DryRun == DryRunAll { + if err := i.config.Client.Update(ctx, &catalog, client.DryRunAll); err != nil { + return nil, err + } + return &catalog, nil + } + + if err := i.config.Client.Update(ctx, &catalog); err != nil { return nil, err } - cu.Logf("Updating catalog %q in namespace %q", cu.CatalogName, cu.config.Namespace) + i.Logf("Updating catalog %q in namespace %q", i.CatalogName, i.config.Namespace) return &catalog, nil } -func (cu *CatalogUpdate) setUpdatedCatalog(catalog *olmv1.ClusterCatalog) { +func (i *CatalogUpdate) setUpdatedCatalog(catalog *olmv1.ClusterCatalog) { existingLabels := catalog.GetLabels() if existingLabels == nil { existingLabels = make(map[string]string) } - if cu.Labels != nil { - for k, v := range cu.Labels { + if i.Labels != nil { + for k, v := range i.Labels { if v == "" { delete(existingLabels, k) } else { @@ -81,54 +91,52 @@ func (cu *CatalogUpdate) setUpdatedCatalog(catalog *olmv1.ClusterCatalog) { catalog.SetLabels(existingLabels) } - if cu.Priority != nil { - catalog.Spec.Priority = *cu.Priority + if i.Priority != nil { + catalog.Spec.Priority = *i.Priority } if catalog.Spec.Source.Image == nil { catalog.Spec.Source.Image = &olmv1.ImageSource{} } - if cu.PollIntervalMinutes != nil { - if *cu.PollIntervalMinutes == 0 || *cu.PollIntervalMinutes == -1 { + if i.PollIntervalMinutes != nil { + if *i.PollIntervalMinutes == 0 || *i.PollIntervalMinutes == -1 { catalog.Spec.Source.Image.PollIntervalMinutes = nil } else { - catalog.Spec.Source.Image.PollIntervalMinutes = cu.PollIntervalMinutes + catalog.Spec.Source.Image.PollIntervalMinutes = i.PollIntervalMinutes } } - if cu.ImageRef != "" { - catalog.Spec.Source.Image.Ref = cu.ImageRef + if i.ImageRef != "" { + catalog.Spec.Source.Image.Ref = i.ImageRef } - if cu.AvailabilityMode != "" { - catalog.Spec.AvailabilityMode = olmv1.AvailabilityMode(cu.AvailabilityMode) - } + catalog.Spec.AvailabilityMode = olmv1.AvailabilityMode(i.AvailabilityMode) } -func (cu *CatalogUpdate) setDefaults(catalog *olmv1.ClusterCatalog) { - if !cu.IgnoreUnset { +func (i *CatalogUpdate) setDefaults(catalog *olmv1.ClusterCatalog) { + if !i.IgnoreUnset { return } catalogSrc := catalog.Spec.Source - if cu.Priority == nil { - cu.Priority = &catalog.Spec.Priority + if i.Priority == nil { + i.Priority = &catalog.Spec.Priority } - if cu.PollIntervalMinutes == nil && catalogSrc.Image != nil && catalogSrc.Image.PollIntervalMinutes != nil { - cu.PollIntervalMinutes = catalogSrc.Image.PollIntervalMinutes + if i.PollIntervalMinutes == nil && catalogSrc.Image != nil && catalogSrc.Image.PollIntervalMinutes != nil { + i.PollIntervalMinutes = catalogSrc.Image.PollIntervalMinutes } - if cu.ImageRef == "" && catalogSrc.Image != nil { - cu.ImageRef = catalogSrc.Image.Ref + if i.ImageRef == "" && catalogSrc.Image != nil { + i.ImageRef = catalogSrc.Image.Ref } - if cu.AvailabilityMode == "" { - cu.AvailabilityMode = string(catalog.Spec.AvailabilityMode) + if i.AvailabilityMode == "" { + i.AvailabilityMode = string(catalog.Spec.AvailabilityMode) } - if len(cu.Labels) == 0 { - cu.Labels = catalog.Labels + if len(i.Labels) == 0 { + i.Labels = catalog.Labels } } diff --git a/internal/pkg/v1/action/extension_delete.go b/internal/pkg/v1/action/extension_delete.go index 488fce4a..6c17e73e 100644 --- a/internal/pkg/v1/action/extension_delete.go +++ b/internal/pkg/v1/action/extension_delete.go @@ -4,9 +4,8 @@ import ( "context" "errors" "fmt" - "strings" - apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" olmv1 "github.com/operator-framework/operator-controller/api/v1" @@ -17,8 +16,12 @@ import ( type ExtensionDeletion struct { config *action.Configuration ExtensionName string - DeleteAll bool - Logf func(string, ...interface{}) + + DeleteAll bool + + DryRun string + Output string + Logf func(string, ...interface{}) } // NewExtensionDelete creates a new ExtensionDeletion action @@ -31,51 +34,55 @@ func NewExtensionDelete(cfg *action.Configuration) *ExtensionDeletion { } } -func (u *ExtensionDeletion) Run(ctx context.Context) ([]string, error) { - if u.DeleteAll && u.ExtensionName != "" { +func (i *ExtensionDeletion) Run(ctx context.Context) ([]olmv1.ClusterExtension, error) { + if i.DeleteAll && i.ExtensionName != "" { return nil, fmt.Errorf("cannot specify both --all and an extension name") } - if !u.DeleteAll { - return u.deleteExtension(ctx, u.ExtensionName) + if !i.DeleteAll { + ext, err := i.deleteExtension(ctx, i.ExtensionName) + return []olmv1.ClusterExtension{ext}, err } // delete all existing extensions - return u.deleteAllExtensions(ctx) + return i.deleteAllExtensions(ctx) } // deleteExtension deletes a single extension in the cluster -func (u *ExtensionDeletion) deleteExtension(ctx context.Context, extName string) ([]string, error) { +func (i *ExtensionDeletion) deleteExtension(ctx context.Context, extName string) (olmv1.ClusterExtension, error) { op := &olmv1.ClusterExtension{} op.SetName(extName) op.SetGroupVersionKind(olmv1.GroupVersion.WithKind("ClusterExtension")) - lowerKind := strings.ToLower(op.GetObjectKind().GroupVersionKind().Kind) - err := u.config.Client.Delete(ctx, op) + + if i.DryRun == DryRunAll { + err := i.config.Client.Delete(ctx, op, client.DryRunAll) + return *op, err + } + + err := i.config.Client.Delete(ctx, op) if err != nil { - if !apierrors.IsNotFound(err) { - return []string{u.ExtensionName}, fmt.Errorf("delete %s %q: %v", lowerKind, op.GetName(), err) - } - return nil, err + return *op, err } // wait for deletion - return []string{u.ExtensionName}, waitForDeletion(ctx, u.config.Client, op) + return *op, waitForDeletion(ctx, i.config.Client, op) } // deleteAllExtensions deletes all extensions in the cluster -func (u *ExtensionDeletion) deleteAllExtensions(ctx context.Context) ([]string, error) { +func (i *ExtensionDeletion) deleteAllExtensions(ctx context.Context) ([]olmv1.ClusterExtension, error) { var extensionList olmv1.ClusterExtensionList - if err := u.config.Client.List(ctx, &extensionList); err != nil { + if err := i.config.Client.List(ctx, &extensionList); err != nil { return nil, err } if len(extensionList.Items) == 0 { return nil, ErrNoResourcesFound } errs := make([]error, 0, len(extensionList.Items)) - names := make([]string, 0, len(extensionList.Items)) + result := []olmv1.ClusterExtension{} for _, extension := range extensionList.Items { - names = append(names, extension.Name) - if _, err := u.deleteExtension(ctx, extension.Name); err != nil { + if op, err := i.deleteExtension(ctx, extension.Name); err != nil { errs = append(errs, fmt.Errorf("failed deleting extension %q: %w", extension.Name, err)) + } else { + result = append(result, op) } } - return names, errors.Join(errs...) + return result, errors.Join(errs...) } diff --git a/internal/pkg/v1/action/extension_delete_test.go b/internal/pkg/v1/action/extension_delete_test.go index 5b4f4af4..520a949e 100644 --- a/internal/pkg/v1/action/extension_delete_test.go +++ b/internal/pkg/v1/action/extension_delete_test.go @@ -39,9 +39,9 @@ var _ = Describe("ExtensionDelete", func() { deleter := internalaction.NewExtensionDelete(&cfg) deleter.ExtensionName = "foo" deleter.DeleteAll = true - extNames, err := deleter.Run(context.TODO()) + extensions, err := deleter.Run(context.TODO()) Expect(err).NotTo(BeNil()) - Expect(extNames).To(BeEmpty()) + Expect(extensions).To(BeEmpty()) validateExistingExtensions(cfg.Client, []string{"ext1", "ext2"}) }) @@ -51,9 +51,10 @@ var _ = Describe("ExtensionDelete", func() { deleter := internalaction.NewExtensionDelete(&cfg) deleter.ExtensionName = "does-not-exist" - extNames, err := deleter.Run(context.TODO()) + extensions, err := deleter.Run(context.TODO()) Expect(err).NotTo(BeNil()) - Expect(extNames).To(BeEmpty()) + Expect(extensions).To(HaveLen(1)) + validateExtensionList(extensions, []string{deleter.ExtensionName}) validateExistingExtensions(cfg.Client, []string{"ext1", "ext2"}) }) @@ -74,9 +75,9 @@ var _ = Describe("ExtensionDelete", func() { deleter := internalaction.NewExtensionDelete(&cfg) deleter.DeleteAll = true - extNames, err := deleter.Run(context.TODO()) + extensions, err := deleter.Run(context.TODO()) Expect(err).NotTo(BeNil()) - Expect(extNames).To(BeEmpty()) + Expect(extensions).To(BeEmpty()) validateExistingExtensions(cfg.Client, []string{}) }) @@ -86,9 +87,9 @@ var _ = Describe("ExtensionDelete", func() { deleter := internalaction.NewExtensionDelete(&cfg) deleter.DeleteAll = true - extNames, err := deleter.Run(context.TODO()) + extensions, err := deleter.Run(context.TODO()) Expect(err).To(BeNil()) - Expect(extNames).To(ContainElements([]string{"ext1", "ext2", "ext3"})) + validateExtensionList(extensions, []string{"ext1", "ext2", "ext3"}) validateExistingExtensions(cfg.Client, []string{}) }) @@ -103,6 +104,10 @@ func validateExistingExtensions(c client.Client, wantedNames []string) { extensions := extensionList.Items Expect(extensions).To(HaveLen(len(wantedNames))) + validateExtensionList(extensions, wantedNames) +} + +func validateExtensionList(extensions []olmv1.ClusterExtension, wantedNames []string) { for _, wantedName := range wantedNames { Expect(slices.ContainsFunc(extensions, func(ext olmv1.ClusterExtension) bool { return ext.Name == wantedName diff --git a/internal/pkg/v1/action/extension_installed_get.go b/internal/pkg/v1/action/extension_get.go similarity index 82% rename from internal/pkg/v1/action/extension_installed_get.go rename to internal/pkg/v1/action/extension_get.go index 994fc5f9..49c758e8 100644 --- a/internal/pkg/v1/action/extension_installed_get.go +++ b/internal/pkg/v1/action/extension_get.go @@ -3,6 +3,7 @@ package action import ( "context" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -15,6 +16,8 @@ type ExtensionInstalledGet struct { config *action.Configuration ExtensionName string + Selector labels.Selector + Logf func(string, ...interface{}) } @@ -40,7 +43,11 @@ func (i *ExtensionInstalledGet) Run(ctx context.Context) ([]olmv1.ClusterExtensi // list var result olmv1.ClusterExtensionList - err := i.config.Client.List(ctx, &result, &client.ListOptions{}) + listOpts := &client.ListOptions{} + if i.Selector != nil { + listOpts.LabelSelector = i.Selector + } + err := i.config.Client.List(ctx, &result, listOpts) return result.Items, err } diff --git a/internal/pkg/v1/action/extension_installed_get_test.go b/internal/pkg/v1/action/extension_get_test.go similarity index 100% rename from internal/pkg/v1/action/extension_installed_get_test.go rename to internal/pkg/v1/action/extension_get_test.go diff --git a/internal/pkg/v1/action/extension_install.go b/internal/pkg/v1/action/extension_install.go index 05126791..8af75a9e 100644 --- a/internal/pkg/v1/action/extension_install.go +++ b/internal/pkg/v1/action/extension_install.go @@ -11,21 +11,30 @@ import ( "k8s.io/apimachinery/pkg/util/wait" "sigs.k8s.io/controller-runtime/pkg/client" - ocv1 "github.com/operator-framework/operator-controller/api/v1" + olmv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/kubectl-operator/pkg/action" ) type ExtensionInstall struct { - config *action.Configuration - ExtensionName string - Namespace NamespaceConfig - PackageName string - Channels []string - Version string - ServiceAccount string - CleanupTimeout time.Duration - Logf func(string, ...interface{}) + config *action.Configuration + ExtensionName string + + Namespace NamespaceConfig + PackageName string + Channels []string + Version string + CatalogSelector *metav1.LabelSelector + ServiceAccount string + CleanupTimeout time.Duration + UpgradeConstraintPolicy string + PreflightCRDUpgradeSafetyEnforcement string + CRDUpgradeSafetyEnforcement string + Labels map[string]string + + DryRun string + Output string + Logf func(string, ...interface{}) } type NamespaceConfig struct { Name string @@ -38,30 +47,46 @@ func NewExtensionInstall(cfg *action.Configuration) *ExtensionInstall { } } -func (i *ExtensionInstall) buildClusterExtension() ocv1.ClusterExtension { - extension := ocv1.ClusterExtension{ +func (i *ExtensionInstall) buildClusterExtension() olmv1.ClusterExtension { + extension := olmv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ - Name: i.ExtensionName, + Name: i.ExtensionName, + Labels: i.Labels, }, - Spec: ocv1.ClusterExtensionSpec{ - Source: ocv1.SourceConfig{ - SourceType: ocv1.SourceTypeCatalog, - Catalog: &ocv1.CatalogFilter{ + Spec: olmv1.ClusterExtensionSpec{ + Source: olmv1.SourceConfig{ + SourceType: olmv1.SourceTypeCatalog, + Catalog: &olmv1.CatalogFilter{ PackageName: i.PackageName, Version: i.Version, }, }, Namespace: i.Namespace.Name, - ServiceAccount: ocv1.ServiceAccountReference{ + ServiceAccount: olmv1.ServiceAccountReference{ Name: i.ServiceAccount, }, }, } + if i.CatalogSelector != nil { + extension.Spec.Source.Catalog.Selector = i.CatalogSelector + } + if len(i.UpgradeConstraintPolicy) > 0 { + extension.Spec.Source.Catalog.UpgradeConstraintPolicy = olmv1.UpgradeConstraintPolicy(i.UpgradeConstraintPolicy) + } + if len(i.CRDUpgradeSafetyEnforcement) > 0 { + extension.Spec.Install = &olmv1.ClusterExtensionInstallConfig{ + Preflight: &olmv1.PreflightConfig{ + CRDUpgradeSafety: &olmv1.CRDUpgradeSafetyPreflightConfig{ + Enforcement: olmv1.CRDUpgradeSafetyEnforcement(i.CRDUpgradeSafetyEnforcement), + }, + }, + } + } return extension } -func (i *ExtensionInstall) Run(ctx context.Context) (*ocv1.ClusterExtension, error) { +func (i *ExtensionInstall) Run(ctx context.Context) (*olmv1.ClusterExtension, error) { extension := i.buildClusterExtension() // Add Channels to extension @@ -69,14 +94,19 @@ func (i *ExtensionInstall) Run(ctx context.Context) (*ocv1.ClusterExtension, err extension.Spec.Source.Catalog.Channels = i.Channels } - // TODO: Add CatalogSelector to extension - + if i.DryRun == DryRunAll { + if err := i.config.Client.Create(ctx, &extension, client.DryRunAll); err != nil { + return nil, err + } + return &extension, nil + } // Create the extension if err := i.config.Client.Create(ctx, &extension); err != nil { return nil, err } clusterExtension, err := i.waitForExtensionInstall(ctx) if err != nil { + i.Logf("failed to install extension %s: %w; cleaning up extension", i.PackageName, err) cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), i.CleanupTimeout) defer cancelCleanup() cleanupErr := i.cleanup(cleanupCtx) @@ -87,8 +117,8 @@ func (i *ExtensionInstall) Run(ctx context.Context) (*ocv1.ClusterExtension, err // waitForClusterExtensionInstalled waits for the ClusterExtension to be installed // and returns the ClusterExtension object -func (i *ExtensionInstall) waitForExtensionInstall(ctx context.Context) (*ocv1.ClusterExtension, error) { - clusterExtension := &ocv1.ClusterExtension{ +func (i *ExtensionInstall) waitForExtensionInstall(ctx context.Context) (*olmv1.ClusterExtension, error) { + clusterExtension := &olmv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Name: i.ExtensionName, }, @@ -99,12 +129,12 @@ func (i *ExtensionInstall) waitForExtensionInstall(ctx context.Context) (*ocv1.C if err := i.config.Client.Get(conditionCtx, key, clusterExtension); err != nil { return false, err } - progressingCondition := meta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing) - if progressingCondition != nil && progressingCondition.Reason != ocv1.ReasonSucceeded { + progressingCondition := meta.FindStatusCondition(clusterExtension.Status.Conditions, olmv1.TypeProgressing) + if progressingCondition != nil && progressingCondition.Reason != olmv1.ReasonSucceeded { errMsg = progressingCondition.Message return false, nil } - if !meta.IsStatusConditionPresentAndEqual(clusterExtension.Status.Conditions, ocv1.TypeInstalled, metav1.ConditionTrue) { + if !meta.IsStatusConditionPresentAndEqual(clusterExtension.Status.Conditions, olmv1.TypeInstalled, metav1.ConditionTrue) { return false, nil } return true, nil @@ -118,13 +148,13 @@ func (i *ExtensionInstall) waitForExtensionInstall(ctx context.Context) (*ocv1.C } func (i *ExtensionInstall) cleanup(ctx context.Context) error { - clusterExtension := &ocv1.ClusterExtension{ + clusterExtension := &olmv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Name: i.ExtensionName, }, } if err := waitForDeletion(ctx, i.config.Client, clusterExtension); err != nil { - return fmt.Errorf("delete clusterextension %q: %v", i.ExtensionName, err) + return fmt.Errorf("delete clusterextension %q: %w", i.ExtensionName, err) } return nil } diff --git a/internal/pkg/v1/action/extension_update.go b/internal/pkg/v1/action/extension_update.go index 4ef40324..85e02230 100644 --- a/internal/pkg/v1/action/extension_update.go +++ b/internal/pkg/v1/action/extension_update.go @@ -10,6 +10,7 @@ import ( "github.com/blang/semver/v4" 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" @@ -17,38 +18,37 @@ import ( ) type ExtensionUpdate struct { - cfg *action.Configuration + config *action.Configuration + ExtensionName string - 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 + Version string + Channels []string + CatalogSelector *metav1.LabelSelector UpgradeConstraintPolicy string Labels map[string]string IgnoreUnset bool - CleanupTimeout time.Duration + CleanupTimeout time.Duration + CRDUpgradeSafetyEnforcement string - Logf func(string, ...interface{}) + DryRun string + Output string + Logf func(string, ...interface{}) } func NewExtensionUpdate(cfg *action.Configuration) *ExtensionUpdate { return &ExtensionUpdate{ - cfg: cfg, - Logf: func(string, ...interface{}) {}, + config: cfg, + Logf: func(string, ...interface{}) {}, } } -func (ou *ExtensionUpdate) Run(ctx context.Context) (*olmv1.ClusterExtension, error) { +func (i *ExtensionUpdate) 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 { + opKey := types.NamespacedName{Name: i.ExtensionName} + if err = i.config.Client.Get(ctx, opKey, &ext); err != nil { return nil, err } @@ -56,41 +56,44 @@ func (ou *ExtensionUpdate) Run(ctx context.Context) (*olmv1.ClusterExtension, er return nil, fmt.Errorf("unrecognized source type: %q", ext.Spec.Source.SourceType) } - ou.setDefaults(ext) + i.setDefaults(ext) - if ou.Version != "" { - if _, err = semver.ParseRange(ou.Version); err != nil { + if i.Version != "" { + if _, err = semver.ParseRange(i.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) { + if !i.needsUpdate(ext) { return nil, ErrNoChange } - ou.prepareUpdatedExtension(&ext, constraintPolicy) - if err := ou.cfg.Client.Update(ctx, &ext); err != nil { + i.prepareUpdatedExtension(&ext) + if i.DryRun == DryRunAll { + if err := i.config.Client.Update(ctx, &ext, client.DryRunAll); err != nil { + return nil, err + } + return &ext, nil + } + + if err := i.config.Client.Update(ctx, &ext); err != nil { return nil, err } - if err := waitUntilExtensionStatusCondition(ctx, ou.cfg.Client, &ext, olmv1.TypeInstalled, metav1.ConditionTrue); err != nil { + if err := waitUntilExtensionStatusCondition(ctx, i.config.Client, &ext, olmv1.TypeInstalled, metav1.ConditionTrue); err != nil { return nil, fmt.Errorf("timed out waiting for extension: %w", err) } return &ext, nil } -func (ou *ExtensionUpdate) setDefaults(ext olmv1.ClusterExtension) { - if !ou.IgnoreUnset { - if ou.UpgradeConstraintPolicy == "" { - ou.UpgradeConstraintPolicy = string(olmv1.UpgradeConstraintPolicyCatalogProvided) +func (i *ExtensionUpdate) setDefaults(ext olmv1.ClusterExtension) { + if !i.IgnoreUnset { + if i.UpgradeConstraintPolicy == "" { + i.UpgradeConstraintPolicy = string(olmv1.UpgradeConstraintPolicyCatalogProvided) + } + if i.CRDUpgradeSafetyEnforcement == "" { + i.CRDUpgradeSafetyEnforcement = string(olmv1.CRDUpgradeSafetyEnforcementStrict) } return @@ -99,37 +102,48 @@ func (ou *ExtensionUpdate) setDefaults(ext olmv1.ClusterExtension) { // 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 i.Version == "" { + i.Version = catalogSrc.Version } - if len(ou.Channels) == 0 { - ou.Channels = catalogSrc.Channels + if len(i.Channels) == 0 { + i.Channels = catalogSrc.Channels } - if ou.UpgradeConstraintPolicy == "" { - ou.UpgradeConstraintPolicy = string(catalogSrc.UpgradeConstraintPolicy) + if i.UpgradeConstraintPolicy == "" { + i.UpgradeConstraintPolicy = string(catalogSrc.UpgradeConstraintPolicy) } - if len(ou.Labels) == 0 { - ou.Labels = ext.Labels + if i.CRDUpgradeSafetyEnforcement == "" && ext.Spec.Install != nil && ext.Spec.Install.Preflight != nil && + ext.Spec.Install.Preflight.CRDUpgradeSafety != nil { + i.CRDUpgradeSafetyEnforcement = string(ext.Spec.Install.Preflight.CRDUpgradeSafety.Enforcement) } - if ou.Selector == "" && catalogSrc.Selector != nil { - ou.parsedSelector = catalogSrc.Selector + if len(i.Labels) == 0 { + i.Labels = ext.Labels + } + if i.CatalogSelector == nil { + i.CatalogSelector = catalogSrc.Selector } } -func (ou *ExtensionUpdate) needsUpdate(ext olmv1.ClusterExtension, constraintPolicy olmv1.UpgradeConstraintPolicy) bool { +func (i *ExtensionUpdate) needsUpdate(ext olmv1.ClusterExtension) 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 := (catalogSrc.Selector == nil && i.CatalogSelector == nil) || + (catalogSrc.Selector != nil && i.CatalogSelector != nil && + catalogSrc.Selector.String() == i.CatalogSelector.String()) + + var crdUpgradeSafetyEnforcement string + if ext.Spec.Install != nil && ext.Spec.Install.Preflight != nil && + ext.Spec.Install.Preflight.CRDUpgradeSafety != nil { + crdUpgradeSafetyEnforcement = string(ext.Spec.Install.Preflight.CRDUpgradeSafety.Enforcement) + } + + if catalogSrc.Version == i.Version && + slices.Equal(catalogSrc.Channels, i.Channels) && + string(catalogSrc.UpgradeConstraintPolicy) == i.UpgradeConstraintPolicy && + maps.Equal(ext.Labels, i.Labels) && + crdUpgradeSafetyEnforcement == i.CRDUpgradeSafetyEnforcement && sameSelectors { return false } @@ -137,10 +151,24 @@ func (ou *ExtensionUpdate) needsUpdate(ext olmv1.ClusterExtension, constraintPol return true } -func (ou *ExtensionUpdate) 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 +func (i *ExtensionUpdate) prepareUpdatedExtension(ext *olmv1.ClusterExtension) { + existingLabels := ext.GetLabels() + if existingLabels == nil { + existingLabels = make(map[string]string) + } + if i.Labels != nil { + for k, v := range i.Labels { + if v == "" { + delete(existingLabels, k) + } else { + existingLabels[k] = v + } + } + ext.SetLabels(existingLabels) + } + ext.Spec.Source.Catalog.Version = i.Version + ext.Spec.Source.Catalog.Selector = i.CatalogSelector + ext.Spec.Source.Catalog.Channels = i.Channels + ext.Spec.Source.Catalog.UpgradeConstraintPolicy = olmv1.UpgradeConstraintPolicy(i.UpgradeConstraintPolicy) + ext.Spec.Install.Preflight.CRDUpgradeSafety.Enforcement = olmv1.CRDUpgradeSafetyEnforcement(i.CRDUpgradeSafetyEnforcement) } diff --git a/internal/pkg/v1/action/extension_update_test.go b/internal/pkg/v1/action/extension_update_test.go index 4e33de5e..a32f2349 100644 --- a/internal/pkg/v1/action/extension_update_test.go +++ b/internal/pkg/v1/action/extension_update_test.go @@ -39,7 +39,7 @@ var _ = Describe("ExtensionUpdate", func() { cfg := setupEnv() updater := internalaction.NewExtensionUpdate(&cfg) - updater.Package = "does-not-exist" + updater.ExtensionName = "does-not-exist" ext, err := updater.Run(context.TODO()) Expect(err).NotTo(BeNil()) @@ -51,7 +51,7 @@ var _ = Describe("ExtensionUpdate", func() { cfg := setupEnv(buildExtension("test", withSourceType("unknown"))) updater := internalaction.NewExtensionUpdate(&cfg) - updater.Package = "test" + updater.ExtensionName = "test" ext, err := updater.Run(context.TODO()) Expect(err).NotTo(BeNil()) @@ -63,11 +63,12 @@ var _ = Describe("ExtensionUpdate", func() { cfg := setupEnv(buildExtension( "test", withSourceType(olmv1.SourceTypeCatalog), + withCRDUpgradePolicy(string(olmv1.CRDUpgradeSafetyEnforcementStrict)), withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided))), ) updater := internalaction.NewExtensionUpdate(&cfg) - updater.Package = "test" + updater.ExtensionName = "test" ext, err := updater.Run(context.TODO()) Expect(err).NotTo(BeNil()) @@ -80,13 +81,14 @@ var _ = Describe("ExtensionUpdate", func() { "test", withSourceType(olmv1.SourceTypeCatalog), withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided)), + withCRDUpgradePolicy(string(olmv1.CRDUpgradeSafetyEnforcementStrict)), withChannels("a", "b"), withLabels(map[string]string{"c": "d"}), withVersion("10.0.4"), )) updater := internalaction.NewExtensionUpdate(&cfg) - updater.Package = "test" + updater.ExtensionName = "test" updater.IgnoreUnset = true ext, err := updater.Run(context.TODO()) @@ -99,11 +101,12 @@ var _ = Describe("ExtensionUpdate", func() { cfg := setupEnv(buildExtension( "test", withSourceType(olmv1.SourceTypeCatalog), + withCRDUpgradePolicy(string(olmv1.CRDUpgradeSafetyEnforcementStrict)), withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided))), ) updater := internalaction.NewExtensionUpdate(&cfg) - updater.Package = "test" + updater.ExtensionName = "test" updater.Version = "10-4" ext, err := updater.Run(context.TODO()) @@ -116,6 +119,7 @@ var _ = Describe("ExtensionUpdate", func() { testExt := buildExtension( "test", withSourceType(olmv1.SourceTypeCatalog), + withCRDUpgradePolicy(string(olmv1.CRDUpgradeSafetyEnforcementStrict)), withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided)), ) cfg := setupEnv(testExt) @@ -124,7 +128,7 @@ var _ = Describe("ExtensionUpdate", func() { cancel() updater := internalaction.NewExtensionUpdate(&cfg) - updater.Package = "test" + updater.ExtensionName = "test" updater.Version = "10.0.4" updater.Channels = []string{"a", "b"} updater.Labels = map[string]string{"c": "d"} @@ -140,6 +144,7 @@ var _ = Describe("ExtensionUpdate", func() { testExt := buildExtension( "test", withSourceType(olmv1.SourceTypeCatalog), + withCRDUpgradePolicy(string(olmv1.CRDUpgradeSafetyEnforcementNone)), withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided)), ) cfg := setupEnv(testExt, buildExtension("test2"), buildExtension("test3")) @@ -152,11 +157,12 @@ var _ = Describe("ExtensionUpdate", func() { }() updater := internalaction.NewExtensionUpdate(&cfg) - updater.Package = "test" + updater.ExtensionName = "test" updater.Version = "10.0.4" updater.Channels = []string{"a", "b"} updater.Labels = map[string]string{"c": "d"} updater.UpgradeConstraintPolicy = string(olmv1.UpgradeConstraintPolicySelfCertified) + updater.CRDUpgradeSafetyEnforcement = string(olmv1.CRDUpgradeSafetyEnforcementStrict) ext, err := updater.Run(context.TODO()) Expect(err).To(BeNil()) diff --git a/internal/pkg/v1/action/helpers.go b/internal/pkg/v1/action/helpers.go index 3a76ab18..8e3be292 100644 --- a/internal/pkg/v1/action/helpers.go +++ b/internal/pkg/v1/action/helpers.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/briandowns/spinner" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -17,6 +18,7 @@ import ( ) const pollInterval = 250 * time.Millisecond +const DryRunAll = "All" func objectKeyForObject(obj client.Object) types.NamespacedName { return types.NamespacedName{ @@ -32,6 +34,10 @@ func waitUntilCatalogStatusCondition( conditionType string, conditionStatus metav1.ConditionStatus, ) error { + fmt.Printf("waiting for ClusterCatalog %q to become healthy...\n", catalog.Name) + s := spinner.New(spinner.CharSets[1], 100*time.Millisecond) + s.Start() + defer s.Stop() opKey := objectKeyForObject(catalog) return wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) { if err := cl.Get(conditionCtx, opKey, catalog); err != nil { @@ -54,6 +60,10 @@ func waitUntilExtensionStatusCondition( conditionType string, conditionStatus metav1.ConditionStatus, ) error { + s := spinner.New(spinner.CharSets[1], 100*time.Millisecond) + s.Prefix = fmt.Sprintf("waiting for ClusterExtension %q to become healthy...", extension.Name) + s.Start() + defer s.Stop() opKey := objectKeyForObject(extension) return wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) { if err := cl.Get(conditionCtx, opKey, extension); err != nil { @@ -70,6 +80,10 @@ func waitUntilExtensionStatusCondition( } func deleteWithTimeout(cl deleter, obj client.Object, timeout time.Duration) error { + s := spinner.New(spinner.CharSets[1], 100*time.Millisecond) + s.Prefix = fmt.Sprintf("deleting %s...", obj.GetObjectKind().GroupVersionKind().Kind) + s.Start() + defer s.Stop() ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -81,6 +95,10 @@ func deleteWithTimeout(cl deleter, obj client.Object, timeout time.Duration) err } func waitForDeletion(ctx context.Context, cl getter, objs ...client.Object) error { + s := spinner.New(spinner.CharSets[1], 100*time.Millisecond) + s.Prefix = fmt.Sprintf("waiting for delete %s...", objs[0].GetObjectKind().GroupVersionKind().Kind) + s.Start() + defer s.Stop() for _, obj := range objs { lowerKind := strings.ToLower(obj.GetObjectKind().GroupVersionKind().Kind) key := objectKeyForObject(obj) @@ -92,7 +110,7 @@ func waitForDeletion(ctx context.Context, cl getter, objs ...client.Object) erro } return false, nil }); err != nil { - return fmt.Errorf("wait for %s %q deleted: %v", lowerKind, key.Name, err) + return fmt.Errorf("wait for %s %q deleted: %w", lowerKind, key.Name, err) } } return nil diff --git a/internal/pkg/v1/client/port_forward.go b/internal/pkg/v1/client/port_forward.go index aab6e42f..3a1b64f3 100644 --- a/internal/pkg/v1/client/port_forward.go +++ b/internal/pkg/v1/client/port_forward.go @@ -2,12 +2,10 @@ package client import ( "context" - "crypto/rand" "crypto/tls" "crypto/x509" "fmt" "io" - "math/big" "net/http" "net/url" "os" @@ -15,6 +13,7 @@ import ( "strings" corev1 "k8s.io/api/core/v1" + discoveryv1 "k8s.io/api/discovery/v1" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/client-go/rest" "k8s.io/client-go/tools/portforward" @@ -172,7 +171,8 @@ func (c *portForwardClientV1) All(ctx context.Context, cc *olmv1.ClusterCatalog) // Get a pod for a given service func (c *portForwardClient) getPodAndPortForService(ctx context.Context, namespace, serviceName string, servicePort int64) (string, int, error) { svc := corev1.Service{} - if err := c.cl.Get(ctx, client.ObjectKey{Name: serviceName, Namespace: namespace}, &svc); err != nil { + svcKey := client.ObjectKey{Name: serviceName, Namespace: namespace} + if err := c.cl.Get(ctx, svcKey, &svc); err != nil { return "", -1, err } @@ -187,29 +187,24 @@ func (c *portForwardClient) getPodAndPortForService(ctx context.Context, namespa return "", -1, fmt.Errorf("service %q has no port %q", serviceName, servicePort) } - endpoints := corev1.Endpoints{} - if err := c.cl.Get(ctx, client.ObjectKey{Name: serviceName, Namespace: namespace}, &endpoints); err != nil { + ep := discoveryv1.EndpointSliceList{} + err := c.cl.List(ctx, &ep, client.MatchingLabels{discoveryv1.LabelServiceName: serviceName}, client.InNamespace(namespace)) + if err != nil { return "", -1, err } - readyAddresses := []corev1.EndpointAddress{} - for _, subset := range endpoints.Subsets { - readyAddresses = append(readyAddresses, subset.Addresses...) + var pods []string + for _, e := range ep.Items { + for _, a := range e.Endpoints { + pods = append(pods, a.TargetRef.Name) + } } - if len(readyAddresses) == 0 { - return "", -1, fmt.Errorf("no endpoints ready for service %s/%s", namespace, serviceName) + if len(pods) == 0 { + return "", -1, fmt.Errorf("no pods ready for service %q", svcKey) } - randAddress, err := rand.Int(rand.Reader, big.NewInt(int64(len(readyAddresses)))) - if err != nil { - return "", -1, err - } - - address := readyAddresses[randAddress.Int64()] - podName := address.TargetRef.Name - // Select the first pod (or you could add load balancing logic here) - return podName, podPort, nil + return pods[0], podPort, nil } // Port forwarding logic to connect to a pod diff --git a/olmv1.md b/olmv1.md index 5fb83133..6b1422ed 100644 --- a/olmv1.md +++ b/olmv1.md @@ -11,7 +11,7 @@ Within the repository, these are defined in `internal/cmd/olmv1.go`, which in tu ```bash $ kubectl operator olmv1 --help -Manage extensions via `olmv1` in a cluster from the command line. +Manage OLMv1 resources like clusterextensions and clustercatalogs from the command line. Usage: operator olmv1 [command] @@ -21,6 +21,7 @@ Available Commands: delete Delete a resource get Display one or many resource(s) install Install a resource + search Search for packages update Update a resource ``` @@ -58,11 +59,13 @@ Aliases: catalog, catalogs Flags: - --available true means that the catalog should be active and serving data (default true) - --cleanup-timeout duration the amount of time to wait before cancelling cleanup after a failed creation attempt (default 1m0s) - --labels stringToString labels that will be added to the catalog (default []) - --priority int32 priority determines the likelihood of a catalog being selected in conflict scenarios - --source-poll-interval-minutes int catalog source polling interval [in minutes] (default 10) + --available string determines whether a catalog should be active and serving data. Setting the flag to false means the catalog will not serve its contents. + --cleanup-timeout duration the amount of time to wait before cancelling cleanup after a failed creation attempt. (default 1m0s) + --dry-run string display the object that would be sent on a request without applying it. One of: (All) + --labels stringToString labels to add to the catalog. Set a label's value as empty to remove it. (default []) + -o, --output string output format for dry-run manifests. One of: (json, yaml) + --priority int32 relative priority of the catalog among all on-cluster catalogs for installing or updating packages. A higher number equals greater priority; negative values indicate less priority than the default. + --source-poll-interval-minutes int the interval in minutes to poll the catalog's source image for new content. Only valid for tag based source image references. Set to 0 or -1 to disable polling. ``` The flags allow for setting most mutable fields: @@ -71,8 +74,10 @@ The flags allow for setting most mutable fields: - `--labels`: Additional labels to add to the newly created `ClusterCatalog` as `key=value` pairs. This flag may be specified multiple times. - `--priority`: Integer priority used for ordering `ClusterCatalogs` in case two extension packages have the same name across `ClusterCatalogs`, with a higher value indicating greater relative priority. Default: 0 - `--source-poll-interval-minutes`: The polling interval to check for changes if the `ClusterCatalog` source image provided is not a digest based image, i.e, if it is referenced by tag. Set to 0 to disable polling. Default: 10 +- `--dry-run`: Generate the manifest that would be applied with the command without actually applying it to the cluster. +- `--output`: The format for displaying manifests if `--dry-run` is specified. -The command requires at minimum a resource name and image reference: +The command requires at minimum a resource name and image reference. ```bash $ kubectl operator olmv1 create catalog mycatalog myorg/mycatalogrepo:tag ``` @@ -106,12 +111,18 @@ Usage: operator olmv1 install extension [flags] Flags: - -c, --channels strings channels which would be used for getting updates, e.g, --channels "stable,dev-preview,preview" - -d, --cleanup-timeout duration the amount of time to wait before cancelling cleanup after a failed creation attempt (default 1m0s) - -n, --namespace string namespace to install the operator in - -p, --package-name string package name of the operator to install - -s, --service-account string service account name to use for the extension installation (default "default") - -v, --version string version (or version range) from which to resolve bundles + -l, --catalog-selector string selector (label query) to filter catalogs to search for the package, supports '=', '==', '!=', 'in', 'notin'.(e.g. -l key1=value1,key2=value2,key3 in (value3)). Matching objects must satisfy all of the specified label constraints. + -c, --channels strings channels to be used for getting updates. If omitted, extension versions in all channels will be considered for upgrades. When used with '--version', only package versions meeting both constraints will be considered. + --cleanup-timeout duration the amount of time to wait before cancelling cleanup after a failed creation attempt (default 1m0s) + --crd-upgrade-safety-enforcement string policy for preflight CRD Upgrade safety checks. One of: [Strict None], (default Strict) + --dry-run string display the object that would be sent on a request without applying it. One of: (All) + --labels stringToString labels to add to the extension. Set a label's value as empty to remove that label (default []) + -n, --namespace string namespace to install the operator in (default "olmv1-system") + -o, --output string output format for dry-run manifests. One of: (json, yaml) + -p, --package-name string package name of the operator to install. Required. + -s, --service-account string service account name to use for the extension installation (default "default") + --upgrade-constraint-policy string controls whether the package upgrade path(s) defined in the catalog are enforced. One of [CatalogProvided SelfCertified], (default CatalogProvided) + -v, --version string version (or version range) in semver format to limit the allowable package versions to. If used with '--channel', only package versions meeting both constraints will be considered. ``` The flags allow for setting most mutable fields: @@ -119,8 +130,14 @@ The flags allow for setting most mutable fields: - **`-p`, `--package-name`**: Name of the package to install. **Required** - `-c`, `--channels`: An optional list of channels within a package to restrict searches for an installable version to. - `-d`, `--cleanup-timeout`: If a `ClusterExtension` creation attempt fails due to the resource never becoming healthy, `olmv1` cleans up by deleting the failed resource, with a timeout specified by `--cleanup-timeout`. Default: 1 minute (1m) -- `-s`, `--service-account`: Name of the service account present in the namespace specified by `--namespace` to use for creating and managing resources for the new `ClusterExtension`. -- `-v`, `--version`: A version or version range to restrict search for an installable version to. +- `-s`, `--service-account`: Name of the ServiceAccount present in the namespace specified by `--namespace` to use for creating and managing resources for the new `ClusterExtension`. If not specified, the command expects a ServiceAccount `default` to be present in the namespace provided by `--namespace` with the required permissions to create and manage all the resources the `ClusterExtension` may require. +- `-v`, `--version`: A version or version range to restrict search for an installable version to. If specified along with `--channels`, only versions in the version range belonging to one or more of the channels specified will be allowed. +- `--dry-run`: Generate the manifest that would be applied with the command without actually applying it to the cluster. +- `--output`: The format for displaying manifests if `--dry-run` is specified. +- `--catalog-selector`: Limit the sources that the package specified by the ClusterExtension can be installed from to ClusterCatalogs matching the provided label selector. Only useful if the ClusterCatalogs on cluster have been labelled meaningfully, such as by maturity, provider etc. eg: +- `--labels`: Labels to be added to the new ClusterExtension. +- `--upgrade-constraint-policy`:Controls how upgrade versions are picked for the ClusterExtension. If set to `SelfCertified`, upgrade to any version of the package (even earlier versions, i.e, downgrades) are allowed. If set to `CatalogProvided`, only upgrade paths mentioned in the ClusterCatalog are allowed for the package. Note that `SelfCertified` upgrades may be unsafe and lead to data loss. +- `--crd-upgrade-safety-enforcement`: configures pre-flight CRD Upgrade safety checks. If set to `Strict`, an upgrade will be blocked if it has breaking changes to a CRD on cluster. If set to `None`, this pre-flight check is skipped, which may cause unsafe changes during installs and upgrades.

@@ -164,7 +181,9 @@ Aliases: catalog, catalogs [catalog_name] Flags: - --all delete all catalogs + -a, --all delete all catalogs + --dry-run string display the object that would be sent on a request without applying it. One of: (All) + -o, --output string output format for dry-run manifests. One of: (json, yaml) ``` The command requires exactly one of a resource name or the `--all` flag: @@ -173,6 +192,13 @@ $ kubectl operator olmv1 delete catalog mycatalog $ kubectl operator olmv1 delete catalog --all ``` +Using `--all` along with a resource name is invalid. For example, the following command is invalid: +```bash +$ kubectl operator olmv1 delete catalog mycatalog --all + +failed to delete catalog: cannot specify both --all and a catalog name +``` +
### olmv1 delete extension @@ -187,7 +213,9 @@ Aliases: extension, extensions [extension_name] Flags: - -a, --all delete all extensions + -a, --all delete all extensions + --dry-run string display the object that would be sent on a request without applying it. One of: (All) + -o, --output string output format for dry-run manifests. One of: (json, yaml) ``` The command requires exactly one of a resource name or the `--all` flag: @@ -196,6 +224,13 @@ $ kubectl operator olmv1 delete extension myex $ kubectl operator olmv1 delete extension --all ``` +Using `--all` along with a resource name is invalid. For example, the following command is invalid: +```bash +$ kubectl operator olmv1 delete extension myex --all + +failed to delete extension: cannot specify both --all and an extension name +``` +

@@ -228,32 +263,37 @@ Usage: operator olmv1 update catalog [flags] Flags: - --availability-mode string available means that the catalog should be active and serving data - --ignore-unset when enabled, any unset flag value will not be changed. Disabling this flag replaces all other unset or empty values with a default value, overwriting any values on the existing CR (default true) - --image string Image reference for the catalog source. Leave unset to retain the current image. - --labels stringToString labels that will be added to the catalog (default []) - --priority int32 priority determines the likelihood of a catalog being selected in conflict scenarios - --source-poll-interval-minutes int catalog source polling interval [in minutes]. Set to 0 or -1 to remove the polling interval. (default 5) + --available string determines whether a catalog should be active and serving data. Setting the flag to false means the catalog will not serve its contents. Set to true by default for new catalogs. + --dry-run string display the object that would be sent on a request without applying it. One of: (All) + --ignore-unset set to false to revert all values not specifically set with flags in the command to their default as defined by the clustercatalog customresoucedefinition. (default true) + --image string image reference for the catalog source. Leave unset to retain the current image. + --labels stringToString labels to add to the catalog. Set a label's value as empty to remove it. (default []) + -o, --output string output format for dry-run manifests. One of: (json, yaml) + --priority int32 relative priority of the catalog among all on-cluster catalogs for installing or updating packages. A higher number equals greater priority; negative values indicate less priority than the default. + --source-poll-interval-minutes int the interval in minutes to poll the catalog's source image for new content. Only valid for tag based source image references. Set to 0 or -1 to disable polling. + ``` The flags allow for setting most mutable fields: - `--ignore-unset`: Sets the behavior of unspecified or empty flags, whether they should be ignored, preserving the current value on the resource, or treated as valid and used to set the field values to their default value. -- `--availablity-mode`: Sets whether the `ClusterCatalog` should be actively serving and making its contents available on cluster. Valid values: `Available`|`Unavailable`. +- `--available`: Sets whether the `ClusterCatalog` should be actively serving and making its contents available on cluster. Valid values: `true`|`false`. - `--image`: Update the image reference for the `ClusterCatalog`. - `--labels`: Additional labels to add to the `ClusterCatalog` as `key=value` pairs. This flag may be specified multiple times. Setting the value of a label to an empty string deletes the label from the resource. - `--priority`: Integer priority used for ordering `ClusterCatalogs` in case two extension packages have the same name across `ClusterCatalogs`, with a higher value indicating greater relative priority. - `--source-poll-interval-minutes`: The polling interval to check for changes if the `ClusterCatalog` source image provided is not a digest based image, i.e, if it is referenced by tag. Set to 0 or -1 to disable polling. +- `--dry-run`: Generate the manifest that would be applied with the command without actually applying it to the cluster. +- `--output`: The format for displaying manifests if `--dry-run` is specified.
To update specific fields on a catalog, like adding a new label or setting availability, the required flag may be used on its own: ```bash -$ kubectl operator olmv1 update catalog mycatalog --label newlabel=newkey --label labeltoremove= -$ kubectl operator olmv1 update catalog --availability-mode Available +$ kubectl operator olmv1 update catalog mycatalog --labels newlabel=newkey --labels labeltoremove= +$ kubectl operator olmv1 update catalog --available true ``` To reset a specific field on a catalog to its default, the value needs to be provided or all existing fields must be specified with `--ignore-unset`. ```bash -$ kubectl operator olmv1 update catalog mycatalog --availability-mode Available +$ kubectl operator olmv1 update catalog mycatalog --available true $ kubectl operator olmv1 update catalog mycatalog --ignore-unset=false --priority=10 --source-poll-interval-minutes=-1 --image=myorg/mycatalogrepo:tag --labels existing1=labelvalue1 --labels existing2=labelvalue2 ``` @@ -268,31 +308,43 @@ Usage: operator olmv1 update extension [flags] Flags: - --channels stringArray desired channels for extension versions. AND operation with version. If empty or not specified, all available channels will be taken into consideration - --ignore-unset when enabled, any unset flag value will not be changed. Disabling this flag replaces all other unset or empty values with a default value, overwriting any values on the existing CR (default true) - --labels stringToString labels that will be set on the extension (default []) - --selector string filters the set of catalogs used in the bundle selection process. Empty means that all catalogs will be used in the bundle selection process - --upgrade-constraint-policy string controls whether the upgrade path(s) defined in the catalog are enforced. One of CatalogProvided|SelfCertified, Default: CatalogProvided - --version string desired extension version (single or range) in semVer format. AND operation with channels + -l, --catalog-selector string selector (label query) to filter catalogs to search for the package, supports '=', '==', '!=', 'in', 'notin'.(e.g. -l key1=value1,key2=value2,key3 in (value3)). Matching objects must satisfy all of the specified label constraints. + -c, --channels strings channels to be used for getting updates. If omitted, extension versions in all channels will be considered for upgrades. When used with '--version', only package versions meeting both constraints will be considered. + --crd-upgrade-safety-enforcement string policy for preflight CRD Upgrade safety checks. One of: [Strict None], (default Strict) + --dry-run string display the object that would be sent on a request without applying it. One of: (All) + --ignore-unset set to false to revert all values not specifically set with flags in the command to their default as defined by the clusterextension customresoucedefinition. (default true) + --labels stringToString labels to add to the extension. Set a label's value as empty to remove that label (default []) + -o, --output string output format for dry-run manifests. One of: (json, yaml) + --upgrade-constraint-policy string controls whether the package upgrade path(s) defined in the catalog are enforced. One of [CatalogProvided SelfCertified], (default CatalogProvided) + -v, --version string version (or version range) in semver format to limit the allowable package versions to. If used with '--channel', only package versions meeting both constraints will be considered. ``` The flags allow for setting most mutable fields: - `--ignore-unset`: Sets the behavior of unspecified or empty flags, whether they should be ignored, preserving the current value on the resource, or treated as valid and used to set the field values to their default value. -- `-v`, `--version`: A version or version range to restrict search for a version upgrade. +- `-v`, `--version`: A version or version range to restrict search for a version upgrade. If specified along with `--channels`, only versions in the version range belonging to one or more of the channels specified will be allowed. + Valid version range format examples: + - Exact: `--version 1.2.3` + - Range: `--version ">=1.0.0 <2.0.0"` + - Wildcard: `--version 1.2.x` (any patch version of 1.2) + - Minimum: `--version ">=1.5.0"` - `-c`, `--channels`: An optional list of channels within a package to restrict searches for updates. If empty or unspecified, no channel restrictions apply while searching for valid package versions for extension updates. - `--upgrade-constraint-policy`: Specifies upgrade selection behavior. Valid values: `CatalogProvided|SelfCertified`. `SelfCertified` can be used to override upgrade graphs within a catalog and upgrade to any version at the risk of using non-standard upgrade paths. `CatalogProvided` restricts upgrades to standard paths between versions explicitly allowed within the `ClusterCatalog`. - `--labels`: Additional labels to add to the `ClusterExtension` as `key=value` pairs. This flag may be specified multiple times. Setting the value of a label to an empty string deletes the label from the resource. +- `--dry-run`: Generate the manifest that would be applied with the command without actually applying it to the cluster. +- `--output`: The format for displaying manifests if `--dry-run` is specified. +- `--catalog-selector`: Limit the sources that the package specified by the ClusterExtension can be installed from to ClusterCatalogs matching the provided label selector. +- `--crd-upgrade-safety-enforcement`: configures pre-flight CRD Upgrade safety checks. If set to `Strict`, an upgrade will be blocked if it has breaking changes to a CRD on cluster. If set to `None`, this pre-flight check is skipped, which may cause unsafe changes during installs and upgrades. To update specific fields on an extension, like adding a new label or updating desired version range, the required flag may be used on its own: ```bash -$ kubectl operator olmv1 update extension myex --label newlabel=newkey --label labeltoremove= +$ kubectl operator olmv1 update extension myex --labels newlabel=newkey --labels labeltoremove= $ kubectl operator olmv1 update extension myex --version 1.2.x ``` To reset a specific field to its default on an extension, the value needs to be provided or all existing fields must be specified with `--ignore-unset`. ```bash -$ kubectl operator olmv1 update catalog --upgrade-constraint-policy CatalogProvided -$ kubectl operator olmv1 update catalog --ignore-unset=false --version=1.0.x --channels=stable,candidate --labels existing1=labelvalue1 --labels existing2=labelvalue2 +$ kubectl operator olmv1 update extension --upgrade-constraint-policy CatalogProvided +$ kubectl operator olmv1 update extension --ignore-unset=false --version=1.0.x --channels=stable,candidate --labels existing1=labelvalue1 --labels existing2=labelvalue2 ```
@@ -326,8 +378,16 @@ Usage: Aliases: catalog, catalogs + +Flags: + -o, --output string output format. One of: (json, yaml) + -l, --selector string selector (label query) to filter on, supports '=', '==', '!=', 'in', 'notin'.(e.g. -l key1=value1,key2=value2,key3 in (value3)). Matching objects must satisfy all of the specified label constraints. ``` +The flags allow for limiting or formatting output: +- `--output`: The format for displaying the resources. Valid values: json, yaml. +- `--selector`: Limit the resources listed to those matching the provided label selector. + ```bash $ kubectl operator olmv1 get catalog NAME AVAILABILITY PRIORITY LASTUNPACKED SERVING AGE @@ -346,10 +406,74 @@ Usage: Aliases: extension, extensions [extension_name] + +Flags: + -o, --output string output format. One of: (json, yaml) + -l, --selector string selector (label query) to filter on, supports '=', '==', '!=', 'in', 'notin'.(e.g. -l key1=value1,key2=value2,key3 in (value3)). Matching objects must satisfy all of the specified label constraints. ``` +The flags allow for limiting or formatting output: +- `--output`: The format for displaying the resources. Valid values: json, yaml. +- `--selector`: Limit the resources listed to those matching the provided label selector. + ```bash $ kubectl operator olmv1 get extension NAME INSTALLED BUNDLE VERSION SOURCE TYPE INSTALLED PROGRESSING AGE test-operator prometheusoperator.0.47.0 0.47.0 Community Operators Index True False 44m -``` \ No newline at end of file +``` + +## olmv1 search +Search available sources for packages or versions. Currently supports searching ClusterCatalogs + +```bash +Search one or all available catalogs for packages or versions + +Usage: + operator olmv1 search [command] + +Available Commands: + catalog Search catalogs for installable operators matching parameters +``` +
+ +### olmv1 search catalog +Search one or all available catalogs for packages or versions + +```bash +kubectl-operator olmv1 search catalog --help +Search catalogs for installable operators matching parameters + +Usage: + operator olmv1 search catalog [flags] + +Aliases: + catalog, catalogs + +Flags: + --catalog string name of the catalog to search. If not provided, all available catalogs are searched. + --catalogd-namespace string namespace for the catalogd controller (default "olmv1-system") + --list-versions list all versions available for each package + -o, --output string output format. One of: (yaml|json) + --package string search for package by name. If empty, all available packages will be listed + -l, --selector string selector (label query) to filter catalogs on, supports '=', '==', and '!=' + --timeout string timeout for fetching catalog contents (default "5m") +``` + +The flags allow for limiting or formatting output: +- `--catalog`: When non-empty, limits the listed packages or bundles to only those from the catalog provided through this flag +- `--selector`: When non-empty, limits the listed packages or bundles to only the ones from ClusterCatalogs matching the label selector provided through this flag. +- `--timeout`: Time to wait before aborting the command +- `--catalogd-namespace`: By default, the catalogd-controller is installed in `olmv1-system`. If the catalogd-controller's service is present in another namespace, it can be specified through this flag. +- `--list-versions`: By default, the search command shows the package name, the source ClusterCatalog, the package maintainer and the valid channels available on the package, if any. If this flag is specified, it lists the versions available for each package instead of listing channels. +- `--package`: If non-empty, limit the listed packages and bundles to only those with the package name specified by this flag. +- `--output`: This flag allows the output to be provided in a specific format. Currently support yaml, json. If empty, provides a simplified table of packages instead. + +```bash +$ kubectl operator olmv1 search catalog + +PACKAGE CATALOG PROVIDER CHANNELS +accuknox-operator operatorhubio stable +ack-acm-controller operatorhubio alpha +ack-acmpca-controller operatorhubio alpha +... +```