diff --git a/go.mod b/go.mod index ccd0537b..9f4da0d2 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,9 @@ require ( 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 + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.1 // indirect + github.com/go-git/go-git/v5 v5.13.1 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -71,14 +74,18 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/joelanford/ignore v0.1.1 // indirect 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/mailru/easyjson v0.9.0 // 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 github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/sequential v0.5.0 // indirect @@ -87,6 +94,7 @@ require ( 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 + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/nxadm/tail v1.4.8 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/runtime-spec v1.2.0 // indirect @@ -116,6 +124,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect diff --git a/go.sum b/go.sum index c8fc3cc6..bf102d4d 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/Microsoft/hcsshim v0.12.9 h1:2zJy5KA+l0loz1HzEGqyNnjd3fyZA31ZBCGKacp6 github.com/Microsoft/hcsshim v0.12.9/go.mod h1:fJ0gkFAna6ukt0bLdKB8djt4XIJhF/vEPuoIWYVvZ8Y= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -103,6 +105,12 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.1 h1:u+dcrgaguSSkbjzHwelEjc0Yj300NUevrrPphk/SoRA= +github.com/go-git/go-billy/v5 v5.6.1/go.mod h1:0AsLr1z2+Uksi4NlElmMblP5rPcDZNRCD8ujZCRR2BE= +github.com/go-git/go-git/v5 v5.13.1 h1:DAQ9APonnlvSWpvolXWIuV6Q6zXy2wHbN4cVlNR5Q+M= +github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0qu3XXXVixc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -166,6 +174,8 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= @@ -184,6 +194,10 @@ github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/joelanford/ignore v0.1.1 h1:vKky5RDoPT+WbONrbQBgOn95VV/UPh4ejlyAbbzgnQk= +github.com/joelanford/ignore v0.1.1/go.mod h1:8eho/D8fwQ3rIXrLwE23AaeaGDNXqLE9QJ3zJ4LIPCw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -204,6 +218,8 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW 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= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/capability v0.3.0 h1:kEP+y6te0gEXIaeQhIi0s7vKs/w0RPoH1qPa6jROcVg= github.com/moby/sys/capability v0.3.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= @@ -221,6 +237,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -471,6 +489,8 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/cmd/internal/olmv1/catalog_search.go b/internal/cmd/internal/olmv1/catalog_search.go new file mode 100644 index 00000000..2178690c --- /dev/null +++ b/internal/cmd/internal/olmv1/catalog_search.go @@ -0,0 +1,62 @@ +package olmv1 + +import ( + "os" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" + v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" + "github.com/operator-framework/kubectl-operator/pkg/action" +) + +// NewCatalogSearchCmd handles get commands in the form of: +// catalog(s) - this will either list all packages +// from available catalogs if no catalog has been provided. +// The results are restricted to only the contents of specific +// catalogs if either specified by name or label selector. +// results may also be restricted to the contents of a single +// package by name across one or more catalogs. +func NewCatalogSearchCmd(cfg *action.Configuration) *cobra.Command { + i := v1action.NewCatalogSearch(cfg) + i.Logf = log.Printf + + cmd := &cobra.Command{ + Use: "catalog", + Aliases: []string{"catalogs"}, + Args: cobra.RangeArgs(0, 1), + Short: "Search catalogs for installable operators matching parameters", + Run: func(cmd *cobra.Command, args []string) { + catalogContents, err := i.Run(cmd.Context()) + if err != nil { + log.Fatalf("failed querying catalog(s): %v", err) + } + switch i.OutputFormat { + case "", "table": + 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) + } + }, + } + bindCatalogSearchFlags(cmd.Flags(), i) + + 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.") +} diff --git a/internal/cmd/internal/olmv1/printing.go b/internal/cmd/internal/olmv1/printing.go index 8d62181b..18e49683 100644 --- a/internal/cmd/internal/olmv1/printing.go +++ b/internal/cmd/internal/olmv1/printing.go @@ -3,16 +3,22 @@ package olmv1 import ( "cmp" "fmt" + "io" "os" "slices" + "sort" + "strings" "text/tabwriter" "time" "github.com/blang/semver/v4" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/duration" + "k8s.io/apimachinery/pkg/util/json" 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) { @@ -63,6 +69,125 @@ func printFormattedCatalogs(catalogs ...olmv1.ClusterCatalog) { _ = tw.Flush() } +func printFormattedDeclCfg(w io.Writer, catalogDcfg map[string]*declcfg.DeclarativeConfig, listVersions bool) { + var printedHeaders bool + tw := tabwriter.NewWriter(w, 3, 4, 2, ' ', 0) + sortedCatalogs := []string{} + for catalogName := range catalogDcfg { + sortedCatalogs = append(sortedCatalogs, catalogName) + } + sort.Strings(sortedCatalogs) + for _, catalogName := range sortedCatalogs { + dcfg := catalogDcfg[catalogName] + type dcfgPrintMeta struct { + provider string + channels []string + versions []semver.Version + } + pkgProviders := map[string]*dcfgPrintMeta{} + sort.SliceStable(dcfg.Packages, func(i, j int) bool { + return dcfg.Packages[i].Name < dcfg.Packages[j].Name + }) + + if listVersions { + for _, b := range dcfg.Bundles { + if pkgProviders[b.Package] == nil { + pkgProviders[b.Package] = &dcfgPrintMeta{ + versions: []semver.Version{}, + provider: getCSVProvider(&b), + } + } + bundleVersion, err := getBundleVersion(&b) + if err == nil { + pkgProviders[b.Package].versions = append(pkgProviders[b.Package].versions, bundleVersion) + } + } + } else { + for _, c := range dcfg.Channels { + if pkgProviders[c.Package] == nil { + pkgProviders[c.Package] = &dcfgPrintMeta{channels: []string{}} + } + pkgProviders[c.Package].channels = append(pkgProviders[c.Package].channels, c.Name) + } + } + + for _, p := range dcfg.Packages { + if listVersions { + sort.SliceStable(pkgProviders[p.Name].versions, func(i, j int) bool { + return pkgProviders[p.Name].versions[i].GT(pkgProviders[p.Name].versions[j]) + }) + for _, v := range pkgProviders[p.Name].versions { + if !printedHeaders { + _, _ = fmt.Fprint(tw, "PACKAGE\tCATALOG\tPROVIDER\tVERSION\n") + printedHeaders = true + } + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + p.Name, + catalogName, + pkgProviders[p.Name].provider, + v) + } + } else { + sort.Strings(pkgProviders[p.Name].channels) + if !printedHeaders { + _, _ = fmt.Fprint(tw, "PACKAGE\tCATALOG\tPROVIDER\tCHANNELS\n") + printedHeaders = true + } + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + p.Name, + catalogName, + pkgProviders[p.Name].provider, + strings.Join(pkgProviders[p.Name].channels, ",")) + } + } + } + if !printedHeaders { + _, _ = fmt.Fprint(tw, "No resources found.\n") + } + _ = tw.Flush() +} + +func getBundleVersion(bundle *declcfg.Bundle) (semver.Version, error) { + for _, p := range bundle.Properties { + if p.Type == property.TypePackage { + var pkgProp property.Package + if err := json.Unmarshal(p.Value, &pkgProp); err == nil && len(pkgProp.Version) > 0 { + parsedVersion, err := semver.Parse(pkgProp.Version) + if err != nil { + return semver.Version{}, err + } + return parsedVersion, nil + } + } + } + return semver.Version{}, fmt.Errorf("no version property") +} + +func getCSVProvider(bundle *declcfg.Bundle) string { + for _, csvProp := range bundle.Properties { + if csvProp.Type == property.TypeCSVMetadata { + var pkgProp property.CSVMetadata + if err := json.Unmarshal(csvProp.Value, &pkgProp); err == nil && len(pkgProp.Provider.Name) > 0 { + return pkgProp.Provider.Name + } + } + } + return "" +} + +func printDeclCfgJSON(w io.Writer, catalogDcfg map[string]*declcfg.DeclarativeConfig) { + for _, dcfg := range catalogDcfg { + _ = declcfg.WriteJSON(*dcfg, w) + } +} + +func printDeclCfgYAML(w io.Writer, catalogDcfg map[string]*declcfg.DeclarativeConfig) { + for _, dcfg := range catalogDcfg { + _ = declcfg.WriteYAML(*dcfg, w) + _, _ = w.Write([]byte("---\n")) + } +} + // sortExtensions sorts extensions in place and uses the following sorting order: // name (asc), version (desc) func sortExtensions(extensions []olmv1.ClusterExtension) { diff --git a/internal/cmd/olmv1.go b/internal/cmd/olmv1.go index 0d53e7d3..a4ce21dd 100644 --- a/internal/cmd/olmv1.go +++ b/internal/cmd/olmv1.go @@ -58,12 +58,20 @@ func newOlmV1Cmd(cfg *action.Configuration) *cobra.Command { } installCmd.AddCommand(olmv1.NewExtensionInstallCmd(cfg)) + searchCmd := &cobra.Command{ + Use: "search", + Short: "Search for packages", + Long: "Search one or all available catalogs for packages or versions", + } + searchCmd.AddCommand(olmv1.NewCatalogSearchCmd(cfg)) + cmd.AddCommand( installCmd, getCmd, createCmd, deleteCmd, updateCmd, + searchCmd, ) return cmd diff --git a/internal/pkg/v1/action/catalog_installed_get.go b/internal/pkg/v1/action/catalog_installed_get.go index 3dfa4795..2a27a391 100644 --- a/internal/pkg/v1/action/catalog_installed_get.go +++ b/internal/pkg/v1/action/catalog_installed_get.go @@ -2,7 +2,9 @@ package action import ( "context" + "fmt" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -15,6 +17,8 @@ type CatalogInstalledGet struct { config *action.Configuration CatalogName string + Selector string + Logf func(string, ...interface{}) } @@ -41,7 +45,15 @@ func (i *CatalogInstalledGet) Run(ctx context.Context) ([]olmv1.ClusterCatalog, // list var result olmv1.ClusterCatalogList - err := i.config.Client.List(ctx, &result, &client.ListOptions{}) + 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 + } + err := i.config.Client.List(ctx, &result, listOptions) return result.Items, err } diff --git a/internal/pkg/v1/action/catalog_installed_get_test.go b/internal/pkg/v1/action/catalog_installed_get_test.go index b8c7ee0a..9e74f92a 100644 --- a/internal/pkg/v1/action/catalog_installed_get_test.go +++ b/internal/pkg/v1/action/catalog_installed_get_test.go @@ -79,4 +79,23 @@ var _ = Describe("CatalogInstalledGet", func() { Expect(err).NotTo(BeNil()) Expect(catalogs).To(BeEmpty()) }) + + It("returns catalogs matching labels when specified", func() { + initCatalogs := []client.Object{ + newClusterCatalog("cat-a1"), + newClusterCatalog("cat-a2"), + newClusterCatalog("cat-b1"), + newClusterCatalog("cat-b2"), + } + initCatalogs[0].SetLabels(map[string]string{"foo": "bar"}) + initCatalogs[1].SetLabels(map[string]string{"foo": "bar"}) + + cfg := setupEnv(initCatalogs...) + + getter := internalaction.NewCatalogInstalledGet(&cfg) + getter.Selector = "foo=bar" + 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 new file mode 100644 index 00000000..0d4fc769 --- /dev/null +++ b/internal/pkg/v1/action/catalog_search.go @@ -0,0 +1,156 @@ +package action + +import ( + "context" + "fmt" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-registry/alpha/declcfg" + + catalogClient "github.com/operator-framework/kubectl-operator/internal/pkg/v1/client" + "github.com/operator-framework/kubectl-operator/pkg/action" +) + +type CatalogSearch struct { + config *action.Configuration + CatalogName string + OutputFormat string + Selector string + ListVersions bool + Package string + CatalogdNamespace string + Timeout string + + Logf func(string, ...interface{}) +} + +func NewCatalogSearch(cfg *action.Configuration) *CatalogSearch { + return &CatalogSearch{ + config: cfg, + Logf: func(string, ...interface{}) {}, + } +} + +func (i *CatalogSearch) Run(ctx context.Context) (map[string]*declcfg.DeclarativeConfig, error) { + 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) + } + i.config.Config.Timeout = catalogListTimeout + } + var catalogList []olmv1.ClusterCatalog + listCmd := NewCatalogInstalledGet(i.config) + listCmd.Selector = i.Selector + listCmd.CatalogName = i.CatalogName + result, err := listCmd.Run(ctx) + if err != nil { + return nil, err + } + for _, c := range result { + if isCatalogServing(c) { + catalogList = append(catalogList, c) + } + } + if len(catalogList) == 0 { + 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) + } + return nil, fmt.Errorf("no serving catalogs found") + } + searchClientV1 := catalogClient.NewK8sClient(i.config.Config, i.config.Client, i.CatalogdNamespace).V1() + catalogDeclCfg := map[string]*declcfg.DeclarativeConfig{} + foundPackage := len(i.Package) == 0 // whether to check for empty package query + for _, c := range catalogList { + catalogContent, err := searchClientV1.All(ctx, &c) + if err != nil { + return nil, err + } + defer catalogContent.Close() + declConfigContents, err := declcfg.LoadReader(catalogContent) + if err != nil { + return nil, err + } + if len(i.Package) == 0 { + catalogDeclCfg[c.Name] = declConfigContents + continue + } + + filteredContents := filterPackage(declConfigContents, i.Package) + + if len(filteredContents.Packages) > 0 { + catalogDeclCfg[c.Name] = filteredContents + foundPackage = true + } + } + 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) + } + if len(i.Selector) > 0 { + return nil, fmt.Errorf("package %s was not found in ClusterCatalogs matching label %s", i.Package, i.Selector) + } + return nil, fmt.Errorf("package %s was not found in any serving ClusterCatalog", i.Package) + } + return catalogDeclCfg, nil +} + +func isCatalogServing(c olmv1.ClusterCatalog) bool { + if c.Spec.AvailabilityMode != olmv1.AvailabilityModeAvailable { + return false + } + if !meta.IsStatusConditionPresentAndEqual(c.Status.Conditions, olmv1.TypeServing, metav1.ConditionTrue) { + return false + } + if c.Status.ResolvedSource == nil || c.Status.ResolvedSource.Image == nil { + return false + } + return true +} + +func filterPackage(dcfg *declcfg.DeclarativeConfig, packageName string) *declcfg.DeclarativeConfig { + filteredDeclCfg := &declcfg.DeclarativeConfig{ + Channels: []declcfg.Channel{}, + Bundles: []declcfg.Bundle{}, + Deprecations: []declcfg.Deprecation{}, + Others: []declcfg.Meta{}, + } + for _, p := range dcfg.Packages { + if p.Name == packageName { + filteredDeclCfg.Packages = []declcfg.Package{p} + break + } + } + for _, e := range dcfg.Channels { + if e.Package == packageName { + filteredDeclCfg.Channels = append(filteredDeclCfg.Channels, e) + } + } + + for _, e := range dcfg.Bundles { + if e.Package == packageName { + filteredDeclCfg.Bundles = append(filteredDeclCfg.Bundles, e) + } + } + + for _, e := range dcfg.Deprecations { + if e.Package == packageName { + filteredDeclCfg.Deprecations = append(filteredDeclCfg.Deprecations, e) + } + } + + for _, e := range dcfg.Others { + if e.Package == packageName { + filteredDeclCfg.Others = append(filteredDeclCfg.Others, e) + } + } + return filteredDeclCfg +} diff --git a/internal/pkg/v1/client/port_forward.go b/internal/pkg/v1/client/port_forward.go new file mode 100644 index 00000000..aab6e42f --- /dev/null +++ b/internal/pkg/v1/client/port_forward.go @@ -0,0 +1,274 @@ +package client + +import ( + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" + "sigs.k8s.io/controller-runtime/pkg/client" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" +) + +type Client interface { + V1() V1Client +} + +type V1Client interface { + All(ctx context.Context, cc *olmv1.ClusterCatalog) (io.ReadCloser, error) +} + +type LiveClient struct { + HTTPClient *http.Client + BaseURL *url.URL +} + +func (c *LiveClient) V1() V1Client { + return &LiveClientV1{c} +} + +type LiveClientV1 struct { + *LiveClient +} + +func (c *LiveClientV1) All(ctx context.Context, _ *olmv1.ClusterCatalog) (io.ReadCloser, error) { + allURL := c.LiveClient.BaseURL.JoinPath("api", "v1", "all").String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, allURL, nil) + if err != nil { + return nil, err + } + resp, err := c.LiveClient.HTTPClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + return resp.Body, nil +} + +func NewK8sClient(cfg *rest.Config, cl client.Client, caNamespace string) Client { + c := &portForwardClient{ + cfg: cfg, + cl: cl, + } + c.httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: c.loadKnownCAs(caNamespace), + }, + }, + } + return c +} + +type portForwardClient struct { + cfg *rest.Config + cl client.Client + httpClient *http.Client +} + +func (c *portForwardClient) V1() V1Client { + return &portForwardClientV1{c} +} + +type portForwardClientV1 struct { + *portForwardClient +} + +func (c *portForwardClientV1) All(ctx context.Context, cc *olmv1.ClusterCatalog) (io.ReadCloser, error) { + if !meta.IsStatusConditionTrue(cc.Status.Conditions, olmv1.TypeServing) { + return nil, fmt.Errorf("cluster catalog %q is not serving", cc.Name) + } + if cc.Status.URLs == nil { + return nil, fmt.Errorf("cluster catalog %q has no URLs", cc.Name) + } + baseURL, err := url.Parse(cc.Status.URLs.Base) + if err != nil { + return nil, fmt.Errorf("failed to parse ClusterCatalog URL %q: %w", cc.Status.URLs.Base, err) + } + serviceHostname := baseURL.Hostname() + servicePortStr := baseURL.Port() + if servicePortStr == "" { + switch baseURL.Scheme { + case "http": + servicePortStr = "80" + case "https": + servicePortStr = "443" + } + } + servicePort, err := strconv.ParseInt(servicePortStr, 10, 32) + if err != nil { + return nil, err + } + + labels := strings.Split(serviceHostname, ".") + if len(labels) < 2 { + return nil, fmt.Errorf("invalid base URL %q", cc.Status.URLs.Base) + } + serviceName := labels[0] + namespace := labels[1] + + // Find a pod and pod port for the given service + podName, podPort, err := c.getPodAndPortForService(ctx, namespace, serviceName, servicePort) + if err != nil { + return nil, err + } + + pf, err := c.getPortForwarder(namespace, podName, podPort) + if err != nil { + return nil, err + } + + fwdErr := make(chan error, 1) + go func() { + fwdErr <- pf.ForwardPorts() + }() + + defer pf.Close() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case err := <-fwdErr: + return nil, err + case <-pf.Ready: + } + forwardedPorts, err := pf.GetPorts() + if err != nil { + return nil, err + } + if len(forwardedPorts) != 1 { + return nil, fmt.Errorf("expected 1 forwarded port, got %d", len(forwardedPorts)) + } + localPort := forwardedPorts[0].Local + + localURL := url.URL{ + Scheme: baseURL.Scheme, + Host: fmt.Sprintf("localhost:%d", localPort), + Path: baseURL.Path, + } + liveClient := &LiveClient{ + HTTPClient: c.httpClient, + BaseURL: &localURL, + } + return liveClient.V1().All(ctx, cc) +} + +// 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 { + return "", -1, err + } + + podPort := -1 + for _, port := range svc.Spec.Ports { + if int64(port.Port) == servicePort { + podPort = port.TargetPort.IntValue() + break + } + } + if podPort == -1 { + 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 { + return "", -1, err + } + + readyAddresses := []corev1.EndpointAddress{} + for _, subset := range endpoints.Subsets { + readyAddresses = append(readyAddresses, subset.Addresses...) + } + if len(readyAddresses) == 0 { + return "", -1, fmt.Errorf("no endpoints ready for service %s/%s", namespace, serviceName) + } + + 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 +} + +// Port forwarding logic to connect to a pod +func (c *portForwardClient) getPortForwarder(namespace, podName string, podPort int) (*portforward.PortForwarder, error) { + apiserverURL, err := url.Parse(c.cfg.Host) + if err != nil { + return nil, err + } + + portForwardURL := apiserverURL.JoinPath( + "api", "v1", + "namespaces", namespace, + "pods", podName, "portforward", + ) + + transport, upgrader, err := spdy.RoundTripperFor(c.cfg) + if err != nil { + return nil, err + } + + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport, Timeout: c.cfg.Timeout}, "POST", portForwardURL) + + ports := []string{fmt.Sprintf("0:%d", podPort)} + stopChan := make(chan struct{}, 1) + readyChan := make(chan struct{}, 1) + + pf, err := portforward.New(dialer, ports, stopChan, readyChan, io.Discard, os.Stderr) + if err != nil { + return nil, err + } + + return pf, nil +} + +func (c *portForwardClient) loadKnownCAs(caNamespace string) *x509.CertPool { + // for openshift, reference annotation service.beta.openshift.io/serving-cert-secret-name + // on the openshift-catalogd/catalogd-service service + secretPrefix := "catalogd" + knownCAsSecrets := []struct { + Namespace string + Key string + }{ + {caNamespace, "ca.crt"}, + } + rootCAs := x509.NewCertPool() + for _, secretInfo := range knownCAsSecrets { + secret := corev1.SecretList{} + if err := c.cl.List(context.TODO(), &secret, &client.ListOptions{Namespace: caNamespace}); err != nil { + continue + } + if len(secret.Items) == 0 { + continue + } + for _, caSecret := range secret.Items { + if strings.HasPrefix(caSecret.Name, secretPrefix) && len(caSecret.Data[secretInfo.Key]) > 0 { + rootCAs.AppendCertsFromPEM(caSecret.Data[secretInfo.Key]) + continue + } + } + } + return rootCAs +} diff --git a/pkg/action/config.go b/pkg/action/config.go index f265aef8..53f3d297 100644 --- a/pkg/action/config.go +++ b/pkg/action/config.go @@ -6,6 +6,8 @@ import ( "github.com/spf13/pflag" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" @@ -32,6 +34,7 @@ func NewScheme() (*runtime.Scheme, error) { } type Configuration struct { + Config *rest.Config Client client.Client Namespace string Scheme *runtime.Scheme @@ -79,6 +82,9 @@ func (c *Configuration) Load() error { if err != nil { return err } + if err = scheme.AddToScheme(sch); err != nil { + return err + } cl, err := client.New(cc, client.Options{ Scheme: sch, }) @@ -86,6 +92,7 @@ func (c *Configuration) Load() error { return err } + c.Config = cc c.Scheme = sch c.Client = &operatorClient{cl} c.Namespace = ns