diff --git a/cmd/grafanactl/resources/command.go b/cmd/grafanactl/resources/command.go index 04141c9d..3b22a79c 100644 --- a/cmd/grafanactl/resources/command.go +++ b/cmd/grafanactl/resources/command.go @@ -20,6 +20,7 @@ func Command() *cobra.Command { cmd.AddCommand(editCmd(configOpts)) cmd.AddCommand(getCmd(configOpts)) cmd.AddCommand(listCmd(configOpts)) + cmd.AddCommand(getMetaCmd(configOpts)) cmd.AddCommand(pullCmd(configOpts)) cmd.AddCommand(pushCmd(configOpts)) cmd.AddCommand(serveCmd(configOpts)) diff --git a/cmd/grafanactl/resources/fetch.go b/cmd/grafanactl/resources/fetch.go index 8fe54d15..75480c34 100644 --- a/cmd/grafanactl/resources/fetch.go +++ b/cmd/grafanactl/resources/fetch.go @@ -15,6 +15,7 @@ type fetchRequest struct { StopOnError bool ExcludeManaged bool ExpectSingleTarget bool + LabelSelector string Processors []remote.Processor } @@ -50,6 +51,12 @@ func fetchResources(ctx context.Context, opts fetchRequest, args []string) (*fet return nil, err } + if opts.LabelSelector != "" { + for i := range filters { + filters[i].LabelSelector = opts.LabelSelector + } + } + pull, err := remote.NewDefaultPuller(ctx, opts.Config) if err != nil { return nil, err diff --git a/cmd/grafanactl/resources/get.go b/cmd/grafanactl/resources/get.go index f31110f9..b08d53ee 100644 --- a/cmd/grafanactl/resources/get.go +++ b/cmd/grafanactl/resources/get.go @@ -20,8 +20,9 @@ import ( ) type getOpts struct { - IO cmdio.Options - OnError OnErrorMode + IO cmdio.Options + OnError OnErrorMode + LabelSelector string } func (opts *getOpts) setup(flags *pflag.FlagSet) { @@ -31,6 +32,8 @@ func (opts *getOpts) setup(flags *pflag.FlagSet) { opts.IO.RegisterCustomCodec("wide", &tableCodec{wide: true}) opts.IO.DefaultFormat("text") + flags.StringVarP(&opts.LabelSelector, "selector", "l", "", "Filter resources by label selector (e.g. -l key=value,other=value)") + // Bind all the flags opts.IO.BindFlags(flags) } @@ -98,8 +101,9 @@ func getCmd(configOpts *cmdconfig.Options) *cobra.Command { } res, err := fetchResources(ctx, fetchRequest{ - Config: cfg, - StopOnError: opts.OnError.StopOnError(), + Config: cfg, + StopOnError: opts.OnError.StopOnError(), + LabelSelector: opts.LabelSelector, }, args) if err != nil { return err diff --git a/cmd/grafanactl/resources/get_meta.go b/cmd/grafanactl/resources/get_meta.go new file mode 100644 index 00000000..1353f55f --- /dev/null +++ b/cmd/grafanactl/resources/get_meta.go @@ -0,0 +1,236 @@ +package resources + +import ( + "errors" + "fmt" + "io" + "time" + + cmdconfig "github.com/grafana/grafanactl/cmd/grafanactl/config" + cmdio "github.com/grafana/grafanactl/cmd/grafanactl/io" + "github.com/grafana/grafanactl/internal/format" + "github.com/grafana/grafanactl/internal/resources" + "github.com/grafana/grafanactl/internal/resources/discovery" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "golang.org/x/sync/errgroup" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/duration" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/client-go/metadata" +) + +type getMetaOpts struct { + IO cmdio.Options + LabelSelector string +} + +func (opts *getMetaOpts) setup(flags *pflag.FlagSet) { + opts.IO.RegisterCustomCodec("text", &partialMetaTableCodec{wide: false}) + opts.IO.RegisterCustomCodec("wide", &partialMetaTableCodec{wide: true}) + opts.IO.DefaultFormat("text") + + flags.StringVarP(&opts.LabelSelector, "selector", "l", "", "Filter resources by label selector (e.g. -l key=value,other=value)") + + opts.IO.BindFlags(flags) +} + +func (opts *getMetaOpts) Validate() error { + return opts.IO.Validate() +} + +func getMetaCmd(configOpts *cmdconfig.Options) *cobra.Command { + opts := &getMetaOpts{} + + cmd := &cobra.Command{ + Use: "get-meta RESOURCE_SELECTOR", + Args: cobra.ExactArgs(1), + Short: "Get partial object metadata for Grafana resources", + Long: "Get partial object metadata (name, namespace, labels, annotations) for Grafana resources.", + Example: ` + # All instances of a resource type: + + grafanactl resources get-meta dashboards + + # One or more specific instances: + + grafanactl resources get-meta dashboards/foo + grafanactl resources get-meta dashboards/foo,bar + + # Long kind format with version: + + grafanactl resources get-meta dashboards.v1alpha1.dashboard.grafana.app + grafanactl resources get-meta dashboards.v1alpha1.dashboard.grafana.app/foo +`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + codec, err := opts.IO.Codec() + if err != nil { + return err + } + + cfg, err := configOpts.LoadRESTConfig(ctx) + if err != nil { + return err + } + + sels, err := resources.ParseSelectors(args) + if err != nil { + return err + } + + reg, err := discovery.NewDefaultRegistry(ctx, cfg) + if err != nil { + return err + } + + filters, err := reg.MakeFilters(discovery.MakeFiltersOptions{ + Selectors: sels, + PreferredVersionOnly: true, + }) + if err != nil { + return err + } + + if len(filters) != 1 { + return fmt.Errorf("expected exactly one resource type, got %d", len(filters)) + } + + filter := filters[0] + + mdClient, err := metadata.NewForConfig(&cfg.Config) + if err != nil { + return err + } + + gvr := filter.Descriptor.GroupVersionResource() + rc := mdClient.Resource(gvr).Namespace(cfg.Namespace) + + var list metav1.PartialObjectMetadataList + + switch filter.Type { + case resources.FilterTypeAll: + result, err := rc.List(ctx, metav1.ListOptions{}) + if err != nil { + return err + } + + list = *result + + case resources.FilterTypeSingle, resources.FilterTypeMultiple: + g, ctx := errgroup.WithContext(ctx) + items := make([]metav1.PartialObjectMetadata, len(filter.ResourceUIDs)) + + for i, name := range filter.ResourceUIDs { + g.Go(func() error { + item, err := rc.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + + items[i] = *item + return nil + }) + } + + if err := g.Wait(); err != nil { + return err + } + + list.Items = items + } + + return codec.Encode(cmd.OutOrStdout(), &list) + }, + } + + opts.setup(cmd.Flags()) + + return cmd +} + +type partialMetaTableCodec struct { + wide bool +} + +func (c *partialMetaTableCodec) Format() format.Format { + if c.wide { + return "wide" + } + + return "text" +} + +func (c *partialMetaTableCodec) Encode(output io.Writer, input any) error { + list, ok := input.(*metav1.PartialObjectMetadataList) + if !ok { + return fmt.Errorf("expected *metav1.PartialObjectMetadataList, got %T", input) + } + + table := &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{ + { + Name: "NAME", + Type: "string", + Format: "name", + Priority: 0, + Description: "The name of the resource.", + }, + { + Name: "NAMESPACE", + Type: "string", + Priority: 0, + Description: "The namespace of the resource.", + }, + { + Name: "AGE", + Type: "string", + Format: "date-time", + Priority: 0, + Description: "The age of the resource.", + }, + }, + } + + if c.wide { + table.ColumnDefinitions = append(table.ColumnDefinitions, metav1.TableColumnDefinition{ + Name: "LABELS", + Type: "string", + Priority: 0, + Description: "The labels of the resource.", + }) + } + + for i := range list.Items { + item := &list.Items[i] + age := duration.HumanDuration(time.Since(item.CreationTimestamp.Time)) + + var row metav1.TableRow + if c.wide { + row = metav1.TableRow{ + Cells: []any{item.Name, item.Namespace, age, labels.FormatLabels(item.Labels)}, + Object: runtime.RawExtension{Object: item}, + } + } else { + row = metav1.TableRow{ + Cells: []any{item.Name, item.Namespace, age}, + Object: runtime.RawExtension{Object: item}, + } + } + + table.Rows = append(table.Rows, row) + } + + printer := printers.NewTablePrinter(printers.PrintOptions{ + Wide: c.wide, + }) + + return printer.PrintObj(table, output) +} + +func (c *partialMetaTableCodec) Decode(io.Reader, any) error { + return errors.New("partialMetaTableCodec does not support decoding") +} diff --git a/docs/reference/cli/grafanactl_resources.md b/docs/reference/cli/grafanactl_resources.md index c3300898..f6864990 100644 --- a/docs/reference/cli/grafanactl_resources.md +++ b/docs/reference/cli/grafanactl_resources.md @@ -27,6 +27,7 @@ Manipulate Grafana resources. * [grafanactl resources delete](grafanactl_resources_delete.md) - Delete resources from Grafana * [grafanactl resources edit](grafanactl_resources_edit.md) - Edit resources from Grafana * [grafanactl resources get](grafanactl_resources_get.md) - Get resources from Grafana +* [grafanactl resources get-meta](grafanactl_resources_get-meta.md) - Get partial object metadata for Grafana resources * [grafanactl resources list](grafanactl_resources_list.md) - List available Grafana API resources * [grafanactl resources pull](grafanactl_resources_pull.md) - Pull resources from Grafana * [grafanactl resources push](grafanactl_resources_push.md) - Push resources to Grafana diff --git a/docs/reference/cli/grafanactl_resources_get-meta.md b/docs/reference/cli/grafanactl_resources_get-meta.md new file mode 100644 index 00000000..1d05f65a --- /dev/null +++ b/docs/reference/cli/grafanactl_resources_get-meta.md @@ -0,0 +1,53 @@ +## grafanactl resources get-meta + +Get partial object metadata for Grafana resources + +### Synopsis + +Get partial object metadata (name, namespace, labels, annotations) for Grafana resources. + +``` +grafanactl resources get-meta RESOURCE_SELECTOR [flags] +``` + +### Examples + +``` + + # All instances of a resource type: + + grafanactl resources get-meta dashboards + + # One or more specific instances: + + grafanactl resources get-meta dashboards/foo + grafanactl resources get-meta dashboards/foo,bar + + # Long kind format with version: + + grafanactl resources get-meta dashboards.v1alpha1.dashboard.grafana.app + grafanactl resources get-meta dashboards.v1alpha1.dashboard.grafana.app/foo + +``` + +### Options + +``` + -h, --help help for get-meta + -o, --output string Output format. One of: json, text, wide, yaml (default "text") + -l, --selector string Filter resources by label selector (e.g. -l key=value,other=value) +``` + +### Options inherited from parent commands + +``` + --config string Path to the configuration file to use + --context string Name of the context to use + --no-color Disable color output + -v, --verbose count Verbose mode. Multiple -v options increase the verbosity (maximum: 3). +``` + +### SEE ALSO + +* [grafanactl resources](grafanactl_resources.md) - Manipulate Grafana resources + diff --git a/docs/reference/cli/grafanactl_resources_get.md b/docs/reference/cli/grafanactl_resources_get.md index d20ff2ed..7b807a4f 100644 --- a/docs/reference/cli/grafanactl_resources_get.md +++ b/docs/reference/cli/grafanactl_resources_get.md @@ -62,6 +62,7 @@ grafanactl resources get [RESOURCE_SELECTOR]... [flags] fail — continue processing all resources and exit 1 if any failed (default) abort — stop on the first error and exit 1 (default "fail") -o, --output string Output format. One of: json, text, wide, yaml (default "text") + -l, --selector string Filter resources by label selector (e.g. -l key=value,other=value) ``` ### Options inherited from parent commands diff --git a/internal/resources/filter.go b/internal/resources/filter.go index 88bf09f1..6452797c 100644 --- a/internal/resources/filter.go +++ b/internal/resources/filter.go @@ -41,9 +41,10 @@ func (t FilterType) String() string { // Unlike Selector, filters use the Descriptor to identify the resource type, // which fully defines the target API resource. type Filter struct { - Type FilterType - Descriptor Descriptor - ResourceUIDs []string + Type FilterType + Descriptor Descriptor + ResourceUIDs []string + LabelSelector string } func (f Filter) String() string { diff --git a/internal/resources/remote/puller.go b/internal/resources/remote/puller.go index 14299359..a900aba0 100644 --- a/internal/resources/remote/puller.go +++ b/internal/resources/remote/puller.go @@ -111,7 +111,7 @@ func (p *Puller) Pull(ctx context.Context, req PullRequest) (*OperationSummary, errg.Go(func() error { switch filt.Type { case resources.FilterTypeAll: - res, err := p.client.List(ctx, filt.Descriptor, metav1.ListOptions{}) + res, err := p.client.List(ctx, filt.Descriptor, metav1.ListOptions{LabelSelector: filt.LabelSelector}) if err != nil { if req.StopOnError { return err