diff --git a/Makefile b/Makefile index fb58cb25..2e007040 100644 --- a/Makefile +++ b/Makefile @@ -135,6 +135,10 @@ run: generate kind-cluster install ## Create a kind cluster and install a local build-container: build-linux ## Build docker image for catalogd. docker build -f Dockerfile -t $(IMAGE) bin/linux +.PHONY: build-cli +build-cli: ## Build the catalogd CLI + go build -o bin/catalogd ./cmd/cli/main.go + ##@ Deploy .PHONY: kind-cluster diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go new file mode 100644 index 00000000..03782183 --- /dev/null +++ b/internal/cli/helpers.go @@ -0,0 +1,166 @@ +package cli + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/charmbracelet/glamour" + "github.com/operator-framework/catalogd/api/core/v1alpha1" + "github.com/operator-framework/operator-registry/alpha/declcfg" + "k8s.io/apimachinery/pkg/api/meta" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func CatalogdTermRenderer(style string) (*glamour.TermRenderer, error) { + sc := glamour.DefaultStyles[style] + sc.Document.BlockSuffix = "" + sc.Document.BlockPrefix = "" + return glamour.NewTermRenderer(glamour.WithStyles(*sc)) +} + +type CatalogFilterFunc func(catalog *v1alpha1.Catalog) bool + +func FetchCatalogs(cfg *rest.Config, ctx context.Context, filters ...CatalogFilterFunc) ([]v1alpha1.Catalog, error) { + dynamicClient := dynamic.NewForConfigOrDie(cfg) + + catalogList := &v1alpha1.CatalogList{} + unstructCatalogs, err := dynamicClient.Resource(v1alpha1.GroupVersion.WithResource("catalogs")).List(ctx, v1.ListOptions{}) + if err != nil { + return nil, err + } + + err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructCatalogs.UnstructuredContent(), catalogList) + if err != nil { + return nil, err + } + + catalogs := []v1alpha1.Catalog{} + for _, catalog := range catalogList.Items { + for _, filter := range filters { + if !filter(&catalog) { + continue + } + } + + catalogs = append(catalogs, catalog) + } + + return catalogs, nil +} + +func WithNameCatalogFilter(name string) CatalogFilterFunc { + return func(catalog *v1alpha1.Catalog) bool { + if name == "" { + return true + } + return catalog.Name == name + } +} + +type ContentFilterFunc func(meta *declcfg.Meta) bool +type WriteFunc func(meta *declcfg.Meta, catalog *v1alpha1.Catalog) error + +func WriteContents(cfg *rest.Config, ctx context.Context, catalogs []v1alpha1.Catalog, writeFunc WriteFunc, filters ...ContentFilterFunc) error { + kubeClient := kubernetes.NewForConfigOrDie(cfg) + for _, catalog := range catalogs { + if !meta.IsStatusConditionTrue(catalog.Status.Conditions, v1alpha1.TypeUnpacked) { + continue + } + + url, err := url.Parse(catalog.Status.ContentURL) + if err != nil { + return fmt.Errorf("parsing catalog content url for catalog %q: %w", catalog.Name, err) + } + // url is expected to be in the format of + // http://{service_name}.{namespace}.svc/{catalog_name}/all.json + // so to get the namespace and name of the service we grab only + // the hostname and split it on the '.' character + ns := strings.Split(url.Hostname(), ".")[1] + name := strings.Split(url.Hostname(), ".")[0] + port := url.Port() + // the ProxyGet() call below needs an explicit port value, so if + // value from url.Port() is empty, we assume port 80. + if port == "" { + port = "80" + } + + rw := kubeClient.CoreV1().Services(ns).ProxyGet( + url.Scheme, + name, + port, + url.Path, + map[string]string{}, + ) + + rc, err := rw.Stream(ctx) + if err != nil { + return fmt.Errorf("getting catalog contents for catalog %q: %w", catalog.Name, err) + } + defer rc.Close() + + err = declcfg.WalkMetasReader(rc, func(meta *declcfg.Meta, err error) error { + if err != nil { + return err + } + + for _, filter := range filters { + if !filter(meta) { + return nil + } + } + + writeErr := writeFunc(meta, &catalog) + if writeErr != nil { + return writeErr + } + return nil + }) + if err != nil { + return fmt.Errorf("reading FBC for catalog %q: %w", catalog.Name, err) + } + } + + return nil +} + +func WithSchemaContentFilter(schema string) ContentFilterFunc { + return func(meta *declcfg.Meta) bool { + if schema == "" { + return true + } + return meta.Schema == schema + } +} + +func WithPackageContentFilter(pkg string) ContentFilterFunc { + return func(meta *declcfg.Meta) bool { + if pkg == "" { + return true + } + return meta.Package == pkg + } +} + +func WithNameContentFilter(name string) ContentFilterFunc { + return func(meta *declcfg.Meta) bool { + if name == "" { + return true + } + return meta.Name == name + } +} + +func WithNameContainsContentFilter(name string) ContentFilterFunc { + return func(meta *declcfg.Meta) bool { + if name == "" { + return true + } + return strings.Contains(meta.Name, name) + } +} diff --git a/internal/cli/inspect.go b/internal/cli/inspect.go index 16c6968c..485a4efb 100644 --- a/internal/cli/inspect.go +++ b/internal/cli/inspect.go @@ -3,19 +3,14 @@ package cli import ( "context" "encoding/json" - "fmt" - "strings" + "os" - "github.com/charmbracelet/glamour" + "github.com/alecthomas/chroma/quick" "github.com/operator-framework/catalogd/api/core/v1alpha1" "github.com/operator-framework/operator-registry/alpha/declcfg" "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/api/meta" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/yaml" ) var inspectCmd = cobra.Command{ @@ -25,117 +20,49 @@ var inspectCmd = cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { pkg, _ := cmd.Flags().GetString("package") catalog, _ := cmd.Flags().GetString("catalog") + output, _ := cmd.Flags().GetString("output") schema := args[0] name := args[1] - return inspect(schema, pkg, name, catalog) + return inspect(schema, pkg, name, catalog, output) }, } func init() { inspectCmd.Flags().String("package", "", "specify the FBC object package that should be used to filter the resulting output") inspectCmd.Flags().String("catalog", "", "specify the catalog that should be used. By default it will fetch from all catalogs") + inspectCmd.Flags().String("output", "json", "specify the output format. Valid values are 'json' and 'yaml'") } -func inspect(schema, pkg, name, catalogName string) error { - sc := glamour.DraculaStyleConfig - sc.Document.BlockSuffix = "" - sc.Document.BlockPrefix = "" - tr, err := glamour.NewTermRenderer(glamour.WithStyles(sc)) - if err != nil { - return err - } - +func inspect(schema, pkg, name, catalogName, out string) error { cfg := ctrl.GetConfigOrDie() - kubeClient := kubernetes.NewForConfigOrDie(cfg) - dynamicClient := dynamic.NewForConfigOrDie(cfg) ctx := context.Background() - catalogs := &v1alpha1.CatalogList{} - if catalogName == "" { - // get Catalog list - unstructCatalogs, err := dynamicClient.Resource(v1alpha1.GroupVersion.WithResource("catalogs")).List(ctx, v1.ListOptions{}) - if err != nil { - return err - } - - err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructCatalogs.UnstructuredContent(), catalogs) - if err != nil { - return err - } - } else { - // get Catalog - unstructCatalog, err := dynamicClient.Resource(v1alpha1.GroupVersion.WithResource("catalogs")).Get(ctx, catalogName, v1.GetOptions{}) - if err != nil { - return err - } - - ctlg := v1alpha1.Catalog{} - err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructCatalog.UnstructuredContent(), &ctlg) - if err != nil { - return err - } - catalogs.Items = append(catalogs.Items, ctlg) + catalogs, err := FetchCatalogs(cfg, ctx, WithNameCatalogFilter(catalogName)) + if err != nil { + return err } - for _, catalog := range catalogs.Items { - if !meta.IsStatusConditionTrue(catalog.Status.Conditions, v1alpha1.TypeUnpacked) { - continue - } - - rw := kubeClient.CoreV1().Services("catalogd-system").ProxyGet( - "http", - "catalogd-catalogserver", - "80", - fmt.Sprintf("catalogs/%s/all.json", catalog.Name), - map[string]string{}, - ) - - rc, err := rw.Stream(ctx) - if err != nil { - return fmt.Errorf("getting catalog contents for catalog %q: %w", catalog.Name, err) - } - defer rc.Close() - - err = declcfg.WalkMetasReader(rc, func(meta *declcfg.Meta, err error) error { + err = WriteContents(cfg, ctx, catalogs, + func(meta *declcfg.Meta, _ *v1alpha1.Catalog) error { + outBytes, err := json.MarshalIndent(meta.Blob, "", " ") if err != nil { return err } - - if schema != "" { - if meta.Schema != schema { - return nil + if out == "yaml" { + outBytes, err = yaml.JSONToYAML(outBytes) + if err != nil { + return err } } + // TODO: This uses ansi escape codes to colorize the output. Unfortunately, this + // means it isn't compatible with jq or yq that expect the output to be plain text. + return quick.Highlight(os.Stdout, string(outBytes), out, "terminal16m", "dracula") + }, + WithNameContentFilter(name), WithSchemaContentFilter(schema), WithPackageContentFilter(pkg), + ) - if pkg != "" { - if meta.Package != pkg { - return nil - } - } - - if name != "" { - if meta.Name != name { - return nil - } - } - - outJson, err := json.MarshalIndent(meta.Blob, "", " ") - if err != nil { - return err - } - outMd := strings.Builder{} - outMd.WriteString("```json\n") - outMd.WriteString(string(outJson)) - outMd.WriteString("\n```\n") - - out, _ := tr.Render(outMd.String()) - fmt.Print(out) - return nil - }) - if err != nil { - return fmt.Errorf("reading FBC for catalog %q: %w", catalog.Name, err) - } + if err != nil { + return err } - return nil } diff --git a/internal/cli/list.go b/internal/cli/list.go index efd2aac9..b6b5d734 100644 --- a/internal/cli/list.go +++ b/internal/cli/list.go @@ -5,15 +5,9 @@ import ( "fmt" "strings" - "github.com/charmbracelet/glamour" "github.com/operator-framework/catalogd/api/core/v1alpha1" "github.com/operator-framework/operator-registry/alpha/declcfg" "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/api/meta" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" ctrl "sigs.k8s.io/controller-runtime" ) @@ -26,8 +20,9 @@ var listCmd = cobra.Command{ pkg, _ := cmd.Flags().GetString("package") name, _ := cmd.Flags().GetString("name") catalog, _ := cmd.Flags().GetString("catalog") + style, _ := cmd.Flags().GetString("style") - return list(schema, pkg, name, catalog) + return list(schema, pkg, name, catalog, style) }, } @@ -36,92 +31,24 @@ func init() { listCmd.Flags().String("package", "", "specify the FBC object package that should be used to filter the resulting output") listCmd.Flags().String("name", "", "specify the FBC object name that should be used to filter the resulting output") listCmd.Flags().String("catalog", "", "specify the catalog that should be used. By default it will fetch from all catalogs") - + listCmd.Flags().String("style", "dracula", "specify the style that should be used to render the output") } -func list(schema, pkg, name, catalogName string) error { - sc := glamour.DraculaStyleConfig - sc.Document.BlockSuffix = "" - sc.Document.BlockPrefix = "" - tr, err := glamour.NewTermRenderer(glamour.WithStyles(sc)) +func list(schema, pkg, name, catalogName, style string) error { + renderer, err := CatalogdTermRenderer(style) if err != nil { return err } cfg := ctrl.GetConfigOrDie() - kubeClient := kubernetes.NewForConfigOrDie(cfg) - dynamicClient := dynamic.NewForConfigOrDie(cfg) ctx := context.Background() - - catalogs := &v1alpha1.CatalogList{} - if catalogName == "" { - // get Catalog list - unstructCatalogs, err := dynamicClient.Resource(v1alpha1.GroupVersion.WithResource("catalogs")).List(ctx, v1.ListOptions{}) - if err != nil { - return err - } - - err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructCatalogs.UnstructuredContent(), catalogs) - if err != nil { - return err - } - } else { - // get Catalog - unstructCatalog, err := dynamicClient.Resource(v1alpha1.GroupVersion.WithResource("catalogs")).Get(ctx, catalogName, v1.GetOptions{}) - if err != nil { - return err - } - - ctlg := v1alpha1.Catalog{} - err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructCatalog.UnstructuredContent(), &ctlg) - if err != nil { - return err - } - catalogs.Items = append(catalogs.Items, ctlg) + catalogs, err := FetchCatalogs(cfg, ctx, WithNameCatalogFilter(catalogName)) + if err != nil { + return err } - for _, catalog := range catalogs.Items { - if !meta.IsStatusConditionTrue(catalog.Status.Conditions, v1alpha1.TypeUnpacked) { - continue - } - - rw := kubeClient.CoreV1().Services("catalogd-system").ProxyGet( - "http", - "catalogd-catalogserver", - "80", - fmt.Sprintf("catalogs/%s/all.json", catalog.Name), - map[string]string{}, - ) - - rc, err := rw.Stream(ctx) - if err != nil { - return fmt.Errorf("getting catalog contents for catalog %q: %w", catalog.Name, err) - } - defer rc.Close() - - err = declcfg.WalkMetasReader(rc, func(meta *declcfg.Meta, err error) error { - if err != nil { - return err - } - - if schema != "" { - if meta.Schema != schema { - return nil - } - } - - if pkg != "" { - if meta.Package != pkg { - return nil - } - } - - if name != "" { - if meta.Name != name { - return nil - } - } - + err = WriteContents(cfg, ctx, catalogs, + func(meta *declcfg.Meta, catalog *v1alpha1.Catalog) error { outMd := strings.Builder{} outMd.WriteString(fmt.Sprintf("`%s` **%s** ", catalog.Name, meta.Schema)) if meta.Package != "" { @@ -129,14 +56,14 @@ func list(schema, pkg, name, catalogName string) error { } outMd.WriteString(meta.Name) - out, _ := tr.Render(outMd.String()) + out, _ := renderer.Render(outMd.String()) fmt.Print(out) return nil - }) - if err != nil { - return fmt.Errorf("reading FBC for catalog %q: %w", catalog.Name, err) - } + }, + WithNameContentFilter(name), WithSchemaContentFilter(schema), WithPackageContentFilter(pkg), + ) + if err != nil { + return err } - return nil } diff --git a/internal/cli/root.go b/internal/cli/root.go index bf1f14a8..440cfb23 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -12,9 +12,12 @@ var root = cobra.Command{ Long: "CLI for interacting with catalogd", } +//TODO: Make common global flags + func init() { root.AddCommand(&listCmd) root.AddCommand(&inspectCmd) + root.AddCommand(&searchCmd) } func Execute() { diff --git a/internal/cli/search.go b/internal/cli/search.go new file mode 100644 index 00000000..7e205fc4 --- /dev/null +++ b/internal/cli/search.go @@ -0,0 +1,68 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/operator-framework/catalogd/api/core/v1alpha1" + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/spf13/cobra" + ctrl "sigs.k8s.io/controller-runtime" +) + +var searchCmd = cobra.Command{ + Use: "search [input] [flags]", + Short: "Searches catalog objects", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + pkg, _ := cmd.Flags().GetString("package") + catalog, _ := cmd.Flags().GetString("catalog") + schema, _ := cmd.Flags().GetString("schema") + style, _ := cmd.Flags().GetString("style") + input := args[0] + return search(input, schema, pkg, catalog, style) + }, +} + +func init() { + searchCmd.Flags().String("schema", "", "specify the FBC object schema that should be used to filter the resulting output") + searchCmd.Flags().String("package", "", "specify the FBC object package that should be used to filter the resulting output") + searchCmd.Flags().String("catalog", "", "specify the catalog that should be used. By default it will fetch from all catalogs") + searchCmd.Flags().String("style", "dracula", "specify the style that should be used to render the output") +} + +func search(input, schema, pkg, catalogName, style string) error { + renderer, err := CatalogdTermRenderer(style) + if err != nil { + return err + } + cfg := ctrl.GetConfigOrDie() + ctx := context.Background() + + catalogs, err := FetchCatalogs(cfg, ctx, WithNameCatalogFilter(catalogName)) + if err != nil { + return err + } + + err = WriteContents(cfg, ctx, catalogs, + func(meta *declcfg.Meta, catalog *v1alpha1.Catalog) error { + outMd := strings.Builder{} + outMd.WriteString(fmt.Sprintf("`%s` **%s** ", catalog.Name, meta.Schema)) + if meta.Package != "" { + outMd.WriteString(fmt.Sprintf("_%s_ ", meta.Package)) + } + outMd.WriteString(meta.Name) + + out, _ := renderer.Render(outMd.String()) + fmt.Print(out) + return nil + }, + WithNameContainsContentFilter(input), WithSchemaContentFilter(schema), WithPackageContentFilter(pkg), + ) + + if err != nil { + return err + } + return nil +}